|
44 | 44 | app = FastAPI( |
45 | 45 | title="WebVideo2NAS API", |
46 | 46 | description="API for managing web video downloads (M3U8 and MP4)", |
47 | | - version="1.8.6" |
| 47 | + version="1.8.8" |
48 | 48 | ) |
49 | 49 |
|
50 | 50 | # CORS middleware |
@@ -107,19 +107,22 @@ def _enforce_client_allowlist(request: Request) -> None: |
107 | 107 | raise HTTPException(status_code=403, detail="Client IP not allowed") |
108 | 108 |
|
109 | 109 |
|
| 110 | +_RATE_LIMIT_MULTIPLIERS = {"read": 6, "write": 1} |
| 111 | + |
110 | 112 | def _rate_limit(request: Request, bucket: str) -> None: |
111 | 113 | if RATE_LIMIT_PER_MINUTE <= 0: |
112 | 114 | return |
| 115 | + multiplier = _RATE_LIMIT_MULTIPLIERS.get(bucket, 1) |
| 116 | + limit = RATE_LIMIT_PER_MINUTE * multiplier |
113 | 117 | client_ip = _get_client_ip(request) |
114 | 118 | window = int(datetime.utcnow().timestamp() // 60) |
115 | 119 | key = f"rl:{bucket}:{client_ip}:{window}" |
116 | 120 | try: |
117 | 121 | count = redis_client.incr(key) |
118 | 122 | redis_client.expire(key, 90) |
119 | 123 | except Exception: |
120 | | - # If Redis is unavailable, skip rate limiting (avoid breaking core API). |
121 | 124 | return |
122 | | - if count > RATE_LIMIT_PER_MINUTE: |
| 125 | + if count > limit: |
123 | 126 | raise HTTPException(status_code=429, detail="Rate limit exceeded") |
124 | 127 |
|
125 | 128 |
|
@@ -204,30 +207,33 @@ def get_db(): |
204 | 207 | finally: |
205 | 208 | db.close() |
206 | 209 |
|
207 | | -def verify_api_key(request: Request, authorization: Optional[str] = Header(None)): |
208 | | - """Verify API key from Authorization header""" |
| 210 | +def _verify_key_common(request: Request, authorization: Optional[str], bucket: str) -> str: |
209 | 211 | _enforce_client_allowlist(request) |
210 | | - _rate_limit(request, bucket="auth") |
| 212 | + _rate_limit(request, bucket=bucket) |
211 | 213 | if not API_KEY or API_KEY.strip() == "" or API_KEY.strip() == "change-this-key": |
212 | 214 | raise HTTPException(status_code=503, detail="Server not configured: API_KEY is not set") |
213 | 215 | if not authorization: |
214 | 216 | raise HTTPException(status_code=401, detail="Missing Authorization header") |
215 | | - |
216 | | - # Support both "Bearer TOKEN" and just "TOKEN" |
217 | 217 | token = authorization.replace("Bearer ", "").strip() |
218 | | - |
219 | 218 | if token != API_KEY: |
220 | 219 | raise HTTPException(status_code=401, detail="Invalid API key") |
221 | | - |
222 | 220 | return token |
223 | 221 |
|
| 222 | +def verify_api_key(request: Request, authorization: Optional[str] = Header(None)): |
| 223 | + """Verify API key — write-endpoint rate limit (RATE_LIMIT_PER_MINUTE).""" |
| 224 | + return _verify_key_common(request, authorization, bucket="write") |
| 225 | + |
| 226 | +def verify_api_key_read(request: Request, authorization: Optional[str] = Header(None)): |
| 227 | + """Verify API key — read-endpoint rate limit (6x write limit).""" |
| 228 | + return _verify_key_common(request, authorization, bucket="read") |
| 229 | + |
224 | 230 | # Routes |
225 | 231 | @app.get("/") |
226 | 232 | async def root(): |
227 | 233 | """Root endpoint""" |
228 | 234 | return { |
229 | 235 | "name": "WebVideo2NAS API", |
230 | | - "version": "1.8.6", |
| 236 | + "version": "1.8.8", |
231 | 237 | "status": "running" |
232 | 238 | } |
233 | 239 |
|
@@ -316,7 +322,7 @@ async def list_jobs( |
316 | 322 | status: Optional[str] = None, |
317 | 323 | limit: int = 50, |
318 | 324 | db: Session = Depends(get_db), |
319 | | - api_key: str = Depends(verify_api_key) |
| 325 | + api_key: str = Depends(verify_api_key_read) |
320 | 326 | ): |
321 | 327 | """List all jobs with optional status filter""" |
322 | 328 | try: |
@@ -363,7 +369,7 @@ async def list_jobs( |
363 | 369 | async def get_job( |
364 | 370 | job_id: str, |
365 | 371 | db: Session = Depends(get_db), |
366 | | - api_key: str = Depends(verify_api_key) |
| 372 | + api_key: str = Depends(verify_api_key_read) |
367 | 373 | ): |
368 | 374 | """Get details of a specific job""" |
369 | 375 | try: |
@@ -430,7 +436,7 @@ async def delete_job( |
430 | 436 | @app.get("/api/status", response_model=SystemStatus) |
431 | 437 | async def get_status( |
432 | 438 | db: Session = Depends(get_db), |
433 | | - api_key: str = Depends(verify_api_key) |
| 439 | + api_key: str = Depends(verify_api_key_read) |
434 | 440 | ): |
435 | 441 | """Get system status""" |
436 | 442 | try: |
|
0 commit comments