chore: add CI, lint config, login rate limit and split large modules
- Rate-limit failed logins (8 per 15 min per IP or username, 429 + Retry-After) - Add ruff/mypy config and GitHub Actions workflow (ruff, pytest, tsc, tests, build) - Apply ruff autofixes (import order, pyupgrade) - Extract rpy_helpers.py, engine-util.ts, TranslationParts.tsx and translation-types.ts from oversized modules - Add dependency-free React UI tests - Refresh README and ARCHITECTURE (Naninovel/Tomie, status and plans) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
57a4edcbfd
commit
bbbcc6a5d8
42 files changed
+678
-391
No files matched your search
@@ -0,0 +1,55 @@
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
|
||||
class FailureLimiter:
|
||||
"""Sliding-window counter of failed attempts, kept in process memory.
|
||||
|
||||
Several uvicorn workers would each count separately, which only loosens the
|
||||
limit; the service runs a single backend process.
|
||||
"""
|
||||
|
||||
def __init__(self, max_failures: int, window_seconds: float) -> None:
|
||||
self.max_failures = max_failures
|
||||
self.window = window_seconds
|
||||
self._hits: dict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
def _prune(self, key: str, now: float) -> deque[float]:
|
||||
hits = self._hits[key]
|
||||
while hits and now - hits[0] > self.window:
|
||||
hits.popleft()
|
||||
if not hits:
|
||||
self._hits.pop(key, None)
|
||||
return hits
|
||||
|
||||
def retry_after(self, *keys: str) -> int:
|
||||
"""Seconds until a blocked key is allowed again, 0 if none is blocked."""
|
||||
now = time.monotonic()
|
||||
wait = 0.0
|
||||
for key in keys:
|
||||
hits = self._prune(key, now)
|
||||
if len(hits) >= self.max_failures:
|
||||
wait = max(wait, hits[0] + self.window - now)
|
||||
return int(wait) + 1 if wait > 0 else 0
|
||||
|
||||
def fail(self, *keys: str) -> None:
|
||||
now = time.monotonic()
|
||||
for key in keys:
|
||||
self._prune(key, now)
|
||||
self._hits[key].append(now)
|
||||
|
||||
def reset(self, *keys: str) -> None:
|
||||
for key in keys:
|
||||
self._hits.pop(key, None)
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
login_limiter = FailureLimiter(max_failures=8, window_seconds=15 * 60)
|
||||
Reference in new issue
Block a user