diff --git a/.claude/skills/deploy-massprice/SKILL.md b/.claude/skills/deploy-massprice/SKILL.md index fb407c0..f7c79c5 100644 --- a/.claude/skills/deploy-massprice/SKILL.md +++ b/.claude/skills/deploy-massprice/SKILL.md @@ -69,9 +69,11 @@ cd /mnt/user/appdata/massprice && docker compose restart `auth_state.json` (the saved MassPrice session imported via the Session page — a live bearer credential for the user's MassPrice account while valid, see `CLAUDE.md`; losing this just costs one re-import, not data), -`run_history.json`, `rules_cache.json`, and `notify_state.json` (the -session-expiry alert's send-once dedupe flag). Both are excluded from the -deploy rsync (which runs `--delete`) and from git. +`run_history.json`, `rules_cache.json`, `session_state.json` (the +"session is known-bad" flag behind the dashboard banner), and +`notify_state.json` (the session-expiry alert's send-once dedupe flag). +Both are excluded from the deploy rsync (which runs `--delete`) and from +git. ## If scheduled runs stop working @@ -84,9 +86,15 @@ Check, in order: it recurs (e.g. a MassPrice redesign changes the modal/selectors), this is the first thing to suspect — see `CLAUDE.md`. 1. **Dashboard banner / Activity page** — a "session expired or missing" - message means go log in again via the Session page. "rule id not - found" means a rule was renamed/deleted on MassPrice — click "Refresh - Rules". + message means go log in again via the Session page. "Rule id … not + found on /Rule/List" means that rule was **deleted** on MassPrice + (a rename can't cause it — schedules are keyed by the stable rule + id): click "Refresh Rules", which re-syncs `schedules.json` against + the live list, drops schedules for deleted rules, and updates cached + names for renamed ones. Renames matter because run verification + matches the Activity page by name — a stale one shows every run as + "Not Confirmed" while it's actually succeeding, so refresh after + renaming anything. 2. **Is the container still running** — `docker ps --filter name=massprice` — a Chromium crash inside the container (rare, but possible if `shm_size` ever gets dropped from `docker-compose.yml`) diff --git a/.env.example b/.env.example index c125470..419842d 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,14 @@ TZ=America/Phoenix VERIFY_POLL_MINUTES=5 VERIFY_MAX_ATTEMPTS=12 +# ── Request patience ───────────────────────────────────────────────── +# How long "Refresh Rules" / "Import Session" wait for the single +# browser worker before answering "still working, reload in a moment" +# (the work still finishes in the background). Keep it comfortably under +# gunicorn's 120s timeout — a killed worker takes the scheduler and all +# pending verification polls with it. Rarely worth changing. +REQUEST_WAIT_SECONDS=90 + # ── Session-expiry email alert (optional) ─────────────────────────── # Sent once when the saved MassPrice session expires/is missing — not # on every failed run, just the first one after it goes bad. Same diff --git a/CLAUDE.md b/CLAUDE.md index f71f80e..50870dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,11 +30,25 @@ tool is confirmed working, so there's exactly one thing triggering runs. - **`scheduler.py`** — an in-process APScheduler `BackgroundScheduler` holding one `CronTrigger` job per `(rule, schedule entry)` pair, rebuilt from `schedules.json` on startup and whenever the webui saves - a change. Triggered jobs (and manual "Run Now" clicks) are pushed onto - one `queue.Queue`, drained by a single dedicated worker thread — this - guarantees only one Playwright browser session is ever active at a - time, matching how a human would actually operate the site and - avoiding concurrent-session weirdness. + a change. **Every** Playwright session goes through `submit()` onto + one `queue.Queue`, drained by a single dedicated worker thread — + scheduled runs, "Run Now", "Refresh Rules" and cookie imports alike. + That guarantees only one browser is ever active at a time, matching + how a human would actually operate the site and avoiding + concurrent-session weirdness. Nothing may call `automation.py` + directly from a Flask request thread: with `threads = 4` that opens a + second Chromium alongside a scheduled run, and both write + `auth_state.json` — which Playwright writes itself, non-atomically — + so a torn write reads back as "session expired" and costs a pointless + cookie re-import. + - Request handlers that need an answer (refresh, import) use + `run_browser_task()`, which waits `REQUEST_WAIT_SECONDS` (90) and + then raises `BrowserBusy` → HTTP 503, *without* cancelling the work. + The bound is the point: gunicorn kills any worker whose request + exceeds its `timeout` (120s), and since the jobstore and queue live + in that worker's memory, being killed drops every pending + verification poll. Waiting behind a slow rule run must never be able + to take the scheduler down. - **Must run in exactly one process.** `gunicorn.conf.py` pins `workers = 1` for this reason — a second worker would instantiate a second scheduler and duplicate every run. Fine for a low-traffic @@ -58,7 +72,15 @@ tool is confirmed working, so there's exactly one thing triggering runs. - **`automation.py`** — all Playwright logic. `list_rules()` scrapes `https://massprice.tcgplayer.com/Rule/List` for the live rule id/name pairs (cached to `rules_cache.json`, refreshed via the - Dashboard's "Refresh Rules" button or on first boot). `run_rule(id)` + Dashboard's "Refresh Rules" button or on first boot). A refresh also + runs `_reconcile_schedules()` in `app.py`, which re-syncs + `schedules.json` against that live list: rule **ids** are stable but + names aren't, and a stale cached name silently breaks run + verification (it's what the Activity-page match keys on), while a + rule deleted upstream would otherwise keep firing cron jobs with no + card left in the UI to disable them. Deliberately skipped when the + scrape returns an empty list, so a MassPrice redesign that breaks + the scrape can't delete every saved schedule. `run_rule(id)` finds that rule's `li.rule.widget-rule` row, clicks its `.dropdown-ellipsis` three-dot toggle, clicks the row's `a.run-rule[data-rule-id=...]` item — mirroring exactly what a human @@ -118,10 +140,16 @@ tool is confirmed working, so there's exactly one thing triggering runs. against), the run fails with a clear "session expired or missing" error rather than attempting to log in — see below for why. - **`storage.py`** — tiny JSON read/write helpers (temp-file + - `os.replace`, so a crash mid-write can't corrupt state) for - everything under `/data`: `schedules.json`, `auth_state.json`, - `run_history.json` (bounded to the last 200 entries), `rules_cache.json`, + `os.replace`, so a crash mid-write can't corrupt state) for the state + files under `/data`: `schedules.json`, `run_history.json` (bounded to + the last 200 entries), `rules_cache.json`, `session_state.json` (the + "is the saved session known-bad" flag behind the dashboard banner) and `notify_state.json` (the session-expiry alert's send-once dedupe flag). + `auth_state.json` lives in the same directory but is **not** written + through here — Playwright writes it itself via + `context.storage_state(path=...)` in `automation._save_state()`, so it + gets none of that atomic-write protection. Worth knowing: it's also + the one file two concurrent Playwright sessions can both write. - **`templates/` / `static/`** — a small, plain dark dashboard. This is a deliberately different, simpler visual identity than Arr Summary's teletext system — that design language is specific to that app, not a @@ -175,9 +203,16 @@ automated run re-saves it (`_save_state()`), which tends to slide a expire (inactivity, a password change, TCGplayer forcing re-auth) and need re-importing. -The Dashboard shows a red banner (`_session_warning()` in `app.py`) when -the most recent run/refresh failed specifically because of a missing/ -expired session, so this doesn't just look like "no rules found." +The Dashboard shows a red banner (`_session_warning()` in `app.py`) +whenever the saved session is known to be missing/expired, so this +doesn't just look like "no rules found." The flag behind it lives in +`session_state.json` and is set by `automation._ensure_authenticated()` +on *any* failed page load — a scheduled run, a "Refresh Rules", or a +verification poll — and cleared the moment one authenticates again (or a +fresh export is imported). It's deliberately not inferred from +`run_history.json`: `list_rules()` writes no history entry, so a failed +refresh — often the first symptom the user sees — would never raise the +banner. ## Session-expiry email alert @@ -210,9 +245,10 @@ python3 app.py Runs on port 5000 (`PORT` env to change). Without Chromium installed and without a saved `auth_state.json`, the Dashboard still boots — -`_bootstrap()` in `app.py` best-effort-catches the initial rule sync, so -local UI/schedule-editing work doesn't require Docker or a real MassPrice -session at all. `DATA_DIR` env var overrides where JSON state is +`_bootstrap()` in `app.py` queues the initial rule sync instead of +running it inline, so the failure lands in the worker thread's log and +local UI/schedule-editing work doesn't require Docker or a real +MassPrice session at all. `DATA_DIR` env var overrides where JSON state is read/written (defaults to `/data`, which won't exist locally — point it at a scratch directory). diff --git a/app.py b/app.py index 55232b6..be0b91f 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,6 @@ import hmac import os +import secrets from datetime import datetime, timedelta, timezone from functools import wraps @@ -9,13 +10,20 @@ import automation import scheduler import storage -APP_VERSION = "0.8.0" +APP_VERSION = "0.10.0" APP_VERSION_DATE = "2026-09-01" app = Flask(__name__) -app.secret_key = os.environ.get("SECRET_KEY", "dev-secret-change-me") +# `or default`, not `get(name, default)`: docker-compose.yml always *sets* +# these vars, so a missing/blank value in .env arrives as an empty string +# and the two-arg default never applies. An empty secret_key makes Flask +# raise on every session write, and an empty LOGIN_PASSWORD would make +# compare_digest("", "") return True — i.e. no login at all. +app.secret_key = os.environ.get("SECRET_KEY") or "dev-secret-change-me" +app.config["SESSION_COOKIE_HTTPONLY"] = True +app.config["SESSION_COOKIE_SAMESITE"] = "Lax" -LOGIN_PASSWORD = os.environ.get("LOGIN_PASSWORD", "changeme") +LOGIN_PASSWORD = os.environ.get("LOGIN_PASSWORD") or "changeme" DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] DAY_LABELS = {"mon": "Mon", "tue": "Tue", "wed": "Wed", "thu": "Thu", "fri": "Fri", "sat": "Sat", "sun": "Sun"} @@ -25,15 +33,65 @@ def login_required(view): @wraps(view) def wrapped(*args, **kwargs): if not session.get("authenticated"): + # The JSON API answers 401 instead of redirecting: fetch() + # follows a 302 to /login, gets 200 HTML back, and the caller + # reads res.ok as success — "Run Now" would report "Queued" + # for a run that was never queued. + if request.path.startswith("/api/"): + return jsonify({"error": "Signed out — reload the page to log in again."}), 401 return redirect(url_for("login", next=request.path)) return view(*args, **kwargs) return wrapped +def _csrf_token(): + token = session.get("csrf_token") + if not token: + token = secrets.token_urlsafe(32) + session["csrf_token"] = token + return token + + +def csrf_protect(view): + """Header-based CSRF check for the JSON API. The token is minted per + browser session, handed to the page in a tag, and echoed back + by app.js in X-CSRF-Token — a cross-origin page can read neither the + meta tag nor the session cookie's contents, so it can't forge the + header, and a plain cross-site