164 lines
5.9 KiB
Python
164 lines
5.9 KiB
Python
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()
|