"""Application factory. Builds a single unified Flask app served on ONE port: - frontoffice at the root: / (View) and /alerts (Add/Edit/Remove) - backoffice under /backoffice: users, mail config, settings, audit log, mail log — all restricted to admins. Everything shares the same database, models, and login system. The ``variant`` argument is retained for backwards compatibility with existing WSGI entry points but no longer changes what is registered — both areas are always available on the same app. """ import os import logging from flask import Flask, redirect, url_for, render_template, request, session from flask_login import LoginManager from flask_wtf.csrf import CSRFProtect from sqlalchemy import inspect, text from sqlalchemy.exc import IntegrityError, OperationalError from .config import Config from .models import db, User, AppSetting from .scheduler import init_scheduler APP_VERSION = "1.5" login_manager = LoginManager() csrf = CSRFProtect() def _setup_logging(): logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) def _run_migrations(app: Flask): """Lightweight, additive schema migrations so an existing DB can be upgraded in place (unzip-over-the-top) without any data loss. Only ADDs missing tables/columns — never drops or alters existing data. Every step is idempotent and tolerant of a concurrent process having already applied it (both gunicorn apps boot against the same file). """ with app.app_context(): # 1. Create any brand-new tables (e.g. mail_log) and missing tables. try: db.create_all() except OperationalError as e: # Another booting process created a table at the same moment. app.logger.info("create_all raced (ignored): %s", e) db.session.rollback() # 2. Add any missing columns on the users table. inspector = inspect(db.engine) existing_cols = {c["name"] for c in inspector.get_columns("users")} add_column = { "avatar_data": "ALTER TABLE users ADD COLUMN avatar_data BLOB", "avatar_mime": "ALTER TABLE users ADD COLUMN avatar_mime VARCHAR(64)", } for col, ddl in add_column.items(): if col in existing_cols: continue try: with db.engine.begin() as conn: conn.execute(text(ddl)) app.logger.info("Migration: added users.%s", col) except OperationalError as e: # "duplicate column name" — another process won the race. Fine. app.logger.info("Add column %s raced (ignored): %s", col, e) def _seed_settings(app: Flask): """Seed default settings (mail config, IMAP, reminders) when rows are missing. Never overwrites values an admin has already set — so upgrades keep existing configuration. New keys (e.g. IMAP) are seeded on upgrade. Uses an INSERT that tolerates a concurrent process seeding the same key. """ with app.app_context(): defaults = { # ---- SMTP (outgoing) — Purelymail defaults ---- "smtp_host": app.config.get("SMTP_HOST", "smtp.purelymail.com"), "smtp_port": str(app.config.get("SMTP_PORT", 465)), "smtp_username": app.config.get("SMTP_USERNAME", ""), "smtp_password": app.config.get("SMTP_PASSWORD", ""), "smtp_encryption": app.config.get("SMTP_ENCRYPTION", "ssl"), "smtp_from_address": app.config.get("SMTP_FROM_ADDRESS", ""), "smtp_from_name": app.config.get("SMTP_FROM_NAME", "AlertHub"), # ---- IMAP (Sent-folder copy) — Purelymail defaults ---- "imap_enabled": "true", "imap_host": app.config.get("IMAP_HOST", "imap.purelymail.com"), "imap_port": str(app.config.get("IMAP_PORT", 993)), "imap_username": app.config.get("IMAP_USERNAME", ""), "imap_password": app.config.get("IMAP_PASSWORD", ""), "imap_sent_folder": app.config.get("IMAP_SENT_FOLDER", "Sent"), # ---- Reminders ---- "default_reminder_days": app.config.get("DEFAULT_REMINDER_DAYS", "30,14,7,1"), } for k, v in defaults.items(): if AppSetting.get(k) is not None: continue try: row = AppSetting(key=k, value=v) db.session.add(row) db.session.commit() except IntegrityError: # A concurrent boot inserted this key first — that's fine. db.session.rollback() def _initialise_database(app: Flask): """Run migrations + seeding exactly once at a time across processes. The container starts several worker processes that all import the app and hit the shared SQLite file simultaneously. A cross-process file lock serialises initialisation so concurrent DDL/seeding can't collide. """ import fcntl # POSIX-only; the container is Linux. db_dir = os.path.dirname(app.config["DATABASE_PATH"]) or "." os.makedirs(db_dir, exist_ok=True) lock_path = os.path.join(db_dir, ".alerthub-init.lock") try: lock_file = open(lock_path, "w") except OSError: # If we somehow can't open the lock file, fall back to unlocked init; # the per-step tolerance above still guards against most races. _run_migrations(app) _seed_settings(app) return with lock_file: try: fcntl.flock(lock_file, fcntl.LOCK_EX) except OSError: pass # advisory lock unavailable — proceed; steps are tolerant. try: _run_migrations(app) _seed_settings(app) finally: try: fcntl.flock(lock_file, fcntl.LOCK_UN) except OSError: pass def create_app(variant: str = "frontoffice") -> Flask: """Build the unified Flask app (frontoffice + /backoffice on one port). ``variant`` is accepted for backwards compatibility with the existing WSGI entry points but no longer restricts which blueprints are registered. """ if variant not in ("frontoffice", "backoffice"): variant = "frontoffice" _setup_logging() app = Flask( __name__, template_folder="templates", static_folder="static", ) app.config.from_object(Config) app.config["APP_VARIANT"] = variant # Make sure data dir exists os.makedirs(os.path.dirname(app.config["DATABASE_PATH"]), exist_ok=True) db.init_app(app) csrf.init_app(app) login_manager.init_app(app) login_manager.login_view = "auth.login" login_manager.login_message = "Please sign in to continue." login_manager.login_message_category = "info" @login_manager.user_loader def load_user(user_id): try: return db.session.get(User, int(user_id)) except (TypeError, ValueError): return None # ---- Blueprints ---- # Unified app: the frontoffice lives at the root and the backoffice is # mounted under /backoffice, so both are served on the SAME port. Access to # backoffice views is still restricted to admins by @admin_required. from .routes_auth import auth_bp from .routes_main import main_bp from .routes_backoffice import backoffice_bp app.register_blueprint(auth_bp) app.register_blueprint(main_bp) app.register_blueprint(backoffice_bp, url_prefix="/backoffice") # ---- Error handlers ---- @app.errorhandler(403) def forbidden(_): return render_template("error.html", code=403, message="You don't have permission to view this page."), 403 @app.errorhandler(404) def not_found(_): return render_template("error.html", code=404, message="Page not found."), 404 @app.errorhandler(500) def server_error(_): return render_template("error.html", code=500, message="Internal server error."), 500 # ---- Security headers ---- @app.after_request def add_security_headers(response): response.headers.setdefault("X-Content-Type-Options", "nosniff") response.headers.setdefault("X-Frame-Options", "DENY") response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()") # Reasonable CSP for our self-hosted assets + the QR data URI response.headers.setdefault( "Content-Security-Policy", "default-src 'self'; img-src 'self' data:; " "style-src 'self' 'unsafe-inline'; script-src 'self'; " "font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" ) return response # ---- Template context: brand + which area we're in ---- @app.context_processor def inject_globals(): # The backoffice is now mounted under /backoffice on the same origin, # so cross-links are simple same-site URLs and "is_backoffice" is # determined by which blueprint served the current request. is_bo = (request.blueprint == "backoffice") if request else False try: backoffice_url = url_for("backoffice.dashboard") frontoffice_url = url_for("main.index") except Exception: backoffice_url = "/backoffice/" frontoffice_url = "/" return { "app_variant": "backoffice" if is_bo else "frontoffice", "brand_name": "AlertHub", "is_backoffice": is_bo, "app_version": APP_VERSION, "footer_text": "© 2026 Martinhal IT - Joao Vaz - Version " + APP_VERSION, "backoffice_url": backoffice_url, "frontoffice_url": frontoffice_url, } # ---- Initialise DB (additive migrations) + seed default settings. # Serialised across the container's worker processes via a file lock. ---- _initialise_database(app) # ---- First-run guard: if there are no users yet, force the operator # through the "create administrator" setup screen before anything else. ---- app._alerthub_has_users = False @app.before_request def _require_initial_setup(): if app._alerthub_has_users: return None # Allow static assets and the setup endpoint itself through. if request.endpoint in ("static", "auth.setup"): return None try: if User.query.count() > 0: app._alerthub_has_users = True return None except Exception: return None return redirect(url_for("auth.setup")) # ---- Scheduler. init_scheduler() is idempotent (guarded by a module-level # singleton), and the container runs a single Gunicorn worker, so exactly # one reminder scheduler runs regardless of how many times this is called. ---- init_scheduler(app) return app