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 @@
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
### Fixed: Ollama Provider Handler Initialization
......
# 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
| `/api/admin/scheduler/status` | GET | global admin | Scheduler status |
| `/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/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/crypto/prices` | GET | global admin | Crypto prices |
| `/api/admin/crypto/btc-prices` | GET | global admin | BTC prices |
......
......@@ -55,7 +55,11 @@ Server starts on `http://127.0.0.1:17765`
## 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:
- Provider configuration and API key management
......
......@@ -54,7 +54,7 @@ from .auth.qwen import QwenOAuth2
from .handlers import RequestHandler, RotationHandler, AutoselectHandler
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__ = [
# Config
"config",
......@@ -71,8 +71,6 @@ __all__ = [
"Model",
"Provider",
"ErrorTracking",
"AutoselectModelInfo",
"AutoselectConfig",
# Providers
"BaseProviderHandler",
"GoogleProviderHandler",
......
......@@ -213,7 +213,7 @@ class KiroAuthManager:
username = getpass.getuser()
unique_string = f"{hostname}-{username}-kiro-gateway"
return hashlib.sha256(unique_string.encode()).hexdigest()
except:
except Exception:
return hashlib.sha256(b"default-machine-fingerprint").hexdigest()
async def get_access_token(self) -> str:
......@@ -389,5 +389,5 @@ class KiroAuthManager:
username = getpass.getuser()
unique_string = f"{hostname}-{username}-kiro-gateway"
return hashlib.sha256(unique_string.encode()).hexdigest()
except:
except Exception:
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/>.
import json
import pickle
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 pathlib import Path
import time
......@@ -139,7 +166,7 @@ class RedisCache(CacheBackend):
try:
data = self.redis.get(self._make_key(key))
if data:
return pickle.loads(data)
return _cache_decode(data)
return None
except Exception as e:
logger.warning(f"Redis get error: {e}")
......@@ -147,7 +174,7 @@ class RedisCache(CacheBackend):
def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
try:
data = pickle.dumps(value)
data = _cache_encode(value)
if ttl:
self.redis.setex(self._make_key(key), ttl, data)
else:
......@@ -252,8 +279,9 @@ class SQLiteCache(CacheBackend):
conn.commit()
return None
# Deserialize the value
return pickle.loads(value_str.encode('latin1'))
# Decode: stored value may be bytes (new) or a latin1 string (legacy pickle)
raw = value_str if isinstance(value_str, bytes) else value_str.encode('latin1')
return _cache_decode(raw)
return None
except Exception as e:
......@@ -265,11 +293,9 @@ class SQLiteCache(CacheBackend):
import time
try:
# Serialize the value
value_bytes = pickle.dumps(value)
value_bytes = _cache_encode(value)
# Store as latin1 string so it fits the TEXT column used by legacy schema
value_str = value_bytes.decode('latin1')
# Calculate TTL timestamp if provided
ttl_timestamp = time.time() + ttl if ttl else None
with sqlite3.connect(str(self.db_path)) as conn:
......@@ -429,7 +455,7 @@ class MySQLCache(CacheBackend):
return None
# Deserialize the value
return pickle.loads(value_str.encode('latin1'))
return _cache_decode(value_str.encode('latin1'))
return None
except Exception as e:
......@@ -441,7 +467,7 @@ class MySQLCache(CacheBackend):
try:
# Serialize the value
value_bytes = pickle.dumps(value)
value_bytes = _cache_encode(value)
value_str = value_bytes.decode('latin1')
# Calculate TTL timestamp if provided
......@@ -528,7 +554,7 @@ class FileCache(CacheBackend):
try:
with open(cache_path, 'rb') as f:
return pickle.load(f)
return _cache_decode(f.read())
except Exception as e:
logger.warning(f"File cache get error for {key}: {e}")
return None
......@@ -537,7 +563,7 @@ class FileCache(CacheBackend):
cache_path = self._get_cache_path(key)
try:
with open(cache_path, 'wb') as f:
pickle.dump(value, f)
f.write(_cache_encode(value))
except Exception as e:
logger.warning(f"File cache set error for {key}: {e}")
......@@ -1613,7 +1639,7 @@ class ResponseCache:
try:
pattern = f"{self.key_prefix}*"
stats['current_size'] = len(self.redis_client.keys(pattern))
except:
except Exception:
stats['current_size'] = 0
elif self.backend == 'sqlite' and self.sqlite_backend:
stats['current_size'] = self.sqlite_backend.get_size()
......
......@@ -91,7 +91,7 @@ class ContextManager:
is_rotation = True
logger = logging.getLogger(__name__)
logger.info(f"Condensation model '{model_value}' is a rotation ID")
except:
except Exception:
pass # Not a rotation, treat as direct model
if is_rotation:
......
......@@ -23,6 +23,7 @@ Database module for persistent tracking of context dimensions and rate limiting.
"""
import sqlite3
import json
import hashlib
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Any
from datetime import datetime, timedelta
......@@ -30,6 +31,25 @@ import logging
import asyncio
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:
import mysql.connector as _mysql_connector
MYSQL_AVAILABLE = True
......@@ -706,13 +726,16 @@ class DatabaseManager:
}
# 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:
username: Username to authenticate
password_hash: SHA256 hash of the password
password: Plain-text password
Returns:
User dict if authenticated, None otherwise
......@@ -734,7 +757,7 @@ class DatabaseManager:
""")
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:
select_fields.append('email')
if 'email_verified' in columns:
......@@ -747,11 +770,26 @@ class DatabaseManager:
cursor.execute(f'''
SELECT {', '.join(select_fields)}
FROM users
WHERE username = {placeholder} AND password_hash = {placeholder} AND is_active = 1
''', (username, password_hash))
WHERE username = {placeholder} AND is_active = 1
''', (username,))
row = cursor.fetchone()
if row:
if not row:
return None
stored_hash = row[4]
if not _verify_password(password, stored_hash):
return None
# Auto-upgrade legacy SHA-256 hash to bcrypt on successful login
if not stored_hash.startswith("$2") and _BCRYPT_AVAILABLE:
new_hash = _hash_password(password)
cursor.execute(
f'UPDATE users SET password_hash = {placeholder} WHERE id = {placeholder}',
(new_hash, row[0])
)
conn.commit()
result = {
'id': row[0],
'username': row[1],
......@@ -763,7 +801,7 @@ class DatabaseManager:
'last_verification_email_sent': None
}
idx = 4
idx = 5
if 'email' in columns:
result['email'] = row[idx] or None
idx += 1
......@@ -777,7 +815,6 @@ class DatabaseManager:
result['last_verification_email_sent'] = row[idx] if row[idx] else None
return result
return None
def get_user_by_username(self, username: str) -> Optional[Dict]:
"""
......@@ -816,7 +853,7 @@ class DatabaseManager:
Args:
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')
created_by: Username of the creator
email: Email address (optional)
......@@ -1089,7 +1126,7 @@ class DatabaseManager:
Args:
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:
cursor = conn.cursor()
......@@ -1336,7 +1373,9 @@ class DatabaseManager:
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:
user_id: User ID
......@@ -1345,17 +1384,16 @@ class DatabaseManager:
Returns:
True if password matches, False otherwise
"""
import hashlib
password_hash = hashlib.sha256(password.encode()).hexdigest()
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
SELECT id FROM users
WHERE id = {placeholder} AND password_hash = {placeholder}
''', (user_id, password_hash))
return cursor.fetchone() is not None
SELECT password_hash FROM users WHERE id = {placeholder}
''', (user_id,))
row = cursor.fetchone()
if not row:
return False
return _verify_password(password, row[0])
def update_user_email(self, user_id: int, new_email: str):
"""
......
......@@ -34,16 +34,9 @@ logger = logging.getLogger(__name__)
def hash_password(password: str) -> str:
"""
Hash a password using SHA256.
Args:
password: Plain text password
Returns:
SHA256 hash of the password
"""
return hashlib.sha256(password.encode()).hexdigest()
"""Hash a password. Delegates to database._hash_password (bcrypt when available)."""
from aisbf.database import _hash_password
return _hash_password(password)
def generate_verification_token() -> str:
......
......@@ -3095,7 +3095,7 @@ class RotationHandler:
error_part = last_error[:json_start]
error_details.append(f"**Last error:** {error_part}")
error_details.append(f"```json\n{formatted_json}\n```")
except:
except Exception:
error_details.append(f"**Last error:**")
error_details.append(f"{last_error}")
else:
......@@ -3104,7 +3104,7 @@ class RotationHandler:
else:
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}")
......
......@@ -145,11 +145,32 @@ class CryptoWalletManager:
}
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'
for attempt in range(5):
with self.db._get_connection() as conn:
cursor = conn.cursor()
if self.db.db_type == 'sqlite':
cursor.execute("BEGIN EXCLUSIVE")
else:
cursor.execute("START TRANSACTION")
try:
if self.db.db_type == 'mysql':
cursor.execute(f"""
SELECT COALESCE(MAX(derivation_index), -1)
FROM user_crypto_addresses
WHERE crypto_type = {placeholder}
FOR UPDATE
""", (crypto_type,))
else:
cursor.execute(f"""
SELECT COALESCE(MAX(derivation_index), -1)
FROM user_crypto_addresses
......@@ -159,8 +180,6 @@ class CryptoWalletManager:
address_info = self.derive_address(crypto_type, next_index)
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(f"""
INSERT INTO user_crypto_addresses
(user_id, crypto_type, address, derivation_path, derivation_index, payment_id)
......@@ -168,6 +187,15 @@ class CryptoWalletManager:
""", (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:
cursor = conn.cursor()
......
......@@ -358,8 +358,8 @@ class PayPalPaymentHandler:
"""Verify PayPal webhook signature via PayPal's verify-webhook-signature API."""
webhook_id = self.webhook_secret # stored as 'webhook_secret' in admin settings
if not webhook_id:
logger.warning("PayPal webhook_id not configured - skipping signature verification")
return True
logger.error("PayPal webhook_id not configured - rejecting webhook (configure webhook_id in payment settings)")
return False
try:
access_token = await self.get_access_token()
......@@ -386,56 +386,159 @@ class PayPalPaymentHandler:
logger.error(f"PayPal webhook signature verification error: {e}")
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):
"""Handle completed order (Vault v3)"""
"""Handle completed checkout order — credit wallet for top-up orders."""
order_id = resource.get('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):
"""Handle approved order"""
"""Handle approved order (capture pending)."""
order_id = resource.get('id')
logger.info(f"PayPal order approved: {order_id}")
async def _handle_payment_capture_completed(self, resource: dict):
"""Handle completed payment capture"""
"""Handle completed payment capture — credit wallet."""
capture_id = resource.get('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):
"""Handle denied payment capture"""
"""Handle denied payment capture — queue for retry."""
capture_id = resource.get('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):
"""Handle refunded payment"""
"""Handle refunded payment — debit the wallet to reverse the credit."""
refund_id = resource.get('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):
"""Handle vault token creation"""
"""Handle vault token creation."""
token_id = resource.get('id')
logger.info(f"PayPal vault token created: {token_id}")
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')
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):
"""Handle dispute creation"""
"""Handle dispute creation — log and alert via application logger (admin monitors logs)."""
dispute_id = resource.get('dispute_id')
logger.warning(f"PayPal dispute created: {dispute_id}")
# TODO: Send notification to admin
reason = resource.get('reason', 'unknown')
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):
"""Handle dispute resolution"""
"""Handle dispute resolution."""
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:
"""Create PayPal order for wallet top up"""
......
......@@ -158,10 +158,6 @@ class StripePaymentHandler:
logger.error(f"Error handling Stripe webhook: {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:
"""Create Stripe PaymentIntent for wallet top up"""
try:
......@@ -204,20 +200,22 @@ class StripePaymentHandler:
return {'success': False, 'error': str(e)}
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']}")
metadata = payment_intent.get('metadata', {})
if metadata.get('topup') == 'true':
if metadata.get('topup') != 'true':
return
try:
user_id = int(metadata['user_id'])
amount = Decimal(metadata['amount'])
except (KeyError, ValueError) as e:
logger.error(f"Stripe webhook: missing/invalid metadata on {payment_intent['id']}: {e}")
return
from aisbf.payments.wallet.manager import WalletManager
from sqlalchemy.ext.asyncio import AsyncSession
# Create database session and wallet manager
async with AsyncSession(self.db.engine) as session:
wallet_manager = WalletManager(session)
wallet_manager = WalletManager(self.db)
await wallet_manager.credit_wallet(
user_id=user_id,
amount=amount,
......@@ -228,9 +226,7 @@ class StripePaymentHandler:
'metadata': {'payment_intent': payment_intent['id']}
}
)
await session.commit()
logger.info(f"Wallet credited successfully for user {user_id}, amount {amount}")
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]:
"""
......
......@@ -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_status ON crypto_transactions(status)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_user_addresses_user ON user_crypto_addresses(user_id)')
except:
except Exception:
pass
def _create_payment_tables(self, cursor, auto_increment, timestamp_default, boolean_type, text_type, decimal_type):
......@@ -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_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)')
except:
except Exception:
pass
logger.info("✅ Created/verified payment tables")
......@@ -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_status ON subscriptions(status)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_subscriptions_period_end ON subscriptions(current_period_end)')
except:
except Exception:
pass
def _create_job_tables(self, cursor, auto_increment, timestamp_default, boolean_type, text_type, decimal_type):
......@@ -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_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)')
except:
except Exception:
pass
def _create_config_tables(self, cursor, auto_increment, timestamp_default, boolean_type, text_type, decimal_type):
......@@ -582,7 +582,7 @@ class PaymentMigrations:
INSERT IGNORE INTO crypto_price_sources (name, api_type, endpoint_url, api_key, priority)
VALUES (%s, %s, %s, %s, %s)
''', (name, api_type, endpoint, api_key, priority))
except:
except Exception:
pass
# Insert default consolidation settings (INSERT OR IGNORE = only if not exists)
......@@ -605,7 +605,7 @@ class PaymentMigrations:
INSERT IGNORE INTO crypto_consolidation_settings (crypto_type, threshold_amount, admin_address, is_enabled)
VALUES (%s, %s, %s, 0)
''', (crypto_type, threshold, address))
except:
except Exception:
pass
# Insert default email notification settings (INSERT OR IGNORE = only if not exists)
......@@ -633,7 +633,7 @@ class PaymentMigrations:
INSERT IGNORE INTO email_notification_settings (notification_type, subject_template, is_enabled)
VALUES (%s, %s, 1)
''', (notif_type, subject))
except:
except Exception:
pass
logger.info("✅ Default payment system data checked (existing records preserved)")
......@@ -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_user ON wallet_transactions(user_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_wallet_transactions_created ON wallet_transactions(created_at)')
except:
except Exception:
pass
logger.info("✅ Created/verified wallet system tables")
......@@ -1770,7 +1770,7 @@ class ClaudeProviderHandler(BaseProviderHandler):
try:
error_body = response.json()
logging.warning(f"ClaudeProviderHandler: Error response: {error_body}")
except:
except Exception:
logging.warning(f"ClaudeProviderHandler: Error response (text): {response.text[:200]}")
except Exception as api_error:
......@@ -1847,7 +1847,7 @@ class ClaudeProviderHandler(BaseProviderHandler):
try:
error_body = fallback_response.json()
logging.warning(f"ClaudeProviderHandler: Fallback error response: {error_body}")
except:
except Exception:
logging.warning(f"ClaudeProviderHandler: Fallback error response (text): {fallback_response.text[:200]}")
finally:
await fallback_client.aclose()
......
......@@ -554,7 +554,7 @@ class KiroProviderHandler(BaseProviderHandler):
try:
error_body = nexlab_response.json()
logging.warning(f"KiroProviderHandler: Nexlab error response: {error_body}")
except:
except Exception:
logging.warning(f"KiroProviderHandler: Nexlab error response (text): {nexlab_response.text[:200]}")
finally:
await nexlab_client.aclose()
......@@ -640,7 +640,7 @@ class KiroProviderHandler(BaseProviderHandler):
try:
error_body = response.json()
logging.warning(f"KiroProviderHandler: Error response: {error_body}")
except:
except Exception:
logging.warning(f"KiroProviderHandler: Error response (text): {response.text[:200]}")
break
......
......@@ -19,7 +19,7 @@ def get_machine_fingerprint() -> str:
username = getpass.getuser()
unique_string = f"{hostname}-{username}-kiro-gateway"
return hashlib.sha256(unique_string.encode()).hexdigest()
except:
except Exception:
return hashlib.sha256(b"default-machine-fingerprint").hexdigest()
def generate_completion_id() -> str:
......
......@@ -52,6 +52,7 @@ import argparse
import secrets
import hashlib
import asyncio
from aisbf.database import _hash_password as _db_hash_password, _verify_password as _db_verify_password
import httpx
import multiprocessing
from logging.handlers import RotatingFileHandler
......@@ -568,6 +569,39 @@ _session_secret = _get_or_create_session_secret()
# Note: SessionMiddleware will be added AFTER the @app.middleware decorators
# to ensure proper middleware execution order
# SHA-256 of the factory-default "admin" password. If the stored hash still
# matches this value the admin hasn't changed their password yet.
_DEFAULT_ADMIN_SHA256 = '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918'
# Dashboard paths the user may visit even when must_change_password is set
_MUST_CHANGE_PASSWORD_WHITELIST = (
'/dashboard/settings',
'/dashboard/logout',
'/api/admin/settings/',
)
# --- Login rate limiter ---
# Keyed by (ip, username); value is list of failure timestamps.
_login_failures: dict = {}
_LOGIN_MAX_ATTEMPTS = 10 # failures before lockout
_LOGIN_WINDOW_SECS = 300 # 5-minute sliding window
_LOGIN_LOCKOUT_SECS = 600 # 10-minute lockout after max failures
def _login_rate_limit_check(ip: str, username: str) -> bool:
"""Return True (blocked) when too many recent failures exist."""
key = f"{ip}:{username.lower()}"
now = time.time()
attempts = [t for t in _login_failures.get(key, []) if now - t < _LOGIN_WINDOW_SECS]
_login_failures[key] = attempts
return len(attempts) >= _LOGIN_MAX_ATTEMPTS
def _login_record_failure(ip: str, username: str) -> None:
key = f"{ip}:{username.lower()}"
_login_failures.setdefault(key, []).append(time.time())
def _login_clear_failures(ip: str, username: str) -> None:
_login_failures.pop(f"{ip}:{username.lower()}", None)
# These will be initialized in startup event or main() after config is loaded
request_handler = None
rotation_handler = None
......@@ -619,6 +653,8 @@ _model_cache = {}
_model_cache_timestamps = {}
_cache_refresh_interval = 4 * 3600 # 4 hours in seconds
_cache_refresh_task = None
# Strong references to fire-and-forget tasks so the GC does not cancel them
_background_tasks: set = set()
def initialize_app(custom_config_dir=None):
"""Initialize app globals. Called by startup event or main()."""
......@@ -669,13 +705,6 @@ def initialize_app(custom_config_dir=None):
'password': '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918'
}
# Initialize analytics with the config database
# NOTE: Database will be initialized later with proper config
# from aisbf.analytics import initialize_analytics
# from aisbf.database import DatabaseRegistry
# db = DatabaseRegistry.get_config_database()
# initialize_analytics(db)
# logger.info("Analytics module initialized")
_initialized = True
logger.info("App initialization complete")
......@@ -831,7 +860,7 @@ def validate_kiro_credentials(provider_id: str, provider_config) -> bool:
if token_data.get('access_token') or token_data.get('refresh_token'):
found_token = True
break
except:
except Exception:
pass
conn.close()
......@@ -1267,7 +1296,7 @@ async def startup_event():
auth_methods.append("OAuth2 File ✓")
else:
auth_methods.append("OAuth2 File ✗")
except:
except Exception:
auth_methods.append("OAuth2 File ✗ (check failed)")
# Check database credentials
......@@ -1291,7 +1320,7 @@ async def startup_event():
auth_methods.append("Database Credentials ✗ (no files)")
else:
auth_methods.append("Database Credentials ✗ (no database)")
except:
except Exception:
auth_methods.append("Database Credentials ✗ (check failed)")
logger.info(f" Auth Methods: {', '.join(auth_methods)}")
......@@ -1537,17 +1566,29 @@ async def api_token_authorization_middleware(request: Request, call_next):
async def auth_middleware(request: Request, call_next):
"""Check API token authentication if enabled"""
if server_config and server_config.get('auth_enabled', False):
# Skip auth for root endpoint, dashboard routes, auth routes, admin API routes, webhooks, favicon, and browser metadata
# Skip token auth for paths that use session auth or are public
if (request.url.path == "/" or
request.url.path.startswith("/dashboard") or
request.url.path.startswith("/auth/") or
request.url.path.startswith("/api/admin") or
request.url.path.startswith("/api/webhooks/") or # Webhooks don't need auth
request.url.path.startswith("/api/webhooks/") or
request.url.path == "/favicon.ico" or
request.url.path.startswith("/.well-known/")):
response = await call_next(request)
return response
# /api/admin/* uses session auth — allow through only when a valid admin session exists
if request.url.path.startswith("/api/admin"):
expires_at = request.session.get('expires_at')
session_valid = (
request.session.get('logged_in') and
request.session.get('role') == 'admin' and
not (expires_at and int(time.time()) > expires_at)
)
if session_valid:
response = await call_next(request)
return response
# No valid admin session — fall through to Bearer-token check below
# Skip auth for public models endpoints (GET only)
if request.method == "GET" and request.url.path in ["/api/models", "/api/v1/models"]:
response = await call_next(request)
......@@ -1855,9 +1896,11 @@ async def tier_limit_middleware(request: Request, call_next):
request.url.path.endswith("/audio/speech") or
request.url.path.endswith("/images/generations")
):
# Increment request counters asynchronously
# Increment request counters asynchronously; keep a strong reference so GC cannot cancel it
import asyncio
asyncio.create_task(db.increment_user_request_count(user_id))
_t = asyncio.create_task(db.increment_user_request_count(user_id))
_background_tasks.add(_t)
_t.add_done_callback(_background_tasks.discard)
return response
......@@ -1921,11 +1964,14 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
content={"detail": exc.errors(), "body": body_data}
)
# CORS middleware
# CORS middleware — wildcard origins are incompatible with allow_credentials=True
# (browsers reject credentialed cross-origin requests to "*"). API clients
# (curl, SDK) never send cookies, so credentials are not needed here.
# Dashboard authentication relies on session cookies which are same-origin only.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
......@@ -1957,7 +2003,13 @@ async def dashboard_context_middleware(request: Request, call_next):
# Add session middleware AFTER the @app.middleware decorators
# This ensures SessionMiddleware runs before auth_middleware and tier_limit_middleware
# Middleware execution order: last added = first executed
app.add_middleware(SessionMiddleware, secret_key=_session_secret, max_age=30 * 24 * 60 * 60) # 30 days max age
app.add_middleware(
SessionMiddleware,
secret_key=_session_secret,
max_age=30 * 24 * 60 * 60, # 30 days
same_site="lax", # prevents cross-site request forgery
https_only=os.environ.get("AISBF_HTTPS", "false").lower() == "true",
)
# Add proxy headers middleware LAST so it executes FIRST
# This ensures proxy headers are processed before any other middleware (including auth_middleware)
......@@ -2119,6 +2171,9 @@ async def paypal_webhook(request: Request):
result = await payment_service.paypal_handler.handle_webhook(payload, headers)
if result.get('status') == 'error':
# Return 400 so PayPal retries rather than treating the error as success
return JSONResponse(status_code=400, content=result)
return result
......@@ -2411,17 +2466,9 @@ async def dashboard_analytics(
async def dashboard_login_page(request: Request):
"""Show dashboard login page"""
import logging
from jinja2 import Environment, FileSystemLoader, DictLoader
logger = logging.getLogger(__name__)
try:
# Create a completely fresh Jinja2 environment to avoid any caching issues
env = Environment(loader=FileSystemLoader("templates"), auto_reload=False)
# Add the required globals
env.globals['url_for'] = url_for
env.globals['get_base_url'] = get_base_url
# Check if signup is enabled
signup_enabled = False
if config and hasattr(config, 'aisbf') and config.aisbf:
......@@ -2511,21 +2558,27 @@ async def auth_logincheck(request: Request):
@app.post("/dashboard/login")
async def dashboard_login(request: Request, username: str = Form(...), password: str = Form(...), remember_me: bool = Form(False)):
"""Handle dashboard login"""
client_ip = request.client.host if request.client else "unknown"
# Hash the submitted password
password_hash = hashlib.sha256(password.encode()).hexdigest()
# Rate-limit check before touching credentials
if _login_rate_limit_check(client_ip, username):
return RedirectResponse(
url=url_for(request, "/dashboard/login") + "?error=Too+many+failed+attempts.+Please+wait+and+try+again.",
status_code=303
)
# Try database authentication first
# Try database authentication first (plain password — database.py handles hashing/verification)
db = DatabaseRegistry.get_config_database()
user = db.authenticate_user(username, password_hash)
user = db.authenticate_user(username, password)
if user:
# Database user authenticated
logger.info(f"User authenticated: username={username}, email={user.get('email')}, user_id={user['id']}")
request.session['logged_in'] = True
_login_clear_failures(client_ip, username)
request.session['username'] = username
request.session['display_name'] = user.get('display_name') or ''
request.session['email'] = user.get('email') or '' # Ensure we get the email from user dict
request.session['email'] = user.get('email') or ''
request.session['role'] = user['role']
request.session['user_id'] = user['id']
request.session['remember_me'] = remember_me
......@@ -2579,21 +2632,28 @@ async def dashboard_login(request: Request, username: str = Form(...), password:
stored_username = dashboard_config.get('username', 'admin')
stored_password_hash = dashboard_config.get('password', '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918')
if username == stored_username and password_hash == stored_password_hash:
if username == stored_username and _db_verify_password(password, stored_password_hash):
_login_clear_failures(client_ip, username)
request.session['logged_in'] = True
request.session['username'] = username
request.session['role'] = 'admin'
request.session['user_id'] = None # Config admin has no user_id
request.session['remember_me'] = remember_me
# Flag if still using the factory-default password so we can force a change
request.session['must_change_password'] = (stored_password_hash == _DEFAULT_ADMIN_SHA256)
if remember_me:
# Set session to expire in 30 days for remember me
request.session['expires_at'] = int(time.time()) + 30 * 24 * 60 * 60
else:
# For non-remember-me sessions, set expiry to 2 weeks (default session length)
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
if request.session['must_change_password']:
return RedirectResponse(
url=url_for(request, "/dashboard/settings") + "?warning=default_password",
status_code=303
)
return RedirectResponse(url=url_for(request, "/dashboard"), status_code=303)
# If we reach here, authentication failed
# Authentication failed — record the failure for rate limiting
_login_record_failure(client_ip, username)
return RedirectResponse(url=url_for(request, "/dashboard/login") + "?error=Invalid username or password", status_code=303)
......@@ -3110,9 +3170,9 @@ async def dashboard_reset_password(
try:
db = DatabaseRegistry.get_config_database()
# Validate token
token_valid = db.validate_password_reset_token(email, token)
if not token_valid:
# Validate token and retrieve user
reset_user = db.get_user_by_reset_token(token)
if not reset_user or reset_user.get('email', '').lower() != email.lower():
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
......@@ -3151,12 +3211,11 @@ async def dashboard_reset_password(
}
)
# Hash new password
# Hash new password and update; clear the token to prevent reuse
password_hash = hash_password(password)
# Update user password and invalidate token
db.update_user_password(email, password_hash)
db.invalidate_password_reset_token(email, token)
user_id = reset_user['id']
db.update_user_password(user_id, password_hash)
db.clear_password_reset_token(user_id)
logger.info(f"Password successfully reset for user {email}")
......@@ -3663,7 +3722,7 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state
# Generate secure random password for OAuth users (never used for login)
random_password = secrets.token_urlsafe(32)
password_hash = hashlib.sha256(random_password.encode()).hexdigest()
password_hash = _db_hash_password(random_password)
# Generate clean username from display_name with email fallback
google_username = db.generate_username_from_display_name(display_name, email)
......@@ -3847,7 +3906,7 @@ async def oauth2_github_callback(request: Request, code: str = Query(...), state
# New user - create account automatically (no password required)
# Generate secure random password for OAuth users (never used for login)
random_password = secrets.token_urlsafe(32)
password_hash = hashlib.sha256(random_password.encode()).hexdigest()
password_hash = _db_hash_password(random_password)
# Generate clean username from display_name with email fallback
github_username = db.generate_username_from_display_name(display_name, email)
......@@ -3916,46 +3975,53 @@ def require_dashboard_auth(request: Request):
# Check if session has expired
expires_at = request.session.get('expires_at')
if expires_at and int(time.time()) > expires_at:
# Session expired
request.session.clear()
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
# Extend session expiry for remember me users on each request (sliding expiration)
# Extend session expiry on each request (sliding expiration)
if request.session.get('remember_me'):
# Refresh expiry to 30 days from now for remember me
request.session['expires_at'] = int(time.time()) + 30 * 24 * 60 * 60
elif expires_at:
# For non-remember-me sessions, refresh to 2 weeks from now (sliding expiration)
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
# Force password change if still using factory default
if request.session.get('must_change_password'):
path = request.url.path
if not any(path.startswith(p) for p in _MUST_CHANGE_PASSWORD_WHITELIST):
return RedirectResponse(
url=url_for(request, "/dashboard/settings") + "?warning=default_password",
status_code=303
)
return None
def require_api_auth(request: Request):
"""Check if user is logged in to dashboard (API version - returns JSON)"""
if not request.session.get('logged_in'):
return JSONResponse(
status_code=401,
content={"error": "Authentication required"}
)
return JSONResponse(status_code=401, content={"error": "Authentication required"})
# Check if session has expired
expires_at = request.session.get('expires_at')
if expires_at and int(time.time()) > expires_at:
# Session expired
request.session.clear()
return JSONResponse(
status_code=401,
content={"error": "Session expired"}
)
return JSONResponse(status_code=401, content={"error": "Session expired"})
# Extend session expiry for remember me users on each request (sliding expiration)
# Extend session expiry on each request (sliding expiration)
if request.session.get('remember_me'):
# Refresh expiry to 30 days from now for remember me
request.session['expires_at'] = int(time.time()) + 30 * 24 * 60 * 60
elif expires_at:
# For non-remember-me sessions, refresh to 2 weeks from now (sliding expiration)
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
# Force password change if still using factory default
if request.session.get('must_change_password'):
path = request.url.path
if not any(path.startswith(p) for p in _MUST_CHANGE_PASSWORD_WHITELIST):
return JSONResponse(
status_code=403,
content={"error": "Default password must be changed before using the API",
"redirect": "/dashboard/settings?warning=default_password"}
)
return None
def require_api_admin(request: Request):
......@@ -5392,8 +5458,7 @@ async def dashboard_settings_save(
aisbf_config['auth']['tokens'] = [t.strip() for t in auth_tokens.split('\n') if t.strip()]
aisbf_config['dashboard']['username'] = dashboard_username
if dashboard_password: # Only update if provided - hash the password
password_hash = hashlib.sha256(dashboard_password.encode()).hexdigest()
aisbf_config['dashboard']['password'] = password_hash
aisbf_config['dashboard']['password'] = _db_hash_password(dashboard_password)
aisbf_config['internal_model']['condensation_model_id'] = condensation_model_id
aisbf_config['internal_model']['autoselect_model_id'] = autoselect_model_id
......@@ -5529,6 +5594,10 @@ async def dashboard_settings_save(
with open(config_path, 'w') as f:
json.dump(aisbf_config, f, indent=2)
# If a new dashboard password was submitted, clear the forced-change flag
if dashboard_password:
request.session.pop('must_change_password', None)
return templates.TemplateResponse(
request=request,
name="dashboard/settings.html",
......@@ -5656,8 +5725,7 @@ async def dashboard_users_add(request: Request, username: str = Form(...), passw
db = DatabaseRegistry.get_config_database()
# Hash the password
password_hash = hashlib.sha256(password.encode()).hexdigest()
password_hash = _db_hash_password(password)
try:
# Get current admin username
......@@ -5690,7 +5758,7 @@ async def dashboard_users_edit(request: Request, user_id: int, username: str = F
try:
# Update user (only if password is provided)
if password:
password_hash = hashlib.sha256(password.encode()).hexdigest()
password_hash = _db_hash_password(password)
db.update_user(user_id, username, password_hash, role, is_active, username)
else:
db.update_user(user_id, username, None, role, is_active, username)
......@@ -6347,7 +6415,7 @@ async def dashboard_provider_upload_chunk(
try:
for chunk_path in temp_dir.glob(f"{upload_id}.part*"):
chunk_path.unlink()
except:
except Exception:
pass
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
......@@ -7350,7 +7418,7 @@ async def api_get_crypto_prices(request: Request):
FROM crypto_price_sources
""")
sources = {row[0].lower(): bool(row[1]) for row in cursor.fetchall()}
except:
except Exception:
# Default if table doesn't exist yet
sources = {'coinbase': True, 'binance': True, 'kraken': True}
......@@ -7968,7 +8036,7 @@ async def get_payment_system_status(request: Request):
""")
balances = {row[0]: float(row[1]) for row in cursor.fetchall()}
total_balance_usd = sum(balances.values())
except:
except Exception:
balances = {}
total_balance_usd = 0.0
......@@ -7979,7 +8047,7 @@ async def get_payment_system_status(request: Request):
WHERE status = 'pending'
""")
pending_count = cursor.fetchone()[0]
except:
except Exception:
pending_count = 0
# Get failed payments count from payment_transactions
......@@ -7989,7 +8057,7 @@ async def get_payment_system_status(request: Request):
WHERE status = 'failed'
""")
failed_count = cursor.fetchone()[0]
except:
except Exception:
failed_count = 0
return JSONResponse({
......@@ -8027,7 +8095,7 @@ async def get_payment_system_config(request: Request):
row[0].lower(): bool(row[4])
for row in cursor.fetchall()
}
except:
except Exception:
price_sources = {
'coinbase': True,
'binance': True,
......@@ -8060,7 +8128,7 @@ async def get_payment_system_config(request: Request):
row[0].lower(): float(row[1])
for row in cursor.fetchall()
}
except:
except Exception:
consolidation = {
'btc': 0.01,
'eth': 0.1,
......@@ -12901,7 +12969,7 @@ async def dashboard_oauth2_callback(
request.session['oauth2_user_id'] = user_id
if provider:
request.session['oauth2_provider'] = provider
except:
except Exception:
pass
# Detect if this is a direct localhost callback (no extension involved)
......
......@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
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"
readme = "README.md"
license = "GPL-3.0-or-later"
......
......@@ -49,7 +49,7 @@ class InstallCommand(_install):
setup(
name="aisbf",
version="0.99.50",
version="0.99.51",
author="AISBF Contributors",
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",
......
......@@ -263,6 +263,10 @@ document.getElementById('timeRangeSelect').addEventListener('change', function()
})();
{% 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
{% if is_admin and available_users|length >= 25 %}
(function() {
......@@ -301,9 +305,9 @@ document.getElementById('timeRangeSelect').addEventListener('change', function()
}
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;">
${user.username}${user.role === 'admin' ? ' <span style="color: #60a5fa;">(admin)</span>' : ''}
${escHtml(user.username)}${user.role === 'admin' ? ' <span style="color: #60a5fa;">(admin)</span>' : ''}
</div>
`).join('');
resultsDiv.style.display = 'block';
......
......@@ -40,6 +40,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
<script>
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
const autoselectData = {
config: {{ autoselect_json | safe }},
rotations: {{ available_rotations | safe }},
......@@ -70,15 +74,15 @@ function renderAutoselectList() {
const modelCount = autoselect.available_models ? autoselect.available_models.length : 0;
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;">
<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>
</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 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 -->
</div>
`;
......@@ -135,12 +139,12 @@ function renderAutoselectDetails(autoselectKey) {
container.innerHTML = `
<div class="form-group">
<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 class="form-group">
<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 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