v_2.3
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user