Commit f997a0fb authored by Your Name's avatar Your Name

Fix fix fix...

parent ceafa183
# AISBF CLI Tools Integration TODO
# TODO
**Date**: 2026-03-29
**Context**: Integration and verification of various AI CLI tools as AISBF providers
**Goal**: Expand AISBF's provider ecosystem by supporting popular CLI tools and verifying compatibility with existing services
## Planned Features
---
## 🔥 HIGH PRIORITY (Implement Soon)
### 1. Gemini CLI Integration
**Estimated Effort**: 2-3 days
**Expected Benefit**: Direct access to Google's Gemini models via official CLI
**ROI**: ⭐⭐⭐⭐ High
**Description**: Integrate Google's official Gemini CLI tool as a provider type in AISBF, allowing users to leverage their Gemini CLI credentials and configurations.
**Tasks**:
- [ ] Research Gemini CLI authentication and API structure
- [ ] Create `GeminiCLIProviderHandler` class in `aisbf/providers.py`
- [ ] Implement CLI command execution and response parsing
- [ ] Add configuration schema to `config/providers.json`
- [ ] Test with various Gemini models (Flash, Pro, Ultra)
- [ ] Add streaming support
- [ ] Update documentation with setup instructions
- [ ] Add dashboard UI support for Gemini CLI configuration
**Configuration Example**:
```json
{
"gemini-cli": {
"id": "gemini-cli",
"name": "Gemini CLI",
"type": "gemini-cli",
"api_key_required": false,
"gemini_cli_config": {
"cli_path": "/usr/local/bin/gemini",
"config_file": "~/.config/gemini/config.json"
}
}
}
```
---
### 2. Qwen CLI Integration
**Estimated Effort**: 2-3 days
**Expected Benefit**: Access to Alibaba's Qwen models via CLI
**ROI**: ⭐⭐⭐⭐ High
**Description**: Integrate Qwen CLI tool as a provider type, enabling access to Qwen's language models through their official command-line interface.
**Tasks**:
- [ ] Research Qwen CLI authentication and API structure
- [ ] Create `QwenCLIProviderHandler` class in `aisbf/providers.py`
- [ ] Implement CLI command execution and response parsing
- [ ] Add configuration schema to `config/providers.json`
- [ ] Test with available Qwen models
- [ ] Add streaming support if available
- [ ] Update documentation with setup instructions
- [ ] Add dashboard UI support for Qwen CLI configuration
**Configuration Example**:
```json
{
"qwen-cli": {
"id": "qwen-cli",
"name": "Qwen CLI",
"type": "qwen-cli",
"api_key_required": false,
"qwen_cli_config": {
"cli_path": "/usr/local/bin/qwen",
"api_key": "YOUR_QWEN_API_KEY"
}
}
}
```
---
### 3. GitHub Copilot CLI Integration
**Estimated Effort**: 3-4 days
**Expected Benefit**: Leverage GitHub Copilot's code-focused models
**ROI**: ⭐⭐⭐⭐⭐ Very High
**Description**: Integrate GitHub Copilot CLI as a provider type, allowing users to access Copilot's models through their GitHub authentication.
**Tasks**:
- [ ] Research GitHub Copilot CLI authentication flow
- [ ] Create `CopilotCLIProviderHandler` class in `aisbf/providers.py`
- [ ] Implement GitHub OAuth integration if needed
- [ ] Implement CLI command execution and response parsing
- [ ] Add configuration schema to `config/providers.json`
- [ ] Test with Copilot models
- [ ] Add streaming support
- [ ] Handle GitHub authentication tokens
- [ ] Update documentation with setup instructions
- [ ] Add dashboard UI support for Copilot CLI configuration
**Configuration Example**:
```json
{
"copilot-cli": {
"id": "copilot-cli",
"name": "GitHub Copilot CLI",
"type": "copilot-cli",
"api_key_required": false,
"copilot_cli_config": {
"cli_path": "/usr/local/bin/github-copilot-cli",
"auth_token": "ghp_xxxxxxxxxxxxx"
}
}
}
```
---
## 🔶 MEDIUM PRIORITY
### 4. Bolt.new Verification
**Estimated Effort**: 1-2 days
**Expected Benefit**: Verify compatibility with Bolt.new service
**ROI**: ⭐⭐⭐ Medium
**Description**: Verify that AISBF can work with Bolt.new (StackBlitz's AI-powered full-stack web development tool) and document any integration requirements.
**Tasks**:
- [ ] Research Bolt.new API structure and authentication
- [ ] Test existing AISBF providers with Bolt.new
- [ ] Identify any compatibility issues
- [ ] Document integration steps
- [ ] Create example configurations
- [ ] Test with various Bolt.new features
- [ ] Update documentation with Bolt.new integration guide
---
### 5. DeepSeek Verification
**Estimated Effort**: 1-2 days
**Expected Benefit**: Verify compatibility with DeepSeek API
**ROI**: ⭐⭐⭐ Medium
**Description**: Verify that AISBF's existing OpenAI-compatible provider handler works correctly with DeepSeek's API, or create a dedicated handler if needed.
**Tasks**:
- [ ] Research DeepSeek API structure and authentication
- [ ] Test with existing OpenAI provider handler
- [ ] Identify any API differences or incompatibilities
- [ ] Create dedicated `DeepSeekProviderHandler` if needed
- [ ] Add configuration example to `config/providers.json`
- [ ] Test with various DeepSeek models
- [ ] Document any special requirements or limitations
- [ ] Update documentation with DeepSeek integration guide
**Configuration Example**:
```json
{
"deepseek": {
"id": "deepseek",
"name": "DeepSeek",
"endpoint": "https://api.deepseek.com/v1",
"type": "openai",
"api_key_required": true,
"api_key": "YOUR_DEEPSEEK_API_KEY"
}
}
```
---
### 6. Rovo Dev CLI Verification
**Estimated Effort**: 1-2 days
**Expected Benefit**: Verify compatibility with Atlassian Rovo Dev CLI
**ROI**: ⭐⭐⭐ Medium
**Description**: Verify that AISBF can integrate with Atlassian's Rovo Dev CLI tool and document the integration process.
**Tasks**:
- [ ] Research Rovo Dev CLI authentication and API structure
- [ ] Test existing AISBF providers with Rovo Dev CLI
- [ ] Identify integration requirements
- [ ] Create dedicated handler if needed
- [ ] Add configuration schema
- [ ] Test with Rovo Dev features
- [ ] Document integration steps
- [ ] Update documentation with Rovo Dev CLI guide
---
## 📋 Implementation Notes
### General CLI Integration Pattern
When integrating CLI tools, follow this pattern:
1. **Authentication**: Determine how the CLI tool handles authentication (config files, environment variables, OAuth tokens)
2. **Command Execution**: Use Python's `subprocess` module to execute CLI commands
3. **Response Parsing**: Parse CLI output (JSON, plain text, etc.) into AISBF's standard format
4. **Error Handling**: Handle CLI errors, timeouts, and authentication failures
5. **Streaming**: Implement streaming if the CLI tool supports it (parse output line-by-line)
6. **Configuration**: Add CLI-specific configuration fields (cli_path, config_file, etc.)
### Testing Checklist
For each CLI tool integration:
- [ ] Test authentication flow
- [ ] Test basic chat completion
- [ ] Test streaming responses
- [ ] Test error handling
- [ ] Test with multiple models
- [ ] Test rate limiting
- [ ] Test in rotations
- [ ] Test in autoselect
- [ ] Verify dashboard configuration UI
- [ ] Update documentation
---
## 🔵 Future Enhancements
### Additional CLI Tools to Consider
- Claude CLI (official Anthropic CLI)
- Mistral CLI
- Cohere CLI
- AI21 CLI
- Perplexity CLI
### CLI Tool Management Features
- [ ] CLI tool version detection and compatibility checking
- [ ] Automatic CLI tool installation/update
- [ ] CLI tool health monitoring
- [ ] CLI tool performance benchmarking
- [ ] Unified CLI tool configuration interface
---
## 📚 Documentation Updates Required
When completing CLI tool integrations:
1. Update `README.md` with CLI tool support section
2. Update `DOCUMENTATION.md` with detailed CLI tool setup guides
3. Update `AI.PROMPT` with CLI tool configuration patterns
4. Add CLI tool examples to `API_EXAMPLES.md`
5. Update dashboard help text for CLI tool configuration
- [ ] Add support for mempalace
- [ ] Add support for caveman mode
- [ ] Integrate github larsderidder context-lens project
......@@ -162,6 +162,8 @@ ensure_venv() {
if [ -f "$SHARE_DIR/requirements.txt" ]; then
echo "Installing requirements from $SHARE_DIR/requirements.txt"
"$VENV_DIR/bin/pip" install -r "$SHARE_DIR/requirements.txt"
# Force reinstall uvicorn in venv to ensure it's available inside the virtual environment
"$VENV_DIR/bin/pip" install --force-reinstall uvicorn
fi
# Save version for future upgrade detection
......@@ -173,6 +175,8 @@ ensure_venv() {
# Only update requirements, aisbf is accessed from system site-packages
if [ -f "$SHARE_DIR/requirements.txt" ]; then
"$VENV_DIR/bin/pip" install -r "$SHARE_DIR/requirements.txt"
# Force reinstall uvicorn in venv to ensure it's available inside the virtual environment
"$VENV_DIR/bin/pip" install --force-reinstall uvicorn
fi
python3 -c "import aisbf; print(aisbf.__version__)" > "$VENV_DIR/.aisbf_version" 2>/dev/null || echo "unknown" > "$VENV_DIR/.aisbf_version"
echo "Virtual environment updated successfully"
......@@ -191,9 +195,13 @@ update_venv() {
if [ $ALREADY_SATISFIED -ne 0 ]; then
echo "Installing new requirements (this will take a while!) ... "
"$VENV_DIR/bin/pip" install -r "$SHARE_DIR/requirements.txt"
# Force reinstall uvicorn in venv to ensure it's available inside the virtual environment
"$VENV_DIR/bin/pip" install --force-reinstall uvicorn
echo "[OK]"
else
echo "Virtual env already up to date"
# Still force reinstall uvicorn to ensure it's in venv
"$VENV_DIR/bin/pip" install --force-reinstall uvicorn
fi
fi
}
......
......@@ -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.20"
__version__ = "0.99.21"
__all__ = [
# Config
"config",
......
......@@ -124,7 +124,8 @@ class Analytics:
self,
provider_id: str,
from_datetime: Optional[datetime] = None,
to_datetime: Optional[datetime] = None
to_datetime: Optional[datetime] = None,
user_filter: Optional[int] = None
) -> Dict[str, Any]:
"""
Get statistics for a specific provider.
......@@ -177,7 +178,8 @@ class Analytics:
self,
provider_id: str,
from_datetime: datetime,
to_datetime: datetime
to_datetime: datetime,
user_filter: Optional[int] = None
) -> Dict[str, Any]:
"""Get provider stats from database for a specific date range."""
# This is a placeholder - in a real implementation, you'd query the database
......@@ -194,7 +196,8 @@ class Analytics:
self,
provider_id: Optional[str] = None,
from_datetime: Optional[datetime] = None,
to_datetime: Optional[datetime] = None
to_datetime: Optional[datetime] = None,
user_filter: Optional[int] = None
) -> Dict[str, Any]:
"""
Get token usage for a specific date range.
......@@ -254,7 +257,8 @@ class Analytics:
def get_all_providers_stats(
self,
from_datetime: Optional[datetime] = None,
to_datetime: Optional[datetime] = None
to_datetime: Optional[datetime] = None,
user_filter: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
Get statistics for all providers.
......@@ -360,17 +364,19 @@ class Analytics:
cursor = conn.cursor()
placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
# Determine date format based on database type
# Determine date format and time bucket expression based on database type
if self.db.db_type == 'sqlite':
date_format = "%Y-%m-%d %H:%M"
time_bucket_expr = f"strftime('{date_format}', timestamp)"
else:
date_format = "%Y-%m-%d %H:%i"
time_bucket_expr = f"DATE_FORMAT(timestamp, '{date_format}')"
if provider_id:
if user_filter:
cursor.execute(f'''
SELECT
strftime('{date_format}', timestamp) as time_bucket,
{time_bucket_expr} as time_bucket,
SUM(tokens_used) as tokens
FROM token_usage
WHERE provider_id = {placeholder} AND user_id = {placeholder} AND timestamp >= {placeholder} AND timestamp <= {placeholder}
......@@ -380,7 +386,7 @@ class Analytics:
else:
cursor.execute(f'''
SELECT
strftime('{date_format}', timestamp) as time_bucket,
{time_bucket_expr} as time_bucket,
SUM(tokens_used) as tokens
FROM token_usage
WHERE provider_id = {placeholder} AND timestamp >= {placeholder} AND timestamp <= {placeholder}
......@@ -391,7 +397,7 @@ class Analytics:
if user_filter:
cursor.execute(f'''
SELECT
strftime('{date_format}', timestamp) as time_bucket,
{time_bucket_expr} as time_bucket,
SUM(tokens_used) as tokens,
provider_id
FROM token_usage
......@@ -402,7 +408,7 @@ class Analytics:
else:
cursor.execute(f'''
SELECT
strftime('{date_format}', timestamp) as time_bucket,
{time_bucket_expr} as time_bucket,
SUM(tokens_used) as tokens,
provider_id
FROM token_usage
......@@ -610,7 +616,8 @@ class Analytics:
def get_cost_overview(
self,
from_datetime: Optional[datetime] = None,
to_datetime: Optional[datetime] = None
to_datetime: Optional[datetime] = None,
user_filter: Optional[int] = None
) -> Dict[str, Any]:
"""
Get cost overview for all providers.
......@@ -665,7 +672,7 @@ class Analytics:
}
}
def get_optimization_recommendations(self) -> List[Dict[str, Any]]:
def get_optimization_recommendations(self, user_filter: Optional[int] = None) -> List[Dict[str, Any]]:
"""
Generate optimization recommendations based on analytics.
......@@ -919,12 +926,14 @@ class Analytics:
if self.db.db_type == 'sqlite':
date_format = "%Y-%m-%d %H:%M"
time_bucket_expr = f"strftime('{date_format}', timestamp)"
else:
date_format = "%Y-%m-%d %H:%i"
time_bucket_expr = f"DATE_FORMAT(timestamp, '{date_format}')"
cursor.execute(f'''
SELECT
strftime('{date_format}', timestamp) as time_bucket,
{time_bucket_expr} as time_bucket,
SUM(tokens_used) as tokens,
provider_id
FROM token_usage
......
......@@ -36,11 +36,11 @@ except ImportError:
redis = None
try:
import mysql.connector
import mysql.connector as _mysql_connector
MYSQL_AVAILABLE = True
except ImportError:
MYSQL_AVAILABLE = False
mysql = None
_mysql_connector = None
try:
import numpy as np
......@@ -357,7 +357,7 @@ class MySQLCache(CacheBackend):
"""Initialize the MySQL database and create tables"""
try:
# Try to connect to the database
conn = mysql.connector.connect(**self.mysql_config)
conn = _mysql_connector.connect(**self.mysql_config)
cursor = conn.cursor()
# Create cache table
......@@ -379,12 +379,12 @@ class MySQLCache(CacheBackend):
conn.commit()
cursor.close()
conn.close()
except mysql.connector.Error as e:
except _mysql_connector.Error as e:
if e.errno == 1049: # Unknown database
# Try to create the database
temp_config = self.mysql_config.copy()
del temp_config['database']
conn = mysql.connector.connect(**temp_config)
conn = _mysql_connector.connect(**temp_config)
cursor = conn.cursor()
cursor.execute(f"CREATE DATABASE IF NOT EXISTS `{self.mysql_config['database']}`")
conn.commit()
......@@ -399,7 +399,7 @@ class MySQLCache(CacheBackend):
def _cleanup_expired(self):
"""Clean up expired cache entries"""
try:
conn = mysql.connector.connect(**self.mysql_config)
conn = _mysql_connector.connect(**self.mysql_config)
cursor = conn.cursor()
cursor.execute('DELETE FROM cache WHERE ttl IS NOT NULL AND ttl < UNIX_TIMESTAMP()')
conn.commit()
......@@ -414,7 +414,7 @@ class MySQLCache(CacheBackend):
try:
self._cleanup_expired()
conn = mysql.connector.connect(**self.mysql_config)
conn = _mysql_connector.connect(**self.mysql_config)
cursor = conn.cursor()
cursor.execute('SELECT `value`, ttl FROM cache WHERE `key` = %s', (key,))
row = cursor.fetchone()
......@@ -447,7 +447,7 @@ class MySQLCache(CacheBackend):
# Calculate TTL timestamp if provided
ttl_timestamp = time.time() + ttl if ttl else None
conn = mysql.connector.connect(**self.mysql_config)
conn = _mysql_connector.connect(**self.mysql_config)
cursor = conn.cursor()
cursor.execute('''
INSERT INTO cache (`key`, `value`, ttl)
......@@ -462,7 +462,7 @@ class MySQLCache(CacheBackend):
def delete(self, key: str) -> None:
try:
conn = mysql.connector.connect(**self.mysql_config)
conn = _mysql_connector.connect(**self.mysql_config)
cursor = conn.cursor()
cursor.execute('DELETE FROM cache WHERE `key` = %s', (key,))
conn.commit()
......@@ -477,7 +477,7 @@ class MySQLCache(CacheBackend):
try:
self._cleanup_expired()
conn = mysql.connector.connect(**self.mysql_config)
conn = _mysql_connector.connect(**self.mysql_config)
cursor = conn.cursor()
cursor.execute('SELECT ttl FROM cache WHERE `key` = %s', (key,))
row = cursor.fetchone()
......@@ -499,7 +499,7 @@ class MySQLCache(CacheBackend):
def clear(self) -> None:
try:
conn = mysql.connector.connect(**self.mysql_config)
conn = _mysql_connector.connect(**self.mysql_config)
cursor = conn.cursor()
cursor.execute('DELETE FROM cache')
conn.commit()
......@@ -560,8 +560,23 @@ class FileCache(CacheBackend):
logger.warning(f"File cache clear error: {e}")
class NumpyFileCache:
"""Specialized cache for numpy arrays (for model embeddings)"""
class NumpyCacheBackend:
"""Abstract base class for numpy array cache backends"""
def save_array(self, key: str, array: Any, metadata: Optional[Dict] = None) -> None:
raise NotImplementedError
def load_array(self, key: str) -> tuple[Optional[Any], Optional[Dict]]:
raise NotImplementedError
def exists(self, key: str) -> bool:
raise NotImplementedError
def delete(self, key: str) -> None:
raise NotImplementedError
class NumpyFileCache(NumpyCacheBackend):
"""File-based cache for numpy arrays (for model embeddings)"""
def __init__(self, cache_dir: str = '~/.aisbf/cache'):
self.cache_dir = Path(cache_dir).expanduser()
......@@ -622,6 +637,74 @@ class NumpyFileCache:
logger.warning(f"Numpy cache delete error for {key}: {e}")
class NumpyRedisCache(NumpyCacheBackend):
"""Redis-based cache for numpy arrays"""
def __init__(self, redis_client, key_prefix: str = 'aisbf:numpy:'):
self.redis = redis_client
self.key_prefix = key_prefix
def _make_key(self, key: str, suffix: str = '') -> str:
return f"{self.key_prefix}{key}{suffix}"
def save_array(self, key: str, array: Any, metadata: Optional[Dict] = None) -> None:
"""Save numpy array with optional metadata to Redis"""
if not NUMPY_AVAILABLE:
raise ImportError("NumPy is not available")
try:
# Serialize numpy array to bytes
import io
buf = io.BytesIO()
np.save(buf, array)
buf.seek(0)
array_bytes = buf.read()
# Store array
self.redis.set(self._make_key(key), array_bytes)
# Store metadata if present
if metadata:
self.redis.set(self._make_key(key, ':meta'), json.dumps(metadata))
except Exception as e:
logger.warning(f"Numpy Redis cache save error for {key}: {e}")
def load_array(self, key: str) -> tuple[Optional[Any], Optional[Dict]]:
"""Load numpy array and metadata from Redis"""
if not NUMPY_AVAILABLE:
return None, None
try:
array_bytes = self.redis.get(self._make_key(key))
if not array_bytes:
return None, None
import io
buf = io.BytesIO(array_bytes)
array = np.load(buf)
metadata = None
meta_bytes = self.redis.get(self._make_key(key, ':meta'))
if meta_bytes:
metadata = json.loads(meta_bytes)
return array, metadata
except Exception as e:
logger.warning(f"Numpy Redis cache load error for {key}: {e}")
return None, None
def exists(self, key: str) -> bool:
return bool(self.redis.exists(self._make_key(key)))
def delete(self, key: str) -> None:
try:
self.redis.delete(self._make_key(key))
self.redis.delete(self._make_key(key, ':meta'))
except Exception as e:
logger.warning(f"Numpy Redis cache delete error for {key}: {e}")
class CacheManager:
"""Unified cache manager with support for multiple backends"""
......@@ -652,11 +735,36 @@ class CacheManager:
return self._backend
@property
def numpy_cache(self) -> NumpyFileCache:
def numpy_cache(self) -> NumpyCacheBackend:
if self._numpy_cache is None:
self._numpy_cache = NumpyFileCache()
self._numpy_cache = self._create_numpy_backend()
return self._numpy_cache
def _create_numpy_backend(self) -> NumpyCacheBackend:
"""Create appropriate numpy cache backend based on configuration"""
cache_type = self.cache_type.lower()
if cache_type == 'redis' and REDIS_AVAILABLE:
try:
redis_client = redis.Redis(
host=self.config.get('redis_host', 'localhost'),
port=self.config.get('redis_port', 6379),
db=self.config.get('redis_db', 0),
password=self.config.get('redis_password', ''),
decode_responses=False
)
# Test connection
redis_client.ping()
return NumpyRedisCache(
redis_client,
key_prefix=self.config.get('redis_key_prefix', 'aisbf:') + 'numpy:'
)
except Exception as e:
logger.warning(f"Failed to create Redis numpy cache, falling back to file: {e}")
# Default to file cache for all other backends
return NumpyFileCache()
def _create_backend(self) -> CacheBackend:
"""Create appropriate cache backend based on configuration"""
cache_type = self.cache_type.lower()
......@@ -891,7 +999,7 @@ class MySQLResponseCache:
def _init_db(self):
"""Initialize MySQL database"""
try:
conn = mysql.connector.connect(**self.mysql_config)
conn = _mysql_connector.connect(**self.mysql_config)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS response_cache (
......@@ -908,11 +1016,11 @@ class MySQLResponseCache:
conn.commit()
cursor.close()
conn.close()
except mysql.connector.Error as e:
except _mysql_connector.Error as e:
if e.errno == 1049:
temp_config = self.mysql_config.copy()
del temp_config['database']
conn = mysql.connector.connect(**temp_config)
conn = _mysql_connector.connect(**temp_config)
cursor = conn.cursor()
cursor.execute(f"CREATE DATABASE IF NOT EXISTS `{self.mysql_config['database']}`")
conn.commit()
......@@ -925,7 +1033,7 @@ class MySQLResponseCache:
def _cleanup_expired(self):
"""Clean up expired entries"""
try:
conn = mysql.connector.connect(**self.mysql_config)
conn = _mysql_connector.connect(**self.mysql_config)
cursor = conn.cursor()
cursor.execute('DELETE FROM response_cache WHERE ttl IS NOT NULL AND ttl < UNIX_TIMESTAMP()')
conn.commit()
......@@ -938,7 +1046,7 @@ class MySQLResponseCache:
"""Get cached response"""
try:
self._cleanup_expired()
conn = mysql.connector.connect(**self.mysql_config)
conn = _mysql_connector.connect(**self.mysql_config)
cursor = conn.cursor()
cursor.execute('SELECT `value`, ttl FROM response_cache WHERE `key` = %s', (key,))
row = cursor.fetchone()
......@@ -960,7 +1068,7 @@ class MySQLResponseCache:
try:
value_str = json.dumps(value, ensure_ascii=False)
ttl_timestamp = time.time() + ttl
conn = mysql.connector.connect(**self.mysql_config)
conn = _mysql_connector.connect(**self.mysql_config)
cursor = conn.cursor()
cursor.execute('''
INSERT INTO response_cache (`key`, `value`, ttl)
......@@ -976,7 +1084,7 @@ class MySQLResponseCache:
def delete(self, key: str) -> None:
"""Delete cached response"""
try:
conn = mysql.connector.connect(**self.mysql_config)
conn = _mysql_connector.connect(**self.mysql_config)
cursor = conn.cursor()
cursor.execute('DELETE FROM response_cache WHERE `key` = %s', (key,))
conn.commit()
......@@ -988,7 +1096,7 @@ class MySQLResponseCache:
def clear(self) -> None:
"""Clear all cached responses"""
try:
conn = mysql.connector.connect(**self.mysql_config)
conn = _mysql_connector.connect(**self.mysql_config)
cursor = conn.cursor()
cursor.execute('DELETE FROM response_cache')
conn.commit()
......@@ -1000,7 +1108,7 @@ class MySQLResponseCache:
def get_size(self) -> int:
"""Get number of cached items"""
try:
conn = mysql.connector.connect(**self.mysql_config)
conn = _mysql_connector.connect(**self.mysql_config)
cursor = conn.cursor()
cursor.execute('SELECT COUNT(*) FROM response_cache')
count = cursor.fetchone()[0]
......
......@@ -239,6 +239,7 @@ class CurrencyConfig(BaseModel):
class SMTPConfig(BaseModel):
"""Configuration for SMTP email sending"""
enabled: bool = False
host: str = "localhost"
port: int = 587
username: str = ""
......
This source diff could not be displayed because it is too large. You can view the blob instead.
"""
Copyleft (C) 2026 Stefy Lanza <stefy@nexlab.net>
......@@ -17,8 +18,6 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Why did the programmer quit his job? Because he didn't get arrays!
"""
import smtplib
import hashlib
......@@ -27,6 +26,9 @@ from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional
import logging
import traceback
import sys
from aisbf.config import SMTPConfig
logger = logging.getLogger(__name__)
......@@ -47,19 +49,29 @@ def hash_password(password: str) -> str:
def generate_verification_token() -> str:
"""
Generate a secure random verification token.
Returns:
Random token string (32 bytes hex)
"""
return secrets.token_hex(32)
def generate_password_reset_token() -> str:
"""
Generate a secure random password reset token.
Returns:
Random token string (32 bytes hex)
"""
return generate_verification_token()
def send_verification_email(
to_email: str,
username: str,
verification_token: str,
base_url: str,
smtp_config: dict
smtp_config: SMTPConfig
) -> bool:
"""
Send email verification email to a user.
......@@ -76,12 +88,12 @@ def send_verification_email(
"""
try:
# Create verification URL
verification_url = f"{base_url}/dashboard/verify-email?token={verification_token}"
verification_url = f"{base_url}/dashboard/verify-email?token={verification_token}&email={to_email}"
# Create message
msg = MIMEMultipart('alternative')
msg['Subject'] = 'Verify your AISBF account'
msg['From'] = f"{smtp_config.get('from_name', 'AISBF')} <{smtp_config.get('from_email')}>"
msg['From'] = f"{smtp_config.from_name} <{smtp_config.from_email}>"
msg['To'] = to_email
# Create plain text and HTML versions
......@@ -92,9 +104,9 @@ Thank you for signing up for AISBF!
Please verify your email address by clicking the link below:
{verification_url}
{verification_url}
This link will expire in {smtp_config.get('verification_token_expiry_hours', 24)} hours.
This link will expire in 24 hours.
If you did not create this account, please ignore this email.
......@@ -118,7 +130,7 @@ AISBF Team
</p>
<p>Or copy and paste this link into your browser:</p>
<p><a href="{verification_url}">{verification_url}</a></p>
<p>This link will expire in {smtp_config.get('verification_token_expiry_hours', 24)} hours.</p>
<p>This link will expire in 24 hours.</p>
<p>If you did not create this account, please ignore this email.</p>
<br>
<p>Best regards,<br>AISBF Team</p>
......@@ -132,21 +144,48 @@ AISBF Team
msg.attach(part1)
msg.attach(part2)
logger.debug(f"SMTP Config:")
logger.debug(f" Host: {smtp_config.host}")
logger.debug(f" Port: {smtp_config.port}")
logger.debug(f" Use SSL: {smtp_config.use_ssl}")
logger.debug(f" Use TLS: {smtp_config.use_tls}")
logger.debug(f" Username: {smtp_config.username}")
logger.debug(f" Password length: {len(smtp_config.password)} chars")
logger.debug(f" From Email: {smtp_config.from_email}")
logger.debug(f" From Name: {smtp_config.from_name}")
# Send email
if smtp_config.get('use_ssl', False):
if smtp_config.use_ssl:
# Use SSL
with smtplib.SMTP_SSL(smtp_config['host'], smtp_config['port']) as server:
if smtp_config.get('username') and smtp_config.get('password'):
server.login(smtp_config['username'], smtp_config['password'])
logger.debug("Connecting with SSL")
with smtplib.SMTP_SSL(smtp_config.host, smtp_config.port) as server:
server.set_debuglevel(1)
if smtp_config.username and smtp_config.password:
logger.debug("Sending EHLO")
server.ehlo()
logger.debug(f"Logging in with user: {smtp_config.username}")
server.login(smtp_config.username, smtp_config.password)
logger.debug("Sending message")
server.send_message(msg)
logger.debug("Message sent successfully")
else:
# Use TLS or no encryption
with smtplib.SMTP(smtp_config['host'], smtp_config['port']) as server:
if smtp_config.get('use_tls', True):
logger.debug("Connecting plaintext")
with smtplib.SMTP(smtp_config.host, smtp_config.port) as server:
server.set_debuglevel(1)
logger.debug("Sending EHLO")
server.ehlo()
if smtp_config.use_tls:
logger.debug("Starting TLS")
server.starttls()
if smtp_config.get('username') and smtp_config.get('password'):
server.login(smtp_config['username'], smtp_config['password'])
logger.debug("Sending EHLO after STARTTLS")
server.ehlo()
if smtp_config.username and smtp_config.password:
logger.debug(f"Logging in with user: {smtp_config.username}")
server.login(smtp_config.username, smtp_config.password)
logger.debug("Sending message")
server.send_message(msg)
logger.debug("Message sent successfully")
logger.info(f"Verification email sent to {to_email}")
return True
......@@ -161,7 +200,7 @@ def send_password_reset_email(
username: str,
reset_token: str,
base_url: str,
smtp_config: dict
smtp_config: SMTPConfig
) -> bool:
"""
Send password reset email to a user.
......@@ -183,7 +222,7 @@ def send_password_reset_email(
# Create message
msg = MIMEMultipart('alternative')
msg['Subject'] = 'Reset your AISBF password'
msg['From'] = f"{smtp_config.get('from_name', 'AISBF')} <{smtp_config.get('from_email')}>"
msg['From'] = f"{smtp_config.from_name} <{smtp_config.from_email}>"
msg['To'] = to_email
# Create plain text and HTML versions
......@@ -234,21 +273,48 @@ AISBF Team
msg.attach(part1)
msg.attach(part2)
logger.debug(f"SMTP Config:")
logger.debug(f" Host: {smtp_config.host}")
logger.debug(f" Port: {smtp_config.port}")
logger.debug(f" Use SSL: {smtp_config.use_ssl}")
logger.debug(f" Use TLS: {smtp_config.use_tls}")
logger.debug(f" Username: {smtp_config.username}")
logger.debug(f" Password length: {len(smtp_config.password)} chars")
logger.debug(f" From Email: {smtp_config.from_email}")
logger.debug(f" From Name: {smtp_config.from_name}")
# Send email
if smtp_config.get('use_ssl', False):
if smtp_config.use_ssl:
# Use SSL
with smtplib.SMTP_SSL(smtp_config['host'], smtp_config['port']) as server:
if smtp_config.get('username') and smtp_config.get('password'):
server.login(smtp_config['username'], smtp_config['password'])
logger.debug("Connecting with SSL")
with smtplib.SMTP_SSL(smtp_config.host, smtp_config.port) as server:
server.set_debuglevel(1)
if smtp_config.username and smtp_config.password:
logger.debug("Sending EHLO")
server.ehlo()
logger.debug(f"Logging in with user: {smtp_config.username}")
server.login(smtp_config.username, smtp_config.password)
logger.debug("Sending message")
server.send_message(msg)
logger.debug("Message sent successfully")
else:
# Use TLS or no encryption
with smtplib.SMTP(smtp_config['host'], smtp_config['port']) as server:
if smtp_config.get('use_tls', True):
logger.debug("Connecting plaintext")
with smtplib.SMTP(smtp_config.host, smtp_config.port) as server:
server.set_debuglevel(1)
logger.debug("Sending EHLO")
server.ehlo()
if smtp_config.use_tls:
logger.debug("Starting TLS")
server.starttls()
if smtp_config.get('username') and smtp_config.get('password'):
server.login(smtp_config['username'], smtp_config['password'])
logger.debug("Sending EHLO after STARTTLS")
server.ehlo()
if smtp_config.username and smtp_config.password:
logger.debug(f"Logging in with user: {smtp_config.username}")
server.login(smtp_config.username, smtp_config.password)
logger.debug("Sending message")
server.send_message(msg)
logger.debug("Message sent successfully")
logger.info(f"Password reset email sent to {to_email}")
return True
......@@ -256,3 +322,127 @@ AISBF Team
except Exception as e:
logger.error(f"Failed to send password reset email to {to_email}: {e}")
return False
def send_test_email(
to_email: str,
smtp_config: SMTPConfig
) -> bool:
"""
Send a test email to verify SMTP configuration.
Args:
to_email: Recipient email address
smtp_config: SMTP configuration dictionary
Returns:
True if email sent successfully, False otherwise
"""
try:
msg = MIMEMultipart('alternative')
msg['Subject'] = 'AISBF SMTP Test Email'
msg['From'] = f"{smtp_config.from_name} <{smtp_config.from_email}>"
msg['To'] = to_email
text = """
This is a test email sent from your AISBF server.
If you received this email, your SMTP configuration is working correctly!
Best regards,
AISBF Team
"""
html = """
<html>
<head></head>
<body>
<h2>AISBF SMTP Test Email</h2>
<p>This is a test email sent from your AISBF server.</p>
<p style="color: #4CAF50; font-weight: bold;">✅ If you received this email, your SMTP configuration is working correctly!</p>
<br>
<p>Best regards,<br>AISBF Team</p>
</body>
</html>
"""
part1 = MIMEText(text, 'plain')
part2 = MIMEText(html, 'html')
msg.attach(part1)
msg.attach(part2)
logger.info(f"Sending test email to {to_email}")
logger.info(f"SMTP Config:")
logger.info(f" Host: {smtp_config.host}")
logger.info(f" Port: {smtp_config.port}")
logger.info(f" Use SSL: {smtp_config.use_ssl}")
logger.info(f" Use TLS: {smtp_config.use_tls}")
logger.info(f" Username: {smtp_config.username}")
logger.info(f" Password length: {len(smtp_config.password)} chars")
logger.info(f" From Email: {smtp_config.from_email}")
logger.info(f" From Name: {smtp_config.from_name}")
# Enable SMTP debug logging at INFO level always
old_stdout = sys.stdout
log_buffer = []
class LogBuffer:
def write(self, data):
line = data.strip()
if line:
log_buffer.append(line)
logger.info(f"SMTP: {line}")
def flush(self):
pass
sys.stdout = LogBuffer()
try:
# Send email
if smtp_config.use_ssl:
# Use SSL
logger.info("Connecting with SSL")
with smtplib.SMTP_SSL(smtp_config.host, smtp_config.port) as server:
server.set_debuglevel(2)
if smtp_config.username and smtp_config.password:
logger.info("Sending EHLO")
server.ehlo()
logger.info(f"Logging in with user: {smtp_config.username}")
server.login(smtp_config.username, smtp_config.password)
logger.info("Sending message")
server.send_message(msg)
logger.info("Message sent successfully")
else:
# Use TLS or no encryption
logger.info("Connecting plaintext")
with smtplib.SMTP(smtp_config.host, smtp_config.port) as server:
server.set_debuglevel(2)
logger.info("Sending EHLO")
server.ehlo()
if smtp_config.use_tls:
logger.info("Starting TLS")
server.starttls()
logger.info("Sending EHLO after STARTTLS")
server.ehlo()
if smtp_config.username and smtp_config.password:
logger.info(f"Logging in with user: {smtp_config.username}")
server.login(smtp_config.username, smtp_config.password)
logger.info("Sending message")
server.send_message(msg)
logger.info("Message sent successfully")
finally:
sys.stdout = old_stdout
logger.info(f"Test email sent successfully to {to_email}")
return True
except smtplib.SMTPException as e:
logger.error(f"SMTP Error sending test email to {to_email}: {e}")
logger.error(f"SMTP Code: {e.smtp_code}, Message: {e.smtp_error}")
logger.error(f"SMTP Server Response: {getattr(e, 'resp', 'N/A')}")
return False
except Exception as e:
logger.error(f"Exception sending test email to {to_email}: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
return False
......@@ -24,7 +24,7 @@ import asyncio
import time
import os
import random
from typing import Dict, List, Optional, Union
from typing import Dict, List, Optional, Union, Any
from ..models import Provider, Model, ErrorTracking
from ..config import config
from ..utils import count_messages_tokens
......@@ -1263,6 +1263,51 @@ class BaseProviderHandler:
# Fall back to direct processing (either batching disabled, streaming, or batching returned None)
return await self._handle_request_direct(model, messages, max_tokens, temperature, stream, tools, tool_choice)
def _get_models_cache_key(self) -> str:
"""Get unified cache key for provider models list"""
return f"provider_models:{self.provider_id}"
def _save_models_cache(self, models: List[Any]) -> None:
"""Save models list to unified cache system"""
import logging
try:
from ..cache import get_cache_manager
cache = get_cache_manager()
cache_data = {
'models': [m.dict() if hasattr(m, 'dict') else dict(m) for m in models],
'cached_at': time.time()
}
# Cache for 24 hours (86400 seconds)
cache.set(self._get_models_cache_key(), cache_data, ttl=86400)
logging.info(f"{self.__class__.__name__}: ✓ Saved {len(models)} models to cache")
except Exception as e:
logging.warning(f"{self.__class__.__name__}: Failed to save models cache: {e}")
def _load_models_cache(self) -> Optional[List[Any]]:
"""Load models list from unified cache system if available and not expired"""
import logging
try:
from ..cache import get_cache_manager
from ..models import Model
cache = get_cache_manager()
cache_data = cache.get(self._get_models_cache_key())
if not cache_data:
return None
models = []
for model_dict in cache_data.get('models', []):
models.append(Model(**model_dict))
logging.info(f"{self.__class__.__name__}: ✓ Loaded {len(models)} models from cache")
return models
except Exception as e:
logging.warning(f"{self.__class__.__name__}: Failed to load models cache: {e}")
return None
async def _handle_request_direct(self, model: str, messages: List[Dict], max_tokens: Optional[int] = None,
temperature: Optional[float] = 1.0, stream: Optional[bool] = False,
tools: Optional[List[Dict]] = None, tool_choice: Optional[Union[str, Dict]] = None) -> Union[Dict, object]:
......
......@@ -1623,97 +1623,8 @@ class ClaudeProviderHandler(BaseProviderHandler):
'cache_hit_rate_percent': round(hit_rate, 2),
}
def _get_models_cache_path(self) -> str:
"""Get the path to the models cache file."""
import os
cache_dir = os.path.expanduser("~/.aisbf")
os.makedirs(cache_dir, exist_ok=True)
return os.path.join(cache_dir, f"claude_models_cache_{self.provider_id}.json")
def _save_models_cache(self, models: List[Model]) -> None:
"""Save models to cache file."""
import logging
import json
try:
cache_path = self._get_models_cache_path()
cache_data = {
'timestamp': time.time(),
'models': []
}
for m in models:
model_dict = {'id': m.id, 'name': m.name}
if m.context_size:
model_dict['context_size'] = m.context_size
if m.context_length:
model_dict['context_length'] = m.context_length
if m.description:
model_dict['description'] = m.description
if m.pricing:
model_dict['pricing'] = m.pricing
if m.top_provider:
model_dict['top_provider'] = m.top_provider
if m.supported_parameters:
model_dict['supported_parameters'] = m.supported_parameters
cache_data['models'].append(model_dict)
with open(cache_path, 'w') as f:
json.dump(cache_data, f, indent=2)
logging.info(f"ClaudeProviderHandler: ✓ Saved {len(models)} models to cache: {cache_path}")
except Exception as e:
logging.warning(f"ClaudeProviderHandler: Failed to save models cache: {e}")
def _load_models_cache(self) -> Optional[List[Model]]:
"""Load models from cache file if available and not too old."""
import logging
import json
import os
try:
cache_path = self._get_models_cache_path()
if not os.path.exists(cache_path):
logging.info(f"ClaudeProviderHandler: No cache file found at {cache_path}")
return None
with open(cache_path, 'r') as f:
cache_data = json.load(f)
cache_age = time.time() - cache_data.get('timestamp', 0)
cache_age_hours = cache_age / 3600
logging.info(f"ClaudeProviderHandler: Found cache file (age: {cache_age_hours:.1f} hours)")
if cache_age > 86400:
logging.info(f"ClaudeProviderHandler: Cache is too old (>{cache_age_hours:.1f} hours), ignoring")
return None
models = []
for m in cache_data.get('models', []):
models.append(Model(
id=m['id'],
name=m['name'],
provider_id=self.provider_id,
context_size=m.get('context_size'),
context_length=m.get('context_length'),
description=m.get('description'),
pricing=m.get('pricing'),
top_provider=m.get('top_provider'),
supported_parameters=m.get('supported_parameters')
))
if models:
logging.info(f"ClaudeProviderHandler: ✓ Loaded {len(models)} models from cache")
return models
else:
logging.info(f"ClaudeProviderHandler: Cache file is empty")
return None
except Exception as e:
logging.warning(f"ClaudeProviderHandler: Failed to load models cache: {e}")
return None
# Model caching is now handled by the base class using the unified cache system
# _get_models_cache_path(), _save_models_cache(), _load_models_cache() are inherited from BaseProviderHandler
async def get_models(self) -> List[Model]:
"""Return list of available Claude models by querying the API."""
......
......@@ -391,89 +391,8 @@ class KiroProviderHandler(BaseProviderHandler):
# Final flush to ensure all buffered data reaches the client
await asyncio.sleep(0)
def _get_models_cache_path(self) -> str:
"""Get the path to the models cache file."""
cache_dir = os.path.expanduser("~/.aisbf")
os.makedirs(cache_dir, exist_ok=True)
return os.path.join(cache_dir, f"kiro_models_cache_{self.provider_id}.json")
def _save_models_cache(self, models: List[Model]) -> None:
"""Save models to cache file."""
try:
cache_path = self._get_models_cache_path()
cache_data = {
'timestamp': time.time(),
'models': []
}
for m in models:
model_dict = {'id': m.id, 'name': m.name}
if m.context_size:
model_dict['context_size'] = m.context_size
if m.context_length:
model_dict['context_length'] = m.context_length
if m.description:
model_dict['description'] = m.description
if m.pricing:
model_dict['pricing'] = m.pricing
if m.top_provider:
model_dict['top_provider'] = m.top_provider
if m.supported_parameters:
model_dict['supported_parameters'] = m.supported_parameters
cache_data['models'].append(model_dict)
with open(cache_path, 'w') as f:
json.dump(cache_data, f, indent=2)
logging.info(f"KiroProviderHandler: ✓ Saved {len(models)} models to cache: {cache_path}")
except Exception as e:
logging.warning(f"KiroProviderHandler: Failed to save models cache: {e}")
def _load_models_cache(self) -> Optional[List[Model]]:
"""Load models from cache file if available and not too old."""
try:
cache_path = self._get_models_cache_path()
if not os.path.exists(cache_path):
logging.info(f"KiroProviderHandler: No cache file found at {cache_path}")
return None
with open(cache_path, 'r') as f:
cache_data = json.load(f)
cache_age = time.time() - cache_data.get('timestamp', 0)
cache_age_hours = cache_age / 3600
logging.info(f"KiroProviderHandler: Found cache file (age: {cache_age_hours:.1f} hours)")
if cache_age > 86400:
logging.info(f"KiroProviderHandler: Cache is too old (>{cache_age_hours:.1f} hours), ignoring")
return None
models = []
for m in cache_data.get('models', []):
models.append(Model(
id=m['id'],
name=m['name'],
provider_id=self.provider_id,
context_size=m.get('context_size'),
context_length=m.get('context_length'),
description=m.get('description'),
pricing=m.get('pricing'),
top_provider=m.get('top_provider'),
supported_parameters=m.get('supported_parameters')
))
if models:
logging.info(f"KiroProviderHandler: ✓ Loaded {len(models)} models from cache")
return models
else:
logging.info(f"KiroProviderHandler: Cache file is empty")
return None
except Exception as e:
logging.warning(f"KiroProviderHandler: Failed to load models cache: {e}")
return None
# Model caching is now handled by the base class using the unified cache system
# _get_models_cache_path(), _save_models_cache(), _load_models_cache() are inherited from BaseProviderHandler
async def get_models(self) -> List[Model]:
"""Return list of available models using fallback strategy."""
......
......@@ -137,8 +137,17 @@ class TorHiddenService:
('HiddenServicePort', f'{self.config.hidden_service_port} 127.0.0.1:{local_port}')
])
# Read the hostname file to get onion address
# Wait for Tor daemon to create the hostname file (can take up to 30 seconds)
import time
hostname_file = hidden_service_dir / 'hostname'
# Wait up to 30 seconds with 1 second intervals
for attempt in range(30):
if hostname_file.exists() and hostname_file.stat().st_size > 0:
break
time.sleep(1)
logger.debug(f"Waiting for hostname file... attempt {attempt + 1}/30")
if hostname_file.exists():
self.onion_address = hostname_file.read_text().strip()
logger.info(f"Persistent hidden service created: {self.onion_address}")
......
......@@ -123,6 +123,7 @@
"verification_token_expiry_hours": 24
},
"smtp": {
"enabled": false,
"host": "localhost",
"port": 587,
"username": "",
......
......@@ -28,10 +28,11 @@ from fastapi.responses import JSONResponse, StreamingResponse, HTMLResponse, Red
from fastapi.middleware.cors import CORSMiddleware
from fastapi.exceptions import RequestValidationError
from fastapi.templating import Jinja2Templates
from jinja2 import Environment, FileSystemLoader
from aisbf.models import ChatCompletionRequest, ChatCompletionResponse
from aisbf.handlers import RequestHandler, RotationHandler, AutoselectHandler
from aisbf.mcp import mcp_server, MCPAuthLevel, load_mcp_config
from aisbf.database import initialize_database
from aisbf.database import DatabaseRegistry
from aisbf.cache import initialize_cache
from aisbf.tor import setup_tor_hidden_service, TorHiddenService
from starlette.middleware.sessions import SessionMiddleware
......@@ -379,6 +380,10 @@ class ProxyHeadersMiddleware(BaseHTTPMiddleware):
forwarded_prefix = request.headers.get("X-Forwarded-Prefix") or request.headers.get("X-Script-Name")
forwarded_for = request.headers.get("X-Forwarded-For")
# Debug logging for proxy headers
if forwarded_proto or forwarded_host or forwarded_prefix:
logger.debug(f"Proxy headers detected - Proto: {forwarded_proto}, Host: {forwarded_host}, Prefix: {forwarded_prefix}, Path: {request.url.path}")
# Update request scope with proxy information
if forwarded_proto:
request.scope["scheme"] = forwarded_proto
......@@ -454,9 +459,17 @@ def url_for(request: Request, path: str) -> str:
if not path.startswith("/"):
path = "/" + path
if root_path:
# Check if we're behind a proxy by looking for proxy headers
# If X-Forwarded-Host is present, we're behind a proxy
is_behind_proxy = "x-forwarded-host" in request.headers or "x-forwarded-proto" in request.headers
if is_behind_proxy:
# Behind proxy: return relative URL that browser resolves correctly
return root_path + path
# If root_path is "/" (no prefix), just return the path
if root_path and root_path != "/":
return root_path + path
else:
return path
else:
# Not behind proxy: return full URL
base_url = get_base_url(request)
......@@ -469,8 +482,8 @@ app = FastAPI(
max_request_size=100 * 1024 * 1024 # 100MB max request size
)
# Add proxy headers middleware (must be added before other middleware)
app.add_middleware(ProxyHeadersMiddleware)
# Note: ProxyHeadersMiddleware will be added LAST (after all other middleware)
# so it executes FIRST and processes proxy headers before other middleware
# Initialize Jinja2 templates with custom globals for proxy-aware URLs
templates = Jinja2Templates(directory="templates")
......@@ -493,7 +506,7 @@ def setup_template_globals():
# Call setup after templates are initialized
setup_template_globals()
# Add session middleware at module level with a persistent secret key
# Session secret key generation function
# This is needed for uvicorn import (when main() doesn't run)
# Use a persistent secret key so sessions survive server restarts
def _get_or_create_session_secret():
......@@ -524,9 +537,8 @@ def _get_or_create_session_secret():
return secret
_session_secret = _get_or_create_session_secret()
# Configure session middleware: 30 days max age (cookie expiration)
# Note: Session data is stored in signed cookies, so it persists across restarts
app.add_middleware(SessionMiddleware, secret_key=_session_secret, max_age=30 * 24 * 60 * 60) # 30 days max age
# Note: SessionMiddleware will be added AFTER the @app.middleware decorators
# to ensure proper middleware execution order
# These will be initialized in startup event or main() after config is loaded
request_handler = None
......@@ -944,7 +956,7 @@ async def startup_event():
# Initialize database
try:
db_config = config.aisbf.database if config.aisbf and config.aisbf.database else None
initialize_database(db_config)
DatabaseRegistry.get_config_database(db_config)
except Exception as e:
logger.error(f"Failed to initialize database: {e}")
# Continue startup even if database fails
......@@ -1059,7 +1071,7 @@ async def startup_event():
if not has_valid_auth:
try:
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
if db:
# Check for uploaded credentials files for this provider
auth_files = db.get_user_auth_files(0, provider_id) # 0 for admin/global
......@@ -1236,7 +1248,7 @@ async def auth_middleware(request: Request, call_next):
else:
# Check user API tokens
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
user_auth = db.authenticate_user_token(token)
if user_auth:
......@@ -1262,6 +1274,68 @@ async def auth_middleware(request: Request, call_next):
request.state.token_id = None
request.state.is_global_token = False
# Check for unverified email for logged in dashboard users
# Only enforce email verification if:
# 1. User is logged in to dashboard
# 2. User is not an admin (admins bypass email verification)
# 3. User's email is not verified
# 4. Email verification is enabled in config
# Debug: Log session state for all dashboard requests
if request.url.path.startswith("/dashboard"):
logger.debug(f"Dashboard request to {request.url.path} - Session: logged_in={request.session.get('logged_in')}, email_verified={request.session.get('email_verified')}, role={request.session.get('role')}")
if (request.url.path.startswith("/dashboard") and
request.session.get('logged_in') and
request.session.get('role') != 'admin'):
# Check if email verification is enabled in config
require_verification = False
if config and hasattr(config, 'aisbf') and hasattr(config.aisbf, 'signup'):
require_verification = getattr(config.aisbf.signup, 'require_email_verification', False)
logger.debug(f"Email verification check - require_verification={require_verification}, email_verified={request.session.get('email_verified')}")
# Check if user's email verification status has changed since login
# This handles the case where email was verified in another browser/session
user_id = request.session.get('user_id')
if user_id and require_verification:
try:
from aisbf.database import get_database
db = DatabaseRegistry.get_config_database()
current_user = db.get_user_by_id(user_id)
if current_user and current_user.get('email_verified') != request.session.get('email_verified'):
# Email verification status changed, log user out
logger.info(f"Email verification status changed for user {user_id}, logging out session")
request.session.clear()
return RedirectResponse(url=url_for(request, "/dashboard/login") + "?error=Your email verification status has changed. Please log in again.", status_code=303)
except Exception as e:
logger.error(f"Error checking email verification status for user {user_id}: {e}")
# Only check email_verified if verification is required
if require_verification and not request.session.get('email_verified'):
# Allow only specific routes for unverified users
# These are the ONLY pages an unverified user can access
allowed_routes = [
"/dashboard/verify",
"/dashboard/resend-verification",
"/dashboard/logout",
"/dashboard/verify-email"
]
# Check if current path matches any allowed route exactly or starts with it
is_allowed = False
for route in allowed_routes:
if request.url.path == route or request.url.path == route + "/":
is_allowed = True
break
if not is_allowed:
# Block access and redirect to verify page
redirect_url = url_for(request, "/dashboard/verify")
logger.info(f"BLOCKING unverified user access to {request.url.path}, redirecting to: {redirect_url}")
return RedirectResponse(url=redirect_url, status_code=303)
response = await call_next(request)
return response
......@@ -1290,7 +1364,7 @@ async def tier_limit_middleware(request: Request, call_next):
return await call_next(request)
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
# Get user tier and current usage
tier = db.get_user_tier(user_id)
......@@ -1453,7 +1527,7 @@ async def record_token_usage_async(user_id: int, token_id: int):
"""Asynchronously record token usage"""
try:
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
# Record with dummy values for now - will be updated when we know the actual usage
db.record_user_token_usage(user_id, token_id, '', '', 0)
except Exception as e:
......@@ -1519,6 +1593,15 @@ app.add_middleware(
allow_headers=["*"],
)
# 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
# Add proxy headers middleware LAST so it executes FIRST
# This ensures proxy headers are processed before any other middleware (including auth_middleware)
app.add_middleware(ProxyHeadersMiddleware)
# Dashboard routes
@app.get("/dashboard/analytics", response_class=HTMLResponse)
async def dashboard_analytics(
......@@ -1541,7 +1624,7 @@ async def dashboard_analytics(
from aisbf.database import get_database
# Get analytics and database
db = get_database()
db = DatabaseRegistry.get_config_database()
analytics = get_analytics(db)
# Parse date range
......@@ -1564,8 +1647,16 @@ async def dashboard_analytics(
if from_datetime and to_datetime:
time_range = 'custom'
# Get all users for filter dropdown
all_users = db.get_users() if db else []
# Check user role and apply user restriction
is_admin = request.session.get('role') == 'admin'
current_user_id = request.session.get('user_id')
# For non-admin users, force user filter to current user
if not is_admin and current_user_id is not None:
user_filter = current_user_id
# Get all users for filter dropdown (only for admins)
all_users = db.get_users() if db and is_admin else []
# Get available providers, models, rotations, and autoselects for filter dropdowns
available_providers = list(config.providers.keys()) if config else []
......@@ -1582,9 +1673,9 @@ async def dashboard_analytics(
# Get provider statistics (with optional filter)
if provider_filter:
provider_stats = [analytics.get_provider_stats(provider_filter, from_datetime, to_datetime)]
provider_stats = [analytics.get_provider_stats(provider_filter, from_datetime, to_datetime, user_filter=user_filter)]
else:
provider_stats = analytics.get_all_providers_stats(from_datetime, to_datetime)
provider_stats = analytics.get_all_providers_stats(from_datetime, to_datetime, user_filter=user_filter)
# Get token usage over time (with optional filters)
token_over_time = analytics.get_token_usage_over_time(
......@@ -1605,17 +1696,17 @@ async def dashboard_analytics(
)
# Get cost overview
cost_overview = analytics.get_cost_overview(from_datetime, to_datetime)
cost_overview = analytics.get_cost_overview(from_datetime, to_datetime, user_filter=user_filter)
# Get optimization recommendations
recommendations = analytics.get_optimization_recommendations()
recommendations = analytics.get_optimization_recommendations(user_filter=user_filter)
# Get date range usage summary
date_range_usage = None
if from_datetime or to_datetime:
start = from_datetime or (datetime.now() - timedelta(days=1))
end = to_datetime or datetime.now()
date_range_usage = analytics.get_token_usage_by_date_range(provider_filter, start, end)
date_range_usage = analytics.get_token_usage_by_date_range(provider_filter, start, end, user_filter=user_filter)
return templates.TemplateResponse(
request=request,
......@@ -1623,6 +1714,7 @@ async def dashboard_analytics(
context={
"request": request,
"session": request.session,
"is_admin": is_admin,
"provider_stats": provider_stats,
"token_over_time": json.dumps(token_over_time),
"model_performance": model_performance,
......@@ -1665,9 +1757,23 @@ async def dashboard_login_page(request: Request):
if config and hasattr(config, 'aisbf') and config.aisbf:
signup_enabled = getattr(config.aisbf.signup, 'enabled', False) if config.aisbf.signup else False
# Get and render template
template = env.get_template("dashboard/login.html")
html_content = template.render(request=request, signup_enabled=signup_enabled, config=config.aisbf if config and config.aisbf else {})
# Check if SMTP is enabled
smtp_enabled = False
if config and hasattr(config, 'aisbf') and config.aisbf and hasattr(config.aisbf, 'smtp') and config.aisbf.smtp:
smtp_enabled = getattr(config.aisbf.smtp, 'enabled', False)
# Check for signup success notification
show_verify_email = request.query_params.get('signup') == 'success' and smtp_enabled
# Check for error message in query params
error_message = request.query_params.get('error')
# Check for success message in query params
success_message = request.query_params.get('success')
# Get and render template using templates Jinja2Templates instance
template = templates.get_template("dashboard/login.html")
html_content = template.render(request=request, signup_enabled=signup_enabled, smtp_enabled=smtp_enabled, show_verify_email=show_verify_email, error=error_message, success=success_message, config=config.aisbf if config and config.aisbf else {})
return HTMLResponse(content=html_content)
except Exception as e:
......@@ -1683,29 +1789,54 @@ async def dashboard_login(request: Request, username: str = Form(...), password:
password_hash = hashlib.sha256(password.encode()).hexdigest()
# Try database authentication first
db = get_database()
db = DatabaseRegistry.get_config_database()
user = db.authenticate_user(username, password_hash)
if user:
# Database user authenticated - check if email is verified
if not user['email_verified']:
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={"request": request, "error": "Please verify your email address before logging in"}
)
# Database user authenticated
logger.info(f"User authenticated: username={username}, email={user.get('email')}, user_id={user['id']}")
request.session['logged_in'] = True
request.session['username'] = username
request.session['email'] = user.get('email') or '' # Ensure we get the email from user dict
request.session['role'] = user['role']
request.session['user_id'] = user['id']
request.session['remember_me'] = remember_me
request.session['email_verified'] = user['email_verified']
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
# Check if email is verified
if not user['email_verified']:
# Check if account is expired (24 hours old and unverified)
from datetime import datetime, timedelta
if user['created_at']:
if isinstance(user['created_at'], str):
created_at = datetime.fromisoformat(user['created_at'])
else:
# Already a datetime object
created_at = user['created_at']
else:
created_at = datetime.now()
if datetime.now() - created_at > timedelta(hours=24):
# Delete expired unverified account
db.delete_user(user['id'])
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={
"request": request,
"error": "Your account verification has expired. Please sign up again.",
"config": config.aisbf if config and config.aisbf else {}
}
)
else:
# Redirect to verification page
return RedirectResponse(url=url_for(request, "/dashboard/verify"), status_code=303)
return RedirectResponse(url=url_for(request, "/dashboard"), status_code=303)
# Fallback to config admin
......@@ -1727,16 +1858,8 @@ async def dashboard_login(request: Request, username: str = Form(...), password:
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
return RedirectResponse(url=url_for(request, "/dashboard"), status_code=303)
# Check if signup is enabled
signup_enabled = False
if config and hasattr(config, 'aisbf') and config.aisbf:
signup_enabled = getattr(config.aisbf.signup, 'enabled', False) if config.aisbf.signup else False
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={"request": request, "error": "Invalid credentials", "signup_enabled": signup_enabled, "config": config.aisbf if config and config.aisbf else {}}
)
# If we reach here, authentication failed
return RedirectResponse(url=url_for(request, "/dashboard/login") + "?error=Invalid username or password", status_code=303)
@app.get("/dashboard/signup", response_class=HTMLResponse)
......@@ -1774,6 +1897,7 @@ async def dashboard_signup_page(request: Request):
@app.post("/dashboard/signup")
async def dashboard_signup(
request: Request,
username: str = Form(...),
email: str = Form(...),
password: str = Form(...),
confirm_password: str = Form(...)
......@@ -1793,12 +1917,29 @@ async def dashboard_signup(
if not signup_enabled:
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
# Validate username format
import re
if not re.match(r"^[a-zA-Z0-9_.-]+$", username):
return templates.TemplateResponse(
request=request,
name="dashboard/signup.html",
context={"request": request, "error": "Username can only contain letters, numbers, underscores, hyphens, and dots", "config": config.aisbf if config and config.aisbf else {}}
)
# Validate username length
if len(username) < 3 or len(username) > 50:
return templates.TemplateResponse(
request=request,
name="dashboard/signup.html",
context={"request": request, "error": "Username must be between 3 and 50 characters", "config": config.aisbf if config and config.aisbf else {}}
)
# Validate passwords match
if password != confirm_password:
return templates.TemplateResponse(
request=request,
name="dashboard/signup.html",
context={"request": request, "error": "Passwords do not match"}
context={"request": request, "error": "Passwords do not match", "config": config.aisbf if config and config.aisbf else {}}
)
# Validate password strength (minimum 8 characters)
......@@ -1806,7 +1947,7 @@ async def dashboard_signup(
return templates.TemplateResponse(
request=request,
name="dashboard/signup.html",
context={"request": request, "error": "Password must be at least 8 characters long"}
context={"request": request, "error": "Password must be at least 8 characters long", "config": config.aisbf if config and config.aisbf else {}}
)
# Validate email format
......@@ -1815,11 +1956,29 @@ async def dashboard_signup(
return templates.TemplateResponse(
request=request,
name="dashboard/signup.html",
context={"request": request, "error": "Invalid email address"}
context={"request": request, "error": "Invalid email address", "config": config.aisbf if config and config.aisbf else {}}
)
# Check if username is already taken
try:
db = DatabaseRegistry.get_config_database()
existing_user_by_username = db.get_user_by_username(username)
if existing_user_by_username:
return templates.TemplateResponse(
request=request,
name="dashboard/signup.html",
context={"request": request, "error": "This username is already taken. Please choose a different one.", "config": config.aisbf if config and config.aisbf else {}}
)
except Exception as e:
logger.error(f"Error checking username uniqueness: {e}", exc_info=True)
return templates.TemplateResponse(
request=request,
name="dashboard/signup.html",
context={"request": request, "error": "An error occurred during signup. Please try again.", "config": config.aisbf if config and config.aisbf else {}}
)
try:
db = get_database()
db = DatabaseRegistry.get_config_database()
# Check if user already exists
existing_user = db.get_user_by_email(email)
......@@ -1828,51 +1987,56 @@ async def dashboard_signup(
return templates.TemplateResponse(
request=request,
name="dashboard/signup.html",
context={"request": request, "error": "An account with this email already exists"}
context={"request": request, "error": "An account with this email already exists", "config": config.aisbf if config and config.aisbf else {}}
)
else:
# Resend verification email for unverified user
verification_token = generate_verification_token()
db.set_verification_token(email, verification_token)
expires_at = datetime.now() + timedelta(hours=24)
db.set_verification_token(existing_user['id'], verification_token, expires_at)
db.update_last_verification_email_sent(existing_user['id'], datetime.now())
# Send verification email
try:
base_url = get_base_url(request)
verification_url = f"{base_url}/dashboard/verify-email?token={verification_token}&email={email}"
send_verification_email(email, verification_url, config.aisbf.smtp if config.aisbf.smtp else None)
send_verification_email(email, email, verification_token, base_url, config.aisbf.smtp if config.aisbf.smtp else None)
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
except Exception as e:
logger.error(f"Failed to send verification email: {e}")
return templates.TemplateResponse(
request=request,
name="dashboard/signup.html",
context={"request": request, "message": "Account already exists but not verified. A new verification email has been sent.", "config": config.aisbf if config and config.aisbf else {}}
)
return templates.TemplateResponse(
request=request,
name="dashboard/signup.html",
context={"request": request, "message": "Account already exists but not verified. A new verification email has been sent."}
)
# Create new user
# Hash password
password_hash = hash_password(password)
verification_token = generate_verification_token()
user_id = db.create_user(email, password_hash, verification_token)
# Create user
user_id = db.create_user(username=username, password_hash=password_hash, role='user', email=email, email_verified=False)
# Set verification token
expires_at = datetime.now() + timedelta(hours=24)
db.set_verification_token(user_id, verification_token, expires_at)
db.update_last_verification_email_sent(user_id, datetime.now())
# Send verification email
try:
base_url = get_base_url(request)
verification_url = f"{base_url}/dashboard/verify-email?token={verification_token}&email={email}"
send_verification_email(email, verification_url, config.aisbf.smtp if config.aisbf.smtp else None)
send_verification_email(email, email, verification_token, base_url, config.aisbf.smtp if config.aisbf.smtp else None)
return templates.TemplateResponse(
request=request,
name="dashboard/signup.html",
context={"request": request, "message": "Account created successfully! Please check your email to verify your account."}
)
return RedirectResponse(url=url_for(request, "/dashboard/login") + "?signup=success", status_code=303)
except Exception as e:
logger.error(f"Failed to send verification email: {e}")
# Still create user but inform them about email issue
return templates.TemplateResponse(
request=request,
name="dashboard/signup.html",
context={"request": request, "message": "Account created successfully! However, there was an issue sending the verification email. Please contact an administrator."}
context={"request": request, "message": "Account created successfully! However, there was an issue sending the verification email. Please contact an administrator.", "config": config.aisbf if config and config.aisbf else {}}
)
except Exception as e:
......@@ -1880,9 +2044,123 @@ async def dashboard_signup(
return templates.TemplateResponse(
request=request,
name="dashboard/signup.html",
context={"request": request, "error": "An error occurred during signup. Please try again."}
context={"request": request, "error": "An error occurred during signup. Please try again.", "config": config.aisbf if config and config.aisbf else {}}
)
@app.get("/dashboard/verify")
async def verify_email_page(request: Request):
"""Show email verification page"""
from aisbf.database import get_database
import logging
logger = logging.getLogger(__name__)
# Check if user is logged in
if not request.session.get('logged_in'):
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
user_id = request.session.get('user_id')
if not user_id:
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
db = DatabaseRegistry.get_config_database()
user = db.get_user_by_id(user_id)
if not user or user['email_verified']:
return RedirectResponse(url=url_for(request, "/dashboard"), status_code=303)
# Check if can resend (last sent > 10 min ago)
can_resend = True
if user.get('last_verification_email_sent'):
from datetime import datetime, timedelta
if isinstance(user['last_verification_email_sent'], str):
last_sent = datetime.fromisoformat(user['last_verification_email_sent'])
else:
# Already a datetime object
last_sent = user['last_verification_email_sent']
if datetime.now() - last_sent < timedelta(minutes=10):
can_resend = False
# Render verify page
return templates.TemplateResponse(
request=request,
name="dashboard/verify.html",
context={
"request": request,
"user": user,
"can_resend": can_resend,
"config": config.aisbf if config and config.aisbf else {}
}
)
@app.post("/dashboard/resend-verification")
async def resend_verification(request: Request):
"""Resend verification email"""
from aisbf.database import get_database
from aisbf.email_utils import send_verification_email, generate_verification_token
import logging
logger = logging.getLogger(__name__)
# Check if user is logged in
if not request.session.get('logged_in'):
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
user_id = request.session.get('user_id')
if not user_id:
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
db = DatabaseRegistry.get_config_database()
user = db.get_user_by_id(user_id)
if not user or user['email_verified']:
return RedirectResponse(url=url_for(request, "/dashboard"), status_code=303)
# Check if can resend
if user.get('last_verification_email_sent'):
from datetime import datetime, timedelta
if isinstance(user['last_verification_email_sent'], str):
last_sent = datetime.fromisoformat(user['last_verification_email_sent'])
else:
# Already a datetime object
last_sent = user['last_verification_email_sent']
if datetime.now() - last_sent < timedelta(minutes=10):
return templates.TemplateResponse(
request=request,
name="dashboard/verify.html",
context={
"request": request,
"user": user,
"can_resend": False,
"error": "Please wait 10 minutes before requesting another verification email.",
"config": config.aisbf if config and config.aisbf else {}
}
)
# Generate new token and send
verification_token = generate_verification_token()
expires_at = datetime.now() + timedelta(hours=24)
db.set_verification_token(user_id, verification_token, expires_at)
db.update_last_verification_email_sent(user_id, datetime.now())
try:
base_url = get_base_url(request)
send_verification_email(user['email'], user['username'], verification_token, base_url, config.aisbf.smtp if config.aisbf.smtp else None)
message = "Verification email sent successfully!"
except Exception as e:
logger.error(f"Failed to send verification email: {e}")
message = "Failed to send verification email. Please try again later."
return templates.TemplateResponse(
request=request,
name="dashboard/verify.html",
context={
"request": request,
"user": user,
"can_resend": False, # Just sent, can't resend immediately
"message": message,
"config": config.aisbf if config and config.aisbf else {}
}
)
@app.get("/dashboard/verify-email")
async def verify_email(request: Request, token: str, email: str):
......@@ -1893,31 +2171,277 @@ async def verify_email(request: Request, token: str, email: str):
logger = logging.getLogger(__name__)
try:
db = get_database()
db = DatabaseRegistry.get_config_database()
# Verify the token
if db.verify_email_token(email, token):
# Token is valid, mark email as verified
db.verify_email(email)
# If user is already logged in, update their session
if request.session.get('logged_in'):
request.session['email_verified'] = True
# Redirect to login page with success message
return RedirectResponse(url=url_for(request, "/dashboard/login") + "?success=Email verified successfully! You can now log in.", status_code=303)
else:
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={"request": request, "message": "Email verified successfully! You can now log in."}
context={
"request": request,
"error": "Invalid or expired verification token",
"config": config.aisbf if config and config.aisbf else {}
}
)
else:
except Exception as e:
logger.error(f"Error during email verification: {e}", exc_info=True)
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={
"request": request,
"error": "An error occurred during email verification",
"config": config.aisbf if config and config.aisbf else {}
}
)
@app.get("/dashboard/forgot-password", response_class=HTMLResponse)
async def dashboard_forgot_password_page(request: Request):
"""Show forgot password page"""
import logging
logger = logging.getLogger(__name__)
try:
# Check if SMTP is configured
smtp_enabled = False
if config and hasattr(config, 'aisbf') and config.aisbf and hasattr(config.aisbf, 'smtp'):
smtp_enabled = getattr(config.aisbf.smtp, 'enabled', False)
if not smtp_enabled:
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
# 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
# Get and render template
template = env.get_template("dashboard/forgot_password.html")
html_content = template.render(request=request, config=config.aisbf if config and config.aisbf else {})
return HTMLResponse(content=html_content)
except Exception as e:
logger.error(f"Error rendering forgot password page: {e}", exc_info=True)
raise
@app.post("/dashboard/forgot-password")
async def dashboard_forgot_password(request: Request, email: str = Form(...)):
"""Handle forgot password request"""
from aisbf.database import get_database
from aisbf.email_utils import generate_password_reset_token, send_password_reset_email
import logging
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
# Check if SMTP is configured
smtp_enabled = False
if config and hasattr(config, 'aisbf') and config.aisbf and hasattr(config.aisbf, 'smtp'):
smtp_enabled = getattr(config.aisbf.smtp, 'enabled', False)
if not smtp_enabled:
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
try:
db = DatabaseRegistry.get_config_database()
# Check if user exists
user = db.get_user_by_email(email)
if user and user['email_verified']:
# Generate password reset token (1 hour expiry)
reset_token = generate_password_reset_token()
expires_at = datetime.now() + timedelta(hours=1)
# Store token in database
db.set_password_reset_token(user['id'], reset_token, expires_at)
# Send reset email
try:
base_url = get_base_url(request)
success = send_password_reset_email(
to_email=email,
username=user.get('username', email),
reset_token=reset_token,
base_url=base_url,
smtp_config=config.aisbf.smtp
)
if success:
logger.info(f"Password reset email sent to {email}")
else:
logger.error(f"Failed to send password reset email to {email}")
except Exception as e:
logger.error(f"Failed to send password reset email: {e}")
# Always return the same message regardless of whether user exists (security best practice)
return templates.TemplateResponse(
request=request,
name="dashboard/forgot_password.html",
context={
"request": request,
"success": True,
"message": "If an account exists with that email address, we have sent a password reset link.",
"message_type": "success"
}
)
except Exception as e:
logger.error(f"Error processing forgot password request: {e}", exc_info=True)
return templates.TemplateResponse(
request=request,
name="dashboard/forgot_password.html",
context={
"request": request,
"error": "An error occurred processing your request. Please try again later.",
"config": config.aisbf if config and config.aisbf else {}
}
)
@app.get("/dashboard/reset-password", response_class=HTMLResponse)
async def dashboard_reset_password_page(request: Request, token: str = Query(...), email: str = Query(...)):
"""Show password reset page"""
import logging
logger = logging.getLogger(__name())
try:
db = DatabaseRegistry.get_config_database()
# Validate token
token_valid = db.validate_password_reset_token(email, token)
if not token_valid:
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={"request": request, "error": "Invalid or expired verification token"}
context={
"request": request,
"error": "Invalid or expired password reset token. Please request a new one.",
"config": config.aisbf if config and config.aisbf else {}
}
)
# 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
# Get and render template
template = env.get_template("dashboard/reset_password.html")
html_content = template.render(request=request, email=email, token=token, config=config.aisbf if config and config.aisbf else {})
return HTMLResponse(content=html_content)
except Exception as e:
logger.error(f"Error during email verification: {e}", exc_info=True)
logger.error(f"Error rendering reset password page: {e}", exc_info=True)
raise
@app.post("/dashboard/reset-password")
async def dashboard_reset_password(
request: Request,
email: str = Form(...),
token: str = Form(...),
password: str = Form(...),
confirm_password: str = Form(...)
):
"""Handle password reset confirmation"""
from aisbf.database import get_database
from aisbf.email_utils import hash_password
import logging
logger = logging.getLogger(__name__)
try:
db = DatabaseRegistry.get_config_database()
# Validate token
token_valid = db.validate_password_reset_token(email, token)
if not token_valid:
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={
"request": request,
"error": "Invalid or expired password reset token. Please request a new one.",
"config": config.aisbf if config and config.aisbf else {}
}
)
# Validate passwords match
if password != confirm_password:
return templates.TemplateResponse(
request=request,
name="dashboard/reset_password.html",
context={
"request": request,
"email": email,
"token": token,
"error": "Passwords do not match",
"config": config.aisbf if config and config.aisbf else {}
}
)
# Validate password strength (minimum 8 characters)
if len(password) < 8:
return templates.TemplateResponse(
request=request,
name="dashboard/reset_password.html",
context={
"request": request,
"email": email,
"token": token,
"error": "Password must be at least 8 characters long",
"config": config.aisbf if config and config.aisbf else {}
}
)
# Hash new password
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)
logger.info(f"Password successfully reset for user {email}")
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={"request": request, "error": "An error occurred during email verification"}
context={
"request": request,
"message": "Password has been reset successfully. You can now login with your new password.",
"config": config.aisbf if config and config.aisbf else {}
}
)
except Exception as e:
logger.error(f"Error processing password reset: {e}", exc_info=True)
return templates.TemplateResponse(
request=request,
name="dashboard/reset_password.html",
context={
"request": request,
"email": email,
"token": token,
"error": "An error occurred resetting your password. Please try again later.",
"config": config.aisbf if config and config.aisbf else {}
}
)
@app.get("/dashboard/logout")
......@@ -1936,7 +2460,7 @@ async def dashboard_profile(request: Request):
# User dashboard - load usage stats same as main dashboard user route
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
user_id = request.session.get('user_id')
# Get user statistics
......@@ -1983,21 +2507,20 @@ async def dashboard_profile(request: Request):
@app.post("/dashboard/profile")
async def dashboard_profile_save(request: Request, username: str = Form(...), email: str = Form(...)):
"""Save user profile changes"""
async def dashboard_profile_save(request: Request, username: str = Form(...)):
"""Save user profile changes (username only)"""
auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse):
return auth_check
from aisbf.database import get_database
user_id = request.session.get('user_id')
db = get_database()
db = DatabaseRegistry.get_config_database()
try:
db.update_user_profile(user_id, username, email)
db.update_user_profile(user_id, username, None)
# Update session with new username
request.session['username'] = username
request.session['email'] = email
return RedirectResponse(url=url_for(request, "/dashboard/profile?success=Profile updated successfully"), status_code=303)
except Exception as e:
......@@ -2013,7 +2536,7 @@ async def dashboard_change_password(request: Request):
# User dashboard - load usage stats same as main dashboard user route
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
user_id = request.session.get('user_id')
# Get user statistics
......@@ -2068,7 +2591,7 @@ async def dashboard_change_password_save(request: Request, current_password: str
from aisbf.database import get_database
user_id = request.session.get('user_id')
db = get_database()
db = DatabaseRegistry.get_config_database()
if new_password != confirm_password:
return RedirectResponse(url=url_for(request, "/dashboard/change-password?error=New passwords do not match"), status_code=303)
......@@ -2090,6 +2613,203 @@ async def dashboard_change_password_save(request: Request, current_password: str
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
@app.get("/dashboard/change-email", response_class=HTMLResponse)
async def dashboard_change_email(request: Request):
"""Change email page"""
auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse):
return auth_check
return templates.TemplateResponse(
request=request,
name="dashboard/change_email.html",
context={
"session": request.session,
"success": request.query_params.get('success'),
"error": request.query_params.get('error')
}
)
@app.post("/dashboard/change-email")
async def dashboard_change_email_save(request: Request, new_email: str = Form(...), password: str = Form(...)):
"""Process email change request"""
auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse):
return auth_check
from aisbf.database import get_database
from aisbf.email_utils import send_email_verification, hash_password
import secrets
user_id = request.session.get('user_id')
db = DatabaseRegistry.get_config_database()
try:
# Verify current password
if not db.verify_user_password(user_id, password):
return RedirectResponse(url=url_for(request, "/dashboard/change-email?error=Incorrect password"), status_code=303)
# Check if new email is already in use
existing_user = db.get_user_by_email(new_email)
if existing_user and existing_user['id'] != user_id:
return RedirectResponse(url=url_for(request, "/dashboard/change-email?error=Email address already in use"), status_code=303)
# Generate verification token
token = secrets.token_urlsafe(32)
expires_at = datetime.now() + timedelta(hours=24)
# Store pending email change in session (we'll update after verification)
request.session['pending_email_change'] = {
'new_email': new_email,
'token': token,
'expires_at': expires_at.isoformat()
}
# Send verification email to new address
base_url = get_base_url(request)
verification_url = f"{base_url}/dashboard/verify-email-change?token={token}&email={new_email}"
if config and config.aisbf and config.aisbf.smtp and config.aisbf.smtp.enabled:
send_email_verification(
new_email,
verification_url,
config.aisbf.smtp
)
return RedirectResponse(
url=url_for(request, "/dashboard/change-email?success=Verification email sent to new address. Please check your inbox."),
status_code=303
)
else:
return RedirectResponse(
url=url_for(request, "/dashboard/change-email?error=Email service not configured. Please contact administrator."),
status_code=303
)
except Exception as e:
logger.error(f"Email change error: {e}")
return RedirectResponse(url=url_for(request, f"/dashboard/change-email?error=Failed to process email change: {str(e)}"), status_code=303)
@app.get("/dashboard/verify-email-change")
async def verify_email_change(request: Request, token: str = Query(...), email: str = Query(...)):
"""Verify new email address"""
from aisbf.database import get_database
db = DatabaseRegistry.get_config_database()
user_id = request.session.get('user_id')
if not user_id:
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
try:
# Check pending email change in session
pending = request.session.get('pending_email_change', {})
if not pending or pending.get('token') != token or pending.get('new_email') != email:
return templates.TemplateResponse(
request=request,
name="dashboard/change_email.html",
context={
"session": request.session,
"error": "Invalid or expired verification link"
}
)
# Check expiration
expires_at = datetime.fromisoformat(pending['expires_at'])
if datetime.now() > expires_at:
return templates.TemplateResponse(
request=request,
name="dashboard/change_email.html",
context={
"session": request.session,
"error": "Verification link has expired"
}
)
# Update email
db.update_user_email(user_id, email)
request.session['email'] = email
# Clear pending change
request.session.pop('pending_email_change', None)
return RedirectResponse(
url=url_for(request, "/dashboard/profile?success=Email address updated successfully"),
status_code=303
)
except Exception as e:
logger.error(f"Email verification error: {e}")
return templates.TemplateResponse(
request=request,
name="dashboard/change_email.html",
context={
"session": request.session,
"error": f"Failed to verify email: {str(e)}"
}
)
@app.get("/dashboard/delete-account", response_class=HTMLResponse)
async def dashboard_delete_account(request: Request):
"""Delete account confirmation page"""
auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse):
return auth_check
from aisbf.database import get_database
user_id = request.session.get('user_id')
db = DatabaseRegistry.get_config_database()
# Check for active subscription
subscription = db.get_user_subscription(user_id)
has_subscription = subscription is not None and subscription.get('status') == 'active'
subscription_tier = subscription.get('tier_name', '') if subscription else ''
return templates.TemplateResponse(
request=request,
name="dashboard/delete_account.html",
context={
"session": request.session,
"error": request.query_params.get('error'),
"has_subscription": has_subscription,
"subscription_tier": subscription_tier
}
)
@app.post("/dashboard/delete-account")
async def dashboard_delete_account_confirm(request: Request, password: str = Form(...), confirmation: str = Form(...)):
"""Process account deletion"""
auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse):
return auth_check
from aisbf.database import get_database
user_id = request.session.get('user_id')
db = DatabaseRegistry.get_config_database()
try:
# Verify confirmation text
if confirmation != "DELETE":
return RedirectResponse(url=url_for(request, "/dashboard/delete-account?error=Please type DELETE to confirm"), status_code=303)
# Verify password
if not db.verify_user_password(user_id, password):
return RedirectResponse(url=url_for(request, "/dashboard/delete-account?error=Incorrect password"), status_code=303)
# Delete user (this will cascade delete all related data)
db.delete_user(user_id)
# Clear session
request.session.clear()
return RedirectResponse(url=url_for(request, "/dashboard/login?message=Account deleted successfully"), status_code=303)
except Exception as e:
logger.error(f"Account deletion error: {e}")
return RedirectResponse(url=url_for(request, f"/dashboard/delete-account?error=Failed to delete account: {str(e)}"), status_code=303)
# ==============================================
# OAuth2 Authentication Endpoints (Google + GitHub)
# ==============================================
......@@ -2187,7 +2907,7 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state
email = user_info.get('email')
email_verified = user_info.get('email_verified', False)
db = get_database()
db = DatabaseRegistry.get_config_database()
# Lookup existing user
existing_user = db.get_user_by_email(email)
......@@ -2196,6 +2916,7 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state
# Existing user - login directly
request.session['logged_in'] = True
request.session['username'] = existing_user['username']
request.session['email'] = existing_user.get('email', '')
request.session['role'] = existing_user['role']
request.session['user_id'] = existing_user['id']
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
......@@ -2219,6 +2940,7 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state
# Login the new user
request.session['logged_in'] = True
request.session['username'] = email
request.session['email'] = email
request.session['role'] = 'user'
request.session['user_id'] = user_id
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
......@@ -2230,11 +2952,11 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state
return RedirectResponse(url=url_for(request, "/dashboard"), status_code=303)
except Exception as e:
logger.error(f"Google OAuth2 callback failed: {e}", exc_info=True)
logger.error(f"Error during email verification: {e}", exc_info=True)
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={"request": request, "error": "Authentication failed. Please try again."}
context={"request": request, "error": "An error occurred during email verification", "config": config.aisbf if config and config.aisbf else {}}
)
......@@ -2325,7 +3047,7 @@ async def oauth2_github_callback(request: Request, code: str = Query(...), state
email = user_info.get('email')
db = get_database()
db = DatabaseRegistry.get_config_database()
# Lookup existing user
existing_user = db.get_user_by_email(email)
......@@ -2334,6 +3056,7 @@ async def oauth2_github_callback(request: Request, code: str = Query(...), state
# Existing user - login directly
request.session['logged_in'] = True
request.session['username'] = existing_user['username']
request.session['email'] = existing_user.get('email', '')
request.session['role'] = existing_user['role']
request.session['user_id'] = existing_user['id']
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
......@@ -2343,13 +3066,14 @@ async def oauth2_github_callback(request: Request, code: str = Query(...), state
random_password = secrets.token_urlsafe(32)
password_hash = hashlib.sha256(random_password.encode()).hexdigest()
# Create user with verified email (GitHub emails are always verified)
# Create user with verified email (no verification required)
user_id = db.create_user(email, password_hash, None)
db.verify_email(email) # Mark email as verified automatically
# Login the new user
request.session['logged_in'] = True
request.session['username'] = email
request.session['email'] = email
request.session['role'] = 'user'
request.session['user_id'] = user_id
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
......@@ -2426,7 +3150,7 @@ async def dashboard_index(request: Request):
else:
# User dashboard - show user stats
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
user_id = request.session.get('user_id')
# Get user statistics
......@@ -2504,7 +3228,7 @@ async def dashboard_providers(request: Request):
else:
# Database user: load from database
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
user_providers = db.get_user_providers(current_user_id)
# Convert to the format expected by the frontend
......@@ -2701,7 +3425,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
else:
# Database user: save to database
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
# Save each provider to database
for provider_key, provider_config in providers_data.items():
......@@ -2859,7 +3583,7 @@ async def dashboard_rotations(request: Request):
else:
# Database user: load from database
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
user_rotations = db.get_user_rotations(current_user_id)
# Convert to the format expected by the frontend
......@@ -2919,7 +3643,7 @@ async def dashboard_rotations_save(request: Request, config: str = Form(...)):
else:
# Database user: save to database
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
# Save each rotation to database
rotations = rotations_data.get('rotations', {})
......@@ -2956,7 +3680,7 @@ async def dashboard_rotations_save(request: Request, config: str = Form(...)):
rotations_data = json.load(f)
else:
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
user_rotations = db.get_user_rotations(current_user_id)
rotations_data = {"rotations": {}, "notifyerrors": False}
for rotation in user_rotations:
......@@ -2998,7 +3722,7 @@ async def dashboard_autoselect(request: Request):
else:
# Database user: load from database
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
user_autoselects = db.get_user_autoselects(current_user_id)
# Convert to the format expected by the frontend
......@@ -3079,7 +3803,7 @@ async def dashboard_autoselect_save(request: Request, config: str = Form(...)):
else:
# Database user: save to database
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
# Save each autoselect to database
for autoselect_key, autoselect_config in autoselect_data.items():
......@@ -3115,7 +3839,7 @@ async def dashboard_autoselect_save(request: Request, config: str = Form(...)):
autoselect_data = json.load(f)
else:
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
user_autoselects = db.get_user_autoselects(current_user_id)
autoselect_data = {}
for autoselect in user_autoselects:
......@@ -3154,7 +3878,7 @@ async def dashboard_prompts(request: Request):
prompts_data = []
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
for prompt_file in prompt_files:
content = None
......@@ -3266,7 +3990,7 @@ async def dashboard_prompts_save(request: Request, prompt_key: str = Form(...),
else:
# Regular user saves to database
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
db.save_user_prompt(user_id, prompt_key, prompt_content)
return RedirectResponse(url=url_for(request, "/dashboard/prompts?success=1"), status_code=303)
......@@ -3283,7 +4007,7 @@ async def dashboard_prompts_reset(request: Request, prompt_key: str):
return JSONResponse({"success": False, "error": "Not authenticated"}, status_code=401)
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
db.delete_user_prompt(user_id, prompt_key)
return JSONResponse({"success": True})
......@@ -3393,6 +4117,21 @@ async def dashboard_settings_save(
redis_db: int = Form(0),
redis_password: str = Form(""),
redis_key_prefix: str = Form("aisbf:"),
response_cache_enabled: bool = Form(False),
response_cache_backend: str = Form("memory"),
response_cache_ttl: int = Form(600),
response_cache_max_memory: int = Form(1000),
response_cache_redis_host: str = Form("localhost"),
response_cache_redis_port: int = Form(6379),
response_cache_redis_db: int = Form(0),
response_cache_redis_password: str = Form(""),
response_cache_redis_key_prefix: str = Form("aisbf:response:"),
response_cache_sqlite_path: str = Form("~/.aisbf/response_cache.db"),
response_cache_mysql_host: str = Form("localhost"),
response_cache_mysql_port: int = Form(3306),
response_cache_mysql_user: str = Form("aisbf"),
response_cache_mysql_password: str = Form(""),
response_cache_mysql_database: str = Form("aisbf_response_cache"),
mcp_enabled: bool = Form(False),
autoselect_tokens: str = Form(""),
fullconfig_tokens: str = Form(""),
......@@ -3405,18 +4144,23 @@ async def dashboard_settings_save(
tor_socks_port: int = Form(9050),
tor_socks_host: str = Form("127.0.0.1"),
signup_enabled: bool = Form(False),
smtp_server: str = Form(""),
signup_require_verification: bool = Form(False),
verification_token_expiry: int = Form(24),
smtp_host: str = Form(""),
smtp_port: int = Form(587),
smtp_username: str = Form(""),
smtp_password: str = Form(""),
smtp_use_tls: bool = Form(True),
smtp_from_address: str = Form(""),
smtp_use_ssl: bool = Form(False),
smtp_from_email: str = Form(""),
smtp_from_name: str = Form(""),
oauth2_google_enabled: bool = Form(False),
oauth2_google_client_id: str = Form(""),
oauth2_google_client_secret: str = Form(""),
oauth2_github_enabled: bool = Form(False),
oauth2_github_client_id: str = Form(""),
oauth2_github_client_secret: str = Form("")
oauth2_github_client_secret: str = Form(""),
smtp_enabled: bool = Form(False)
):
"""Save server settings"""
auth_check = require_admin(request)
......@@ -3467,6 +4211,33 @@ async def dashboard_settings_save(
aisbf_config['cache']['redis_password'] = redis_password
aisbf_config['cache']['redis_key_prefix'] = redis_key_prefix
# Update response cache config
if 'response_cache' not in aisbf_config:
aisbf_config['response_cache'] = {}
aisbf_config['response_cache']['enabled'] = response_cache_enabled
aisbf_config['response_cache']['backend'] = response_cache_backend
aisbf_config['response_cache']['ttl'] = response_cache_ttl
aisbf_config['response_cache']['max_memory_cache'] = response_cache_max_memory
# Response cache Redis settings
aisbf_config['response_cache']['redis_host'] = response_cache_redis_host
aisbf_config['response_cache']['redis_port'] = response_cache_redis_port
aisbf_config['response_cache']['redis_db'] = response_cache_redis_db
if response_cache_redis_password: # Only update if provided
aisbf_config['response_cache']['redis_password'] = response_cache_redis_password
aisbf_config['response_cache']['redis_key_prefix'] = response_cache_redis_key_prefix
# Response cache SQLite settings
aisbf_config['response_cache']['sqlite_path'] = response_cache_sqlite_path
# Response cache MySQL settings
aisbf_config['response_cache']['mysql_host'] = response_cache_mysql_host
aisbf_config['response_cache']['mysql_port'] = response_cache_mysql_port
aisbf_config['response_cache']['mysql_user'] = response_cache_mysql_user
if response_cache_mysql_password: # Only update if provided
aisbf_config['response_cache']['mysql_password'] = response_cache_mysql_password
aisbf_config['response_cache']['mysql_database'] = response_cache_mysql_database
# Update MCP config
if 'mcp' not in aisbf_config:
aisbf_config['mcp'] = {}
......@@ -3486,6 +4257,62 @@ async def dashboard_settings_save(
aisbf_config['tor']['socks_port'] = tor_socks_port
aisbf_config['tor']['socks_host'] = tor_socks_host
# Update Signup config
if 'signup' not in aisbf_config:
aisbf_config['signup'] = {}
aisbf_config['signup']['enabled'] = signup_enabled
aisbf_config['signup']['require_email_verification'] = signup_require_verification
aisbf_config['signup']['verification_token_expiry_hours'] = verification_token_expiry
# Update SMTP config
if 'smtp' not in aisbf_config:
aisbf_config['smtp'] = {}
aisbf_config['smtp']['enabled'] = smtp_enabled
aisbf_config['smtp']['host'] = smtp_host
aisbf_config['smtp']['port'] = smtp_port
aisbf_config['smtp']['username'] = smtp_username
# Preserve existing password if submitted field is empty
if smtp_password:
aisbf_config['smtp']['password'] = smtp_password
elif 'password' not in aisbf_config['smtp']:
# Initialize as empty if not exists
aisbf_config['smtp']['password'] = ""
aisbf_config['smtp']['use_tls'] = smtp_use_tls
aisbf_config['smtp']['use_ssl'] = smtp_use_ssl
aisbf_config['smtp']['from_email'] = smtp_from_email
aisbf_config['smtp']['from_name'] = smtp_from_name
# Update OAuth2 config
if 'oauth2' not in aisbf_config:
aisbf_config['oauth2'] = {}
# Google OAuth2
if 'google' not in aisbf_config['oauth2']:
aisbf_config['oauth2']['google'] = {}
aisbf_config['oauth2']['google']['enabled'] = oauth2_google_enabled
aisbf_config['oauth2']['google']['client_id'] = oauth2_google_client_id
# Preserve existing client_secret if submitted field is empty
if oauth2_google_client_secret:
aisbf_config['oauth2']['google']['client_secret'] = oauth2_google_client_secret
elif 'client_secret' not in aisbf_config['oauth2']['google']:
aisbf_config['oauth2']['google']['client_secret'] = ""
aisbf_config['oauth2']['google']['scopes'] = [
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile"
]
# GitHub OAuth2
if 'github' not in aisbf_config['oauth2']:
aisbf_config['oauth2']['github'] = {}
aisbf_config['oauth2']['github']['enabled'] = oauth2_github_enabled
aisbf_config['oauth2']['github']['client_id'] = oauth2_github_client_id
# Preserve existing client_secret if submitted field is empty
if oauth2_github_client_secret:
aisbf_config['oauth2']['github']['client_secret'] = oauth2_github_client_secret
elif 'client_secret' not in aisbf_config['oauth2']['github']:
aisbf_config['oauth2']['github']['client_secret'] = ""
# Save config
config_path = Path.home() / '.aisbf' / 'aisbf.json'
config_path.parent.mkdir(parents=True, exist_ok=True)
......@@ -3503,6 +4330,42 @@ async def dashboard_settings_save(
}
)
@app.post("/dashboard/test-smtp")
async def dashboard_test_smtp(request: Request):
auth_check = require_admin(request)
if auth_check:
return auth_check
try:
body = await request.json()
from aisbf.email_utils import send_test_email
# Send test email to specified recipient
test_recipient = body.get('test_recipient')
if not test_recipient:
return JSONResponse({"success": False, "error": "Test recipient email is required"})
# Load the actual saved SMTP config from aisbf.json
config_path = Path.home() / '.aisbf' / 'aisbf.json'
if not config_path.exists():
config_path = Path(__file__).parent / 'config' / 'aisbf.json'
with open(config_path) as f:
aisbf_config = json.load(f)
smtp_config = aisbf_config.get('smtp', {})
result = send_test_email(test_recipient, smtp_config)
if result:
return JSONResponse({"success": True})
else:
return JSONResponse({"success": False, "error": "Failed to send test email"})
except Exception as e:
logger.error(f"Error testing SMTP: {e}")
return JSONResponse({"success": False, "error": str(e)})
# Admin user management routes
@app.get("/dashboard/users", response_class=HTMLResponse)
async def dashboard_users(request: Request):
......@@ -3512,7 +4375,7 @@ async def dashboard_users(request: Request):
return auth_check
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
# Get all users
users = db.get_users()
......@@ -3535,7 +4398,7 @@ async def dashboard_users_add(request: Request, username: str = Form(...), passw
return auth_check
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
# Hash the password
password_hash = hashlib.sha256(password.encode()).hexdigest()
......@@ -3566,7 +4429,7 @@ async def dashboard_users_edit(request: Request, user_id: int, username: str = F
return auth_check
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
try:
# Update user (only if password is provided)
......@@ -3597,7 +4460,7 @@ async def dashboard_users_toggle(request: Request, user_id: int):
return auth_check
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
try:
users = db.get_users()
......@@ -3618,7 +4481,7 @@ async def dashboard_users_delete(request: Request, user_id: int):
return auth_check
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
try:
db.delete_user(user_id)
......@@ -3643,8 +4506,8 @@ async def dashboard_restart(request: Request):
# Re-initialize database if config changed
db_config = config.aisbf.database if config.aisbf and config.aisbf.database else None
if db_config:
from aisbf.database import initialize_database
initialize_database(db_config)
from aisbf.database import DatabaseRegistry
DatabaseRegistry.get_config_database(db_config)
# Re-initialize cache if config changed
cache_config = config.aisbf.cache if config.aisbf and config.aisbf.cache else None
......@@ -3679,7 +4542,7 @@ async def dashboard_user_providers(request: Request):
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
# Get user-specific providers
user_providers = db.get_user_providers(user_id)
......@@ -3707,7 +4570,7 @@ async def dashboard_user_providers_save(request: Request, provider_name: str = F
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
try:
# Validate JSON
......@@ -3744,7 +4607,7 @@ async def dashboard_user_providers_delete(request: Request, provider_name: str):
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
try:
db.delete_user_provider(user_id, provider_name)
......@@ -3778,7 +4641,7 @@ async def dashboard_user_provider_upload(
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
try:
# Validate file type
......@@ -3838,7 +4701,7 @@ async def dashboard_user_provider_files(request: Request, provider_name: str):
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
try:
files = db.get_user_auth_files(user_id, provider_name)
......@@ -3864,7 +4727,7 @@ async def dashboard_user_provider_file_download(
from aisbf.database import get_database
from fastapi.responses import FileResponse
db = get_database()
db = DatabaseRegistry.get_config_database()
try:
file_info = db.get_user_auth_file(user_id, provider_name, file_type)
......@@ -3900,7 +4763,7 @@ async def dashboard_user_provider_file_delete(
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
try:
file_info = db.get_user_auth_file(user_id, provider_name, file_type)
......@@ -3978,7 +4841,7 @@ async def dashboard_provider_upload(
else:
# Database user: save to database
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
# Get user auth files directory
auth_files_dir = get_user_auth_files_dir(current_user_id)
......@@ -4072,7 +4935,7 @@ async def dashboard_provider_upload_form(
else:
# Database user: save to database
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
# Get user auth files directory
auth_files_dir = get_user_auth_files_dir(current_user_id)
......@@ -4183,7 +5046,7 @@ async def dashboard_provider_upload_chunk(
# Save metadata to database if not admin
if not is_config_admin:
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
db.save_user_auth_file(
user_id=current_user_id,
provider_id=provider_key,
......@@ -4322,7 +5185,7 @@ async def dashboard_user_rotations(request: Request):
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
# Get user-specific rotations
user_rotations = db.get_user_rotations(user_id)
......@@ -4350,7 +5213,7 @@ async def dashboard_user_rotations_save(request: Request, rotation_name: str = F
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
try:
# Validate JSON
......@@ -4387,7 +5250,7 @@ async def dashboard_user_rotations_delete(request: Request, rotation_name: str):
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
try:
db.delete_user_rotation(user_id, rotation_name)
......@@ -4408,7 +5271,7 @@ async def dashboard_user_autoselects(request: Request):
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
# Get user-specific autoselects
user_autoselects = db.get_user_autoselects(user_id)
......@@ -4436,7 +5299,7 @@ async def dashboard_user_autoselects_save(request: Request, autoselect_name: str
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
try:
# Validate JSON
......@@ -4473,7 +5336,7 @@ async def dashboard_user_autoselects_delete(request: Request, autoselect_name: s
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
try:
db.delete_user_autoselect(user_id, autoselect_name)
......@@ -4536,7 +5399,7 @@ async def dashboard_user_tokens(request: Request):
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
# Get user API tokens
user_tokens = db.get_user_api_tokens(user_id)
......@@ -4566,7 +5429,7 @@ async def dashboard_user_tokens_create(request: Request, description: str = Form
from aisbf.database import get_database
import secrets
db = get_database()
db = DatabaseRegistry.get_config_database()
# Generate a secure token
token = secrets.token_urlsafe(32)
......@@ -4593,7 +5456,7 @@ async def dashboard_user_tokens_delete(request: Request, token_id: int):
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
try:
db.delete_user_api_token(user_id, token_id)
......@@ -4659,7 +5522,7 @@ async def dashboard_admin_tiers(request: Request):
return auth_check
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
tiers = db.get_all_tiers()
......@@ -4682,7 +5545,7 @@ async def api_list_tiers(request: Request):
return auth_check
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
tiers = db.get_all_tiers()
return JSONResponse(tiers)
......@@ -4695,7 +5558,7 @@ async def api_get_tier(request: Request, tier_id: int):
return auth_check
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
tier = db.get_tier_by_id(tier_id)
if not tier:
......@@ -4711,7 +5574,7 @@ async def api_create_tier(request: Request):
return auth_check
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
try:
body = await request.json()
......@@ -4744,7 +5607,7 @@ async def api_update_tier(request: Request, tier_id: int):
return auth_check
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
try:
body = await request.json()
......@@ -4794,7 +5657,7 @@ async def api_delete_tier(request: Request, tier_id: int):
return auth_check
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
try:
success = db.delete_tier(tier_id)
......@@ -4833,7 +5696,7 @@ async def dashboard_admin_tier_edit(request: Request, tier_id: int):
return auth_check
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
tier = db.get_tier_by_id(tier_id)
if not tier:
......@@ -4857,7 +5720,7 @@ async def dashboard_admin_tier_save(request: Request):
return auth_check
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
try:
form = await request.form()
......@@ -4967,7 +5830,7 @@ async def dashboard_pricing(request: Request):
return auth_check
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
tiers = db.get_all_tiers()
current_tier = db.get_user_tier(request.session.get('user_id'))
......@@ -5006,7 +5869,7 @@ async def dashboard_subscription(request: Request):
return auth_check
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
user_id = request.session.get('user_id')
# Get user subscription info
......@@ -5049,7 +5912,7 @@ async def dashboard_billing(request: Request):
return auth_check
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
user_id = request.session.get('user_id')
transactions = db.get_user_payment_transactions(user_id)
......@@ -5458,7 +6321,7 @@ async def list_all_models(request: Request):
# Try to authenticate user
try:
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
token = auth_header.split(" ")[1]
user = db.get_user_by_token(token)
if user:
......@@ -5525,7 +6388,7 @@ async def v1_list_all_models(request: Request):
# Try to authenticate user
try:
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
token = auth_header.split(" ")[1]
user = db.get_user_by_token(token)
if user:
......@@ -6999,7 +7862,7 @@ async def user_list_models_by_username(request: Request, username: str):
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/u/{username}/models
"""
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
# Get target user by username
target_user = db.get_user_by_username(username)
......@@ -7646,7 +8509,7 @@ async def user_list_providers_by_username(request: Request, username: str):
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/u/{username}/providers
"""
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
target_user = db.get_user_by_username(username)
if not target_user:
......@@ -7765,7 +8628,7 @@ async def user_list_rotations_by_username(request: Request, username: str):
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/u/{username}/rotations
"""
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
target_user = db.get_user_by_username(username)
if not target_user:
......@@ -7845,7 +8708,7 @@ async def user_list_autoselects_by_username(request: Request, username: str):
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/u/{username}/autoselects
"""
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
target_user = db.get_user_by_username(username)
if not target_user:
......@@ -7946,7 +8809,7 @@ async def user_chat_completions_by_username(request: Request, username: str, bod
http://localhost:17765/api/u/{username}/chat/completions
"""
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
target_user = db.get_user_by_username(username)
if not target_user:
......@@ -8085,7 +8948,7 @@ async def user_list_config_models_by_username(request: Request, username: str, c
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/u/{username}/rotations/models
"""
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
target_user = db.get_user_by_username(username)
if not target_user:
......@@ -8858,7 +9721,7 @@ async def dashboard_claude_auth_complete(request: Request):
# Non-config-admin user: save credentials to database
try:
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
provider_key = request.session.get('oauth2_provider')
if db and current_user_id and provider_key:
# Read the credentials that were just saved to file
......@@ -8974,7 +9837,7 @@ async def dashboard_claude_auth_status(request: Request):
# Non-config-admin user: check database for credentials
try:
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
if db and current_user_id:
db_creds = db.get_user_oauth2_credentials(
user_id=current_user_id,
......@@ -9152,7 +10015,7 @@ async def dashboard_kilo_auth_poll(request: Request):
# Non-config-admin user: save credentials to database
try:
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
provider_key = request.session.get('kilo_provider')
if db and current_user_id and provider_key:
# Save to database
......@@ -9261,7 +10124,7 @@ async def dashboard_kilo_auth_status(request: Request):
# Non-config-admin user: check database for credentials
try:
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
if db and current_user_id:
db_creds = db.get_user_oauth2_credentials(
user_id=current_user_id,
......@@ -9465,7 +10328,7 @@ async def dashboard_codex_auth_poll(request: Request):
# Non-admin user: save credentials to database instead of file
try:
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
provider_key = request.session.get('codex_provider')
if db and current_user_id and provider_key:
# Read the credentials that were just saved to file
......@@ -9578,7 +10441,7 @@ async def dashboard_codex_auth_status(request: Request):
# Non-config-admin user: check database for credentials
try:
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
if db and current_user_id:
db_creds = db.get_user_oauth2_credentials(
user_id=current_user_id,
......@@ -9831,7 +10694,7 @@ async def dashboard_qwen_auth_poll(request: Request):
# Non-config-admin user: also save credentials to database
try:
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
provider_key = request.session.get('qwen_provider')
if db and current_user_id and provider_key:
# Read the credentials that were just saved to file
......@@ -9944,7 +10807,7 @@ async def dashboard_qwen_auth_status(request: Request):
# Non-config-admin user: check database for credentials
try:
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
if db and current_user_id:
db_creds = db.get_user_oauth2_credentials(
user_id=current_user_id,
......@@ -10022,7 +10885,7 @@ async def dashboard_qwen_auth_logout(request: Request):
if current_user_id:
try:
from aisbf.database import get_database
db = get_database()
db = DatabaseRegistry.get_config_database()
if db:
db.delete_user_oauth2_credentials(current_user_id, provider_key, 'qwen_oauth2')
except Exception as e:
......
......@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "aisbf"
version = "0.99.20"
version = "0.99.21"
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"
......
......@@ -112,6 +112,7 @@ setup(
'aisbf/classifier.py',
'aisbf/streaming_optimization.py',
'aisbf/analytics.py',
'aisbf/email_utils.py',
]),
# aisbf.providers subpackage
('share/aisbf/aisbf/providers', [
......@@ -170,8 +171,13 @@ setup(
'templates/dashboard/rate_limits.html',
'templates/dashboard/users.html',
'templates/dashboard/signup.html',
'templates/dashboard/verify.html',
'templates/dashboard/forgot_password.html',
'templates/dashboard/reset_password.html',
'templates/dashboard/profile.html',
'templates/dashboard/change_password.html',
'templates/dashboard/change_email.html',
'templates/dashboard/delete_account.html',
'templates/dashboard/admin_tiers.html',
'templates/dashboard/admin_tier_form.html',
'templates/dashboard/pricing.html',
......
......@@ -124,6 +124,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</select>
</div>
{% if is_admin %}
<div style="flex: 1; min-width: 150px;">
<label style="display: block; margin-bottom: 5px; color: #a0a0a0; font-size: 14px;">User</label>
<select name="user_filter" style="width: 100%; padding: 10px; border-radius: 4px; background: #0f3460; color: white; border: 1px solid #2a4a7a;">
......@@ -133,6 +134,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% endfor %}
</select>
</div>
{% endif %}
<div>
<button type="submit" style="padding: 10px 20px; background: #3498db; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">
......
{% extends "base.html" %}
{% block title %}Change Email - AISBF{% endblock %}
{% block content %}
<div class="container">
<h1>Change Email Address</h1>
<p>Update your email address. You will need to verify the new email before it takes effect.</p>
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<div class="card">
<h2>Current Email</h2>
<p style="color: #a0a0a0; margin-bottom: 2rem;">{{ session.email }}</p>
<form method="POST" action="{{ url_for(request, '/dashboard/change-email') }}">
<div class="form-group">
<label for="new_email">New Email Address</label>
<input type="email" id="new_email" name="new_email" required>
</div>
<div class="form-group">
<label for="password">Current Password</label>
<input type="password" id="password" name="password" required>
<small style="color: #a0a0a0; display: block; margin-top: 0.5rem;">Confirm your password to proceed</small>
</div>
<div style="margin-top: 1.5rem; display: flex; gap: 1rem;">
<button type="submit" class="btn btn-primary">Send Verification Email</button>
<a href="{{ url_for(request, '/dashboard/profile') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
<style>
.container {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
}
.card {
background: #16213e;
padding: 2rem;
border-radius: 8px;
margin-top: 1.5rem;
}
.card h2 {
margin-bottom: 1.5rem;
color: #e0e0e0;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #e0e0e0;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #0f3460;
border-radius: 4px;
background: #1a1a2e;
color: #e0e0e0;
font-size: 1rem;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
}
.alert {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.alert-success {
background: #1b5e20;
color: #a5d6a7;
border: 1px solid #2e7d32;
}
.alert-error {
background: #b71c1c;
color: #ef9a9a;
border: 1px solid #c62828;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-secondary {
background: #0f3460;
color: #e0e0e0;
}
.btn-secondary:hover {
background: #16213e;
}
</style>
{% endblock %}
{% extends "base.html" %}
{% block title %}Delete Account - AISBF{% endblock %}
{% block content %}
<div class="container">
<h1 style="color: #f44336;">Delete Account</h1>
<p>This action cannot be undone. All your data will be permanently deleted.</p>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% if has_subscription %}
<div class="alert alert-warning">
<strong>⚠️ Warning: Active Subscription Detected</strong>
<p style="margin-top: 0.5rem;">You have an active paid subscription ({{ subscription_tier }}). Deleting your account will:</p>
<ul style="margin-left: 1.5rem; margin-top: 0.5rem;">
<li>Cancel your subscription immediately</li>
<li>You will lose access to all premium features</li>
<li>No refunds will be issued for remaining subscription time</li>
</ul>
<p style="margin-top: 0.5rem;">Consider canceling your subscription first if you want to use it until the end of the billing period.</p>
</div>
{% endif %}
<div class="card danger-card">
<h2>Confirm Account Deletion</h2>
<div class="warning-box">
<h3>⚠️ This will permanently delete:</h3>
<ul>
<li>Your account and profile information</li>
<li>All your API providers and configurations</li>
<li>All your rotation and autoselect settings</li>
<li>All your usage history and analytics</li>
<li>All your API tokens</li>
{% if has_subscription %}
<li><strong>Your active subscription ({{ subscription_tier }})</strong></li>
{% endif %}
</ul>
</div>
<form method="POST" action="{{ url_for(request, '/dashboard/delete-account') }}" onsubmit="return confirmDeletion()">
<div class="form-group">
<label for="password">Enter Your Password to Confirm</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label for="confirmation">Type "DELETE" to confirm</label>
<input type="text" id="confirmation" name="confirmation" required placeholder="DELETE">
</div>
<div style="margin-top: 1.5rem; display: flex; gap: 1rem;">
<button type="submit" class="btn btn-danger">Delete My Account Permanently</button>
<a href="{{ url_for(request, '/dashboard/profile') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
<style>
.container {
margin: 0 auto;
padding: 2rem;
}
.card {
background: #16213e;
padding: 2rem;
border-radius: 8px;
margin-top: 1.5rem;
}
.danger-card {
border: 2px solid #d32f2f;
}
.card h2 {
margin-bottom: 1.5rem;
color: #f44336;
}
.warning-box {
background: #1a1a2e;
border-left: 4px solid #f44336;
padding: 1rem;
margin-bottom: 1.5rem;
border-radius: 4px;
}
.warning-box h3 {
color: #f44336;
margin-bottom: 0.75rem;
font-size: 1.1rem;
}
.warning-box ul {
margin-left: 1.5rem;
color: #e0e0e0;
}
.warning-box li {
margin-bottom: 0.5rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #e0e0e0;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #0f3460;
border-radius: 4px;
background: #1a1a2e;
color: #e0e0e0;
font-size: 1rem;
}
.form-group input:focus {
outline: none;
border-color: #f44336;
}
.alert {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.alert-error {
background: #b71c1c;
color: #ef9a9a;
border: 1px solid #c62828;
}
.alert-warning {
background: #e65100;
color: #ffcc80;
border: 1px solid #ef6c00;
}
.alert-warning strong {
display: block;
margin-bottom: 0.5rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
text-decoration: none;
display: inline-block;
}
.btn-danger {
background: #d32f2f;
color: white;
font-weight: 600;
}
.btn-danger:hover {
background: #b71c1c;
}
.btn-secondary {
background: #0f3460;
color: #e0e0e0;
}
.btn-secondary:hover {
background: #16213e;
}
</style>
<script>
function confirmDeletion() {
const confirmation = document.getElementById('confirmation').value;
if (confirmation !== 'DELETE') {
alert('Please type "DELETE" exactly to confirm account deletion.');
return false;
}
return confirm('Are you absolutely sure? This action cannot be undone and all your data will be permanently deleted.');
}
</script>
{% endblock %}
{% extends "base.html" %}
{% block title %}Forgot Password{% endblock %}
{% block header_css %}
<style>
.login-container {
max-width: 450px;
margin: 80px auto;
padding: 30px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.login-container h2 {
text-align: center;
margin-bottom: 25px;
color: #333;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #444;
}
.form-group input {
width: 100%;
padding: 12px 15px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #2196F3;
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
}
.btn {
background: #2196F3;
color: white;
border: none;
padding: 12px 20px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background 0.2s;
}
.btn-secondary {
background: #666;
}
.btn-secondary:hover {
background: #555;
}
.button-row {
display: flex;
gap: 10px;
margin-top: 20px;
}
.button-row .btn {
flex: 1;
width: auto;
}
.btn:hover {
background: #1976D2;
}
.alert {
padding: 12px;
margin-bottom: 20px;
border-radius: 4px;
text-align: center;
}
.alert.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.back-link {
text-align: center;
margin-top: 20px;
}
.back-link a {
color: #666;
text-decoration: none;
}
.back-link a:hover {
text-decoration: underline;
}
</style>
{% endblock %}
{% block content %}
<div class="login-container">
<h2>Reset Your Password</h2>
{% if message %}
<div class="alert {{ message_type }}">
{{ message }}
</div>
{% endif %}
{% if success %}
<p style="text-align: center; color: #155724; line-height: 1.6;">
If an account exists with that email address, we have sent a password reset link.
<br><br>
The link will expire in 24 hours. Please check your inbox and spam folder.
</p>
<div class="button-row">
<a href="{{ url_for(request, '/dashboard/login') }}" class="btn" style="text-decoration: none; text-align: center; width: 100%;">Back to Login</a>
</div>
{% else %}
<p style="text-align: center; color: #666; margin-bottom: 25px;">
Enter your email address and we'll send you a link to reset your password.
</p>
<form method="POST">
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" required autofocus>
</div>
<div class="button-row">
<button type="submit" class="btn">Send Reset Link</button>
<a href="{{ url_for(request, '/dashboard/login') }}" class="btn btn-secondary" style="text-decoration: none; text-align: center;">Back to Login</a>
</div>
</form>
{% endif %}
</div>
{% endblock %}
......@@ -25,6 +25,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
{% if show_verify_email %}
<div class="alert alert-info">Please check your email and verify your account before logging in.</div>
{% endif %}
<form method="POST" action="{{ url_for(request, '/dashboard/login') }}">
<div class="form-group">
......@@ -46,8 +54,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<button type="submit" class="btn" style="width: 100%;">Login</button>
{% if signup_enabled %}
{% if smtp_enabled %}
<div style="text-align: center; margin-top: 20px;">
<p><a href="{{ url_for(request, '/dashboard/forgot-password') }}" style="color: #2196F3; text-decoration: none;">Forgot your password?</a></p>
</div>
{% endif %}
{% if signup_enabled %}
<div style="text-align: center; margin-top: 10px;">
<p>Don't have an account? <a href="{{ url_for(request, '/dashboard/signup') }}" style="color: #4CAF50;">Sign up here</a></p>
</div>
{% endif %}
......
......@@ -26,7 +26,16 @@
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" value="{{ session.email }}" required>
<input type="email" id="email" name="email" value="{{ session.email or '' }}" readonly style="background: #0f1419; cursor: not-allowed;">
{% if not session.email %}
<small style="color: #a0a0a0; display: block; margin-top: 0.5rem;">
No email address set. <a href="{{ url_for(request, '/dashboard/change-email') }}" style="color: #667eea;">Add Email</a> (requires verification)
</small>
{% else %}
<small style="color: #a0a0a0; display: block; margin-top: 0.5rem;">
<a href="{{ url_for(request, '/dashboard/change-email') }}" style="color: #667eea;">Change Email</a> (requires verification)
</small>
{% endif %}
</div>
<div class="form-group">
......@@ -42,6 +51,12 @@
</div>
</form>
</div>
<div class="card" style="margin-top: 2rem; border: 1px solid #d32f2f;">
<h2 style="color: #f44336;">Danger Zone</h2>
<p style="color: #a0a0a0; margin-bottom: 1rem;">Permanently delete your account and all associated data.</p>
<a href="{{ url_for(request, '/dashboard/delete-account') }}" class="btn" style="background: #d32f2f; color: white;">Delete Account</a>
</div>
</div>
<style>
......
{% extends "base.html" %}
{% block title %}Reset Password{% endblock %}
{% block header_css %}
<style>
.login-container {
max-width: 450px;
margin: 80px auto;
padding: 30px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.login-container h2 {
text-align: center;
margin-bottom: 25px;
color: #333;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #444;
}
.form-group input {
width: 100%;
padding: 12px 15px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #2196F3;
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
}
.btn {
background: #2196F3;
color: white;
border: none;
padding: 12px 20px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
width: 100%;
transition: background 0.2s;
}
.btn:hover {
background: #1976D2;
}
.alert {
padding: 12px;
margin-bottom: 20px;
border-radius: 4px;
text-align: center;
}
.alert.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.back-link {
text-align: center;
margin-top: 20px;
}
.back-link a {
color: #666;
text-decoration: none;
}
.back-link a:hover {
text-decoration: underline;
}
.password-hint {
font-size: 12px;
color: #666;
margin-top: 5px;
}
</style>
{% endblock %}
{% block content %}
<div class="login-container">
<h2>Set New Password</h2>
{% if message %}
<div class="alert {{ message_type }}">
{{ message }}
</div>
{% endif %}
{% if valid_token and not success %}
<p style="text-align: center; color: #666; margin-bottom: 25px;">
Please enter your new password below.
</p>
<form method="POST">
<div class="form-group">
<label for="password">New Password</label>
<input type="password" id="password" name="password" required minlength="8">
<div class="password-hint">Must be at least 8 characters long</div>
</div>
<div class="form-group">
<label for="password_confirm">Confirm Password</label>
<input type="password" id="password_confirm" name="password_confirm" required>
</div>
<button type="submit" class="btn">Reset Password</button>
</form>
{% elif success %}
<p style="text-align: center; color: #155724; line-height: 1.6;">
Your password has been successfully reset.
<br><br>
You can now login with your new password.
</p>
<div style="text-align: center; margin-top: 25px;">
<a href="{{ url_for(request, '/dashboard/login') }}" class="btn" style="display: inline-block; text-decoration: none; width: auto; padding: 10px 25px;">
Go to Login
</a>
</div>
{% else %}
<p style="text-align: center; color: #721c24; line-height: 1.6;">
This password reset link is invalid or has expired.
<br><br>
Please request a new password reset link.
</p>
<div style="text-align: center; margin-top: 25px;">
<a href="{{ url_for(request, '/dashboard/forgot-password') }}" class="btn" style="display: inline-block; text-decoration: none; width: auto; padding: 10px 25px;">
Request New Reset Link
</a>
</div>
{% endif %}
</div>
{% endblock %}
......@@ -584,6 +584,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<h3 style="margin: 30px 0 20px;">SMTP Email Configuration</h3>
<div class="form-group">
<label>
<input type="checkbox" name="smtp_enabled" {% if config.smtp and config.smtp.enabled %}checked{% endif %}>
Enable SMTP Email
</label>
<small style="color: #666; display: block; margin-top: 5px;">Enable email sending functionality for password resets, account verification, and notifications</small>
</div>
<div class="form-group">
<label for="smtp_host">SMTP Host</label>
<input type="text" id="smtp_host" name="smtp_host"
......@@ -641,6 +649,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<small style="color: #666; display: block; margin-top: 5px;">Display name for sent emails</small>
</div>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<input type="email" id="smtp_test_email" name="smtp_test_email" placeholder="Email address to send test to" style="flex: 1;">
<button type="button" onclick="testSMTP()" class="btn btn-secondary">Send Test Email</button>
</div>
<h3 style="margin: 30px 0 20px;">Request Batching</h3>
<div class="form-group">
......@@ -786,13 +799,19 @@ async function testSMTP() {
const port = document.getElementById('smtp_port').value;
const username = document.getElementById('smtp_username').value;
const fromEmail = document.getElementById('smtp_from_email').value;
const testEmail = document.getElementById('smtp_test_email').value;
if (!host || !port || !fromEmail) {
alert('Please fill in SMTP host, port, and from email before testing');
return;
}
if (!confirm('This will send a test email to the admin email address. Continue?')) {
if (!testEmail) {
alert('Please enter a test recipient email address');
return;
}
if (!confirm(`This will send a test email to ${testEmail}. Continue?`)) {
return;
}
......@@ -808,7 +827,8 @@ async function testSMTP() {
use_tls: document.querySelector('input[name="smtp_use_tls"]').checked,
use_ssl: document.querySelector('input[name="smtp_use_ssl"]').checked,
from_email: fromEmail,
from_name: document.getElementById('smtp_from_name').value
from_name: document.getElementById('smtp_from_name').value,
test_recipient: testEmail
})
});
......
......@@ -32,12 +32,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<form method="POST" action="{{ url_for(request, '/dashboard/signup') }}">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus
pattern="[a-zA-Z0-9_-]+"
title="Username can only contain letters, numbers, underscores, and hyphens"
<input type="text" id="username" name="username" required autofocus
pattern="[a-zA-Z0-9_.-]+"
title="Username can only contain letters, numbers, underscores, hyphens, and dots"
minlength="3" maxlength="50">
<small style="color: #666; display: block; margin-top: 5px;">
3-50 characters, letters, numbers, underscores, and hyphens only
3-50 characters, letters, numbers, underscores, hyphens, and dots only
</small>
</div>
......
<!--
Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
{% extends "base.html" %}
{% block title %}Verify Email - AISBF Dashboard{% endblock %}
{% block content %}
<div style="max-width: 500px; margin: 50px auto;">
<h2 style="margin-bottom: 30px; text-align: center;">Verify Your Email</h2>
<p style="text-align: center; margin-bottom: 30px;">
Please check your email inbox and click the verification link to activate your account.
</p>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% if message %}
<div class="alert alert-success">{{ message }}</div>
{% endif %}
<div style="background: #f5f5f5; padding: 20px; border-radius: 8px; margin-bottom: 30px;">
<h3 style="margin-top: 0; color: #333;">Didn't receive the email?</h3>
<p style="margin-bottom: 20px;">Check your spam/junk folder, or request a new verification email.</p>
{% if can_resend %}
<form method="POST" action="{{ url_for(request, '/dashboard/resend-verification') }}">
<button type="submit" class="btn" style="width: 100%;">Resend Verification Email</button>
</form>
{% else %}
<p style="color: #666; font-style: italic;">You can request another verification email in a few minutes.</p>
{% endif %}
</div>
<div style="text-align: center;">
<p><a href="{{ url_for(request, '/dashboard/logout') }}" style="color: #2196F3; text-decoration: none;">Logout</a></p>
</div>
</div>
{% endblock %}
\ No newline at end of file
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