Firefox served a cached copy of the brewing page after the advice became a button and a pop-out, so the phone kept showing the old inline advice - a long paragraph in the table cell, with the button label reading "Read the adviceAdvice". Two separate causes: The pages had no cache headers, so Firefox was free to reuse them. They now send Cache-Control: no-store, must-revalidate. The rule that hides the short label was scoped to `table.data .short`, so it did not apply to the advice button's label. A stale stylesheet then showed both forms at once. The rule is now on `.short` alone, so it applies wherever the pattern is used - a table heading, the advice button, the Active Beers page - and only ever one form can show. Verified at 390px and 1280px: the phone shows "Advice", the desktop shows "Read the advice" and "BREWING ADVICE", and no element ever shows both. 192 tests pass.
213 lines
7.7 KiB
Python
213 lines
7.7 KiB
Python
"""Application factory for the beer_data web app.
|
|
|
|
The application is deliberately small: a handful of pages, one Mongo database,
|
|
no build step for the front end. Everything server-side lives in blueprints
|
|
under ``src/flask/views/`` and the domain maths in ``src/flask/brewing.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from flask import (Flask, g, redirect, request, session, url_for)
|
|
from flask_wtf import CSRFProtect
|
|
|
|
__all__ = ["create_app"]
|
|
|
|
|
|
#: The Content-Security-Policy the app sends with every response.
|
|
#:
|
|
#: The app serves only its own vendored assets — CSS, JavaScript, fonts and
|
|
#: the logo are all in ``static/`` — so a strict ``'self'`` policy is
|
|
#: enforceable. There is no ``'unsafe-inline'`` anywhere, which is why the
|
|
#: templates carry no inline scripts or style attributes.
|
|
CSP = (
|
|
"default-src 'self'; "
|
|
"script-src 'self'; "
|
|
"style-src 'self'; "
|
|
"img-src 'self' data:; "
|
|
"font-src 'self'; "
|
|
"connect-src 'self'; "
|
|
"form-action 'self'; "
|
|
"object-src 'none'; "
|
|
"frame-ancestors 'none'; "
|
|
"base-uri 'self'"
|
|
)
|
|
|
|
|
|
class ImproperlyConfigured(RuntimeError):
|
|
"""Raised when the app is missing configuration it cannot safely default.
|
|
|
|
Werkzeug 3 dropped its own ``ImproperlyConfigured``, and Flask has never
|
|
exported one, so the app defines its own. It is a ``RuntimeError`` so it
|
|
is not mistaken for an HTTP error the app should try to render a page for.
|
|
"""
|
|
|
|
|
|
def _require_secret_key() -> str:
|
|
"""Return the Flask secret key, refusing to start without a real one.
|
|
|
|
A known default secret key means anyone can forge a session cookie and
|
|
log in as the operator, so a missing or placeholder value must stop the
|
|
app rather than silently fall back. The one exception is the test
|
|
suite, which supplies its own key through the config.
|
|
"""
|
|
secret = os.getenv("SECRET_KEY", "").strip()
|
|
if not secret:
|
|
raise ImproperlyConfigured(
|
|
"SECRET_KEY is not set. Export a random value, e.g. "
|
|
"`export SECRET_KEY=$(python -c 'import secrets; print(secrets.token_hex(32))')`."
|
|
)
|
|
if secret.lower() in {"change-me-in-the-deployment", "dev-secret-change-me"}:
|
|
raise ImproperlyConfigured(
|
|
"SECRET_KEY is still the placeholder value. Set a real secret key."
|
|
)
|
|
return secret
|
|
|
|
|
|
def create_app(config: dict | None = None) -> Flask:
|
|
"""Build and configure the Flask application."""
|
|
app = Flask(
|
|
__name__,
|
|
template_folder="templates",
|
|
static_folder="static",
|
|
)
|
|
|
|
app.config.update(
|
|
SECRET_KEY=_require_secret_key(),
|
|
MONGO_HOST=os.getenv("MONGO_HOST", "127.0.0.1"),
|
|
MONGO_USER=os.getenv("MONGO_USER", "root"),
|
|
MONGO_PASS=os.getenv("MONGO_PASS", ""),
|
|
SESSION_COOKIE_HTTPONLY=True,
|
|
SESSION_COOKIE_SAMESITE="Lax",
|
|
# Flask-WTF needs this for the login form to render and validate.
|
|
WTF_CSRF_ENABLED=True,
|
|
)
|
|
if config:
|
|
app.config.update(config)
|
|
|
|
# The pages must not be cached by the browser.
|
|
|
|
# A phone held on to a cached copy of the brewing page and showed
|
|
# the old inline advice instead of the button and the pop-out, so
|
|
# the change was not visible until the cache was cleared by hand.
|
|
@app.after_request
|
|
def _no_store(response):
|
|
if response.mimetype == "text/html":
|
|
response.headers["Cache-Control"] = "no-store, must-revalidate"
|
|
response.headers["Pragma"] = "no-cache"
|
|
return response
|
|
|
|
csrf = CSRFProtect(app)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Request-scoped repository
|
|
# ------------------------------------------------------------------ #
|
|
from mongo.repository import BeerRepository
|
|
|
|
def get_repo() -> BeerRepository:
|
|
"""One Mongo client per request, closed when the request ends."""
|
|
if "repo" not in g:
|
|
# A client can be injected via config (used by the test suite);
|
|
# otherwise build one from the environment.
|
|
injected = app.config.get("MONGO_CLIENT")
|
|
g.repo = BeerRepository(client=injected) if injected else BeerRepository()
|
|
return g.repo
|
|
|
|
app.extensions["beer_repo"] = get_repo
|
|
|
|
@app.teardown_appcontext
|
|
def _close_repo(_exc=None):
|
|
repo = g.pop("repo", None)
|
|
# An injected client (the test suite) is owned by the caller — do not
|
|
# close it, or the next request in the same test gets a dead client.
|
|
if repo is not None and app.config.get("MONGO_CLIENT") is None:
|
|
repo.client.close()
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Auth helpers
|
|
# ------------------------------------------------------------------ #
|
|
def logged_in() -> bool:
|
|
return bool(session.get("logged_in"))
|
|
|
|
@app.context_processor
|
|
def inject_globals():
|
|
return {
|
|
"logged_in": logged_in(),
|
|
"current_path": request.path,
|
|
"app_name": "Brew Log",
|
|
}
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Error handlers
|
|
# ------------------------------------------------------------------ #
|
|
@app.after_request
|
|
def _set_security_headers(response):
|
|
"""Add the standard security headers.
|
|
|
|
The app serves its own vendored JavaScript and CSS and loads nothing
|
|
from a CDN, so a strict Content-Security-Policy of 'self' is enough
|
|
and is genuinely enforceable.
|
|
"""
|
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
|
response.headers.setdefault("Referrer-Policy", "same-origin")
|
|
response.headers.setdefault("Content-Security-Policy", CSP)
|
|
return response
|
|
|
|
@app.errorhandler(404)
|
|
def _not_found(_exc):
|
|
return render_template("error.html", code=404,
|
|
message="That page does not exist."), 404
|
|
|
|
@app.errorhandler(500)
|
|
def _server_error(_exc): # pragma: no cover - defensive
|
|
return render_template("error.html", code=500,
|
|
message="Something broke on our side."), 500
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Blueprints
|
|
# ------------------------------------------------------------------ #
|
|
from views.analysis import bp as analysis_bp
|
|
from views.auth import bp as auth_bp
|
|
from views.brews import bp as brews_bp
|
|
|
|
app.register_blueprint(auth_bp)
|
|
app.register_blueprint(brews_bp)
|
|
app.register_blueprint(analysis_bp)
|
|
|
|
@app.route("/healthz")
|
|
def healthz():
|
|
"""Liveness probe. Never touches Mongo — it answers "is the process
|
|
up", not "is the app working". A database outage must not cause the
|
|
kubelet to restart a perfectly healthy process."""
|
|
return {"status": "ok"}, 200
|
|
|
|
@app.route("/readyz")
|
|
def readyz():
|
|
"""Readiness probe. Checks that Mongo is actually reachable, so a
|
|
pod with no database is taken out of the Service's endpoints instead
|
|
of serving errors."""
|
|
try:
|
|
repo = get_repo()
|
|
repo.client.admin.command("ping")
|
|
except Exception as exc: # noqa: BLE001 - any failure means not ready
|
|
return {"status": "not ready", "error": str(exc)}, 503
|
|
return {"status": "ready"}, 200
|
|
|
|
@app.route("/")
|
|
def index():
|
|
if logged_in():
|
|
return redirect(url_for("brews.index"))
|
|
return redirect(url_for("auth.login"))
|
|
|
|
return app
|
|
|
|
|
|
def render_template(*args, **kwargs): # noqa: D401 - re-export for views
|
|
"""Small shim so view modules can import ``render_template`` from one place."""
|
|
from flask import render_template as _rt
|
|
|
|
return _rt(*args, **kwargs)
|