mobucheeri commited on
Commit
325b0dd
·
verified ·
1 Parent(s): 4784e01

Add per-install API keys and rate limiting

Browse files
Files changed (1) hide show
  1. api/app.py +119 -8
api/app.py CHANGED
@@ -4,10 +4,15 @@ os.environ.setdefault("OPENBLAS_NUM_THREADS", "1")
4
  import sys
5
  import re
6
  import json
 
 
 
 
 
7
  sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
8
  import numpy as np
9
  import torch
10
- from fastapi import FastAPI, HTTPException
11
  from fastapi.middleware.cors import CORSMiddleware
12
  from pydantic import BaseModel
13
  from src.config import checkpoints, device, max_seq_len, data_processed, numeric_features
@@ -318,15 +323,118 @@ class PredictResponse(BaseModel):
318
  threshold: float = 0.5
319
  margin: float = 0.1
320
 
321
- app = FastAPI(title="Twitter Bot Detector API", version="1.0.0")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
  app.add_middleware(
323
  CORSMiddleware,
324
  allow_origin_regex=r"^(https://(x|twitter)\.com|chrome-extension://.*)$",
325
  allow_credentials=False,
326
  allow_methods=["POST", "GET"],
327
- allow_headers=["Content-Type"],
328
  )
329
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  @app.on_event("startup")
331
  async def startup():
332
  try:
@@ -338,7 +446,8 @@ async def startup():
338
  print(f"[!] Model load failed: {e}")
339
 
340
  @app.post("/predict", response_model=PredictResponse)
341
- async def predict_endpoint(request: PredictRequest):
 
342
  try:
343
  return PredictResponse(**predict(request.model_dump()))
344
  except FileNotFoundError as e:
@@ -353,9 +462,10 @@ class BatchResponse(BaseModel):
353
  results: list[PredictResponse]
354
 
355
  @app.post("/predict_batch", response_model=BatchResponse)
356
- async def predict_batch_endpoint(request: BatchRequest):
357
  if len(request.profiles) > 50:
358
- raise HTTPException(status_code=429, detail="batch limit is 50 profiles")
 
359
  try:
360
  results = [PredictResponse(**predict(p.model_dump())) for p in request.profiles]
361
  return BatchResponse(results=results)
@@ -413,9 +523,10 @@ def score_thread_reply(profile):
413
  return {"username": username, "flag": flag, "reasons": reasons}
414
 
415
  @app.post("/predict_thread_batch", response_model=ThreadReplyBatchResponse)
416
- async def predict_thread_batch_endpoint(request: ThreadReplyBatchRequest):
417
  if len(request.replies) > 100:
418
- raise HTTPException(status_code=429, detail="batch limit is 100 replies")
 
419
  results = [ThreadReplyResponse(**score_thread_reply(r.model_dump())) for r in request.replies]
420
  return ThreadReplyBatchResponse(results=results)
421
 
 
4
  import sys
5
  import re
6
  import json
7
+ import hmac
8
+ import hashlib
9
+ import base64
10
+ import secrets
11
+ import time
12
  sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
13
  import numpy as np
14
  import torch
15
+ from fastapi import FastAPI, HTTPException, Request, Header, Depends
16
  from fastapi.middleware.cors import CORSMiddleware
17
  from pydantic import BaseModel
18
  from src.config import checkpoints, device, max_seq_len, data_processed, numeric_features
 
323
  threshold: float = 0.5
324
  margin: float = 0.1
325
 
326
+ # --- API key auth and rate limiting ---
327
+ # Keys are HMAC-signed with API_SECRET, so they verify statelessly: no database.
328
+ # With API_SECRET unset (local dev), auth is disabled entirely. With it set,
329
+ # keys are issued and rate limited per key, but missing keys are only rejected
330
+ # once REQUIRE_API_KEY=1, so older extension versions keep working during rollout.
331
+ API_SECRET = os.environ.get("API_SECRET", "")
332
+ REQUIRE_API_KEY = os.environ.get("REQUIRE_API_KEY", "") == "1"
333
+ KEY_PREFIX = "xbd1"
334
+
335
+ RATE_LIMITS = {
336
+ "free": {"per_minute": 60, "per_day": 1000},
337
+ "anon": {"per_minute": 60, "per_day": 300},
338
+ }
339
+ REGISTER_LIMIT_PER_HOUR = 5
340
+
341
+ def _sign_key_payload(payload):
342
+ digest = hmac.new(API_SECRET.encode(), payload.encode(), hashlib.sha256).digest()
343
+ return base64.urlsafe_b64encode(digest).decode().rstrip("=")
344
+
345
+ def issue_api_key(tier="free"):
346
+ payload = f"{tier}.{secrets.token_urlsafe(9)}.{int(time.time())}"
347
+ return f"{KEY_PREFIX}.{payload}.{_sign_key_payload(payload)}"
348
+
349
+ def verify_api_key(key):
350
+ parts = key.split(".")
351
+ if len(parts) != 5 or parts[0] != KEY_PREFIX:
352
+ return None
353
+ payload = ".".join(parts[1:4])
354
+ if not hmac.compare_digest(parts[4], _sign_key_payload(payload)):
355
+ return None
356
+ return {"tier": parts[1], "key_id": parts[2]}
357
+
358
+ _rate_buckets = {}
359
+ _register_buckets = {}
360
+
361
+ def _prune(store, max_entries, max_age):
362
+ if len(store) <= max_entries:
363
+ return
364
+ cutoff = time.time() - max_age
365
+ for stale in [k for k, v in store.items() if v["seen"] < cutoff]:
366
+ del store[stale]
367
+
368
+ def _client_ip(request):
369
+ forwarded = request.headers.get("x-forwarded-for", "")
370
+ if forwarded:
371
+ return forwarded.split(",")[0].strip()
372
+ return request.client.host if request.client else "unknown"
373
+
374
+ def check_rate_limit(bucket, tier, cost=1):
375
+ limits = RATE_LIMITS.get(tier, RATE_LIMITS["anon"])
376
+ now = time.time()
377
+ minute, day = int(now // 60), int(now // 86400)
378
+ state = _rate_buckets.get(bucket)
379
+ if state is None:
380
+ _prune(_rate_buckets, 20000, 86400)
381
+ state = {"minute": minute, "minute_count": 0, "day": day, "day_count": 0, "seen": now}
382
+ _rate_buckets[bucket] = state
383
+ if state["minute"] != minute:
384
+ state["minute"], state["minute_count"] = minute, 0
385
+ if state["day"] != day:
386
+ state["day"], state["day_count"] = day, 0
387
+ state["seen"] = now
388
+ if state["day_count"] + cost > limits["per_day"]:
389
+ retry = (day + 1) * 86400 - int(now)
390
+ raise HTTPException(status_code=429, detail="daily scan limit reached",
391
+ headers={"Retry-After": str(retry)})
392
+ if state["minute_count"] + cost > limits["per_minute"]:
393
+ retry = max((minute + 1) * 60 - int(now), 1)
394
+ raise HTTPException(status_code=429, detail="too many requests, slow down",
395
+ headers={"Retry-After": str(retry)})
396
+ state["minute_count"] += cost
397
+ state["day_count"] += cost
398
+
399
+ async def api_key_guard(request: Request, x_api_key: str = Header(default="", alias="X-API-Key")):
400
+ if not API_SECRET:
401
+ return {"tier": "free", "bucket": f"ip:{_client_ip(request)}"}
402
+ key_info = verify_api_key(x_api_key) if x_api_key else None
403
+ if key_info is None:
404
+ if REQUIRE_API_KEY:
405
+ raise HTTPException(status_code=401, detail="missing or invalid API key")
406
+ return {"tier": "anon", "bucket": f"ip:{_client_ip(request)}"}
407
+ return {"tier": key_info["tier"], "bucket": f"key:{key_info['key_id']}"}
408
+
409
+ app = FastAPI(title="Twitter Bot Detector API", version="1.1.0")
410
  app.add_middleware(
411
  CORSMiddleware,
412
  allow_origin_regex=r"^(https://(x|twitter)\.com|chrome-extension://.*)$",
413
  allow_credentials=False,
414
  allow_methods=["POST", "GET"],
415
+ allow_headers=["Content-Type", "X-API-Key"],
416
  )
417
 
418
+ @app.post("/register")
419
+ async def register(request: Request):
420
+ if not API_SECRET:
421
+ raise HTTPException(status_code=503, detail="registration unavailable: API_SECRET not configured")
422
+ ip = _client_ip(request)
423
+ now = time.time()
424
+ hour = int(now // 3600)
425
+ state = _register_buckets.get(ip)
426
+ if state is None or state["hour"] != hour:
427
+ _prune(_register_buckets, 5000, 86400)
428
+ state = {"hour": hour, "count": 0, "seen": now}
429
+ _register_buckets[ip] = state
430
+ state["seen"] = now
431
+ if state["count"] >= REGISTER_LIMIT_PER_HOUR:
432
+ retry = (hour + 1) * 3600 - int(now)
433
+ raise HTTPException(status_code=429, detail="too many registrations from this address",
434
+ headers={"Retry-After": str(retry)})
435
+ state["count"] += 1
436
+ return {"api_key": issue_api_key("free"), "tier": "free"}
437
+
438
  @app.on_event("startup")
439
  async def startup():
440
  try:
 
446
  print(f"[!] Model load failed: {e}")
447
 
448
  @app.post("/predict", response_model=PredictResponse)
449
+ async def predict_endpoint(request: PredictRequest, auth: dict = Depends(api_key_guard)):
450
+ check_rate_limit(auth["bucket"], auth["tier"])
451
  try:
452
  return PredictResponse(**predict(request.model_dump()))
453
  except FileNotFoundError as e:
 
462
  results: list[PredictResponse]
463
 
464
  @app.post("/predict_batch", response_model=BatchResponse)
465
+ async def predict_batch_endpoint(request: BatchRequest, auth: dict = Depends(api_key_guard)):
466
  if len(request.profiles) > 50:
467
+ raise HTTPException(status_code=413, detail="batch limit is 50 profiles")
468
+ check_rate_limit(auth["bucket"], auth["tier"], cost=len(request.profiles))
469
  try:
470
  results = [PredictResponse(**predict(p.model_dump())) for p in request.profiles]
471
  return BatchResponse(results=results)
 
523
  return {"username": username, "flag": flag, "reasons": reasons}
524
 
525
  @app.post("/predict_thread_batch", response_model=ThreadReplyBatchResponse)
526
+ async def predict_thread_batch_endpoint(request: ThreadReplyBatchRequest, auth: dict = Depends(api_key_guard)):
527
  if len(request.replies) > 100:
528
+ raise HTTPException(status_code=413, detail="batch limit is 100 replies")
529
+ check_rate_limit(auth["bucket"], auth["tier"])
530
  results = [ThreadReplyResponse(**score_thread_reply(r.model_dump())) for r in request.replies]
531
  return ThreadReplyBatchResponse(results=results)
532