4 Commits

Author SHA1 Message Date
481bdabaa8 Add admin panel 2025-12-19 02:07:25 +07:00
8e634994bd Add challenges promotion 2025-12-18 23:47:11 +07:00
33f49f4e47 Fix security 2025-12-18 17:15:21 +07:00
57bad3b4a8 Redesign health service + create backup service 2025-12-18 03:35:13 +07:00
70 changed files with 6209 additions and 374 deletions

View File

@@ -10,6 +10,7 @@ OPENAI_API_KEY=sk-...
# Telegram Bot
TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
BOT_API_SECRET=change_me_random_secret_for_bot_api
# S3 Storage - FirstVDS (set S3_ENABLED=true to use)
S3_ENABLED=false
@@ -20,5 +21,14 @@ S3_SECRET_ACCESS_KEY=your-secret-access-key
S3_ENDPOINT_URL=https://s3.firstvds.ru
S3_PUBLIC_URL=https://your-bucket-name.s3.firstvds.ru
# Backup Service
TELEGRAM_ADMIN_ID=947392854
S3_BACKUP_PREFIX=backups/
BACKUP_RETENTION_DAYS=14
# Status Service (optional - for external monitoring)
EXTERNAL_URL=https://your-domain.com
PUBLIC_URL=https://your-domain.com
# Frontend (for build)
VITE_API_URL=/api/v1

View File

@@ -31,6 +31,12 @@ help:
@echo " make shell - Open backend shell"
@echo " make frontend-sh - Open frontend shell"
@echo ""
@echo " Backup:"
@echo " make backup-now - Run backup immediately"
@echo " make backup-list - List available backups in S3"
@echo " make backup-restore - Restore from backup (interactive)"
@echo " make backup-logs - Show backup service logs"
@echo ""
@echo " Cleanup:"
@echo " make clean - Stop and remove containers, volumes"
@echo " make prune - Remove unused Docker resources"
@@ -137,3 +143,20 @@ test-backend:
# Production
prod:
$(DC) -f docker-compose.yml up -d --build
# Backup
backup-now:
$(DC) exec backup python /app/backup.py
backup-list:
$(DC) exec backup python /app/restore.py
backup-restore:
@read -p "Backup filename: " file; \
$(DC) exec -it backup python /app/restore.py "$$file"
backup-logs:
$(DC) logs -f backup
backup-shell:
$(DC) exec backup bash

View File

@@ -0,0 +1,28 @@
"""Add challenge proposals support
Revision ID: 011_add_challenge_proposals
Revises: 010_add_telegram_profile
Create Date: 2024-12-18
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '011_add_challenge_proposals'
down_revision: Union[str, None] = '010_add_telegram_profile'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('challenges', sa.Column('proposed_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True))
op.add_column('challenges', sa.Column('status', sa.String(20), server_default='approved', nullable=False))
def downgrade() -> None:
op.drop_column('challenges', 'status')
op.drop_column('challenges', 'proposed_by_id')

View File

@@ -0,0 +1,32 @@
"""Add user banned fields
Revision ID: 012_add_user_banned
Revises: 011_add_challenge_proposals
Create Date: 2024-12-18
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '012_add_user_banned'
down_revision: Union[str, None] = '011_add_challenge_proposals'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('users', sa.Column('is_banned', sa.Boolean(), server_default='false', nullable=False))
op.add_column('users', sa.Column('banned_at', sa.DateTime(), nullable=True))
op.add_column('users', sa.Column('banned_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True))
op.add_column('users', sa.Column('ban_reason', sa.String(500), nullable=True))
def downgrade() -> None:
op.drop_column('users', 'ban_reason')
op.drop_column('users', 'banned_by_id')
op.drop_column('users', 'banned_at')
op.drop_column('users', 'is_banned')

View File

@@ -0,0 +1,61 @@
"""Add admin_logs table
Revision ID: 013_add_admin_logs
Revises: 012_add_user_banned
Create Date: 2024-12-18
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '013_add_admin_logs'
down_revision: Union[str, None] = '012_add_user_banned'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def index_exists(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
indexes = inspector.get_indexes(table_name)
return any(idx['name'] == index_name for idx in indexes)
def upgrade() -> None:
if not table_exists('admin_logs'):
op.create_table(
'admin_logs',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('admin_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('action', sa.String(50), nullable=False),
sa.Column('target_type', sa.String(50), nullable=False),
sa.Column('target_id', sa.Integer(), nullable=False),
sa.Column('details', sa.JSON(), nullable=True),
sa.Column('ip_address', sa.String(50), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
if not index_exists('admin_logs', 'ix_admin_logs_admin_id'):
op.create_index('ix_admin_logs_admin_id', 'admin_logs', ['admin_id'])
if not index_exists('admin_logs', 'ix_admin_logs_action'):
op.create_index('ix_admin_logs_action', 'admin_logs', ['action'])
if not index_exists('admin_logs', 'ix_admin_logs_created_at'):
op.create_index('ix_admin_logs_created_at', 'admin_logs', ['created_at'])
def downgrade() -> None:
op.drop_index('ix_admin_logs_created_at', 'admin_logs')
op.drop_index('ix_admin_logs_action', 'admin_logs')
op.drop_index('ix_admin_logs_admin_id', 'admin_logs')
op.drop_table('admin_logs')

View File

@@ -0,0 +1,57 @@
"""Add admin_2fa_sessions table
Revision ID: 014_add_admin_2fa
Revises: 013_add_admin_logs
Create Date: 2024-12-18
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '014_add_admin_2fa'
down_revision: Union[str, None] = '013_add_admin_logs'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def index_exists(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
indexes = inspector.get_indexes(table_name)
return any(idx['name'] == index_name for idx in indexes)
def upgrade() -> None:
if not table_exists('admin_2fa_sessions'):
op.create_table(
'admin_2fa_sessions',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('code', sa.String(6), nullable=False),
sa.Column('telegram_sent', sa.Boolean(), server_default='false', nullable=False),
sa.Column('is_verified', sa.Boolean(), server_default='false', nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
if not index_exists('admin_2fa_sessions', 'ix_admin_2fa_sessions_user_id'):
op.create_index('ix_admin_2fa_sessions_user_id', 'admin_2fa_sessions', ['user_id'])
if not index_exists('admin_2fa_sessions', 'ix_admin_2fa_sessions_expires_at'):
op.create_index('ix_admin_2fa_sessions_expires_at', 'admin_2fa_sessions', ['expires_at'])
def downgrade() -> None:
op.drop_index('ix_admin_2fa_sessions_expires_at', 'admin_2fa_sessions')
op.drop_index('ix_admin_2fa_sessions_user_id', 'admin_2fa_sessions')
op.drop_table('admin_2fa_sessions')

View File

@@ -0,0 +1,54 @@
"""Add static_content table
Revision ID: 015_add_static_content
Revises: 014_add_admin_2fa
Create Date: 2024-12-18
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '015_add_static_content'
down_revision: Union[str, None] = '014_add_admin_2fa'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def index_exists(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
indexes = inspector.get_indexes(table_name)
return any(idx['name'] == index_name for idx in indexes)
def upgrade() -> None:
if not table_exists('static_content'):
op.create_table(
'static_content',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('key', sa.String(100), unique=True, nullable=False),
sa.Column('title', sa.String(200), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('updated_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
if not index_exists('static_content', 'ix_static_content_key'):
op.create_index('ix_static_content_key', 'static_content', ['key'], unique=True)
def downgrade() -> None:
op.drop_index('ix_static_content_key', 'static_content')
op.drop_table('static_content')

View File

@@ -0,0 +1,36 @@
"""Add banned_until field
Revision ID: 016_add_banned_until
Revises: 015_add_static_content
Create Date: 2024-12-19
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '016_add_banned_until'
down_revision: Union[str, None] = '015_add_static_content'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
if not column_exists('users', 'banned_until'):
op.add_column('users', sa.Column('banned_until', sa.DateTime(), nullable=True))
def downgrade() -> None:
if column_exists('users', 'banned_until'):
op.drop_column('users', 'banned_until')

View File

@@ -0,0 +1,32 @@
"""Make admin_id nullable in admin_logs for system actions
Revision ID: 017_admin_logs_nullable_admin_id
Revises: 016_add_banned_until
Create Date: 2024-12-19
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '017_admin_logs_nullable_admin_id'
down_revision: Union[str, None] = '016_add_banned_until'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Make admin_id nullable for system actions (like auto-unban)
op.alter_column('admin_logs', 'admin_id',
existing_type=sa.Integer(),
nullable=True)
def downgrade() -> None:
# Revert to not nullable (will fail if there are NULL values)
op.alter_column('admin_logs', 'admin_id',
existing_type=sa.Integer(),
nullable=False)

View File

@@ -1,13 +1,15 @@
from typing import Annotated
from datetime import datetime
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, status, Header
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import decode_access_token
from app.models import User, Participant, Marathon, UserRole, ParticipantRole
from app.models import User, Participant, Marathon, UserRole, ParticipantRole, AdminLog, AdminActionType
security = HTTPBearer()
@@ -42,6 +44,50 @@ async def get_current_user(
detail="User not found",
)
# Check if user is banned
if user.is_banned:
# Auto-unban if ban expired
if user.banned_until and datetime.utcnow() > user.banned_until:
# Save ban info for logging before clearing
old_ban_reason = user.ban_reason
old_banned_until = user.banned_until.isoformat() if user.banned_until else None
user.is_banned = False
user.banned_at = None
user.banned_until = None
user.banned_by_id = None
user.ban_reason = None
# Log system auto-unban action
log = AdminLog(
admin_id=None, # System action, no admin
action=AdminActionType.USER_AUTO_UNBAN.value,
target_type="user",
target_id=user.id,
details={
"nickname": user.nickname,
"reason": old_ban_reason,
"banned_until": old_banned_until,
"system": True,
},
ip_address=None,
)
db.add(log)
await db.commit()
await db.refresh(user)
else:
# Still banned - return ban info in error
ban_info = {
"banned_at": user.banned_at.isoformat() if user.banned_at else None,
"banned_until": user.banned_until.isoformat() if user.banned_until else None,
"reason": user.ban_reason,
}
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ban_info,
)
return user
@@ -55,6 +101,21 @@ def require_admin(user: User) -> User:
return user
def require_admin_with_2fa(user: User) -> User:
"""Check if user is admin with Telegram linked (2FA enabled)"""
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
if not user.telegram_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Для доступа к админ-панели необходимо привязать Telegram в профиле",
)
return user
async def get_participant(
db: AsyncSession,
user_id: int,
@@ -145,3 +206,21 @@ async def require_creator(
# Type aliases for cleaner dependency injection
CurrentUser = Annotated[User, Depends(get_current_user)]
DbSession = Annotated[AsyncSession, Depends(get_db)]
async def verify_bot_secret(
x_bot_secret: str | None = Header(None, alias="X-Bot-Secret")
) -> None:
"""Verify that request comes from trusted bot using secret key."""
if not settings.BOT_API_SECRET:
# If secret is not configured, skip check (for development)
return
if x_bot_secret != settings.BOT_API_SECRET:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid or missing bot secret"
)
BotSecretDep = Annotated[None, Depends(verify_bot_secret)]

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content
router = APIRouter(prefix="/api/v1")
@@ -15,3 +15,4 @@ router.include_router(admin.router)
router.include_router(events.router)
router.include_router(assignments.router)
router.include_router(telegram.router)
router.include_router(content.router)

View File

@@ -1,11 +1,19 @@
from fastapi import APIRouter, HTTPException, Query
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, Request
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from pydantic import BaseModel, Field
from app.api.deps import DbSession, CurrentUser, require_admin
from app.models import User, UserRole, Marathon, Participant, Game
from app.schemas import UserPublic, MarathonListItem, MessageResponse
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
from app.models import User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent
from app.schemas import (
UserPublic, MessageResponse,
AdminUserResponse, BanUserRequest, AdminLogResponse, AdminLogsListResponse,
BroadcastRequest, BroadcastResponse, StaticContentResponse, StaticContentUpdate,
StaticContentCreate, DashboardStats
)
from app.services.telegram_notifier import telegram_notifier
from app.core.rate_limit import limiter
router = APIRouter(prefix="/admin", tags=["admin"])
@@ -14,21 +22,6 @@ class SetUserRole(BaseModel):
role: str = Field(..., pattern="^(user|admin)$")
class AdminUserResponse(BaseModel):
id: int
login: str
nickname: str
role: str
avatar_url: str | None = None
telegram_id: int | None = None
telegram_username: str | None = None
marathons_count: int = 0
created_at: str
class Config:
from_attributes = True
class AdminMarathonResponse(BaseModel):
id: int
title: str
@@ -44,6 +37,29 @@ class AdminMarathonResponse(BaseModel):
from_attributes = True
# ============ Helper Functions ============
async def log_admin_action(
db,
admin_id: int,
action: str,
target_type: str,
target_id: int,
details: dict | None = None,
ip_address: str | None = None
):
"""Log an admin action."""
log = AdminLog(
admin_id=admin_id,
action=action,
target_type=target_type,
target_id=target_id,
details=details,
ip_address=ip_address,
)
db.add(log)
await db.commit()
@router.get("/users", response_model=list[AdminUserResponse])
async def list_users(
current_user: CurrentUser,
@@ -51,9 +67,10 @@ async def list_users(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
search: str | None = None,
banned_only: bool = False,
):
"""List all users. Admin only."""
require_admin(current_user)
require_admin_with_2fa(current_user)
query = select(User).order_by(User.created_at.desc())
@@ -63,6 +80,9 @@ async def list_users(
(User.nickname.ilike(f"%{search}%"))
)
if banned_only:
query = query.where(User.is_banned == True)
query = query.offset(skip).limit(limit)
result = await db.execute(query)
users = result.scalars().all()
@@ -83,6 +103,10 @@ async def list_users(
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
is_banned=user.is_banned,
banned_at=user.banned_at.isoformat() if user.banned_at else None,
banned_until=user.banned_until.isoformat() if user.banned_until else None,
ban_reason=user.ban_reason,
))
return response
@@ -91,7 +115,7 @@ async def list_users(
@router.get("/users/{user_id}", response_model=AdminUserResponse)
async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
"""Get user details. Admin only."""
require_admin(current_user)
require_admin_with_2fa(current_user)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
@@ -112,6 +136,10 @@ async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
is_banned=user.is_banned,
banned_at=user.banned_at.isoformat() if user.banned_at else None,
banned_until=user.banned_until.isoformat() if user.banned_until else None,
ban_reason=user.ban_reason,
)
@@ -121,9 +149,10 @@ async def set_user_role(
data: SetUserRole,
current_user: CurrentUser,
db: DbSession,
request: Request,
):
"""Set user's global role. Admin only."""
require_admin(current_user)
require_admin_with_2fa(current_user)
# Cannot change own role
if user_id == current_user.id:
@@ -134,10 +163,19 @@ async def set_user_role(
if not user:
raise HTTPException(status_code=404, detail="User not found")
old_role = user.role
user.role = data.role
await db.commit()
await db.refresh(user)
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.USER_ROLE_CHANGE.value,
"user", user_id,
{"old_role": old_role, "new_role": data.role, "nickname": user.nickname},
request.client.host if request.client else None
)
marathons_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
)
@@ -152,13 +190,17 @@ async def set_user_role(
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
is_banned=user.is_banned,
banned_at=user.banned_at.isoformat() if user.banned_at else None,
banned_until=user.banned_until.isoformat() if user.banned_until else None,
ban_reason=user.ban_reason,
)
@router.delete("/users/{user_id}", response_model=MessageResponse)
async def delete_user(user_id: int, current_user: CurrentUser, db: DbSession):
"""Delete a user. Admin only."""
require_admin(current_user)
require_admin_with_2fa(current_user)
# Cannot delete yourself
if user_id == current_user.id:
@@ -188,7 +230,7 @@ async def list_marathons(
search: str | None = None,
):
"""List all marathons. Admin only."""
require_admin(current_user)
require_admin_with_2fa(current_user)
query = (
select(Marathon)
@@ -227,25 +269,34 @@ async def list_marathons(
@router.delete("/marathons/{marathon_id}", response_model=MessageResponse)
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession, request: Request):
"""Delete a marathon. Admin only."""
require_admin(current_user)
require_admin_with_2fa(current_user)
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
marathon_title = marathon.title
await db.delete(marathon)
await db.commit()
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.MARATHON_DELETE.value,
"marathon", marathon_id,
{"title": marathon_title},
request.client.host if request.client else None
)
return MessageResponse(message="Marathon deleted")
@router.get("/stats")
async def get_stats(current_user: CurrentUser, db: DbSession):
"""Get platform statistics. Admin only."""
require_admin(current_user)
require_admin_with_2fa(current_user)
users_count = await db.scalar(select(func.count()).select_from(User))
marathons_count = await db.scalar(select(func.count()).select_from(Marathon))
@@ -258,3 +309,439 @@ async def get_stats(current_user: CurrentUser, db: DbSession):
"games_count": games_count,
"total_participations": participants_count,
}
# ============ Ban/Unban Users ============
@router.post("/users/{user_id}/ban", response_model=AdminUserResponse)
@limiter.limit("10/minute")
async def ban_user(
request: Request,
user_id: int,
data: BanUserRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Ban a user. Admin only."""
require_admin_with_2fa(current_user)
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot ban yourself")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.role == UserRole.ADMIN.value:
raise HTTPException(status_code=400, detail="Cannot ban another admin")
if user.is_banned:
raise HTTPException(status_code=400, detail="User is already banned")
user.is_banned = True
user.banned_at = datetime.utcnow()
# Normalize to naive datetime (remove tzinfo) to match banned_at
user.banned_until = data.banned_until.replace(tzinfo=None) if data.banned_until else None
user.banned_by_id = current_user.id
user.ban_reason = data.reason
await db.commit()
await db.refresh(user)
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.USER_BAN.value,
"user", user_id,
{"nickname": user.nickname, "reason": data.reason},
request.client.host if request.client else None
)
marathons_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
)
return AdminUserResponse(
id=user.id,
login=user.login,
nickname=user.nickname,
role=user.role,
avatar_url=user.avatar_url,
telegram_id=user.telegram_id,
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
is_banned=user.is_banned,
banned_at=user.banned_at.isoformat() if user.banned_at else None,
banned_until=user.banned_until.isoformat() if user.banned_until else None,
ban_reason=user.ban_reason,
)
@router.post("/users/{user_id}/unban", response_model=AdminUserResponse)
async def unban_user(
request: Request,
user_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Unban a user. Admin only."""
require_admin_with_2fa(current_user)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.is_banned:
raise HTTPException(status_code=400, detail="User is not banned")
user.is_banned = False
user.banned_at = None
user.banned_until = None
user.banned_by_id = None
user.ban_reason = None
await db.commit()
await db.refresh(user)
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.USER_UNBAN.value,
"user", user_id,
{"nickname": user.nickname},
request.client.host if request.client else None
)
marathons_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
)
return AdminUserResponse(
id=user.id,
login=user.login,
nickname=user.nickname,
role=user.role,
avatar_url=user.avatar_url,
telegram_id=user.telegram_id,
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
is_banned=user.is_banned,
banned_at=None,
banned_until=None,
ban_reason=None,
)
# ============ Force Finish Marathon ============
@router.post("/marathons/{marathon_id}/force-finish", response_model=MessageResponse)
async def force_finish_marathon(
request: Request,
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Force finish a marathon. Admin only."""
require_admin_with_2fa(current_user)
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
if marathon.status == MarathonStatus.FINISHED.value:
raise HTTPException(status_code=400, detail="Marathon is already finished")
old_status = marathon.status
marathon.status = MarathonStatus.FINISHED.value
marathon.end_date = datetime.utcnow()
await db.commit()
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.MARATHON_FORCE_FINISH.value,
"marathon", marathon_id,
{"title": marathon.title, "old_status": old_status},
request.client.host if request.client else None
)
# Notify participants
await telegram_notifier.notify_marathon_finish(db, marathon_id, marathon.title)
return MessageResponse(message="Marathon finished")
# ============ Admin Logs ============
@router.get("/logs", response_model=AdminLogsListResponse)
async def get_logs(
current_user: CurrentUser,
db: DbSession,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
action: str | None = None,
admin_id: int | None = None,
):
"""Get admin action logs. Admin only."""
require_admin_with_2fa(current_user)
query = (
select(AdminLog)
.options(selectinload(AdminLog.admin))
.order_by(AdminLog.created_at.desc())
)
if action:
query = query.where(AdminLog.action == action)
if admin_id:
query = query.where(AdminLog.admin_id == admin_id)
# Get total count
count_query = select(func.count()).select_from(AdminLog)
if action:
count_query = count_query.where(AdminLog.action == action)
if admin_id:
count_query = count_query.where(AdminLog.admin_id == admin_id)
total = await db.scalar(count_query)
query = query.offset(skip).limit(limit)
result = await db.execute(query)
logs = result.scalars().all()
return AdminLogsListResponse(
logs=[
AdminLogResponse(
id=log.id,
admin_id=log.admin_id,
admin_nickname=log.admin.nickname if log.admin else None,
action=log.action,
target_type=log.target_type,
target_id=log.target_id,
details=log.details,
ip_address=log.ip_address,
created_at=log.created_at,
)
for log in logs
],
total=total or 0,
)
# ============ Broadcast ============
@router.post("/broadcast/all", response_model=BroadcastResponse)
@limiter.limit("1/minute")
async def broadcast_to_all(
request: Request,
data: BroadcastRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Send broadcast message to all users with Telegram linked. Admin only."""
require_admin_with_2fa(current_user)
# Get all users with telegram_id
result = await db.execute(
select(User).where(User.telegram_id.isnot(None))
)
users = result.scalars().all()
total_count = len(users)
sent_count = 0
for user in users:
if await telegram_notifier.send_message(user.telegram_id, data.message):
sent_count += 1
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.BROADCAST_ALL.value,
"broadcast", 0,
{"message": data.message[:100], "sent": sent_count, "total": total_count},
request.client.host if request.client else None
)
return BroadcastResponse(sent_count=sent_count, total_count=total_count)
@router.post("/broadcast/marathon/{marathon_id}", response_model=BroadcastResponse)
@limiter.limit("3/minute")
async def broadcast_to_marathon(
request: Request,
marathon_id: int,
data: BroadcastRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Send broadcast message to marathon participants. Admin only."""
require_admin_with_2fa(current_user)
# Check marathon exists
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
# Get participants count
total_result = await db.execute(
select(User)
.join(Participant, Participant.user_id == User.id)
.where(
Participant.marathon_id == marathon_id,
User.telegram_id.isnot(None)
)
)
users = total_result.scalars().all()
total_count = len(users)
sent_count = await telegram_notifier.notify_marathon_participants(
db, marathon_id, data.message
)
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.BROADCAST_MARATHON.value,
"marathon", marathon_id,
{"title": marathon.title, "message": data.message[:100], "sent": sent_count, "total": total_count},
request.client.host if request.client else None
)
return BroadcastResponse(sent_count=sent_count, total_count=total_count)
# ============ Static Content ============
@router.get("/content", response_model=list[StaticContentResponse])
async def list_content(current_user: CurrentUser, db: DbSession):
"""List all static content. Admin only."""
require_admin_with_2fa(current_user)
result = await db.execute(
select(StaticContent).order_by(StaticContent.key)
)
return result.scalars().all()
@router.get("/content/{key}", response_model=StaticContentResponse)
async def get_content(key: str, current_user: CurrentUser, db: DbSession):
"""Get static content by key. Admin only."""
require_admin_with_2fa(current_user)
result = await db.execute(
select(StaticContent).where(StaticContent.key == key)
)
content = result.scalar_one_or_none()
if not content:
raise HTTPException(status_code=404, detail="Content not found")
return content
@router.put("/content/{key}", response_model=StaticContentResponse)
async def update_content(
request: Request,
key: str,
data: StaticContentUpdate,
current_user: CurrentUser,
db: DbSession,
):
"""Update static content. Admin only."""
require_admin_with_2fa(current_user)
result = await db.execute(
select(StaticContent).where(StaticContent.key == key)
)
content = result.scalar_one_or_none()
if not content:
raise HTTPException(status_code=404, detail="Content not found")
content.title = data.title
content.content = data.content
content.updated_by_id = current_user.id
content.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(content)
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.CONTENT_UPDATE.value,
"content", content.id,
{"key": key, "title": data.title},
request.client.host if request.client else None
)
return content
@router.post("/content", response_model=StaticContentResponse)
async def create_content(
request: Request,
data: StaticContentCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Create static content. Admin only."""
require_admin_with_2fa(current_user)
# Check if key exists
result = await db.execute(
select(StaticContent).where(StaticContent.key == data.key)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Content with this key already exists")
content = StaticContent(
key=data.key,
title=data.title,
content=data.content,
updated_by_id=current_user.id,
)
db.add(content)
await db.commit()
await db.refresh(content)
return content
# ============ Dashboard ============
@router.get("/dashboard", response_model=DashboardStats)
async def get_dashboard(current_user: CurrentUser, db: DbSession):
"""Get dashboard statistics. Admin only."""
require_admin_with_2fa(current_user)
users_count = await db.scalar(select(func.count()).select_from(User))
banned_users_count = await db.scalar(
select(func.count()).select_from(User).where(User.is_banned == True)
)
marathons_count = await db.scalar(select(func.count()).select_from(Marathon))
active_marathons_count = await db.scalar(
select(func.count()).select_from(Marathon).where(Marathon.status == MarathonStatus.ACTIVE.value)
)
games_count = await db.scalar(select(func.count()).select_from(Game))
total_participations = await db.scalar(select(func.count()).select_from(Participant))
# Get recent logs
result = await db.execute(
select(AdminLog)
.options(selectinload(AdminLog.admin))
.order_by(AdminLog.created_at.desc())
.limit(10)
)
recent_logs = result.scalars().all()
return DashboardStats(
users_count=users_count or 0,
banned_users_count=banned_users_count or 0,
marathons_count=marathons_count or 0,
active_marathons_count=active_marathons_count or 0,
games_count=games_count or 0,
total_participations=total_participations or 0,
recent_logs=[
AdminLogResponse(
id=log.id,
admin_id=log.admin_id,
admin_nickname=log.admin.nickname if log.admin else None,
action=log.action,
target_type=log.target_type,
target_id=log.target_id,
details=log.details,
ip_address=log.ip_address,
created_at=log.created_at,
)
for log in recent_logs
],
)

View File

@@ -1,16 +1,22 @@
from fastapi import APIRouter, HTTPException, status
from datetime import datetime, timedelta
import secrets
from fastapi import APIRouter, HTTPException, status, Request
from sqlalchemy import select
from app.api.deps import DbSession, CurrentUser
from app.core.security import verify_password, get_password_hash, create_access_token
from app.models import User
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPublic
from app.core.rate_limit import limiter
from app.models import User, UserRole, Admin2FASession
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate, LoginResponse
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=TokenResponse)
async def register(data: UserRegister, db: DbSession):
@limiter.limit("5/minute")
async def register(request: Request, data: UserRegister, db: DbSession):
# Check if login already exists
result = await db.execute(select(User).where(User.login == data.login.lower()))
if result.scalar_one_or_none():
@@ -34,12 +40,13 @@ async def register(data: UserRegister, db: DbSession):
return TokenResponse(
access_token=access_token,
user=UserPublic.model_validate(user),
user=UserPrivate.model_validate(user),
)
@router.post("/login", response_model=TokenResponse)
async def login(data: UserLogin, db: DbSession):
@router.post("/login", response_model=LoginResponse)
@limiter.limit("10/minute")
async def login(request: Request, data: UserLogin, db: DbSession):
# Find user
result = await db.execute(select(User).where(User.login == data.login.lower()))
user = result.scalar_one_or_none()
@@ -50,15 +57,109 @@ async def login(data: UserLogin, db: DbSession):
detail="Incorrect login or password",
)
# Check if user is banned
if user.is_banned:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Your account has been banned",
)
# If admin with Telegram linked, require 2FA
if user.role == UserRole.ADMIN.value and user.telegram_id:
# Generate 6-digit code
code = "".join([str(secrets.randbelow(10)) for _ in range(6)])
# Create 2FA session (expires in 5 minutes)
session = Admin2FASession(
user_id=user.id,
code=code,
expires_at=datetime.utcnow() + timedelta(minutes=5),
)
db.add(session)
await db.commit()
await db.refresh(session)
# Send code to Telegram
message = f"🔐 <b>Код подтверждения для входа в админку</b>\n\nВаш код: <code>{code}</code>\n\nКод действителен 5 минут."
sent = await telegram_notifier.send_message(user.telegram_id, message)
if sent:
session.telegram_sent = True
await db.commit()
return LoginResponse(
requires_2fa=True,
two_factor_session_id=session.id,
)
# Regular user or admin without Telegram - generate token immediately
# Admin without Telegram can login but admin panel will check for Telegram
access_token = create_access_token(subject=user.id)
return LoginResponse(
access_token=access_token,
user=UserPrivate.model_validate(user),
)
@router.post("/2fa/verify", response_model=TokenResponse)
@limiter.limit("5/minute")
async def verify_2fa(request: Request, session_id: int, code: str, db: DbSession):
"""Verify 2FA code and return JWT token."""
# Find session
result = await db.execute(
select(Admin2FASession).where(Admin2FASession.id == session_id)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid session",
)
if session.is_verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Session already verified",
)
if datetime.utcnow() > session.expires_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Code expired",
)
if session.code != code:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid code",
)
# Mark as verified
session.is_verified = True
await db.commit()
# Get user
result = await db.execute(select(User).where(User.id == session.user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User not found",
)
# Generate token
access_token = create_access_token(subject=user.id)
return TokenResponse(
access_token=access_token,
user=UserPublic.model_validate(user),
user=UserPrivate.model_validate(user),
)
@router.get("/me", response_model=UserPublic)
@router.get("/me", response_model=UserPrivate)
async def get_me(current_user: CurrentUser):
return UserPublic.model_validate(current_user)
"""Get current user's full profile (including private data)"""
return UserPrivate.model_validate(current_user)

View File

@@ -3,7 +3,8 @@ from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser, require_participant, require_organizer, get_participant
from app.models import Marathon, MarathonStatus, Game, GameStatus, Challenge
from app.models import Marathon, MarathonStatus, Game, GameStatus, Challenge, User
from app.models.challenge import ChallengeStatus
from app.schemas import (
ChallengeCreate,
ChallengeUpdate,
@@ -15,7 +16,9 @@ from app.schemas import (
ChallengesSaveRequest,
ChallengesGenerateRequest,
)
from app.schemas.challenge import ChallengePropose, ProposedByUser
from app.services.gpt import gpt_service
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(tags=["challenges"])
@@ -23,7 +26,7 @@ router = APIRouter(tags=["challenges"])
async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
result = await db.execute(
select(Challenge)
.options(selectinload(Challenge.game))
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
.where(Challenge.id == challenge_id)
)
challenge = result.scalar_one_or_none()
@@ -32,9 +35,36 @@ async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
return challenge
def build_challenge_response(challenge: Challenge, game: Game) -> ChallengeResponse:
"""Helper to build ChallengeResponse with proposed_by"""
proposed_by = None
if challenge.proposed_by:
proposed_by = ProposedByUser(
id=challenge.proposed_by.id,
nickname=challenge.proposed_by.nickname
)
return ChallengeResponse(
id=challenge.id,
title=challenge.title,
description=challenge.description,
type=challenge.type,
difficulty=challenge.difficulty,
points=challenge.points,
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
status=challenge.status,
proposed_by=proposed_by,
)
@router.get("/games/{game_id}/challenges", response_model=list[ChallengeResponse])
async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession):
"""List challenges for a game. Participants can view challenges for approved games only."""
"""List challenges for a game. Participants can view approved and pending challenges."""
# Get game and check access
result = await db.execute(
select(Game).where(Game.id == game_id)
@@ -54,30 +84,17 @@ async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession
if game.status != GameStatus.APPROVED.value and game.proposed_by_id != current_user.id:
raise HTTPException(status_code=403, detail="Game not accessible")
result = await db.execute(
select(Challenge)
.where(Challenge.game_id == game_id)
.order_by(Challenge.difficulty, Challenge.created_at)
)
# Get challenges with proposed_by
query = select(Challenge).options(selectinload(Challenge.proposed_by)).where(Challenge.game_id == game_id)
# Regular participants see approved and pending challenges (but not rejected)
if not current_user.is_admin and participant and not participant.is_organizer:
query = query.where(Challenge.status.in_([ChallengeStatus.APPROVED.value, ChallengeStatus.PENDING.value]))
result = await db.execute(query.order_by(Challenge.status.desc(), Challenge.difficulty, Challenge.created_at))
challenges = result.scalars().all()
return [
ChallengeResponse(
id=c.id,
title=c.title,
description=c.description,
type=c.type,
difficulty=c.difficulty,
points=c.points,
estimated_time=c.estimated_time,
proof_type=c.proof_type,
proof_hint=c.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
is_generated=c.is_generated,
created_at=c.created_at,
)
for c in challenges
]
return [build_challenge_response(c, game) for c in challenges]
@router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse])
@@ -94,36 +111,21 @@ async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser,
if not current_user.is_admin and not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Get all challenges from approved games in this marathon
# Get all approved challenges from approved games in this marathon
result = await db.execute(
select(Challenge)
.join(Game, Challenge.game_id == Game.id)
.options(selectinload(Challenge.game))
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
.where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value,
Challenge.status == ChallengeStatus.APPROVED.value,
)
.order_by(Game.title, Challenge.difficulty, Challenge.created_at)
)
challenges = result.scalars().all()
return [
ChallengeResponse(
id=c.id,
title=c.title,
description=c.description,
type=c.type,
difficulty=c.difficulty,
points=c.points,
estimated_time=c.estimated_time,
proof_type=c.proof_type,
proof_hint=c.proof_hint,
game=GameShort(id=c.game.id, title=c.game.title, cover_url=None),
is_generated=c.is_generated,
created_at=c.created_at,
)
for c in challenges
]
return [build_challenge_response(c, c.game) for c in challenges]
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
@@ -166,25 +168,13 @@ async def create_challenge(
proof_type=data.proof_type.value,
proof_hint=data.proof_hint,
is_generated=False,
status=ChallengeStatus.APPROVED.value, # Organizer-created challenges are auto-approved
)
db.add(challenge)
await db.commit()
await db.refresh(challenge)
return ChallengeResponse(
id=challenge.id,
title=challenge.title,
description=challenge.description,
type=challenge.type,
difficulty=challenge.difficulty,
points=challenge.points,
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
)
return build_challenge_response(challenge, game)
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
@@ -386,26 +376,12 @@ async def update_challenge(
await db.commit()
await db.refresh(challenge)
game = challenge.game
return ChallengeResponse(
id=challenge.id,
title=challenge.title,
description=challenge.description,
type=challenge.type,
difficulty=challenge.difficulty,
points=challenge.points,
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
)
return build_challenge_response(challenge, challenge.game)
@router.delete("/challenges/{challenge_id}", response_model=MessageResponse)
async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
"""Delete a challenge. Organizers only."""
"""Delete a challenge. Organizers can delete any, participants can delete their own pending."""
challenge = await get_challenge_or_404(db, challenge_id)
# Check marathon is in preparing state
@@ -414,10 +390,206 @@ async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbS
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon")
# Only organizers can delete challenges
await require_organizer(db, current_user, challenge.game.marathon_id)
participant = await get_participant(db, current_user.id, challenge.game.marathon_id)
# Check permissions
if current_user.is_admin or (participant and participant.is_organizer):
# Organizers can delete any challenge
pass
elif challenge.proposed_by_id == current_user.id and challenge.status == ChallengeStatus.PENDING.value:
# Participants can delete their own pending challenges
pass
else:
raise HTTPException(status_code=403, detail="You can only delete your own pending challenges")
await db.delete(challenge)
await db.commit()
return MessageResponse(message="Challenge deleted")
# ============ Proposed challenges endpoints ============
@router.post("/games/{game_id}/propose-challenge", response_model=ChallengeResponse)
async def propose_challenge(
game_id: int,
data: ChallengePropose,
current_user: CurrentUser,
db: DbSession,
):
"""Propose a challenge for a game. Participants only, during PREPARING phase."""
# Get game
result = await db.execute(select(Game).where(Game.id == game_id))
game = result.scalar_one_or_none()
if not game:
raise HTTPException(status_code=404, detail="Game not found")
# Check marathon is in preparing state
result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
marathon = result.scalar_one()
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot propose challenges to active or finished marathon")
# Check user is participant
participant = await get_participant(db, current_user.id, game.marathon_id)
if not participant and not current_user.is_admin:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Can only propose challenges to approved games
if game.status != GameStatus.APPROVED.value:
raise HTTPException(status_code=400, detail="Can only propose challenges to approved games")
challenge = Challenge(
game_id=game_id,
title=data.title,
description=data.description,
type=data.type.value,
difficulty=data.difficulty.value,
points=data.points,
estimated_time=data.estimated_time,
proof_type=data.proof_type.value,
proof_hint=data.proof_hint,
is_generated=False,
proposed_by_id=current_user.id,
status=ChallengeStatus.PENDING.value,
)
db.add(challenge)
await db.commit()
await db.refresh(challenge)
# Load proposed_by relationship
challenge.proposed_by = current_user
return build_challenge_response(challenge, game)
@router.get("/marathons/{marathon_id}/proposed-challenges", response_model=list[ChallengeResponse])
async def list_proposed_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""List all pending proposed challenges for a marathon. Organizers only."""
# Check marathon exists
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
# Only organizers can see all proposed challenges
await require_organizer(db, current_user, marathon_id)
# Get all pending challenges from approved games
result = await db.execute(
select(Challenge)
.join(Game, Challenge.game_id == Game.id)
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
.where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value,
Challenge.status == ChallengeStatus.PENDING.value,
)
.order_by(Challenge.created_at.desc())
)
challenges = result.scalars().all()
return [build_challenge_response(c, c.game) for c in challenges]
@router.get("/marathons/{marathon_id}/my-proposed-challenges", response_model=list[ChallengeResponse])
async def list_my_proposed_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""List current user's proposed challenges for a marathon."""
# Check marathon exists
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
# Check user is participant
participant = await get_participant(db, current_user.id, marathon_id)
if not participant and not current_user.is_admin:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Get user's proposed challenges
result = await db.execute(
select(Challenge)
.join(Game, Challenge.game_id == Game.id)
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
.where(
Game.marathon_id == marathon_id,
Challenge.proposed_by_id == current_user.id,
)
.order_by(Challenge.created_at.desc())
)
challenges = result.scalars().all()
return [build_challenge_response(c, c.game) for c in challenges]
@router.patch("/challenges/{challenge_id}/approve", response_model=ChallengeResponse)
async def approve_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
"""Approve a proposed challenge. Organizers only."""
challenge = await get_challenge_or_404(db, challenge_id)
# Check marathon is in preparing state
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
marathon = result.scalar_one()
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot approve challenges in active or finished marathon")
# Only organizers can approve
await require_organizer(db, current_user, challenge.game.marathon_id)
if challenge.status != ChallengeStatus.PENDING.value:
raise HTTPException(status_code=400, detail="Challenge is not pending")
challenge.status = ChallengeStatus.APPROVED.value
await db.commit()
await db.refresh(challenge)
# Send Telegram notification to proposer
if challenge.proposed_by_id:
await telegram_notifier.notify_challenge_approved(
db,
challenge.proposed_by_id,
marathon.title,
challenge.game.title,
challenge.title
)
return build_challenge_response(challenge, challenge.game)
@router.patch("/challenges/{challenge_id}/reject", response_model=ChallengeResponse)
async def reject_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
"""Reject a proposed challenge. Organizers only."""
challenge = await get_challenge_or_404(db, challenge_id)
# Check marathon is in preparing state
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
marathon = result.scalar_one()
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot reject challenges in active or finished marathon")
# Only organizers can reject
await require_organizer(db, current_user, challenge.game.marathon_id)
if challenge.status != ChallengeStatus.PENDING.value:
raise HTTPException(status_code=400, detail="Challenge is not pending")
# Save info for notification before changing status
proposer_id = challenge.proposed_by_id
game_title = challenge.game.title
challenge_title = challenge.title
challenge.status = ChallengeStatus.REJECTED.value
await db.commit()
await db.refresh(challenge)
# Send Telegram notification to proposer
if proposer_id:
await telegram_notifier.notify_challenge_rejected(
db,
proposer_id,
marathon.title,
game_title,
challenge_title
)
return build_challenge_response(challenge, challenge.game)

View File

@@ -0,0 +1,20 @@
from fastapi import APIRouter, HTTPException
from sqlalchemy import select
from app.api.deps import DbSession
from app.models import StaticContent
from app.schemas import StaticContentResponse
router = APIRouter(prefix="/content", tags=["content"])
@router.get("/{key}", response_model=StaticContentResponse)
async def get_public_content(key: str, db: DbSession):
"""Get public static content by key. No authentication required."""
result = await db.execute(
select(StaticContent).where(StaticContent.key == key)
)
content = result.scalar_one_or_none()
if not content:
raise HTTPException(status_code=404, detail="Content not found")
return content

View File

@@ -1,7 +1,8 @@
from datetime import timedelta
import secrets
import string
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
@@ -10,6 +11,10 @@ from app.api.deps import (
require_participant, require_organizer, require_creator,
get_participant,
)
from app.core.security import decode_access_token
# Optional auth for endpoints that need it conditionally
optional_auth = HTTPBearer(auto_error=False)
from app.models import (
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
@@ -188,6 +193,15 @@ async def create_marathon(
async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
marathon = await get_marathon_or_404(db, marathon_id)
# For private marathons, require participation (or admin/creator)
if not marathon.is_public and not current_user.is_admin:
participation = await get_participation(db, current_user.id, marathon_id)
if not participation:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not a participant of this private marathon",
)
# Count participants and approved games
participants_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id)
@@ -428,7 +442,16 @@ async def join_public_marathon(marathon_id: int, current_user: CurrentUser, db:
@router.get("/{marathon_id}/participants", response_model=list[ParticipantWithUser])
async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSession):
await get_marathon_or_404(db, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
# For private marathons, require participation (or admin)
if not marathon.is_public and not current_user.is_admin:
participation = await get_participation(db, current_user.id, marathon_id)
if not participation:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not a participant of this private marathon",
)
result = await db.execute(
select(Participant)
@@ -497,8 +520,42 @@ async def set_participant_role(
@router.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry])
async def get_leaderboard(marathon_id: int, db: DbSession):
await get_marathon_or_404(db, marathon_id)
async def get_leaderboard(
marathon_id: int,
db: DbSession,
credentials: HTTPAuthorizationCredentials | None = Depends(optional_auth),
):
"""
Get marathon leaderboard.
Public marathons: no auth required.
Private marathons: requires auth + participation check.
"""
marathon = await get_marathon_or_404(db, marathon_id)
# For private marathons, require authentication and participation
if not marathon.is_public:
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required for private marathon leaderboard",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_access_token(credentials.credentials)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = int(payload.get("sub"))
participant = await get_participant(db, user_id, marathon_id)
if not participant:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not a participant of this marathon",
)
result = await db.execute(
select(Participant)

View File

@@ -5,7 +5,7 @@ from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.api.deps import DbSession, CurrentUser, BotSecretDep
from app.core.config import settings
from app.core.security import create_telegram_link_token, verify_telegram_link_token
from app.models import User, Participant, Marathon, Assignment, Challenge, Event, Game
@@ -86,7 +86,7 @@ async def generate_link_token(current_user: CurrentUser):
)
logger.info(f"[TG_LINK] Token created: {token} (length: {len(token)})")
bot_username = settings.TELEGRAM_BOT_USERNAME or "GameMarathonBot"
bot_username = settings.TELEGRAM_BOT_USERNAME or "BCMarathonbot"
bot_url = f"https://t.me/{bot_username}?start={token}"
logger.info(f"[TG_LINK] Bot URL: {bot_url}")
@@ -94,7 +94,7 @@ async def generate_link_token(current_user: CurrentUser):
@router.post("/confirm-link", response_model=TelegramLinkResponse)
async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession):
async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession, _: BotSecretDep):
"""Confirm Telegram account linking (called by bot)."""
logger.info(f"[TG_CONFIRM] ========== CONFIRM LINK REQUEST ==========")
logger.info(f"[TG_CONFIRM] telegram_id: {data.telegram_id}")
@@ -145,7 +145,7 @@ async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession):
@router.get("/user/{telegram_id}", response_model=TelegramUserResponse | None)
async def get_user_by_telegram_id(telegram_id: int, db: DbSession):
async def get_user_by_telegram_id(telegram_id: int, db: DbSession, _: BotSecretDep):
"""Get user by Telegram ID."""
logger.info(f"[TG_USER] Looking up user by telegram_id={telegram_id}")
@@ -168,7 +168,7 @@ async def get_user_by_telegram_id(telegram_id: int, db: DbSession):
@router.post("/unlink/{telegram_id}", response_model=TelegramLinkResponse)
async def unlink_telegram(telegram_id: int, db: DbSession):
async def unlink_telegram(telegram_id: int, db: DbSession, _: BotSecretDep):
"""Unlink Telegram account."""
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
@@ -187,7 +187,7 @@ async def unlink_telegram(telegram_id: int, db: DbSession):
@router.get("/marathons/{telegram_id}", response_model=list[TelegramMarathonResponse])
async def get_user_marathons(telegram_id: int, db: DbSession):
async def get_user_marathons(telegram_id: int, db: DbSession, _: BotSecretDep):
"""Get user's marathons by Telegram ID."""
# Get user
result = await db.execute(
@@ -231,7 +231,7 @@ async def get_user_marathons(telegram_id: int, db: DbSession):
@router.get("/marathon/{marathon_id}", response_model=TelegramMarathonDetails | None)
async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession):
async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession, _: BotSecretDep):
"""Get marathon details for user by Telegram ID."""
# Get user
result = await db.execute(
@@ -341,7 +341,7 @@ async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession
@router.get("/stats/{telegram_id}", response_model=TelegramStatsResponse | None)
async def get_user_stats(telegram_id: int, db: DbSession):
async def get_user_stats(telegram_id: int, db: DbSession, _: BotSecretDep):
"""Get user's overall statistics by Telegram ID."""
# Get user
result = await db.execute(

View File

@@ -8,7 +8,7 @@ from app.models import User, Participant, Assignment, Marathon
from app.models.assignment import AssignmentStatus
from app.models.marathon import MarathonStatus
from app.schemas import (
UserPublic, UserUpdate, TelegramLink, MessageResponse,
UserPublic, UserPrivate, UserUpdate, TelegramLink, MessageResponse,
PasswordChange, UserStats, UserProfilePublic,
)
from app.services.storage import storage_service
@@ -17,7 +17,8 @@ router = APIRouter(prefix="/users", tags=["users"])
@router.get("/{user_id}", response_model=UserPublic)
async def get_user(user_id: int, db: DbSession):
async def get_user(user_id: int, db: DbSession, current_user: CurrentUser):
"""Get user profile. Requires authentication."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
@@ -58,23 +59,25 @@ async def get_user_avatar(user_id: int, db: DbSession):
)
@router.patch("/me", response_model=UserPublic)
@router.patch("/me", response_model=UserPrivate)
async def update_me(data: UserUpdate, current_user: CurrentUser, db: DbSession):
"""Update current user's profile"""
if data.nickname is not None:
current_user.nickname = data.nickname
await db.commit()
await db.refresh(current_user)
return UserPublic.model_validate(current_user)
return UserPrivate.model_validate(current_user)
@router.post("/me/avatar", response_model=UserPublic)
@router.post("/me/avatar", response_model=UserPrivate)
async def upload_avatar(
current_user: CurrentUser,
db: DbSession,
file: UploadFile = File(...),
):
"""Upload current user's avatar"""
# Validate file
if not file.content_type.startswith("image/"):
raise HTTPException(
@@ -115,7 +118,7 @@ async def upload_avatar(
await db.commit()
await db.refresh(current_user)
return UserPublic.model_validate(current_user)
return UserPrivate.model_validate(current_user)
@router.post("/me/telegram", response_model=MessageResponse)
@@ -193,8 +196,8 @@ async def get_my_stats(current_user: CurrentUser, db: DbSession):
@router.get("/{user_id}/stats", response_model=UserStats)
async def get_user_stats(user_id: int, db: DbSession):
"""Получить статистику пользователя"""
async def get_user_stats(user_id: int, db: DbSession, current_user: CurrentUser):
"""Получить статистику пользователя. Requires authentication."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
@@ -207,8 +210,8 @@ async def get_user_stats(user_id: int, db: DbSession):
@router.get("/{user_id}/profile", response_model=UserProfilePublic)
async def get_user_profile(user_id: int, db: DbSession):
"""Получить публичный профиль пользователя со статистикой"""
async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUser):
"""Получить публичный профиль пользователя со статистикой. Requires authentication."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()

View File

@@ -22,6 +22,7 @@ class Settings(BaseSettings):
TELEGRAM_BOT_TOKEN: str = ""
TELEGRAM_BOT_USERNAME: str = ""
TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES: int = 10
BOT_API_SECRET: str = "" # Secret key for bot-to-backend communication
# Frontend
FRONTEND_URL: str = "http://localhost:3000"

View File

@@ -0,0 +1,5 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
# Rate limiter using client IP address as key
limiter = Limiter(key_func=get_remote_address)

View File

@@ -1,7 +1,10 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
# Configure logging
logging.basicConfig(
@@ -14,6 +17,7 @@ from pathlib import Path
from app.core.config import settings
from app.core.database import engine, Base, async_session_maker
from app.core.rate_limit import limiter
from app.api.v1 import router as api_router
from app.services.event_scheduler import event_scheduler
from app.services.dispute_scheduler import dispute_scheduler
@@ -49,6 +53,10 @@ app = FastAPI(
lifespan=lifespan,
)
# Rate limiting
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# CORS
app.add_middleware(
CORSMiddleware,

View File

@@ -8,6 +8,9 @@ from app.models.activity import Activity, ActivityType
from app.models.event import Event, EventType
from app.models.swap_request import SwapRequest, SwapRequestStatus
from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVote
from app.models.admin_log import AdminLog, AdminActionType
from app.models.admin_2fa import Admin2FASession
from app.models.static_content import StaticContent
__all__ = [
"User",
@@ -35,4 +38,8 @@ __all__ = [
"DisputeStatus",
"DisputeComment",
"DisputeVote",
"AdminLog",
"AdminActionType",
"Admin2FASession",
"StaticContent",
]

View File

@@ -0,0 +1,20 @@
from datetime import datetime
from sqlalchemy import String, DateTime, Integer, ForeignKey, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class Admin2FASession(Base):
__tablename__ = "admin_2fa_sessions"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
code: Mapped[str] = mapped_column(String(6), nullable=False)
telegram_sent: Mapped[bool] = mapped_column(Boolean, default=False)
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
user: Mapped["User"] = relationship("User", foreign_keys=[user_id])

View File

@@ -0,0 +1,46 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, DateTime, Integer, ForeignKey, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class AdminActionType(str, Enum):
# User actions
USER_BAN = "user_ban"
USER_UNBAN = "user_unban"
USER_AUTO_UNBAN = "user_auto_unban" # System automatic unban
USER_ROLE_CHANGE = "user_role_change"
# Marathon actions
MARATHON_FORCE_FINISH = "marathon_force_finish"
MARATHON_DELETE = "marathon_delete"
# Content actions
CONTENT_UPDATE = "content_update"
# Broadcast actions
BROADCAST_ALL = "broadcast_all"
BROADCAST_MARATHON = "broadcast_marathon"
# Auth actions
ADMIN_LOGIN = "admin_login"
ADMIN_2FA_SUCCESS = "admin_2fa_success"
ADMIN_2FA_FAIL = "admin_2fa_fail"
class AdminLog(Base):
__tablename__ = "admin_logs"
id: Mapped[int] = mapped_column(primary_key=True)
admin_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True, index=True) # Nullable for system actions
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
target_type: Mapped[str] = mapped_column(String(50), nullable=False)
target_id: Mapped[int] = mapped_column(Integer, nullable=False)
details: Mapped[dict | None] = mapped_column(JSON, nullable=True)
ip_address: Mapped[str | None] = mapped_column(String(50), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
# Relationships
admin: Mapped["User"] = relationship("User", foreign_keys=[admin_id])

View File

@@ -29,6 +29,12 @@ class ProofType(str, Enum):
STEAM = "steam"
class ChallengeStatus(str, Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class Challenge(Base):
__tablename__ = "challenges"
@@ -45,8 +51,13 @@ class Challenge(Base):
is_generated: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Proposed challenges support
proposed_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
status: Mapped[str] = mapped_column(String(20), default="approved") # pending, approved, rejected
# Relationships
game: Mapped["Game"] = relationship("Game", back_populates="challenges")
proposed_by: Mapped["User"] = relationship("User", foreign_keys=[proposed_by_id])
assignments: Mapped[list["Assignment"]] = relationship(
"Assignment",
back_populates="challenge"

View File

@@ -0,0 +1,20 @@
from datetime import datetime
from sqlalchemy import String, DateTime, Integer, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class StaticContent(Base):
__tablename__ = "static_content"
id: Mapped[int] = mapped_column(primary_key=True)
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
updated_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
updated_by: Mapped["User | None"] = relationship("User", foreign_keys=[updated_by_id])

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, BigInteger, DateTime
from sqlalchemy import String, BigInteger, DateTime, Boolean, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
@@ -27,6 +27,13 @@ class User(Base):
role: Mapped[str] = mapped_column(String(20), default=UserRole.USER.value)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Ban fields
is_banned: Mapped[bool] = mapped_column(Boolean, default=False)
banned_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
banned_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # None = permanent
banned_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
ban_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Relationships
created_marathons: Mapped[list["Marathon"]] = relationship(
"Marathon",
@@ -47,6 +54,11 @@ class User(Base):
back_populates="approved_by",
foreign_keys="Game.approved_by_id"
)
banned_by: Mapped["User | None"] = relationship(
"User",
remote_side="User.id",
foreign_keys=[banned_by_id]
)
@property
def is_admin(self) -> bool:

View File

@@ -3,7 +3,7 @@ from app.schemas.user import (
UserLogin,
UserUpdate,
UserPublic,
UserWithTelegram,
UserPrivate,
TokenResponse,
TelegramLink,
PasswordChange,
@@ -81,6 +81,22 @@ from app.schemas.dispute import (
AssignmentDetailResponse,
ReturnedAssignmentResponse,
)
from app.schemas.admin import (
BanUserRequest,
AdminUserResponse,
AdminLogResponse,
AdminLogsListResponse,
BroadcastRequest,
BroadcastResponse,
StaticContentResponse,
StaticContentUpdate,
StaticContentCreate,
TwoFactorInitiateRequest,
TwoFactorInitiateResponse,
TwoFactorVerifyRequest,
LoginResponse,
DashboardStats,
)
__all__ = [
# User
@@ -88,7 +104,7 @@ __all__ = [
"UserLogin",
"UserUpdate",
"UserPublic",
"UserWithTelegram",
"UserPrivate",
"TokenResponse",
"TelegramLink",
"PasswordChange",
@@ -157,4 +173,19 @@ __all__ = [
"DisputeResponse",
"AssignmentDetailResponse",
"ReturnedAssignmentResponse",
# Admin
"BanUserRequest",
"AdminUserResponse",
"AdminLogResponse",
"AdminLogsListResponse",
"BroadcastRequest",
"BroadcastResponse",
"StaticContentResponse",
"StaticContentUpdate",
"StaticContentCreate",
"TwoFactorInitiateRequest",
"TwoFactorInitiateResponse",
"TwoFactorVerifyRequest",
"LoginResponse",
"DashboardStats",
]

View File

@@ -0,0 +1,119 @@
from datetime import datetime
from pydantic import BaseModel, Field
from typing import Any
# ============ User Ban ============
class BanUserRequest(BaseModel):
reason: str = Field(..., min_length=1, max_length=500)
banned_until: datetime | None = None # None = permanent ban
class AdminUserResponse(BaseModel):
id: int
login: str
nickname: str
role: str
avatar_url: str | None = None
telegram_id: int | None = None
telegram_username: str | None = None
marathons_count: int = 0
created_at: str
is_banned: bool = False
banned_at: str | None = None
banned_until: str | None = None # None = permanent
ban_reason: str | None = None
class Config:
from_attributes = True
# ============ Admin Logs ============
class AdminLogResponse(BaseModel):
id: int
admin_id: int | None = None # Nullable for system actions
admin_nickname: str | None = None # Nullable for system actions
action: str
target_type: str
target_id: int
details: dict | None = None
ip_address: str | None = None
created_at: datetime
class Config:
from_attributes = True
class AdminLogsListResponse(BaseModel):
logs: list[AdminLogResponse]
total: int
# ============ Broadcast ============
class BroadcastRequest(BaseModel):
message: str = Field(..., min_length=1, max_length=2000)
class BroadcastResponse(BaseModel):
sent_count: int
total_count: int
# ============ Static Content ============
class StaticContentResponse(BaseModel):
id: int
key: str
title: str
content: str
updated_at: datetime
created_at: datetime
class Config:
from_attributes = True
class StaticContentUpdate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
content: str = Field(..., min_length=1)
class StaticContentCreate(BaseModel):
key: str = Field(..., min_length=1, max_length=100, pattern=r"^[a-z0-9_-]+$")
title: str = Field(..., min_length=1, max_length=200)
content: str = Field(..., min_length=1)
# ============ 2FA ============
class TwoFactorInitiateRequest(BaseModel):
pass # No additional data needed
class TwoFactorInitiateResponse(BaseModel):
session_id: int
expires_at: datetime
message: str = "Code sent to Telegram"
class TwoFactorVerifyRequest(BaseModel):
session_id: int
code: str = Field(..., min_length=6, max_length=6)
class LoginResponse(BaseModel):
"""Login response that may require 2FA"""
access_token: str | None = None
token_type: str = "bearer"
user: Any = None # UserPrivate
requires_2fa: bool = False
two_factor_session_id: int | None = None
# ============ Dashboard Stats ============
class DashboardStats(BaseModel):
users_count: int
banned_users_count: int
marathons_count: int
active_marathons_count: int
games_count: int
total_participations: int
recent_logs: list[AdminLogResponse] = []

View File

@@ -1,10 +1,19 @@
from datetime import datetime
from pydantic import BaseModel, Field
from app.models.challenge import ChallengeType, Difficulty, ProofType
from app.models.challenge import ChallengeType, Difficulty, ProofType, ChallengeStatus
from app.schemas.game import GameShort
class ProposedByUser(BaseModel):
"""Minimal user info for proposed challenges"""
id: int
nickname: str
class Config:
from_attributes = True
class ChallengeBase(BaseModel):
title: str = Field(..., min_length=1, max_length=100)
description: str = Field(..., min_length=1)
@@ -36,11 +45,18 @@ class ChallengeResponse(ChallengeBase):
game: GameShort
is_generated: bool
created_at: datetime
status: str = "approved"
proposed_by: ProposedByUser | None = None
class Config:
from_attributes = True
class ChallengePropose(ChallengeBase):
"""Schema for proposing a challenge by a participant"""
pass
class ChallengeGenerated(BaseModel):
"""Schema for GPT-generated challenges"""
title: str

View File

@@ -29,30 +29,30 @@ class UserUpdate(BaseModel):
class UserPublic(UserBase):
"""Public user info visible to other users - minimal data"""
id: int
login: str
avatar_url: str | None = None
role: str = "user"
telegram_id: int | None = None
telegram_username: str | None = None
telegram_first_name: str | None = None
telegram_last_name: str | None = None
telegram_avatar_url: str | None = None
telegram_avatar_url: str | None = None # Only TG avatar is public
created_at: datetime
class Config:
from_attributes = True
class UserWithTelegram(UserPublic):
class UserPrivate(UserPublic):
"""Full user info visible only to the user themselves"""
login: str
telegram_id: int | None = None
telegram_username: str | None = None
telegram_first_name: str | None = None
telegram_last_name: str | None = None
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserPublic
user: UserPrivate
class TelegramLink(BaseModel):

View File

@@ -276,6 +276,42 @@ class TelegramNotifier:
)
return await self.notify_user(db, user_id, message)
async def notify_challenge_approved(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
game_title: str,
challenge_title: str
) -> bool:
"""Notify user that their proposed challenge was approved."""
message = (
f"✅ <b>Твой челлендж одобрен!</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Игра: {game_title}\n"
f"Задание: {challenge_title}\n\n"
f"Теперь оно доступно для всех участников."
)
return await self.notify_user(db, user_id, message)
async def notify_challenge_rejected(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
game_title: str,
challenge_title: str
) -> bool:
"""Notify user that their proposed challenge was rejected."""
message = (
f"❌ <b>Твой челлендж отклонён</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Игра: {game_title}\n"
f"Задание: {challenge_title}\n\n"
f"Ты можешь предложить другой челлендж."
)
return await self.notify_user(db, user_id, message)
# Global instance
telegram_notifier = TelegramNotifier()

View File

@@ -31,5 +31,8 @@ python-magic==0.4.27
# S3 Storage
boto3==1.34.0
# Rate limiting
slowapi==0.1.9
# Utils
python-dotenv==1.0.0

30
backup-service/Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM python:3.11-slim
WORKDIR /app
# Install PostgreSQL client (for pg_dump and psql) and cron
RUN apt-get update && apt-get install -y \
postgresql-client \
cron \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Make scripts executable
RUN chmod +x backup.py restore.py
# Setup cron
COPY crontab /etc/cron.d/backup-cron
RUN chmod 0644 /etc/cron.d/backup-cron
RUN crontab /etc/cron.d/backup-cron
# Create log file
RUN touch /var/log/cron.log
# Start cron in foreground and tail logs
CMD ["sh", "-c", "printenv > /etc/environment && cron && tail -f /var/log/cron.log"]

217
backup-service/backup.py Normal file
View File

@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""
PostgreSQL Backup Service for WebApp.
- Creates pg_dump backup
- Compresses with gzip
- Uploads to S3 FirstVDS
- Rotates old backups (configurable retention)
- Sends Telegram notifications
"""
import gzip
import os
import subprocess
import sys
from datetime import datetime, timedelta, timezone
import boto3
import httpx
from botocore.config import Config as BotoConfig
from botocore.exceptions import ClientError
from config import config
def create_s3_client():
"""Initialize S3 client (same pattern as backend storage.py)."""
return boto3.client(
"s3",
endpoint_url=config.S3_ENDPOINT_URL,
aws_access_key_id=config.S3_ACCESS_KEY_ID,
aws_secret_access_key=config.S3_SECRET_ACCESS_KEY,
region_name=config.S3_REGION or "us-east-1",
config=BotoConfig(signature_version="s3v4"),
)
def send_telegram_notification(message: str, is_error: bool = False) -> None:
"""Send notification to Telegram admin."""
if not config.TELEGRAM_BOT_TOKEN or not config.TELEGRAM_ADMIN_ID:
print("Telegram not configured, skipping notification")
return
emoji = "\u274c" if is_error else "\u2705"
text = f"{emoji} *Database Backup*\n\n{message}"
url = f"https://api.telegram.org/bot{config.TELEGRAM_BOT_TOKEN}/sendMessage"
data = {
"chat_id": config.TELEGRAM_ADMIN_ID,
"text": text,
"parse_mode": "Markdown",
}
try:
response = httpx.post(url, json=data, timeout=30)
response.raise_for_status()
print("Telegram notification sent")
except Exception as e:
print(f"Failed to send Telegram notification: {e}")
def create_backup() -> tuple[str, bytes]:
"""Create pg_dump backup and compress it."""
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
filename = f"marathon_backup_{timestamp}.sql.gz"
# Build pg_dump command
env = os.environ.copy()
env["PGPASSWORD"] = config.DB_PASSWORD
cmd = [
"pg_dump",
"-h",
config.DB_HOST,
"-p",
config.DB_PORT,
"-U",
config.DB_USER,
"-d",
config.DB_NAME,
"--no-owner",
"--no-acl",
"-F",
"p", # plain SQL format
]
print(f"Running pg_dump for database {config.DB_NAME}...")
result = subprocess.run(
cmd,
env=env,
capture_output=True,
)
if result.returncode != 0:
raise Exception(f"pg_dump failed: {result.stderr.decode()}")
# Compress the output
print("Compressing backup...")
compressed = gzip.compress(result.stdout, compresslevel=9)
return filename, compressed
def upload_to_s3(s3_client, filename: str, data: bytes) -> str:
"""Upload backup to S3."""
key = f"{config.S3_BACKUP_PREFIX}{filename}"
print(f"Uploading to S3: {key}...")
s3_client.put_object(
Bucket=config.S3_BUCKET_NAME,
Key=key,
Body=data,
ContentType="application/gzip",
)
return key
def rotate_old_backups(s3_client) -> int:
"""Delete backups older than BACKUP_RETENTION_DAYS."""
cutoff_date = datetime.now(timezone.utc) - timedelta(
days=config.BACKUP_RETENTION_DAYS
)
deleted_count = 0
print(f"Rotating backups older than {config.BACKUP_RETENTION_DAYS} days...")
# List all objects with backup prefix
try:
paginator = s3_client.get_paginator("list_objects_v2")
pages = paginator.paginate(
Bucket=config.S3_BUCKET_NAME,
Prefix=config.S3_BACKUP_PREFIX,
)
for page in pages:
for obj in page.get("Contents", []):
last_modified = obj["LastModified"]
if last_modified.tzinfo is None:
last_modified = last_modified.replace(tzinfo=timezone.utc)
if last_modified < cutoff_date:
s3_client.delete_object(
Bucket=config.S3_BUCKET_NAME,
Key=obj["Key"],
)
deleted_count += 1
print(f"Deleted old backup: {obj['Key']}")
except ClientError as e:
print(f"Error during rotation: {e}")
return deleted_count
def main() -> int:
"""Main backup routine."""
start_time = datetime.now()
print(f"{'=' * 50}")
print(f"Backup started at {start_time}")
print(f"{'=' * 50}")
try:
# Validate configuration
if not config.S3_BUCKET_NAME:
raise Exception("S3_BUCKET_NAME is not configured")
if not config.S3_ACCESS_KEY_ID:
raise Exception("S3_ACCESS_KEY_ID is not configured")
if not config.S3_SECRET_ACCESS_KEY:
raise Exception("S3_SECRET_ACCESS_KEY is not configured")
if not config.S3_ENDPOINT_URL:
raise Exception("S3_ENDPOINT_URL is not configured")
# Create S3 client
s3_client = create_s3_client()
# Create backup
filename, data = create_backup()
size_mb = len(data) / (1024 * 1024)
print(f"Backup created: {filename} ({size_mb:.2f} MB)")
# Upload to S3
s3_key = upload_to_s3(s3_client, filename, data)
print(f"Uploaded to S3: {s3_key}")
# Rotate old backups
deleted_count = rotate_old_backups(s3_client)
print(f"Deleted {deleted_count} old backups")
# Calculate duration
duration = datetime.now() - start_time
# Send success notification
message = (
f"Backup completed successfully!\n\n"
f"*File:* `{filename}`\n"
f"*Size:* {size_mb:.2f} MB\n"
f"*Duration:* {duration.seconds}s\n"
f"*Deleted old:* {deleted_count} files"
)
send_telegram_notification(message, is_error=False)
print(f"{'=' * 50}")
print("Backup completed successfully!")
print(f"{'=' * 50}")
return 0
except Exception as e:
error_msg = f"Backup failed!\n\n*Error:* `{str(e)}`"
send_telegram_notification(error_msg, is_error=True)
print(f"{'=' * 50}")
print(f"Backup failed: {e}")
print(f"{'=' * 50}")
return 1
if __name__ == "__main__":
sys.exit(main())

33
backup-service/config.py Normal file
View File

@@ -0,0 +1,33 @@
"""Configuration for backup service."""
import os
from dataclasses import dataclass
@dataclass
class Config:
"""Backup service configuration from environment variables."""
# Database
DB_HOST: str = os.getenv("DB_HOST", "db")
DB_PORT: str = os.getenv("DB_PORT", "5432")
DB_NAME: str = os.getenv("DB_NAME", "marathon")
DB_USER: str = os.getenv("DB_USER", "marathon")
DB_PASSWORD: str = os.getenv("DB_PASSWORD", "123")
# S3
S3_BUCKET_NAME: str = os.getenv("S3_BUCKET_NAME", "")
S3_REGION: str = os.getenv("S3_REGION", "ru-1")
S3_ACCESS_KEY_ID: str = os.getenv("S3_ACCESS_KEY_ID", "")
S3_SECRET_ACCESS_KEY: str = os.getenv("S3_SECRET_ACCESS_KEY", "")
S3_ENDPOINT_URL: str = os.getenv("S3_ENDPOINT_URL", "")
S3_BACKUP_PREFIX: str = os.getenv("S3_BACKUP_PREFIX", "backups/")
# Telegram
TELEGRAM_BOT_TOKEN: str = os.getenv("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_ADMIN_ID: str = os.getenv("TELEGRAM_ADMIN_ID", "947392854")
# Backup settings
BACKUP_RETENTION_DAYS: int = int(os.getenv("BACKUP_RETENTION_DAYS", "14"))
config = Config()

4
backup-service/crontab Normal file
View File

@@ -0,0 +1,4 @@
# Backup cron job
# Run backup daily at 3:00 AM UTC
0 3 * * * /usr/local/bin/python /app/backup.py >> /var/log/cron.log 2>&1
# Empty line required at end of crontab

View File

@@ -0,0 +1,2 @@
boto3==1.34.0
httpx==0.26.0

158
backup-service/restore.py Normal file
View File

@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""
Restore PostgreSQL database from S3 backup.
Usage:
python restore.py - List available backups
python restore.py <filename> - Restore from specific backup
"""
import gzip
import os
import subprocess
import sys
import boto3
from botocore.config import Config as BotoConfig
from botocore.exceptions import ClientError
from config import config
def create_s3_client():
"""Initialize S3 client."""
return boto3.client(
"s3",
endpoint_url=config.S3_ENDPOINT_URL,
aws_access_key_id=config.S3_ACCESS_KEY_ID,
aws_secret_access_key=config.S3_SECRET_ACCESS_KEY,
region_name=config.S3_REGION or "us-east-1",
config=BotoConfig(signature_version="s3v4"),
)
def list_backups(s3_client) -> list[tuple[str, float, str]]:
"""List all available backups."""
print("Available backups:\n")
try:
paginator = s3_client.get_paginator("list_objects_v2")
pages = paginator.paginate(
Bucket=config.S3_BUCKET_NAME,
Prefix=config.S3_BACKUP_PREFIX,
)
backups = []
for page in pages:
for obj in page.get("Contents", []):
filename = obj["Key"].replace(config.S3_BACKUP_PREFIX, "")
size_mb = obj["Size"] / (1024 * 1024)
modified = obj["LastModified"].strftime("%Y-%m-%d %H:%M:%S")
backups.append((filename, size_mb, modified))
# Sort by date descending (newest first)
backups.sort(key=lambda x: x[2], reverse=True)
for filename, size_mb, modified in backups:
print(f" {filename} ({size_mb:.2f} MB) - {modified}")
return backups
except ClientError as e:
print(f"Error listing backups: {e}")
return []
def restore_backup(s3_client, filename: str) -> None:
"""Download and restore backup."""
key = f"{config.S3_BACKUP_PREFIX}{filename}"
print(f"Downloading {filename} from S3...")
try:
response = s3_client.get_object(
Bucket=config.S3_BUCKET_NAME,
Key=key,
)
compressed_data = response["Body"].read()
except ClientError as e:
raise Exception(f"Failed to download backup: {e}")
print("Decompressing...")
sql_data = gzip.decompress(compressed_data)
print(f"Restoring to database {config.DB_NAME}...")
# Build psql command
env = os.environ.copy()
env["PGPASSWORD"] = config.DB_PASSWORD
cmd = [
"psql",
"-h",
config.DB_HOST,
"-p",
config.DB_PORT,
"-U",
config.DB_USER,
"-d",
config.DB_NAME,
]
result = subprocess.run(
cmd,
env=env,
input=sql_data,
capture_output=True,
)
if result.returncode != 0:
stderr = result.stderr.decode()
# psql may return warnings that aren't fatal errors
if "ERROR" in stderr:
raise Exception(f"psql restore failed: {stderr}")
else:
print(f"Warnings: {stderr}")
print("Restore completed successfully!")
def main() -> int:
"""Main restore routine."""
# Validate configuration
if not config.S3_BUCKET_NAME:
print("Error: S3_BUCKET_NAME is not configured")
return 1
s3_client = create_s3_client()
if len(sys.argv) < 2:
# List available backups
backups = list_backups(s3_client)
if backups:
print(f"\nTo restore, run: python restore.py <filename>")
else:
print("No backups found.")
return 0
filename = sys.argv[1]
# Confirm restore
print(f"WARNING: This will restore database from {filename}")
print("This may overwrite existing data!")
print()
confirm = input("Type 'yes' to continue: ")
if confirm.lower() != "yes":
print("Restore cancelled.")
return 0
try:
restore_backup(s3_client, filename)
return 0
except Exception as e:
print(f"Restore failed: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -5,6 +5,7 @@ class Settings(BaseSettings):
TELEGRAM_BOT_TOKEN: str
API_URL: str = "http://backend:8000"
BOT_USERNAME: str = "" # Will be set dynamically on startup
BOT_API_SECRET: str = "" # Secret for backend API communication
class Config:
env_file = ".env"

View File

@@ -32,6 +32,11 @@ class APIClient:
session = await self._get_session()
url = f"{self.base_url}/api/v1{endpoint}"
# Add bot secret header for authentication
headers = kwargs.pop("headers", {})
if settings.BOT_API_SECRET:
headers["X-Bot-Secret"] = settings.BOT_API_SECRET
logger.info(f"[APIClient] {method} {url}")
if 'json' in kwargs:
logger.info(f"[APIClient] Request body: {kwargs['json']}")
@@ -39,7 +44,7 @@ class APIClient:
logger.info(f"[APIClient] Request params: {kwargs['params']}")
try:
async with session.request(method, url, **kwargs) as response:
async with session.request(method, url, headers=headers, **kwargs) as response:
logger.info(f"[APIClient] Response status: {response.status}")
response_text = await response.text()
logger.info(f"[APIClient] Response body: {response_text[:500]}")

View File

@@ -27,7 +27,8 @@ services:
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
OPENAI_API_KEY: ${OPENAI_API_KEY}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-GameMarathonBot}
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-BCMarathonbot}
BOT_API_SECRET: ${BOT_API_SECRET:-}
DEBUG: ${DEBUG:-false}
# S3 Storage
S3_ENABLED: ${S3_ENABLED:-false}
@@ -81,6 +82,7 @@ services:
environment:
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- API_URL=http://backend:8000
- BOT_API_SECRET=${BOT_API_SECRET:-}
depends_on:
- backend
restart: unless-stopped
@@ -94,7 +96,13 @@ services:
BACKEND_URL: http://backend:8000
FRONTEND_URL: http://frontend:80
BOT_URL: http://bot:8080
EXTERNAL_URL: ${EXTERNAL_URL:-}
PUBLIC_URL: ${PUBLIC_URL:-}
CHECK_INTERVAL: "30"
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
TELEGRAM_ADMIN_ID: ${TELEGRAM_ADMIN_ID:-947392854}
volumes:
- status_data:/app/data
ports:
- "8001:8001"
depends_on:
@@ -103,5 +111,31 @@ services:
- bot
restart: unless-stopped
backup:
build:
context: ./backup-service
dockerfile: Dockerfile
container_name: marathon-backup
environment:
DB_HOST: db
DB_PORT: "5432"
DB_NAME: marathon
DB_USER: marathon
DB_PASSWORD: ${DB_PASSWORD:-marathon}
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-}
S3_REGION: ${S3_REGION:-ru-1}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-}
S3_BACKUP_PREFIX: ${S3_BACKUP_PREFIX:-backups/}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
TELEGRAM_ADMIN_ID: ${TELEGRAM_ADMIN_ID:-947392854}
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-14}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
volumes:
postgres_data:
status_data:

View File

@@ -1,6 +1,7 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { ToastContainer, ConfirmModal } from '@/components/ui'
import { BannedScreen } from '@/components/BannedScreen'
// Layout
import { Layout } from '@/components/layout/Layout'
@@ -23,6 +24,17 @@ import { NotFoundPage } from '@/pages/NotFoundPage'
import { TeapotPage } from '@/pages/TeapotPage'
import { ServerErrorPage } from '@/pages/ServerErrorPage'
// Admin Pages
import {
AdminLayout,
AdminDashboardPage,
AdminUsersPage,
AdminMarathonsPage,
AdminLogsPage,
AdminBroadcastPage,
AdminContentPage,
} from '@/pages/admin'
// Protected route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
@@ -46,6 +58,19 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
}
function App() {
const banInfo = useAuthStore((state) => state.banInfo)
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
// Show banned screen if user is authenticated and banned
if (isAuthenticated && banInfo) {
return (
<>
<ToastContainer />
<BannedScreen banInfo={banInfo} />
</>
)
}
return (
<>
<ToastContainer />
@@ -159,6 +184,23 @@ function App() {
<Route path="500" element={<ServerErrorPage />} />
<Route path="error" element={<ServerErrorPage />} />
{/* Admin routes */}
<Route
path="admin"
element={
<ProtectedRoute>
<AdminLayout />
</ProtectedRoute>
}
>
<Route index element={<AdminDashboardPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="marathons" element={<AdminMarathonsPage />} />
<Route path="logs" element={<AdminLogsPage />} />
<Route path="broadcast" element={<AdminBroadcastPage />} />
<Route path="content" element={<AdminContentPage />} />
</Route>
{/* 404 - must be last */}
<Route path="*" element={<NotFoundPage />} />
</Route>

View File

@@ -1,10 +1,25 @@
import client from './client'
import type { AdminUser, AdminMarathon, UserRole, PlatformStats } from '@/types'
import type {
AdminUser,
AdminMarathon,
UserRole,
PlatformStats,
AdminLogsResponse,
BroadcastResponse,
StaticContent,
DashboardStats
} from '@/types'
export const adminApi = {
// Dashboard
getDashboard: async (): Promise<DashboardStats> => {
const response = await client.get<DashboardStats>('/admin/dashboard')
return response.data
},
// Users
listUsers: async (skip = 0, limit = 50, search?: string): Promise<AdminUser[]> => {
const params: Record<string, unknown> = { skip, limit }
listUsers: async (skip = 0, limit = 50, search?: string, bannedOnly = false): Promise<AdminUser[]> => {
const params: Record<string, unknown> = { skip, limit, banned_only: bannedOnly }
if (search) params.search = search
const response = await client.get<AdminUser[]>('/admin/users', { params })
return response.data
@@ -24,6 +39,19 @@ export const adminApi = {
await client.delete(`/admin/users/${id}`)
},
banUser: async (id: number, reason: string, bannedUntil?: string): Promise<AdminUser> => {
const response = await client.post<AdminUser>(`/admin/users/${id}/ban`, {
reason,
banned_until: bannedUntil || null,
})
return response.data
},
unbanUser: async (id: number): Promise<AdminUser> => {
const response = await client.post<AdminUser>(`/admin/users/${id}/unban`)
return response.data
},
// Marathons
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
const params: Record<string, unknown> = { skip, limit }
@@ -36,9 +64,62 @@ export const adminApi = {
await client.delete(`/admin/marathons/${id}`)
},
forceFinishMarathon: async (id: number): Promise<void> => {
await client.post(`/admin/marathons/${id}/force-finish`)
},
// Stats
getStats: async (): Promise<PlatformStats> => {
const response = await client.get<PlatformStats>('/admin/stats')
return response.data
},
// Logs
getLogs: async (skip = 0, limit = 50, action?: string, adminId?: number): Promise<AdminLogsResponse> => {
const params: Record<string, unknown> = { skip, limit }
if (action) params.action = action
if (adminId) params.admin_id = adminId
const response = await client.get<AdminLogsResponse>('/admin/logs', { params })
return response.data
},
// Broadcast
broadcastToAll: async (message: string): Promise<BroadcastResponse> => {
const response = await client.post<BroadcastResponse>('/admin/broadcast/all', { message })
return response.data
},
broadcastToMarathon: async (marathonId: number, message: string): Promise<BroadcastResponse> => {
const response = await client.post<BroadcastResponse>(`/admin/broadcast/marathon/${marathonId}`, { message })
return response.data
},
// Static Content
listContent: async (): Promise<StaticContent[]> => {
const response = await client.get<StaticContent[]>('/admin/content')
return response.data
},
getContent: async (key: string): Promise<StaticContent> => {
const response = await client.get<StaticContent>(`/admin/content/${key}`)
return response.data
},
updateContent: async (key: string, title: string, content: string): Promise<StaticContent> => {
const response = await client.put<StaticContent>(`/admin/content/${key}`, { title, content })
return response.data
},
createContent: async (key: string, title: string, content: string): Promise<StaticContent> => {
const response = await client.post<StaticContent>('/admin/content', { key, title, content })
return response.data
},
}
// Public content API (no auth required)
export const contentApi = {
getPublicContent: async (key: string): Promise<StaticContent> => {
const response = await client.get<StaticContent>(`/content/${key}`)
return response.data
},
}

View File

@@ -1,5 +1,5 @@
import client from './client'
import type { TokenResponse, User } from '@/types'
import type { TokenResponse, LoginResponse, User } from '@/types'
export interface RegisterData {
login: string
@@ -18,8 +18,15 @@ export const authApi = {
return response.data
},
login: async (data: LoginData): Promise<TokenResponse> => {
const response = await client.post<TokenResponse>('/auth/login', data)
login: async (data: LoginData): Promise<LoginResponse> => {
const response = await client.post<LoginResponse>('/auth/login', data)
return response.data
},
verify2FA: async (sessionId: number, code: string): Promise<TokenResponse> => {
const response = await client.post<TokenResponse>('/auth/2fa/verify', null, {
params: { session_id: sessionId, code }
})
return response.data
},

View File

@@ -1,4 +1,5 @@
import axios, { AxiosError } from 'axios'
import { useAuthStore, type BanInfo } from '@/store/auth'
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
@@ -18,10 +19,20 @@ client.interceptors.request.use((config) => {
return config
})
// Helper to check if detail is ban info object
function isBanInfo(detail: unknown): detail is BanInfo {
return (
typeof detail === 'object' &&
detail !== null &&
'banned_at' in detail &&
'reason' in detail
)
}
// Response interceptor to handle errors
client.interceptors.response.use(
(response) => response,
(error: AxiosError<{ detail: string }>) => {
(error: AxiosError<{ detail: string | BanInfo }>) => {
// Unauthorized - redirect to login
if (error.response?.status === 401) {
localStorage.removeItem('token')
@@ -29,6 +40,15 @@ client.interceptors.response.use(
window.location.href = '/login'
}
// Forbidden - check if user is banned
if (error.response?.status === 403) {
const detail = error.response.data?.detail
if (isBanInfo(detail)) {
// User is banned - set ban info in store
useAuthStore.getState().setBanned(detail)
}
}
// Server error or network error - redirect to 500 page
if (
error.response?.status === 500 ||

View File

@@ -79,6 +79,11 @@ export const gamesApi = {
await client.delete(`/challenges/${id}`)
},
updateChallenge: async (id: number, data: Partial<CreateChallengeData>): Promise<Challenge> => {
const response = await client.patch<Challenge>(`/challenges/${id}`, data)
return response.data
},
previewChallenges: async (marathonId: number, gameIds?: number[]): Promise<ChallengesPreviewResponse> => {
const data = gameIds?.length ? { game_ids: gameIds } : undefined
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`, data)
@@ -89,4 +94,30 @@ export const gamesApi = {
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/save-challenges`, { challenges })
return response.data
},
// Proposed challenges
proposeChallenge: async (gameId: number, data: CreateChallengeData): Promise<Challenge> => {
const response = await client.post<Challenge>(`/games/${gameId}/propose-challenge`, data)
return response.data
},
getProposedChallenges: async (marathonId: number): Promise<Challenge[]> => {
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/proposed-challenges`)
return response.data
},
getMyProposedChallenges: async (marathonId: number): Promise<Challenge[]> => {
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/my-proposed-challenges`)
return response.data
},
approveChallenge: async (id: number): Promise<Challenge> => {
const response = await client.patch<Challenge>(`/challenges/${id}/approve`)
return response.data
},
rejectChallenge: async (id: number): Promise<Challenge> => {
const response = await client.patch<Challenge>(`/challenges/${id}/reject`)
return response.data
},
}

View File

@@ -3,7 +3,7 @@ export { marathonsApi } from './marathons'
export { gamesApi } from './games'
export { wheelApi } from './wheel'
export { feedApi } from './feed'
export { adminApi } from './admin'
export { adminApi, contentApi } from './admin'
export { eventsApi } from './events'
export { challengesApi } from './challenges'
export { assignmentsApi } from './assignments'

View File

@@ -0,0 +1,130 @@
import { Ban, LogOut, Calendar, Clock, AlertTriangle, Sparkles } from 'lucide-react'
import { useAuthStore } from '@/store/auth'
import { NeonButton } from '@/components/ui'
interface BanInfo {
banned_at: string | null
banned_until: string | null
reason: string | null
}
interface BannedScreenProps {
banInfo: BanInfo
}
function formatDate(dateStr: string | null) {
if (!dateStr) return null
return new Date(dateStr).toLocaleString('ru-RU', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Moscow',
}) + ' (МСК)'
}
export function BannedScreen({ banInfo }: BannedScreenProps) {
const logout = useAuthStore((state) => state.logout)
const bannedAtFormatted = formatDate(banInfo.banned_at)
const bannedUntilFormatted = formatDate(banInfo.banned_until)
return (
<div className="min-h-screen bg-dark-900 flex flex-col items-center justify-center text-center px-4">
{/* Background effects */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 -left-32 w-96 h-96 bg-red-500/5 rounded-full blur-[100px]" />
<div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-orange-500/5 rounded-full blur-[100px]" />
</div>
{/* Icon */}
<div className="relative mb-8">
<div className="w-32 h-32 rounded-3xl bg-dark-700/50 border-2 border-red-500/30 flex items-center justify-center shadow-[0_0_30px_rgba(239,68,68,0.2)]">
<Ban className="w-16 h-16 text-red-400" />
</div>
<div className="absolute -bottom-2 -right-2 w-12 h-12 rounded-xl bg-red-500/20 border border-red-500/40 flex items-center justify-center animate-pulse">
<AlertTriangle className="w-6 h-6 text-red-400" />
</div>
{/* Decorative dots */}
<div className="absolute -top-2 -left-2 w-3 h-3 rounded-full bg-red-500/50 animate-pulse" />
<div className="absolute top-4 -right-4 w-2 h-2 rounded-full bg-orange-500/50 animate-pulse" style={{ animationDelay: '0.5s' }} />
</div>
{/* Title with glow */}
<div className="relative mb-4">
<h1 className="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-red-400 via-orange-400 to-red-400">
Аккаунт заблокирован
</h1>
<div className="absolute inset-0 text-4xl font-bold text-red-500/20 blur-xl">
Аккаунт заблокирован
</div>
</div>
<p className="text-gray-400 mb-8 max-w-md">
Ваш доступ к платформе был ограничен администрацией.
</p>
{/* Ban Info Card */}
<div className="glass rounded-2xl p-6 mb-8 max-w-md w-full border border-red-500/20 text-left space-y-4">
{bannedAtFormatted && (
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-dark-700/50">
<Calendar className="w-5 h-5 text-gray-500" />
</div>
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider">Дата блокировки</p>
<p className="text-white font-medium">{bannedAtFormatted}</p>
</div>
</div>
)}
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-dark-700/50">
<Clock className="w-5 h-5 text-gray-500" />
</div>
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider">Срок</p>
<p className={`font-medium ${bannedUntilFormatted ? 'text-amber-400' : 'text-red-400'}`}>
{bannedUntilFormatted ? `до ${bannedUntilFormatted}` : 'Навсегда'}
</p>
</div>
</div>
{banInfo.reason && (
<div className="pt-4 border-t border-dark-600">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Причина</p>
<p className="text-white bg-dark-700/50 rounded-xl p-4 border border-dark-600">
{banInfo.reason}
</p>
</div>
)}
</div>
{/* Info text */}
<p className="text-gray-500 text-sm mb-8 max-w-md">
{banInfo.banned_until
? 'Ваш аккаунт будет автоматически разблокирован по истечении срока.'
: 'Если вы считаете, что блокировка ошибочна, обратитесь к администрации.'}
</p>
{/* Logout button */}
<NeonButton
variant="secondary"
size="lg"
onClick={logout}
icon={<LogOut className="w-5 h-5" />}
>
Выйти из аккаунта
</NeonButton>
{/* Decorative sparkles */}
<div className="absolute top-1/4 left-1/4 opacity-20">
<Sparkles className="w-6 h-6 text-red-400 animate-pulse" />
</div>
<div className="absolute bottom-1/3 right-1/4 opacity-20">
<Sparkles className="w-4 h-4 text-orange-400 animate-pulse" style={{ animationDelay: '1s' }} />
</div>
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { Gamepad2, LogOut, Trophy, User, Menu, X } from 'lucide-react'
import { Gamepad2, LogOut, Trophy, User, Menu, X, Shield } from 'lucide-react'
import { TelegramLink } from '@/components/TelegramLink'
import { clsx } from 'clsx'
@@ -74,6 +74,21 @@ export function Layout() {
<span>Марафоны</span>
</Link>
{user?.role === 'admin' && (
<Link
to="/admin"
className={clsx(
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200',
location.pathname.startsWith('/admin')
? 'text-purple-400 bg-purple-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
>
<Shield className="w-5 h-5" />
<span>Админка</span>
</Link>
)}
<div className="flex items-center gap-3 ml-2 pl-4 border-l border-dark-600">
<Link
to="/profile"
@@ -144,6 +159,20 @@ export function Layout() {
<Trophy className="w-5 h-5" />
<span>Марафоны</span>
</Link>
{user?.role === 'admin' && (
<Link
to="/admin"
className={clsx(
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
location.pathname.startsWith('/admin')
? 'text-purple-400 bg-purple-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
>
<Shield className="w-5 h-5" />
<span>Админка</span>
</Link>
)}
<Link
to="/profile"
className={clsx(

View File

@@ -61,6 +61,27 @@ export function LobbyPage() {
})
const [isCreatingChallenge, setIsCreatingChallenge] = useState(false)
// Edit challenge
const [editingChallengeId, setEditingChallengeId] = useState<number | null>(null)
const [editChallenge, setEditChallenge] = useState({
title: '',
description: '',
type: 'completion',
difficulty: 'medium',
points: 50,
estimated_time: 30,
proof_type: 'screenshot',
proof_hint: '',
})
const [isUpdatingChallenge, setIsUpdatingChallenge] = useState(false)
// Proposed challenges
const [proposedChallenges, setProposedChallenges] = useState<Challenge[]>([])
const [myProposedChallenges, setMyProposedChallenges] = useState<Challenge[]>([])
const [approvingChallengeId, setApprovingChallengeId] = useState<number | null>(null)
const [isProposingChallenge, setIsProposingChallenge] = useState(false)
const [editingProposedId, setEditingProposedId] = useState<number | null>(null)
// Start marathon
const [isStarting, setIsStarting] = useState(false)
@@ -84,6 +105,23 @@ export function LobbyPage() {
} catch {
setPendingGames([])
}
// Load proposed challenges for organizers
try {
const proposed = await gamesApi.getProposedChallenges(parseInt(id))
setProposedChallenges(proposed)
} catch {
setProposedChallenges([])
}
}
// Load my proposed challenges for all participants
if (marathonData.my_participation) {
try {
const myProposed = await gamesApi.getMyProposedChallenges(parseInt(id))
setMyProposedChallenges(myProposed)
} catch {
setMyProposedChallenges([])
}
}
} catch (error) {
console.error('Failed to load data:', error)
@@ -249,6 +287,206 @@ export function LobbyPage() {
}
}
const handleStartEditChallenge = (challenge: Challenge) => {
setEditingChallengeId(challenge.id)
setEditChallenge({
title: challenge.title,
description: challenge.description,
type: challenge.type,
difficulty: challenge.difficulty,
points: challenge.points,
estimated_time: challenge.estimated_time || 30,
proof_type: challenge.proof_type,
proof_hint: challenge.proof_hint || '',
})
}
const handleUpdateChallenge = async (challengeId: number, gameId: number) => {
if (!editChallenge.title.trim() || !editChallenge.description.trim()) {
toast.warning('Заполните название и описание')
return
}
setIsUpdatingChallenge(true)
try {
await gamesApi.updateChallenge(challengeId, {
title: editChallenge.title.trim(),
description: editChallenge.description.trim(),
type: editChallenge.type,
difficulty: editChallenge.difficulty,
points: editChallenge.points,
estimated_time: editChallenge.estimated_time || undefined,
proof_type: editChallenge.proof_type,
proof_hint: editChallenge.proof_hint.trim() || undefined,
})
toast.success('Задание обновлено')
setEditingChallengeId(null)
const challenges = await gamesApi.getChallenges(gameId)
setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось обновить задание')
} finally {
setIsUpdatingChallenge(false)
}
}
const loadProposedChallenges = async () => {
if (!id) return
try {
const proposed = await gamesApi.getProposedChallenges(parseInt(id))
setProposedChallenges(proposed)
} catch (error) {
console.error('Failed to load proposed challenges:', error)
}
}
const handleApproveChallenge = async (challengeId: number) => {
setApprovingChallengeId(challengeId)
try {
await gamesApi.approveChallenge(challengeId)
toast.success('Задание одобрено')
await loadProposedChallenges()
// Reload challenges for the game
const challenge = proposedChallenges.find(c => c.id === challengeId)
if (challenge) {
const challenges = await gamesApi.getChallenges(challenge.game.id)
setGameChallenges(prev => ({ ...prev, [challenge.game.id]: challenges }))
}
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось одобрить задание')
} finally {
setApprovingChallengeId(null)
}
}
const handleRejectChallenge = async (challengeId: number) => {
const confirmed = await confirm({
title: 'Отклонить задание?',
message: 'Задание будет удалено.',
confirmText: 'Отклонить',
cancelText: 'Отмена',
variant: 'danger',
})
if (!confirmed) return
setApprovingChallengeId(challengeId)
try {
await gamesApi.rejectChallenge(challengeId)
toast.success('Задание отклонено')
await loadProposedChallenges()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось отклонить задание')
} finally {
setApprovingChallengeId(null)
}
}
const handleStartEditProposed = (challenge: Challenge) => {
setEditingProposedId(challenge.id)
setEditChallenge({
title: challenge.title,
description: challenge.description,
type: challenge.type,
difficulty: challenge.difficulty,
points: challenge.points,
estimated_time: challenge.estimated_time || 30,
proof_type: challenge.proof_type,
proof_hint: challenge.proof_hint || '',
})
}
const handleUpdateProposedChallenge = async (challengeId: number) => {
if (!editChallenge.title.trim() || !editChallenge.description.trim()) {
toast.warning('Заполните название и описание')
return
}
setIsUpdatingChallenge(true)
try {
await gamesApi.updateChallenge(challengeId, {
title: editChallenge.title.trim(),
description: editChallenge.description.trim(),
type: editChallenge.type,
difficulty: editChallenge.difficulty,
points: editChallenge.points,
estimated_time: editChallenge.estimated_time || undefined,
proof_type: editChallenge.proof_type,
proof_hint: editChallenge.proof_hint.trim() || undefined,
})
toast.success('Задание обновлено')
setEditingProposedId(null)
await loadProposedChallenges()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось обновить задание')
} finally {
setIsUpdatingChallenge(false)
}
}
const handleProposeChallenge = async (gameId: number) => {
if (!newChallenge.title.trim() || !newChallenge.description.trim()) {
toast.warning('Заполните название и описание')
return
}
setIsProposingChallenge(true)
try {
await gamesApi.proposeChallenge(gameId, {
title: newChallenge.title.trim(),
description: newChallenge.description.trim(),
type: newChallenge.type,
difficulty: newChallenge.difficulty,
points: newChallenge.points,
estimated_time: newChallenge.estimated_time || undefined,
proof_type: newChallenge.proof_type,
proof_hint: newChallenge.proof_hint.trim() || undefined,
})
toast.success('Задание предложено на модерацию')
setNewChallenge({
title: '',
description: '',
type: 'completion',
difficulty: 'medium',
points: 50,
estimated_time: 30,
proof_type: 'screenshot',
proof_hint: '',
})
setAddingChallengeToGameId(null)
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось предложить задание')
} finally {
setIsProposingChallenge(false)
}
}
const handleDeleteMyProposedChallenge = async (challengeId: number) => {
const confirmed = await confirm({
title: 'Удалить предложение?',
message: 'Предложенное задание будет удалено.',
confirmText: 'Удалить',
cancelText: 'Отмена',
variant: 'danger',
})
if (!confirmed) return
try {
await gamesApi.deleteChallenge(challengeId)
toast.success('Предложение удалено')
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось удалить предложение')
}
}
const handleGenerateChallenges = async () => {
if (!id) return
@@ -476,41 +714,156 @@ export function LobbyPage() {
</div>
) : (
<>
{gameChallenges[game.id]?.length > 0 ? (
gameChallenges[game.id].map((challenge) => (
{(() => {
// For organizers: hide pending challenges (they see them in separate block)
// For regular users: hide their own pending/rejected challenges (they see them in "My proposals")
// but show their own approved challenges in both places
const visibleChallenges = isOrganizer
? gameChallenges[game.id]?.filter(c => c.status !== 'pending') || []
: gameChallenges[game.id]?.filter(c =>
!(c.proposed_by?.id === user?.id && c.status !== 'approved')
) || []
return visibleChallenges.length > 0 ? (
visibleChallenges.map((challenge) => (
<div
key={challenge.id}
className="flex items-start justify-between gap-3 p-3 bg-dark-700/50 rounded-lg border border-dark-600"
className="p-3 bg-dark-700/50 rounded-lg border border-dark-600"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
'bg-red-500/20 text-red-400 border-red-500/30'
}`}>
{challenge.difficulty === 'easy' ? 'Легко' :
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
</span>
<span className="text-xs text-neon-400 font-semibold">
+{challenge.points}
</span>
{challenge.is_generated && (
<span className="text-xs text-gray-500 flex items-center gap-1">
<Sparkles className="w-3 h-3" /> ИИ
</span>
{editingChallengeId === challenge.id ? (
// Edit form
<div className="space-y-3">
<Input
placeholder="Название задания"
value={editChallenge.title}
onChange={(e) => setEditChallenge(prev => ({ ...prev, title: e.target.value }))}
/>
<textarea
placeholder="Описание"
value={editChallenge.description}
onChange={(e) => setEditChallenge(prev => ({ ...prev, description: e.target.value }))}
className="input w-full resize-none"
rows={2}
/>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-gray-400 mb-1 block">Тип</label>
<select
value={editChallenge.type}
onChange={(e) => setEditChallenge(prev => ({ ...prev, type: e.target.value }))}
className="input w-full"
>
<option value="completion">Прохождение</option>
<option value="no_death">Без смертей</option>
<option value="speedrun">Спидран</option>
<option value="collection">Коллекция</option>
<option value="achievement">Достижение</option>
<option value="challenge_run">Челлендж-ран</option>
</select>
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Сложность</label>
<select
value={editChallenge.difficulty}
onChange={(e) => setEditChallenge(prev => ({ ...prev, difficulty: e.target.value }))}
className="input w-full"
>
<option value="easy">Легко</option>
<option value="medium">Средне</option>
<option value="hard">Сложно</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-gray-400 mb-1 block">Очки</label>
<Input
type="number"
value={editChallenge.points}
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
min={1}
max={500}
/>
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
<select
value={editChallenge.proof_type}
onChange={(e) => setEditChallenge(prev => ({ ...prev, proof_type: e.target.value }))}
className="input w-full"
>
<option value="screenshot">Скриншот</option>
<option value="video">Видео</option>
<option value="steam">Steam</option>
</select>
</div>
</div>
<div className="flex gap-2">
<NeonButton
size="sm"
onClick={() => handleUpdateChallenge(challenge.id, game.id)}
isLoading={isUpdatingChallenge}
icon={<Check className="w-4 h-4" />}
>
Сохранить
</NeonButton>
<NeonButton
variant="outline"
size="sm"
onClick={() => setEditingChallengeId(null)}
>
Отмена
</NeonButton>
</div>
</div>
) : (
// Display challenge
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
{challenge.status === 'pending' && getStatusBadge('pending')}
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
'bg-red-500/20 text-red-400 border-red-500/30'
}`}>
{challenge.difficulty === 'easy' ? 'Легко' :
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
</span>
<span className="text-xs text-neon-400 font-semibold">
+{challenge.points}
</span>
{challenge.is_generated && (
<span className="text-xs text-gray-500 flex items-center gap-1">
<Sparkles className="w-3 h-3" /> ИИ
</span>
)}
{challenge.proposed_by && (
<span className="text-xs text-gray-500 flex items-center gap-1">
<User className="w-3 h-3" /> {challenge.proposed_by.nickname}
</span>
)}
</div>
<h5 className="text-sm font-medium text-white">{challenge.title}</h5>
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
</div>
{isOrganizer && (
<div className="flex gap-1 shrink-0">
<button
onClick={() => handleStartEditChallenge(challenge)}
className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
>
<Edit2 className="w-3 h-3" />
</button>
<button
onClick={() => handleDeleteChallenge(challenge.id, game.id)}
className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
)}
</div>
<h5 className="text-sm font-medium text-white">{challenge.title}</h5>
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
</div>
{isOrganizer && (
<button
onClick={() => handleDeleteChallenge(challenge.id, game.id)}
className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
))
@@ -518,15 +871,16 @@ export function LobbyPage() {
<p className="text-center text-gray-500 py-4 text-sm">
Нет заданий
</p>
)}
)
})()}
{/* Add challenge form */}
{isOrganizer && game.status === 'approved' && (
{/* Add/Propose challenge form */}
{game.status === 'approved' && (
addingChallengeToGameId === game.id ? (
<div className="mt-4 p-4 glass rounded-xl space-y-3 border border-neon-500/20">
<h4 className="font-semibold text-white text-sm flex items-center gap-2">
<Plus className="w-4 h-4 text-neon-400" />
Новое задание
{isOrganizer ? 'Новое задание' : 'Предложить задание'}
</h4>
<Input
placeholder="Название задания"
@@ -613,15 +967,27 @@ export function LobbyPage() {
</div>
</div>
<div className="flex gap-2">
<NeonButton
size="sm"
onClick={() => handleCreateChallenge(game.id)}
isLoading={isCreatingChallenge}
disabled={!newChallenge.title || !newChallenge.description}
icon={<Plus className="w-4 h-4" />}
>
Добавить
</NeonButton>
{isOrganizer ? (
<NeonButton
size="sm"
onClick={() => handleCreateChallenge(game.id)}
isLoading={isCreatingChallenge}
disabled={!newChallenge.title || !newChallenge.description}
icon={<Plus className="w-4 h-4" />}
>
Добавить
</NeonButton>
) : (
<NeonButton
size="sm"
onClick={() => handleProposeChallenge(game.id)}
isLoading={isProposingChallenge}
disabled={!newChallenge.title || !newChallenge.description}
icon={<Plus className="w-4 h-4" />}
>
Предложить
</NeonButton>
)}
<NeonButton
variant="outline"
size="sm"
@@ -630,6 +996,11 @@ export function LobbyPage() {
Отмена
</NeonButton>
</div>
{!isOrganizer && (
<p className="text-xs text-gray-500">
Задание будет отправлено на модерацию организаторам
</p>
)}
</div>
) : (
<button
@@ -640,7 +1011,7 @@ export function LobbyPage() {
className="w-full mt-2 p-3 rounded-lg border-2 border-dashed border-dark-600 text-gray-400 hover:text-neon-400 hover:border-neon-500/30 transition-all flex items-center justify-center gap-2"
>
<Plus className="w-4 h-4" />
Добавить задание вручную
{isOrganizer ? 'Добавить задание вручную' : 'Предложить задание'}
</button>
)
)}
@@ -721,6 +1092,233 @@ export function LobbyPage() {
</GlassCard>
)}
{/* Proposed challenges for moderation */}
{isOrganizer && proposedChallenges.length > 0 && (
<GlassCard className="mb-8 border-accent-500/30">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
<Sparkles className="w-5 h-5 text-accent-400" />
</div>
<div>
<h3 className="font-semibold text-accent-400">Предложенные задания</h3>
<p className="text-sm text-gray-400">{proposedChallenges.length} заданий ожидают</p>
</div>
</div>
<div className="space-y-3">
{proposedChallenges.map((challenge) => (
<div
key={challenge.id}
className="p-4 bg-dark-700/50 rounded-xl border border-dark-600"
>
{editingProposedId === challenge.id ? (
// Edit form
<div className="space-y-3">
<span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
{challenge.game.title}
</span>
<Input
placeholder="Название задания"
value={editChallenge.title}
onChange={(e) => setEditChallenge(prev => ({ ...prev, title: e.target.value }))}
/>
<textarea
placeholder="Описание"
value={editChallenge.description}
onChange={(e) => setEditChallenge(prev => ({ ...prev, description: e.target.value }))}
className="input w-full resize-none"
rows={2}
/>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-gray-400 mb-1 block">Тип</label>
<select
value={editChallenge.type}
onChange={(e) => setEditChallenge(prev => ({ ...prev, type: e.target.value }))}
className="input w-full"
>
<option value="completion">Прохождение</option>
<option value="no_death">Без смертей</option>
<option value="speedrun">Спидран</option>
<option value="collection">Коллекция</option>
<option value="achievement">Достижение</option>
<option value="challenge_run">Челлендж-ран</option>
</select>
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Сложность</label>
<select
value={editChallenge.difficulty}
onChange={(e) => setEditChallenge(prev => ({ ...prev, difficulty: e.target.value }))}
className="input w-full"
>
<option value="easy">Легко</option>
<option value="medium">Средне</option>
<option value="hard">Сложно</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-gray-400 mb-1 block">Очки</label>
<Input
type="number"
value={editChallenge.points}
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
min={1}
max={500}
/>
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
<select
value={editChallenge.proof_type}
onChange={(e) => setEditChallenge(prev => ({ ...prev, proof_type: e.target.value }))}
className="input w-full"
>
<option value="screenshot">Скриншот</option>
<option value="video">Видео</option>
<option value="steam">Steam</option>
</select>
</div>
</div>
<div className="flex gap-2">
<NeonButton
size="sm"
onClick={() => handleUpdateProposedChallenge(challenge.id)}
isLoading={isUpdatingChallenge}
icon={<Check className="w-4 h-4" />}
>
Сохранить
</NeonButton>
<NeonButton
variant="outline"
size="sm"
onClick={() => setEditingProposedId(null)}
>
Отмена
</NeonButton>
</div>
{challenge.proposed_by && (
<p className="text-xs text-gray-500 flex items-center gap-1">
<User className="w-3 h-3" /> Предложил: {challenge.proposed_by.nickname}
</p>
)}
</div>
) : (
// Display
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
{challenge.game.title}
</span>
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
'bg-red-500/20 text-red-400 border-red-500/30'
}`}>
{challenge.difficulty === 'easy' ? 'Легко' :
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
</span>
<span className="text-xs text-neon-400 font-semibold">
+{challenge.points}
</span>
</div>
<h4 className="font-medium text-white mb-1">{challenge.title}</h4>
<p className="text-sm text-gray-400 mb-2">{challenge.description}</p>
{challenge.proposed_by && (
<p className="text-xs text-gray-500 flex items-center gap-1">
<User className="w-3 h-3" /> Предложил: {challenge.proposed_by.nickname}
</p>
)}
</div>
<div className="flex gap-2 shrink-0">
<button
onClick={() => handleStartEditProposed(challenge)}
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleApproveChallenge(challenge.id)}
disabled={approvingChallengeId === challenge.id}
className="p-2 rounded-lg text-green-400 hover:bg-green-500/10 transition-colors disabled:opacity-50"
>
{approvingChallengeId === challenge.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CheckCircle className="w-4 h-4" />
)}
</button>
<button
onClick={() => handleRejectChallenge(challenge.id)}
disabled={approvingChallengeId === challenge.id}
className="p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-50"
>
<XCircle className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
))}
</div>
</GlassCard>
)}
{/* My proposed challenges (for non-organizers) */}
{!isOrganizer && myProposedChallenges.length > 0 && (
<GlassCard className="mb-8 border-neon-500/30">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center">
<Sparkles className="w-5 h-5 text-neon-400" />
</div>
<div>
<h3 className="font-semibold text-neon-400">Мои предложения</h3>
<p className="text-sm text-gray-400">{myProposedChallenges.length} заданий</p>
</div>
</div>
<div className="space-y-3">
{myProposedChallenges.map((challenge) => (
<div
key={challenge.id}
className="flex items-start justify-between gap-3 p-4 bg-dark-700/50 rounded-xl border border-dark-600"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
{challenge.game.title}
</span>
{getStatusBadge(challenge.status)}
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
'bg-red-500/20 text-red-400 border-red-500/30'
}`}>
{challenge.difficulty === 'easy' ? 'Легко' :
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
</span>
<span className="text-xs text-neon-400 font-semibold">
+{challenge.points}
</span>
</div>
<h4 className="font-medium text-white mb-1">{challenge.title}</h4>
<p className="text-sm text-gray-400">{challenge.description}</p>
</div>
{challenge.status === 'pending' && (
<button
onClick={() => handleDeleteMyProposedChallenge(challenge.id)}
className="p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
</GlassCard>
)}
{/* Generate challenges */}
{isOrganizer && approvedGames.length > 0 && !previewChallenges && (
<GlassCard className="mb-8">

View File

@@ -6,7 +6,7 @@ import { z } from 'zod'
import { useAuthStore } from '@/store/auth'
import { marathonsApi } from '@/api'
import { NeonButton, Input, GlassCard } from '@/components/ui'
import { Gamepad2, LogIn, AlertCircle, Trophy, Users, Zap, Target } from 'lucide-react'
import { Gamepad2, LogIn, AlertCircle, Trophy, Users, Zap, Target, Shield, ArrowLeft } from 'lucide-react'
const loginSchema = z.object({
login: z.string().min(3, 'Логин должен быть не менее 3 символов'),
@@ -17,8 +17,9 @@ type LoginForm = z.infer<typeof loginSchema>
export function LoginPage() {
const navigate = useNavigate()
const { login, isLoading, error, clearError, consumePendingInviteCode } = useAuthStore()
const { login, verify2FA, cancel2FA, pending2FA, isLoading, error, clearError, consumePendingInviteCode } = useAuthStore()
const [submitError, setSubmitError] = useState<string | null>(null)
const [twoFACode, setTwoFACode] = useState('')
const {
register,
@@ -32,7 +33,12 @@ export function LoginPage() {
setSubmitError(null)
clearError()
try {
await login(data)
const result = await login(data)
// If 2FA required, don't navigate
if (result.requires2FA) {
return
}
// Check for pending invite code
const pendingCode = consumePendingInviteCode()
@@ -52,6 +58,24 @@ export function LoginPage() {
}
}
const handle2FASubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitError(null)
clearError()
try {
await verify2FA(twoFACode)
navigate('/marathons')
} catch {
setSubmitError(error || 'Неверный код')
}
}
const handleCancel2FA = () => {
cancel2FA()
setTwoFACode('')
setSubmitError(null)
}
const features = [
{ icon: <Trophy className="w-5 h-5" />, text: 'Соревнуйтесь с друзьями' },
{ icon: <Target className="w-5 h-5" />, text: 'Выполняйте челленджи' },
@@ -113,61 +137,120 @@ export function LoginPage() {
{/* Form Block (right) */}
<GlassCard className="p-8">
{/* Header */}
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-white mb-2">Добро пожаловать!</h2>
<p className="text-gray-400">Войдите, чтобы продолжить</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{(submitError || error) && (
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span>{submitError || error}</span>
{pending2FA ? (
// 2FA Form
<>
{/* Header */}
<div className="text-center mb-8">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-neon-500/10 border border-neon-500/30 flex items-center justify-center">
<Shield className="w-8 h-8 text-neon-500" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">Двухфакторная аутентификация</h2>
<p className="text-gray-400">Введите код из Telegram</p>
</div>
)}
<Input
label="Логин"
placeholder="Введите логин"
error={errors.login?.message}
autoComplete="username"
{...register('login')}
/>
{/* 2FA Form */}
<form onSubmit={handle2FASubmit} className="space-y-5">
{(submitError || error) && (
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span>{submitError || error}</span>
</div>
)}
<Input
label="Пароль"
type="password"
placeholder="Введите пароль"
error={errors.password?.message}
autoComplete="current-password"
{...register('password')}
/>
<Input
label="Код подтверждения"
placeholder="000000"
value={twoFACode}
onChange={(e) => setTwoFACode(e.target.value.replace(/\D/g, '').slice(0, 6))}
maxLength={6}
className="text-center text-2xl tracking-widest font-mono"
autoFocus
/>
<NeonButton
type="submit"
className="w-full"
size="lg"
isLoading={isLoading}
icon={<LogIn className="w-5 h-5" />}
>
Войти
</NeonButton>
</form>
<NeonButton
type="submit"
className="w-full"
size="lg"
isLoading={isLoading}
disabled={twoFACode.length !== 6}
icon={<Shield className="w-5 h-5" />}
>
Подтвердить
</NeonButton>
</form>
{/* Footer */}
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
<p className="text-gray-400 text-sm">
Нет аккаунта?{' '}
<Link
to="/register"
className="text-neon-400 hover:text-neon-300 transition-colors font-medium"
>
Зарегистрироваться
</Link>
</p>
</div>
{/* Back button */}
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
<button
onClick={handleCancel2FA}
className="text-gray-400 hover:text-white transition-colors text-sm flex items-center justify-center gap-2 mx-auto"
>
<ArrowLeft className="w-4 h-4" />
Вернуться к входу
</button>
</div>
</>
) : (
// Regular Login Form
<>
{/* Header */}
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-white mb-2">Добро пожаловать!</h2>
<p className="text-gray-400">Войдите, чтобы продолжить</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{(submitError || error) && (
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span>{submitError || error}</span>
</div>
)}
<Input
label="Логин"
placeholder="Введите логин"
error={errors.login?.message}
autoComplete="username"
{...register('login')}
/>
<Input
label="Пароль"
type="password"
placeholder="Введите пароль"
error={errors.password?.message}
autoComplete="current-password"
{...register('password')}
/>
<NeonButton
type="submit"
className="w-full"
size="lg"
isLoading={isLoading}
icon={<LogIn className="w-5 h-5" />}
>
Войти
</NeonButton>
</form>
{/* Footer */}
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
<p className="text-gray-400 text-sm">
Нет аккаунта?{' '}
<Link
to="/register"
className="text-neon-400 hover:text-neon-300 transition-colors font-medium"
>
Зарегистрироваться
</Link>
</p>
</div>
</>
)}
</GlassCard>
</div>

View File

@@ -0,0 +1,190 @@
import { useState, useEffect } from 'react'
import { adminApi } from '@/api'
import type { AdminMarathon } from '@/types'
import { useToast } from '@/store/toast'
import { NeonButton } from '@/components/ui'
import { Send, Users, Trophy, AlertTriangle } from 'lucide-react'
export function AdminBroadcastPage() {
const [message, setMessage] = useState('')
const [targetType, setTargetType] = useState<'all' | 'marathon'>('all')
const [marathonId, setMarathonId] = useState<number | null>(null)
const [marathons, setMarathons] = useState<AdminMarathon[]>([])
const [sending, setSending] = useState(false)
const [loadingMarathons, setLoadingMarathons] = useState(false)
const toast = useToast()
useEffect(() => {
if (targetType === 'marathon') {
loadMarathons()
}
}, [targetType])
const loadMarathons = async () => {
setLoadingMarathons(true)
try {
const data = await adminApi.listMarathons(0, 100)
setMarathons(data.filter(m => m.status === 'active'))
} catch (err) {
console.error('Failed to load marathons:', err)
} finally {
setLoadingMarathons(false)
}
}
const handleSend = async () => {
if (!message.trim()) {
toast.error('Введите сообщение')
return
}
if (targetType === 'marathon' && !marathonId) {
toast.error('Выберите марафон')
return
}
setSending(true)
try {
let result
if (targetType === 'all') {
result = await adminApi.broadcastToAll(message)
} else {
result = await adminApi.broadcastToMarathon(marathonId!, message)
}
toast.success(`Отправлено ${result.sent_count} из ${result.total_count} сообщений`)
setMessage('')
} catch (err) {
console.error('Failed to send broadcast:', err)
toast.error('Ошибка отправки')
} finally {
setSending(false)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-pink-500/20 border border-pink-500/30">
<Send className="w-6 h-6 text-pink-400" />
</div>
<h1 className="text-2xl font-bold text-white">Рассылка уведомлений</h1>
</div>
<div className="max-w-2xl space-y-6">
{/* Target Selection */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-300">
Кому отправить
</label>
<div className="flex gap-4">
<button
onClick={() => {
setTargetType('all')
setMarathonId(null)
}}
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
targetType === 'all'
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
}`}
>
<Users className="w-5 h-5" />
<span className="font-medium">Всем пользователям</span>
</button>
<button
onClick={() => setTargetType('marathon')}
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
targetType === 'marathon'
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
}`}
>
<Trophy className="w-5 h-5" />
<span className="font-medium">Участникам марафона</span>
</button>
</div>
</div>
{/* Marathon Selection */}
{targetType === 'marathon' && (
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-300">
Выберите марафон
</label>
{loadingMarathons ? (
<div className="animate-pulse bg-dark-700 h-12 rounded-xl" />
) : (
<select
value={marathonId || ''}
onChange={(e) => setMarathonId(Number(e.target.value) || null)}
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-accent-500/50 transition-colors"
>
<option value="">Выберите марафон...</option>
{marathons.map((m) => (
<option key={m.id} value={m.id}>
{m.title} ({m.participants_count} участников)
</option>
))}
</select>
)}
{marathons.length === 0 && !loadingMarathons && (
<p className="text-sm text-gray-500">Нет активных марафонов</p>
)}
</div>
)}
{/* Message */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-300">
Сообщение
</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={6}
placeholder="Введите текст сообщения... (поддерживается HTML: <b>, <i>, <code>)"
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors resize-none"
/>
<div className="flex items-center justify-between text-xs">
<p className="text-gray-500">
Поддерживается HTML: &lt;b&gt;, &lt;i&gt;, &lt;code&gt;, &lt;a href&gt;
</p>
<p className={`${message.length > 2000 ? 'text-red-400' : 'text-gray-500'}`}>
{message.length} / 2000
</p>
</div>
</div>
{/* Send Button */}
<NeonButton
size="lg"
color="purple"
onClick={handleSend}
disabled={sending || !message.trim() || message.length > 2000 || (targetType === 'marathon' && !marathonId)}
isLoading={sending}
icon={<Send className="w-5 h-5" />}
className="w-full"
>
{sending ? 'Отправка...' : 'Отправить рассылку'}
</NeonButton>
{/* Warning */}
<div className="glass rounded-xl p-4 border border-amber-500/20">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-amber-400 font-medium mb-1">Обратите внимание</p>
<p className="text-sm text-gray-400">
Сообщение будет отправлено только пользователям с привязанным Telegram.
Рассылка ограничена: 1 сообщение всем в минуту, 3 сообщения марафону в минуту.
</p>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,261 @@
import { useState, useEffect } from 'react'
import { adminApi } from '@/api'
import type { StaticContent } from '@/types'
import { useToast } from '@/store/toast'
import { NeonButton } from '@/components/ui'
import { FileText, Plus, Pencil, X, Save, Code } from 'lucide-react'
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export function AdminContentPage() {
const [contents, setContents] = useState<StaticContent[]>([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState<StaticContent | null>(null)
const [creating, setCreating] = useState(false)
const [saving, setSaving] = useState(false)
// Form state
const [formKey, setFormKey] = useState('')
const [formTitle, setFormTitle] = useState('')
const [formContent, setFormContent] = useState('')
const toast = useToast()
useEffect(() => {
loadContents()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const loadContents = async () => {
try {
const data = await adminApi.listContent()
setContents(data)
} catch (err) {
console.error('Failed to load contents:', err)
toast.error('Ошибка загрузки контента')
} finally {
setLoading(false)
}
}
const handleEdit = (content: StaticContent) => {
setEditing(content)
setFormKey(content.key)
setFormTitle(content.title)
setFormContent(content.content)
setCreating(false)
}
const handleCreate = () => {
setCreating(true)
setEditing(null)
setFormKey('')
setFormTitle('')
setFormContent('')
}
const handleCancel = () => {
setEditing(null)
setCreating(false)
setFormKey('')
setFormTitle('')
setFormContent('')
}
const handleSave = async () => {
if (!formTitle.trim() || !formContent.trim()) {
toast.error('Заполните все поля')
return
}
if (creating && !formKey.trim()) {
toast.error('Введите ключ')
return
}
setSaving(true)
try {
if (creating) {
const newContent = await adminApi.createContent(formKey, formTitle, formContent)
setContents([...contents, newContent])
toast.success('Контент создан')
} else if (editing) {
const updated = await adminApi.updateContent(editing.key, formTitle, formContent)
setContents(contents.map(c => c.id === updated.id ? updated : c))
toast.success('Контент обновлён')
}
handleCancel()
} catch (err) {
console.error('Failed to save content:', err)
toast.error('Ошибка сохранения')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-accent-500 border-t-transparent" />
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-neon-500/20 border border-neon-500/30">
<FileText className="w-6 h-6 text-neon-400" />
</div>
<h1 className="text-2xl font-bold text-white">Статический контент</h1>
</div>
<NeonButton onClick={handleCreate} icon={<Plus className="w-4 h-4" />}>
Добавить
</NeonButton>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Content List */}
<div className="space-y-4">
{contents.length === 0 ? (
<div className="glass rounded-xl border border-dark-600 p-8 text-center">
<FileText className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">Нет статического контента</p>
<p className="text-sm text-gray-500 mt-1">Создайте первую страницу</p>
</div>
) : (
contents.map((content) => (
<div
key={content.id}
className={`glass rounded-xl border p-5 cursor-pointer transition-all duration-200 ${
editing?.id === content.id
? 'border-accent-500/50 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
: 'border-dark-600 hover:border-dark-500'
}`}
onClick={() => handleEdit(content)}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<Code className="w-4 h-4 text-neon-400" />
<p className="text-sm text-neon-400 font-mono">{content.key}</p>
</div>
<h3 className="text-lg font-medium text-white truncate">{content.title}</h3>
<p className="text-sm text-gray-400 mt-2 line-clamp-2">
{content.content.replace(/<[^>]*>/g, '').slice(0, 100)}...
</p>
</div>
<button
onClick={(e) => {
e.stopPropagation()
handleEdit(content)
}}
className="p-2 text-gray-400 hover:text-accent-400 hover:bg-accent-500/10 rounded-lg transition-colors ml-3"
>
<Pencil className="w-4 h-4" />
</button>
</div>
<p className="text-xs text-gray-500 mt-4 pt-3 border-t border-dark-600">
Обновлено: {formatDate(content.updated_at)}
</p>
</div>
))
)}
</div>
{/* Editor */}
{(editing || creating) && (
<div className="glass rounded-xl border border-dark-600 p-6 sticky top-6 h-fit">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
{creating ? (
<>
<Plus className="w-5 h-5 text-neon-400" />
Новый контент
</>
) : (
<>
<Pencil className="w-5 h-5 text-accent-400" />
Редактирование
</>
)}
</h2>
<button
onClick={handleCancel}
className="p-2 text-gray-400 hover:text-white hover:bg-dark-600/50 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
{creating && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Ключ
</label>
<input
type="text"
value={formKey}
onChange={(e) => setFormKey(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ''))}
placeholder="about-page"
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white font-mono placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
/>
<p className="text-xs text-gray-500 mt-1.5">
Только буквы, цифры, дефисы и подчеркивания
</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Заголовок
</label>
<input
type="text"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
placeholder="Заголовок страницы"
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Содержимое (HTML)
</label>
<textarea
value={formContent}
onChange={(e) => setFormContent(e.target.value)}
rows={14}
placeholder="<p>HTML контент...</p>"
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 font-mono text-sm resize-none transition-colors"
/>
</div>
<NeonButton
onClick={handleSave}
disabled={saving}
isLoading={saving}
icon={<Save className="w-4 h-4" />}
className="w-full"
>
{saving ? 'Сохранение...' : 'Сохранить'}
</NeonButton>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,207 @@
import { useState, useEffect } from 'react'
import { adminApi } from '@/api'
import type { DashboardStats } from '@/types'
import { Users, Trophy, Gamepad2, UserCheck, Ban, Activity, TrendingUp } from 'lucide-react'
const ACTION_LABELS: Record<string, string> = {
user_ban: 'Бан пользователя',
user_unban: 'Разбан пользователя',
user_role_change: 'Изменение роли',
marathon_force_finish: 'Принудительное завершение',
marathon_delete: 'Удаление марафона',
content_update: 'Обновление контента',
broadcast_all: 'Рассылка всем',
broadcast_marathon: 'Рассылка марафону',
admin_login: 'Вход админа',
admin_2fa_success: '2FA успех',
admin_2fa_fail: '2FA неудача',
}
const ACTION_COLORS: Record<string, string> = {
user_ban: 'text-red-400',
user_unban: 'text-green-400',
user_role_change: 'text-accent-400',
marathon_force_finish: 'text-orange-400',
marathon_delete: 'text-red-400',
content_update: 'text-neon-400',
broadcast_all: 'text-pink-400',
broadcast_marathon: 'text-pink-400',
admin_login: 'text-blue-400',
admin_2fa_success: 'text-green-400',
admin_2fa_fail: 'text-red-400',
}
function StatCard({
icon: Icon,
label,
value,
gradient,
glowColor
}: {
icon: typeof Users
label: string
value: number
gradient: string
glowColor: string
}) {
return (
<div className={`glass rounded-xl p-5 border border-dark-600 hover:border-dark-500 transition-all duration-300 hover:shadow-[0_0_20px_${glowColor}]`}>
<div className="flex items-center gap-4">
<div className={`p-3 rounded-xl ${gradient} shadow-lg`}>
<Icon className="w-6 h-6 text-white" />
</div>
<div>
<p className="text-sm text-gray-400">{label}</p>
<p className="text-2xl font-bold text-white">{value.toLocaleString()}</p>
</div>
</div>
</div>
)
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export function AdminDashboardPage() {
const [stats, setStats] = useState<DashboardStats | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
loadDashboard()
}, [])
const loadDashboard = async () => {
try {
const data = await adminApi.getDashboard()
setStats(data)
} catch (err) {
console.error('Failed to load dashboard:', err)
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-accent-500 border-t-transparent" />
</div>
)
}
if (!stats) {
return (
<div className="text-center text-gray-400 py-12">
Не удалось загрузить данные
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-accent-500/20 border border-accent-500/30">
<TrendingUp className="w-6 h-6 text-accent-400" />
</div>
<h1 className="text-2xl font-bold text-white">Дашборд</h1>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<StatCard
icon={Users}
label="Всего пользователей"
value={stats.users_count}
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
glowColor="rgba(59,130,246,0.15)"
/>
<StatCard
icon={Ban}
label="Заблокировано"
value={stats.banned_users_count}
gradient="bg-gradient-to-br from-red-500 to-red-600"
glowColor="rgba(239,68,68,0.15)"
/>
<StatCard
icon={Trophy}
label="Всего марафонов"
value={stats.marathons_count}
gradient="bg-gradient-to-br from-accent-500 to-pink-500"
glowColor="rgba(139,92,246,0.15)"
/>
<StatCard
icon={Activity}
label="Активных марафонов"
value={stats.active_marathons_count}
gradient="bg-gradient-to-br from-green-500 to-emerald-600"
glowColor="rgba(34,197,94,0.15)"
/>
<StatCard
icon={Gamepad2}
label="Всего игр"
value={stats.games_count}
gradient="bg-gradient-to-br from-orange-500 to-amber-500"
glowColor="rgba(249,115,22,0.15)"
/>
<StatCard
icon={UserCheck}
label="Участий в марафонах"
value={stats.total_participations}
gradient="bg-gradient-to-br from-neon-500 to-cyan-500"
glowColor="rgba(34,211,238,0.15)"
/>
</div>
{/* Recent Logs */}
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
<div className="p-4 border-b border-dark-600">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Activity className="w-5 h-5 text-accent-400" />
Последние действия
</h2>
</div>
<div className="p-4">
{stats.recent_logs.length === 0 ? (
<p className="text-gray-400 text-center py-4">Нет записей</p>
) : (
<div className="space-y-3">
{stats.recent_logs.map((log) => (
<div
key={log.id}
className="flex items-start justify-between p-4 bg-dark-700/50 hover:bg-dark-700 rounded-xl border border-dark-600 transition-colors"
>
<div>
<p className={`font-medium ${ACTION_COLORS[log.action] || 'text-white'}`}>
{ACTION_LABELS[log.action] || log.action}
</p>
<p className="text-sm text-gray-400 mt-1">
<span className="text-gray-500">Админ:</span> {log.admin_nickname}
<span className="text-gray-600 mx-2"></span>
<span className="text-gray-500">{log.target_type}</span> #{log.target_id}
</p>
{log.details && (
<p className="text-xs text-gray-500 mt-2 font-mono bg-dark-800 rounded px-2 py-1 inline-block">
{JSON.stringify(log.details)}
</p>
)}
</div>
<span className="text-xs text-gray-500 whitespace-nowrap ml-4">
{formatDate(log.created_at)}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,169 @@
import { Outlet, NavLink, Navigate, Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { NeonButton } from '@/components/ui'
import {
LayoutDashboard,
Users,
Trophy,
ScrollText,
Send,
FileText,
ArrowLeft,
Shield,
MessageCircle,
Sparkles,
Lock
} from 'lucide-react'
const navItems = [
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' },
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
{ to: '/admin/content', icon: FileText, label: 'Контент' },
]
export function AdminLayout() {
const user = useAuthStore((state) => state.user)
// Only admins can access
if (!user || user.role !== 'admin') {
return <Navigate to="/" replace />
}
// Admin without Telegram - show warning
if (!user.telegram_id) {
return (
<div className="min-h-[70vh] flex flex-col items-center justify-center text-center px-4">
{/* Background effects */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/3 -left-32 w-96 h-96 bg-amber-500/5 rounded-full blur-[100px]" />
<div className="absolute bottom-1/3 -right-32 w-96 h-96 bg-accent-500/5 rounded-full blur-[100px]" />
</div>
{/* Icon */}
<div className="relative mb-8 animate-float">
<div className="w-32 h-32 rounded-3xl bg-dark-700/50 border border-amber-500/30 flex items-center justify-center shadow-[0_0_30px_rgba(245,158,11,0.15)]">
<Lock className="w-16 h-16 text-amber-400" />
</div>
<div className="absolute -bottom-2 -right-2 w-12 h-12 rounded-xl bg-accent-500/20 border border-accent-500/30 flex items-center justify-center">
<Shield className="w-6 h-6 text-accent-400" />
</div>
{/* Decorative dots */}
<div className="absolute -top-2 -left-2 w-3 h-3 rounded-full bg-amber-500/50 animate-pulse" />
<div className="absolute top-4 -right-4 w-2 h-2 rounded-full bg-accent-500/50 animate-pulse" style={{ animationDelay: '0.5s' }} />
</div>
{/* Title with glow */}
<div className="relative mb-4">
<h1 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 via-orange-400 to-accent-400">
Требуется Telegram
</h1>
<div className="absolute inset-0 text-3xl font-bold text-amber-500/20 blur-xl">
Требуется Telegram
</div>
</div>
<p className="text-gray-400 mb-2 max-w-md">
Для доступа к админ-панели необходимо привязать Telegram-аккаунт.
</p>
<p className="text-gray-500 text-sm mb-8 max-w-md">
Это требуется для двухфакторной аутентификации при входе.
</p>
{/* Info card */}
<div className="glass rounded-xl p-4 mb-8 max-w-md border border-amber-500/20">
<div className="flex items-center gap-2 text-amber-400 mb-2">
<Shield className="w-4 h-4" />
<span className="text-sm font-semibold">Двухфакторная аутентификация</span>
</div>
<p className="text-gray-400 text-sm">
После привязки Telegram при входе в админ-панель вам будет отправляться код подтверждения.
</p>
</div>
{/* Buttons */}
<div className="flex gap-4">
<Link to="/profile">
<NeonButton size="lg" color="purple" icon={<MessageCircle className="w-5 h-5" />}>
Привязать Telegram
</NeonButton>
</Link>
<Link to="/marathons">
<NeonButton size="lg" variant="secondary" icon={<ArrowLeft className="w-5 h-5" />}>
На сайт
</NeonButton>
</Link>
</div>
{/* Decorative sparkles */}
<div className="absolute top-1/4 left-1/4 opacity-20">
<Sparkles className="w-6 h-6 text-amber-400 animate-pulse" />
</div>
<div className="absolute bottom-1/3 right-1/4 opacity-20">
<Sparkles className="w-4 h-4 text-accent-400 animate-pulse" style={{ animationDelay: '1s' }} />
</div>
</div>
)
}
return (
<div className="flex h-full min-h-[calc(100vh-64px)]">
{/* Background effects */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-0 -left-32 w-96 h-96 bg-accent-500/5 rounded-full blur-[100px]" />
<div className="absolute bottom-0 -right-32 w-96 h-96 bg-neon-500/5 rounded-full blur-[100px]" />
</div>
{/* Sidebar */}
<aside className="w-64 glass border-r border-dark-600 flex flex-col relative z-10">
<div className="p-4 border-b border-dark-600">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-accent-500 to-pink-500 flex items-center justify-center">
<Shield className="w-4 h-4 text-white" />
</div>
<h2 className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-accent-400 to-pink-400">
Админ-панель
</h2>
</div>
</div>
<nav className="flex-1 p-4 space-y-1">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 ${
isActive
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30 shadow-[0_0_10px_rgba(139,92,246,0.15)]'
: 'text-gray-400 hover:bg-dark-600/50 hover:text-white border border-transparent'
}`
}
>
<item.icon className="w-5 h-5" />
<span className="font-medium">{item.label}</span>
</NavLink>
))}
</nav>
<div className="p-4 border-t border-dark-600">
<NavLink
to="/marathons"
className="flex items-center gap-3 px-3 py-2.5 text-gray-400 hover:text-neon-400 transition-colors rounded-lg hover:bg-dark-600/50"
>
<ArrowLeft className="w-5 h-5" />
<span className="font-medium">Вернуться на сайт</span>
</NavLink>
</div>
</aside>
{/* Main content */}
<main className="flex-1 p-6 overflow-auto relative z-10">
<Outlet />
</main>
</div>
)
}

View File

@@ -0,0 +1,208 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from '@/api'
import type { AdminLog } from '@/types'
import { useToast } from '@/store/toast'
import { ChevronLeft, ChevronRight, Filter, ScrollText } from 'lucide-react'
const ACTION_LABELS: Record<string, string> = {
user_ban: 'Бан пользователя',
user_unban: 'Разбан пользователя',
user_auto_unban: 'Авто-разбан (система)',
user_role_change: 'Изменение роли',
marathon_force_finish: 'Принудительное завершение',
marathon_delete: 'Удаление марафона',
content_update: 'Обновление контента',
broadcast_all: 'Рассылка всем',
broadcast_marathon: 'Рассылка марафону',
admin_login: 'Вход админа',
admin_2fa_success: '2FA успех',
admin_2fa_fail: '2FA неудача',
}
const ACTION_COLORS: Record<string, string> = {
user_ban: 'bg-red-500/20 text-red-400 border border-red-500/30',
user_unban: 'bg-green-500/20 text-green-400 border border-green-500/30',
user_auto_unban: 'bg-cyan-500/20 text-cyan-400 border border-cyan-500/30',
user_role_change: 'bg-accent-500/20 text-accent-400 border border-accent-500/30',
marathon_force_finish: 'bg-orange-500/20 text-orange-400 border border-orange-500/30',
marathon_delete: 'bg-red-500/20 text-red-400 border border-red-500/30',
content_update: 'bg-neon-500/20 text-neon-400 border border-neon-500/30',
broadcast_all: 'bg-pink-500/20 text-pink-400 border border-pink-500/30',
broadcast_marathon: 'bg-pink-500/20 text-pink-400 border border-pink-500/30',
admin_login: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
admin_2fa_success: 'bg-green-500/20 text-green-400 border border-green-500/30',
admin_2fa_fail: 'bg-red-500/20 text-red-400 border border-red-500/30',
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
export function AdminLogsPage() {
const [logs, setLogs] = useState<AdminLog[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [actionFilter, setActionFilter] = useState<string>('')
const [page, setPage] = useState(0)
const toast = useToast()
const LIMIT = 30
const loadLogs = useCallback(async () => {
setLoading(true)
try {
const data = await adminApi.getLogs(page * LIMIT, LIMIT, actionFilter || undefined)
setLogs(data.logs)
setTotal(data.total)
} catch (err) {
console.error('Failed to load logs:', err)
toast.error('Ошибка загрузки логов')
} finally {
setLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, actionFilter])
useEffect(() => {
loadLogs()
}, [loadLogs])
const totalPages = Math.ceil(total / LIMIT)
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-orange-500/20 border border-orange-500/30">
<ScrollText className="w-6 h-6 text-orange-400" />
</div>
<h1 className="text-2xl font-bold text-white">Логи действий</h1>
</div>
<span className="text-sm text-gray-400 bg-dark-700/50 px-3 py-1.5 rounded-lg border border-dark-600">
Всего: <span className="text-white font-medium">{total}</span> записей
</span>
</div>
{/* Filters */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<Filter className="w-5 h-5 text-gray-500" />
<select
value={actionFilter}
onChange={(e) => {
setActionFilter(e.target.value)
setPage(0)
}}
className="bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white focus:outline-none focus:border-accent-500/50 transition-colors min-w-[200px]"
>
<option value="">Все действия</option>
{Object.entries(ACTION_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
</div>
{/* Logs Table */}
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-dark-700/50 border-b border-dark-600">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Дата</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Админ</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действие</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Цель</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Детали</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">IP</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-600">
{loading ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
</td>
</tr>
) : logs.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
Логи не найдены
</td>
</tr>
) : (
logs.map((log) => (
<tr key={log.id} className="hover:bg-dark-700/30 transition-colors">
<td className="px-4 py-3 text-sm text-gray-400 whitespace-nowrap font-mono">
{formatDate(log.created_at)}
</td>
<td className="px-4 py-3 text-sm font-medium">
{log.admin_nickname ? (
<span className="text-white">{log.admin_nickname}</span>
) : (
<span className="text-cyan-400 italic">Система</span>
)}
</td>
<td className="px-4 py-3">
<span className={`inline-flex px-2.5 py-1 text-xs font-medium rounded-lg ${ACTION_COLORS[log.action] || 'bg-dark-600/50 text-gray-400 border border-dark-500'}`}>
{ACTION_LABELS[log.action] || log.action}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-400">
<span className="text-gray-500">{log.target_type}</span>
<span className="text-neon-400 font-mono ml-1">#{log.target_id}</span>
</td>
<td className="px-4 py-3 text-sm text-gray-500 max-w-xs">
{log.details ? (
<span className="font-mono text-xs bg-dark-700/50 px-2 py-1 rounded truncate block">
{JSON.stringify(log.details)}
</span>
) : (
<span className="text-gray-600"></span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-500 font-mono">
{log.ip_address || '—'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t border-dark-600">
<button
onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
>
<ChevronLeft className="w-4 h-4" />
Назад
</button>
<span className="text-sm text-gray-500">
Страница <span className="text-white font-medium">{page + 1}</span> из <span className="text-white font-medium">{totalPages || 1}</span>
</span>
<button
onClick={() => setPage(page + 1)}
disabled={page >= totalPages - 1}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
>
Вперед
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,242 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from '@/api'
import type { AdminMarathon } from '@/types'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
import { NeonButton } from '@/components/ui'
import { Search, Trash2, StopCircle, ChevronLeft, ChevronRight, Trophy, Clock, CheckCircle, Loader2 } from 'lucide-react'
const STATUS_CONFIG: Record<string, { label: string; icon: typeof Clock; className: string }> = {
preparing: {
label: 'Подготовка',
icon: Loader2,
className: 'bg-amber-500/20 text-amber-400 border border-amber-500/30'
},
active: {
label: 'Активный',
icon: Clock,
className: 'bg-green-500/20 text-green-400 border border-green-500/30'
},
finished: {
label: 'Завершён',
icon: CheckCircle,
className: 'bg-dark-600/50 text-gray-400 border border-dark-500'
},
}
function formatDate(dateStr: string | null) {
if (!dateStr) return '—'
return new Date(dateStr).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
export function AdminMarathonsPage() {
const [marathons, setMarathons] = useState<AdminMarathon[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [page, setPage] = useState(0)
const toast = useToast()
const confirm = useConfirm()
const LIMIT = 20
const loadMarathons = useCallback(async () => {
setLoading(true)
try {
const data = await adminApi.listMarathons(page * LIMIT, LIMIT, search || undefined)
setMarathons(data)
} catch (err) {
console.error('Failed to load marathons:', err)
toast.error('Ошибка загрузки марафонов')
} finally {
setLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, search])
useEffect(() => {
loadMarathons()
}, [loadMarathons])
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
setPage(0)
loadMarathons()
}
const handleDelete = async (marathon: AdminMarathon) => {
const confirmed = await confirm({
title: 'Удалить марафон',
message: `Вы уверены, что хотите удалить марафон "${marathon.title}"? Это действие необратимо.`,
confirmText: 'Удалить',
variant: 'danger',
})
if (!confirmed) return
try {
await adminApi.deleteMarathon(marathon.id)
setMarathons(marathons.filter(m => m.id !== marathon.id))
toast.success('Марафон удалён')
} catch (err) {
console.error('Failed to delete marathon:', err)
toast.error('Ошибка удаления')
}
}
const handleForceFinish = async (marathon: AdminMarathon) => {
const confirmed = await confirm({
title: 'Завершить марафон',
message: `Принудительно завершить марафон "${marathon.title}"? Участники получат уведомление.`,
confirmText: 'Завершить',
variant: 'warning',
})
if (!confirmed) return
try {
await adminApi.forceFinishMarathon(marathon.id)
setMarathons(marathons.map(m =>
m.id === marathon.id ? { ...m, status: 'finished' } : m
))
toast.success('Марафон завершён')
} catch (err) {
console.error('Failed to finish marathon:', err)
toast.error('Ошибка завершения')
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-accent-500/20 border border-accent-500/30">
<Trophy className="w-6 h-6 text-accent-400" />
</div>
<h1 className="text-2xl font-bold text-white">Марафоны</h1>
</div>
{/* Search */}
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<input
type="text"
placeholder="Поиск по названию..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl pl-10 pr-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/20 transition-all"
/>
</div>
<NeonButton type="submit" color="purple">
Найти
</NeonButton>
</form>
{/* Marathons Table */}
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-dark-700/50 border-b border-dark-600">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">ID</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Название</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Создатель</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Участники</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Игры</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Даты</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действия</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-600">
{loading ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
</td>
</tr>
) : marathons.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
Марафоны не найдены
</td>
</tr>
) : (
marathons.map((marathon) => {
const statusConfig = STATUS_CONFIG[marathon.status] || STATUS_CONFIG.finished
const StatusIcon = statusConfig.icon
return (
<tr key={marathon.id} className="hover:bg-dark-700/30 transition-colors">
<td className="px-4 py-3 text-sm text-gray-400 font-mono">{marathon.id}</td>
<td className="px-4 py-3 text-sm text-white font-medium">{marathon.title}</td>
<td className="px-4 py-3 text-sm text-gray-300">{marathon.creator.nickname}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg ${statusConfig.className}`}>
<StatusIcon className={`w-3 h-3 ${marathon.status === 'preparing' ? 'animate-spin' : ''}`} />
{statusConfig.label}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-400">{marathon.participants_count}</td>
<td className="px-4 py-3 text-sm text-gray-400">{marathon.games_count}</td>
<td className="px-4 py-3 text-sm text-gray-400">
<span className="text-gray-500">{formatDate(marathon.start_date)}</span>
<span className="text-gray-600 mx-1"></span>
<span className="text-gray-500">{formatDate(marathon.end_date)}</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
{marathon.status !== 'finished' && (
<button
onClick={() => handleForceFinish(marathon)}
className="p-2 text-orange-400 hover:bg-orange-500/20 rounded-lg transition-colors"
title="Завершить марафон"
>
<StopCircle className="w-4 h-4" />
</button>
)}
<button
onClick={() => handleDelete(marathon)}
className="p-2 text-red-400 hover:bg-red-500/20 rounded-lg transition-colors"
title="Удалить"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t border-dark-600">
<button
onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
>
<ChevronLeft className="w-4 h-4" />
Назад
</button>
<span className="text-sm text-gray-500">
Страница <span className="text-white font-medium">{page + 1}</span>
</span>
<button
onClick={() => setPage(page + 1)}
disabled={marathons.length < LIMIT}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
>
Вперед
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,398 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from '@/api'
import type { AdminUser, UserRole } from '@/types'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
import { NeonButton } from '@/components/ui'
import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X } from 'lucide-react'
export function AdminUsersPage() {
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [bannedOnly, setBannedOnly] = useState(false)
const [page, setPage] = useState(0)
const [banModalUser, setBanModalUser] = useState<AdminUser | null>(null)
const [banReason, setBanReason] = useState('')
const [banDuration, setBanDuration] = useState<string>('permanent')
const [banCustomDate, setBanCustomDate] = useState('')
const [banning, setBanning] = useState(false)
const toast = useToast()
const confirm = useConfirm()
const LIMIT = 20
const loadUsers = useCallback(async () => {
setLoading(true)
try {
const data = await adminApi.listUsers(page * LIMIT, LIMIT, search || undefined, bannedOnly)
setUsers(data)
} catch (err) {
console.error('Failed to load users:', err)
toast.error('Ошибка загрузки пользователей')
} finally {
setLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, search, bannedOnly])
useEffect(() => {
loadUsers()
}, [loadUsers])
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
setPage(0)
loadUsers()
}
const handleBan = async () => {
if (!banModalUser || !banReason.trim()) return
let bannedUntil: string | undefined
if (banDuration !== 'permanent') {
const now = new Date()
if (banDuration === '1d') {
now.setDate(now.getDate() + 1)
bannedUntil = now.toISOString()
} else if (banDuration === '7d') {
now.setDate(now.getDate() + 7)
bannedUntil = now.toISOString()
} else if (banDuration === '30d') {
now.setDate(now.getDate() + 30)
bannedUntil = now.toISOString()
} else if (banDuration === 'custom' && banCustomDate) {
bannedUntil = new Date(banCustomDate).toISOString()
}
}
setBanning(true)
try {
const updated = await adminApi.banUser(banModalUser.id, banReason, bannedUntil)
setUsers(users.map(u => u.id === updated.id ? updated : u))
toast.success(`Пользователь ${updated.nickname} заблокирован`)
setBanModalUser(null)
setBanReason('')
setBanDuration('permanent')
setBanCustomDate('')
} catch (err) {
console.error('Failed to ban user:', err)
toast.error('Ошибка блокировки')
} finally {
setBanning(false)
}
}
const handleUnban = async (user: AdminUser) => {
const confirmed = await confirm({
title: 'Разблокировать пользователя',
message: `Вы уверены, что хотите разблокировать ${user.nickname}?`,
confirmText: 'Разблокировать',
})
if (!confirmed) return
try {
const updated = await adminApi.unbanUser(user.id)
setUsers(users.map(u => u.id === updated.id ? updated : u))
toast.success(`Пользователь ${updated.nickname} разблокирован`)
} catch (err) {
console.error('Failed to unban user:', err)
toast.error('Ошибка разблокировки')
}
}
const handleRoleChange = async (user: AdminUser, newRole: UserRole) => {
const confirmed = await confirm({
title: 'Изменить роль',
message: `Изменить роль ${user.nickname} на ${newRole === 'admin' ? 'Администратор' : 'Пользователь'}?`,
confirmText: 'Изменить',
})
if (!confirmed) return
try {
const updated = await adminApi.setUserRole(user.id, newRole)
setUsers(users.map(u => u.id === updated.id ? updated : u))
toast.success(`Роль ${updated.nickname} изменена`)
} catch (err) {
console.error('Failed to change role:', err)
toast.error('Ошибка изменения роли')
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-500/20 border border-blue-500/30">
<Users className="w-6 h-6 text-blue-400" />
</div>
<h1 className="text-2xl font-bold text-white">Пользователи</h1>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<form onSubmit={handleSearch} className="flex-1 flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<input
type="text"
placeholder="Поиск по логину или никнейму..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl pl-10 pr-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/20 transition-all"
/>
</div>
<NeonButton type="submit" color="purple">
Найти
</NeonButton>
</form>
<label className="flex items-center gap-2 text-gray-300 cursor-pointer group">
<input
type="checkbox"
checked={bannedOnly}
onChange={(e) => {
setBannedOnly(e.target.checked)
setPage(0)
}}
className="w-4 h-4 rounded border-dark-600 bg-dark-700 text-accent-500 focus:ring-accent-500/50 focus:ring-offset-0"
/>
<span className="group-hover:text-white transition-colors">Только заблокированные</span>
</label>
</div>
{/* Users Table */}
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-dark-700/50 border-b border-dark-600">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">ID</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Логин</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Никнейм</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Роль</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Telegram</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Марафоны</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действия</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-600">
{loading ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
</td>
</tr>
) : users.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
Пользователи не найдены
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id} className="hover:bg-dark-700/30 transition-colors">
<td className="px-4 py-3 text-sm text-gray-400 font-mono">{user.id}</td>
<td className="px-4 py-3 text-sm text-white">{user.login}</td>
<td className="px-4 py-3 text-sm text-white font-medium">{user.nickname}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg ${
user.role === 'admin'
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30'
: 'bg-dark-600/50 text-gray-400 border border-dark-500'
}`}>
{user.role === 'admin' && <Shield className="w-3 h-3" />}
{user.role === 'admin' ? 'Админ' : 'Пользователь'}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-400">
{user.telegram_username ? (
<span className="text-neon-400">@{user.telegram_username}</span>
) : (
<span className="text-gray-600"></span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-400">{user.marathons_count}</td>
<td className="px-4 py-3">
{user.is_banned ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-red-500/20 text-red-400 border border-red-500/30">
<Ban className="w-3 h-3" />
Заблокирован
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-green-500/20 text-green-400 border border-green-500/30">
<UserCheck className="w-3 h-3" />
Активен
</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
{user.is_banned ? (
<button
onClick={() => handleUnban(user)}
className="p-2 text-green-400 hover:bg-green-500/20 rounded-lg transition-colors"
title="Разблокировать"
>
<UserCheck className="w-4 h-4" />
</button>
) : user.role !== 'admin' ? (
<button
onClick={() => setBanModalUser(user)}
className="p-2 text-red-400 hover:bg-red-500/20 rounded-lg transition-colors"
title="Заблокировать"
>
<Ban className="w-4 h-4" />
</button>
) : null}
{user.role === 'admin' ? (
<button
onClick={() => handleRoleChange(user, 'user')}
className="p-2 text-orange-400 hover:bg-orange-500/20 rounded-lg transition-colors"
title="Снять права админа"
>
<ShieldOff className="w-4 h-4" />
</button>
) : (
<button
onClick={() => handleRoleChange(user, 'admin')}
className="p-2 text-accent-400 hover:bg-accent-500/20 rounded-lg transition-colors"
title="Сделать админом"
>
<Shield className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t border-dark-600">
<button
onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
>
<ChevronLeft className="w-4 h-4" />
Назад
</button>
<span className="text-sm text-gray-500">
Страница <span className="text-white font-medium">{page + 1}</span>
</span>
<button
onClick={() => setPage(page + 1)}
disabled={users.length < LIMIT}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
>
Вперед
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
{/* Ban Modal */}
{banModalUser && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="glass rounded-2xl p-6 max-w-md w-full mx-4 border border-dark-600 shadow-2xl">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Ban className="w-5 h-5 text-red-400" />
Заблокировать {banModalUser.nickname}?
</h3>
<button
onClick={() => {
setBanModalUser(null)
setBanReason('')
setBanDuration('permanent')
setBanCustomDate('')
}}
className="p-1.5 text-gray-400 hover:text-white rounded-lg hover:bg-dark-600/50 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Ban Duration */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
Срок блокировки
</label>
<select
value={banDuration}
onChange={(e) => setBanDuration(e.target.value)}
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white focus:outline-none focus:border-accent-500/50 transition-colors"
>
<option value="permanent">Навсегда</option>
<option value="1d">1 день</option>
<option value="7d">7 дней</option>
<option value="30d">30 дней</option>
<option value="custom">Указать дату</option>
</select>
</div>
{/* Custom Date */}
{banDuration === 'custom' && (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
Разблокировать
</label>
<input
type="datetime-local"
value={banCustomDate}
onChange={(e) => setBanCustomDate(e.target.value)}
min={new Date().toISOString().slice(0, 16)}
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white focus:outline-none focus:border-accent-500/50 transition-colors"
/>
</div>
)}
{/* Reason */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-300 mb-2">
Причина
</label>
<textarea
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
placeholder="Причина блокировки..."
rows={3}
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors resize-none"
/>
</div>
<div className="flex gap-3 justify-end">
<NeonButton
variant="ghost"
onClick={() => {
setBanModalUser(null)
setBanReason('')
setBanDuration('permanent')
setBanCustomDate('')
}}
>
Отмена
</NeonButton>
<NeonButton
variant="danger"
onClick={handleBan}
disabled={!banReason.trim() || banning || (banDuration === 'custom' && !banCustomDate)}
isLoading={banning}
icon={<Ban className="w-4 h-4" />}
>
Заблокировать
</NeonButton>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,7 @@
export { AdminLayout } from './AdminLayout'
export { AdminDashboardPage } from './AdminDashboardPage'
export { AdminUsersPage } from './AdminUsersPage'
export { AdminMarathonsPage } from './AdminMarathonsPage'
export { AdminLogsPage } from './AdminLogsPage'
export { AdminBroadcastPage } from './AdminBroadcastPage'
export { AdminContentPage } from './AdminContentPage'

View File

@@ -3,6 +3,21 @@ import { persist } from 'zustand/middleware'
import type { User } from '@/types'
import { authApi, type RegisterData, type LoginData } from '@/api/auth'
interface Pending2FA {
sessionId: number
}
interface LoginResult {
requires2FA: boolean
sessionId?: number
}
export interface BanInfo {
banned_at: string | null
banned_until: string | null
reason: string | null
}
interface AuthState {
user: User | null
token: string | null
@@ -11,8 +26,12 @@ interface AuthState {
error: string | null
pendingInviteCode: string | null
avatarVersion: number
pending2FA: Pending2FA | null
banInfo: BanInfo | null
login: (data: LoginData) => Promise<void>
login: (data: LoginData) => Promise<LoginResult>
verify2FA: (code: string) => Promise<void>
cancel2FA: () => void
register: (data: RegisterData) => Promise<void>
logout: () => void
clearError: () => void
@@ -20,6 +39,8 @@ interface AuthState {
consumePendingInviteCode: () => string | null
updateUser: (updates: Partial<User>) => void
bumpAvatarVersion: () => void
setBanned: (banInfo: BanInfo) => void
clearBanned: () => void
}
export const useAuthStore = create<AuthState>()(
@@ -32,18 +53,34 @@ export const useAuthStore = create<AuthState>()(
error: null,
pendingInviteCode: null,
avatarVersion: 0,
pending2FA: null,
banInfo: null,
login: async (data) => {
set({ isLoading: true, error: null })
set({ isLoading: true, error: null, pending2FA: null })
try {
const response = await authApi.login(data)
localStorage.setItem('token', response.access_token)
set({
user: response.user,
token: response.access_token,
isAuthenticated: true,
isLoading: false,
})
// Check if 2FA is required
if (response.requires_2fa && response.two_factor_session_id) {
set({
isLoading: false,
pending2FA: { sessionId: response.two_factor_session_id },
})
return { requires2FA: true, sessionId: response.two_factor_session_id }
}
// Regular login (no 2FA)
if (response.access_token && response.user) {
localStorage.setItem('token', response.access_token)
set({
user: response.user,
token: response.access_token,
isAuthenticated: true,
isLoading: false,
})
}
return { requires2FA: false }
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
set({
@@ -54,6 +91,37 @@ export const useAuthStore = create<AuthState>()(
}
},
verify2FA: async (code) => {
const pending = get().pending2FA
if (!pending) {
throw new Error('No pending 2FA session')
}
set({ isLoading: true, error: null })
try {
const response = await authApi.verify2FA(pending.sessionId, code)
localStorage.setItem('token', response.access_token)
set({
user: response.user,
token: response.access_token,
isAuthenticated: true,
isLoading: false,
pending2FA: null,
})
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
set({
error: error.response?.data?.detail || '2FA verification failed',
isLoading: false,
})
throw err
}
},
cancel2FA: () => {
set({ pending2FA: null, error: null })
},
register: async (data) => {
set({ isLoading: true, error: null })
try {
@@ -81,6 +149,7 @@ export const useAuthStore = create<AuthState>()(
user: null,
token: null,
isAuthenticated: false,
banInfo: null,
})
},
@@ -104,6 +173,14 @@ export const useAuthStore = create<AuthState>()(
bumpAvatarVersion: () => {
set({ avatarVersion: get().avatarVersion + 1 })
},
setBanned: (banInfo) => {
set({ banInfo })
},
clearBanned: () => {
set({ banInfo: null })
},
}),
{
name: 'auth-storage',

View File

@@ -1,26 +1,40 @@
// User types
export type UserRole = 'user' | 'admin'
export interface User {
// Public user info (visible to other users)
export interface UserPublic {
id: number
login: string
nickname: string
avatar_url: string | null
role: UserRole
telegram_id: number | null
telegram_username: string | null
telegram_first_name: string | null
telegram_last_name: string | null
telegram_avatar_url: string | null
created_at: string
}
// Full user info (only for own profile from /auth/me)
export interface User extends UserPublic {
login?: string // Only visible to self
telegram_id?: number | null // Only visible to self
telegram_username?: string | null // Only visible to self
telegram_first_name?: string | null // Only visible to self
telegram_last_name?: string | null // Only visible to self
}
export interface TokenResponse {
access_token: string
token_type: string
user: User
}
// Login response (may require 2FA for admins)
export interface LoginResponse {
access_token?: string | null
token_type: string
user?: User | null
requires_2fa: boolean
two_factor_session_id?: number | null
}
// Marathon types
export type MarathonStatus = 'preparing' | 'active' | 'finished'
export type ParticipantRole = 'participant' | 'organizer'
@@ -130,6 +144,13 @@ export type ChallengeType =
export type Difficulty = 'easy' | 'medium' | 'hard'
export type ProofType = 'screenshot' | 'video' | 'steam'
export type ChallengeStatus = 'pending' | 'approved' | 'rejected'
export interface ProposedByUser {
id: number
nickname: string
}
export interface Challenge {
id: number
game: GameShort
@@ -143,6 +164,8 @@ export interface Challenge {
proof_hint: string | null
is_generated: boolean
created_at: string
status: ChallengeStatus
proposed_by: ProposedByUser | null
}
export interface ChallengePreview {
@@ -390,6 +413,10 @@ export interface AdminUser {
telegram_username: string | null
marathons_count: number
created_at: string
is_banned: boolean
banned_at: string | null
banned_until: string | null // null = permanent ban
ban_reason: string | null
}
export interface AdminMarathon {
@@ -411,6 +438,64 @@ export interface PlatformStats {
total_participations: number
}
// Admin action log types
export type AdminActionType =
| 'user_ban'
| 'user_unban'
| 'user_role_change'
| 'marathon_force_finish'
| 'marathon_delete'
| 'content_update'
| 'broadcast_all'
| 'broadcast_marathon'
| 'admin_login'
| 'admin_2fa_success'
| 'admin_2fa_fail'
export interface AdminLog {
id: number
admin_id: number
admin_nickname: string
action: AdminActionType
target_type: string
target_id: number
details: Record<string, unknown> | null
ip_address: string | null
created_at: string
}
export interface AdminLogsResponse {
logs: AdminLog[]
total: number
}
// Broadcast types
export interface BroadcastResponse {
sent_count: number
total_count: number
}
// Static content types
export interface StaticContent {
id: number
key: string
title: string
content: string
updated_at: string
created_at: string
}
// Dashboard stats
export interface DashboardStats {
users_count: number
banned_users_count: number
marathons_count: number
active_marathons_count: number
games_count: number
total_participations: number
recent_logs: AdminLog[]
}
// Dispute types
export type DisputeStatus = 'open' | 'valid' | 'invalid'

View File

@@ -17,6 +17,10 @@ http {
# File upload limit (15 MB)
client_max_body_size 15M;
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=api_auth:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=api_general:10m rate=60r/m;
upstream backend {
server backend:8000;
}
@@ -37,8 +41,22 @@ http {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Backend API
# Auth API - strict rate limit (10 req/min with burst of 5)
location /api/v1/auth {
limit_req zone=api_auth burst=5 nodelay;
limit_req_status 429;
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Backend API - general rate limit (60 req/min with burst of 20)
location /api {
limit_req zone=api_general burst=20 nodelay;
limit_req_status 429;
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@@ -6,6 +6,9 @@ WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Create data directory for SQLite
RUN mkdir -p /app/data
# Copy application
COPY . .

85
status-service/alerts.py Normal file
View File

@@ -0,0 +1,85 @@
"""Telegram alerting for status changes."""
import os
from datetime import datetime
from typing import Optional
import httpx
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_ADMIN_ID = os.getenv("TELEGRAM_ADMIN_ID", "")
async def send_telegram_alert(message: str, is_recovery: bool = False) -> bool:
"""Send alert to Telegram."""
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_ADMIN_ID:
print("Telegram alerting not configured")
return False
emoji = "\u2705" if is_recovery else "\u26a0\ufe0f"
text = f"{emoji} *Status Alert*\n\n{message}"
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
data = {
"chat_id": TELEGRAM_ADMIN_ID,
"text": text,
"parse_mode": "Markdown",
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, json=data)
response.raise_for_status()
print(f"Telegram alert sent: {message[:50]}...")
return True
except Exception as e:
print(f"Failed to send Telegram alert: {e}")
return False
async def alert_service_down(service_name: str, display_name: str, message: Optional[str]):
"""Alert when service goes down."""
now = datetime.now().strftime("%d.%m.%Y %H:%M:%S")
text = (
f"*{display_name}* is DOWN\n\n"
f"Time: `{now}`\n"
)
if message:
text += f"Error: `{message}`"
await send_telegram_alert(text, is_recovery=False)
async def alert_service_recovered(service_name: str, display_name: str, downtime_minutes: int):
"""Alert when service recovers."""
now = datetime.now().strftime("%d.%m.%Y %H:%M:%S")
text = (
f"*{display_name}* is back ONLINE\n\n"
f"Time: `{now}`\n"
f"Downtime: `{downtime_minutes} min`"
)
await send_telegram_alert(text, is_recovery=True)
async def alert_ssl_expiring(domain: str, days_left: int):
"""Alert when SSL certificate is expiring soon."""
text = (
f"*SSL Certificate Expiring*\n\n"
f"Domain: `{domain}`\n"
f"Days left: `{days_left}`\n\n"
f"Please renew the certificate!"
)
await send_telegram_alert(text, is_recovery=False)
async def alert_ssl_expired(domain: str):
"""Alert when SSL certificate has expired."""
text = (
f"*SSL Certificate EXPIRED*\n\n"
f"Domain: `{domain}`\n\n"
f"Certificate has expired! Site may show security warnings."
)
await send_telegram_alert(text, is_recovery=False)

261
status-service/database.py Normal file
View File

@@ -0,0 +1,261 @@
"""SQLite database for storing metrics history."""
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
import json
DB_PATH = Path("/app/data/metrics.db")
def get_connection() -> sqlite3.Connection:
"""Get database connection."""
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
return conn
def init_db():
"""Initialize database tables."""
conn = get_connection()
cursor = conn.cursor()
# Metrics history table
cursor.execute("""
CREATE TABLE IF NOT EXISTS metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_name TEXT NOT NULL,
status TEXT NOT NULL,
latency_ms REAL,
message TEXT,
checked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Incidents table
cursor.execute("""
CREATE TABLE IF NOT EXISTS incidents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_name TEXT NOT NULL,
status TEXT NOT NULL,
message TEXT,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
resolved_at TIMESTAMP,
notified BOOLEAN DEFAULT FALSE
)
""")
# SSL certificates table
cursor.execute("""
CREATE TABLE IF NOT EXISTS ssl_certificates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL UNIQUE,
issuer TEXT,
expires_at TIMESTAMP,
days_until_expiry INTEGER,
checked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Create indexes
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_metrics_service_time
ON metrics(service_name, checked_at DESC)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_incidents_service
ON incidents(service_name, started_at DESC)
""")
conn.commit()
conn.close()
def save_metric(service_name: str, status: str, latency_ms: Optional[float], message: Optional[str]):
"""Save a metric record."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute(
"INSERT INTO metrics (service_name, status, latency_ms, message) VALUES (?, ?, ?, ?)",
(service_name, status, latency_ms, message)
)
conn.commit()
conn.close()
def get_latency_history(service_name: str, hours: int = 24) -> list[dict]:
"""Get latency history for a service."""
conn = get_connection()
cursor = conn.cursor()
since = datetime.now() - timedelta(hours=hours)
cursor.execute("""
SELECT latency_ms, status, checked_at
FROM metrics
WHERE service_name = ? AND checked_at > ? AND latency_ms IS NOT NULL
ORDER BY checked_at ASC
""", (service_name, since.isoformat()))
rows = cursor.fetchall()
conn.close()
return [
{
"latency_ms": row["latency_ms"],
"status": row["status"],
"checked_at": row["checked_at"]
}
for row in rows
]
def get_uptime_stats(service_name: str, hours: int = 24) -> dict:
"""Calculate uptime statistics for a service."""
conn = get_connection()
cursor = conn.cursor()
since = datetime.now() - timedelta(hours=hours)
cursor.execute("""
SELECT COUNT(*) as total,
SUM(CASE WHEN status = 'operational' THEN 1 ELSE 0 END) as successful
FROM metrics
WHERE service_name = ? AND checked_at > ?
""", (service_name, since.isoformat()))
row = cursor.fetchone()
conn.close()
total = row["total"] or 0
successful = row["successful"] or 0
return {
"total_checks": total,
"successful_checks": successful,
"uptime_percent": (successful / total * 100) if total > 0 else 100.0
}
def get_avg_latency(service_name: str, hours: int = 24) -> Optional[float]:
"""Get average latency for a service."""
conn = get_connection()
cursor = conn.cursor()
since = datetime.now() - timedelta(hours=hours)
cursor.execute("""
SELECT AVG(latency_ms) as avg_latency
FROM metrics
WHERE service_name = ? AND checked_at > ? AND latency_ms IS NOT NULL
""", (service_name, since.isoformat()))
row = cursor.fetchone()
conn.close()
return row["avg_latency"]
def create_incident(service_name: str, status: str, message: Optional[str]) -> int:
"""Create a new incident."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute(
"INSERT INTO incidents (service_name, status, message) VALUES (?, ?, ?)",
(service_name, status, message)
)
incident_id = cursor.lastrowid
conn.commit()
conn.close()
return incident_id
def resolve_incident(service_name: str):
"""Resolve open incidents for a service."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
UPDATE incidents
SET resolved_at = CURRENT_TIMESTAMP
WHERE service_name = ? AND resolved_at IS NULL
""", (service_name,))
conn.commit()
conn.close()
def get_open_incident(service_name: str) -> Optional[dict]:
"""Get open incident for a service."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM incidents
WHERE service_name = ? AND resolved_at IS NULL
ORDER BY started_at DESC LIMIT 1
""", (service_name,))
row = cursor.fetchone()
conn.close()
if row:
return dict(row)
return None
def mark_incident_notified(incident_id: int):
"""Mark incident as notified."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("UPDATE incidents SET notified = TRUE WHERE id = ?", (incident_id,))
conn.commit()
conn.close()
def get_recent_incidents(limit: int = 10) -> list[dict]:
"""Get recent incidents."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM incidents
ORDER BY started_at DESC
LIMIT ?
""", (limit,))
rows = cursor.fetchall()
conn.close()
return [dict(row) for row in rows]
def save_ssl_info(domain: str, issuer: str, expires_at: datetime, days_until_expiry: int):
"""Save SSL certificate info."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO ssl_certificates
(domain, issuer, expires_at, days_until_expiry, checked_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
""", (domain, issuer, expires_at.isoformat(), days_until_expiry))
conn.commit()
conn.close()
def get_ssl_info(domain: str) -> Optional[dict]:
"""Get SSL certificate info."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT * FROM ssl_certificates WHERE domain = ?", (domain,))
row = cursor.fetchone()
conn.close()
if row:
return dict(row)
return None
def cleanup_old_metrics(days: int = 1):
"""Delete metrics older than specified days (default: 24 hours)."""
conn = get_connection()
cursor = conn.cursor()
cutoff = datetime.now() - timedelta(days=days)
cursor.execute("DELETE FROM metrics WHERE checked_at < ?", (cutoff.isoformat(),))
deleted = cursor.rowcount
conn.commit()
conn.close()
return deleted

View File

@@ -1,6 +1,7 @@
"""Status monitoring service with persistence and alerting."""
import os
import asyncio
from datetime import datetime, timedelta
from datetime import datetime
from typing import Optional
from contextlib import asynccontextmanager
@@ -8,52 +9,81 @@ from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from monitors import ServiceMonitor, ServiceStatus
from monitors import ServiceMonitor
from database import init_db, get_recent_incidents, get_latency_history, cleanup_old_metrics
# Configuration
BACKEND_URL = os.getenv("BACKEND_URL", "http://backend:8000")
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://frontend:80")
BOT_URL = os.getenv("BOT_URL", "http://bot:8080")
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "30"))
EXTERNAL_URL = os.getenv("EXTERNAL_URL", "") # Public URL for external checks
PUBLIC_URL = os.getenv("PUBLIC_URL", "") # Public HTTPS URL for SSL checks
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "600")) # 10 minutes
# Initialize monitor
monitor = ServiceMonitor()
# Background task reference
background_task: Optional[asyncio.Task] = None
cleanup_task: Optional[asyncio.Task] = None
async def periodic_health_check():
"""Background task to check services periodically"""
"""Background task to check services periodically."""
while True:
await monitor.check_all_services(
backend_url=BACKEND_URL,
frontend_url=FRONTEND_URL,
bot_url=BOT_URL
)
try:
await monitor.check_all_services(
backend_url=BACKEND_URL,
frontend_url=FRONTEND_URL,
bot_url=BOT_URL,
external_url=EXTERNAL_URL,
public_url=PUBLIC_URL
)
except Exception as e:
print(f"Health check error: {e}")
await asyncio.sleep(CHECK_INTERVAL)
async def periodic_cleanup():
"""Background task to cleanup old metrics (hourly)."""
while True:
await asyncio.sleep(3600) # 1 hour
try:
deleted = cleanup_old_metrics(days=1) # Keep only last 24 hours
print(f"Cleaned up {deleted} old metrics")
except Exception as e:
print(f"Cleanup error: {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup and shutdown events"""
global background_task
"""Startup and shutdown events."""
global background_task, cleanup_task
# Initialize database
init_db()
print("Database initialized")
# Start background health checks
background_task = asyncio.create_task(periodic_health_check())
cleanup_task = asyncio.create_task(periodic_cleanup())
yield
# Cancel background task on shutdown
if background_task:
background_task.cancel()
try:
await background_task
except asyncio.CancelledError:
pass
# Cancel background tasks on shutdown
for task in [background_task, cleanup_task]:
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(
title="Status Monitor",
description="Service health monitoring",
description="Service health monitoring with persistence and alerting",
lifespan=lifespan
)
@@ -62,9 +92,11 @@ templates = Jinja2Templates(directory="templates")
@app.get("/", response_class=HTMLResponse)
async def status_page(request: Request):
"""Main status page"""
"""Main status page."""
services = monitor.get_all_statuses()
overall_status = monitor.get_overall_status()
ssl_status = monitor.get_ssl_status()
incidents = get_recent_incidents(limit=5)
return templates.TemplateResponse(
"index.html",
@@ -72,6 +104,8 @@ async def status_page(request: Request):
"request": request,
"services": services,
"overall_status": overall_status,
"ssl_status": ssl_status,
"incidents": incidents,
"last_check": monitor.last_check,
"check_interval": CHECK_INTERVAL
}
@@ -80,30 +114,52 @@ async def status_page(request: Request):
@app.get("/api/status")
async def api_status():
"""API endpoint for service statuses"""
"""API endpoint for service statuses."""
services = monitor.get_all_statuses()
overall_status = monitor.get_overall_status()
ssl_status = monitor.get_ssl_status()
return {
"overall_status": overall_status,
"overall_status": overall_status.value,
"services": {name: status.to_dict() for name, status in services.items()},
"ssl": ssl_status,
"last_check": monitor.last_check.isoformat() if monitor.last_check else None,
"check_interval_seconds": CHECK_INTERVAL
}
@app.get("/api/history/{service_name}")
async def api_history(service_name: str, hours: int = 24):
"""API endpoint for service latency history."""
history = get_latency_history(service_name, hours=hours)
return {
"service": service_name,
"hours": hours,
"data": history
}
@app.get("/api/incidents")
async def api_incidents(limit: int = 20):
"""API endpoint for recent incidents."""
incidents = get_recent_incidents(limit=limit)
return {"incidents": incidents}
@app.get("/api/health")
async def health():
"""Health check for this service"""
"""Health check for this service."""
return {"status": "ok", "service": "status-monitor"}
@app.post("/api/refresh")
async def refresh_status():
"""Force refresh all service statuses"""
"""Force refresh all service statuses."""
await monitor.check_all_services(
backend_url=BACKEND_URL,
frontend_url=FRONTEND_URL,
bot_url=BOT_URL
bot_url=BOT_URL,
external_url=EXTERNAL_URL,
public_url=PUBLIC_URL
)
return {"status": "refreshed"}

View File

@@ -1,11 +1,19 @@
"""Service monitoring with persistence and alerting."""
import asyncio
from datetime import datetime, timedelta
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import Optional
from enum import Enum
import httpx
from database import (
save_metric, get_latency_history, get_uptime_stats, get_avg_latency,
create_incident, resolve_incident, get_open_incident, mark_incident_notified
)
from alerts import alert_service_down, alert_service_recovered
from ssl_monitor import check_and_alert_ssl, SSLInfo
class Status(str, Enum):
OPERATIONAL = "operational"
@@ -25,11 +33,17 @@ class ServiceStatus:
uptime_percent: float = 100.0
message: Optional[str] = None
version: Optional[str] = None
avg_latency_24h: Optional[float] = None
latency_history: list = None
# For uptime calculation
# For uptime calculation (in-memory, backed by DB)
total_checks: int = 0
successful_checks: int = 0
def __post_init__(self):
if self.latency_history is None:
self.latency_history = []
def to_dict(self) -> dict:
return {
"name": self.name,
@@ -40,7 +54,8 @@ class ServiceStatus:
"last_incident": self.last_incident.isoformat() if self.last_incident else None,
"uptime_percent": round(self.uptime_percent, 2),
"message": self.message,
"version": self.version
"version": self.version,
"avg_latency_24h": round(self.avg_latency_24h, 2) if self.avg_latency_24h else None,
}
def update_uptime(self, is_success: bool):
@@ -69,12 +84,17 @@ class ServiceMonitor:
"bot": ServiceStatus(
name="bot",
display_name="Telegram Bot"
)
),
"external": ServiceStatus(
name="external",
display_name="External Access"
),
}
self.last_check: Optional[datetime] = None
self.ssl_info: Optional[SSLInfo] = None
async def check_backend(self, url: str) -> tuple[Status, Optional[float], Optional[str], Optional[str]]:
"""Check backend API health"""
"""Check backend API health."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
start = datetime.now()
@@ -92,9 +112,7 @@ class ServiceMonitor:
return Status.DOWN, None, str(e)[:100], None
async def check_database(self, backend_url: str) -> tuple[Status, Optional[float], Optional[str]]:
"""Check database through backend"""
# We check database indirectly - if backend is up, DB is likely up
# Could add a specific /health/db endpoint to backend later
"""Check database through backend."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
start = datetime.now()
@@ -109,7 +127,7 @@ class ServiceMonitor:
return Status.DOWN, None, "Cannot reach backend"
async def check_frontend(self, url: str) -> tuple[Status, Optional[float], Optional[str]]:
"""Check frontend availability"""
"""Check frontend availability."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
start = datetime.now()
@@ -126,7 +144,7 @@ class ServiceMonitor:
return Status.DOWN, None, str(e)[:100]
async def check_bot(self, url: str) -> tuple[Status, Optional[float], Optional[str]]:
"""Check Telegram bot health"""
"""Check Telegram bot health."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
start = datetime.now()
@@ -142,8 +160,93 @@ class ServiceMonitor:
except Exception as e:
return Status.DOWN, None, str(e)[:100]
async def check_all_services(self, backend_url: str, frontend_url: str, bot_url: str):
"""Check all services concurrently"""
async def check_external(self, url: str) -> tuple[Status, Optional[float], Optional[str]]:
"""Check external (public) URL availability."""
if not url:
return Status.UNKNOWN, None, "Not configured"
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
start = datetime.now()
response = await client.get(url)
latency = (datetime.now() - start).total_seconds() * 1000
if response.status_code == 200:
return Status.OPERATIONAL, latency, None
else:
return Status.DEGRADED, latency, f"HTTP {response.status_code}"
except httpx.TimeoutException:
return Status.DOWN, None, "Timeout"
except Exception as e:
return Status.DOWN, None, str(e)[:100]
async def _process_check_result(
self,
service_name: str,
result: tuple,
now: datetime
):
"""Process check result with DB persistence and alerting."""
if isinstance(result, Exception):
return
if len(result) == 4:
status, latency, message, version = result
else:
status, latency, message = result
version = None
svc = self.services[service_name]
was_down = svc.status in (Status.DOWN, Status.DEGRADED)
is_down = status in (Status.DOWN, Status.DEGRADED)
# Update service status
svc.status = status
svc.latency_ms = latency
svc.message = message
if version:
svc.version = version
svc.last_check = now
svc.update_uptime(status == Status.OPERATIONAL)
# Save metric to database
save_metric(service_name, status.value, latency, message)
# Load historical data
svc.latency_history = get_latency_history(service_name, hours=24)
svc.avg_latency_24h = get_avg_latency(service_name, hours=24)
# Update uptime from DB
stats = get_uptime_stats(service_name, hours=24)
if stats["total_checks"] > 0:
svc.uptime_percent = stats["uptime_percent"]
# Handle incident tracking and alerting
if is_down and not was_down:
# Service just went down
svc.last_incident = now
incident_id = create_incident(service_name, status.value, message)
await alert_service_down(service_name, svc.display_name, message)
mark_incident_notified(incident_id)
elif not is_down and was_down:
# Service recovered
open_incident = get_open_incident(service_name)
if open_incident:
started_at = datetime.fromisoformat(open_incident["started_at"])
downtime_minutes = int((now - started_at).total_seconds() / 60)
resolve_incident(service_name)
await alert_service_recovered(service_name, svc.display_name, downtime_minutes)
async def check_all_services(
self,
backend_url: str,
frontend_url: str,
bot_url: str,
external_url: str = "",
public_url: str = ""
):
"""Check all services concurrently."""
now = datetime.now()
# Run all checks concurrently
@@ -152,61 +255,18 @@ class ServiceMonitor:
self.check_database(backend_url),
self.check_frontend(frontend_url),
self.check_bot(bot_url),
self.check_external(external_url),
return_exceptions=True
)
# Process backend result
if not isinstance(results[0], Exception):
status, latency, message, version = results[0]
svc = self.services["backend"]
was_down = svc.status == Status.DOWN
svc.status = status
svc.latency_ms = latency
svc.message = message
svc.version = version
svc.last_check = now
svc.update_uptime(status == Status.OPERATIONAL)
if status != Status.OPERATIONAL and not was_down:
svc.last_incident = now
# Process results
service_names = ["backend", "database", "frontend", "bot", "external"]
for i, service_name in enumerate(service_names):
await self._process_check_result(service_name, results[i], now)
# Process database result
if not isinstance(results[1], Exception):
status, latency, message = results[1]
svc = self.services["database"]
was_down = svc.status == Status.DOWN
svc.status = status
svc.latency_ms = latency
svc.message = message
svc.last_check = now
svc.update_uptime(status == Status.OPERATIONAL)
if status != Status.OPERATIONAL and not was_down:
svc.last_incident = now
# Process frontend result
if not isinstance(results[2], Exception):
status, latency, message = results[2]
svc = self.services["frontend"]
was_down = svc.status == Status.DOWN
svc.status = status
svc.latency_ms = latency
svc.message = message
svc.last_check = now
svc.update_uptime(status == Status.OPERATIONAL)
if status != Status.OPERATIONAL and not was_down:
svc.last_incident = now
# Process bot result
if not isinstance(results[3], Exception):
status, latency, message = results[3]
svc = self.services["bot"]
was_down = svc.status == Status.DOWN
svc.status = status
svc.latency_ms = latency
svc.message = message
svc.last_check = now
svc.update_uptime(status == Status.OPERATIONAL)
if status != Status.OPERATIONAL and not was_down:
svc.last_incident = now
# Check SSL certificate (if public URL is HTTPS)
if public_url and public_url.startswith("https://"):
self.ssl_info = await check_and_alert_ssl(public_url)
self.last_check = now
@@ -214,8 +274,12 @@ class ServiceMonitor:
return self.services
def get_overall_status(self) -> Status:
"""Get overall system status based on all services"""
statuses = [svc.status for svc in self.services.values()]
"""Get overall system status based on all services."""
# Exclude external from overall status if not configured
statuses = [
svc.status for name, svc in self.services.items()
if name != "external" or svc.status != Status.UNKNOWN
]
if all(s == Status.OPERATIONAL for s in statuses):
return Status.OPERATIONAL
@@ -225,3 +289,17 @@ class ServiceMonitor:
return Status.DEGRADED
else:
return Status.UNKNOWN
def get_ssl_status(self) -> Optional[dict]:
"""Get SSL certificate status."""
if not self.ssl_info:
return None
return {
"domain": self.ssl_info.domain,
"issuer": self.ssl_info.issuer,
"expires_at": self.ssl_info.expires_at.isoformat(),
"days_until_expiry": self.ssl_info.days_until_expiry,
"is_valid": self.ssl_info.is_valid,
"error": self.ssl_info.error
}

View File

@@ -0,0 +1,140 @@
"""SSL certificate monitoring."""
import ssl
import socket
from datetime import datetime, timezone
from dataclasses import dataclass
from typing import Optional
from urllib.parse import urlparse
from database import save_ssl_info, get_ssl_info
from alerts import alert_ssl_expiring, alert_ssl_expired
@dataclass
class SSLInfo:
domain: str
issuer: str
expires_at: datetime
days_until_expiry: int
is_valid: bool
error: Optional[str] = None
def check_ssl_certificate(url: str) -> Optional[SSLInfo]:
"""Check SSL certificate for a URL."""
try:
parsed = urlparse(url)
hostname = parsed.hostname
if not hostname:
return None
# Skip non-HTTPS or localhost
if parsed.scheme != "https" or hostname in ("localhost", "127.0.0.1"):
return None
context = ssl.create_default_context()
conn = context.wrap_socket(
socket.socket(socket.AF_INET),
server_hostname=hostname
)
conn.settimeout(10.0)
try:
conn.connect((hostname, parsed.port or 443))
cert = conn.getpeercert()
finally:
conn.close()
if not cert:
return SSLInfo(
domain=hostname,
issuer="Unknown",
expires_at=datetime.now(timezone.utc),
days_until_expiry=0,
is_valid=False,
error="No certificate found"
)
# Parse expiry date
not_after = cert.get("notAfter", "")
expires_at = datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z")
expires_at = expires_at.replace(tzinfo=timezone.utc)
# Calculate days until expiry
now = datetime.now(timezone.utc)
days_until_expiry = (expires_at - now).days
# Get issuer
issuer_parts = cert.get("issuer", ())
issuer = "Unknown"
for part in issuer_parts:
for key, value in part:
if key == "organizationName":
issuer = value
break
return SSLInfo(
domain=hostname,
issuer=issuer,
expires_at=expires_at,
days_until_expiry=days_until_expiry,
is_valid=days_until_expiry > 0
)
except ssl.SSLCertVerificationError as e:
hostname = urlparse(url).hostname or url
return SSLInfo(
domain=hostname,
issuer="Invalid",
expires_at=datetime.now(timezone.utc),
days_until_expiry=0,
is_valid=False,
error=f"SSL verification failed: {str(e)[:100]}"
)
except Exception as e:
hostname = urlparse(url).hostname or url
return SSLInfo(
domain=hostname,
issuer="Unknown",
expires_at=datetime.now(timezone.utc),
days_until_expiry=0,
is_valid=False,
error=str(e)[:100]
)
async def check_and_alert_ssl(url: str, warn_days: int = 14) -> Optional[SSLInfo]:
"""Check SSL and send alerts if needed."""
ssl_info = check_ssl_certificate(url)
if not ssl_info:
return None
# Save to database
save_ssl_info(
domain=ssl_info.domain,
issuer=ssl_info.issuer,
expires_at=ssl_info.expires_at,
days_until_expiry=ssl_info.days_until_expiry
)
# Check if we need to alert
prev_info = get_ssl_info(ssl_info.domain)
if ssl_info.days_until_expiry <= 0:
# Certificate expired
await alert_ssl_expired(ssl_info.domain)
elif ssl_info.days_until_expiry <= warn_days:
# Certificate expiring soon - alert once per day
should_alert = True
if prev_info and prev_info.get("checked_at"):
# Check if we already alerted today
last_check = datetime.fromisoformat(prev_info["checked_at"])
if (datetime.now() - last_check).days < 1:
should_alert = False
if should_alert:
await alert_ssl_expiring(ssl_info.domain, ssl_info.days_until_expiry)
return ssl_info

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>System Status</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* {
margin: 0;
@@ -19,7 +20,7 @@
}
.container {
max-width: 900px;
max-width: 1100px;
margin: 0 auto;
padding: 40px 20px;
}
@@ -39,6 +40,13 @@
background-clip: text;
}
h2 {
font-size: 1.3rem;
font-weight: 600;
margin: 30px 0 16px 0;
color: #94a3b8;
}
.overall-status {
display: inline-flex;
align-items: center;
@@ -174,8 +182,9 @@
.service-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.metric {
@@ -212,6 +221,132 @@
color: #fca5a5;
}
/* Latency chart */
.latency-chart {
height: 60px;
margin-top: 12px;
}
/* SSL Card */
.ssl-card {
background: rgba(30, 41, 59, 0.5);
border: 1px solid rgba(100, 116, 139, 0.2);
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
}
.ssl-card.warning {
border-color: rgba(250, 204, 21, 0.3);
}
.ssl-card.danger {
border-color: rgba(239, 68, 68, 0.3);
}
.ssl-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.ssl-title {
font-size: 1.1rem;
font-weight: 600;
color: #f1f5f9;
}
.ssl-badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
}
.ssl-badge.valid {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.ssl-badge.expiring {
background: rgba(250, 204, 21, 0.15);
color: #facc15;
}
.ssl-badge.expired {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.ssl-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
}
/* Incidents */
.incidents-list {
background: rgba(30, 41, 59, 0.5);
border: 1px solid rgba(100, 116, 139, 0.2);
border-radius: 16px;
overflow: hidden;
}
.incident-item {
padding: 16px 20px;
border-bottom: 1px solid rgba(100, 116, 139, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.incident-item:last-child {
border-bottom: none;
}
.incident-info {
display: flex;
align-items: center;
gap: 12px;
}
.incident-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.incident-dot.resolved {
background: #22c55e;
}
.incident-dot.open {
background: #ef4444;
animation: pulse 2s infinite;
}
.incident-service {
font-weight: 500;
color: #f1f5f9;
}
.incident-message {
font-size: 0.85rem;
color: #94a3b8;
}
.incident-time {
font-size: 0.85rem;
color: #64748b;
}
.no-incidents {
padding: 30px;
text-align: center;
color: #64748b;
}
.refresh-btn {
display: inline-flex;
align-items: center;
@@ -292,8 +427,42 @@
</p>
</header>
{% if ssl_status %}
<div class="ssl-card {% if ssl_status.days_until_expiry <= 0 %}danger{% elif ssl_status.days_until_expiry <= 14 %}warning{% endif %}">
<div class="ssl-header">
<span class="ssl-title">SSL Certificate</span>
<span class="ssl-badge {% if ssl_status.days_until_expiry <= 0 %}expired{% elif ssl_status.days_until_expiry <= 14 %}expiring{% else %}valid{% endif %}">
{% if ssl_status.days_until_expiry <= 0 %}
Expired
{% elif ssl_status.days_until_expiry <= 14 %}
Expiring Soon
{% else %}
Valid
{% endif %}
</span>
</div>
<div class="ssl-info">
<div class="metric">
<div class="metric-label">Domain</div>
<div class="metric-value">{{ ssl_status.domain }}</div>
</div>
<div class="metric">
<div class="metric-label">Issuer</div>
<div class="metric-value">{{ ssl_status.issuer }}</div>
</div>
<div class="metric">
<div class="metric-label">Days Left</div>
<div class="metric-value {% if ssl_status.days_until_expiry <= 0 %}bad{% elif ssl_status.days_until_expiry <= 14 %}warning{% else %}good{% endif %}">
{{ ssl_status.days_until_expiry }}
</div>
</div>
</div>
</div>
{% endif %}
<div class="services-grid">
{% for name, service in services.items() %}
{% if service.status.value != 'unknown' or name != 'external' %}
<div class="service-card">
<div class="service-header">
<span class="service-name">{{ service.display_name }}</span>
@@ -322,7 +491,17 @@
</div>
</div>
<div class="metric">
<div class="metric-label">Uptime</div>
<div class="metric-label">Avg 24h</div>
<div class="metric-value {% if service.avg_latency_24h and service.avg_latency_24h < 200 %}good{% elif service.avg_latency_24h and service.avg_latency_24h < 500 %}warning{% elif service.avg_latency_24h %}bad{% endif %}">
{% if service.avg_latency_24h %}
{{ "%.0f"|format(service.avg_latency_24h) }} ms
{% else %}
{% endif %}
</div>
</div>
<div class="metric">
<div class="metric-label">Uptime 24h</div>
<div class="metric-value {% if service.uptime_percent >= 99 %}good{% elif service.uptime_percent >= 95 %}warning{% else %}bad{% endif %}">
{{ "%.1f"|format(service.uptime_percent) }}%
</div>
@@ -333,20 +512,49 @@
<div class="metric-value">{{ service.version }}</div>
</div>
{% endif %}
{% if service.last_incident %}
<div class="metric">
<div class="metric-label">Last Incident</div>
<div class="metric-value warning">{{ service.last_incident.strftime('%d.%m %H:%M') }}</div>
</div>
{% endif %}
</div>
{% if service.latency_history and service.latency_history|length > 1 %}
<div class="latency-chart">
<canvas id="chart-{{ name }}"></canvas>
</div>
{% endif %}
{% if service.message %}
<div class="service-message">{{ service.message }}</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
<h2>Recent Incidents</h2>
<div class="incidents-list">
{% if incidents and incidents|length > 0 %}
{% for incident in incidents %}
<div class="incident-item">
<div class="incident-info">
<span class="incident-dot {% if incident.resolved_at %}resolved{% else %}open{% endif %}"></span>
<div>
<div class="incident-service">{{ incident.service_name | title }}</div>
<div class="incident-message">{{ incident.message or 'Service unavailable' }}</div>
</div>
</div>
<div class="incident-time">
{{ incident.started_at[:16].replace('T', ' ') }}
{% if incident.resolved_at %}
- Resolved
{% else %}
- Ongoing
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="no-incidents">
No recent incidents
</div>
{% endif %}
</div>
<center>
<button class="refresh-btn" onclick="refreshStatus(this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -363,6 +571,55 @@
</div>
<script>
// Initialize latency charts
{% for name, service in services.items() %}
{% if service.latency_history and service.latency_history|length > 1 %}
(function() {
const ctx = document.getElementById('chart-{{ name }}').getContext('2d');
const data = {{ service.latency_history | tojson }};
new Chart(ctx, {
type: 'line',
data: {
labels: data.map(d => ''),
datasets: [{
data: data.map(d => d.latency_ms),
borderColor: '#00d4ff',
backgroundColor: 'rgba(0, 212, 255, 0.1)',
fill: true,
tension: 0.4,
pointRadius: 0,
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => ctx.raw.toFixed(0) + ' ms'
}
}
},
scales: {
x: { display: false },
y: {
display: false,
beginAtZero: true
}
},
interaction: {
intersect: false,
mode: 'index'
}
}
});
})();
{% endif %}
{% endfor %}
async function refreshStatus(btn) {
btn.classList.add('loading');
btn.disabled = true;