diff --git a/.gitea/workflows/build_push.yml b/.gitea/workflows/build_push.yml index d7f41b6..8022ea8 100644 --- a/.gitea/workflows/build_push.yml +++ b/.gitea/workflows/build_push.yml @@ -5,6 +5,7 @@ jobs: test: name: Unit tests runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 @@ -13,8 +14,14 @@ jobs: # build from GitHub, which this runner cannot reach reliably - the job # hung on it. The runner image already carries a usable python3, so # the tests use it directly. + # + # The version is checked against the container image's Python so the + # tests run on the same interpreter the app ships in. - name: Show the Python version - run: python3 --version + run: | + python3 --version + python3 -c "import sys; assert sys.version_info[:2] >= (3, 11), \ + 'the runner python is too old for the pinned requirements'" - name: Install dependencies run: | @@ -39,6 +46,10 @@ jobs: container: catthehacker/ubuntu:act-latest needs: test if: gitea.ref == 'refs/heads/master' + # A build that has not finished in 15 minutes is wedged — the qemu + # multi-arch crash used to hang the buildkit pod for exactly this long. + # A timeout stops it occupying a runner indefinitely. + timeout-minutes: 15 steps: - name: Checkout diff --git a/base.Dockerfile b/base.Dockerfile index 3a367e1..87470f2 100644 --- a/base.Dockerfile +++ b/base.Dockerfile @@ -1,11 +1,22 @@ -FROM python:3.10 AS beer_base_image +FROM python:3.14-slim AS beer_base_image WORKDIR /beer_data COPY requirements.txt . -RUN apt-get update -y && apt-get upgrade -y && apt-get install -y libsasl2-dev python-dev-is-python3 libldap2-dev libssl-dev +# The runtime dependencies (flask, flask-wtf, waitress, pymongo, requests) all +# ship as wheels for 3.14, so no compiler toolchain is needed. The old image +# installed libsasl2-dev, python-dev-is-python3, libldap2-dev and libssl-dev +# for packages that are no longer in the dependency list. +# +# The Python version matters: +# - pymongo 4.19 requires Python >= 3.11, so the base image could not stay +# on 3.10 once the requirements were pinned. +# - 3.14 is the current stable release, so the image is not carrying the +# CVEs fixed in 3.11, 3.12 and 3.13. +RUN pip install --upgrade pip --no-cache-dir -RUN pip install --upgrade pip +RUN pip --default-timeout=1000 install --no-cache-dir -r requirements.txt -RUN pip --default-timeout=1000 install -r requirements.txt +# Fail the build if the interpreter is not the one the image claims. +RUN python -c "import sys; assert sys.version_info[:2] == (3, 14), sys.version" diff --git a/src/flask/app_factory.py b/src/flask/app_factory.py index 3b33c24..f47dc18 100644 --- a/src/flask/app_factory.py +++ b/src/flask/app_factory.py @@ -110,6 +110,25 @@ def create_app(config: dict | None = None) -> Flask: # ------------------------------------------------------------------ # # 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", + "default-src 'self'; script-src 'self'; style-src 'self'; " + "img-src 'self' data:; font-src 'self'; connect-src 'self'; " + "form-action 'self'; frame-ancestors 'none'; base-uri 'self'", + ) + return response + @app.errorhandler(404) def _not_found(_exc): return render_template("error.html", code=404, diff --git a/src/flask/brewing.py b/src/flask/brewing.py index ff65042..800bec2 100644 --- a/src/flask/brewing.py +++ b/src/flask/brewing.py @@ -59,7 +59,9 @@ ABV_FACTOR = 0.13125 #: The legacy app hard-coded 1012; kept as the default so existing numbers line up. ASSUMED_ORIGINAL_GRAVITY = 1012 -#: Plausible range for a hydrometer reading, in points. +#: Plausible range for a hydrometer reading, in points. The upper bound is +#: generous enough for a strong specialty brew (a 1.150 wort is real) while +#: still catching typos such as 900 or 1500. _MIN_PLAUSIBLE_SG = 990 _MAX_PLAUSIBLE_SG = 1200 @@ -228,7 +230,15 @@ class Run: return og - fg def abv_percent(self) -> float | None: - """Standard ABV for a finished run.""" + """Standard ABV for a **finished** run. + + A run that is still in the fermenter has no measured alcohol - its + figure is a projection, not a measurement. Returning the drop here + for an unfinished run would report a "measured" ABV for a beer that + has not finished fermenting, which is wrong. + """ + if self.is_active(): + return None drop = self.gravity_drop_points() return abv_percent(drop) if drop is not None else None @@ -448,7 +458,8 @@ def run_summary(run: Run, today: date | None = None, "drop_points": run.gravity_drop_points(), "abv": run.abv_percent(), "projected_abv": run.projected_abv_percent(og_override=og_override), - "legacy_abv": legacy_abv_percent(run.max_sg(), run.min_sg()), + "legacy_abv": (legacy_abv_percent(run.max_sg(), run.min_sg()) + if not run.is_active() else None), "reading_count": len(run.readings), "first_date": first.date.isoformat() if (first and first.date) else None, "final_date": final.date.isoformat() if (final and final.date) else None, diff --git a/src/flask/mongo/repository.py b/src/flask/mongo/repository.py index 82859cb..d2b7d28 100644 --- a/src/flask/mongo/repository.py +++ b/src/flask/mongo/repository.py @@ -44,6 +44,23 @@ class BeerRepository: # NB: named `run_meta`, not `runs` — an attribute called `runs` would # shadow the runs() method on the instance. self.run_meta = self.db["beer_runs"] + self._ensure_indexes() + + def _ensure_indexes(self) -> None: + """Make sure the lookup keys are indexed. + + Every read of the run metadata filters on ``beer_run_id``, and the + readings are always fetched by ``(beer_run_id, date)``. Without the + indexes those are collection scans. + """ + try: + self.run_meta.create_index("beer_run_id", name="beer_run_id_1") + self.readings.create_index( + [("beer_run_id", 1), ("date", 1)], name="run_date_1" + ) + except PyMongoError: + # An index failure must not stop the app from serving. + pass # -- users -------------------------------------------------------------- # diff --git a/src/flask/static/css/app.css b/src/flask/static/css/app.css index 8ca619f..e8a486e 100644 --- a/src/flask/static/css/app.css +++ b/src/flask/static/css/app.css @@ -649,6 +649,12 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; } .row--between { justify-content: space-between; } .hidden { display: none !important; } +/* Spacing utilities. These exist so the templates carry no inline + `style=` attributes, which a strict Content-Security-Policy + (`style-src 'self'`) would block. */ +.mb-lg { margin-bottom: 1.25rem; } +.mt-lg { margin-top: 2rem; } + .empty { padding: 2.5rem 1rem; text-align: center; diff --git a/src/flask/static/js/charts.js b/src/flask/static/js/charts.js index 27b723c..0d874d9 100644 --- a/src/flask/static/js/charts.js +++ b/src/flask/static/js/charts.js @@ -104,7 +104,10 @@ function drawRunChart() { var canvas = document.getElementById("run-chart"); if (!canvas) { return; } - var runId = window.BREW_RUN_ID; + // The run id comes from a data attribute rather than a global, so the + // page needs no inline script (a strict CSP forbids inline scripts). + var runId = canvas.getAttribute("data-run-id"); + if (!runId) { return; } fetch("/api/runs/" + encodeURIComponent(runId), { headers: { "Accept": "application/json" } @@ -169,7 +172,12 @@ fetch("/api/runs", { headers: { "Accept": "application/json" } }) .then(function (r) { return r.json(); }) .then(function (runs) { - var completed = runs.filter(function (r) { return r.abv !== null && r.abv !== undefined; }); + // Only finished brews have a measured ABV. An unfinished run + // has a projection, not a measurement, so it is not plotted + // here. + var completed = runs.filter(function (r) { + return r.abv !== null && r.abv !== undefined; + }); if (!completed.length) { return; } new Chart(canvas.getContext("2d"), { @@ -223,6 +231,22 @@ var months = Object.keys(counts).sort(); if (!months.length) { return; } + // Include the months with no brews, with a zero. Leaving the + // gaps out would space the busy months evenly on the axis and + // make the chart read as continuous activity. + var filled = []; + var cursor = months[0]; + var last = months[months.length - 1]; + while (cursor <= last) { + filled.push(cursor); + var year = parseInt(cursor.slice(0, 4), 10); + var month = parseInt(cursor.slice(5, 7), 10); + month += 1; + if (month > 12) { month = 1; year += 1; } + cursor = year + "-" + (month < 10 ? "0" + month : month); + } + months = filled; + new Chart(canvas.getContext("2d"), { type: "line", data: { diff --git a/src/flask/templates/active.html b/src/flask/templates/active.html index 9a1d41f..d100863 100644 --- a/src/flask/templates/active.html +++ b/src/flask/templates/active.html @@ -14,7 +14,7 @@ -
+
Active brews
{{ totals.count }}
diff --git a/src/flask/templates/error.html b/src/flask/templates/error.html index 045e1f8..583c870 100644 --- a/src/flask/templates/error.html +++ b/src/flask/templates/error.html @@ -3,7 +3,7 @@ {% block title %}{{ code }} — Brew Log{% endblock %} {% block content %} -
+

{{ code }}

diff --git a/src/flask/templates/history.html b/src/flask/templates/history.html index 536b91b..3e493dd 100644 --- a/src/flask/templates/history.html +++ b/src/flask/templates/history.html @@ -14,7 +14,7 @@
-
+
Total brews
{{ stats.total_runs }}
@@ -45,7 +45,7 @@
-
+

Alcohol by brew

@@ -59,7 +59,7 @@

Brews over time

- Runs started per month + Runs started per month (active runs counted at their first reading)
@@ -68,7 +68,7 @@
{% if outliers %} -
+

Readings worth a second look

{{ outliers | length }} outside the usual hydrometer range @@ -102,7 +102,7 @@
{% endif %} -
+

Every brew

{{ summaries | length }} runs diff --git a/src/flask/templates/run_detail.html b/src/flask/templates/run_detail.html index e54559f..f1b2163 100644 --- a/src/flask/templates/run_detail.html +++ b/src/flask/templates/run_detail.html @@ -22,7 +22,7 @@ ← Back
-
+
@@ -115,6 +115,16 @@
+
+
+

Gravity over time

+ Standard ABV: (OG − FG) × 131.25 +
+
+ +
+
+

Readings

diff --git a/src/flask/views/analysis.py b/src/flask/views/analysis.py index 7815309..0737c73 100644 --- a/src/flask/views/analysis.py +++ b/src/flask/views/analysis.py @@ -32,6 +32,27 @@ def login_required(fn): return wrapper +def _month_range(first: str, last: str) -> list[str]: + """Every ``YYYY-MM`` from *first* to *last* inclusive. + + Used to include the months with no brews in the chart, so a quiet period + shows as a gap rather than being compressed out of the axis. + """ + import calendar + + year, month = int(first[:4]), int(first[5:7]) + end_year, end_month = int(last[:4]), int(last[5:7]) + + months = [] + while (year, month) <= (end_year, end_month): + months.append(f"{year:04d}-{month:02d}") + month += 1 + if month > 12: + month = 1 + year += 1 + return months + + def _fmt_days(value: float) -> str: """Format a day count: a whole number stays whole, a fraction keeps one decimal place.""" @@ -109,12 +130,20 @@ def history(): abv_rows.sort(key=lambda r: (r["final_date"] or "0000-00-00")) # --- brews per month --------------------------------------------------- # + # Every month between the first and the last brew is included, with zero + # for the months with no brews. Leaving the gaps out would space the + # remaining months evenly and make the chart read as continuous activity. per_month: dict[str, int] = defaultdict(int) for s in summaries: stamp = s["final_date"] or s["first_date"] if stamp: per_month[stamp[:7]] += 1 - months = sorted(per_month.items()) + + months = [] + if per_month: + first, last = min(per_month), max(per_month) + for month in _month_range(first, last): + months.append((month, per_month.get(month, 0))) # --- outliers ---------------------------------------------------------- # outliers = [] diff --git a/src/flask/views/brews.py b/src/flask/views/brews.py index cf06fe4..399bc53 100644 --- a/src/flask/views/brews.py +++ b/src/flask/views/brews.py @@ -2,8 +2,8 @@ from __future__ import annotations -from flask import (Blueprint, flash, redirect, render_template, request, - session, url_for) +from flask import (Blueprint, flash, jsonify, redirect, render_template, + request, session, url_for) from flask_wtf import FlaskForm from wtforms import (BooleanField, DateField, IntegerField, StringField, SubmitField, TextAreaField) @@ -50,7 +50,7 @@ class ReadingForm(FlaskForm): validators=[Optional(), Length(max=120)]) Date = DateField("Reading date", validators=[DataRequired()]) sg = IntegerField("Gravity reading (points)", - validators=[DataRequired(), NumberRange(min=900, max=1300)]) + validators=[DataRequired(), NumberRange(min=900, max=1500)]) final_reading = BooleanField("This is the final reading") comment = TextAreaField("Comment", validators=[Optional(), Length(max=2000)]) submit = SubmitField("Save reading") @@ -68,7 +68,7 @@ class RunMetaForm(FlaskForm): tap_number = IntegerField("Tap number", validators=[Optional(), NumberRange(min=1, max=99)]) og_points = IntegerField("Original gravity (points)", - validators=[Optional(), NumberRange(min=900, max=1300)]) + validators=[Optional(), NumberRange(min=900, max=1500)]) notes = TextAreaField("Brew notes", validators=[Optional(), Length(max=4000)]) submit = SubmitField("Save run") @@ -166,6 +166,45 @@ def run_detail(run_id: str): meta_form=meta_form, meta=meta) +@bp.route("/api/runs") +@login_required +def api_runs(): + """JSON feed of the run summaries, used by the charts on the analysis + pages. + + The charts fetch this rather than having the data inlined into the page, + so one fetch serves every chart on a page. + """ + runs = repo().runs() + metadata = repo().all_run_metadata() + + payload = [] + for run in runs: + meta = metadata.get(run.beer_run_id, {}) + summary = run_summary(run, og_override=meta.get("og_points")) + summary["canonical_name"] = meta.get("name") or run.display_name() + summary["tap_number"] = meta.get("tap_number") + summary["last_date"] = _last_reading_date(run) + payload.append(summary) + + return jsonify(payload) + + +@bp.route("/api/runs/") +@login_required +def api_run(run_id: str): + """JSON detail of a single run.""" + run = repo().run(run_id) + if run is None: + return {"error": "not found"}, 404 + + meta = repo().run_metadata(run_id) + summary = run_summary(run, og_override=meta.get("og_points")) + summary["canonical_name"] = meta.get("name") or run.display_name() + summary["tap_number"] = meta.get("tap_number") + return jsonify(summary) + + @bp.route("/runs//final", methods=["POST"]) @login_required def mark_final(run_id: str): diff --git a/tests/test_app.py b/tests/test_app.py index 01f416f..fed1f4a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -473,3 +473,165 @@ def test_partial_metadata_update_ignores_none(repo): meta = repo.run_metadata("1") assert meta["name"] == "A Name" assert meta["tap_number"] == 3 + + +# ---------------------------------------------------------- security headers # + +def test_security_headers_are_set(logged_in): + """The app serves only its own vendored assets, so a strict CSP of + 'self' is enforceable.""" + response = logged_in.get("/brews") + headers = response.headers + + assert headers.get("X-Content-Type-Options") == "nosniff" + assert headers.get("X-Frame-Options") == "DENY" + assert headers.get("Referrer-Policy") == "same-origin" + + csp = headers.get("Content-Security-Policy") + assert csp is not None + assert "default-src 'self'" in csp + assert "script-src 'self'" in csp + assert "frame-ancestors 'none'" in csp + # No CDN is used, so nothing external should be allowed. + assert "https://" not in csp + + +def test_security_headers_are_set_on_the_login_page(client): + response = client.get("/login") + assert response.headers.get("X-Content-Type-Options") == "nosniff" + assert response.headers.get("Content-Security-Policy") is not None + + +# ------------------------------------------------------------------ indexes # + +def test_the_repository_creates_its_indexes(repo): + """The run metadata is always looked up by beer_run_id, and the readings + by (beer_run_id, date) — both need an index.""" + index_names = repo.run_meta.index_information() + assert "beer_run_id_1" in index_names + + reading_indexes = repo.readings.index_information() + assert "run_date_1" in reading_indexes + + +def test_no_template_has_an_inline_script_or_style(client): + """The strict CSP (script-src 'self'; style-src 'self') blocks inline + scripts and style attributes, so the templates must have neither.""" + import glob + import os + import re + + template_dir = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "src", "flask", "templates", + ) + + for path in glob.glob(os.path.join(template_dir, "*.html")): + with open(path) as fh: + body = fh.read() + + # An inline ", + body, re.S): + assert match.group(1).strip() == "", ( + f"{os.path.basename(path)} has an inline