"""Проверка и очистка состояния прогресса, которое клиент хранит на сервере. Сервер не доверяет присланному JSON: оставляем только известные поля, приводим числа к допустимым диапазонам и ограничиваем размеры. Так испорченные данные не попадут ни на другие устройства, ни в интерфейс. """ from __future__ import annotations import math import re MAX_HISTORY = 600 MAX_PROGRESS = 200 MAX_KEYS = 200 _ID_MAX = 40 _ID_RE = re.compile(r"[^A-Za-z0-9_.-]") _LANGS = ("ru", "en") _KINDS = ("lesson", "practice", "weak") class StateError(ValueError): """Состояние имеет неверную структуру.""" def _num(value: object, low: float, high: float) -> int | float: if isinstance(value, bool) or not isinstance(value, (int, float)): return 0 if isinstance(value, float) and not math.isfinite(value): return 0 clamped = min(max(value, low), high) return clamped def _ident(value: object) -> str: return _ID_RE.sub("", value)[:_ID_MAX] if isinstance(value, str) else "" def _is_key_char(char: object) -> bool: # одиночные суррогаты не кодируются в UTF-8 и ломали бы сохранение return isinstance(char, str) and len(char) == 1 and not 0xD800 <= ord(char) <= 0xDFFF def _choice(value: object, allowed: tuple[str, ...]) -> str: return value if isinstance(value, str) and value in allowed else "" def _history_row(row: dict) -> dict: return { "t": _num(row.get("t"), 0, 4_000_000_000_000), "id": _ident(row.get("id")), "k": _choice(row.get("k"), _KINDS), "lang": _choice(row.get("lang"), _LANGS), "cpm": _num(row.get("cpm"), 0, 3000), "acc": _num(row.get("acc"), 0, 100), "err": _num(row.get("err"), 0, 100_000), "len": _num(row.get("len"), 0, 100_000), "sec": _num(row.get("sec"), 0, 1_000_000), } def _progress_row(row: dict) -> dict: return { "best": _num(row.get("best"), 0, 3000), "acc": _num(row.get("acc"), 0, 100), "stars": int(_num(row.get("stars"), 0, 3)), "tries": int(_num(row.get("tries"), 0, 100_000)), } def _key_row(row: dict) -> dict: return {name: _num(row.get(name), 0, 1_000_000_000) for name in ("n", "err", "ms", "ln")} def _section(data: dict, name: str, kind: type) -> object: value = data.get(name) if value is None: return kind() if not isinstance(value, kind): raise StateError(f"поле {name} имеет неверный тип") return value def clean_state(data: object) -> dict: """Возвращает очищенную копию состояния или поднимает StateError.""" if not isinstance(data, dict): raise StateError("ожидался объект") progress_in = _section(data, "progress", dict) history_in = _section(data, "history", list) keys_in = _section(data, "keys", dict) progress = {} for key, row in sorted(progress_in.items()): # порядок как у клиента: обрезка одинакова на обеих сторонах ident = _ident(key) if ident and isinstance(row, dict) and len(progress) < MAX_PROGRESS: progress[ident] = _progress_row(row) history = [_history_row(row) for row in history_in if isinstance(row, dict)][-MAX_HISTORY:] keys = {} for lang in _LANGS: per_lang = keys_in.get(lang, {}) if not isinstance(per_lang, dict): raise StateError("поле keys имеет неверный тип") cleaned = {} for char, row in per_lang.items(): if _is_key_char(char) and isinstance(row, dict) and len(cleaned) < MAX_KEYS: cleaned[char] = _key_row(row) keys[lang] = cleaned return {"progress": progress, "history": history, "keys": keys}