webscout 8.3__py3-none-any.whl → 8.3.2__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/AIauto.py +4 -4
- webscout/AIbase.py +61 -1
- webscout/AIutel.py +46 -53
- webscout/Bing_search.py +418 -0
- webscout/Extra/YTToolkit/ytapi/patterns.py +45 -45
- webscout/Extra/YTToolkit/ytapi/stream.py +1 -1
- webscout/Extra/YTToolkit/ytapi/video.py +10 -10
- webscout/Extra/autocoder/autocoder_utiles.py +1 -1
- webscout/Extra/gguf.py +706 -177
- webscout/Litlogger/formats.py +9 -0
- webscout/Litlogger/handlers.py +18 -0
- webscout/Litlogger/logger.py +43 -1
- webscout/Provider/AISEARCH/genspark_search.py +7 -7
- webscout/Provider/AISEARCH/scira_search.py +3 -2
- webscout/Provider/GeminiProxy.py +140 -0
- webscout/Provider/LambdaChat.py +7 -1
- webscout/Provider/MCPCore.py +78 -75
- webscout/Provider/OPENAI/BLACKBOXAI.py +1046 -1017
- webscout/Provider/OPENAI/GeminiProxy.py +328 -0
- webscout/Provider/OPENAI/Qwen3.py +303 -303
- webscout/Provider/OPENAI/README.md +5 -0
- webscout/Provider/OPENAI/README_AUTOPROXY.md +238 -0
- webscout/Provider/OPENAI/TogetherAI.py +355 -0
- webscout/Provider/OPENAI/__init__.py +16 -1
- webscout/Provider/OPENAI/autoproxy.py +332 -0
- webscout/Provider/OPENAI/base.py +101 -14
- webscout/Provider/OPENAI/chatgpt.py +15 -2
- webscout/Provider/OPENAI/chatgptclone.py +14 -3
- webscout/Provider/OPENAI/deepinfra.py +339 -328
- webscout/Provider/OPENAI/e2b.py +295 -74
- webscout/Provider/OPENAI/mcpcore.py +109 -70
- webscout/Provider/OPENAI/opkfc.py +18 -6
- webscout/Provider/OPENAI/scirachat.py +59 -50
- webscout/Provider/OPENAI/toolbaz.py +2 -10
- webscout/Provider/OPENAI/writecream.py +166 -166
- webscout/Provider/OPENAI/x0gpt.py +367 -367
- webscout/Provider/OPENAI/xenai.py +514 -0
- webscout/Provider/OPENAI/yep.py +389 -383
- webscout/Provider/STT/__init__.py +3 -0
- webscout/Provider/STT/base.py +281 -0
- webscout/Provider/STT/elevenlabs.py +265 -0
- webscout/Provider/TTI/__init__.py +4 -1
- webscout/Provider/TTI/aiarta.py +399 -365
- webscout/Provider/TTI/base.py +74 -2
- webscout/Provider/TTI/bing.py +231 -0
- webscout/Provider/TTI/fastflux.py +63 -30
- webscout/Provider/TTI/gpt1image.py +149 -0
- webscout/Provider/TTI/imagen.py +196 -0
- webscout/Provider/TTI/magicstudio.py +60 -29
- webscout/Provider/TTI/piclumen.py +43 -32
- webscout/Provider/TTI/pixelmuse.py +232 -225
- webscout/Provider/TTI/pollinations.py +43 -32
- webscout/Provider/TTI/together.py +287 -0
- webscout/Provider/TTI/utils.py +2 -1
- webscout/Provider/TTS/README.md +1 -0
- webscout/Provider/TTS/__init__.py +2 -1
- webscout/Provider/TTS/freetts.py +140 -0
- webscout/Provider/TTS/speechma.py +45 -39
- webscout/Provider/TogetherAI.py +366 -0
- webscout/Provider/UNFINISHED/ChutesAI.py +314 -0
- webscout/Provider/UNFINISHED/fetch_together_models.py +95 -0
- webscout/Provider/XenAI.py +324 -0
- webscout/Provider/__init__.py +8 -0
- webscout/Provider/deepseek_assistant.py +378 -0
- webscout/Provider/scira_chat.py +3 -2
- webscout/Provider/toolbaz.py +0 -1
- webscout/auth/__init__.py +44 -0
- webscout/auth/api_key_manager.py +189 -0
- webscout/auth/auth_system.py +100 -0
- webscout/auth/config.py +76 -0
- webscout/auth/database.py +400 -0
- webscout/auth/exceptions.py +67 -0
- webscout/auth/middleware.py +248 -0
- webscout/auth/models.py +130 -0
- webscout/auth/providers.py +257 -0
- webscout/auth/rate_limiter.py +254 -0
- webscout/auth/request_models.py +127 -0
- webscout/auth/request_processing.py +226 -0
- webscout/auth/routes.py +526 -0
- webscout/auth/schemas.py +103 -0
- webscout/auth/server.py +312 -0
- webscout/auth/static/favicon.svg +11 -0
- webscout/auth/swagger_ui.py +203 -0
- webscout/auth/templates/components/authentication.html +237 -0
- webscout/auth/templates/components/base.html +103 -0
- webscout/auth/templates/components/endpoints.html +750 -0
- webscout/auth/templates/components/examples.html +491 -0
- webscout/auth/templates/components/footer.html +75 -0
- webscout/auth/templates/components/header.html +27 -0
- webscout/auth/templates/components/models.html +286 -0
- webscout/auth/templates/components/navigation.html +70 -0
- webscout/auth/templates/static/api.js +455 -0
- webscout/auth/templates/static/icons.js +168 -0
- webscout/auth/templates/static/main.js +784 -0
- webscout/auth/templates/static/particles.js +201 -0
- webscout/auth/templates/static/styles.css +3353 -0
- webscout/auth/templates/static/ui.js +374 -0
- webscout/auth/templates/swagger_ui.html +170 -0
- webscout/client.py +49 -3
- webscout/litagent/Readme.md +12 -3
- webscout/litagent/agent.py +99 -62
- webscout/scout/core/scout.py +104 -26
- webscout/scout/element.py +139 -18
- webscout/swiftcli/core/cli.py +14 -3
- webscout/swiftcli/decorators/output.py +59 -9
- webscout/update_checker.py +31 -49
- webscout/version.py +1 -1
- webscout/webscout_search.py +4 -12
- webscout/webscout_search_async.py +3 -10
- webscout/yep_search.py +2 -11
- {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/METADATA +41 -11
- {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/RECORD +116 -68
- {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/entry_points.txt +1 -1
- webscout/Provider/HF_space/__init__.py +0 -0
- webscout/Provider/HF_space/qwen_qwen2.py +0 -206
- webscout/Provider/OPENAI/api.py +0 -1035
- webscout/Provider/TTI/artbit.py +0 -0
- {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/WHEEL +0 -0
- {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/licenses/LICENSE.md +0 -0
- {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication system initialization for the Webscout API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
|
|
10
|
+
from webscout.Litlogger import Logger, LogLevel, LogFormat, ConsoleHandler
|
|
11
|
+
from .database import DatabaseManager
|
|
12
|
+
from .api_key_manager import APIKeyManager
|
|
13
|
+
from .rate_limiter import RateLimiter
|
|
14
|
+
from .middleware import AuthMiddleware
|
|
15
|
+
|
|
16
|
+
# Setup logger
|
|
17
|
+
logger = Logger(
|
|
18
|
+
name="webscout.api",
|
|
19
|
+
level=LogLevel.INFO,
|
|
20
|
+
handlers=[ConsoleHandler(stream=sys.stdout)],
|
|
21
|
+
fmt=LogFormat.DEFAULT
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Global authentication system instances
|
|
25
|
+
db_manager: Optional[DatabaseManager] = None
|
|
26
|
+
api_key_manager: Optional[APIKeyManager] = None
|
|
27
|
+
rate_limiter: Optional[RateLimiter] = None
|
|
28
|
+
auth_middleware: Optional[AuthMiddleware] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def initialize_auth_system(app: FastAPI, auth_required: bool = True, rate_limit_enabled: bool = True) -> None:
|
|
32
|
+
"""Initialize the authentication system."""
|
|
33
|
+
global db_manager, api_key_manager, rate_limiter, auth_middleware
|
|
34
|
+
|
|
35
|
+
if not auth_required:
|
|
36
|
+
logger.info("Auth system is disabled (no-auth mode): skipping DB and API key manager initialization.")
|
|
37
|
+
db_manager = None
|
|
38
|
+
api_key_manager = None
|
|
39
|
+
rate_limiter = None
|
|
40
|
+
auth_middleware = None
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
# Initialize database manager
|
|
45
|
+
mongo_url = os.getenv("MONGODB_URL")
|
|
46
|
+
data_dir = os.getenv("WEBSCOUT_DATA_DIR", "data")
|
|
47
|
+
|
|
48
|
+
db_manager = DatabaseManager(mongo_url, data_dir)
|
|
49
|
+
|
|
50
|
+
# Initialize API key manager
|
|
51
|
+
api_key_manager = APIKeyManager(db_manager)
|
|
52
|
+
|
|
53
|
+
# Initialize rate limiter
|
|
54
|
+
rate_limiter = RateLimiter(db_manager)
|
|
55
|
+
|
|
56
|
+
# Initialize auth middleware with configuration
|
|
57
|
+
auth_middleware = AuthMiddleware(
|
|
58
|
+
api_key_manager,
|
|
59
|
+
rate_limiter,
|
|
60
|
+
auth_required=auth_required,
|
|
61
|
+
rate_limit_enabled=rate_limit_enabled
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Add auth middleware to app
|
|
65
|
+
app.middleware("http")(auth_middleware)
|
|
66
|
+
|
|
67
|
+
# Add startup event to initialize database
|
|
68
|
+
async def startup_event():
|
|
69
|
+
if db_manager:
|
|
70
|
+
await db_manager.initialize()
|
|
71
|
+
logger.info("Authentication system initialized successfully")
|
|
72
|
+
logger.info(f"Auth required: {auth_required}, Rate limiting: {rate_limit_enabled}")
|
|
73
|
+
|
|
74
|
+
# Store startup function for later use
|
|
75
|
+
app.state.startup_event = startup_event
|
|
76
|
+
|
|
77
|
+
logger.info("Authentication system setup completed")
|
|
78
|
+
|
|
79
|
+
except Exception as e:
|
|
80
|
+
logger.error(f"Failed to initialize authentication system: {e}")
|
|
81
|
+
# Fall back to legacy auth if new system fails
|
|
82
|
+
logger.warning("Falling back to legacy authentication system")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_auth_components():
|
|
86
|
+
"""Get the initialized authentication components."""
|
|
87
|
+
if db_manager is None:
|
|
88
|
+
return {
|
|
89
|
+
"db_manager": None,
|
|
90
|
+
"api_key_manager": None,
|
|
91
|
+
"rate_limiter": None,
|
|
92
|
+
"auth_middleware": None
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
"db_manager": db_manager,
|
|
97
|
+
"api_key_manager": api_key_manager,
|
|
98
|
+
"rate_limiter": rate_limiter,
|
|
99
|
+
"auth_middleware": auth_middleware
|
|
100
|
+
}
|
webscout/auth/config.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for the Webscout API server.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import List, Dict, Optional, Any
|
|
6
|
+
from webscout.Litlogger import Logger, LogLevel, LogFormat, ConsoleHandler
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
# Configuration constants
|
|
10
|
+
DEFAULT_PORT = 8000
|
|
11
|
+
DEFAULT_HOST = "0.0.0.0"
|
|
12
|
+
|
|
13
|
+
# Setup logger
|
|
14
|
+
logger = Logger(
|
|
15
|
+
name="webscout.api",
|
|
16
|
+
level=LogLevel.INFO,
|
|
17
|
+
handlers=[ConsoleHandler(stream=sys.stdout)],
|
|
18
|
+
fmt=LogFormat.DEFAULT
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ServerConfig:
|
|
23
|
+
"""Centralized configuration management for the API server."""
|
|
24
|
+
|
|
25
|
+
def __init__(self):
|
|
26
|
+
self.api_key: Optional[str] = None
|
|
27
|
+
self.provider_map: Dict[str, Any] = {}
|
|
28
|
+
self.default_provider: str = "ChatGPT"
|
|
29
|
+
self.base_url: Optional[str] = None
|
|
30
|
+
self.host: str = DEFAULT_HOST
|
|
31
|
+
self.port: int = DEFAULT_PORT
|
|
32
|
+
self.debug: bool = False
|
|
33
|
+
self.cors_origins: List[str] = ["*"]
|
|
34
|
+
self.max_request_size: int = 10 * 1024 * 1024 # 10MB
|
|
35
|
+
self.request_timeout: int = 300 # 5 minutes
|
|
36
|
+
self.auth_required: bool = True # New: Enable/disable authentication
|
|
37
|
+
self.rate_limit_enabled: bool = True # New: Enable/disable rate limiting
|
|
38
|
+
self.default_rate_limit: int = 60 # Default rate limit for no-auth mode
|
|
39
|
+
|
|
40
|
+
def update(self, **kwargs) -> None:
|
|
41
|
+
"""Update configuration with provided values."""
|
|
42
|
+
for key, value in kwargs.items():
|
|
43
|
+
if hasattr(self, key) and value is not None:
|
|
44
|
+
setattr(self, key, value)
|
|
45
|
+
logger.info(f"Config updated: {key} = {value}")
|
|
46
|
+
|
|
47
|
+
def validate(self) -> None:
|
|
48
|
+
"""Validate configuration settings."""
|
|
49
|
+
if self.port < 1 or self.port > 65535:
|
|
50
|
+
raise ValueError(f"Invalid port number: {self.port}")
|
|
51
|
+
|
|
52
|
+
if self.default_provider not in self.provider_map and self.provider_map:
|
|
53
|
+
available_providers = list(set(v.__name__ for v in self.provider_map.values()))
|
|
54
|
+
logger.warning(f"Default provider '{self.default_provider}' not found. Available: {available_providers}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AppConfig:
|
|
58
|
+
"""Legacy configuration class for backward compatibility."""
|
|
59
|
+
api_key: Optional[str] = None
|
|
60
|
+
provider_map = {}
|
|
61
|
+
tti_provider_map = {} # Add TTI provider map
|
|
62
|
+
default_provider = "ChatGPT"
|
|
63
|
+
default_tti_provider = "PollinationsAI" # Add default TTI provider
|
|
64
|
+
base_url: Optional[str] = None
|
|
65
|
+
auth_required: bool = True # New: Enable/disable authentication
|
|
66
|
+
rate_limit_enabled: bool = True # New: Enable/disable rate limiting
|
|
67
|
+
default_rate_limit: int = 60 # Default rate limit for no-auth mode
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def set_config(cls, **data):
|
|
71
|
+
"""Set configuration values."""
|
|
72
|
+
for key, value in data.items():
|
|
73
|
+
setattr(cls, key, value)
|
|
74
|
+
# Sync with new config system
|
|
75
|
+
from .server import config
|
|
76
|
+
config.update(**data)
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
# webscout/auth/database.py
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import asyncio
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Optional, List, Dict, Any, Union
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import threading
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import motor.motor_asyncio
|
|
14
|
+
HAS_MOTOR = True
|
|
15
|
+
except ImportError:
|
|
16
|
+
HAS_MOTOR = False
|
|
17
|
+
|
|
18
|
+
from .models import User, APIKey, RateLimitEntry
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class JSONDatabase:
|
|
24
|
+
"""JSON file-based database fallback."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, data_dir: str = "data"):
|
|
27
|
+
self.data_dir = Path(data_dir)
|
|
28
|
+
self.data_dir.mkdir(exist_ok=True)
|
|
29
|
+
|
|
30
|
+
self.users_file = self.data_dir / "users.json"
|
|
31
|
+
self.api_keys_file = self.data_dir / "api_keys.json"
|
|
32
|
+
self.rate_limits_file = self.data_dir / "rate_limits.json"
|
|
33
|
+
|
|
34
|
+
self._lock = threading.RLock()
|
|
35
|
+
|
|
36
|
+
# Initialize files if they don't exist
|
|
37
|
+
for file_path in [self.users_file, self.api_keys_file, self.rate_limits_file]:
|
|
38
|
+
if not file_path.exists():
|
|
39
|
+
self._write_json(file_path, [])
|
|
40
|
+
|
|
41
|
+
def _read_json(self, file_path: Path) -> List[Dict[str, Any]]:
|
|
42
|
+
"""Read JSON file safely."""
|
|
43
|
+
try:
|
|
44
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
45
|
+
return json.load(f)
|
|
46
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
def _write_json(self, file_path: Path, data: List[Dict[str, Any]]) -> None:
|
|
50
|
+
"""Write JSON file safely."""
|
|
51
|
+
with self._lock:
|
|
52
|
+
# Write to temporary file first, then rename for atomicity
|
|
53
|
+
temp_file = file_path.with_suffix('.tmp')
|
|
54
|
+
try:
|
|
55
|
+
with open(temp_file, 'w', encoding='utf-8') as f:
|
|
56
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
57
|
+
temp_file.replace(file_path)
|
|
58
|
+
except Exception as e:
|
|
59
|
+
if temp_file.exists():
|
|
60
|
+
temp_file.unlink()
|
|
61
|
+
raise e
|
|
62
|
+
|
|
63
|
+
async def create_user(self, user: User) -> User:
|
|
64
|
+
"""Create a new user."""
|
|
65
|
+
users = self._read_json(self.users_file)
|
|
66
|
+
|
|
67
|
+
# Check if user already exists
|
|
68
|
+
for existing_user in users:
|
|
69
|
+
if existing_user.get("username") == user.username:
|
|
70
|
+
raise ValueError(f"User with username '{user.username}' already exists")
|
|
71
|
+
|
|
72
|
+
users.append(user.to_dict())
|
|
73
|
+
self._write_json(self.users_file, users)
|
|
74
|
+
return user
|
|
75
|
+
|
|
76
|
+
async def get_user_by_id(self, user_id: str) -> Optional[User]:
|
|
77
|
+
"""Get user by ID."""
|
|
78
|
+
users = self._read_json(self.users_file)
|
|
79
|
+
for user_data in users:
|
|
80
|
+
if user_data.get("id") == user_id:
|
|
81
|
+
return User.from_dict(user_data)
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
async def get_user_by_username(self, username: str) -> Optional[User]:
|
|
85
|
+
"""Get user by username."""
|
|
86
|
+
users = self._read_json(self.users_file)
|
|
87
|
+
for user_data in users:
|
|
88
|
+
if user_data.get("username") == username:
|
|
89
|
+
return User.from_dict(user_data)
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
async def get_user_by_telegram_id(self, telegram_id: str) -> Optional[User]:
|
|
93
|
+
"""Get user by Telegram ID."""
|
|
94
|
+
users = self._read_json(self.users_file)
|
|
95
|
+
for user_data in users:
|
|
96
|
+
if user_data.get("telegram_id") == telegram_id:
|
|
97
|
+
return User.from_dict(user_data)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
async def create_api_key(self, api_key: APIKey) -> APIKey:
|
|
101
|
+
"""Create a new API key."""
|
|
102
|
+
api_keys = self._read_json(self.api_keys_file)
|
|
103
|
+
|
|
104
|
+
# Check if key already exists
|
|
105
|
+
for existing_key in api_keys:
|
|
106
|
+
if existing_key.get("key") == api_key.key:
|
|
107
|
+
raise ValueError("API key already exists")
|
|
108
|
+
|
|
109
|
+
api_keys.append(api_key.to_dict())
|
|
110
|
+
self._write_json(self.api_keys_file, api_keys)
|
|
111
|
+
return api_key
|
|
112
|
+
|
|
113
|
+
async def get_api_key(self, key: str) -> Optional[APIKey]:
|
|
114
|
+
"""Get API key by key value."""
|
|
115
|
+
api_keys = self._read_json(self.api_keys_file)
|
|
116
|
+
for key_data in api_keys:
|
|
117
|
+
if key_data.get("key") == key:
|
|
118
|
+
return APIKey.from_dict(key_data)
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
async def update_api_key(self, api_key: APIKey) -> APIKey:
|
|
122
|
+
"""Update an existing API key."""
|
|
123
|
+
api_keys = self._read_json(self.api_keys_file)
|
|
124
|
+
|
|
125
|
+
for i, key_data in enumerate(api_keys):
|
|
126
|
+
if key_data.get("id") == api_key.id:
|
|
127
|
+
api_keys[i] = api_key.to_dict()
|
|
128
|
+
self._write_json(self.api_keys_file, api_keys)
|
|
129
|
+
return api_key
|
|
130
|
+
|
|
131
|
+
raise ValueError(f"API key with ID '{api_key.id}' not found")
|
|
132
|
+
|
|
133
|
+
async def get_api_keys_by_user(self, user_id: str) -> List[APIKey]:
|
|
134
|
+
"""Get all API keys for a user."""
|
|
135
|
+
api_keys = self._read_json(self.api_keys_file)
|
|
136
|
+
user_keys = []
|
|
137
|
+
|
|
138
|
+
for key_data in api_keys:
|
|
139
|
+
if key_data.get("user_id") == user_id:
|
|
140
|
+
user_keys.append(APIKey.from_dict(key_data))
|
|
141
|
+
|
|
142
|
+
return user_keys
|
|
143
|
+
|
|
144
|
+
async def get_rate_limit_entry(self, api_key_id: str) -> Optional[RateLimitEntry]:
|
|
145
|
+
"""Get rate limit entry for API key."""
|
|
146
|
+
rate_limits = self._read_json(self.rate_limits_file)
|
|
147
|
+
|
|
148
|
+
for entry_data in rate_limits:
|
|
149
|
+
if entry_data.get("api_key_id") == api_key_id:
|
|
150
|
+
return RateLimitEntry.from_dict(entry_data)
|
|
151
|
+
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
async def update_rate_limit_entry(self, entry: RateLimitEntry) -> RateLimitEntry:
|
|
155
|
+
"""Update rate limit entry."""
|
|
156
|
+
rate_limits = self._read_json(self.rate_limits_file)
|
|
157
|
+
|
|
158
|
+
for i, entry_data in enumerate(rate_limits):
|
|
159
|
+
if entry_data.get("api_key_id") == entry.api_key_id:
|
|
160
|
+
rate_limits[i] = entry.to_dict()
|
|
161
|
+
self._write_json(self.rate_limits_file, rate_limits)
|
|
162
|
+
return entry
|
|
163
|
+
|
|
164
|
+
# Create new entry if not found
|
|
165
|
+
rate_limits.append(entry.to_dict())
|
|
166
|
+
self._write_json(self.rate_limits_file, rate_limits)
|
|
167
|
+
return entry
|
|
168
|
+
|
|
169
|
+
async def get_all_rate_limit_entries(self) -> list:
|
|
170
|
+
"""Return all rate limit entries (for maintenance/cleanup)."""
|
|
171
|
+
# Only for JSONDatabase
|
|
172
|
+
entries = self._read_json(self.rate_limits_file)
|
|
173
|
+
return [RateLimitEntry.from_dict(e) for e in entries]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class MongoDatabase:
|
|
177
|
+
"""MongoDB database implementation."""
|
|
178
|
+
|
|
179
|
+
def __init__(self, connection_string: str = "mongodb://localhost:27017", database_name: str = "webscout"):
|
|
180
|
+
self.connection_string = connection_string
|
|
181
|
+
self.database_name = database_name
|
|
182
|
+
self.client = None
|
|
183
|
+
self.db = None
|
|
184
|
+
self._connected = False
|
|
185
|
+
|
|
186
|
+
async def connect(self) -> bool:
|
|
187
|
+
"""Connect to MongoDB."""
|
|
188
|
+
if not HAS_MOTOR:
|
|
189
|
+
logger.warning("motor package not available, cannot connect to MongoDB")
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
self.client = motor.motor_asyncio.AsyncIOMotorClient(self.connection_string)
|
|
194
|
+
self.db = self.client[self.database_name]
|
|
195
|
+
|
|
196
|
+
# Test connection
|
|
197
|
+
await self.client.admin.command('ping')
|
|
198
|
+
self._connected = True
|
|
199
|
+
logger.info("Connected to MongoDB successfully")
|
|
200
|
+
return True
|
|
201
|
+
except Exception as e:
|
|
202
|
+
logger.warning(f"Failed to connect to MongoDB: {e}")
|
|
203
|
+
self._connected = False
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
async def create_user(self, user: User) -> User:
|
|
207
|
+
"""Create a new user."""
|
|
208
|
+
if not self._connected:
|
|
209
|
+
raise RuntimeError("Database not connected")
|
|
210
|
+
|
|
211
|
+
# Check if user exists
|
|
212
|
+
existing = await self.db.users.find_one({"username": user.username})
|
|
213
|
+
if existing:
|
|
214
|
+
raise ValueError(f"User with username '{user.username}' already exists")
|
|
215
|
+
|
|
216
|
+
await self.db.users.insert_one(user.to_dict())
|
|
217
|
+
return user
|
|
218
|
+
|
|
219
|
+
async def get_user_by_id(self, user_id: str) -> Optional[User]:
|
|
220
|
+
"""Get user by ID."""
|
|
221
|
+
if not self._connected:
|
|
222
|
+
raise RuntimeError("Database not connected")
|
|
223
|
+
|
|
224
|
+
user_data = await self.db.users.find_one({"id": user_id})
|
|
225
|
+
return User.from_dict(user_data) if user_data else None
|
|
226
|
+
|
|
227
|
+
async def get_user_by_username(self, username: str) -> Optional[User]:
|
|
228
|
+
"""Get user by username."""
|
|
229
|
+
if not self._connected:
|
|
230
|
+
raise RuntimeError("Database not connected")
|
|
231
|
+
|
|
232
|
+
user_data = await self.db.users.find_one({"username": username})
|
|
233
|
+
return User.from_dict(user_data) if user_data else None
|
|
234
|
+
|
|
235
|
+
async def get_user_by_telegram_id(self, telegram_id: str) -> Optional[User]:
|
|
236
|
+
"""Get user by Telegram ID."""
|
|
237
|
+
if not self._connected:
|
|
238
|
+
raise RuntimeError("Database not connected")
|
|
239
|
+
|
|
240
|
+
user_data = await self.db.users.find_one({"telegram_id": telegram_id})
|
|
241
|
+
return User.from_dict(user_data) if user_data else None
|
|
242
|
+
|
|
243
|
+
async def create_api_key(self, api_key: APIKey) -> APIKey:
|
|
244
|
+
"""Create a new API key."""
|
|
245
|
+
if not self._connected:
|
|
246
|
+
raise RuntimeError("Database not connected")
|
|
247
|
+
|
|
248
|
+
# Check if key exists
|
|
249
|
+
existing = await self.db.api_keys.find_one({"key": api_key.key})
|
|
250
|
+
if existing:
|
|
251
|
+
raise ValueError("API key already exists")
|
|
252
|
+
|
|
253
|
+
await self.db.api_keys.insert_one(api_key.to_dict())
|
|
254
|
+
return api_key
|
|
255
|
+
|
|
256
|
+
async def get_api_key(self, key: str) -> Optional[APIKey]:
|
|
257
|
+
"""Get API key by key value."""
|
|
258
|
+
if not self._connected:
|
|
259
|
+
raise RuntimeError("Database not connected")
|
|
260
|
+
|
|
261
|
+
key_data = await self.db.api_keys.find_one({"key": key})
|
|
262
|
+
return APIKey.from_dict(key_data) if key_data else None
|
|
263
|
+
|
|
264
|
+
async def update_api_key(self, api_key: APIKey) -> APIKey:
|
|
265
|
+
"""Update an existing API key."""
|
|
266
|
+
if not self._connected:
|
|
267
|
+
raise RuntimeError("Database not connected")
|
|
268
|
+
|
|
269
|
+
result = await self.db.api_keys.update_one(
|
|
270
|
+
{"id": api_key.id},
|
|
271
|
+
{"$set": api_key.to_dict()}
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
if result.matched_count == 0:
|
|
275
|
+
raise ValueError(f"API key with ID '{api_key.id}' not found")
|
|
276
|
+
|
|
277
|
+
return api_key
|
|
278
|
+
|
|
279
|
+
async def get_api_keys_by_user(self, user_id: str) -> List[APIKey]:
|
|
280
|
+
"""Get all API keys for a user."""
|
|
281
|
+
if not self._connected:
|
|
282
|
+
raise RuntimeError("Database not connected")
|
|
283
|
+
|
|
284
|
+
cursor = self.db.api_keys.find({"user_id": user_id})
|
|
285
|
+
keys = []
|
|
286
|
+
async for key_data in cursor:
|
|
287
|
+
keys.append(APIKey.from_dict(key_data))
|
|
288
|
+
return keys
|
|
289
|
+
|
|
290
|
+
async def get_rate_limit_entry(self, api_key_id: str) -> Optional[RateLimitEntry]:
|
|
291
|
+
"""Get rate limit entry for API key."""
|
|
292
|
+
if not self._connected:
|
|
293
|
+
raise RuntimeError("Database not connected")
|
|
294
|
+
|
|
295
|
+
entry_data = await self.db.rate_limits.find_one({"api_key_id": api_key_id})
|
|
296
|
+
return RateLimitEntry.from_dict(entry_data) if entry_data else None
|
|
297
|
+
|
|
298
|
+
async def update_rate_limit_entry(self, entry: RateLimitEntry) -> RateLimitEntry:
|
|
299
|
+
"""Update rate limit entry."""
|
|
300
|
+
if not self._connected:
|
|
301
|
+
raise RuntimeError("Database not connected")
|
|
302
|
+
|
|
303
|
+
await self.db.rate_limits.update_one(
|
|
304
|
+
{"api_key_id": entry.api_key_id},
|
|
305
|
+
{"$set": entry.to_dict()},
|
|
306
|
+
upsert=True
|
|
307
|
+
)
|
|
308
|
+
return entry
|
|
309
|
+
|
|
310
|
+
async def get_all_rate_limit_entries(self) -> list:
|
|
311
|
+
"""Return all rate limit entries (for maintenance/cleanup) from MongoDB."""
|
|
312
|
+
if not self._connected:
|
|
313
|
+
raise RuntimeError("Database not connected")
|
|
314
|
+
entries = []
|
|
315
|
+
cursor = self.db.rate_limits.find({})
|
|
316
|
+
async for entry_data in cursor:
|
|
317
|
+
entries.append(RateLimitEntry.from_dict(entry_data))
|
|
318
|
+
return entries
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class DatabaseManager:
|
|
322
|
+
"""Database manager that handles MongoDB with JSON fallback."""
|
|
323
|
+
|
|
324
|
+
def __init__(self, mongo_connection_string: Optional[str] = None, data_dir: str = "data"):
|
|
325
|
+
self.mongo_connection_string = mongo_connection_string or os.getenv("MONGODB_URL")
|
|
326
|
+
self.data_dir = data_dir
|
|
327
|
+
|
|
328
|
+
self.mongo_db = None
|
|
329
|
+
self.json_db = JSONDatabase(data_dir)
|
|
330
|
+
self.use_mongo = False
|
|
331
|
+
|
|
332
|
+
logger.info(f"Database manager initialized with data_dir: {data_dir}")
|
|
333
|
+
|
|
334
|
+
async def initialize(self) -> None:
|
|
335
|
+
"""Initialize database connection."""
|
|
336
|
+
if self.mongo_connection_string:
|
|
337
|
+
try:
|
|
338
|
+
self.mongo_db = MongoDatabase(self.mongo_connection_string)
|
|
339
|
+
self.use_mongo = await self.mongo_db.connect()
|
|
340
|
+
if self.use_mongo:
|
|
341
|
+
logger.info("Using MongoDB as primary database")
|
|
342
|
+
else:
|
|
343
|
+
logger.info("MongoDB connection failed, falling back to JSON database")
|
|
344
|
+
except Exception as e:
|
|
345
|
+
logger.warning(f"MongoDB initialization failed: {e}, using JSON database")
|
|
346
|
+
self.use_mongo = False
|
|
347
|
+
else:
|
|
348
|
+
logger.info("No MongoDB connection string provided, using JSON database")
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def db(self) -> Union[MongoDatabase, JSONDatabase]:
|
|
352
|
+
"""Get the active database instance."""
|
|
353
|
+
return self.mongo_db if self.use_mongo else self.json_db
|
|
354
|
+
|
|
355
|
+
async def create_user(self, user: User) -> User:
|
|
356
|
+
"""Create a new user."""
|
|
357
|
+
return await self.db.create_user(user)
|
|
358
|
+
|
|
359
|
+
async def get_user_by_id(self, user_id: str) -> Optional[User]:
|
|
360
|
+
"""Get user by ID."""
|
|
361
|
+
return await self.db.get_user_by_id(user_id)
|
|
362
|
+
|
|
363
|
+
async def get_user_by_username(self, username: str) -> Optional[User]:
|
|
364
|
+
"""Get user by username."""
|
|
365
|
+
return await self.db.get_user_by_username(username)
|
|
366
|
+
|
|
367
|
+
async def get_user_by_telegram_id(self, telegram_id: str) -> Optional[User]:
|
|
368
|
+
"""Get user by Telegram ID."""
|
|
369
|
+
return await self.db.get_user_by_telegram_id(telegram_id)
|
|
370
|
+
|
|
371
|
+
async def create_api_key(self, api_key: APIKey) -> APIKey:
|
|
372
|
+
"""Create a new API key."""
|
|
373
|
+
return await self.db.create_api_key(api_key)
|
|
374
|
+
|
|
375
|
+
async def get_api_key(self, key: str) -> Optional[APIKey]:
|
|
376
|
+
"""Get API key by key value."""
|
|
377
|
+
return await self.db.get_api_key(key)
|
|
378
|
+
|
|
379
|
+
async def update_api_key(self, api_key: APIKey) -> APIKey:
|
|
380
|
+
"""Update an existing API key."""
|
|
381
|
+
return await self.db.update_api_key(api_key)
|
|
382
|
+
|
|
383
|
+
async def get_api_keys_by_user(self, user_id: str) -> List[APIKey]:
|
|
384
|
+
"""Get all API keys for a user."""
|
|
385
|
+
return await self.db.get_api_keys_by_user(user_id)
|
|
386
|
+
|
|
387
|
+
async def get_rate_limit_entry(self, api_key_id: str) -> Optional[RateLimitEntry]:
|
|
388
|
+
"""Get rate limit entry for API key."""
|
|
389
|
+
return await self.db.get_rate_limit_entry(api_key_id)
|
|
390
|
+
|
|
391
|
+
async def update_rate_limit_entry(self, entry: RateLimitEntry) -> RateLimitEntry:
|
|
392
|
+
"""Update rate limit entry."""
|
|
393
|
+
return await self.db.update_rate_limit_entry(entry)
|
|
394
|
+
|
|
395
|
+
def get_status(self) -> Dict[str, str]:
|
|
396
|
+
"""Get database status."""
|
|
397
|
+
return {
|
|
398
|
+
"type": "MongoDB" if self.use_mongo else "JSON",
|
|
399
|
+
"status": "connected" if (self.use_mongo and self.mongo_db._connected) or (not self.use_mongo) else "disconnected"
|
|
400
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exceptions for the Webscout API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR
|
|
10
|
+
from .request_models import ErrorDetail, ErrorResponse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def clean_text(text):
|
|
14
|
+
"""Clean text by removing null bytes and control characters except newlines and tabs."""
|
|
15
|
+
if not isinstance(text, str):
|
|
16
|
+
return text
|
|
17
|
+
|
|
18
|
+
# Remove null bytes
|
|
19
|
+
text = text.replace('\x00', '')
|
|
20
|
+
|
|
21
|
+
# Keep newlines, tabs, and other printable characters, remove other control chars
|
|
22
|
+
# This regex matches control characters except \n, \r, \t
|
|
23
|
+
return re.sub(r'[\x01-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class APIError(Exception):
|
|
27
|
+
"""Custom exception for API errors."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message: str, status_code: int = HTTP_500_INTERNAL_SERVER_ERROR,
|
|
30
|
+
error_type: str = "server_error", param: Optional[str] = None,
|
|
31
|
+
code: Optional[str] = None):
|
|
32
|
+
self.message = message
|
|
33
|
+
self.status_code = status_code
|
|
34
|
+
self.error_type = error_type
|
|
35
|
+
self.param = param
|
|
36
|
+
self.code = code
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
|
|
39
|
+
def to_response(self) -> JSONResponse:
|
|
40
|
+
"""Convert to FastAPI JSONResponse."""
|
|
41
|
+
error_detail = ErrorDetail(
|
|
42
|
+
message=self.message,
|
|
43
|
+
type=self.error_type,
|
|
44
|
+
param=self.param,
|
|
45
|
+
code=self.code
|
|
46
|
+
)
|
|
47
|
+
error_response = ErrorResponse(error=error_detail)
|
|
48
|
+
return JSONResponse(
|
|
49
|
+
status_code=self.status_code,
|
|
50
|
+
content=error_response.model_dump(exclude_none=True)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def format_exception(e) -> str:
|
|
55
|
+
"""Format exception for JSON response."""
|
|
56
|
+
if isinstance(e, str):
|
|
57
|
+
message = e
|
|
58
|
+
else:
|
|
59
|
+
message = f"{e.__class__.__name__}: {str(e)}"
|
|
60
|
+
return json.dumps({
|
|
61
|
+
"error": {
|
|
62
|
+
"message": message,
|
|
63
|
+
"type": "server_error",
|
|
64
|
+
"param": None,
|
|
65
|
+
"code": "internal_server_error"
|
|
66
|
+
}
|
|
67
|
+
})
|