Files
ForbiddenStarsApp/backend/app/routers/admin.py
T
NotBigGhostandClaude Opus 5 976b8fc622 Объявления: модель, API игрока и админки, очистка HTML
Объявление администрации показывается игроку окном в свой период, пока игрок
не закроет его («Понятно»). Отметка о закрытии хранится на сервере с номером
версии: правка с «показать заново» поднимает версию, и закрывшие прежнюю
увидят объявление снова — ответ помечен updated («обновлено»). Флаг
show_to_new_players=false прячет объявление от зарегистрировавшихся после
начала показа. Пересекающиеся объявления идут от старого к новому.

Текст приходит HTML-ом из редактора админки и сохраняется только после
очистки по белому списку (b, em, mark и mark.red, p, br): атрибуты
отбрасываются, script/style/svg — вместе с содержимым, текст экранируется
заново. Фронт вставляет только этот HTML.

API: GET /api/announcements/pending, POST /api/announcements/{id}/ack;
админка — список со статусом и счётчиком «закрыли N из M», создание, правка,
«снять с показа», удаление, всё в аудит. SSE-событие announcements активным
игрокам. Миграция 0015 идемпотентная.

Тесты: очистка (XSS-попытки, вложенные div), права, период и порядок,
«новые игроки», повторный показ, снятие, удаление, валидация. #84

Перенесено в main без рейтинга из a0a0e52; миграция 0015 — сразу от 0013
(как в ec0445f).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:57:46 +03:00

614 lines
22 KiB
Python

"""Админ-роутер: отдельный вход (логин+пароль) и управление сущностями."""
from __future__ import annotations
from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile
from fastapi.responses import FileResponse
from sqlmodel import Session
from app.auth.admin_login import login_admin
from app.auth.deps import get_current_admin
from app.core import security
from app.core.security import client_ip
from app.core.errors import InvalidCredentialsError, NotFoundError
from app.core.timeutil import iso_utc
from app.db.session import get_session
from app.models import User
from app.routers.matches import attachment_read, build_match_read
from app.schemas import api as s
from app.services.match_service import ParticipantInput
from app.services import (
achievement_service,
admin_service,
announcement_service,
attachment_service,
audit_service,
faction_service,
match_service,
notify,
user_service,
)
_ACHIEVEMENT_ICON_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ
router = APIRouter(prefix="/admin", tags=["admin"])
# ─── Аутентификация админа ───────────────────────────────────────────────────
@router.post("/auth/login", response_model=s.AdminMe)
def admin_login(
body: s.AdminLogin,
request: Request,
response: Response,
session: Session = Depends(get_session),
) -> s.AdminMe:
try:
admin = login_admin(session, body.username, body.password, client_ip(request))
except InvalidCredentialsError:
# Неудачную попытку фиксируем в аудите (перебор пароля админа — прямой путь к
# полному контролю). Серию таких попыток ограничивает throttle в login_admin (#56).
audit_service.record(
session,
actor_id=None,
action="login_failed",
entity_type="admin",
payload={"username": (body.username or "")[:64]},
ip=client_ip(request),
user_agent=request.headers.get("user-agent"),
)
session.commit()
raise
security.set_admin_session(response, admin.id, admin.token_version) # type: ignore[arg-type]
audit_service.record(
session,
actor_id=admin.id,
action="login",
entity_type="admin",
entity_id=admin.id,
ip=client_ip(request),
user_agent=request.headers.get("user-agent"),
)
session.commit()
return s.AdminMe(id=admin.id, nickname=admin.nickname, role=admin.role) # type: ignore[arg-type]
@router.post("/auth/logout", response_model=s.OkResponse)
def admin_logout(request: Request, response: Response) -> s.OkResponse:
security.revoke_session_token(request, security.ADMIN_COOKIE, security.AUDIENCE_ADMIN)
security.clear_admin_session(response)
return s.OkResponse()
@router.get("/me", response_model=s.AdminMe)
def admin_me(admin: User = Depends(get_current_admin)) -> s.AdminMe:
return s.AdminMe(id=admin.id, nickname=admin.nickname, role=admin.role) # type: ignore[arg-type]
# ─── Пользователи ────────────────────────────────────────────────────────────
def _admin_user_read(u: User) -> s.AdminUserRead:
return s.AdminUserRead(
id=u.id, # type: ignore[arg-type]
nickname=u.nickname,
role=u.role,
is_active=u.is_active,
auth_provider=u.auth_provider,
telegram_id=u.telegram_id,
created_at=iso_utc(u.created_at),
has_password=u.password_hash is not None,
)
@router.get("/users", response_model=list[s.AdminUserRead])
def list_users(
query: str | None = Query(None),
session: Session = Depends(get_session),
_admin: User = Depends(get_current_admin),
) -> list[s.AdminUserRead]:
return [_admin_user_read(u) for u in admin_service.list_users(session, query)]
@router.patch("/users/{user_id}", response_model=s.AdminUserRead)
def update_user(
user_id: int,
body: s.AdminUserUpdate,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.AdminUserRead:
u = admin_service.update_user(session, user_id, nickname=body.nickname, is_active=body.is_active)
audit_service.record(
session,
actor_id=admin.id,
action="update",
entity_type="user",
entity_id=user_id,
payload=body.model_dump(exclude_none=True),
ip=client_ip(request),
)
session.commit()
return _admin_user_read(u)
@router.put("/users/{user_id}/password", response_model=s.AdminUserRead)
def set_user_password(
user_id: int,
body: s.AdminPasswordSet,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.AdminUserRead:
"""Задать игроку новый пароль — когда он забыл свой. Сам пароль в аудит не пишется."""
u = admin_service.set_player_password(session, user_id, body.new_password)
audit_service.record(
session,
actor_id=admin.id,
action="update",
entity_type="user",
entity_id=user_id,
payload={"password": "set_by_admin"},
ip=client_ip(request),
)
session.commit()
return _admin_user_read(u)
# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
# routers/dev_admin.py (исключён из прод/тест-образа). В проде аккаунт только
# отключается (PATCH is_active), удалять нельзя.
# ─── Группы ──────────────────────────────────────────────────────────────────
@router.get("/groups", response_model=list[s.AdminGroupRead])
def list_groups(
session: Session = Depends(get_session),
_admin: User = Depends(get_current_admin),
) -> list[s.AdminGroupRead]:
return [
s.AdminGroupRead(
id=g.id, name=g.name, owner_id=g.owner_id, created_at=iso_utc(g.created_at) # type: ignore[arg-type]
)
for g in admin_service.list_groups(session)
]
@router.delete("/groups/{group_id}", response_model=s.OkResponse)
def delete_group(
group_id: int,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.OkResponse:
admin_service.delete_group(session, group_id)
audit_service.record(
session, actor_id=admin.id, action="delete", entity_type="group", entity_id=group_id,
ip=client_ip(request),
)
session.commit()
return s.OkResponse()
# ─── Партии ──────────────────────────────────────────────────────────────────
@router.get("/matches", response_model=list[s.AdminMatchRead])
def list_matches(
group_id: int | None = Query(None),
user_id: int | None = Query(None),
faction_id: int | None = Query(None),
limit: int = Query(200, ge=1, le=500),
offset: int = Query(0, ge=0),
session: Session = Depends(get_session),
_admin: User = Depends(get_current_admin),
) -> list[s.AdminMatchRead]:
return [
s.AdminMatchRead(
id=m.id, # type: ignore[arg-type]
group_id=m.group_id,
group_name=gname,
status=m.status,
played_at=str(m.played_at),
duration_minutes=m.duration_minutes,
win_reason=m.win_reason, # type: ignore[arg-type]
player_count=m.player_count,
created_by=m.created_by,
created_at=iso_utc(m.created_at),
)
for m, gname in admin_service.list_matches(
session,
limit=limit,
offset=offset,
group_id=group_id,
user_id=user_id,
faction_id=faction_id,
)
]
@router.get("/matches/{match_id}", response_model=s.MatchRead)
def get_match(
match_id: int,
session: Session = Depends(get_session),
_admin: User = Depends(get_current_admin),
) -> s.MatchRead:
match = match_service.get_match(session, match_id)
return build_match_read(session, match, can_modify=True)
@router.patch("/matches/{match_id}", response_model=s.MatchRead)
def update_match(
match_id: int,
body: s.MatchUpdate,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.MatchRead:
match = match_service.get_match(session, match_id)
participants = None
if body.participants is not None:
participants = [
ParticipantInput(
user_id=p.user_id,
faction_id=p.faction_id,
place=p.place,
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment,
)
for p in body.participants
]
match = match_service.update_match(
session,
match,
played_at=body.played_at,
overall_comment=body.overall_comment,
overall_comment_set=("overall_comment" in body.model_fields_set),
win_reason=body.win_reason,
win_reason_set=("win_reason" in body.model_fields_set),
participants=participants,
expected_version=body.expected_version,
)
audit_service.record(
session,
actor_id=admin.id,
action="update",
entity_type="match",
entity_id=match.id,
ip=client_ip(request),
)
session.commit()
notify.match_changed(session, match)
return build_match_read(session, match, can_modify=True)
# ─── Фракции (переименование во всей системе) ─────────────────────────────────
@router.get("/factions", response_model=list[s.FactionRead])
def list_factions(
session: Session = Depends(get_session),
_admin: User = Depends(get_current_admin),
) -> list[s.FactionRead]:
return [
s.FactionRead(
id=f.id, code=f.code, name_ru=f.name_ru, expansion_id=f.expansion_id # type: ignore[arg-type]
)
for f in faction_service.list_factions(session)
]
@router.patch("/factions/{faction_id}", response_model=s.FactionRead)
def rename_faction(
faction_id: int,
body: s.FactionRename,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.FactionRead:
f = admin_service.rename_faction(session, faction_id, body.name_ru)
audit_service.record(
session,
actor_id=admin.id,
action="update",
entity_type="faction",
entity_id=faction_id,
payload={"name_ru": f.name_ru},
ip=client_ip(request),
)
session.commit()
return s.FactionRead(
id=f.id, code=f.code, name_ru=f.name_ru, expansion_id=f.expansion_id # type: ignore[arg-type]
)
@router.delete("/matches/{match_id}", response_model=s.OkResponse)
def delete_match(
match_id: int,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.OkResponse:
group_id = match_service.get_match(session, match_id).group_id # для уведомления
# До удаления: каскад унесёт участников вместе с партией.
participant_ids = notify.match_participant_ids(session, match_id)
admin_service.delete_match(session, match_id)
audit_service.record(
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
ip=client_ip(request),
)
session.commit()
notify.match_removed(session, match_id, group_id, participant_ids)
return s.OkResponse()
# ─── Медиа партии (админ правит в любой момент) ───────────────────────────────
@router.get("/matches/{match_id}/attachments", response_model=list[s.AttachmentRead])
def admin_list_attachments(
match_id: int,
session: Session = Depends(get_session),
_admin: User = Depends(get_current_admin),
) -> list[s.AttachmentRead]:
match_service.get_match(session, match_id) # 404 если партии нет
return [
attachment_read(a, f"/api/admin/matches/{match_id}")
for a in attachment_service.list_for_match(session, match_id)
]
@router.post("/matches/{match_id}/attachments", response_model=s.AttachmentRead)
def admin_add_attachment(
match_id: int,
file: UploadFile = File(...),
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.AttachmentRead:
match = match_service.get_match(session, match_id)
content, ext = user_service.read_capped_image(
file, attachment_service.MAX_ATTACHMENT_BYTES, "Файл слишком большой (макс. 10 МБ)."
)
att = attachment_service.add_photo(
session, match, admin, content, ext, user_service.avatar_media_type(ext)
)
notify.match_changed(session, match)
return attachment_read(att, f"/api/admin/matches/{match_id}")
@router.delete("/matches/{match_id}/attachments/{attachment_id}", response_model=s.OkResponse)
def admin_delete_attachment(
match_id: int,
attachment_id: int,
session: Session = Depends(get_session),
_admin: User = Depends(get_current_admin),
) -> s.OkResponse:
match = match_service.get_match(session, match_id)
attachment_service.delete(session, match, attachment_id)
notify.match_changed(session, match)
return s.OkResponse()
@router.get("/matches/{match_id}/attachments/{attachment_id}")
def admin_get_attachment(
match_id: int,
attachment_id: int,
session: Session = Depends(get_session),
_admin: User = Depends(get_current_admin),
) -> FileResponse:
match_service.get_match(session, match_id)
att = attachment_service.get_for_match(session, match_id, attachment_id)
path = attachment_service.file_path(att)
if not path.exists():
raise NotFoundError("Файл не найден.")
return FileResponse(
path, media_type=att.mime_type, headers={"Cache-Control": "private, max-age=3600"}
)
# ─── Ачивки (определения; выдача игрокам — на будущее) ────────────────────────
@router.get("/achievements", response_model=list[s.AchievementRead])
def list_achievements(
_admin: User = Depends(get_current_admin),
) -> list[dict]:
return achievement_service.list_achievements()
@router.post("/achievements", response_model=s.AchievementRead)
def create_achievement(
body: s.AchievementCreate,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> dict:
ach = achievement_service.create(body.name, body.description, body.condition)
audit_service.record(
session, actor_id=admin.id, action="create", entity_type="achievement",
payload={"slug": ach["slug"], "name": ach["name"]},
ip=client_ip(request),
)
session.commit()
return ach
@router.patch("/achievements/{slug}", response_model=s.AchievementRead)
def update_achievement(
slug: str,
body: s.AchievementUpdate,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> dict:
ach = achievement_service.update(
slug, name=body.name, description=body.description, condition=body.condition
)
audit_service.record(
session, actor_id=admin.id, action="update", entity_type="achievement",
payload={"slug": slug}, ip=client_ip(request),
)
session.commit()
return ach
@router.put("/achievements/{slug}/icon", response_model=s.AchievementRead)
def upload_achievement_icon(
slug: str,
file: UploadFile = File(...),
_admin: User = Depends(get_current_admin),
) -> dict:
content, ext = user_service.read_capped_image(
file, _ACHIEVEMENT_ICON_MAX_BYTES, "Файл слишком большой (макс. 2 МБ)."
)
return achievement_service.set_icon(slug, content, ext)
@router.delete("/achievements/{slug}", response_model=s.OkResponse)
def delete_achievement(
slug: str,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.OkResponse:
achievement_service.delete(slug)
audit_service.record(
session, actor_id=admin.id, action="delete", entity_type="achievement",
payload={"slug": slug}, ip=client_ip(request),
)
session.commit()
return s.OkResponse()
# ─── Объявления ──────────────────────────────────────────────────────────────
def _announcement_read(item: dict) -> s.AdminAnnouncementRead:
a = item["a"]
return s.AdminAnnouncementRead(
id=a.id,
title=a.title,
body_html=a.body_html,
starts_at=iso_utc(a.starts_at), # type: ignore[arg-type]
ends_at=iso_utc(a.ends_at), # type: ignore[arg-type]
show_to_new_players=a.show_to_new_players,
revision=a.revision,
status=item["status"],
seen_count=item["seen"],
audience_count=item["audience"],
created_at=iso_utc(a.created_at), # type: ignore[arg-type]
updated_at=iso_utc(a.updated_at), # type: ignore[arg-type]
)
def _announcement_by_id(session: Session, announcement_id: int) -> s.AdminAnnouncementRead:
return _announcement_read(announcement_service.admin_item(session, announcement_id))
@router.get("/announcements", response_model=list[s.AdminAnnouncementRead])
def list_announcements(
session: Session = Depends(get_session),
_admin: User = Depends(get_current_admin),
) -> list[s.AdminAnnouncementRead]:
return [_announcement_read(i) for i in announcement_service.list_admin(session)]
@router.post("/announcements", response_model=s.AdminAnnouncementRead)
def create_announcement(
body: s.AnnouncementWrite,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.AdminAnnouncementRead:
a = announcement_service.create(
session,
title=body.title,
body_html=body.body_html,
starts_at=body.starts_at,
ends_at=body.ends_at,
show_to_new_players=body.show_to_new_players,
actor_id=admin.id,
)
audit_service.record(
session, actor_id=admin.id, action="create", entity_type="announcement",
entity_id=a.id, payload={"title": a.title}, ip=client_ip(request),
)
session.commit()
notify.announcements_changed(session)
return _announcement_by_id(session, a.id) # type: ignore[arg-type]
@router.put("/announcements/{announcement_id}", response_model=s.AdminAnnouncementRead)
def update_announcement(
announcement_id: int,
body: s.AnnouncementUpdate,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.AdminAnnouncementRead:
a = announcement_service.update(
session,
announcement_id,
title=body.title,
body_html=body.body_html,
starts_at=body.starts_at,
ends_at=body.ends_at,
show_to_new_players=body.show_to_new_players,
reshow=body.reshow,
)
audit_service.record(
session, actor_id=admin.id, action="update", entity_type="announcement",
entity_id=announcement_id,
payload={"title": a.title, "reshow": body.reshow, "revision": a.revision},
ip=client_ip(request),
)
session.commit()
notify.announcements_changed(session)
return _announcement_by_id(session, announcement_id)
@router.post("/announcements/{announcement_id}/stop", response_model=s.AdminAnnouncementRead)
def stop_announcement(
announcement_id: int,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.AdminAnnouncementRead:
"""«Снять с показа»: период идущего объявления заканчивается сейчас."""
announcement_service.stop(session, announcement_id)
audit_service.record(
session, actor_id=admin.id, action="update", entity_type="announcement",
entity_id=announcement_id, payload={"stopped": True}, ip=client_ip(request),
)
session.commit()
notify.announcements_changed(session)
return _announcement_by_id(session, announcement_id)
@router.delete("/announcements/{announcement_id}", response_model=s.OkResponse)
def delete_announcement(
announcement_id: int,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.OkResponse:
announcement_service.delete(session, announcement_id)
audit_service.record(
session, actor_id=admin.id, action="delete", entity_type="announcement",
entity_id=announcement_id, ip=client_ip(request),
)
session.commit()
notify.announcements_changed(session)
return s.OkResponse()
# ─── Журнал аудита ───────────────────────────────────────────────────────────
@router.get("/audit-logs", response_model=s.AuditLogList)
def audit_logs(
action: str | None = Query(None),
entity_type: str | None = Query(None),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
session: Session = Depends(get_session),
_admin: User = Depends(get_current_admin),
) -> dict:
return admin_service.list_audit_logs(
session, action=action, entity_type=entity_type, limit=limit, offset=offset
)