279 lines
11 KiB
Python
279 lines
11 KiB
Python
"""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
|