CI / ruff · mypy · pytest (pull_request) Successful in 1h3m54s
- invites.max_uses/uses: обычное приглашение — 1 вход, демо-QR — до DEMO_INVITE_MAX_USES (50), живёт DEMO_INVITE_TTL_HOURS (3 ч); захват атомарный, UPDATE … WHERE uses < max_uses - users.guest: вошедшие по демо-QR; «🧹 Отключить гостей демо» отзывает всех и гасит демо-QR - «👥 Участники»: кнопка «🎤 Демо-QR», гости в списке — 🎟 - миграция 0003 (ADD COLUMN без batch — SQLite не пересоздаёт users); проверена на Postgres - SPEC A-3a, ADR-017, DEMO.md, .env.example Refs #55 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
203 lines
8.3 KiB
Python
203 lines
8.3 KiB
Python
"""Invites, members and revocation (SPEC §3.1, ADR-011)."""
|
|
|
|
import hashlib
|
|
import secrets
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
from enum import StrEnum
|
|
from typing import Any, cast
|
|
|
|
from sqlalchemy import CursorResult, delete, func, select, update
|
|
|
|
from app.config import AccessSettings
|
|
from app.db.models import AccessAttempt, Draft, FsmState, Invite, Role, User, utcnow
|
|
from app.db.session import SessionMaker
|
|
|
|
LAST_SEEN_EVERY = timedelta(minutes=5)
|
|
ATTEMPT_WINDOW = timedelta(hours=1)
|
|
|
|
|
|
class RedeemStatus(StrEnum):
|
|
OK = "ok"
|
|
ALREADY_MEMBER = "already_member"
|
|
REJECTED = "rejected" # unknown, used or expired — one answer, no hints which tokens existed
|
|
THROTTLED = "throttled"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TgUser:
|
|
tg_id: int
|
|
full_name: str
|
|
username: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CreatedInvite:
|
|
token: str
|
|
expires_at: datetime
|
|
max_uses: int = 1
|
|
|
|
|
|
def hash_token(token: str) -> str:
|
|
return hashlib.sha256(token.encode()).hexdigest()
|
|
|
|
|
|
class AccessService:
|
|
def __init__(self, sessions: SessionMaker, settings: AccessSettings, clock: Callable[[], datetime] = utcnow):
|
|
self._sessions = sessions
|
|
self._settings = settings
|
|
self._clock = clock
|
|
|
|
async def bootstrap_admins(self) -> None:
|
|
"""ADMIN_IDS become active admins in the DB; everybody else gets in only by invite (SPEC A-8)."""
|
|
async with self._sessions.begin() as s:
|
|
for tg_id in self._settings.admin_ids:
|
|
user = await s.get(User, tg_id)
|
|
if user is None:
|
|
s.add(User(tg_id=tg_id, full_name=f"admin {tg_id}", role=Role.ADMIN, joined_at=self._clock()))
|
|
else:
|
|
user.role, user.is_active = Role.ADMIN, True
|
|
|
|
async def get_member(self, tg_id: int) -> User | None:
|
|
async with self._sessions() as s:
|
|
user = await s.get(User, tg_id)
|
|
return user if user is not None and user.is_active else None
|
|
|
|
async def touch(self, member: User, seen: TgUser) -> None:
|
|
"""Refresh name and last-seen, at most every few minutes — not a write per update."""
|
|
now = self._clock()
|
|
last = member.last_seen_at
|
|
if (
|
|
last is not None
|
|
and now - _aware(last) < LAST_SEEN_EVERY
|
|
and (member.full_name, member.username) == (seen.full_name, seen.username)
|
|
):
|
|
return
|
|
async with self._sessions.begin() as s:
|
|
await s.execute(
|
|
update(User)
|
|
.where(User.tg_id == member.tg_id)
|
|
.values(last_seen_at=now, full_name=seen.full_name, username=seen.username)
|
|
)
|
|
|
|
async def create_invite(self, admin_id: int, demo: bool = False) -> CreatedInvite:
|
|
"""A personal one-time invite, or the demo QR (#55): up to `demo_invite_max_uses` entrants, short-lived."""
|
|
token = secrets.token_urlsafe(16) # 128 bits, 22 chars of [A-Za-z0-9_-]: fits the 64-char deep link payload
|
|
if demo:
|
|
max_uses, ttl = self._settings.demo_invite_max_uses, self._settings.demo_invite_ttl_hours
|
|
else:
|
|
max_uses, ttl = 1, self._settings.invite_ttl_hours
|
|
expires_at = self._clock() + timedelta(hours=ttl)
|
|
async with self._sessions.begin() as s:
|
|
s.add(
|
|
Invite(
|
|
token_hash=hash_token(token),
|
|
created_by=admin_id,
|
|
created_at=self._clock(),
|
|
expires_at=expires_at,
|
|
max_uses=max_uses,
|
|
)
|
|
)
|
|
return CreatedInvite(token=token, expires_at=expires_at, max_uses=max_uses)
|
|
|
|
async def redeem(self, token: str, who: TgUser) -> RedeemStatus:
|
|
now = self._clock()
|
|
try:
|
|
async with self._sessions.begin() as s:
|
|
existing = await s.get(User, who.tg_id)
|
|
if existing is not None and existing.is_active:
|
|
return RedeemStatus.ALREADY_MEMBER
|
|
failed = await s.scalar(
|
|
select(func.count())
|
|
.select_from(AccessAttempt)
|
|
.where(
|
|
AccessAttempt.tg_id == who.tg_id,
|
|
AccessAttempt.ok.is_(False),
|
|
AccessAttempt.at > now - ATTEMPT_WINDOW,
|
|
)
|
|
)
|
|
if (failed or 0) >= self._settings.max_failed_attempts:
|
|
return RedeemStatus.THROTTLED
|
|
|
|
invite = await s.scalar(select(Invite).where(Invite.token_hash == hash_token(token)))
|
|
if invite is None or invite.uses >= invite.max_uses or _aware(invite.expires_at) <= now:
|
|
raise _Rejected
|
|
guest = invite.max_uses > 1
|
|
|
|
if existing is None:
|
|
s.add(
|
|
User(
|
|
tg_id=who.tg_id,
|
|
full_name=who.full_name,
|
|
username=who.username,
|
|
role=Role.MEMBER,
|
|
invited_by=invite.created_by,
|
|
guest=guest,
|
|
joined_at=now,
|
|
last_seen_at=now,
|
|
)
|
|
)
|
|
else: # revoked earlier, invited again
|
|
existing.is_active, existing.role, existing.invited_by = True, Role.MEMBER, invite.created_by
|
|
existing.guest = guest
|
|
existing.full_name, existing.username, existing.last_seen_at = who.full_name, who.username, now
|
|
await s.flush() # the user row must exist before invites.used_by points at it
|
|
|
|
# Claim atomically: when two people race for the last use only one UPDATE matches the row,
|
|
# the loser's transaction (with its freshly created user) is rolled back.
|
|
result = await s.execute(
|
|
update(Invite)
|
|
.where(Invite.id == invite.id, Invite.uses < Invite.max_uses)
|
|
.values(uses=Invite.uses + 1, used_by=who.tg_id, used_at=now)
|
|
)
|
|
if cast(CursorResult[Any], result).rowcount != 1:
|
|
raise _Rejected
|
|
s.add(AccessAttempt(tg_id=who.tg_id, at=now, ok=True))
|
|
except _Rejected:
|
|
async with self._sessions.begin() as s:
|
|
s.add(AccessAttempt(tg_id=who.tg_id, at=now, ok=False))
|
|
return RedeemStatus.REJECTED
|
|
return RedeemStatus.OK
|
|
|
|
async def list_members(self) -> list[User]:
|
|
async with self._sessions() as s:
|
|
rows = await s.scalars(select(User).where(User.is_active.is_(True)).order_by(User.role, User.joined_at))
|
|
return list(rows)
|
|
|
|
async def revoke(self, tg_id: int) -> bool:
|
|
"""Cut a member off at once: their updates are ignored from now on, draft and dialog state are dropped."""
|
|
async with self._sessions.begin() as s:
|
|
user = await s.get(User, tg_id)
|
|
if user is None or not user.is_active or user.role is Role.ADMIN:
|
|
return False
|
|
user.is_active = False
|
|
await s.execute(delete(Draft).where(Draft.user_id == tg_id))
|
|
# FSM keys are "bot:chat:user:…"; in a private chat chat == user
|
|
await s.execute(delete(FsmState).where(FsmState.key.like(f"%:{tg_id}:{tg_id}:%")))
|
|
return True
|
|
|
|
async def revoke_guests(self) -> int:
|
|
"""After the talk: cut off everybody who came by the demo QR and expire it — a photo of it lets nobody in."""
|
|
now = self._clock()
|
|
async with self._sessions.begin() as s:
|
|
await s.execute(update(Invite).where(Invite.max_uses > 1, Invite.expires_at > now).values(expires_at=now))
|
|
ids = list(
|
|
await s.scalars(
|
|
select(User.tg_id).where(User.guest.is_(True), User.is_active.is_(True), User.role != Role.ADMIN)
|
|
)
|
|
)
|
|
revoked = 0
|
|
for tg_id in ids:
|
|
revoked += await self.revoke(tg_id)
|
|
return revoked
|
|
|
|
|
|
class _Rejected(Exception):
|
|
pass
|
|
|
|
|
|
def _aware(value: datetime) -> datetime:
|
|
# SQLite hands back naive datetimes even for timezone=True columns; everything we store is UTC.
|
|
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|