PUT /api/users/me/password задаёт или меняет пароль. Первый раз текущий пароль
не нужен: так его задают аккаунты из Telegram и все, кто появился до паролей.
Если пароль уже есть, нужен текущий. Иначе оставленная открытой сессия позволила
бы отобрать аккаунт насовсем, поэтому подбор текущего тоже ограничен: 5 неудач
на аккаунт за 15 минут. Ошибка 403 WRONG_CURRENT_PASSWORD, а не 401, чтобы фронт
не принял её за истёкшую сессию.
POST /api/users/me/telegram привязывает Telegram к аккаунту, созданному по
паролю. Подпись виджета проверяется так же, как при входе, ник не меняется.
Связка пишется в auth_identity, как при регистрации через Telegram, поэтому
следующий вход через Telegram попадает в этот аккаунт. Telegram, привязанный к
другому аккаунту, даёт 409 TELEGRAM_TAKEN, повторная привязка — 409
TELEGRAM_ALREADY_LINKED.
PUT /api/admin/users/{id}/password — способ восстановить забытый пароль: почту
приложение не хранит. Работает только для игроков, пароль админа по-прежнему
задаётся в .env. В аудит пишется только факт смены, без пароля. Сборка
AdminUserRead вынесена в хелпер, в ответе появилось has_password.
#24
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
380 lines
16 KiB
Python
380 lines
16 KiB
Python
"""Пользователи: создание из внешней личности, ник, активная группа, профиль."""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from sqlmodel import Session, select
|
|
|
|
from app.auth.password import validate_new_password
|
|
from app.auth.provider import ExternalIdentity
|
|
from app.core.config import settings
|
|
from app.core.errors import (
|
|
NicknameTakenError,
|
|
NotFoundError,
|
|
TelegramAlreadyLinkedError,
|
|
TelegramTakenError,
|
|
ValidationError,
|
|
)
|
|
from app.core.security import hash_password
|
|
from app.core.timeutil import utcnow
|
|
from app.models import AuthIdentity, Faction, User
|
|
|
|
_NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE)
|
|
_BIO_MAX = 500
|
|
|
|
# Аватары: файл на диске под settings.upload_dir/avatars, путь относительный.
|
|
_AVATAR_SUBDIR = "avatars"
|
|
_IMAGE_MEDIA = {"png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
|
|
|
|
|
|
def get_user(session: Session, user_id: int) -> User:
|
|
user = session.get(User, user_id)
|
|
if user is None:
|
|
raise NotFoundError("Пользователь не найден.")
|
|
return user
|
|
|
|
|
|
def nickname_available(session: Session, nickname: str, exclude_user_id: int | None = None) -> bool:
|
|
stmt = select(User).where(User.nickname == nickname)
|
|
existing = session.exec(stmt).first()
|
|
return existing is None or existing.id == exclude_user_id
|
|
|
|
|
|
def search_users(session: Session, query: str, limit: int = 3) -> list[User]:
|
|
"""Подсказки по нику: активные игроки (role='player'), без учёта регистра.
|
|
|
|
SQLite LIKE/lower() сворачивают регистр только для ASCII, а ники бывают
|
|
кириллицей — поэтому выбираем активных игроков и фильтруем/ранжируем в
|
|
Python (casefold). Пользователей десятки, полный проход дёшев; при росте
|
|
до тысяч — заменить на индексированную колонку nickname_lower.
|
|
Сначала префиксные совпадения, затем подстрочные, внутри — по алфавиту.
|
|
"""
|
|
q = (query or "").strip().casefold()
|
|
if not q:
|
|
return []
|
|
users = session.exec(
|
|
select(User).where(User.role == "player", User.is_active == True) # noqa: E712
|
|
).all()
|
|
matched = [u for u in users if q in u.nickname.casefold()]
|
|
matched.sort(
|
|
key=lambda u: (0 if u.nickname.casefold().startswith(q) else 1, u.nickname.casefold())
|
|
)
|
|
return matched[:limit]
|
|
|
|
|
|
def nickname_format_ok(nickname: str) -> bool:
|
|
"""Ник подходит по формату (2–64, буквы/цифры/пробел/.-_)."""
|
|
return bool(_NICK_RE.match((nickname or "").strip()))
|
|
|
|
|
|
def _unique_nickname(session: Session, base: str) -> str:
|
|
base = (base or "Игрок").strip()[:60] or "Игрок"
|
|
candidate = base
|
|
suffix = 1
|
|
while session.exec(select(User).where(User.nickname == candidate)).first() is not None:
|
|
suffix += 1
|
|
candidate = f"{base} {suffix}"
|
|
return candidate
|
|
|
|
|
|
def find_by_identity(session: Session, identity: ExternalIdentity) -> User | None:
|
|
"""Существующий пользователь по внешней личности (provider+external_id).
|
|
|
|
Для Telegram external_id — это НЕИЗМЕНЯЕМЫЙ числовой id аккаунта (не @тег), поэтому
|
|
вход с любого устройства/после смены тега ведёт к одному и тому же пользователю.
|
|
"""
|
|
link = session.exec(
|
|
select(AuthIdentity).where(
|
|
AuthIdentity.provider == identity.provider,
|
|
AuthIdentity.external_id == identity.external_id,
|
|
)
|
|
).first()
|
|
return get_user(session, link.user_id) if link is not None else None
|
|
|
|
|
|
def _create_from_identity(session: Session, identity: ExternalIdentity, nickname: str) -> User:
|
|
user = User(
|
|
nickname=nickname,
|
|
role="player",
|
|
auth_provider=identity.provider,
|
|
telegram_id=identity.telegram_id,
|
|
)
|
|
session.add(user)
|
|
session.flush()
|
|
session.add(
|
|
AuthIdentity(
|
|
user_id=user.id, # type: ignore[arg-type]
|
|
provider=identity.provider,
|
|
external_id=identity.external_id,
|
|
)
|
|
)
|
|
session.commit()
|
|
session.refresh(user)
|
|
return user
|
|
|
|
|
|
def get_or_create_from_identity(session: Session, identity: ExternalIdentity) -> User:
|
|
"""Найти по личности или создать, авто-подбирая уникальный ник (dev-вход по нику)."""
|
|
user = find_by_identity(session, identity)
|
|
if user is not None:
|
|
return user
|
|
nickname = _unique_nickname(session, identity.suggested_nickname or identity.external_id)
|
|
return _create_from_identity(session, identity, nickname)
|
|
|
|
|
|
def register_from_identity(
|
|
session: Session, identity: ExternalIdentity, nickname: str
|
|
) -> User:
|
|
"""Создать пользователя с ЯВНО выбранным ником (регистрация через Telegram).
|
|
|
|
В отличие от get_or_create — ник не подбирается автоматически: занятый/неподходящий
|
|
ник вызывает ошибку, чтобы пользователь выбрал другой.
|
|
"""
|
|
nickname = (nickname or "").strip()
|
|
if not nickname_format_ok(nickname):
|
|
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
|
|
if not nickname_available(session, nickname):
|
|
raise NicknameTakenError()
|
|
return _create_from_identity(session, identity, nickname)
|
|
|
|
|
|
def register_local(session: Session, nickname: str, password: str) -> User:
|
|
"""Регистрация по логину и паролю. Логин — это ник; Telegram можно привязать позже."""
|
|
nickname = (nickname or "").strip()
|
|
if not nickname_format_ok(nickname):
|
|
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
|
|
if not nickname_available(session, nickname):
|
|
raise NicknameTakenError()
|
|
validate_new_password(password)
|
|
user = User(
|
|
nickname=nickname,
|
|
role="player",
|
|
auth_provider="local",
|
|
password_hash=hash_password(password),
|
|
)
|
|
session.add(user)
|
|
session.commit()
|
|
session.refresh(user)
|
|
return user
|
|
|
|
|
|
def set_password(session: Session, user: User, new_password: str) -> User:
|
|
"""Записать новый пароль. Проверку текущего делает вызывающий (игрок — да, админ — нет)."""
|
|
validate_new_password(new_password)
|
|
user.password_hash = hash_password(new_password)
|
|
session.add(user)
|
|
session.commit()
|
|
session.refresh(user)
|
|
return user
|
|
|
|
|
|
def link_telegram(session: Session, user: User, identity: ExternalIdentity) -> User:
|
|
"""Привязать Telegram к существующему аккаунту; ник не меняется.
|
|
|
|
После привязки вход через Telegram попадает в этот аккаунт: find_by_identity находит
|
|
его по той же связке provider+external_id, что создаёт регистрация через Telegram."""
|
|
already = session.exec(
|
|
select(AuthIdentity).where(
|
|
AuthIdentity.user_id == user.id, AuthIdentity.provider == identity.provider
|
|
)
|
|
).first()
|
|
if user.telegram_id is not None or already is not None:
|
|
raise TelegramAlreadyLinkedError()
|
|
taken_by_id = session.exec(select(User).where(User.telegram_id == identity.telegram_id)).first()
|
|
if find_by_identity(session, identity) is not None or taken_by_id is not None:
|
|
raise TelegramTakenError()
|
|
user.telegram_id = identity.telegram_id
|
|
session.add(user)
|
|
session.add(
|
|
AuthIdentity(
|
|
user_id=user.id, # type: ignore[arg-type]
|
|
provider=identity.provider,
|
|
external_id=identity.external_id,
|
|
)
|
|
)
|
|
session.commit()
|
|
session.refresh(user)
|
|
return user
|
|
|
|
|
|
def update_nickname(session: Session, user: User, new_nickname: str) -> User:
|
|
new_nickname = (new_nickname or "").strip()
|
|
if not nickname_format_ok(new_nickname):
|
|
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
|
|
if not nickname_available(session, new_nickname, exclude_user_id=user.id):
|
|
raise NicknameTakenError()
|
|
user.nickname = new_nickname
|
|
session.add(user)
|
|
session.commit()
|
|
session.refresh(user)
|
|
return user
|
|
|
|
|
|
def set_active_group(session: Session, user: User, group_id: int | None) -> User:
|
|
if group_id is not None:
|
|
from app.services import group_service # избегаем цикла импорта
|
|
|
|
if group_service.get_membership(session, group_id, user.id) is None:
|
|
raise ValidationError("Нельзя сделать активной группу, в которой вы не состоите.")
|
|
user.active_group_id = group_id
|
|
session.add(user)
|
|
session.commit()
|
|
session.refresh(user)
|
|
return user
|
|
|
|
|
|
# ─── Профиль: «о себе», аватар ────────────────────────────────────────────────
|
|
|
|
def avatar_url_for(user_id: int, avatar_path: str | None, updated_at: datetime | None) -> str | None:
|
|
"""URL аватара с кэш-бастингом по updated_at. None, если аватара нет.
|
|
|
|
Один источник для build_me, публичного профиля и лидерборда — чтобы клиент
|
|
подтягивал новую картинку после смены (файл перезаписывается по тому же пути)."""
|
|
if not avatar_path:
|
|
return None
|
|
# В БД время наивное и хранится в UTC. .timestamp() у наивного значения считает
|
|
# его локальным, и версия разъезжалась с лидербордом, где то же поле считает SQL
|
|
# (strftime('%s') читает его как UTC) — один аватар качался браузером дважды.
|
|
version = 0
|
|
if updated_at is not None:
|
|
aware = updated_at if updated_at.tzinfo else updated_at.replace(tzinfo=timezone.utc)
|
|
version = int(aware.timestamp())
|
|
return f"/api/users/{user_id}/avatar?v={version}"
|
|
|
|
|
|
def update_bio(session: Session, user: User, bio: str | None) -> User:
|
|
bio = (bio or "").strip()
|
|
if len(bio) > _BIO_MAX:
|
|
raise ValidationError(f"«О себе»: не более {_BIO_MAX} символов.")
|
|
user.bio = bio or None
|
|
session.add(user)
|
|
session.commit()
|
|
session.refresh(user)
|
|
return user
|
|
|
|
|
|
_HISTORY_MODES = {"all", "best"}
|
|
_HISTORY_DETAILS = {"compact", "full"}
|
|
|
|
|
|
def update_history_prefs(
|
|
session: Session, user: User, *, mode: str | None = None, detail: str | None = None
|
|
) -> User:
|
|
"""Витрина истории партий: что показывать и насколько подробно. None — не менять."""
|
|
if mode is not None:
|
|
if mode not in _HISTORY_MODES:
|
|
raise ValidationError("Неизвестный режим истории партий.")
|
|
user.history_mode = mode
|
|
if detail is not None:
|
|
if detail not in _HISTORY_DETAILS:
|
|
raise ValidationError("Неизвестная подробность истории партий.")
|
|
user.history_detail = detail
|
|
session.add(user)
|
|
session.commit()
|
|
session.refresh(user)
|
|
return user
|
|
|
|
|
|
def update_favorite_faction(session: Session, user: User, faction_id: int | None) -> User:
|
|
"""Любимая фракция — личный выбор игрока; None очищает выбор."""
|
|
if faction_id is not None and session.get(Faction, faction_id) is None:
|
|
raise ValidationError("Такой фракции не существует.")
|
|
user.favorite_faction_id = faction_id
|
|
session.add(user)
|
|
session.commit()
|
|
session.refresh(user)
|
|
return user
|
|
|
|
|
|
def read_capped_image(file, max_bytes: int, limit_message: str) -> tuple[bytes, str]:
|
|
"""Прочитать загруженный файл с ограничением размера и убедиться, что это картинка.
|
|
|
|
Читаем на байт больше лимита: так превышение видно, не загружая файл целиком.
|
|
Один хелпер на все загрузки (аватар, фото партии, иконка ачивки) — иначе
|
|
правка лимита или списка форматов расходится по четырём роутерам."""
|
|
content = file.file.read(max_bytes + 1)
|
|
if len(content) > max_bytes:
|
|
raise ValidationError(limit_message)
|
|
ext = sniff_image_ext(content)
|
|
if ext is None:
|
|
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
|
|
return content, ext
|
|
|
|
|
|
def sniff_image_ext(content: bytes) -> str | None:
|
|
"""Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка."""
|
|
if content.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
return "png"
|
|
if content.startswith(b"\xff\xd8\xff"):
|
|
return "jpg"
|
|
if len(content) >= 12 and content[0:4] == b"RIFF" and content[8:12] == b"WEBP":
|
|
return "webp"
|
|
return None
|
|
|
|
|
|
def avatar_media_type(ext: str) -> str:
|
|
return _IMAGE_MEDIA.get(ext, "application/octet-stream")
|
|
|
|
|
|
def avatar_abs_path(avatar_path: str | None) -> Path | None:
|
|
"""Абсолютный путь файла аватара (или None), для отдачи через FileResponse."""
|
|
if not avatar_path:
|
|
return None
|
|
return Path(settings.upload_dir) / avatar_path
|
|
|
|
|
|
def set_avatar(session: Session, user: User, content: bytes, ext: str) -> User:
|
|
avatars_dir = Path(settings.upload_dir) / _AVATAR_SUBDIR
|
|
avatars_dir.mkdir(parents=True, exist_ok=True)
|
|
# Удаляем прежний файл (мог быть с другим расширением), затем пишем новый.
|
|
old = avatar_abs_path(user.avatar_path)
|
|
if old and old.exists():
|
|
try:
|
|
old.unlink()
|
|
except OSError:
|
|
pass
|
|
rel = f"{_AVATAR_SUBDIR}/{user.id}.{ext}"
|
|
(Path(settings.upload_dir) / rel).write_bytes(content)
|
|
user.avatar_path = rel
|
|
# Файл перезаписывается по тому же пути, поэтому при том же расширении avatar_path
|
|
# не меняется, UPDATE не эмитится и onupdate не срабатывает. Без явного бампа
|
|
# кэш-бастер остаётся прежним, и браузер час показывает старую картинку.
|
|
user.updated_at = utcnow()
|
|
session.add(user)
|
|
session.commit()
|
|
session.refresh(user)
|
|
return user
|
|
|
|
|
|
def clear_avatar(session: Session, user: User) -> User:
|
|
path = avatar_abs_path(user.avatar_path)
|
|
if path and path.exists():
|
|
try:
|
|
path.unlink()
|
|
except OSError:
|
|
pass
|
|
user.avatar_path = None
|
|
user.updated_at = utcnow()
|
|
session.add(user)
|
|
session.commit()
|
|
session.refresh(user)
|
|
return user
|
|
|
|
|
|
def public_profile(session: Session, user_id: int) -> dict:
|
|
"""Публичный профиль игрока: ник/«о себе»/аватар + глобальная статистика."""
|
|
from app.services import stats_service # ленивый импорт (избегаем цикла)
|
|
|
|
user = session.get(User, user_id)
|
|
if user is None:
|
|
raise NotFoundError("Пользователь не найден.")
|
|
return {
|
|
"user_id": user.id,
|
|
"nickname": user.nickname,
|
|
"bio": user.bio,
|
|
"avatar_url": avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
|
|
"stats": stats_service.profile_stats(session, user_id, group_id=None),
|
|
}
|