| 20 |
20 |
value is the N-th hop from the END (`HFMD_TRUSTED_PROXY_HOPS`, default 1), never the first one, which the client |
| 21 |
21 |
controls. The header is ignored altogether when the direct peer is not a loopback / private address. |
| 22 |
22 |
|
| 23 |
|
−Account endpoints (`/v1/limits`, `/v1/me/*`, `/v1/admin/*`) are not charged. `/health`, `/openapi.json` |
|
23 |
+A signed-in browser (session cookie, no Bearer) is the `user:<id>` principal with the account's tier — the |
|
24 |
+site (charts, playground) gets account limits without pasting a key. Account endpoints (`/v1/limits`, `/v1/me/*`, |
|
25 |
+`/v1/admin/*`) are not charged. `/health`, `/openapi.json` |
| 24 |
26 |
and the SPA are exempt. Redis down → data endpoints fail open (no headers), the API never goes down because of |
| 25 |
27 |
quotas. |
| 26 |
28 |
|
| 77 |
79 |
|
| 78 |
80 |
@dataclass(frozen=True) |
| 79 |
81 |
class Principal: |
| 80 |
|
− id: str # key:<id> | ip:<hash> |
| 81 |
|
− kind: str # "key" | "keyless" |
|
82 |
+ id: str # key:<id> | user:<id> | ip:<hash> |
|
83 |
+ kind: str # "key" | "session" | "keyless" |
| 82 |
84 |
tier: Tier |
| 83 |
85 |
user_id: int | None = None |
| 84 |
86 |
api_key_id: int | None = None |
| 191 |
193 |
return info |
| 192 |
194 |
|
| 193 |
195 |
|
|
196 |
+def _lookup_session_user(uid: int, version: int) -> tuple[str, str] | None: |
|
197 |
+ """(tier, status) of a signed-in user whose cookie carries `version` — None when unknown or stale.""" |
|
198 |
+ from sqlalchemy import select |
|
199 |
+ |
|
200 |
+ from accounts.models import User |
|
201 |
+ from core.db import session |
|
202 |
+ with session() as s: |
|
203 |
+ row = s.execute(select(User.tier, User.status, User.session_version).where(User.id == uid)).first() |
|
204 |
+ if row is None or int(row[2] or 1) != int(version): |
|
205 |
+ return None |
|
206 |
+ return row[0], row[1] |
|
207 |
+ |
|
208 |
+ |
|
209 |
+def resolve_session(scope: dict) -> Principal | None: |
|
210 |
+ """Browser session (HttpOnly cookie) → the account's tier on data endpoints, principal `user:<id>`. |
|
211 |
+ Cached like keys (same LRU + Redis generation, so tier/status/version changes apply within 60 s).""" |
|
212 |
+ try: |
|
213 |
+ from starlette.requests import Request |
|
214 |
+ |
|
215 |
+ from accounts import security |
|
216 |
+ claims = security.read_session(Request(scope)) |
|
217 |
+ except Exception: |
|
218 |
+ return None |
|
219 |
+ if claims is None: |
|
220 |
+ return None |
|
221 |
+ cache_key = f"sess:{claims.user_id}:{claims.version}" |
|
222 |
+ gen = rl.keys_version() |
|
223 |
+ hit, info = _key_cache.get(cache_key, gen) |
|
224 |
+ if not hit: |
|
225 |
+ info = _lookup_session_user(claims.user_id, claims.version) |
|
226 |
+ _key_cache.put(cache_key, info, gen) |
|
227 |
+ if info is None: |
|
228 |
+ return None |
|
229 |
+ tier_name, status = info |
|
230 |
+ if status in ("disabled", "deleted"): |
|
231 |
+ return None # anonymous, not an error: the browser keeps keyless access |
|
232 |
+ return Principal(f"user:{claims.user_id}", "session", tier_for(tier_name), claims.user_id, None) |
|
233 |
+ |
|
234 |
+ |
| 194 |
235 |
def extract_key(headers: Headers, query: QueryParams) -> str | None: |
| 195 |
236 |
auth = headers.get("authorization", "") |
| 196 |
237 |
if auth[:7].lower() == "bearer ": |
| 242 |
283 |
raise ApiError(403, "ACCOUNT_DISABLED", "This API key belongs to a disabled account. " |
| 243 |
284 |
f"Contact {settings.contact_email}.") |
| 244 |
285 |
return Principal(f"key:{info.key_id}", "key", tier_for(info.tier), info.user_id, info.key_id) |
|
286 |
+ sess = resolve_session(scope) |
|
287 |
+ if sess is not None: |
|
288 |
+ return sess |
| 245 |
289 |
return Principal(f"ip:{hash_ip(ip)}", "keyless", TIERS["keyless"]) |
| 246 |
290 |
|
| 247 |
291 |
|
| 367 |
411 |
except ApiError as exc: |
| 368 |
412 |
return await exc.response()(scope, receive, send) |
| 369 |
413 |
tier = principal.tier |
|
414 |
+ # `user_id` is the API-key credential consumed by accounts.deps (Bearer only): a session principal must NOT |
|
415 |
+ # populate it, otherwise a stale cookie (version bumped) would authenticate through the key fallback. |
| 370 |
416 |
state.update(principal=principal.id, principal_kind=principal.kind, tier=tier.name, |
| 371 |
|
− max_rows=tier.max_rows_per_request, user_id=principal.user_id, api_key_id=principal.api_key_id) |
|
417 |
+ max_rows=tier.max_rows_per_request, api_key_id=principal.api_key_id, |
|
418 |
+ user_id=principal.user_id if principal.kind == "key" else None) |
| 372 |
419 |
|
| 373 |
420 |
if principal.keyless and (route_tags(scope) & KEY_REQUIRED_TAGS): |
| 374 |
421 |
return await _auth_required_error().response()(scope, receive, send) |
| 461 |
508 |
return await send({"type": "websocket.close", "code": 4401, "reason": exc.message[:120]}) |
| 462 |
509 |
tier = principal.tier |
| 463 |
510 |
state.update(principal=principal.id, principal_kind=principal.kind, tier=tier.name, |
| 464 |
|
− max_rows=tier.max_rows_per_request, user_id=principal.user_id, api_key_id=principal.api_key_id) |
|
511 |
+ max_rows=tier.max_rows_per_request, api_key_id=principal.api_key_id, |
|
512 |
+ user_id=principal.user_id if principal.kind == "key" else None) |
| 465 |
513 |
if principal.keyless and (route_tags(scope) & KEY_REQUIRED_TAGS): |
| 466 |
514 |
return await send({"type": "websocket.close", "code": 4401, "reason": "API key required (create a free account)"}) |
| 467 |
515 |
decision = rl.apply_tier(principal.id, tier, req_cost=1) |
| 473 |
521 |
|
| 474 |
522 |
|
| 475 |
523 |
def _tier_for_principal(principal: str): |
| 476 |
|
− """Tier of a `key:<id>` principal (SQLite lookup, 60 s cache) — keyless principals get the keyless tier.""" |
|
524 |
+ """Tier of a `key:<id>` / `user:<id>` principal (SQLite lookup, 60 s cache) — keyless principals get the keyless tier.""" |
| 477 |
525 |
from . import tiers as _tiers |
|
526 |
+ if principal.startswith("user:"): |
|
527 |
+ gen = rl.keys_version() |
|
528 |
+ hit, tier = _key_cache.get("tier:" + principal, gen) |
|
529 |
+ if hit: |
|
530 |
+ return tier |
|
531 |
+ tier_name = "free" |
|
532 |
+ try: |
|
533 |
+ from sqlalchemy import select |
|
534 |
+ from accounts.models import User |
|
535 |
+ from core.db import session |
|
536 |
+ with session() as s: |
|
537 |
+ row = s.execute(select(User.tier).where(User.id == int(principal[5:]))).first() |
|
538 |
+ if row and row[0]: |
|
539 |
+ tier_name = row[0] |
|
540 |
+ except Exception: |
|
541 |
+ pass |
|
542 |
+ tier = _tiers.TIERS.get(tier_name, _tiers.TIERS["free"]) |
|
543 |
+ _key_cache.put("tier:" + principal, tier, gen) |
|
544 |
+ return tier |
| 478 |
545 |
if not principal.startswith("key:"): |
| 479 |
546 |
return _tiers.TIERS["keyless"] |
| 480 |
547 |
gen = rl.keys_version() |