from __future__ import annotations import logging from datetime import datetime from pathlib import Path from flask import Flask, jsonify, redirect, render_template, request, session, url_for, Response from flask_login import current_user from werkzeug.exceptions import HTTPException, RequestEntityTooLarge from .config import Config from .extensions import db, limiter, login_manager, migrate from .models import AppSetting, User, seed_categories, seed_default_settings from .services.assets import asset_url from .services.i18n import inject_i18n, translate as _ from .services.settings import get_int_setting from .services.scheduler import start_scheduler def create_app(config_object=Config): app = Flask(__name__, instance_relative_config=True) app.config.from_object(config_object) Path(app.instance_path).mkdir(parents=True, exist_ok=True) configure_logging(app) db.init_app(app) migrate.init_app(app, db) login_manager.init_app(app) limiter.init_app(app) from .auth.routes import auth_bp from .main.routes import main_bp from .expenses.routes import expenses_bp from .admin.routes import admin_bp from .api.routes import api_bp app.register_blueprint(auth_bp) app.register_blueprint(main_bp) app.register_blueprint(expenses_bp, url_prefix='/expenses') app.register_blueprint(admin_bp, url_prefix='/admin') app.register_blueprint(api_bp) from .cli.commands import register_commands register_commands(app) app.context_processor(inject_i18n) app.jinja_env.globals['asset_url'] = asset_url app.jinja_env.globals['now'] = datetime.utcnow @app.before_request def apply_runtime_settings(): if not current_user.is_authenticated and request.args.get('lang') in app.config['LANGUAGES']: session['language'] = request.args['lang'] app.config['MAX_CONTENT_LENGTH'] = get_int_setting('max_upload_mb', app.config['DEFAULT_MAX_UPLOAD_MB']) * 1024 * 1024 @app.after_request def security_headers(response): response.headers['X-Content-Type-Options'] = 'nosniff' response.headers['X-Frame-Options'] = 'SAMEORIGIN' response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' response.headers['Content-Security-Policy'] = ( "default-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; " "img-src 'self' data:; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; " "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net;" ) return response register_error_handlers(app) @app.route('/manifest.json') def manifest(): return jsonify({ 'name': 'Expense Monitor', 'short_name': 'Expenses', 'start_url': url_for('main.dashboard' if current_user.is_authenticated else 'auth.login'), 'display': 'standalone', 'background_color': '#0f172a', 'theme_color': '#111827', 'icons': [], }) @app.route('/service-worker.js') def service_worker(): content = "self.addEventListener('install',()=>self.skipWaiting());self.addEventListener('fetch',()=>{});" return Response(content, mimetype='application/javascript') @app.route('/health') def health(): return {'status': 'ok'} with app.app_context(): db.create_all() seed_categories() seed_default_settings() ensure_admin(app) start_scheduler(app) return app def configure_logging(app: Flask) -> None: app.logger.setLevel(logging.INFO) if not app.logger.handlers: handler = logging.StreamHandler() handler.setFormatter(logging.Formatter('[%(asctime)s] %(levelname)s in %(module)s: %(message)s')) app.logger.addHandler(handler) def register_error_handlers(app: Flask) -> None: def render_error(status_code: int, title: str, message: str): payload = {'error': title, 'message': message, 'status_code': status_code} if request.path.startswith('/analytics') or request.accept_mimetypes.best == 'application/json': return jsonify(payload), status_code return render_template('errors/error.html', status_code=status_code, title=title, message=message), status_code @app.errorhandler(400) def bad_request(_error): return render_error(400, _('error.400_title'), _('error.400_message')) @app.errorhandler(401) def unauthorized(_error): return render_error(401, _('error.401_title'), _('error.401_message')) @app.errorhandler(403) def forbidden(_error): return render_error(403, _('error.403_title'), _('error.403_message')) @app.errorhandler(404) def not_found(_error): return render_error(404, _('error.404_title'), _('error.404_message')) @app.errorhandler(413) @app.errorhandler(RequestEntityTooLarge) def too_large(_error): return render_error(413, _('error.413_title'), _('error.413_message')) @app.errorhandler(429) def rate_limited(_error): return render_error(429, _('error.429_title'), _('error.429_message')) @app.errorhandler(Exception) def internal_error(error): if isinstance(error, HTTPException): return render_error(error.code or 500, error.name, error.description) app.logger.exception('Unhandled exception: %s', error) return render_error(500, _('error.500_title'), _('error.500_message')) def ensure_admin(app: Flask) -> None: admin_email = 'admin@example.com' if not User.query.filter_by(email=admin_email).first(): admin = User( email=admin_email, full_name='System Admin', role='admin', must_change_password=True, language=app.config['DEFAULT_LANGUAGE'], ) admin.set_password('Admin123!') db.session.add(admin) db.session.commit()