483 lines
15 KiB
Python
483 lines
15 KiB
Python
# ==============================================================================
|
|
# Formeleon - Multi-tenant Form Generator
|
|
# Author: Björn Runden <br@runden.net>
|
|
# Module: app.py
|
|
# Description: Main Flask application initialization and Blueprint registration.
|
|
# Sets up database, configuration, and registers all route blueprints.
|
|
# ==============================================================================
|
|
|
|
"""
|
|
Main Flask application module for Formeleon.
|
|
|
|
This module:
|
|
- Loads application configuration
|
|
- Initializes Flask extensions (SQLAlchemy, Migrate, CORS, Session)
|
|
- Registers all route blueprints (API, Admin, Auth)
|
|
- Sets up error handlers
|
|
- Provides utility functions for request context
|
|
|
|
The application uses Flask Blueprints for modular route organization:
|
|
- /api/v1/* - Public Form API for form submissions
|
|
- /admin/* - Admin Dashboard for form management
|
|
- /auth/* - Authentication routes (OIDC callbacks, logout)
|
|
"""
|
|
|
|
import os
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from flask import Flask, request, session, g
|
|
from flask_cors import CORS
|
|
from flask_session import Session
|
|
from flask_migrate import Migrate
|
|
import uuid
|
|
|
|
# corrected for py3.13 from config import Config, config_map
|
|
from config import Config, config_map
|
|
from models import db, Tenant, User, UserRole, Submission, FormConfig, AuditLog, SubmissionStatus
|
|
|
|
|
|
# Initialize extensions globally (will be bound to app in create_app)
|
|
migrate = Migrate()
|
|
cors = CORS()
|
|
|
|
|
|
def get_config_class() -> type:
|
|
"""
|
|
Get the appropriate configuration class based on FLASK_ENV.
|
|
|
|
:return: Configuration class to use
|
|
"""
|
|
env = os.environ.get('FLASK_ENV', 'development')
|
|
return config_map.get(env, Config)
|
|
|
|
|
|
def create_app(config_class: Optional[type] = None) -> Flask:
|
|
"""
|
|
Create and configure the Flask application instance.
|
|
|
|
:param config_class: Configuration class to use (defaults to environment-based)
|
|
:return: Configured Flask application instance
|
|
"""
|
|
# Initialize Flask app
|
|
app = Flask(__name__)
|
|
app.config.from_object(config_class or get_config_class())
|
|
|
|
# Set up logging
|
|
setup_logging(app)
|
|
|
|
# Initialize extensions with app
|
|
initialize_extensions(app)
|
|
|
|
# Register blueprints
|
|
register_blueprints(app)
|
|
|
|
# Register error handlers
|
|
register_error_handlers(app)
|
|
|
|
# Register CLI commands
|
|
register_cli_commands(app)
|
|
|
|
# Set up dev mode middleware if enabled
|
|
if app.config['DEV_MODE']:
|
|
setup_dev_mode_middleware(app)
|
|
|
|
return app
|
|
|
|
|
|
def setup_logging(app: Flask) -> None:
|
|
"""
|
|
Configure logging for the application.
|
|
|
|
:param app: Flask application instance
|
|
"""
|
|
log_level = logging.DEBUG if app.config['DEBUG'] else logging.INFO
|
|
logging.basicConfig(
|
|
level=log_level,
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
)
|
|
app.logger.setLevel(log_level)
|
|
|
|
# Suppress Flask-SQLAlchemy logs unless in debug mode
|
|
if not app.config['DEBUG']:
|
|
logging.getLogger('flask_sqlalchemy').setLevel(logging.WARNING)
|
|
|
|
|
|
def initialize_extensions(app: Flask) -> None:
|
|
"""
|
|
Initialize Flask extensions with the application.
|
|
|
|
:param app: Flask application instance
|
|
"""
|
|
# Database
|
|
db.init_app(app)
|
|
|
|
# Migration
|
|
migrate.init_app(app, db)
|
|
|
|
# CORS
|
|
if app.config['ENABLE_CORS']:
|
|
cors.init_app(app, resources={
|
|
r'/api/*': {
|
|
'origins': app.config.get('ALLOWED_ORIGINS', '*'),
|
|
'methods': ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
'allow_headers': ['Content-Type', 'Authorization', 'X-Request-ID']
|
|
}
|
|
})
|
|
|
|
# Session
|
|
app.config['SESSION_TYPE'] = 'sqlalchemy'
|
|
app.config['SESSION_SQLALCHEMY'] = db
|
|
Session(app)
|
|
|
|
# Ensure secret key is set
|
|
if not app.config.get('SECRET_KEY'):
|
|
app.config['SECRET_KEY'] = str(uuid.uuid4())
|
|
app.logger.warning('Generated random SECRET_KEY - set explicitly in production!')
|
|
|
|
|
|
def register_blueprints(app: Flask) -> None:
|
|
"""
|
|
Register all route blueprints with the application.
|
|
|
|
:param app: Flask application instance
|
|
"""
|
|
# Import blueprints here to avoid circular imports
|
|
from routes.api import api_bp
|
|
from routes.admin import admin_bp
|
|
from routes.auth import auth_bp
|
|
|
|
# Register blueprints with URL prefixes
|
|
app.register_blueprint(api_bp, url_prefix='/api/v1')
|
|
app.register_blueprint(admin_bp, url_prefix='/admin')
|
|
app.register_blueprint(auth_bp, url_prefix='/auth')
|
|
|
|
# Register root route for health check
|
|
@app.route('/')
|
|
def health_check():
|
|
"""Health check endpoint."""
|
|
return {
|
|
'status': 'ok',
|
|
'version': '1.0.0',
|
|
'dev_mode': app.config['DEV_MODE']
|
|
}, 200
|
|
|
|
|
|
def register_error_handlers(app: Flask) -> None:
|
|
"""
|
|
Register custom error handlers for the application.
|
|
|
|
:param app: Flask application instance
|
|
"""
|
|
@app.errorhandler(400)
|
|
def bad_request_error(error):
|
|
"""Handle 400 Bad Request errors."""
|
|
return {
|
|
'error': 'Bad Request',
|
|
'message': str(error.description) if error.description else 'Invalid request data'
|
|
}, 400
|
|
|
|
@app.errorhandler(401)
|
|
def unauthorized_error(error):
|
|
"""Handle 401 Unauthorized errors."""
|
|
return {
|
|
'error': 'Unauthorized',
|
|
'message': str(error.description) if error.description else 'Authentication required'
|
|
}, 401
|
|
|
|
@app.errorhandler(403)
|
|
def forbidden_error(error):
|
|
"""Handle 403 Forbidden errors."""
|
|
return {
|
|
'error': 'Forbidden',
|
|
'message': str(error.description) if error.description else 'Access denied'
|
|
}, 403
|
|
|
|
@app.errorhandler(404)
|
|
def not_found_error(error):
|
|
"""Handle 404 Not Found errors."""
|
|
return {
|
|
'error': 'Not Found',
|
|
'message': str(error.description) if error.description else 'Resource not found'
|
|
}, 404
|
|
|
|
@app.errorhandler(429)
|
|
def too_many_requests_error(error):
|
|
"""Handle 429 Too Many Requests errors."""
|
|
return {
|
|
'error': 'Too Many Requests',
|
|
'message': str(error.description) if error.description else 'Rate limit exceeded'
|
|
}, 429
|
|
|
|
@app.errorhandler(500)
|
|
def internal_server_error(error):
|
|
"""Handle 500 Internal Server Error."""
|
|
app.logger.error(f'Internal Server Error: {error}')
|
|
return {
|
|
'error': 'Internal Server Error',
|
|
'message': 'An unexpected error occurred'
|
|
}, 500
|
|
|
|
|
|
def register_cli_commands(app: Flask) -> None:
|
|
"""
|
|
Register custom CLI commands for the application.
|
|
|
|
:param app: Flask application instance
|
|
"""
|
|
import click
|
|
from flask.cli import with_appcontext
|
|
|
|
@app.cli.command('seed-db')
|
|
@with_appcontext
|
|
def seed_db():
|
|
"""Seed the database with test data for development."""
|
|
import uuid
|
|
|
|
# Check if data already exists
|
|
if Tenant.query.first():
|
|
print('Database already contains data. Use --force to overwrite.')
|
|
return
|
|
|
|
# Create dev tenant
|
|
tenant = Tenant(
|
|
id='tenant_dev_123',
|
|
name='Development Tenant',
|
|
public_key='dev-public-key-12345',
|
|
allowed_origins='http://localhost:5000,http://localhost:8080',
|
|
status=TenantStatus.ACTIVE
|
|
)
|
|
db.session.add(tenant)
|
|
|
|
# Create sample form
|
|
sample_form = FormConfig(
|
|
id='form_sample_001',
|
|
tenant_id='tenant_dev_123',
|
|
name='Contact Form',
|
|
description='Sample contact form for testing',
|
|
form_structure={
|
|
'version': '1.0',
|
|
'fields': [
|
|
{
|
|
'name': 'name',
|
|
'type': 'text',
|
|
'label': 'Your Name',
|
|
'required': True,
|
|
'validation': {'min_length': 2, 'max_length': 100}
|
|
},
|
|
{
|
|
'name': 'email',
|
|
'type': 'email',
|
|
'label': 'Email Address',
|
|
'required': True,
|
|
'validation': {'pattern': r'^[^@]+@[^@]+\.[^@]+$'}
|
|
},
|
|
{
|
|
'name': 'subject',
|
|
'type': 'text',
|
|
'label': 'Subject',
|
|
'required': True
|
|
},
|
|
{
|
|
'name': 'message',
|
|
'type': 'textarea',
|
|
'label': 'Message',
|
|
'required': True,
|
|
'validation': {'min_length': 10}
|
|
}
|
|
],
|
|
'submit_button': {
|
|
'text': 'Send Message',
|
|
'style': 'primary'
|
|
}
|
|
},
|
|
settings={
|
|
'email_notification': {
|
|
'enabled': False,
|
|
'recipients': ['admin@formeleon.local']
|
|
},
|
|
'success_message': 'Thank you for your message!',
|
|
'recaptcha_enabled': True
|
|
}
|
|
)
|
|
db.session.add(sample_form)
|
|
|
|
# Create dev admin user
|
|
admin_user = User(
|
|
id='user_dev_admin_001',
|
|
email=app.config.get('DEV_USER_EMAIL', 'dev@formeleon.local'),
|
|
name=app.config.get('DEV_USER_NAME', 'Dev Admin'),
|
|
role=UserRole.ADMIN,
|
|
sub='dev-admin-sub-123'
|
|
)
|
|
db.session.add(admin_user)
|
|
|
|
# Associate user with tenant
|
|
from models import user_tenant_association
|
|
db.session.execute(
|
|
user_tenant_association.insert().values(
|
|
user_id=admin_user.id,
|
|
tenant_id=tenant.id
|
|
)
|
|
)
|
|
|
|
db.session.commit()
|
|
print(f'Seeded database with tenant {tenant.id}, form {sample_form.id}, and user {admin_user.id}')
|
|
|
|
@app.cli.command('create-tenant')
|
|
@click.argument('name')
|
|
@click.argument('public_key')
|
|
@click.option('--allowed-origins', default='*', help='Comma-separated allowed origins')
|
|
@with_appcontext
|
|
def create_tenant(name, public_key, allowed_origins):
|
|
"""Create a new tenant."""
|
|
tenant_id = f'tenant_{name.lower().replace(" ", "_")}_{str(uuid.uuid4())[:8]}'
|
|
tenant = Tenant(
|
|
id=tenant_id,
|
|
name=name,
|
|
public_key=public_key,
|
|
allowed_origins=allowed_origins
|
|
)
|
|
db.session.add(tenant)
|
|
db.session.commit()
|
|
print(f'Created tenant {tenant_id} with public key {public_key}')
|
|
|
|
@app.cli.command('create-user')
|
|
@click.argument('email')
|
|
@click.argument('name')
|
|
@click.option('--role', default='user', help='User role: admin, tenant_admin, user, viewer')
|
|
@with_appcontext
|
|
def create_user(email, name, role):
|
|
"""Create a new user."""
|
|
from models import UserRole as UR
|
|
role_enum = getattr(UR, role.upper(), UR.USER)
|
|
user_id = f'user_{email.split("@")[0]}_{str(uuid.uuid4())[:8]}'
|
|
user = User(
|
|
id=user_id,
|
|
email=email,
|
|
name=name,
|
|
role=role_enum
|
|
)
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
print(f'Created user {user_id} with email {email} and role {role}')
|
|
|
|
|
|
def setup_dev_mode_middleware(app: Flask) -> None:
|
|
"""
|
|
Set up development mode middleware for fake authentication.
|
|
|
|
This middleware injects a fake Zitadel user session when DEV_MODE is enabled,
|
|
bypassing the need for actual OIDC authentication during development.
|
|
|
|
:param app: Flask application instance
|
|
"""
|
|
from functools import wraps
|
|
|
|
@app.before_request
|
|
def inject_dev_user():
|
|
"""Inject dev user into session if DEV_MODE is enabled."""
|
|
# Skip for static files, auth routes, and API routes
|
|
if (request.path.startswith('/static/') or
|
|
request.path.startswith('/auth/') or
|
|
request.path.startswith('/api/')):
|
|
return
|
|
|
|
# Skip if user is already authenticated
|
|
if 'user_id' in session:
|
|
return
|
|
|
|
# Inject dev user
|
|
dev_user = {
|
|
'id': 'user_dev_admin_001',
|
|
'email': app.config.get('DEV_USER_EMAIL', 'dev@formeleon.local'),
|
|
'name': app.config.get('DEV_USER_NAME', 'Dev Admin'),
|
|
'role': app.config.get('DEV_USER_ROLE', 'admin'),
|
|
'sub': 'dev-admin-sub-123',
|
|
'tenant_id': app.config.get('DEV_TENANT_ID', 'tenant_dev_123'),
|
|
'dev_mode': True
|
|
}
|
|
session['user_id'] = dev_user['id']
|
|
session['user'] = dev_user
|
|
session.permanent = True
|
|
|
|
# Load or create dev user in database
|
|
from models import User, UserRole, Tenant
|
|
user = User.query.get(dev_user['id'])
|
|
if not user:
|
|
role = UserRole.ADMIN if dev_user['role'] == 'admin' else UserRole.USER
|
|
user = User(
|
|
id=dev_user['id'],
|
|
email=dev_user['email'],
|
|
name=dev_user['name'],
|
|
role=role,
|
|
sub=dev_user['sub']
|
|
)
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
|
|
# Ensure dev tenant exists
|
|
tenant = Tenant.query.get(dev_user['tenant_id'])
|
|
if not tenant:
|
|
tenant = Tenant(
|
|
id=dev_user['tenant_id'],
|
|
name='Development Tenant',
|
|
public_key='dev-public-key-12345',
|
|
allowed_origins='*'
|
|
)
|
|
db.session.add(tenant)
|
|
db.session.commit()
|
|
|
|
|
|
def get_current_user() -> Optional[User]:
|
|
"""
|
|
Get the current authenticated user from the request context.
|
|
|
|
:return: User object if authenticated, None otherwise
|
|
"""
|
|
from flask import session
|
|
if 'user_id' not in session:
|
|
return None
|
|
return User.query.get(session['user_id'])
|
|
|
|
|
|
def get_current_tenant() -> Optional[Tenant]:
|
|
"""
|
|
Get the current tenant from the request context.
|
|
|
|
:return: Tenant object if available, None otherwise
|
|
"""
|
|
from flask import session
|
|
if 'tenant_id' in session:
|
|
return Tenant.query.get(session['tenant_id'])
|
|
|
|
user = get_current_user()
|
|
if user and user.tenants:
|
|
return user.tenants[0]
|
|
|
|
return None
|
|
|
|
|
|
def get_request_context() -> dict:
|
|
"""
|
|
Get standardized request context for audit logging.
|
|
|
|
:return: Dictionary with request context information
|
|
"""
|
|
return {
|
|
'ip_address': request.remote_addr,
|
|
'user_agent': request.user_agent.string if request.user_agent else None,
|
|
'path': request.path,
|
|
'method': request.method
|
|
}
|
|
|
|
|
|
# Create application instance
|
|
app = create_app()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
# This allows running the app directly with python app.py
|
|
# In production, use gunicorn or other WSGI servers
|
|
app.run(host='0.0.0.0', port=5000, debug=app.config['DEBUG'])
|