Commit 51a7e2a5 authored by Your Name's avatar Your Name

Release version 0.99.20: Added user signup functionality with email verification and SMTP support

parent 73e5db11
......@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.99.20] - 2026-04-11
### Added
- **User Signup Functionality**: Complete user registration system with email verification
- Admin-controlled signup enable/disable toggle via dashboard settings
- SMTP server configuration interface in settings page
- Secure password hashing with SHA256
- Email verification with time-limited secure tokens
- Signup form with email and password fields
- Conditional signup link on login page when enabled
- Email verification landing page with status messages
- User accounts are disabled until email is verified
- **SMTP Email System**: Full SMTP email sending capabilities
- Supports plain, TLS, and SSL SMTP connections
- Configurable SMTP host, port, username, password, and sender address
- Proxy-aware verification link generation
- Verification email templates with proper formatting
- Connection timeout and error handling
- **User Management System**:
- Admin user management interface at /dashboard/users
- Create, edit, delete, and toggle user status
- Role-based access control (admin / regular user)
- Users have isolated private configurations
- Each user gets their own providers, rotations, autoselects
- User-specific API endpoints and authentication tokens
### Changed
- **Version Bump**: Updated version to 0.99.20 in setup.py, pyproject.toml, and aisbf/__init__.py
- **Authentication System**: Updated to support both config admin and database users
- **Login System**: Added email verification check before allowing login
- **Dashboard Templates**: Updated login page to show conditional signup link
### Fixed
- **Database Schema**: Added backwards compatible database migration for user tables
- **Session Management**: Added sliding session expiration for authenticated users
- **Proxy Awareness**: All verification links respect reverse proxy configurations
## [0.99.5] - 2026-04-09
### Fixed
......
......@@ -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.19"
__version__ = "0.99.20"
__all__ = [
# Config
"config",
......
......@@ -213,6 +213,23 @@ class AdaptiveRateLimitingConfig(BaseModel):
history_window: int = 3600 # History window in seconds (1 hour)
consecutive_successes_for_recovery: int = 10 # Successes needed before recovery starts
class SignupConfig(BaseModel):
"""Configuration for user signup functionality"""
enabled: bool = False
require_email_verification: bool = True
verification_token_expiry_hours: int = 24
class SMTPConfig(BaseModel):
"""Configuration for SMTP email sending"""
host: str = "localhost"
port: int = 587
username: str = ""
password: str = ""
use_tls: bool = True
use_ssl: bool = False
from_email: str = ""
from_name: str = "AISBF"
class AISBFConfig(BaseModel):
"""Global AISBF configuration from aisbf.json"""
classify_nsfw: bool = False
......@@ -229,6 +246,8 @@ class AISBFConfig(BaseModel):
response_cache: Optional[ResponseCacheConfig] = None
batching: Optional[BatchingConfig] = None
adaptive_rate_limiting: Optional[AdaptiveRateLimitingConfig] = None
signup: Optional[SignupConfig] = None
smtp: Optional[SMTPConfig] = None
class AppConfig(BaseModel):
......@@ -701,6 +720,14 @@ class Config:
adaptive_data = data.get('adaptive_rate_limiting')
if adaptive_data:
data['adaptive_rate_limiting'] = AdaptiveRateLimitingConfig(**adaptive_data)
# Parse signup separately if present
signup_data = data.get('signup')
if signup_data:
data['signup'] = SignupConfig(**signup_data)
# Parse smtp separately if present
smtp_data = data.get('smtp')
if smtp_data:
data['smtp'] = SMTPConfig(**smtp_data)
self.aisbf = AISBFConfig(**data)
self._loaded_files['aisbf'] = str(aisbf_path.absolute())
logger.info(f"Loaded AISBF config: classify_nsfw={self.aisbf.classify_nsfw}, classify_privacy={self.aisbf.classify_privacy}")
......@@ -710,6 +737,10 @@ class Config:
logger.info(f"Batching config: enabled={self.aisbf.batching.enabled}, window_ms={self.aisbf.batching.window_ms}, max_batch_size={self.aisbf.batching.max_batch_size}")
if self.aisbf.adaptive_rate_limiting:
logger.info(f"Adaptive rate limiting: enabled={self.aisbf.adaptive_rate_limiting.enabled}, initial_rate_limit={self.aisbf.adaptive_rate_limiting.initial_rate_limit}")
if self.aisbf.signup:
logger.info(f"Signup config: enabled={self.aisbf.signup.enabled}, require_email_verification={self.aisbf.signup.require_email_verification}")
if self.aisbf.smtp:
logger.info(f"SMTP config: host={self.aisbf.smtp.host}, port={self.aisbf.smtp.port}, from_email={self.aisbf.smtp.from_email}")
logger.info(f"=== Config._load_aisbf_config END ===")
def _initialize_error_tracking(self):
......
......@@ -221,14 +221,70 @@ class DatabaseManager:
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY {auto_increment},
username VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) DEFAULT 'user',
created_by VARCHAR(255),
created_at TIMESTAMP DEFAULT {timestamp_default},
last_login TIMESTAMP NULL,
is_active {boolean_type} DEFAULT 1
is_active {boolean_type} DEFAULT 1,
email_verified {boolean_type} DEFAULT 0,
verification_token VARCHAR(255),
verification_token_expires TIMESTAMP NULL
)
''')
# Migration: Add email-related columns if they don't exist
try:
if self.db_type == 'sqlite':
cursor.execute("PRAGMA table_info(users)")
columns = [row[1] for row in cursor.fetchall()]
if 'email' not in columns:
cursor.execute('ALTER TABLE users ADD COLUMN email VARCHAR(255) UNIQUE')
logger.info("Migration: Added email column to users table")
if 'email_verified' not in columns:
cursor.execute(f'ALTER TABLE users ADD COLUMN email_verified {boolean_type} DEFAULT 0')
logger.info("Migration: Added email_verified column to users table")
if 'verification_token' not in columns:
cursor.execute('ALTER TABLE users ADD COLUMN verification_token VARCHAR(255)')
logger.info("Migration: Added verification_token column to users table")
if 'verification_token_expires' not in columns:
cursor.execute('ALTER TABLE users ADD COLUMN verification_token_expires TIMESTAMP NULL')
logger.info("Migration: Added verification_token_expires column to users table")
else: # mysql
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'email'
""")
if not cursor.fetchone():
cursor.execute('ALTER TABLE users ADD COLUMN email VARCHAR(255) UNIQUE')
logger.info("Migration: Added email column to users table")
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'email_verified'
""")
if not cursor.fetchone():
cursor.execute(f'ALTER TABLE users ADD COLUMN email_verified {boolean_type} DEFAULT 0')
logger.info("Migration: Added email_verified column to users table")
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'verification_token'
""")
if not cursor.fetchone():
cursor.execute('ALTER TABLE users ADD COLUMN verification_token VARCHAR(255)')
logger.info("Migration: Added verification_token column to users table")
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'verification_token_expires'
""")
if not cursor.fetchone():
cursor.execute('ALTER TABLE users ADD COLUMN verification_token_expires TIMESTAMP NULL')
logger.info("Migration: Added verification_token_expires column to users table")
except Exception as e:
logger.warning(f"Migration check for users email fields: {e}")
# User-specific configuration tables for multi-user isolation
cursor.execute(f'''
......@@ -798,7 +854,8 @@ class DatabaseManager:
}
return None
def create_user(self, username: str, password_hash: str, role: str = 'user', created_by: str = None) -> int:
def create_user(self, username: str, password_hash: str, role: str = 'user', created_by: str = None,
email: str = None, email_verified: bool = False) -> int:
"""
Create a new user.
......@@ -807,6 +864,8 @@ class DatabaseManager:
password_hash: SHA256 hash of the password
role: User role ('admin' or 'user')
created_by: Username of the creator
email: Email address (optional)
email_verified: Whether email is verified (default: False)
Returns:
User ID of the created user
......@@ -815,11 +874,108 @@ class DatabaseManager:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
INSERT INTO users (username, password_hash, role, created_by)
VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder})
''', (username, password_hash, role, created_by))
INSERT INTO users (username, email, password_hash, role, created_by, email_verified)
VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder})
''', (username, email, password_hash, role, created_by, 1 if email_verified else 0))
conn.commit()
return cursor.lastrowid
def get_user_by_email(self, email: str) -> Optional[Dict]:
"""
Get a user by email address.
Args:
email: Email address to look up
Returns:
User dict if found, None otherwise
"""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
SELECT id, username, email, role, is_active, email_verified
FROM users
WHERE email = {placeholder}
''', (email,))
row = cursor.fetchone()
if row:
return {
'id': row[0],
'username': row[1],
'email': row[2],
'role': row[3],
'is_active': row[4],
'email_verified': row[5]
}
return None
def set_verification_token(self, user_id: int, token: str, expires_at: datetime):
"""
Set email verification token for a user.
Args:
user_id: User ID
token: Verification token
expires_at: Token expiration datetime
"""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
UPDATE users
SET verification_token = {placeholder}, verification_token_expires = {placeholder}
WHERE id = {placeholder}
''', (token, expires_at.isoformat(), user_id))
conn.commit()
def verify_email(self, token: str) -> Optional[Dict]:
"""
Verify a user's email using the verification token.
Args:
token: Verification token
Returns:
User dict if token is valid and not expired, None otherwise
"""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
# Find user with this token that hasn't expired
cursor.execute(f'''
SELECT id, username, email, verification_token_expires
FROM users
WHERE verification_token = {placeholder} AND is_active = 1
''', (token,))
row = cursor.fetchone()
if not row:
return None
user_id, username, email, expires_str = row
# Check if token has expired
if expires_str:
expires_at = datetime.fromisoformat(expires_str)
if datetime.now() > expires_at:
return None
# Mark email as verified and clear token
cursor.execute(f'''
UPDATE users
SET email_verified = 1, verification_token = NULL, verification_token_expires = NULL
WHERE id = {placeholder}
''', (user_id,))
conn.commit()
return {
'id': user_id,
'username': username,
'email': email
}
def get_users(self) -> List[Dict]:
"""
......
"""
Copyleft (C) 2026 Stefy Lanza <stefy@nexlab.net>
AISBF - AI Service Broker Framework || AI Should Be Free
Email utilities for sending verification emails and notifications.
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/>.
Why did the programmer quit his job? Because he didn't get arrays!
"""
import smtplib
import hashlib
import secrets
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional
import logging
logger = logging.getLogger(__name__)
def hash_password(password: str) -> str:
"""
Hash a password using SHA256.
Args:
password: Plain text password
Returns:
SHA256 hash of the password
"""
return hashlib.sha256(password.encode()).hexdigest()
def generate_verification_token() -> str:
"""
Generate a secure random verification token.
Returns:
Random token string (32 bytes hex)
"""
return secrets.token_hex(32)
def send_verification_email(
to_email: str,
username: str,
verification_token: str,
base_url: str,
smtp_config: dict
) -> bool:
"""
Send email verification email to a user.
Args:
to_email: Recipient email address
username: Username of the user
verification_token: Verification token
base_url: Base URL of the application
smtp_config: SMTP configuration dictionary
Returns:
True if email sent successfully, False otherwise
"""
try:
# Create verification URL
verification_url = f"{base_url}/dashboard/verify-email?token={verification_token}"
# 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['To'] = to_email
# Create plain text and HTML versions
text = f"""
Hello {username},
Thank you for signing up for AISBF!
Please verify your email address by clicking the link below:
{verification_url}
This link will expire in {smtp_config.get('verification_token_expiry_hours', 24)} hours.
If you did not create this account, please ignore this email.
Best regards,
AISBF Team
"""
html = f"""
<html>
<head></head>
<body>
<h2>Hello {username},</h2>
<p>Thank you for signing up for AISBF!</p>
<p>Please verify your email address by clicking the button below:</p>
<p style="margin: 30px 0;">
<a href="{verification_url}"
style="background-color: #4CAF50; color: white; padding: 14px 20px;
text-decoration: none; border-radius: 4px; display: inline-block;">
Verify Email Address
</a>
</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>If you did not create this account, please ignore this email.</p>
<br>
<p>Best regards,<br>AISBF Team</p>
</body>
</html>
"""
# Attach parts
part1 = MIMEText(text, 'plain')
part2 = MIMEText(html, 'html')
msg.attach(part1)
msg.attach(part2)
# Send email
if smtp_config.get('use_ssl', False):
# 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'])
server.send_message(msg)
else:
# Use TLS or no encryption
with smtplib.SMTP(smtp_config['host'], smtp_config['port']) as server:
if smtp_config.get('use_tls', True):
server.starttls()
if smtp_config.get('username') and smtp_config.get('password'):
server.login(smtp_config['username'], smtp_config['password'])
server.send_message(msg)
logger.info(f"Verification email sent to {to_email}")
return True
except Exception as e:
logger.error(f"Failed to send verification email to {to_email}: {e}")
return False
def send_password_reset_email(
to_email: str,
username: str,
reset_token: str,
base_url: str,
smtp_config: dict
) -> bool:
"""
Send password reset email to a user.
Args:
to_email: Recipient email address
username: Username of the user
reset_token: Password reset token
base_url: Base URL of the application
smtp_config: SMTP configuration dictionary
Returns:
True if email sent successfully, False otherwise
"""
try:
# Create reset URL
reset_url = f"{base_url}/dashboard/reset-password?token={reset_token}"
# 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['To'] = to_email
# Create plain text and HTML versions
text = f"""
Hello {username},
You requested to reset your password for your AISBF account.
Please click the link below to reset your password:
{reset_url}
This link will expire in 1 hour.
If you did not request a password reset, please ignore this email.
Best regards,
AISBF Team
"""
html = f"""
<html>
<head></head>
<body>
<h2>Hello {username},</h2>
<p>You requested to reset your password for your AISBF account.</p>
<p>Please click the button below to reset your password:</p>
<p style="margin: 30px 0;">
<a href="{reset_url}"
style="background-color: #2196F3; color: white; padding: 14px 20px;
text-decoration: none; border-radius: 4px; display: inline-block;">
Reset Password
</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<p><a href="{reset_url}">{reset_url}</a></p>
<p>This link will expire in 1 hour.</p>
<p>If you did not request a password reset, please ignore this email.</p>
<br>
<p>Best regards,<br>AISBF Team</p>
</body>
</html>
"""
# Attach parts
part1 = MIMEText(text, 'plain')
part2 = MIMEText(html, 'html')
msg.attach(part1)
msg.attach(part2)
# Send email
if smtp_config.get('use_ssl', False):
# 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'])
server.send_message(msg)
else:
# Use TLS or no encryption
with smtplib.SMTP(smtp_config['host'], smtp_config['port']) as server:
if smtp_config.get('use_tls', True):
server.starttls()
if smtp_config.get('username') and smtp_config.get('password'):
server.login(smtp_config['username'], smtp_config['password'])
server.send_message(msg)
logger.info(f"Password reset email sent to {to_email}")
return True
except Exception as e:
logger.error(f"Failed to send password reset email to {to_email}: {e}")
return False
......@@ -116,5 +116,20 @@
"jitter_factor": 0.25,
"history_window": 3600,
"consecutive_successes_for_recovery": 10
},
"signup": {
"enabled": false,
"require_email_verification": true,
"verification_token_expiry_hours": 24
},
"smtp": {
"host": "localhost",
"port": 587,
"username": "",
"password": "",
"use_tls": true,
"use_ssl": false,
"from_email": "noreply@example.com",
"from_name": "AISBF"
}
}
......@@ -1459,20 +1459,25 @@ async def dashboard_login_page(request: Request):
"""Show dashboard login page"""
import logging
from jinja2 import Environment, FileSystemLoader, DictLoader
logger = logging.getLogger(__name__)
try:
# Create a completely fresh Jinja2 environment to avoid any caching issues
env = Environment(loader=FileSystemLoader("templates"), auto_reload=False)
# Add the required globals
env.globals['url_for'] = url_for
env.globals['get_base_url'] = get_base_url
# Check if signup is enabled
signup_enabled = False
if config and hasattr(config, 'aisbf') and config.aisbf:
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)
html_content = template.render(request=request, signup_enabled=signup_enabled)
return HTMLResponse(content=html_content)
except Exception as e:
logger.error(f"Error rendering login page: {e}", exc_info=True)
......@@ -1482,16 +1487,23 @@ async def dashboard_login_page(request: Request):
async def dashboard_login(request: Request, username: str = Form(...), password: str = Form(...), remember_me: bool = Form(False)):
"""Handle dashboard login"""
from aisbf.database import get_database
# Hash the submitted password
password_hash = hashlib.sha256(password.encode()).hexdigest()
# Try database authentication first
db = get_database()
user = db.authenticate_user(username, password_hash)
if user:
# Database user authenticated
# 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"}
)
request.session['logged_in'] = True
request.session['username'] = username
request.session['role'] = user['role']
......@@ -1504,12 +1516,12 @@ async def dashboard_login(request: Request, username: str = Form(...), password:
# For non-remember-me sessions, set expiry to 2 weeks (default session length)
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
return RedirectResponse(url=url_for(request, "/dashboard"), status_code=303)
# Fallback to config admin
dashboard_config = server_config.get('dashboard_config', {}) if server_config else {}
stored_username = dashboard_config.get('username', 'admin')
stored_password_hash = dashboard_config.get('password', '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918')
if username == stored_username and password_hash == stored_password_hash:
request.session['logged_in'] = True
request.session['username'] = username
......@@ -1523,13 +1535,200 @@ async def dashboard_login(request: Request, username: str = Form(...), password:
# For non-remember-me sessions, set expiry to 2 weeks (default session length)
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"}
context={"request": request, "error": "Invalid credentials", "signup_enabled": signup_enabled}
)
@app.get("/dashboard/signup", response_class=HTMLResponse)
async def dashboard_signup_page(request: Request):
"""Show dashboard signup page"""
import logging
logger = logging.getLogger(__name__)
try:
# 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
if not signup_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/signup.html")
html_content = template.render(request=request)
return HTMLResponse(content=html_content)
except Exception as e:
logger.error(f"Error rendering signup page: {e}", exc_info=True)
raise
@app.post("/dashboard/signup")
async def dashboard_signup(
request: Request,
email: str = Form(...),
password: str = Form(...),
confirm_password: str = Form(...)
):
"""Handle user signup"""
from aisbf.database import get_database
from aisbf.email_utils import hash_password, generate_verification_token, send_verification_email
import logging
logger = logging.getLogger(__name__)
# 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
if not signup_enabled:
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
# Validate passwords match
if password != confirm_password:
return templates.TemplateResponse(
request=request,
name="dashboard/signup.html",
context={"request": request, "error": "Passwords do not match"}
)
# Validate password strength (minimum 8 characters)
if len(password) < 8:
return templates.TemplateResponse(
request=request,
name="dashboard/signup.html",
context={"request": request, "error": "Password must be at least 8 characters long"}
)
# Validate email format
import re
if not re.match(r"[^@]+@[^@]+\.[^@]+", email):
return templates.TemplateResponse(
request=request,
name="dashboard/signup.html",
context={"request": request, "error": "Invalid email address"}
)
try:
db = get_database()
# Check if user already exists
existing_user = db.get_user_by_email(email)
if existing_user:
if existing_user['email_verified']:
return templates.TemplateResponse(
request=request,
name="dashboard/signup.html",
context={"request": request, "error": "An account with this email already exists"}
)
else:
# Resend verification email for unverified user
verification_token = generate_verification_token()
db.set_verification_token(email, verification_token)
# 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)
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."}
)
# Create new user
password_hash = hash_password(password)
verification_token = generate_verification_token()
user_id = db.create_user(email, password_hash, verification_token)
# 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)
return templates.TemplateResponse(
request=request,
name="dashboard/signup.html",
context={"request": request, "message": "Account created successfully! Please check your email to verify your account."}
)
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."}
)
except Exception as e:
logger.error(f"Error during signup: {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."}
)
@app.get("/dashboard/verify-email")
async def verify_email(request: Request, token: str, email: str):
"""Handle email verification"""
from aisbf.database import get_database
import logging
logger = logging.getLogger(__name__)
try:
db = get_database()
# Verify the token
if db.verify_email_token(email, token):
# Token is valid, mark email as verified
db.verify_email(email)
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={"request": request, "message": "Email verified successfully! You can now log in."}
)
else:
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={"request": request, "error": "Invalid or expired verification token"}
)
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"}
)
@app.get("/dashboard/logout")
async def dashboard_logout(request: Request):
"""Handle dashboard logout"""
......@@ -2536,43 +2735,50 @@ async def dashboard_settings(request: Request):
}
)
@app.post("/dashboard/settings")
async def dashboard_settings_save(
request: Request,
host: str = Form(...),
port: int = Form(...),
protocol: str = Form(...),
auth_enabled: bool = Form(False),
auth_tokens: str = Form(""),
dashboard_username: str = Form(...),
dashboard_password: str = Form(""),
condensation_model_id: str = Form(...),
autoselect_model_id: str = Form(...),
database_type: str = Form("sqlite"),
sqlite_path: str = Form("~/.aisbf/aisbf.db"),
mysql_host: str = Form("localhost"),
mysql_port: int = Form(3306),
mysql_user: str = Form("aisbf"),
mysql_password: str = Form(""),
mysql_database: str = Form("aisbf"),
cache_type: str = Form("file"),
redis_host: str = Form("localhost"),
redis_port: int = Form(6379),
redis_db: int = Form(0),
redis_password: str = Form(""),
redis_key_prefix: str = Form("aisbf:"),
mcp_enabled: bool = Form(False),
autoselect_tokens: str = Form(""),
fullconfig_tokens: str = Form(""),
tor_enabled: bool = Form(False),
tor_control_port: int = Form(9051),
tor_control_host: str = Form("127.0.0.1"),
tor_control_password: str = Form(""),
tor_hidden_service_dir: str = Form(""),
tor_hidden_service_port: int = Form(80),
tor_socks_port: int = Form(9050),
tor_socks_host: str = Form("127.0.0.1")
):
@app.post("/dashboard/settings")
async def dashboard_settings_save(
request: Request,
host: str = Form(...),
port: int = Form(...),
protocol: str = Form(...),
auth_enabled: bool = Form(False),
auth_tokens: str = Form(""),
dashboard_username: str = Form(...),
dashboard_password: str = Form(""),
condensation_model_id: str = Form(...),
autoselect_model_id: str = Form(...),
database_type: str = Form("sqlite"),
sqlite_path: str = Form("~/.aisbf/aisbf.db"),
mysql_host: str = Form("localhost"),
mysql_port: int = Form(3306),
mysql_user: str = Form("aisbf"),
mysql_password: str = Form(""),
mysql_database: str = Form("aisbf"),
cache_type: str = Form("file"),
redis_host: str = Form("localhost"),
redis_port: int = Form(6379),
redis_db: int = Form(0),
redis_password: str = Form(""),
redis_key_prefix: str = Form("aisbf:"),
mcp_enabled: bool = Form(False),
autoselect_tokens: str = Form(""),
fullconfig_tokens: str = Form(""),
tor_enabled: bool = Form(False),
tor_control_port: int = Form(9051),
tor_control_host: str = Form("127.0.0.1"),
tor_control_password: str = Form(""),
tor_hidden_service_dir: str = Form(""),
tor_hidden_service_port: int = Form(80),
tor_socks_port: int = Form(9050),
tor_socks_host: str = Form("127.0.0.1"),
signup_enabled: bool = Form(False),
smtp_server: 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("")
):
"""Save server settings"""
auth_check = require_admin(request)
if auth_check:
......
......@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "aisbf"
version = "0.99.19"
version = "0.99.20"
description = "AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations"
readme = "README.md"
license = "GPL-3.0-or-later"
......
......@@ -49,7 +49,7 @@ class InstallCommand(_install):
setup(
name="aisbf",
version="0.99.19",
version="0.99.20",
author="AISBF Contributors",
author_email="stefy@nexlab.net",
description="AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations",
......@@ -167,6 +167,7 @@ setup(
'templates/dashboard/user_tokens.html',
'templates/dashboard/rate_limits.html',
'templates/dashboard/users.html',
'templates/dashboard/signup.html',
]),
# Install static files (extension and favicon)
('share/aisbf/static', [
......
......@@ -45,6 +45,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
<button type="submit" class="btn" style="width: 100%;">Login</button>
{% if signup_enabled %}
<div style="text-align: center; margin-top: 20px;">
<p>Don't have an account? <a href="{{ url_for(request, '/dashboard/signup') }}" style="color: #4CAF50;">Sign up here</a></p>
</div>
{% endif %}
</form>
</div>
{% endblock %}
......@@ -422,12 +422,143 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
</div>
<h3 style="margin: 30px 0 20px;">User Signup</h3>
<div class="form-group">
<label>
<input type="checkbox" name="signup_enabled" {% if config.signup and config.signup.enabled %}checked{% endif %}>
Enable User Signup
</label>
<small style="color: #666; display: block; margin-top: 5px;">Allow users to create accounts via signup form</small>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="signup_require_verification" {% if config.signup and config.signup.require_email_verification %}checked{% endif %}>
Require Email Verification
</label>
<small style="color: #666; display: block; margin-top: 5px;">Users must verify their email before they can login</small>
</div>
<div class="form-group">
<label for="verification_token_expiry">Verification Token Expiry (hours)</label>
<input type="number" id="verification_token_expiry" name="verification_token_expiry"
value="{{ config.signup.verification_token_expiry_hours if config.signup else 24 }}"
min="1" max="168">
<small style="color: #666; display: block; margin-top: 5px;">How long verification links remain valid (1-168 hours)</small>
</div>
<h3 style="margin: 30px 0 20px;">SMTP Email Configuration</h3>
<div class="form-group">
<label for="smtp_host">SMTP Host</label>
<input type="text" id="smtp_host" name="smtp_host"
value="{{ config.smtp.host if config.smtp else 'localhost' }}" required>
<small style="color: #666; display: block; margin-top: 5px;">SMTP server hostname (e.g., smtp.gmail.com)</small>
</div>
<div class="form-group">
<label for="smtp_port">SMTP Port</label>
<input type="number" id="smtp_port" name="smtp_port"
value="{{ config.smtp.port if config.smtp else 587 }}" required>
<small style="color: #666; display: block; margin-top: 5px;">SMTP server port (587 for TLS, 465 for SSL, 25 for no encryption)</small>
</div>
<div class="form-group">
<label for="smtp_username">SMTP Username</label>
<input type="text" id="smtp_username" name="smtp_username"
value="{{ config.smtp.username if config.smtp else '' }}">
<small style="color: #666; display: block; margin-top: 5px;">SMTP authentication username (leave blank if not required)</small>
</div>
<div class="form-group">
<label for="smtp_password">SMTP Password</label>
<input type="password" id="smtp_password" name="smtp_password" placeholder="Leave blank to keep current">
<small style="color: #666; display: block; margin-top: 5px;">SMTP authentication password</small>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="smtp_use_tls" {% if not config.smtp or config.smtp.use_tls %}checked{% endif %}>
Use TLS
</label>
<small style="color: #666; display: block; margin-top: 5px;">Use STARTTLS for secure connection (recommended for port 587)</small>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="smtp_use_ssl" {% if config.smtp and config.smtp.use_ssl %}checked{% endif %}>
Use SSL
</label>
<small style="color: #666; display: block; margin-top: 5px;">Use SSL/TLS from the start (for port 465)</small>
</div>
<div class="form-group">
<label for="smtp_from_email">From Email Address</label>
<input type="email" id="smtp_from_email" name="smtp_from_email"
value="{{ config.smtp.from_email if config.smtp else 'noreply@example.com' }}" required>
<small style="color: #666; display: block; margin-top: 5px;">Email address to send from</small>
</div>
<div class="form-group">
<label for="smtp_from_name">From Name</label>
<input type="text" id="smtp_from_name" name="smtp_from_name"
value="{{ config.smtp.from_name if config.smtp else 'AISBF' }}" required>
<small style="color: #666; display: block; margin-top: 5px;">Display name for sent emails</small>
</div>
<div style="display: flex; gap: 10px; margin-top: 30px;">
<button type="submit" class="btn">Save Settings</button>
<a href="{{ url_for(request, '/dashboard') }}" class="btn btn-secondary">Cancel</a>
<button type="button" onclick="testSMTP()" class="btn btn-secondary" style="margin-left: auto;">Test SMTP</button>
</div>
</form>
<script>
async function testSMTP() {
const host = document.getElementById('smtp_host').value;
const port = document.getElementById('smtp_port').value;
const username = document.getElementById('smtp_username').value;
const fromEmail = document.getElementById('smtp_from_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?')) {
return;
}
try {
const response = await fetch('{{ url_for(request, "/dashboard/test-smtp") }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
host: host,
port: parseInt(port),
username: username,
password: document.getElementById('smtp_password').value,
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
})
});
const data = await response.json();
if (data.success) {
alert('Test email sent successfully! Check your inbox.');
} else {
alert('Failed to send test email: ' + (data.error || 'Unknown error'));
}
} catch (error) {
alert('Error testing SMTP: ' + error.message);
}
}
</script>
<script>
function toggleSSLFields() {
const protocol = document.getElementById('protocol').value;
......
<!--
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 %}Sign Up - AISBF{% endblock %}
{% block content %}
<div style="max-width: 500px; margin: 50px auto;">
<h2 style="margin-bottom: 30px; text-align: center;">Create Account</h2>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% else %}
<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"
minlength="3" maxlength="50">
<small style="color: #666; display: block; margin-top: 5px;">
3-50 characters, letters, numbers, underscores, and hyphens only
</small>
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" required>
<small style="color: #666; display: block; margin-top: 5px;">
You will receive a verification email at this address
</small>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required
minlength="8"
pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}"
title="Password must be at least 8 characters and contain at least one uppercase letter, one lowercase letter, and one number">
<small style="color: #666; display: block; margin-top: 5px;">
At least 8 characters with uppercase, lowercase, and numbers
</small>
</div>
<div class="form-group">
<label for="confirm_password">Confirm Password</label>
<input type="password" id="confirm_password" name="confirm_password" required>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" id="terms" name="terms" required style="width: auto; margin-right: 10px;">
I agree to the Terms of Service and Privacy Policy
</label>
</div>
<button type="submit" class="btn" style="width: 100%;">Create Account</button>
<div style="text-align: center; margin-top: 20px;">
<p>Already have an account? <a href="{{ url_for(request, '/dashboard/login') }}" style="color: #4CAF50;">Login here</a></p>
</div>
</form>
{% endif %}
</div>
<script>
// Client-side password validation
document.querySelector('form')?.addEventListener('submit', function(e) {
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirm_password').value;
if (password !== confirmPassword) {
e.preventDefault();
alert('Passwords do not match!');
return false;
}
// Check password strength
if (password.length < 8) {
e.preventDefault();
alert('Password must be at least 8 characters long!');
return false;
}
if (!/[a-z]/.test(password)) {
e.preventDefault();
alert('Password must contain at least one lowercase letter!');
return false;
}
if (!/[A-Z]/.test(password)) {
e.preventDefault();
alert('Password must contain at least one uppercase letter!');
return false;
}
if (!/\d/.test(password)) {
e.preventDefault();
alert('Password must contain at least one number!');
return false;
}
return true;
});
</script>
{% endblock %}
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