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 @@ -