Spaces:
Running
Running
Add per-install API keys and rate limiting
Browse files- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
|
|
|
| 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=
|
|
|
|
| 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 |
|