ItsSecretz commited on
Commit
51c405f
·
1 Parent(s): 8ba0077

Add API key auth and admin management routes

Browse files
Files changed (3) hide show
  1. .gitignore +3 -0
  2. auth.py +164 -0
  3. main.py +133 -44
.gitignore CHANGED
@@ -4,3 +4,6 @@ __pycache__/
4
  telegram_bot.py
5
  bot.py
6
  render_bot/
 
 
 
 
4
  telegram_bot.py
5
  bot.py
6
  render_bot/
7
+ web/
8
+ api_keys.json
9
+ PROMPT_FOR_OPUS_VERCEL.md
auth.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import time
4
+ import secrets
5
+ import threading
6
+ from typing import Optional, Dict, Any, List, Tuple
7
+
8
+ KEYS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "api_keys.json")
9
+ ADMIN_MASTER_KEY = os.environ.get("ADMIN_MASTER_KEY", "secretz_master_admin_2026")
10
+ INTERNAL_BOT_KEY = os.environ.get("INTERNAL_BOT_KEY", "sk_internal_telebot_secretz_9921")
11
+ REQUIRE_AUTH = os.environ.get("REQUIRE_AUTH", "false").lower() in ("true", "1", "yes")
12
+
13
+ _lock = threading.Lock()
14
+ # In-memory sliding window for rate limiting: {api_key: [timestamp1, timestamp2, ...]}
15
+ _rate_limits: Dict[str, List[float]] = {}
16
+
17
+
18
+ def load_keys() -> Dict[str, Dict[str, Any]]:
19
+ if os.path.exists(KEYS_FILE):
20
+ try:
21
+ with open(KEYS_FILE, "r", encoding="utf-8") as f:
22
+ return json.load(f)
23
+ except Exception:
24
+ return {}
25
+ return {}
26
+
27
+
28
+ def save_keys(keys_data: Dict[str, Dict[str, Any]]):
29
+ try:
30
+ with open(KEYS_FILE, "w", encoding="utf-8") as f:
31
+ json.dump(keys_data, f, indent=2)
32
+ except Exception as e:
33
+ print(f"Error saving api_keys.json: {e}")
34
+
35
+
36
+ class KeyManager:
37
+ @staticmethod
38
+ def create_key(
39
+ name: str,
40
+ days_valid: Optional[int] = 30,
41
+ rate_limit_per_min: int = 60,
42
+ total_credits: int = 1000
43
+ ) -> Dict[str, Any]:
44
+ """Creates a new API key with expiration and credit limits."""
45
+ api_key = f"sk_live_{secrets.token_hex(16)}"
46
+ now = time.time()
47
+ expires_at = now + (days_valid * 86400) if (days_valid and days_valid > 0) else None
48
+
49
+ key_info = {
50
+ "api_key": api_key,
51
+ "name": name.strip() or "Unnamed Client",
52
+ "created_at": now,
53
+ "expires_at": expires_at,
54
+ "rate_limit_per_min": max(1, int(rate_limit_per_min)),
55
+ "total_credits": int(total_credits), # -1 for unlimited
56
+ "used_credits": 0,
57
+ "status": "active"
58
+ }
59
+
60
+ with _lock:
61
+ keys = load_keys()
62
+ keys[api_key] = key_info
63
+ save_keys(keys)
64
+
65
+ return key_info
66
+
67
+ @staticmethod
68
+ def list_keys() -> List[Dict[str, Any]]:
69
+ with _lock:
70
+ keys = load_keys()
71
+ return list(keys.values())
72
+
73
+ @staticmethod
74
+ def revoke_key(api_key: str) -> bool:
75
+ with _lock:
76
+ keys = load_keys()
77
+ if api_key in keys:
78
+ keys[api_key]["status"] = "revoked"
79
+ save_keys(keys)
80
+ return True
81
+ return False
82
+
83
+ @staticmethod
84
+ def delete_key(api_key: str) -> bool:
85
+ with _lock:
86
+ keys = load_keys()
87
+ if api_key in keys:
88
+ del keys[api_key]
89
+ save_keys(keys)
90
+ return True
91
+ return False
92
+
93
+ @staticmethod
94
+ def validate_and_consume(api_key: Optional[str]) -> Tuple[bool, int, str]:
95
+ """
96
+ Validates API key, checks expiration, rate limit, and consumes 1 credit.
97
+ Returns: (is_valid: bool, error_code: int, error_message: str)
98
+ """
99
+ if not REQUIRE_AUTH:
100
+ return True, 200, "OK"
101
+
102
+ if not api_key:
103
+ return False, 401, "API Key required. Pass header 'X-API-Key' or query parameter 'api_key'."
104
+
105
+ # Master admin key bypass
106
+ if api_key == ADMIN_MASTER_KEY or api_key == INTERNAL_BOT_KEY:
107
+ return True, 200, "OK"
108
+
109
+ with _lock:
110
+ keys = load_keys()
111
+ if api_key not in keys:
112
+ return False, 401, "Invalid API Key."
113
+
114
+ info = keys[api_key]
115
+
116
+ # 1. Status check
117
+ if info.get("status") != "active":
118
+ return False, 403, "API Key has been revoked or deactivated."
119
+
120
+ now = time.time()
121
+
122
+ # 2. Expiry check
123
+ expires_at = info.get("expires_at")
124
+ if expires_at and now > expires_at:
125
+ info["status"] = "expired"
126
+ save_keys(keys)
127
+ return False, 403, "API Key has expired."
128
+
129
+ # 3. Credit check
130
+ total_credits = info.get("total_credits", -1)
131
+ used_credits = info.get("used_credits", 0)
132
+ if total_credits != -1 and used_credits >= total_credits:
133
+ return False, 403, "API Key credit quota exhausted."
134
+
135
+ # 4. Rate limit check (sliding window 60s)
136
+ rate_limit = info.get("rate_limit_per_min", 60)
137
+ if api_key not in _rate_limits:
138
+ _rate_limits[api_key] = []
139
+
140
+ # Keep only timestamps in the last 60 seconds
141
+ cutoff = now - 60.0
142
+ _rate_limits[api_key] = [t for t in _rate_limits[api_key] if t > cutoff]
143
+
144
+ if len(_rate_limits[api_key]) >= rate_limit:
145
+ retry_after = round(60.0 - (now - _rate_limits[api_key][0]), 1)
146
+ return False, 429, f"Rate limit exceeded ({rate_limit} req/min). Retry after {retry_after}s."
147
+
148
+ # Record request & consume credit
149
+ _rate_limits[api_key].append(now)
150
+ info["used_credits"] = used_credits + 1
151
+ save_keys(keys)
152
+
153
+ return True, 200, "OK"
154
+
155
+ @staticmethod
156
+ def get_key_status(api_key: str) -> Optional[Dict[str, Any]]:
157
+ if api_key == ADMIN_MASTER_KEY:
158
+ return {"name": "Master Admin", "status": "active", "total_credits": -1, "used_credits": 0, "expires_at": None}
159
+ if api_key == INTERNAL_BOT_KEY:
160
+ return {"name": "Telegram Bot Internal", "status": "active", "total_credits": -1, "used_credits": 0, "expires_at": None}
161
+
162
+ with _lock:
163
+ keys = load_keys()
164
+ return keys.get(api_key)
main.py CHANGED
@@ -1,15 +1,17 @@
1
  import time
2
  import os
3
  from contextlib import asynccontextmanager
4
- from typing import Optional
5
 
6
- from fastapi import FastAPI, Query, HTTPException, Request
7
  from fastapi.middleware.cors import CORSMiddleware
8
  from fastapi.middleware.gzip import GZipMiddleware
9
  from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
10
  from fastapi.staticfiles import StaticFiles
 
11
 
12
  from engine import LookupEngine
 
13
 
14
  start_time = time.time()
15
  _engine: Optional[LookupEngine] = None
@@ -27,13 +29,12 @@ def get_engine() -> LookupEngine:
27
  async def lifespan(app: FastAPI):
28
  get_engine()
29
  yield
30
- # Cleanup if needed
31
 
32
 
33
  app = FastAPI(
34
- title="HyperLookup API",
35
- description="Enterprise-grade ultra-fast lookup API for 2.5B+ Indian Records (Phone & Aadhar) hosted with $0 cost.",
36
- version="2.0.0",
37
  lifespan=lifespan,
38
  docs_url="/docs",
39
  redoc_url="/redoc"
@@ -42,7 +43,7 @@ app = FastAPI(
42
  # Enable GZip compression
43
  app.add_middleware(GZipMiddleware, minimum_size=1000)
44
 
45
- # Enable CORS for any frontend to consume this API freely
46
  app.add_middleware(
47
  CORSMiddleware,
48
  allow_origins=["*"],
@@ -51,33 +52,106 @@ app.add_middleware(
51
  allow_headers=["*"],
52
  )
53
 
54
- # Mount static files
55
  static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
56
  if os.path.exists(static_dir):
57
  app.mount("/static", StaticFiles(directory=static_dir), name="static")
58
 
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  @app.get("/", response_class=HTMLResponse)
61
  async def serve_home():
62
- """Serves the SaaS dashboard UI."""
63
  index_file = os.path.join(static_dir, "index.html")
64
  if os.path.exists(index_file):
65
  return FileResponse(index_file)
66
- return HTMLResponse("<h1>HyperLookup API is Running</h1><p>Visit <a href='/docs'>/docs</a> for Swagger UI.</p>")
 
67
 
 
 
 
 
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  @app.get("/api/lookup")
70
  async def unified_lookup(
71
- q: Optional[str] = Query(None, description="Search query (phone number or aadhar number)"),
72
  phone: Optional[str] = Query(None, description="Direct 10-digit phone number"),
73
- aadhar: Optional[str] = Query(None, description="Direct 12-digit aadhar number")
 
74
  ):
75
- """
76
- Unified Lookup Endpoint:
77
- - Automatically identifies whether input is a Phone Number or Aadhar Number.
78
- - Routes directly to the exact indexed Parquet partition.
79
- - Returns deduplicated and sanitized records with execution latency in milliseconds.
80
- """
81
  eng = get_engine()
82
  if phone:
83
  return eng.query_by_phone(phone)
@@ -90,43 +164,58 @@ async def unified_lookup(
90
 
91
 
92
  @app.get("/api/phone/{phone_number}")
93
- async def lookup_by_phone(phone_number: str):
94
- """Direct REST endpoint for phone lookup."""
95
  return get_engine().query_by_phone(phone_number)
96
 
97
 
98
  @app.get("/api/aadhar/{aadhar_number}")
99
- async def lookup_by_aadhar(aadhar_number: str):
100
- """Direct REST endpoint for aadhar lookup."""
101
  return get_engine().query_by_aadhar(aadhar_number)
102
 
103
 
104
- @app.get("/api/stats")
105
- async def get_system_stats():
106
- """Returns database size, cache statistics, partition status, and engine uptime."""
107
- eng = get_engine()
108
- uptime_sec = round(time.time() - start_time, 2)
109
- cache_items = len(eng.cache)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  return {
111
- "status": "healthy",
112
- "uptime_seconds": uptime_sec,
113
- "engine_warmed": eng.is_warmed,
114
- "cache_entries": cache_items,
115
- "database_info": {
116
- "total_records_indexed": "2,500,000,000+",
117
- "database_size": "227 GB (Compressed Columnar Parquet)",
118
- "phone_partitions": 7,
119
- "aadhar_partitions": 5,
120
- "engine": "DuckDB Columnar Vectorized Engine",
121
- "storage_layer": "Hugging Face Storage Buckets + Cloudflare CDN"
122
- }
123
  }
124
 
125
 
126
- @app.get("/api/health")
127
- async def health_check():
128
- """Liveness probe for deployment platforms."""
129
- return {"status": "ok", "timestamp": time.time()}
 
 
 
 
 
 
 
 
 
 
 
 
130
 
131
 
132
  if __name__ == "__main__":
 
1
  import time
2
  import os
3
  from contextlib import asynccontextmanager
4
+ from typing import Optional, Dict, Any, List
5
 
6
+ from fastapi import FastAPI, Query, HTTPException, Request, Header, Depends
7
  from fastapi.middleware.cors import CORSMiddleware
8
  from fastapi.middleware.gzip import GZipMiddleware
9
  from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
10
  from fastapi.staticfiles import StaticFiles
11
+ from pydantic import BaseModel, Field
12
 
13
  from engine import LookupEngine
14
+ from auth import KeyManager, ADMIN_MASTER_KEY, INTERNAL_BOT_KEY
15
 
16
  start_time = time.time()
17
  _engine: Optional[LookupEngine] = None
 
29
  async def lifespan(app: FastAPI):
30
  get_engine()
31
  yield
 
32
 
33
 
34
  app = FastAPI(
35
+ title="HyperLookup API & SaaS Engine",
36
+ description="Enterprise-grade ultra-fast lookup API for 2.5B+ Indian Records (Phone & Aadhar) with API Key Auth & Quota Controls.",
37
+ version="2.1.0",
38
  lifespan=lifespan,
39
  docs_url="/docs",
40
  redoc_url="/redoc"
 
43
  # Enable GZip compression
44
  app.add_middleware(GZipMiddleware, minimum_size=1000)
45
 
46
+ # Enable CORS for Vercel, Cloudflare, Localhost and all frontend apps
47
  app.add_middleware(
48
  CORSMiddleware,
49
  allow_origins=["*"],
 
52
  allow_headers=["*"],
53
  )
54
 
55
+ # Mount static directory if exists
56
  static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
57
  if os.path.exists(static_dir):
58
  app.mount("/static", StaticFiles(directory=static_dir), name="static")
59
 
60
 
61
+ # ---------------------------------------------------------
62
+ # Request Models
63
+ # ---------------------------------------------------------
64
+ class CreateKeyRequest(BaseModel):
65
+ name: str = Field(..., example="Client Alpha")
66
+ days_valid: Optional[int] = Field(30, description="Validity period in days. Pass 0 or null for lifetime.")
67
+ rate_limit_per_min: Optional[int] = Field(60, description="Max allowed requests per minute.")
68
+ total_credits: Optional[int] = Field(1000, description="Total number of search queries allowed. Pass -1 for unlimited.")
69
+
70
+
71
+ # ---------------------------------------------------------
72
+ # Authentication Dependency
73
+ # ---------------------------------------------------------
74
+ def verify_api_key(
75
+ request: Request,
76
+ api_key_query: Optional[str] = Query(None, alias="api_key"),
77
+ x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
78
+ authorization: Optional[str] = Header(None)
79
+ ) -> str:
80
+ """Extracts and verifies API key from Header or Query Param."""
81
+ token = None
82
+ if x_api_key:
83
+ token = x_api_key
84
+ elif authorization and authorization.lower().startswith("bearer "):
85
+ token = authorization.split(" ", 1)[1]
86
+ elif api_key_query:
87
+ token = api_key_query
88
+
89
+ is_valid, code, msg = KeyManager.validate_and_consume(token)
90
+ if not is_valid:
91
+ raise HTTPException(status_code=code, detail=msg)
92
+
93
+ return token or "unauthenticated"
94
+
95
+
96
+ def verify_admin_key(
97
+ x_admin_key: Optional[str] = Header(None, alias="X-Admin-Key"),
98
+ admin_key_query: Optional[str] = Query(None, alias="admin_key")
99
+ ):
100
+ """Requires the master admin key to access administrative operations."""
101
+ key = x_admin_key or admin_key_query
102
+ if key != ADMIN_MASTER_KEY:
103
+ raise HTTPException(status_code=401, detail="Unauthorized. Master Admin Key required.")
104
+ return key
105
+
106
+
107
+ # ---------------------------------------------------------
108
+ # Public Endpoints
109
+ # ---------------------------------------------------------
110
  @app.get("/", response_class=HTMLResponse)
111
  async def serve_home():
 
112
  index_file = os.path.join(static_dir, "index.html")
113
  if os.path.exists(index_file):
114
  return FileResponse(index_file)
115
+ return HTMLResponse("<h1>HyperLookup Engine API is Online</h1><p>API documentation available at <a href='/docs'>/docs</a>.</p>")
116
+
117
 
118
+ @app.get("/api/health")
119
+ async def health_check():
120
+ """Liveness probe for monitoring services."""
121
+ return {"status": "ok", "timestamp": time.time()}
122
 
123
+
124
+ @app.get("/api/stats")
125
+ async def get_system_stats():
126
+ """Returns engine health, cache status, and database metrics."""
127
+ eng = get_engine()
128
+ uptime_sec = round(time.time() - start_time, 2)
129
+ return {
130
+ "status": "healthy",
131
+ "uptime_seconds": uptime_sec,
132
+ "engine_warmed": eng.is_warmed,
133
+ "cache_entries": len(eng.cache),
134
+ "database_info": {
135
+ "total_records_indexed": "2,500,000,000+",
136
+ "database_size": "227 GB (Compressed Columnar Parquet)",
137
+ "phone_partitions": 7,
138
+ "aadhar_partitions": 5,
139
+ "engine": "DuckDB Columnar Vectorized Engine",
140
+ "storage_layer": "Hugging Face Storage Buckets + Cloudflare CDN"
141
+ }
142
+ }
143
+
144
+
145
+ # ---------------------------------------------------------
146
+ # Protected Search Endpoints (Requires Valid API Key)
147
+ # ---------------------------------------------------------
148
  @app.get("/api/lookup")
149
  async def unified_lookup(
150
+ q: Optional[str] = Query(None, description="Search query (phone or aadhar)"),
151
  phone: Optional[str] = Query(None, description="Direct 10-digit phone number"),
152
+ aadhar: Optional[str] = Query(None, description="Direct 12-digit aadhar number"),
153
+ _key: str = Depends(verify_api_key)
154
  ):
 
 
 
 
 
 
155
  eng = get_engine()
156
  if phone:
157
  return eng.query_by_phone(phone)
 
164
 
165
 
166
  @app.get("/api/phone/{phone_number}")
167
+ async def lookup_by_phone(phone_number: str, _key: str = Depends(verify_api_key)):
 
168
  return get_engine().query_by_phone(phone_number)
169
 
170
 
171
  @app.get("/api/aadhar/{aadhar_number}")
172
+ async def lookup_by_aadhar(aadhar_number: str, _key: str = Depends(verify_api_key)):
 
173
  return get_engine().query_by_aadhar(aadhar_number)
174
 
175
 
176
+ @app.get("/api/key/status")
177
+ async def check_key_status(api_key: str = Query(..., description="API Key to inspect")):
178
+ status = KeyManager.get_key_status(api_key)
179
+ if not status:
180
+ raise HTTPException(status_code=404, detail="API Key not found.")
181
+ return status
182
+
183
+
184
+ # ---------------------------------------------------------
185
+ # Admin Key Management Endpoints (Master Admin Only)
186
+ # ---------------------------------------------------------
187
+ @app.post("/api/admin/keys/create")
188
+ async def create_api_key(req: CreateKeyRequest, _admin: str = Depends(verify_admin_key)):
189
+ """Generate a new client API key with custom expiry, credits, and rate limit."""
190
+ new_key = KeyManager.create_key(
191
+ name=req.name,
192
+ days_valid=req.days_valid,
193
+ rate_limit_per_min=req.rate_limit_per_min or 60,
194
+ total_credits=req.total_credits if req.total_credits is not None else 1000
195
+ )
196
  return {
197
+ "success": True,
198
+ "message": "API key generated successfully.",
199
+ "key_data": new_key
 
 
 
 
 
 
 
 
 
200
  }
201
 
202
 
203
+ @app.get("/api/admin/keys")
204
+ async def list_all_keys(_admin: str = Depends(verify_admin_key)):
205
+ """List all issued API keys with remaining credits and usage stats."""
206
+ return {
207
+ "success": True,
208
+ "keys": KeyManager.list_keys()
209
+ }
210
+
211
+
212
+ @app.delete("/api/admin/keys/{target_key}")
213
+ async def delete_api_key(target_key: str, _admin: str = Depends(verify_admin_key)):
214
+ """Permanently delete or revoke an API key."""
215
+ deleted = KeyManager.delete_key(target_key)
216
+ if not deleted:
217
+ raise HTTPException(status_code=404, detail="Key not found.")
218
+ return {"success": True, "message": "API key deleted successfully."}
219
 
220
 
221
  if __name__ == "__main__":