"""Lightweight server-side rate limiter (no external deps). Per-key (user/IP + action) sliding-window counter, persisted to disk so restarts don't fully reset abuse protection. In-memory fast path + on-disk snapshot. """ from __future__ import annotations import json import os import threading import time from ..config import Config _lock = threading.Lock() _mem: dict[str, list[float]] = {} # key -> list of recent timestamps def _key(action: str, ident: str) -> str: return f"{action}:{ident}" def _load(): p = Config.DATA_DIR / "ratelimit.json" try: if p.exists(): with open(p, encoding="utf-8") as fh: return json.load(fh) except Exception: pass return {} def _save(): try: p = Config.DATA_DIR / "ratelimit.json" p.parent.mkdir(parents=True, exist_ok=True) tmp = p.with_suffix(".json.tmp") with open(tmp, "w", encoding="utf-8") as fh: json.dump(_mem, fh) os.replace(tmp, p) except Exception: pass def check(action: str, ident: str, *, limit: int, window: int) -> bool: """Return True if allowed; False if the limit in `window` seconds was exceeded.""" key = _key(action, ident) now = time.time() with _lock: rec = list(_mem.get(key) or _load().get(key) or []) rec = [t for t in rec if now - t < window] if len(rec) >= limit: _mem[key] = rec return False rec.append(now) _mem[key] = rec # opportunistically persist (bounded writes) try: if int(now) % 5 == 0: _save() except Exception: pass return True