638 lines
20 KiB
Python
638 lines
20 KiB
Python
"""
|
|
Flask application factory for SneakyScanner web interface.
|
|
|
|
This module creates and configures the Flask application with all necessary
|
|
extensions, blueprints, and middleware.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import uuid
|
|
from logging.handlers import RotatingFileHandler
|
|
from pathlib import Path
|
|
|
|
from flask import Flask, g, jsonify, request
|
|
from flask_cors import CORS
|
|
from flask_login import LoginManager, current_user
|
|
from sqlalchemy import create_engine, event
|
|
from sqlalchemy.orm import scoped_session, sessionmaker
|
|
|
|
from web.models import Base
|
|
|
|
|
|
class RequestIDLogFilter(logging.Filter):
|
|
"""
|
|
Logging filter that injects request ID into log records.
|
|
|
|
Adds a 'request_id' attribute to each log record. For requests within
|
|
Flask request context, uses the request ID from g.request_id. For logs
|
|
outside request context (background jobs, startup), uses 'system'.
|
|
"""
|
|
|
|
def filter(self, record):
|
|
"""Add request_id to log record."""
|
|
try:
|
|
# Try to get request ID from Flask's g object
|
|
record.request_id = g.get('request_id', 'system')
|
|
except (RuntimeError, AttributeError):
|
|
# Outside of request context
|
|
record.request_id = 'system'
|
|
return True
|
|
|
|
|
|
def create_app(config: dict = None) -> Flask:
|
|
"""
|
|
Create and configure the Flask application.
|
|
|
|
Args:
|
|
config: Optional configuration dictionary to override defaults
|
|
|
|
Returns:
|
|
Configured Flask application instance
|
|
"""
|
|
app = Flask(__name__,
|
|
instance_relative_config=True,
|
|
static_folder='static',
|
|
template_folder='templates')
|
|
|
|
# Load default configuration
|
|
app.config.from_mapping(
|
|
SECRET_KEY=os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production'),
|
|
SQLALCHEMY_DATABASE_URI=os.environ.get('DATABASE_URL', 'sqlite:///./sneakyscanner.db'),
|
|
SQLALCHEMY_TRACK_MODIFICATIONS=False,
|
|
JSON_SORT_KEYS=False, # Preserve order in JSON responses
|
|
MAX_CONTENT_LENGTH=50 * 1024 * 1024, # 50MB max upload size (supports config files up to ~2MB)
|
|
)
|
|
|
|
# Override with custom config if provided
|
|
if config:
|
|
app.config.update(config)
|
|
|
|
# Ensure instance folder exists
|
|
try:
|
|
os.makedirs(app.instance_path, exist_ok=True)
|
|
except OSError:
|
|
pass
|
|
|
|
# Configure logging
|
|
configure_logging(app)
|
|
|
|
# Initialize database
|
|
init_database(app)
|
|
|
|
# Initialize extensions
|
|
init_extensions(app)
|
|
|
|
# Initialize authentication
|
|
init_authentication(app)
|
|
|
|
# Initialize background scheduler
|
|
init_scheduler(app)
|
|
|
|
# Register blueprints
|
|
register_blueprints(app)
|
|
|
|
# Register error handlers
|
|
register_error_handlers(app)
|
|
|
|
# Register context processors
|
|
register_context_processors(app)
|
|
|
|
# Add request/response handlers
|
|
register_request_handlers(app)
|
|
|
|
app.logger.info("SneakyScanner Flask app initialized")
|
|
|
|
return app
|
|
|
|
|
|
def configure_logging(app: Flask) -> None:
|
|
"""
|
|
Configure application logging with rotation and structured format.
|
|
|
|
Args:
|
|
app: Flask application instance
|
|
"""
|
|
# Set log level from environment or default to INFO
|
|
log_level = os.environ.get('LOG_LEVEL', 'INFO').upper()
|
|
app.logger.setLevel(getattr(logging, log_level, logging.INFO))
|
|
|
|
# Create logs directory if it doesn't exist
|
|
log_dir = Path('logs')
|
|
log_dir.mkdir(exist_ok=True)
|
|
|
|
# Rotating file handler for application logs
|
|
# Max 10MB per file, keep 10 backup files (100MB total)
|
|
app_log_handler = RotatingFileHandler(
|
|
log_dir / 'sneakyscanner.log',
|
|
maxBytes=10 * 1024 * 1024, # 10MB
|
|
backupCount=10
|
|
)
|
|
app_log_handler.setLevel(logging.INFO)
|
|
|
|
# Structured log format with more context
|
|
log_formatter = logging.Formatter(
|
|
'%(asctime)s [%(levelname)s] %(name)s [%(request_id)s] '
|
|
'%(message)s',
|
|
datefmt='%Y-%m-%d %H:%M:%S'
|
|
)
|
|
app_log_handler.setFormatter(log_formatter)
|
|
|
|
# Add filter to inject request ID into log records
|
|
app_log_handler.addFilter(RequestIDLogFilter())
|
|
app.logger.addHandler(app_log_handler)
|
|
|
|
# Separate rotating file handler for errors only
|
|
error_log_handler = RotatingFileHandler(
|
|
log_dir / 'sneakyscanner_errors.log',
|
|
maxBytes=10 * 1024 * 1024, # 10MB
|
|
backupCount=5
|
|
)
|
|
error_log_handler.setLevel(logging.ERROR)
|
|
error_formatter = logging.Formatter(
|
|
'%(asctime)s [%(levelname)s] %(name)s [%(request_id)s]\n'
|
|
'Message: %(message)s\n'
|
|
'Path: %(pathname)s:%(lineno)d\n'
|
|
'%(stack_info)s',
|
|
datefmt='%Y-%m-%d %H:%M:%S'
|
|
)
|
|
error_log_handler.setFormatter(error_formatter)
|
|
error_log_handler.addFilter(RequestIDLogFilter())
|
|
app.logger.addHandler(error_log_handler)
|
|
|
|
# Console handler for development
|
|
if app.debug:
|
|
console_handler = logging.StreamHandler()
|
|
console_handler.setLevel(logging.DEBUG)
|
|
console_formatter = logging.Formatter(
|
|
'%(asctime)s [%(levelname)s] %(name)s [%(request_id)s] %(message)s',
|
|
datefmt='%H:%M:%S'
|
|
)
|
|
console_handler.setFormatter(console_formatter)
|
|
console_handler.addFilter(RequestIDLogFilter())
|
|
app.logger.addHandler(console_handler)
|
|
|
|
app.logger.info("Logging configured with rotation (10MB per file, 10 backups)")
|
|
|
|
|
|
def init_database(app: Flask) -> None:
|
|
"""
|
|
Initialize database connection and session management.
|
|
|
|
Args:
|
|
app: Flask application instance
|
|
"""
|
|
# Determine connect_args based on database type
|
|
connect_args = {}
|
|
if 'sqlite' in app.config['SQLALCHEMY_DATABASE_URI']:
|
|
# SQLite-specific configuration for better concurrency
|
|
connect_args = {
|
|
'timeout': 15, # 15 second timeout for database locks
|
|
'check_same_thread': False # Allow SQLite usage across threads
|
|
}
|
|
|
|
# Create engine
|
|
engine = create_engine(
|
|
app.config['SQLALCHEMY_DATABASE_URI'],
|
|
echo=app.debug, # Log SQL in debug mode
|
|
pool_pre_ping=True, # Verify connections before using
|
|
pool_recycle=3600, # Recycle connections after 1 hour
|
|
connect_args=connect_args
|
|
)
|
|
|
|
# Enable WAL mode for SQLite (better concurrency)
|
|
if 'sqlite' in app.config['SQLALCHEMY_DATABASE_URI']:
|
|
@event.listens_for(engine, "connect")
|
|
def set_sqlite_pragma(dbapi_conn, connection_record):
|
|
"""Set SQLite pragmas for better performance and concurrency."""
|
|
cursor = dbapi_conn.cursor()
|
|
cursor.execute("PRAGMA journal_mode=WAL") # Write-Ahead Logging
|
|
cursor.execute("PRAGMA synchronous=NORMAL") # Faster writes
|
|
cursor.execute("PRAGMA busy_timeout=15000") # 15 second busy timeout
|
|
cursor.close()
|
|
|
|
# Create scoped session factory
|
|
db_session = scoped_session(
|
|
sessionmaker(
|
|
autocommit=False,
|
|
autoflush=False,
|
|
bind=engine
|
|
)
|
|
)
|
|
|
|
# Store session in app for use in views
|
|
app.db_session = db_session
|
|
|
|
# Create tables if they don't exist (for development)
|
|
# In production, use Alembic migrations instead
|
|
if app.debug:
|
|
Base.metadata.create_all(bind=engine)
|
|
|
|
@app.teardown_appcontext
|
|
def shutdown_session(exception=None):
|
|
"""
|
|
Remove database session at end of request.
|
|
|
|
Rollback on exception to prevent partial commits.
|
|
"""
|
|
if exception:
|
|
app.logger.warning(f"Request ended with exception, rolling back database session")
|
|
db_session.rollback()
|
|
db_session.remove()
|
|
|
|
app.logger.info(f"Database initialized: {app.config['SQLALCHEMY_DATABASE_URI']}")
|
|
|
|
|
|
def init_extensions(app: Flask) -> None:
|
|
"""
|
|
Initialize Flask extensions.
|
|
|
|
Args:
|
|
app: Flask application instance
|
|
"""
|
|
# CORS support for API
|
|
CORS(app, resources={
|
|
r"/api/*": {
|
|
"origins": os.environ.get('CORS_ORIGINS', '*').split(','),
|
|
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
"allow_headers": ["Content-Type", "Authorization"],
|
|
}
|
|
})
|
|
|
|
app.logger.info("Extensions initialized")
|
|
|
|
|
|
def init_authentication(app: Flask) -> None:
|
|
"""
|
|
Initialize Flask-Login authentication.
|
|
|
|
Args:
|
|
app: Flask application instance
|
|
"""
|
|
from web.auth.models import User
|
|
|
|
# Initialize LoginManager
|
|
login_manager = LoginManager()
|
|
login_manager.init_app(app)
|
|
|
|
# Configure login view
|
|
login_manager.login_view = 'auth.login'
|
|
login_manager.login_message = 'Please log in to access this page.'
|
|
login_manager.login_message_category = 'info'
|
|
|
|
# User loader callback
|
|
@login_manager.user_loader
|
|
def load_user(user_id):
|
|
"""Load user by ID for Flask-Login."""
|
|
return User.get(user_id, app.db_session)
|
|
|
|
app.logger.info("Authentication initialized")
|
|
|
|
|
|
def init_scheduler(app: Flask) -> None:
|
|
"""
|
|
Initialize background job scheduler.
|
|
|
|
Args:
|
|
app: Flask application instance
|
|
"""
|
|
from web.services.scheduler_service import SchedulerService
|
|
from web.services.scan_service import ScanService
|
|
|
|
# Create and initialize scheduler
|
|
scheduler = SchedulerService()
|
|
scheduler.init_scheduler(app)
|
|
|
|
# Perform startup tasks with app context for database access
|
|
with app.app_context():
|
|
# Clean up any orphaned scans from previous crashes/restarts
|
|
scan_service = ScanService(app.db_session)
|
|
orphaned_count = scan_service.cleanup_orphaned_scans()
|
|
if orphaned_count > 0:
|
|
app.logger.warning(f"Cleaned up {orphaned_count} orphaned scan(s) on startup")
|
|
|
|
# Load all enabled schedules from database
|
|
scheduler.load_schedules_on_startup()
|
|
|
|
# Store in app context for access from routes
|
|
app.scheduler = scheduler
|
|
|
|
app.logger.info("Background scheduler initialized")
|
|
|
|
|
|
def register_blueprints(app: Flask) -> None:
|
|
"""
|
|
Register Flask blueprints for different app sections.
|
|
|
|
Args:
|
|
app: Flask application instance
|
|
"""
|
|
# Import blueprints
|
|
from web.api.scans import bp as scans_bp
|
|
from web.api.schedules import bp as schedules_bp
|
|
from web.api.alerts import bp as alerts_bp
|
|
from web.api.webhooks import bp as webhooks_api_bp
|
|
from web.api.settings import bp as settings_bp
|
|
from web.api.stats import bp as stats_bp
|
|
from web.api.configs import bp as configs_bp
|
|
from web.auth.routes import bp as auth_bp
|
|
from web.routes.main import bp as main_bp
|
|
from web.routes.webhooks import bp as webhooks_bp
|
|
|
|
# Register authentication blueprint
|
|
app.register_blueprint(auth_bp, url_prefix='/auth')
|
|
|
|
# Register main web routes blueprint
|
|
app.register_blueprint(main_bp, url_prefix='/')
|
|
|
|
# Register webhooks web routes blueprint
|
|
app.register_blueprint(webhooks_bp, url_prefix='/webhooks')
|
|
|
|
# Register API blueprints
|
|
app.register_blueprint(scans_bp, url_prefix='/api/scans')
|
|
app.register_blueprint(schedules_bp, url_prefix='/api/schedules')
|
|
app.register_blueprint(alerts_bp, url_prefix='/api/alerts')
|
|
app.register_blueprint(webhooks_api_bp, url_prefix='/api/webhooks')
|
|
app.register_blueprint(settings_bp, url_prefix='/api/settings')
|
|
app.register_blueprint(stats_bp, url_prefix='/api/stats')
|
|
app.register_blueprint(configs_bp, url_prefix='/api/configs')
|
|
|
|
app.logger.info("Blueprints registered")
|
|
|
|
|
|
def register_error_handlers(app: Flask) -> None:
|
|
"""
|
|
Register error handlers for common HTTP errors.
|
|
|
|
Handles errors with either JSON responses (for API requests) or
|
|
HTML templates (for web requests). Ensures database rollback on errors.
|
|
|
|
Args:
|
|
app: Flask application instance
|
|
"""
|
|
from flask import render_template
|
|
from sqlalchemy.exc import SQLAlchemyError
|
|
|
|
def wants_json():
|
|
"""Check if client wants JSON response."""
|
|
# API requests always get JSON
|
|
if request.path.startswith('/api/'):
|
|
return True
|
|
# Check Accept header
|
|
best = request.accept_mimetypes.best_match(['application/json', 'text/html'])
|
|
return best == 'application/json' and \
|
|
request.accept_mimetypes[best] > request.accept_mimetypes['text/html']
|
|
|
|
@app.errorhandler(400)
|
|
def bad_request(error):
|
|
"""Handle 400 Bad Request errors."""
|
|
app.logger.warning(f"Bad request: {request.path} - {str(error)}")
|
|
|
|
if wants_json():
|
|
return jsonify({
|
|
'error': 'Bad Request',
|
|
'message': str(error) or 'The request was invalid'
|
|
}), 400
|
|
return render_template('errors/400.html', error=error), 400
|
|
|
|
@app.errorhandler(401)
|
|
def unauthorized(error):
|
|
"""Handle 401 Unauthorized errors."""
|
|
app.logger.warning(f"Unauthorized access attempt: {request.path}")
|
|
|
|
if wants_json():
|
|
return jsonify({
|
|
'error': 'Unauthorized',
|
|
'message': 'Authentication required'
|
|
}), 401
|
|
return render_template('errors/401.html', error=error), 401
|
|
|
|
@app.errorhandler(403)
|
|
def forbidden(error):
|
|
"""Handle 403 Forbidden errors."""
|
|
app.logger.warning(f"Forbidden access: {request.path}")
|
|
|
|
if wants_json():
|
|
return jsonify({
|
|
'error': 'Forbidden',
|
|
'message': 'You do not have permission to access this resource'
|
|
}), 403
|
|
return render_template('errors/403.html', error=error), 403
|
|
|
|
@app.errorhandler(404)
|
|
def not_found(error):
|
|
"""Handle 404 Not Found errors."""
|
|
app.logger.info(f"Resource not found: {request.path}")
|
|
|
|
if wants_json():
|
|
return jsonify({
|
|
'error': 'Not Found',
|
|
'message': 'The requested resource was not found'
|
|
}), 404
|
|
return render_template('errors/404.html', error=error), 404
|
|
|
|
@app.errorhandler(405)
|
|
def method_not_allowed(error):
|
|
"""Handle 405 Method Not Allowed errors."""
|
|
app.logger.warning(f"Method not allowed: {request.method} {request.path}")
|
|
|
|
if wants_json():
|
|
return jsonify({
|
|
'error': 'Method Not Allowed',
|
|
'message': 'The HTTP method is not allowed for this endpoint'
|
|
}), 405
|
|
return render_template('errors/405.html', error=error), 405
|
|
|
|
@app.errorhandler(500)
|
|
def internal_server_error(error):
|
|
"""
|
|
Handle 500 Internal Server Error.
|
|
|
|
Rolls back database session and logs full traceback.
|
|
"""
|
|
# Rollback database session on error
|
|
try:
|
|
app.db_session.rollback()
|
|
except Exception as e:
|
|
app.logger.error(f"Failed to rollback database session: {str(e)}")
|
|
|
|
# Log error with full context
|
|
app.logger.error(
|
|
f"Internal server error: {request.method} {request.path} - {str(error)}",
|
|
exc_info=True
|
|
)
|
|
|
|
if wants_json():
|
|
return jsonify({
|
|
'error': 'Internal Server Error',
|
|
'message': 'An unexpected error occurred'
|
|
}), 500
|
|
return render_template('errors/500.html', error=error), 500
|
|
|
|
@app.errorhandler(SQLAlchemyError)
|
|
def handle_db_error(error):
|
|
"""
|
|
Handle database errors.
|
|
|
|
Rolls back transaction and returns appropriate error response.
|
|
"""
|
|
# Rollback database session
|
|
try:
|
|
app.db_session.rollback()
|
|
except Exception as e:
|
|
app.logger.error(f"Failed to rollback database session: {str(e)}")
|
|
|
|
# Log database error
|
|
app.logger.error(
|
|
f"Database error: {request.method} {request.path} - {str(error)}",
|
|
exc_info=True
|
|
)
|
|
|
|
if wants_json():
|
|
return jsonify({
|
|
'error': 'Database Error',
|
|
'message': 'A database error occurred'
|
|
}), 500
|
|
return render_template('errors/500.html', error=error), 500
|
|
|
|
|
|
def register_context_processors(app: Flask) -> None:
|
|
"""
|
|
Register template context processors.
|
|
|
|
Makes common variables available to all templates without having to
|
|
pass them explicitly in every render_template call.
|
|
|
|
Args:
|
|
app: Flask application instance
|
|
"""
|
|
@app.context_processor
|
|
def inject_app_settings():
|
|
"""
|
|
Inject application metadata into all templates.
|
|
|
|
Returns:
|
|
Dictionary of variables to add to template context
|
|
"""
|
|
from web.config import APP_NAME, APP_VERSION, REPO_URL
|
|
|
|
return {
|
|
'app_name': APP_NAME,
|
|
'app_version': APP_VERSION,
|
|
'repo_url': REPO_URL
|
|
}
|
|
|
|
app.logger.info("Context processors registered")
|
|
|
|
|
|
def register_request_handlers(app: Flask) -> None:
|
|
"""
|
|
Register request and response handlers.
|
|
|
|
Adds request ID generation, request/response logging with timing,
|
|
and security headers.
|
|
|
|
Args:
|
|
app: Flask application instance
|
|
"""
|
|
import time
|
|
|
|
@app.before_request
|
|
def before_request_handler():
|
|
"""
|
|
Generate request ID and start timing.
|
|
|
|
Sets g.request_id and g.request_start_time for use in logging
|
|
and timing calculations.
|
|
"""
|
|
# Generate unique request ID
|
|
g.request_id = str(uuid.uuid4())[:8] # Short ID for readability
|
|
g.request_start_time = time.time()
|
|
|
|
# Log incoming request with context
|
|
user_info = 'anonymous'
|
|
if current_user.is_authenticated:
|
|
user_info = f'user:{current_user.get_id()}'
|
|
|
|
# Log at INFO level for API calls, DEBUG for other requests
|
|
if request.path.startswith('/api/'):
|
|
app.logger.info(
|
|
f"→ {request.method} {request.path} "
|
|
f"from={request.remote_addr} user={user_info}"
|
|
)
|
|
elif app.debug:
|
|
app.logger.debug(
|
|
f"→ {request.method} {request.path} "
|
|
f"from={request.remote_addr}"
|
|
)
|
|
|
|
@app.after_request
|
|
def after_request_handler(response):
|
|
"""
|
|
Log response and add security headers.
|
|
|
|
Calculates request duration and logs response status.
|
|
"""
|
|
# Calculate request duration
|
|
if hasattr(g, 'request_start_time'):
|
|
duration_ms = (time.time() - g.request_start_time) * 1000
|
|
|
|
# Log response with duration
|
|
if request.path.startswith('/api/'):
|
|
# Log API responses at INFO level
|
|
app.logger.info(
|
|
f"← {request.method} {request.path} "
|
|
f"status={response.status_code} "
|
|
f"duration={duration_ms:.2f}ms"
|
|
)
|
|
elif app.debug:
|
|
# Log web responses at DEBUG level in debug mode
|
|
app.logger.debug(
|
|
f"← {request.method} {request.path} "
|
|
f"status={response.status_code} "
|
|
f"duration={duration_ms:.2f}ms"
|
|
)
|
|
|
|
# Add duration header for API responses
|
|
if request.path.startswith('/api/'):
|
|
response.headers['X-Request-Duration-Ms'] = f"{duration_ms:.2f}"
|
|
response.headers['X-Request-ID'] = g.request_id
|
|
|
|
# Add security headers to all responses
|
|
if request.path.startswith('/api/'):
|
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
|
response.headers['X-Frame-Options'] = 'DENY'
|
|
response.headers['X-XSS-Protection'] = '1; mode=block'
|
|
|
|
return response
|
|
|
|
@app.teardown_request
|
|
def teardown_request_handler(exception=None):
|
|
"""
|
|
Log errors that occur during request processing.
|
|
|
|
Args:
|
|
exception: Exception that occurred, if any
|
|
"""
|
|
if exception:
|
|
app.logger.error(
|
|
f"Request failed: {request.method} {request.path} "
|
|
f"error={type(exception).__name__}: {str(exception)}",
|
|
exc_info=True
|
|
)
|
|
|
|
|
|
# Development server entry point
|
|
def main():
|
|
"""Run development server."""
|
|
app = create_app()
|
|
app.run(
|
|
host=os.environ.get('FLASK_HOST', '0.0.0.0'),
|
|
port=int(os.environ.get('FLASK_PORT', 5000)),
|
|
debug=os.environ.get('FLASK_DEBUG', 'True').lower() == 'true'
|
|
)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|