v0.99.51: security hardening — bcrypt passwords, rate limiting, auth fixes,...

v0.99.51: security hardening — bcrypt passwords, rate limiting, auth fixes, safe cache serialisation

- Migrate password hashing from SHA-256 to bcrypt with backward-compatible auto-upgrade on login
- Add login rate limiting (10 attempts / 5 min window, 10 min lockout) per IP+username
- Force password change when default admin/admin credentials are detected (C3)
- Fix /api/admin/* middleware to require valid admin session instead of unconditional bypass (C5)
- Replace pickle serialisation in all cache backends (Redis, SQLite, MySQL, File) with JSON-first encoding; legacy pickle data still readable (H9)
- Fix PayPal webhook: implement 6 previously empty handler stubs with real wallet credit/debit logic (H1)
- Fix Stripe: remove no-op _handle_payment_succeeded stub, fix real implementation to use WalletManager (C7)
- Fix crypto address derivation race condition via BEGIN EXCLUSIVE / SELECT FOR UPDATE (H6)
- Fix PayPal webhook verification: return False (not True) when webhook_id not configured (C6)
- Fix pre-existing password reset flow using non-existent DB methods
- Fix CORS: allow_credentials=False to be compatible with wildcard origins
- Fix session cookie flags: same_site=lax, https_only via AISBF_HTTPS env var
- Fix background task GC: hold strong references to prevent premature task collection
- Remove dead Jinja2 environment and commented-out analytics init code
- Apply XSS escaping to user-controlled innerHTML in analytics and autoselects dashboard templates
- Update docs: security warnings in README and DEBUG_GUIDE, missing endpoints in ENDPOINTS.md
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 46ddc7ef
...@@ -4,6 +4,9 @@ ...@@ -4,6 +4,9 @@
Comprehensive debug logging has been added throughout the AISBF codebase to help understand how models and providers are selected. This guide explains what information is logged and how to use it. Comprehensive debug logging has been added throughout the AISBF codebase to help understand how models and providers are selected. This guide explains what information is logged and how to use it.
> **Security warning — never enable `AISBF_DEBUG=true` in production.**
> Debug mode logs full request and response bodies, which may contain user messages, API keys, tool call results, and other sensitive data. Use debug logging only in isolated development environments.
## Recent Fixes ## Recent Fixes
### Fixed: Ollama Provider Handler Initialization ### Fixed: Ollama Provider Handler Initialization
......
# AISBF Endpoint Documentation # AISBF Endpoint Documentation
Generated: 2026-04-20T20:48:14+02:00 Generated: 2026-04-22T00:00:00+02:00
--- ---
...@@ -199,6 +199,10 @@ Generated: 2026-04-20T20:48:14+02:00 ...@@ -199,6 +199,10 @@ Generated: 2026-04-20T20:48:14+02:00
| `/api/admin/scheduler/status` | GET | global admin | Scheduler status | | `/api/admin/scheduler/status` | GET | global admin | Scheduler status |
| `/api/admin/scheduler/run-job` | POST | global admin | Run scheduler job manually | | `/api/admin/scheduler/run-job` | POST | global admin | Run scheduler job manually |
| `/api/admin/payment-system/config` | GET | global admin | Payment system configuration | | `/api/admin/payment-system/config` | GET | global admin | Payment system configuration |
| `/api/admin/payment-system/config/price-sources` | PUT | global admin | Update price sources configuration |
| `/api/admin/payment-system/config/consolidation` | PUT | global admin | Update consolidation configuration |
| `/api/admin/payment-system/config/email` | PUT | global admin | Update email configuration |
| `/api/admin/payment-system/config/blockchain` | PUT | global admin | Update blockchain configuration |
| `/api/admin/payment-system/status` | GET | global admin | Payment system status | | `/api/admin/payment-system/status` | GET | global admin | Payment system status |
| `/api/admin/crypto/prices` | GET | global admin | Crypto prices | | `/api/admin/crypto/prices` | GET | global admin | Crypto prices |
| `/api/admin/crypto/btc-prices` | GET | global admin | BTC prices | | `/api/admin/crypto/btc-prices` | GET | global admin | BTC prices |
......
...@@ -55,7 +55,11 @@ Server starts on `http://127.0.0.1:17765` ...@@ -55,7 +55,11 @@ Server starts on `http://127.0.0.1:17765`
## Web Dashboard ## Web Dashboard
Access the dashboard at `http://localhost:17765/dashboard` (default credentials: admin/admin) Access the dashboard at `http://localhost:17765/dashboard` (default credentials: `admin` / `admin`)
> **Security — change the default password immediately.**
> The default `admin/admin` credentials are publicly known. Open the dashboard → Settings → Change Password before exposing AISBF to any network.
> For HTTPS deployments, set the environment variable `AISBF_HTTPS=true` to mark session cookies as Secure.
The dashboard provides: The dashboard provides:
- Provider configuration and API key management - Provider configuration and API key management
......
...@@ -54,7 +54,7 @@ from .auth.qwen import QwenOAuth2 ...@@ -54,7 +54,7 @@ from .auth.qwen import QwenOAuth2
from .handlers import RequestHandler, RotationHandler, AutoselectHandler from .handlers import RequestHandler, RotationHandler, AutoselectHandler
from .utils import count_messages_tokens, split_messages_into_chunks, get_max_request_tokens_for_model from .utils import count_messages_tokens, split_messages_into_chunks, get_max_request_tokens_for_model
__version__ = "0.99.50" __version__ = "0.99.51"
__all__ = [ __all__ = [
# Config # Config
"config", "config",
...@@ -71,8 +71,6 @@ __all__ = [ ...@@ -71,8 +71,6 @@ __all__ = [
"Model", "Model",
"Provider", "Provider",
"ErrorTracking", "ErrorTracking",
"AutoselectModelInfo",
"AutoselectConfig",
# Providers # Providers
"BaseProviderHandler", "BaseProviderHandler",
"GoogleProviderHandler", "GoogleProviderHandler",
......
...@@ -213,7 +213,7 @@ class KiroAuthManager: ...@@ -213,7 +213,7 @@ class KiroAuthManager:
username = getpass.getuser() username = getpass.getuser()
unique_string = f"{hostname}-{username}-kiro-gateway" unique_string = f"{hostname}-{username}-kiro-gateway"
return hashlib.sha256(unique_string.encode()).hexdigest() return hashlib.sha256(unique_string.encode()).hexdigest()
except: except Exception:
return hashlib.sha256(b"default-machine-fingerprint").hexdigest() return hashlib.sha256(b"default-machine-fingerprint").hexdigest()
async def get_access_token(self) -> str: async def get_access_token(self) -> str:
...@@ -389,5 +389,5 @@ class KiroAuthManager: ...@@ -389,5 +389,5 @@ class KiroAuthManager:
username = getpass.getuser() username = getpass.getuser()
unique_string = f"{hostname}-{username}-kiro-gateway" unique_string = f"{hostname}-{username}-kiro-gateway"
return hashlib.sha256(unique_string.encode()).hexdigest() return hashlib.sha256(unique_string.encode()).hexdigest()
except: except Exception:
return hashlib.sha256(b"default-machine-fingerprint").hexdigest() return hashlib.sha256(b"default-machine-fingerprint").hexdigest()
\ No newline at end of file
...@@ -22,6 +22,33 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -22,6 +22,33 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
import json import json
import pickle import pickle
import logging import logging
# --- safe serialisation helpers -------------------------------------------------
# Always write JSON; fall back to pickle only for non-JSON-serialisable objects.
# On read, detect format by attempting JSON first so legacy pickle data still works.
def _cache_encode(value: any) -> bytes:
"""Encode a cache value. Prefers JSON; falls back to pickle."""
try:
return b'\x00' + json.dumps(value, ensure_ascii=False).encode('utf-8')
except (TypeError, ValueError):
return b'\x01' + pickle.dumps(value)
def _cache_decode(data: bytes) -> any:
"""Decode a cache value encoded by _cache_encode, or legacy raw pickle bytes."""
if isinstance(data, memoryview):
data = bytes(data)
if not data:
return None
if data[0:1] == b'\x00':
return json.loads(data[1:].decode('utf-8'))
if data[0:1] == b'\x01':
return pickle.loads(data[1:])
# Legacy: no prefix — assume raw pickle
try:
return pickle.loads(data)
except Exception:
return json.loads(data.decode('utf-8'))
from typing import Any, Optional, Dict, List from typing import Any, Optional, Dict, List
from pathlib import Path from pathlib import Path
import time import time
...@@ -139,7 +166,7 @@ class RedisCache(CacheBackend): ...@@ -139,7 +166,7 @@ class RedisCache(CacheBackend):
try: try:
data = self.redis.get(self._make_key(key)) data = self.redis.get(self._make_key(key))
if data: if data:
return pickle.loads(data) return _cache_decode(data)
return None return None
except Exception as e: except Exception as e:
logger.warning(f"Redis get error: {e}") logger.warning(f"Redis get error: {e}")
...@@ -147,7 +174,7 @@ class RedisCache(CacheBackend): ...@@ -147,7 +174,7 @@ class RedisCache(CacheBackend):
def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None: def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
try: try:
data = pickle.dumps(value) data = _cache_encode(value)
if ttl: if ttl:
self.redis.setex(self._make_key(key), ttl, data) self.redis.setex(self._make_key(key), ttl, data)
else: else:
...@@ -252,8 +279,9 @@ class SQLiteCache(CacheBackend): ...@@ -252,8 +279,9 @@ class SQLiteCache(CacheBackend):
conn.commit() conn.commit()
return None return None
# Deserialize the value # Decode: stored value may be bytes (new) or a latin1 string (legacy pickle)
return pickle.loads(value_str.encode('latin1')) raw = value_str if isinstance(value_str, bytes) else value_str.encode('latin1')
return _cache_decode(raw)
return None return None
except Exception as e: except Exception as e:
...@@ -265,11 +293,9 @@ class SQLiteCache(CacheBackend): ...@@ -265,11 +293,9 @@ class SQLiteCache(CacheBackend):
import time import time
try: try:
# Serialize the value value_bytes = _cache_encode(value)
value_bytes = pickle.dumps(value) # Store as latin1 string so it fits the TEXT column used by legacy schema
value_str = value_bytes.decode('latin1') value_str = value_bytes.decode('latin1')
# Calculate TTL timestamp if provided
ttl_timestamp = time.time() + ttl if ttl else None ttl_timestamp = time.time() + ttl if ttl else None
with sqlite3.connect(str(self.db_path)) as conn: with sqlite3.connect(str(self.db_path)) as conn:
...@@ -429,7 +455,7 @@ class MySQLCache(CacheBackend): ...@@ -429,7 +455,7 @@ class MySQLCache(CacheBackend):
return None return None
# Deserialize the value # Deserialize the value
return pickle.loads(value_str.encode('latin1')) return _cache_decode(value_str.encode('latin1'))
return None return None
except Exception as e: except Exception as e:
...@@ -441,7 +467,7 @@ class MySQLCache(CacheBackend): ...@@ -441,7 +467,7 @@ class MySQLCache(CacheBackend):
try: try:
# Serialize the value # Serialize the value
value_bytes = pickle.dumps(value) value_bytes = _cache_encode(value)
value_str = value_bytes.decode('latin1') value_str = value_bytes.decode('latin1')
# Calculate TTL timestamp if provided # Calculate TTL timestamp if provided
...@@ -528,7 +554,7 @@ class FileCache(CacheBackend): ...@@ -528,7 +554,7 @@ class FileCache(CacheBackend):
try: try:
with open(cache_path, 'rb') as f: with open(cache_path, 'rb') as f:
return pickle.load(f) return _cache_decode(f.read())
except Exception as e: except Exception as e:
logger.warning(f"File cache get error for {key}: {e}") logger.warning(f"File cache get error for {key}: {e}")
return None return None
...@@ -537,7 +563,7 @@ class FileCache(CacheBackend): ...@@ -537,7 +563,7 @@ class FileCache(CacheBackend):
cache_path = self._get_cache_path(key) cache_path = self._get_cache_path(key)
try: try:
with open(cache_path, 'wb') as f: with open(cache_path, 'wb') as f:
pickle.dump(value, f) f.write(_cache_encode(value))
except Exception as e: except Exception as e:
logger.warning(f"File cache set error for {key}: {e}") logger.warning(f"File cache set error for {key}: {e}")
...@@ -1613,7 +1639,7 @@ class ResponseCache: ...@@ -1613,7 +1639,7 @@ class ResponseCache:
try: try:
pattern = f"{self.key_prefix}*" pattern = f"{self.key_prefix}*"
stats['current_size'] = len(self.redis_client.keys(pattern)) stats['current_size'] = len(self.redis_client.keys(pattern))
except: except Exception:
stats['current_size'] = 0 stats['current_size'] = 0
elif self.backend == 'sqlite' and self.sqlite_backend: elif self.backend == 'sqlite' and self.sqlite_backend:
stats['current_size'] = self.sqlite_backend.get_size() stats['current_size'] = self.sqlite_backend.get_size()
......
...@@ -91,7 +91,7 @@ class ContextManager: ...@@ -91,7 +91,7 @@ class ContextManager:
is_rotation = True is_rotation = True
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.info(f"Condensation model '{model_value}' is a rotation ID") logger.info(f"Condensation model '{model_value}' is a rotation ID")
except: except Exception:
pass # Not a rotation, treat as direct model pass # Not a rotation, treat as direct model
if is_rotation: if is_rotation:
......
...@@ -23,6 +23,7 @@ Database module for persistent tracking of context dimensions and rate limiting. ...@@ -23,6 +23,7 @@ Database module for persistent tracking of context dimensions and rate limiting.
""" """
import sqlite3 import sqlite3
import json import json
import hashlib
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple, Any from typing import Dict, List, Optional, Tuple, Any
from datetime import datetime, timedelta from datetime import datetime, timedelta
...@@ -30,6 +31,25 @@ import logging ...@@ -30,6 +31,25 @@ import logging
import asyncio import asyncio
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
try:
import bcrypt as _bcrypt_lib
_BCRYPT_AVAILABLE = True
except ImportError: # pragma: no cover
_BCRYPT_AVAILABLE = False
def _hash_password(password: str) -> str:
"""Hash a password. Uses bcrypt when available, falls back to SHA-256."""
if _BCRYPT_AVAILABLE:
return _bcrypt_lib.hashpw(password.encode(), _bcrypt_lib.gensalt()).decode()
return hashlib.sha256(password.encode()).hexdigest()
def _verify_password(password: str, stored_hash: str) -> bool:
"""Verify a password against a stored hash (bcrypt or legacy SHA-256)."""
if stored_hash.startswith("$2") and _BCRYPT_AVAILABLE:
return _bcrypt_lib.checkpw(password.encode(), stored_hash.encode())
# Legacy SHA-256 path
return hashlib.sha256(password.encode()).hexdigest() == stored_hash
try: try:
import mysql.connector as _mysql_connector import mysql.connector as _mysql_connector
MYSQL_AVAILABLE = True MYSQL_AVAILABLE = True
...@@ -706,13 +726,16 @@ class DatabaseManager: ...@@ -706,13 +726,16 @@ class DatabaseManager:
} }
# User management methods # User management methods
def authenticate_user(self, username: str, password_hash: str) -> Optional[Dict]: def authenticate_user(self, username: str, password: str) -> Optional[Dict]:
""" """
Authenticate a user by username and password hash. Authenticate a user by username and plain-text password.
Supports bcrypt hashes and legacy SHA-256 hashes. On a successful
SHA-256 match the stored hash is transparently upgraded to bcrypt.
Args: Args:
username: Username to authenticate username: Username to authenticate
password_hash: SHA256 hash of the password password: Plain-text password
Returns: Returns:
User dict if authenticated, None otherwise User dict if authenticated, None otherwise
...@@ -720,7 +743,7 @@ class DatabaseManager: ...@@ -720,7 +743,7 @@ class DatabaseManager:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s' placeholder = '?' if self.db_type == 'sqlite' else '%s'
# First check what columns exist in users table # First check what columns exist in users table
if self.db_type == 'sqlite': if self.db_type == 'sqlite':
cursor.execute("PRAGMA table_info(users)") cursor.execute("PRAGMA table_info(users)")
...@@ -733,8 +756,8 @@ class DatabaseManager: ...@@ -733,8 +756,8 @@ class DatabaseManager:
AND TABLE_SCHEMA = DATABASE() AND TABLE_SCHEMA = DATABASE()
""") """)
columns = [col[0] for col in cursor.fetchall()] columns = [col[0] for col in cursor.fetchall()]
select_fields = ['id', 'username', 'role', 'is_active'] select_fields = ['id', 'username', 'role', 'is_active', 'password_hash']
if 'email' in columns: if 'email' in columns:
select_fields.append('email') select_fields.append('email')
if 'email_verified' in columns: if 'email_verified' in columns:
...@@ -743,41 +766,55 @@ class DatabaseManager: ...@@ -743,41 +766,55 @@ class DatabaseManager:
select_fields.append('created_at') select_fields.append('created_at')
if 'last_verification_email_sent' in columns: if 'last_verification_email_sent' in columns:
select_fields.append('last_verification_email_sent') select_fields.append('last_verification_email_sent')
cursor.execute(f''' cursor.execute(f'''
SELECT {', '.join(select_fields)} SELECT {', '.join(select_fields)}
FROM users FROM users
WHERE username = {placeholder} AND password_hash = {placeholder} AND is_active = 1 WHERE username = {placeholder} AND is_active = 1
''', (username, password_hash)) ''', (username,))
row = cursor.fetchone() row = cursor.fetchone()
if row: if not row:
result = { return None
'id': row[0],
'username': row[1],
'role': row[2],
'is_active': row[3],
'email': None,
'email_verified': True,
'created_at': None,
'last_verification_email_sent': None
}
idx = 4 stored_hash = row[4]
if 'email' in columns: if not _verify_password(password, stored_hash):
result['email'] = row[idx] or None return None
idx += 1
if 'email_verified' in columns: # Auto-upgrade legacy SHA-256 hash to bcrypt on successful login
result['email_verified'] = bool(row[idx]) if row[idx] is not None else True if not stored_hash.startswith("$2") and _BCRYPT_AVAILABLE:
idx += 1 new_hash = _hash_password(password)
if 'created_at' in columns: cursor.execute(
result['created_at'] = row[idx] if row[idx] else None f'UPDATE users SET password_hash = {placeholder} WHERE id = {placeholder}',
idx += 1 (new_hash, row[0])
if 'last_verification_email_sent' in columns: )
result['last_verification_email_sent'] = row[idx] if row[idx] else None conn.commit()
return result result = {
return None 'id': row[0],
'username': row[1],
'role': row[2],
'is_active': row[3],
'email': None,
'email_verified': True,
'created_at': None,
'last_verification_email_sent': None
}
idx = 5
if 'email' in columns:
result['email'] = row[idx] or None
idx += 1
if 'email_verified' in columns:
result['email_verified'] = bool(row[idx]) if row[idx] is not None else True
idx += 1
if 'created_at' in columns:
result['created_at'] = row[idx] if row[idx] else None
idx += 1
if 'last_verification_email_sent' in columns:
result['last_verification_email_sent'] = row[idx] if row[idx] else None
return result
def get_user_by_username(self, username: str) -> Optional[Dict]: def get_user_by_username(self, username: str) -> Optional[Dict]:
""" """
...@@ -816,7 +853,7 @@ class DatabaseManager: ...@@ -816,7 +853,7 @@ class DatabaseManager:
Args: Args:
username: Username for the new user username: Username for the new user
password_hash: SHA256 hash of the password password_hash: Password hash (bcrypt or SHA-256 legacy)
role: User role ('admin' or 'user') role: User role ('admin' or 'user')
created_by: Username of the creator created_by: Username of the creator
email: Email address (optional) email: Email address (optional)
...@@ -1089,7 +1126,7 @@ class DatabaseManager: ...@@ -1089,7 +1126,7 @@ class DatabaseManager:
Args: Args:
user_id: User ID user_id: User ID
password_hash: New SHA256 password hash password_hash: New password hash (bcrypt or SHA-256)
""" """
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
...@@ -1336,7 +1373,9 @@ class DatabaseManager: ...@@ -1336,7 +1373,9 @@ class DatabaseManager:
def verify_user_password(self, user_id: int, password: str) -> bool: def verify_user_password(self, user_id: int, password: str) -> bool:
""" """
Verify a user's password. Verify a user's plain-text password against the stored hash.
Supports bcrypt and legacy SHA-256 hashes.
Args: Args:
user_id: User ID user_id: User ID
...@@ -1345,17 +1384,16 @@ class DatabaseManager: ...@@ -1345,17 +1384,16 @@ class DatabaseManager:
Returns: Returns:
True if password matches, False otherwise True if password matches, False otherwise
""" """
import hashlib
password_hash = hashlib.sha256(password.encode()).hexdigest()
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s' placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f''' cursor.execute(f'''
SELECT id FROM users SELECT password_hash FROM users WHERE id = {placeholder}
WHERE id = {placeholder} AND password_hash = {placeholder} ''', (user_id,))
''', (user_id, password_hash)) row = cursor.fetchone()
return cursor.fetchone() is not None if not row:
return False
return _verify_password(password, row[0])
def update_user_email(self, user_id: int, new_email: str): def update_user_email(self, user_id: int, new_email: str):
""" """
......
...@@ -34,16 +34,9 @@ logger = logging.getLogger(__name__) ...@@ -34,16 +34,9 @@ logger = logging.getLogger(__name__)
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
""" """Hash a password. Delegates to database._hash_password (bcrypt when available)."""
Hash a password using SHA256. from aisbf.database import _hash_password
return _hash_password(password)
Args:
password: Plain text password
Returns:
SHA256 hash of the password
"""
return hashlib.sha256(password.encode()).hexdigest()
def generate_verification_token() -> str: def generate_verification_token() -> str:
......
...@@ -3095,7 +3095,7 @@ class RotationHandler: ...@@ -3095,7 +3095,7 @@ class RotationHandler:
error_part = last_error[:json_start] error_part = last_error[:json_start]
error_details.append(f"**Last error:** {error_part}") error_details.append(f"**Last error:** {error_part}")
error_details.append(f"```json\n{formatted_json}\n```") error_details.append(f"```json\n{formatted_json}\n```")
except: except Exception:
error_details.append(f"**Last error:**") error_details.append(f"**Last error:**")
error_details.append(f"{last_error}") error_details.append(f"{last_error}")
else: else:
...@@ -3104,7 +3104,7 @@ class RotationHandler: ...@@ -3104,7 +3104,7 @@ class RotationHandler:
else: else:
error_details.append(f"**Last error:**") error_details.append(f"**Last error:**")
error_details.append(f"{last_error}") error_details.append(f"{last_error}")
except: except Exception:
error_details.append(f"**Last error:**") error_details.append(f"**Last error:**")
error_details.append(f"{last_error}") error_details.append(f"{last_error}")
......
...@@ -145,29 +145,57 @@ class CryptoWalletManager: ...@@ -145,29 +145,57 @@ class CryptoWalletManager:
} }
async def create_payment_address(self, user_id: int, crypto_type: str, payment_id: str) -> str: async def create_payment_address(self, user_id: int, crypto_type: str, payment_id: str) -> str:
"""Derive a fresh address for each payment request""" """Derive a fresh on-chain address for each payment request.
Uses a single locked transaction to read MAX(derivation_index) and
insert the new row atomically, preventing two concurrent requests from
deriving the same address for different users.
"""
placeholder = '?' if self.db.db_type == 'sqlite' else '%s' placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
with self.db._get_connection() as conn: for attempt in range(5):
cursor = conn.cursor() with self.db._get_connection() as conn:
cursor.execute(f""" cursor = conn.cursor()
SELECT COALESCE(MAX(derivation_index), -1)
FROM user_crypto_addresses
WHERE crypto_type = {placeholder}
""", (crypto_type,))
next_index = cursor.fetchone()[0] + 1
address_info = self.derive_address(crypto_type, next_index) if self.db.db_type == 'sqlite':
cursor.execute("BEGIN EXCLUSIVE")
else:
cursor.execute("START TRANSACTION")
with self.db._get_connection() as conn: try:
cursor = conn.cursor() if self.db.db_type == 'mysql':
cursor.execute(f""" cursor.execute(f"""
INSERT INTO user_crypto_addresses SELECT COALESCE(MAX(derivation_index), -1)
(user_id, crypto_type, address, derivation_path, derivation_index, payment_id) FROM user_crypto_addresses
VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}) WHERE crypto_type = {placeholder}
""", (user_id, crypto_type, address_info['address'], FOR UPDATE
address_info['derivation_path'], address_info['derivation_index'], payment_id)) """, (crypto_type,))
conn.commit() else:
cursor.execute(f"""
SELECT COALESCE(MAX(derivation_index), -1)
FROM user_crypto_addresses
WHERE crypto_type = {placeholder}
""", (crypto_type,))
next_index = cursor.fetchone()[0] + 1
address_info = self.derive_address(crypto_type, next_index)
cursor.execute(f"""
INSERT INTO user_crypto_addresses
(user_id, crypto_type, address, derivation_path, derivation_index, payment_id)
VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder})
""", (user_id, crypto_type, address_info['address'],
address_info['derivation_path'], address_info['derivation_index'], payment_id))
conn.commit()
break # success
except Exception as exc:
try:
conn.rollback()
except Exception:
pass
if attempt == 4:
raise
logger.warning(f"crypto address derivation conflict (attempt {attempt+1}): {exc}")
with self.db._get_connection() as conn: with self.db._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
......
...@@ -358,8 +358,8 @@ class PayPalPaymentHandler: ...@@ -358,8 +358,8 @@ class PayPalPaymentHandler:
"""Verify PayPal webhook signature via PayPal's verify-webhook-signature API.""" """Verify PayPal webhook signature via PayPal's verify-webhook-signature API."""
webhook_id = self.webhook_secret # stored as 'webhook_secret' in admin settings webhook_id = self.webhook_secret # stored as 'webhook_secret' in admin settings
if not webhook_id: if not webhook_id:
logger.warning("PayPal webhook_id not configured - skipping signature verification") logger.error("PayPal webhook_id not configured - rejecting webhook (configure webhook_id in payment settings)")
return True return False
try: try:
access_token = await self.get_access_token() access_token = await self.get_access_token()
...@@ -386,56 +386,159 @@ class PayPalPaymentHandler: ...@@ -386,56 +386,159 @@ class PayPalPaymentHandler:
logger.error(f"PayPal webhook signature verification error: {e}") logger.error(f"PayPal webhook signature verification error: {e}")
return False return False
async def _credit_wallet_for_paypal(self, user_id: int, amount: Decimal,
gateway_tx_id: str, description: str) -> None:
"""Credit the user wallet via WalletManager using the db-compatible interface."""
from aisbf.payments.wallet.manager import WalletManager
wallet_manager = WalletManager(self.db)
await wallet_manager.credit_wallet(
user_id=user_id,
amount=amount,
transaction_details={
'payment_gateway': 'paypal',
'gateway_transaction_id': gateway_tx_id,
'description': description,
'metadata': {'paypal_tx_id': gateway_tx_id},
}
)
logger.info(f"PayPal wallet credit: user={user_id}, amount={amount}, tx={gateway_tx_id}")
async def _handle_order_completed(self, resource: dict): async def _handle_order_completed(self, resource: dict):
"""Handle completed order (Vault v3)""" """Handle completed checkout order — credit wallet for top-up orders."""
order_id = resource.get('id') order_id = resource.get('id')
logger.info(f"PayPal order completed: {order_id}") logger.info(f"PayPal order completed: {order_id}")
# TODO: Update transaction status in database
purchase_units = resource.get('purchase_units', [])
if not purchase_units:
return
pu = purchase_units[0]
description = pu.get('description', '')
if 'Wallet top up' not in description:
return
try:
amount = Decimal(pu['amount']['value'])
user_id = int(resource.get('custom_id', 0))
except (KeyError, ValueError, TypeError) as e:
logger.error(f"PayPal order completed: could not parse amount/user_id: {e}")
return
if user_id <= 0:
logger.error(f"PayPal order completed: missing custom_id on order {order_id}")
return
await self._credit_wallet_for_paypal(user_id, amount, order_id,
'Wallet top up via PayPal')
async def _handle_order_approved(self, resource: dict): async def _handle_order_approved(self, resource: dict):
"""Handle approved order""" """Handle approved order (capture pending)."""
order_id = resource.get('id') order_id = resource.get('id')
logger.info(f"PayPal order approved: {order_id}") logger.info(f"PayPal order approved: {order_id}")
async def _handle_payment_capture_completed(self, resource: dict): async def _handle_payment_capture_completed(self, resource: dict):
"""Handle completed payment capture""" """Handle completed payment capture — credit wallet."""
capture_id = resource.get('id') capture_id = resource.get('id')
logger.info(f"PayPal payment capture completed: {capture_id}") logger.info(f"PayPal payment capture completed: {capture_id}")
# TODO: Mark subscription payment as successful custom_id = resource.get('custom_id', '')
amount_obj = resource.get('amount', {})
try:
amount = Decimal(amount_obj.get('value', '0'))
user_id = int(custom_id) if custom_id else 0
except (ValueError, TypeError):
user_id = 0
if user_id > 0 and amount > 0:
await self._credit_wallet_for_paypal(user_id, amount, capture_id,
'Payment capture via PayPal')
else:
logger.warning(f"PayPal capture completed but missing user_id/amount: {capture_id}")
async def _handle_payment_capture_denied(self, resource: dict): async def _handle_payment_capture_denied(self, resource: dict):
"""Handle denied payment capture""" """Handle denied payment capture — queue for retry."""
capture_id = resource.get('id') capture_id = resource.get('id')
logger.warning(f"PayPal payment capture denied: {capture_id}") logger.warning(f"PayPal payment capture denied: {capture_id}")
# TODO: Add to payment retry queue # Record failed attempt in the payment_retry_queue so the scheduler retries it
try:
placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(f"""
INSERT INTO payment_retry_queue
(gateway, gateway_transaction_id, status, next_retry_at, created_at)
VALUES ({placeholder}, {placeholder}, 'pending',
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", ('paypal', capture_id))
conn.commit()
except Exception as e:
logger.error(f"PayPal: failed to queue denied capture {capture_id} for retry: {e}")
async def _handle_payment_refunded(self, resource: dict): async def _handle_payment_refunded(self, resource: dict):
"""Handle refunded payment""" """Handle refunded payment — debit the wallet to reverse the credit."""
refund_id = resource.get('id') refund_id = resource.get('id')
logger.info(f"PayPal payment refunded: {refund_id}") logger.info(f"PayPal payment refunded: {refund_id}")
# TODO: Update transaction and subscription status amount_obj = resource.get('amount', {})
custom_id = resource.get('custom_id', '')
try:
amount = Decimal(amount_obj.get('value', '0'))
user_id = int(custom_id) if custom_id else 0
except (ValueError, TypeError):
user_id = 0
if user_id > 0 and amount > 0:
try:
from aisbf.payments.wallet.manager import WalletManager
wallet_manager = WalletManager(self.db)
await wallet_manager.debit_wallet(
user_id=user_id,
amount=amount,
transaction_details={
'payment_gateway': 'paypal',
'gateway_transaction_id': refund_id,
'description': 'Refund via PayPal',
'metadata': {'refund_id': refund_id},
}
)
logger.info(f"PayPal refund applied: user={user_id}, amount={amount}")
except Exception as e:
logger.error(f"PayPal refund: wallet debit failed for {refund_id}: {e}")
else:
logger.warning(f"PayPal refund: cannot apply refund {refund_id} — missing user_id/amount")
async def _handle_vault_token_created(self, resource: dict): async def _handle_vault_token_created(self, resource: dict):
"""Handle vault token creation""" """Handle vault token creation."""
token_id = resource.get('id') token_id = resource.get('id')
logger.info(f"PayPal vault token created: {token_id}") logger.info(f"PayPal vault token created: {token_id}")
async def _handle_vault_token_deleted(self, resource: dict): async def _handle_vault_token_deleted(self, resource: dict):
"""Handle vault token deletion""" """Handle vault token deletion — deactivate matching payment method."""
token_id = resource.get('id') token_id = resource.get('id')
logger.info(f"PayPal vault token deleted: {token_id}") logger.info(f"PayPal vault token deleted: {token_id}")
# TODO: Mark payment method as inactive in database try:
placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(f"""
UPDATE payment_methods
SET is_active = 0
WHERE gateway_token = {placeholder} AND type = 'paypal'
""", (token_id,))
conn.commit()
if cursor.rowcount:
logger.info(f"Deactivated payment method for deleted PayPal vault token {token_id}")
except Exception as e:
logger.error(f"PayPal: failed to deactivate payment method for vault token {token_id}: {e}")
async def _handle_dispute_created(self, resource: dict): async def _handle_dispute_created(self, resource: dict):
"""Handle dispute creation""" """Handle dispute creation — log and alert via application logger (admin monitors logs)."""
dispute_id = resource.get('dispute_id') dispute_id = resource.get('dispute_id')
logger.warning(f"PayPal dispute created: {dispute_id}") reason = resource.get('reason', 'unknown')
# TODO: Send notification to admin amount_obj = (resource.get('dispute_amount') or {})
amount = amount_obj.get('value', '?')
logger.error(
f"PAYPAL DISPUTE CREATED — id={dispute_id} reason={reason} amount={amount}. "
"Review at https://www.paypal.com/disputes/ and update dispute status manually."
)
async def _handle_dispute_resolved(self, resource: dict): async def _handle_dispute_resolved(self, resource: dict):
"""Handle dispute resolution""" """Handle dispute resolution."""
dispute_id = resource.get('dispute_id') dispute_id = resource.get('dispute_id')
logger.info(f"PayPal dispute resolved: {dispute_id}") outcome = resource.get('dispute_outcome', {}).get('outcome_code', 'unknown')
logger.info(f"PayPal dispute resolved: {dispute_id} outcome={outcome}")
async def create_topup_order(self, user_id: int, amount: Decimal) -> dict: async def create_topup_order(self, user_id: int, amount: Decimal) -> dict:
"""Create PayPal order for wallet top up""" """Create PayPal order for wallet top up"""
......
...@@ -158,10 +158,6 @@ class StripePaymentHandler: ...@@ -158,10 +158,6 @@ class StripePaymentHandler:
logger.error(f"Error handling Stripe webhook: {e}") logger.error(f"Error handling Stripe webhook: {e}")
return {'status': 'error', 'message': str(e)} return {'status': 'error', 'message': str(e)}
async def _handle_payment_succeeded(self, payment_intent: dict):
"""Handle successful payment"""
logger.info(f"Payment succeeded: {payment_intent['id']}")
async def create_topup_intent(self, user_id: int, amount: Decimal, payment_method_id: str = None) -> dict: async def create_topup_intent(self, user_id: int, amount: Decimal, payment_method_id: str = None) -> dict:
"""Create Stripe PaymentIntent for wallet top up""" """Create Stripe PaymentIntent for wallet top up"""
try: try:
...@@ -204,33 +200,33 @@ class StripePaymentHandler: ...@@ -204,33 +200,33 @@ class StripePaymentHandler:
return {'success': False, 'error': str(e)} return {'success': False, 'error': str(e)}
async def _handle_payment_succeeded(self, payment_intent: dict): async def _handle_payment_succeeded(self, payment_intent: dict):
"""Handle successful payment""" """Handle successful Stripe payment — credits user wallet for top-up intents."""
logger.info(f"Payment succeeded: {payment_intent['id']}") logger.info(f"Payment succeeded: {payment_intent['id']}")
metadata = payment_intent.get('metadata', {}) metadata = payment_intent.get('metadata', {})
if metadata.get('topup') == 'true': if metadata.get('topup') != 'true':
return
try:
user_id = int(metadata['user_id']) user_id = int(metadata['user_id'])
amount = Decimal(metadata['amount']) amount = Decimal(metadata['amount'])
except (KeyError, ValueError) as e:
from aisbf.payments.wallet.manager import WalletManager logger.error(f"Stripe webhook: missing/invalid metadata on {payment_intent['id']}: {e}")
from sqlalchemy.ext.asyncio import AsyncSession return
# Create database session and wallet manager
async with AsyncSession(self.db.engine) as session:
wallet_manager = WalletManager(session)
await wallet_manager.credit_wallet(
user_id=user_id,
amount=amount,
transaction_details={
'payment_gateway': 'stripe',
'gateway_transaction_id': payment_intent['id'],
'description': 'Wallet top up via Stripe',
'metadata': {'payment_intent': payment_intent['id']}
}
)
await session.commit()
logger.info(f"Wallet credited successfully for user {user_id}, amount {amount}") from aisbf.payments.wallet.manager import WalletManager
wallet_manager = WalletManager(self.db)
await wallet_manager.credit_wallet(
user_id=user_id,
amount=amount,
transaction_details={
'payment_gateway': 'stripe',
'gateway_transaction_id': payment_intent['id'],
'description': 'Wallet top up via Stripe',
'metadata': {'payment_intent': payment_intent['id']}
}
)
logger.info(f"Wallet credited: user={user_id}, amount={amount}, intent={payment_intent['id']}")
async def auto_charge(self, user_id: int, amount: Decimal, payment_method_id: str) -> Dict[str, Any]: async def auto_charge(self, user_id: int, amount: Decimal, payment_method_id: str) -> Dict[str, Any]:
""" """
......
...@@ -177,7 +177,7 @@ class PaymentMigrations: ...@@ -177,7 +177,7 @@ class PaymentMigrations:
cursor.execute('CREATE INDEX IF NOT EXISTS idx_crypto_tx_user ON crypto_transactions(user_id)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_crypto_tx_user ON crypto_transactions(user_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_crypto_tx_status ON crypto_transactions(status)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_crypto_tx_status ON crypto_transactions(status)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_user_addresses_user ON user_crypto_addresses(user_id)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_user_addresses_user ON user_crypto_addresses(user_id)')
except: except Exception:
pass pass
def _create_payment_tables(self, cursor, auto_increment, timestamp_default, boolean_type, text_type, decimal_type): def _create_payment_tables(self, cursor, auto_increment, timestamp_default, boolean_type, text_type, decimal_type):
...@@ -264,7 +264,7 @@ class PaymentMigrations: ...@@ -264,7 +264,7 @@ class PaymentMigrations:
cursor.execute('CREATE INDEX IF NOT EXISTS idx_payment_transactions_user ON payment_transactions(user_id)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_payment_transactions_user ON payment_transactions(user_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_payment_retry_status ON payment_retry_queue(status, next_retry_at)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_payment_retry_status ON payment_retry_queue(status, next_retry_at)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_api_requests_user_time ON api_requests(user_id, created_at)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_api_requests_user_time ON api_requests(user_id, created_at)')
except: except Exception:
pass pass
logger.info("✅ Created/verified payment tables") logger.info("✅ Created/verified payment tables")
...@@ -299,7 +299,7 @@ class PaymentMigrations: ...@@ -299,7 +299,7 @@ class PaymentMigrations:
cursor.execute('CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_subscriptions_period_end ON subscriptions(current_period_end)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_subscriptions_period_end ON subscriptions(current_period_end)')
except: except Exception:
pass pass
def _create_job_tables(self, cursor, auto_increment, timestamp_default, boolean_type, text_type, decimal_type): def _create_job_tables(self, cursor, auto_increment, timestamp_default, boolean_type, text_type, decimal_type):
...@@ -356,7 +356,7 @@ class PaymentMigrations: ...@@ -356,7 +356,7 @@ class PaymentMigrations:
cursor.execute('CREATE INDEX IF NOT EXISTS idx_job_locks_expires ON job_locks(expires_at)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_job_locks_expires ON job_locks(expires_at)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_consolidation_status ON crypto_consolidation_queue(status)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_consolidation_status ON crypto_consolidation_queue(status)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_email_queue_status ON email_notification_queue(status, next_retry_at)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_email_queue_status ON email_notification_queue(status, next_retry_at)')
except: except Exception:
pass pass
def _create_config_tables(self, cursor, auto_increment, timestamp_default, boolean_type, text_type, decimal_type): def _create_config_tables(self, cursor, auto_increment, timestamp_default, boolean_type, text_type, decimal_type):
...@@ -582,7 +582,7 @@ class PaymentMigrations: ...@@ -582,7 +582,7 @@ class PaymentMigrations:
INSERT IGNORE INTO crypto_price_sources (name, api_type, endpoint_url, api_key, priority) INSERT IGNORE INTO crypto_price_sources (name, api_type, endpoint_url, api_key, priority)
VALUES (%s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s)
''', (name, api_type, endpoint, api_key, priority)) ''', (name, api_type, endpoint, api_key, priority))
except: except Exception:
pass pass
# Insert default consolidation settings (INSERT OR IGNORE = only if not exists) # Insert default consolidation settings (INSERT OR IGNORE = only if not exists)
...@@ -605,7 +605,7 @@ class PaymentMigrations: ...@@ -605,7 +605,7 @@ class PaymentMigrations:
INSERT IGNORE INTO crypto_consolidation_settings (crypto_type, threshold_amount, admin_address, is_enabled) INSERT IGNORE INTO crypto_consolidation_settings (crypto_type, threshold_amount, admin_address, is_enabled)
VALUES (%s, %s, %s, 0) VALUES (%s, %s, %s, 0)
''', (crypto_type, threshold, address)) ''', (crypto_type, threshold, address))
except: except Exception:
pass pass
# Insert default email notification settings (INSERT OR IGNORE = only if not exists) # Insert default email notification settings (INSERT OR IGNORE = only if not exists)
...@@ -633,7 +633,7 @@ class PaymentMigrations: ...@@ -633,7 +633,7 @@ class PaymentMigrations:
INSERT IGNORE INTO email_notification_settings (notification_type, subject_template, is_enabled) INSERT IGNORE INTO email_notification_settings (notification_type, subject_template, is_enabled)
VALUES (%s, %s, 1) VALUES (%s, %s, 1)
''', (notif_type, subject)) ''', (notif_type, subject))
except: except Exception:
pass pass
logger.info("✅ Default payment system data checked (existing records preserved)") logger.info("✅ Default payment system data checked (existing records preserved)")
...@@ -717,7 +717,7 @@ class PaymentMigrations: ...@@ -717,7 +717,7 @@ class PaymentMigrations:
cursor.execute('CREATE INDEX IF NOT EXISTS idx_wallet_transactions_wallet ON wallet_transactions(wallet_id)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_wallet_transactions_wallet ON wallet_transactions(wallet_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_wallet_transactions_user ON wallet_transactions(user_id)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_wallet_transactions_user ON wallet_transactions(user_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_wallet_transactions_created ON wallet_transactions(created_at)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_wallet_transactions_created ON wallet_transactions(created_at)')
except: except Exception:
pass pass
logger.info("✅ Created/verified wallet system tables") logger.info("✅ Created/verified wallet system tables")
...@@ -1770,7 +1770,7 @@ class ClaudeProviderHandler(BaseProviderHandler): ...@@ -1770,7 +1770,7 @@ class ClaudeProviderHandler(BaseProviderHandler):
try: try:
error_body = response.json() error_body = response.json()
logging.warning(f"ClaudeProviderHandler: Error response: {error_body}") logging.warning(f"ClaudeProviderHandler: Error response: {error_body}")
except: except Exception:
logging.warning(f"ClaudeProviderHandler: Error response (text): {response.text[:200]}") logging.warning(f"ClaudeProviderHandler: Error response (text): {response.text[:200]}")
except Exception as api_error: except Exception as api_error:
...@@ -1847,7 +1847,7 @@ class ClaudeProviderHandler(BaseProviderHandler): ...@@ -1847,7 +1847,7 @@ class ClaudeProviderHandler(BaseProviderHandler):
try: try:
error_body = fallback_response.json() error_body = fallback_response.json()
logging.warning(f"ClaudeProviderHandler: Fallback error response: {error_body}") logging.warning(f"ClaudeProviderHandler: Fallback error response: {error_body}")
except: except Exception:
logging.warning(f"ClaudeProviderHandler: Fallback error response (text): {fallback_response.text[:200]}") logging.warning(f"ClaudeProviderHandler: Fallback error response (text): {fallback_response.text[:200]}")
finally: finally:
await fallback_client.aclose() await fallback_client.aclose()
......
...@@ -554,7 +554,7 @@ class KiroProviderHandler(BaseProviderHandler): ...@@ -554,7 +554,7 @@ class KiroProviderHandler(BaseProviderHandler):
try: try:
error_body = nexlab_response.json() error_body = nexlab_response.json()
logging.warning(f"KiroProviderHandler: Nexlab error response: {error_body}") logging.warning(f"KiroProviderHandler: Nexlab error response: {error_body}")
except: except Exception:
logging.warning(f"KiroProviderHandler: Nexlab error response (text): {nexlab_response.text[:200]}") logging.warning(f"KiroProviderHandler: Nexlab error response (text): {nexlab_response.text[:200]}")
finally: finally:
await nexlab_client.aclose() await nexlab_client.aclose()
...@@ -640,7 +640,7 @@ class KiroProviderHandler(BaseProviderHandler): ...@@ -640,7 +640,7 @@ class KiroProviderHandler(BaseProviderHandler):
try: try:
error_body = response.json() error_body = response.json()
logging.warning(f"KiroProviderHandler: Error response: {error_body}") logging.warning(f"KiroProviderHandler: Error response: {error_body}")
except: except Exception:
logging.warning(f"KiroProviderHandler: Error response (text): {response.text[:200]}") logging.warning(f"KiroProviderHandler: Error response (text): {response.text[:200]}")
break break
......
...@@ -19,7 +19,7 @@ def get_machine_fingerprint() -> str: ...@@ -19,7 +19,7 @@ def get_machine_fingerprint() -> str:
username = getpass.getuser() username = getpass.getuser()
unique_string = f"{hostname}-{username}-kiro-gateway" unique_string = f"{hostname}-{username}-kiro-gateway"
return hashlib.sha256(unique_string.encode()).hexdigest() return hashlib.sha256(unique_string.encode()).hexdigest()
except: except Exception:
return hashlib.sha256(b"default-machine-fingerprint").hexdigest() return hashlib.sha256(b"default-machine-fingerprint").hexdigest()
def generate_completion_id() -> str: def generate_completion_id() -> str:
......
This diff is collapsed.
...@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" ...@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "aisbf" name = "aisbf"
version = "0.99.50" version = "0.99.51"
description = "AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations" description = "AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations"
readme = "README.md" readme = "README.md"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
......
...@@ -49,7 +49,7 @@ class InstallCommand(_install): ...@@ -49,7 +49,7 @@ class InstallCommand(_install):
setup( setup(
name="aisbf", name="aisbf",
version="0.99.50", version="0.99.51",
author="AISBF Contributors", author="AISBF Contributors",
author_email="stefy@nexlab.net", author_email="stefy@nexlab.net",
description="AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations", description="AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations",
......
...@@ -263,6 +263,10 @@ document.getElementById('timeRangeSelect').addEventListener('change', function() ...@@ -263,6 +263,10 @@ document.getElementById('timeRangeSelect').addEventListener('change', function()
})(); })();
{% endif %} {% endif %}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// User search autocomplete functionality // User search autocomplete functionality
{% if is_admin and available_users|length >= 25 %} {% if is_admin and available_users|length >= 25 %}
(function() { (function() {
...@@ -301,9 +305,9 @@ document.getElementById('timeRangeSelect').addEventListener('change', function() ...@@ -301,9 +305,9 @@ document.getElementById('timeRangeSelect').addEventListener('change', function()
} }
resultsDiv.innerHTML = users.map(user => ` resultsDiv.innerHTML = users.map(user => `
<div class="user-result-item" data-user-id="${user.id}" data-username="${user.username}" data-role="${user.role}" <div class="user-result-item" data-user-id="${user.id}" data-username="${escHtml(user.username)}" data-role="${escHtml(user.role)}"
style="padding: 10px; cursor: pointer; border-bottom: 1px solid #2a4a7a;"> style="padding: 10px; cursor: pointer; border-bottom: 1px solid #2a4a7a;">
${user.username}${user.role === 'admin' ? ' <span style="color: #60a5fa;">(admin)</span>' : ''} ${escHtml(user.username)}${user.role === 'admin' ? ' <span style="color: #60a5fa;">(admin)</span>' : ''}
</div> </div>
`).join(''); `).join('');
resultsDiv.style.display = 'block'; resultsDiv.style.display = 'block';
......
...@@ -40,6 +40,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -40,6 +40,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div> </div>
<script> <script>
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
const autoselectData = { const autoselectData = {
config: {{ autoselect_json | safe }}, config: {{ autoselect_json | safe }},
rotations: {{ available_rotations | safe }}, rotations: {{ available_rotations | safe }},
...@@ -70,15 +74,15 @@ function renderAutoselectList() { ...@@ -70,15 +74,15 @@ function renderAutoselectList() {
const modelCount = autoselect.available_models ? autoselect.available_models.length : 0; const modelCount = autoselect.available_models ? autoselect.available_models.length : 0;
autoselectItem.innerHTML = ` autoselectItem.innerHTML = `
<div class="autoselect-header" onclick="toggleAutoselect('${key}')" style="padding: 15px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;"> <div class="autoselect-header" onclick="toggleAutoselect('${escHtml(key)}')" style="padding: 15px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
<div style="display: flex; align-items: center; gap: 10px;"> <div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 18px;">${isExpanded ? '▼' : '▶'}</span> <span style="font-size: 18px;">${isExpanded ? '▼' : '▶'}</span>
<strong style="font-size: 16px;">${autoselect.model_name || key}</strong> <strong style="font-size: 16px;">${escHtml(autoselect.model_name || key)}</strong>
<span style="color: #a0a0a0; font-size: 14px;">(${modelCount} available model${modelCount !== 1 ? 's' : ''})</span> <span style="color: #a0a0a0; font-size: 14px;">(${modelCount} available model${modelCount !== 1 ? 's' : ''})</span>
</div> </div>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeAutoselect('${key}')" style="background: #dc3545; padding: 5px 15px;">Remove</button> <button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeAutoselect('${escHtml(key)}')" style="background: #dc3545; padding: 5px 15px;">Remove</button>
</div> </div>
<div id="autoselect-details-${key}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;"> <div id="autoselect-details-${escHtml(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;">
<!-- Details will be rendered here --> <!-- Details will be rendered here -->
</div> </div>
`; `;
...@@ -135,12 +139,12 @@ function renderAutoselectDetails(autoselectKey) { ...@@ -135,12 +139,12 @@ function renderAutoselectDetails(autoselectKey) {
container.innerHTML = ` container.innerHTML = `
<div class="form-group"> <div class="form-group">
<label>Model Name</label> <label>Model Name</label>
<input type="text" value="${autoselect.model_name}" onchange="updateAutoselect('${autoselectKey}', 'model_name', this.value)" required> <input type="text" value="${escHtml(autoselect.model_name)}" onchange="updateAutoselect('${escHtml(autoselectKey)}', 'model_name', this.value)" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Capabilities (comma-separated)</label> <label>Capabilities (comma-separated)</label>
<input type="text" value="${autoselect.capabilities ? autoselect.capabilities.join(', ') : ''}" onchange="updateAutoselectCapabilities('${autoselectKey}', this.value)" placeholder="e.g., t2t, reasoning, multimodal"> <input type="text" value="${escHtml(autoselect.capabilities ? autoselect.capabilities.join(', ') : '')}" onchange="updateAutoselectCapabilities('${escHtml(autoselectKey)}', this.value)" placeholder="e.g., t2t, reasoning, multimodal">
</div> </div>
<div class="form-group"> <div class="form-group">
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment