Files
Alert-Hub-System/app/__init__.py
T

279 lines
11 KiB
Python
Raw Normal View History

2026-09-13 19:59:54 +01:00
"""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