webscout 2025.10.15__py3-none-any.whl → 2025.10.16__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of webscout might be problematic. Click here for more details.
- webscout/Extra/YTToolkit/README.md +1 -1
- webscout/Extra/tempmail/README.md +3 -3
- webscout/Provider/OPENAI/README.md +1 -1
- webscout/Provider/TTI/bing.py +4 -4
- webscout/__init__.py +1 -1
- webscout/client.py +4 -5
- webscout/litprinter/__init__.py +0 -42
- webscout/scout/README.md +59 -8
- webscout/scout/core/scout.py +62 -0
- webscout/scout/element.py +251 -45
- webscout/search/__init__.py +3 -4
- webscout/search/engines/bing/images.py +5 -2
- webscout/search/engines/bing/news.py +6 -4
- webscout/search/engines/bing/text.py +5 -2
- webscout/search/engines/yahoo/__init__.py +41 -0
- webscout/search/engines/yahoo/answers.py +16 -0
- webscout/search/engines/yahoo/base.py +34 -0
- webscout/search/engines/yahoo/images.py +324 -0
- webscout/search/engines/yahoo/maps.py +16 -0
- webscout/search/engines/yahoo/news.py +258 -0
- webscout/search/engines/yahoo/suggestions.py +140 -0
- webscout/search/engines/yahoo/text.py +273 -0
- webscout/search/engines/yahoo/translate.py +16 -0
- webscout/search/engines/yahoo/videos.py +302 -0
- webscout/search/engines/yahoo/weather.py +220 -0
- webscout/search/http_client.py +1 -1
- webscout/search/yahoo_main.py +54 -0
- webscout/{auth → server}/__init__.py +2 -23
- webscout/server/config.py +84 -0
- webscout/{auth → server}/request_processing.py +3 -28
- webscout/{auth → server}/routes.py +6 -148
- webscout/server/schemas.py +23 -0
- webscout/{auth → server}/server.py +11 -43
- webscout/server/simple_logger.py +84 -0
- webscout/version.py +1 -1
- webscout/version.py.bak +1 -1
- webscout/zeroart/README.md +17 -9
- webscout/zeroart/__init__.py +78 -6
- webscout/zeroart/effects.py +51 -1
- webscout/zeroart/fonts.py +559 -1
- {webscout-2025.10.15.dist-info → webscout-2025.10.16.dist-info}/METADATA +10 -52
- {webscout-2025.10.15.dist-info → webscout-2025.10.16.dist-info}/RECORD +49 -45
- {webscout-2025.10.15.dist-info → webscout-2025.10.16.dist-info}/entry_points.txt +1 -1
- webscout/auth/api_key_manager.py +0 -189
- webscout/auth/auth_system.py +0 -85
- webscout/auth/config.py +0 -175
- webscout/auth/database.py +0 -755
- webscout/auth/middleware.py +0 -248
- webscout/auth/models.py +0 -185
- webscout/auth/rate_limiter.py +0 -254
- webscout/auth/schemas.py +0 -103
- webscout/auth/simple_logger.py +0 -236
- webscout/search/engines/yahoo.py +0 -65
- webscout/search/engines/yahoo_news.py +0 -64
- /webscout/{auth → server}/exceptions.py +0 -0
- /webscout/{auth → server}/providers.py +0 -0
- /webscout/{auth → server}/request_models.py +0 -0
- {webscout-2025.10.15.dist-info → webscout-2025.10.16.dist-info}/WHEEL +0 -0
- {webscout-2025.10.15.dist-info → webscout-2025.10.16.dist-info}/licenses/LICENSE.md +0 -0
- {webscout-2025.10.15.dist-info → webscout-2025.10.16.dist-info}/top_level.txt +0 -0
webscout/auth/middleware.py
DELETED
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
# webscout/auth/middleware.py
|
|
2
|
-
|
|
3
|
-
import secrets
|
|
4
|
-
from datetime import datetime
|
|
5
|
-
from typing import Optional, Tuple
|
|
6
|
-
import logging
|
|
7
|
-
|
|
8
|
-
from fastapi import Request, HTTPException, status
|
|
9
|
-
from fastapi.security import APIKeyHeader
|
|
10
|
-
from fastapi.responses import JSONResponse
|
|
11
|
-
|
|
12
|
-
from .api_key_manager import APIKeyManager
|
|
13
|
-
from .rate_limiter import RateLimiter
|
|
14
|
-
from .models import APIKey
|
|
15
|
-
from .schemas import ErrorResponse, RateLimitStatus
|
|
16
|
-
|
|
17
|
-
logger = logging.getLogger(__name__)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class AuthMiddleware:
|
|
21
|
-
"""Authentication and rate limiting middleware."""
|
|
22
|
-
|
|
23
|
-
def __init__(self, api_key_manager: APIKeyManager, rate_limiter: RateLimiter, auth_required: bool = True, rate_limit_enabled: bool = True):
|
|
24
|
-
self.api_key_manager = api_key_manager
|
|
25
|
-
self.rate_limiter = rate_limiter
|
|
26
|
-
self.api_key_header = APIKeyHeader(name="Authorization", auto_error=False)
|
|
27
|
-
self.auth_required = auth_required
|
|
28
|
-
self.rate_limit_enabled = rate_limit_enabled
|
|
29
|
-
|
|
30
|
-
# Paths that don't require authentication
|
|
31
|
-
self.public_paths = {
|
|
32
|
-
"/",
|
|
33
|
-
"/docs",
|
|
34
|
-
"/redoc",
|
|
35
|
-
"/openapi.json",
|
|
36
|
-
"/health",
|
|
37
|
-
"/v1/auth/generate-key" # API key generation endpoint
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
# Paths that require authentication (when auth is enabled)
|
|
41
|
-
self.protected_path_prefixes = [
|
|
42
|
-
"/v1/chat/completions",
|
|
43
|
-
"/v1/images/generations",
|
|
44
|
-
"/v1/models",
|
|
45
|
-
"/v1/TTI/models"
|
|
46
|
-
]
|
|
47
|
-
|
|
48
|
-
def is_protected_path(self, path: str) -> bool:
|
|
49
|
-
"""Check if a path requires authentication."""
|
|
50
|
-
if not self.auth_required:
|
|
51
|
-
return False # No authentication required in no-auth mode
|
|
52
|
-
|
|
53
|
-
if path in self.public_paths:
|
|
54
|
-
return False
|
|
55
|
-
|
|
56
|
-
return any(path.startswith(prefix) for prefix in self.protected_path_prefixes)
|
|
57
|
-
|
|
58
|
-
def extract_api_key(self, authorization_header: Optional[str]) -> Optional[str]:
|
|
59
|
-
"""Extract API key from Authorization header."""
|
|
60
|
-
if not authorization_header:
|
|
61
|
-
return None
|
|
62
|
-
|
|
63
|
-
# Support both "Bearer <key>" and direct key formats
|
|
64
|
-
if authorization_header.startswith("Bearer "):
|
|
65
|
-
return authorization_header[7:]
|
|
66
|
-
elif authorization_header.startswith("ws_"):
|
|
67
|
-
return authorization_header
|
|
68
|
-
else:
|
|
69
|
-
return None
|
|
70
|
-
|
|
71
|
-
async def authenticate_request(self, request: Request) -> Tuple[bool, Optional[APIKey], Optional[dict]]:
|
|
72
|
-
"""
|
|
73
|
-
Authenticate a request.
|
|
74
|
-
|
|
75
|
-
Returns:
|
|
76
|
-
Tuple of (is_authenticated, api_key_object, error_response)
|
|
77
|
-
"""
|
|
78
|
-
path = request.url.path
|
|
79
|
-
|
|
80
|
-
# Check if path requires authentication
|
|
81
|
-
if not self.is_protected_path(path):
|
|
82
|
-
return True, None, None
|
|
83
|
-
|
|
84
|
-
# Get authorization header
|
|
85
|
-
auth_header = request.headers.get("authorization")
|
|
86
|
-
api_key = self.extract_api_key(auth_header)
|
|
87
|
-
|
|
88
|
-
if not api_key:
|
|
89
|
-
error_response = {
|
|
90
|
-
"error": "API key required",
|
|
91
|
-
"code": "missing_api_key",
|
|
92
|
-
"details": {
|
|
93
|
-
"message": "Please provide a valid API key in the Authorization header",
|
|
94
|
-
"format": "Authorization: Bearer <your-api-key>"
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
return False, None, error_response
|
|
98
|
-
|
|
99
|
-
# Validate API key
|
|
100
|
-
is_valid, api_key_obj, error_msg = await self.api_key_manager.validate_api_key(api_key)
|
|
101
|
-
|
|
102
|
-
if not is_valid:
|
|
103
|
-
error_response = {
|
|
104
|
-
"error": error_msg or "Invalid API key",
|
|
105
|
-
"code": "invalid_api_key",
|
|
106
|
-
"details": {
|
|
107
|
-
"message": "The provided API key is invalid, expired, or inactive"
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
return False, None, error_response
|
|
111
|
-
|
|
112
|
-
return True, api_key_obj, None
|
|
113
|
-
|
|
114
|
-
async def check_rate_limit(self, api_key: Optional[APIKey], client_ip: str = "unknown") -> Tuple[bool, dict]:
|
|
115
|
-
"""
|
|
116
|
-
Check rate limit for an API key or IP address.
|
|
117
|
-
|
|
118
|
-
Returns:
|
|
119
|
-
Tuple of (is_allowed, rate_limit_info)
|
|
120
|
-
"""
|
|
121
|
-
if not self.rate_limit_enabled:
|
|
122
|
-
# Rate limiting disabled
|
|
123
|
-
return True, {
|
|
124
|
-
"allowed": True,
|
|
125
|
-
"limit": 999999,
|
|
126
|
-
"remaining": 999999,
|
|
127
|
-
"reset_at": None,
|
|
128
|
-
"retry_after": None
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if api_key:
|
|
132
|
-
return await self.rate_limiter.check_rate_limit(api_key)
|
|
133
|
-
else:
|
|
134
|
-
# No-auth mode: use IP-based rate limiting
|
|
135
|
-
return await self.rate_limiter.check_ip_rate_limit(client_ip)
|
|
136
|
-
|
|
137
|
-
async def process_request(self, request: Request) -> Tuple[bool, Optional[dict], Optional[dict]]:
|
|
138
|
-
"""
|
|
139
|
-
Process a request through authentication and rate limiting.
|
|
140
|
-
|
|
141
|
-
Returns:
|
|
142
|
-
Tuple of (is_allowed, error_response, rate_limit_headers)
|
|
143
|
-
"""
|
|
144
|
-
# Authenticate request
|
|
145
|
-
is_authenticated, api_key_obj, auth_error = await self.authenticate_request(request)
|
|
146
|
-
|
|
147
|
-
if not is_authenticated:
|
|
148
|
-
return False, auth_error, None
|
|
149
|
-
|
|
150
|
-
# If no API key (public endpoint or no-auth mode), handle rate limiting
|
|
151
|
-
if not api_key_obj:
|
|
152
|
-
if not self.auth_required:
|
|
153
|
-
# No-auth mode: still check rate limiting by IP
|
|
154
|
-
client_ip = request.client.host if request.client else "unknown"
|
|
155
|
-
is_allowed, rate_limit_info = await self.check_rate_limit(None, client_ip)
|
|
156
|
-
|
|
157
|
-
if not is_allowed:
|
|
158
|
-
error_response = {
|
|
159
|
-
"error": "Rate limit exceeded",
|
|
160
|
-
"code": "rate_limit_exceeded",
|
|
161
|
-
"details": {
|
|
162
|
-
"message": f"Rate limit of {rate_limit_info['limit']} requests per minute exceeded",
|
|
163
|
-
"retry_after": rate_limit_info["retry_after"],
|
|
164
|
-
"reset_at": rate_limit_info["reset_at"].isoformat() if rate_limit_info["reset_at"] else None
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
rate_limit_headers = {
|
|
168
|
-
"X-RateLimit-Limit": str(rate_limit_info["limit"]),
|
|
169
|
-
"X-RateLimit-Remaining": str(rate_limit_info["remaining"]),
|
|
170
|
-
"Retry-After": str(rate_limit_info["retry_after"])
|
|
171
|
-
}
|
|
172
|
-
if rate_limit_info["reset_at"]:
|
|
173
|
-
rate_limit_headers["X-RateLimit-Reset"] = rate_limit_info["reset_at"].isoformat()
|
|
174
|
-
return False, error_response, rate_limit_headers
|
|
175
|
-
|
|
176
|
-
# Store rate limit info for no-auth mode
|
|
177
|
-
request.state.rate_limit_info = rate_limit_info
|
|
178
|
-
rate_limit_headers = {
|
|
179
|
-
"X-RateLimit-Limit": str(rate_limit_info["limit"]),
|
|
180
|
-
"X-RateLimit-Remaining": str(rate_limit_info["remaining"])
|
|
181
|
-
}
|
|
182
|
-
if rate_limit_info["reset_at"]:
|
|
183
|
-
rate_limit_headers["X-RateLimit-Reset"] = rate_limit_info["reset_at"].isoformat()
|
|
184
|
-
return True, None, rate_limit_headers
|
|
185
|
-
else:
|
|
186
|
-
return True, None, None
|
|
187
|
-
|
|
188
|
-
# Check rate limit for authenticated requests
|
|
189
|
-
client_ip = request.client.host if request.client else "unknown"
|
|
190
|
-
is_allowed, rate_limit_info = await self.check_rate_limit(api_key_obj, client_ip)
|
|
191
|
-
|
|
192
|
-
# Prepare rate limit headers
|
|
193
|
-
rate_limit_headers = {
|
|
194
|
-
"X-RateLimit-Limit": str(rate_limit_info["limit"]),
|
|
195
|
-
"X-RateLimit-Remaining": str(rate_limit_info["remaining"]),
|
|
196
|
-
"X-RateLimit-Reset": rate_limit_info["reset_at"].isoformat()
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
if not is_allowed:
|
|
200
|
-
error_response = {
|
|
201
|
-
"error": "Rate limit exceeded",
|
|
202
|
-
"code": "rate_limit_exceeded",
|
|
203
|
-
"details": {
|
|
204
|
-
"message": f"Rate limit of {rate_limit_info['limit']} requests per minute exceeded",
|
|
205
|
-
"retry_after": rate_limit_info["retry_after"],
|
|
206
|
-
"reset_at": rate_limit_info["reset_at"].isoformat()
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
rate_limit_headers["Retry-After"] = str(rate_limit_info["retry_after"])
|
|
210
|
-
return False, error_response, rate_limit_headers
|
|
211
|
-
|
|
212
|
-
# Store API key info in request state for use in endpoints
|
|
213
|
-
request.state.api_key = api_key_obj
|
|
214
|
-
request.state.rate_limit_info = rate_limit_info
|
|
215
|
-
|
|
216
|
-
return True, None, rate_limit_headers
|
|
217
|
-
|
|
218
|
-
def create_error_response(self, error_data: dict, status_code: int = 401, headers: Optional[dict] = None) -> JSONResponse:
|
|
219
|
-
"""Create a standardized error response."""
|
|
220
|
-
response = JSONResponse(
|
|
221
|
-
status_code=status_code,
|
|
222
|
-
content=error_data
|
|
223
|
-
)
|
|
224
|
-
|
|
225
|
-
if headers:
|
|
226
|
-
for key, value in headers.items():
|
|
227
|
-
response.headers[key] = value
|
|
228
|
-
|
|
229
|
-
return response
|
|
230
|
-
|
|
231
|
-
async def __call__(self, request: Request, call_next):
|
|
232
|
-
"""Middleware callable for FastAPI."""
|
|
233
|
-
# Process request
|
|
234
|
-
is_allowed, error_response, rate_limit_headers = await self.process_request(request)
|
|
235
|
-
|
|
236
|
-
if not is_allowed:
|
|
237
|
-
status_code = 429 if error_response.get("code") == "rate_limit_exceeded" else 401
|
|
238
|
-
return self.create_error_response(error_response, status_code, rate_limit_headers)
|
|
239
|
-
|
|
240
|
-
# Continue with request
|
|
241
|
-
response = await call_next(request)
|
|
242
|
-
|
|
243
|
-
# Add rate limit headers to response
|
|
244
|
-
if rate_limit_headers:
|
|
245
|
-
for key, value in rate_limit_headers.items():
|
|
246
|
-
response.headers[key] = value
|
|
247
|
-
|
|
248
|
-
return response
|
webscout/auth/models.py
DELETED
|
@@ -1,185 +0,0 @@
|
|
|
1
|
-
# webscout/auth/models.py
|
|
2
|
-
|
|
3
|
-
from datetime import datetime, timezone
|
|
4
|
-
from typing import Optional, Dict, Any, List
|
|
5
|
-
from dataclasses import dataclass, field
|
|
6
|
-
import uuid
|
|
7
|
-
import json
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@dataclass
|
|
11
|
-
class User:
|
|
12
|
-
"""User model for authentication system."""
|
|
13
|
-
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
14
|
-
username: str = ""
|
|
15
|
-
telegram_id: int = field(default_factory=lambda: 0) # Required Telegram ID as number only
|
|
16
|
-
|
|
17
|
-
def validate_telegram_id(self) -> None:
|
|
18
|
-
"""Ensure telegram_id is an integer."""
|
|
19
|
-
if not isinstance(self.telegram_id, int):
|
|
20
|
-
raise ValueError("telegram_id must be an integer.")
|
|
21
|
-
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
22
|
-
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
23
|
-
is_active: bool = True
|
|
24
|
-
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
25
|
-
|
|
26
|
-
def to_dict(self) -> Dict[str, Any]:
|
|
27
|
-
"""Convert user to dictionary for storage."""
|
|
28
|
-
return {
|
|
29
|
-
"id": self.id,
|
|
30
|
-
"username": self.username,
|
|
31
|
-
"telegram_id": self.telegram_id,
|
|
32
|
-
"created_at": self.created_at.isoformat(),
|
|
33
|
-
"updated_at": self.updated_at.isoformat(),
|
|
34
|
-
"is_active": self.is_active,
|
|
35
|
-
"metadata": self.metadata
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
@classmethod
|
|
39
|
-
def from_dict(cls, data: Dict[str, Any]) -> "User":
|
|
40
|
-
"""Create user from dictionary."""
|
|
41
|
-
return cls(
|
|
42
|
-
id=data.get("id", str(uuid.uuid4())),
|
|
43
|
-
username=data.get("username", ""),
|
|
44
|
-
telegram_id=int(data.get("telegram_id", 0)),
|
|
45
|
-
created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())),
|
|
46
|
-
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())),
|
|
47
|
-
is_active=data.get("is_active", True),
|
|
48
|
-
metadata=data.get("metadata", {})
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
@dataclass
|
|
53
|
-
class APIKey:
|
|
54
|
-
"""API Key model for authentication system."""
|
|
55
|
-
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
56
|
-
key: str = ""
|
|
57
|
-
user_id: str = ""
|
|
58
|
-
name: Optional[str] = None
|
|
59
|
-
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
60
|
-
last_used_at: Optional[datetime] = None
|
|
61
|
-
expires_at: Optional[datetime] = None
|
|
62
|
-
is_active: bool = True
|
|
63
|
-
rate_limit: int = 10 # requests per minute
|
|
64
|
-
usage_count: int = 0
|
|
65
|
-
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
66
|
-
|
|
67
|
-
def to_dict(self) -> Dict[str, Any]:
|
|
68
|
-
"""Convert API key to dictionary for storage."""
|
|
69
|
-
return {
|
|
70
|
-
"id": self.id,
|
|
71
|
-
"key": self.key,
|
|
72
|
-
"user_id": self.user_id,
|
|
73
|
-
"name": self.name,
|
|
74
|
-
"created_at": self.created_at.isoformat(),
|
|
75
|
-
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None,
|
|
76
|
-
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
|
77
|
-
"is_active": self.is_active,
|
|
78
|
-
"rate_limit": self.rate_limit,
|
|
79
|
-
"usage_count": self.usage_count,
|
|
80
|
-
"metadata": self.metadata
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
@classmethod
|
|
84
|
-
def from_dict(cls, data: Dict[str, Any]) -> "APIKey":
|
|
85
|
-
"""Create API key from dictionary."""
|
|
86
|
-
return cls(
|
|
87
|
-
id=data.get("id", str(uuid.uuid4())),
|
|
88
|
-
key=data.get("key", ""),
|
|
89
|
-
user_id=data.get("user_id", ""),
|
|
90
|
-
name=data.get("name"),
|
|
91
|
-
created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())),
|
|
92
|
-
last_used_at=datetime.fromisoformat(data["last_used_at"]) if data.get("last_used_at") else None,
|
|
93
|
-
expires_at=datetime.fromisoformat(data["expires_at"]) if data.get("expires_at") else None,
|
|
94
|
-
is_active=data.get("is_active", True),
|
|
95
|
-
rate_limit=data.get("rate_limit", 10),
|
|
96
|
-
usage_count=data.get("usage_count", 0),
|
|
97
|
-
metadata=data.get("metadata", {})
|
|
98
|
-
)
|
|
99
|
-
|
|
100
|
-
def is_expired(self) -> bool:
|
|
101
|
-
"""Check if API key is expired."""
|
|
102
|
-
if not self.expires_at:
|
|
103
|
-
return False
|
|
104
|
-
return datetime.now(timezone.utc) > self.expires_at
|
|
105
|
-
|
|
106
|
-
def is_valid(self) -> bool:
|
|
107
|
-
"""Check if API key is valid (active and not expired)."""
|
|
108
|
-
return self.is_active and not self.is_expired()
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
@dataclass
|
|
112
|
-
class RateLimitEntry:
|
|
113
|
-
"""Rate limit tracking entry."""
|
|
114
|
-
api_key_id: str
|
|
115
|
-
requests: List[datetime] = field(default_factory=list)
|
|
116
|
-
|
|
117
|
-
def to_dict(self) -> Dict[str, Any]:
|
|
118
|
-
"""Convert to dictionary for storage."""
|
|
119
|
-
return {
|
|
120
|
-
"api_key_id": self.api_key_id,
|
|
121
|
-
"requests": [req.isoformat() for req in self.requests]
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
@classmethod
|
|
125
|
-
def from_dict(cls, data: Dict[str, Any]) -> "RateLimitEntry":
|
|
126
|
-
"""Create from dictionary."""
|
|
127
|
-
return cls(
|
|
128
|
-
api_key_id=data.get("api_key_id", ""),
|
|
129
|
-
requests=[datetime.fromisoformat(req) for req in data.get("requests", [])]
|
|
130
|
-
)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
@dataclass
|
|
134
|
-
class RequestLog:
|
|
135
|
-
"""Request log entry for API usage tracking."""
|
|
136
|
-
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
137
|
-
request_id: str = ""
|
|
138
|
-
ip_address: str = ""
|
|
139
|
-
model_used: str = ""
|
|
140
|
-
question: str = ""
|
|
141
|
-
answer: str = ""
|
|
142
|
-
user_id: Optional[str] = None
|
|
143
|
-
api_key_id: Optional[str] = None
|
|
144
|
-
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
145
|
-
response_time_ms: Optional[int] = None
|
|
146
|
-
status_code: int = 200
|
|
147
|
-
error_message: Optional[str] = None
|
|
148
|
-
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
149
|
-
|
|
150
|
-
def to_dict(self) -> Dict[str, Any]:
|
|
151
|
-
"""Convert request log to dictionary for storage."""
|
|
152
|
-
return {
|
|
153
|
-
"id": self.id,
|
|
154
|
-
"request_id": self.request_id,
|
|
155
|
-
"ip_address": self.ip_address,
|
|
156
|
-
"model_used": self.model_used,
|
|
157
|
-
"question": self.question,
|
|
158
|
-
"answer": self.answer,
|
|
159
|
-
"user_id": self.user_id,
|
|
160
|
-
"api_key_id": self.api_key_id,
|
|
161
|
-
"created_at": self.created_at.isoformat(),
|
|
162
|
-
"response_time_ms": self.response_time_ms,
|
|
163
|
-
"status_code": self.status_code,
|
|
164
|
-
"error_message": self.error_message,
|
|
165
|
-
"metadata": self.metadata
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
@classmethod
|
|
169
|
-
def from_dict(cls, data: Dict[str, Any]) -> "RequestLog":
|
|
170
|
-
"""Create request log from dictionary."""
|
|
171
|
-
return cls(
|
|
172
|
-
id=data.get("id", str(uuid.uuid4())),
|
|
173
|
-
request_id=data.get("request_id", ""),
|
|
174
|
-
ip_address=data.get("ip_address", ""),
|
|
175
|
-
model_used=data.get("model_used", ""),
|
|
176
|
-
question=data.get("question", ""),
|
|
177
|
-
answer=data.get("answer", ""),
|
|
178
|
-
user_id=data.get("user_id"),
|
|
179
|
-
api_key_id=data.get("api_key_id"),
|
|
180
|
-
created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())),
|
|
181
|
-
response_time_ms=data.get("response_time_ms"),
|
|
182
|
-
status_code=data.get("status_code", 200),
|
|
183
|
-
error_message=data.get("error_message"),
|
|
184
|
-
metadata=data.get("metadata", {})
|
|
185
|
-
)
|
webscout/auth/rate_limiter.py
DELETED
|
@@ -1,254 +0,0 @@
|
|
|
1
|
-
# webscout/auth/rate_limiter.py
|
|
2
|
-
|
|
3
|
-
import asyncio
|
|
4
|
-
from datetime import datetime, timezone, timedelta
|
|
5
|
-
from typing import Optional, Tuple
|
|
6
|
-
import logging
|
|
7
|
-
|
|
8
|
-
from .models import APIKey, RateLimitEntry
|
|
9
|
-
from .database import DatabaseManager
|
|
10
|
-
from .config import AppConfig
|
|
11
|
-
|
|
12
|
-
logger = logging.getLogger(__name__)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class RateLimiter:
|
|
16
|
-
"""Rate limiter for API requests."""
|
|
17
|
-
|
|
18
|
-
def __init__(self, database_manager: DatabaseManager):
|
|
19
|
-
self.db = database_manager
|
|
20
|
-
self.default_rate_limit = 10 # requests per minute
|
|
21
|
-
self.window_size = 60 # 1 minute in seconds
|
|
22
|
-
|
|
23
|
-
async def check_rate_limit(self, api_key: APIKey) -> Tuple[bool, dict]:
|
|
24
|
-
"""
|
|
25
|
-
Check if a request is allowed under the rate limit.
|
|
26
|
-
|
|
27
|
-
Returns:
|
|
28
|
-
Tuple of (is_allowed, rate_limit_info)
|
|
29
|
-
"""
|
|
30
|
-
now = datetime.now(timezone.utc)
|
|
31
|
-
window_start = now - timedelta(seconds=self.window_size)
|
|
32
|
-
|
|
33
|
-
try:
|
|
34
|
-
# Get or create rate limit entry
|
|
35
|
-
entry = await self.db.get_rate_limit_entry(api_key.id)
|
|
36
|
-
|
|
37
|
-
if not entry:
|
|
38
|
-
entry = RateLimitEntry(api_key_id=api_key.id, requests=[])
|
|
39
|
-
|
|
40
|
-
# Clean old requests outside the window
|
|
41
|
-
entry.requests = [req for req in entry.requests if req > window_start]
|
|
42
|
-
|
|
43
|
-
# Check if limit is exceeded
|
|
44
|
-
current_count = len(entry.requests)
|
|
45
|
-
limit = api_key.rate_limit or self.default_rate_limit
|
|
46
|
-
|
|
47
|
-
if current_count >= limit:
|
|
48
|
-
# Rate limit exceeded
|
|
49
|
-
oldest_request = min(entry.requests) if entry.requests else now
|
|
50
|
-
reset_at = oldest_request + timedelta(seconds=self.window_size)
|
|
51
|
-
retry_after = int((reset_at - now).total_seconds())
|
|
52
|
-
|
|
53
|
-
rate_limit_info = {
|
|
54
|
-
"allowed": False,
|
|
55
|
-
"limit": limit,
|
|
56
|
-
"remaining": 0,
|
|
57
|
-
"reset_at": reset_at,
|
|
58
|
-
"retry_after": max(retry_after, 1)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
logger.warning(f"Rate limit exceeded for API key {api_key.id}: {current_count}/{limit}")
|
|
62
|
-
return False, rate_limit_info
|
|
63
|
-
|
|
64
|
-
# Add current request
|
|
65
|
-
entry.requests.append(now)
|
|
66
|
-
|
|
67
|
-
# Update database
|
|
68
|
-
await self.db.update_rate_limit_entry(entry)
|
|
69
|
-
|
|
70
|
-
# Calculate next reset time
|
|
71
|
-
if entry.requests:
|
|
72
|
-
oldest_request = min(entry.requests)
|
|
73
|
-
reset_at = oldest_request + timedelta(seconds=self.window_size)
|
|
74
|
-
else:
|
|
75
|
-
reset_at = now + timedelta(seconds=self.window_size)
|
|
76
|
-
|
|
77
|
-
rate_limit_info = {
|
|
78
|
-
"allowed": True,
|
|
79
|
-
"limit": limit,
|
|
80
|
-
"remaining": limit - len(entry.requests),
|
|
81
|
-
"reset_at": reset_at,
|
|
82
|
-
"retry_after": None
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return True, rate_limit_info
|
|
86
|
-
|
|
87
|
-
except Exception as e:
|
|
88
|
-
logger.error(f"Error checking rate limit for API key {api_key.id}: {e}")
|
|
89
|
-
# In case of error, allow the request but log the issue
|
|
90
|
-
rate_limit_info = {
|
|
91
|
-
"allowed": True,
|
|
92
|
-
"limit": api_key.rate_limit or self.default_rate_limit,
|
|
93
|
-
"remaining": api_key.rate_limit or self.default_rate_limit,
|
|
94
|
-
"reset_at": now + timedelta(seconds=self.window_size),
|
|
95
|
-
"retry_after": None
|
|
96
|
-
}
|
|
97
|
-
return True, rate_limit_info
|
|
98
|
-
|
|
99
|
-
async def reset_rate_limit(self, api_key_id: str) -> bool:
|
|
100
|
-
"""Reset rate limit for an API key (admin function)."""
|
|
101
|
-
try:
|
|
102
|
-
entry = RateLimitEntry(api_key_id=api_key_id, requests=[])
|
|
103
|
-
await self.db.update_rate_limit_entry(entry)
|
|
104
|
-
logger.info(f"Reset rate limit for API key {api_key_id}")
|
|
105
|
-
return True
|
|
106
|
-
except Exception as e:
|
|
107
|
-
logger.error(f"Error resetting rate limit for API key {api_key_id}: {e}")
|
|
108
|
-
return False
|
|
109
|
-
|
|
110
|
-
async def get_rate_limit_status(self, api_key: APIKey) -> dict:
|
|
111
|
-
"""Get current rate limit status without making a request."""
|
|
112
|
-
now = datetime.now(timezone.utc)
|
|
113
|
-
window_start = now - timedelta(seconds=self.window_size)
|
|
114
|
-
|
|
115
|
-
try:
|
|
116
|
-
entry = await self.db.get_rate_limit_entry(api_key.id)
|
|
117
|
-
|
|
118
|
-
if not entry:
|
|
119
|
-
entry = RateLimitEntry(api_key_id=api_key.id, requests=[])
|
|
120
|
-
|
|
121
|
-
# Clean old requests
|
|
122
|
-
entry.requests = [req for req in entry.requests if req > window_start]
|
|
123
|
-
|
|
124
|
-
limit = api_key.rate_limit or self.default_rate_limit
|
|
125
|
-
current_count = len(entry.requests)
|
|
126
|
-
|
|
127
|
-
# Calculate reset time
|
|
128
|
-
if entry.requests:
|
|
129
|
-
oldest_request = min(entry.requests)
|
|
130
|
-
reset_at = oldest_request + timedelta(seconds=self.window_size)
|
|
131
|
-
else:
|
|
132
|
-
reset_at = now + timedelta(seconds=self.window_size)
|
|
133
|
-
|
|
134
|
-
return {
|
|
135
|
-
"limit": limit,
|
|
136
|
-
"remaining": max(0, limit - current_count),
|
|
137
|
-
"reset_at": reset_at,
|
|
138
|
-
"window_size": self.window_size
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
except Exception as e:
|
|
142
|
-
logger.error(f"Error getting rate limit status for API key {api_key.id}: {e}")
|
|
143
|
-
return {
|
|
144
|
-
"limit": api_key.rate_limit or self.default_rate_limit,
|
|
145
|
-
"remaining": api_key.rate_limit or self.default_rate_limit,
|
|
146
|
-
"reset_at": now + timedelta(seconds=self.window_size),
|
|
147
|
-
"window_size": self.window_size
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
async def cleanup_old_entries(self) -> int:
|
|
151
|
-
"""Clean up old rate limit entries (maintenance function)."""
|
|
152
|
-
# Remove requests older than the window_size for all rate limit entries
|
|
153
|
-
try:
|
|
154
|
-
# Try to get all rate limit entries from the database
|
|
155
|
-
if hasattr(self.db, 'get_all_rate_limit_entries'):
|
|
156
|
-
entries = await self.db.get_all_rate_limit_entries()
|
|
157
|
-
else:
|
|
158
|
-
logger.warning("Database does not support get_all_rate_limit_entries; cleanup skipped.")
|
|
159
|
-
return 0
|
|
160
|
-
|
|
161
|
-
now = datetime.now(timezone.utc)
|
|
162
|
-
window_start = now - timedelta(seconds=self.window_size)
|
|
163
|
-
cleaned = 0
|
|
164
|
-
|
|
165
|
-
for entry in entries:
|
|
166
|
-
old_count = len(entry.requests)
|
|
167
|
-
entry.requests = [req for req in entry.requests if req > window_start]
|
|
168
|
-
if len(entry.requests) < old_count:
|
|
169
|
-
await self.db.update_rate_limit_entry(entry)
|
|
170
|
-
cleaned += 1
|
|
171
|
-
logger.info(f"Cleaned up {cleaned} old rate limit entries.")
|
|
172
|
-
return cleaned
|
|
173
|
-
except Exception as e:
|
|
174
|
-
logger.error(f"Error cleaning up old rate limit entries: {e}")
|
|
175
|
-
return 0
|
|
176
|
-
|
|
177
|
-
async def check_ip_rate_limit(self, client_ip: str) -> Tuple[bool, dict]:
|
|
178
|
-
"""
|
|
179
|
-
Check rate limit for an IP address (used in no-auth mode).
|
|
180
|
-
|
|
181
|
-
Returns:
|
|
182
|
-
Tuple of (is_allowed, rate_limit_info)
|
|
183
|
-
"""
|
|
184
|
-
now = datetime.now(timezone.utc)
|
|
185
|
-
window_start = now - timedelta(seconds=self.window_size)
|
|
186
|
-
|
|
187
|
-
# Create a pseudo API key for IP-based rate limiting
|
|
188
|
-
ip_key_id = f"ip_{client_ip}"
|
|
189
|
-
limit = AppConfig.default_rate_limit
|
|
190
|
-
|
|
191
|
-
try:
|
|
192
|
-
# Get or create rate limit entry for IP
|
|
193
|
-
entry = await self.db.get_rate_limit_entry(ip_key_id)
|
|
194
|
-
|
|
195
|
-
if not entry:
|
|
196
|
-
entry = RateLimitEntry(api_key_id=ip_key_id, requests=[])
|
|
197
|
-
|
|
198
|
-
# Clean old requests outside the window
|
|
199
|
-
entry.requests = [req for req in entry.requests if req > window_start]
|
|
200
|
-
|
|
201
|
-
# Check if limit is exceeded
|
|
202
|
-
current_count = len(entry.requests)
|
|
203
|
-
|
|
204
|
-
if current_count >= limit:
|
|
205
|
-
# Rate limit exceeded
|
|
206
|
-
oldest_request = min(entry.requests) if entry.requests else now
|
|
207
|
-
reset_at = oldest_request + timedelta(seconds=self.window_size)
|
|
208
|
-
retry_after = int((reset_at - now).total_seconds())
|
|
209
|
-
|
|
210
|
-
rate_limit_info = {
|
|
211
|
-
"allowed": False,
|
|
212
|
-
"limit": limit,
|
|
213
|
-
"remaining": 0,
|
|
214
|
-
"reset_at": reset_at,
|
|
215
|
-
"retry_after": max(retry_after, 1)
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
logger.warning(f"Rate limit exceeded for IP {client_ip}: {current_count}/{limit}")
|
|
219
|
-
return False, rate_limit_info
|
|
220
|
-
|
|
221
|
-
# Add current request
|
|
222
|
-
entry.requests.append(now)
|
|
223
|
-
|
|
224
|
-
# Update database
|
|
225
|
-
await self.db.update_rate_limit_entry(entry)
|
|
226
|
-
|
|
227
|
-
# Calculate next reset time
|
|
228
|
-
if entry.requests:
|
|
229
|
-
oldest_request = min(entry.requests)
|
|
230
|
-
reset_at = oldest_request + timedelta(seconds=self.window_size)
|
|
231
|
-
else:
|
|
232
|
-
reset_at = now + timedelta(seconds=self.window_size)
|
|
233
|
-
|
|
234
|
-
rate_limit_info = {
|
|
235
|
-
"allowed": True,
|
|
236
|
-
"limit": limit,
|
|
237
|
-
"remaining": limit - len(entry.requests),
|
|
238
|
-
"reset_at": reset_at,
|
|
239
|
-
"retry_after": None
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
return True, rate_limit_info
|
|
243
|
-
|
|
244
|
-
except Exception as e:
|
|
245
|
-
logger.error(f"Error checking rate limit for IP {client_ip}: {e}")
|
|
246
|
-
# In case of error, allow the request but log the issue
|
|
247
|
-
rate_limit_info = {
|
|
248
|
-
"allowed": True,
|
|
249
|
-
"limit": limit,
|
|
250
|
-
"remaining": limit,
|
|
251
|
-
"reset_at": now + timedelta(seconds=self.window_size),
|
|
252
|
-
"retry_after": None
|
|
253
|
-
}
|
|
254
|
-
return True, rate_limit_info
|