# CLAUDE.md — JQC Features Site Canonical rules and context for the **jqc-features** project. Update at the end of every session. ## Purpose Public marketing page for LT Services' JQC program, plus a small admin panel to edit its content. Separate codebase from the main JQC app (`janitorial_qc`). Content = numbered sections, each with expandable topics (text / link / photo / video). Served at `jqcfeatures.ltservicesinc.com`. ## Stack Flask · SQLAlchemy · MySQL 8 · Flask-WTF (CSRF) · Gunicorn · systemd · Nginx · Ubuntu 24.04. Fonts via Google Fonts (Bricolage Grotesque, IBM Plex Sans/Mono). ## Layout (deployment) - App dir: `/home/jqc/jqc_features` (user `jqc`) - venv: `/home/jqc/jqc_features/venv` - `.env` beside `config.py` (app loads it itself — see learnings) - systemd service: `jqc-features` - Auth log: `/home/jqc/jqc_features/logs/auth.log` ## Structure - `app.py` — factory, models (`Section`, `Topic`, `AuditLog`), `log_action()`, ProxyFix wrap, `_configure_auth_logger()`, public routes `/`, `/healthz`. - `admin.py` — blueprint `/admin`: session login, section/topic CRUD, `/admin/audit` read-only audit viewer (filter by action/entity, 50/page). - `config.py` — env-driven config + `_load_dotenv()` (no-expansion loader). - `templates/` — public `index.html`; `admin/` base+login+dashboard+forms. - `static/css/style.css` (public), `static/css/admin.css`, `static/js/main.js`. - `schema.sql` — DDL + idempotent seed. `add_admin.sql` — additive audit_log. - `deploy/` — `jqc-features.service`, `nginx.conf`, `fail2ban/`. ## Data model - `section(id, num UNIQUE, title, subtitle, sort_order)` - `topic(id, section_id FK cascade, slug UNIQUE, title, body_html, link_url, link_label, media_type[none|image|video|embed], media_url, media_caption, sort_order)` - `audit_log(id, actor, action, entity, entity_id, detail, created_at)` Public page orders sections by `sort_order, num`; topics by `sort_order`. ## Conventions (follow every change) - Surgical, additive patches. Preserve route/function/variable names. - `log_action()` is called ONLY AFTER `db.session.commit()` of the change. - Migrations are idempotent: `CREATE TABLE IF NOT EXISTS`; seed uses `INSERT ... ON DUPLICATE KEY UPDATE`. Safe to re-run. - Every POST form includes `{{ csrf_token() }}`; Flask-WTF `CSRFProtect` is global. - Content `body_html` is admin-authored and trusted → rendered via `Markup`. - Read files on disk before editing; verify the patch after applying. ## Auth / admin design - Single admin account. Password stored ONLY as a Werkzeug hash in `ADMIN_PASSWORD_HASH`; empty hash rejects all logins by design. - Session flag `session['admin']`; `login_required` decorator gates all CRUD. - `next` redirect is restricted to paths starting `/admin` (open-redirect guard). - Username compared with `hmac.compare_digest`; password with `check_password_hash` (constant-time). ## Security learnings (this session) - **`.env` is not read automatically.** The app only sees process env. systemd `EnvironmentFile=` OR the in-app `_load_dotenv()` must supply vars. We added `_load_dotenv()` so the `.env` beside `config.py` is authoritative regardless of systemd. systemd-injected vars still win (`os.environ.setdefault`). - **No variable expansion when loading `.env`.** Werkzeug hashes contain `$`; shell/dotenv interpolation corrupts them. `_load_dotenv()` does a plain split, strips matched surrounding quotes, no expansion. - **`SESSION_COOKIE_SECURE=1` breaks login over plain HTTP.** The session cookie (which holds the CSRF token) is marked `Secure`, so the browser drops it on HTTP → "CSRF session token is missing." Serve HTTPS in prod; only set `0` for local HTTP testing. - **Empty `SECRET_KEY` also kills sessions** → same CSRF error. Must be set, stable, secret. Changing it logs everyone out. - **CSRF token 1h default 400s long edits.** `WTF_CSRF_TIME_LIMIT` config: blank → `None` (token valid for the whole session, cookie-bound); integer overrides. `audit_log.created_at` is UTC (`datetime.utcnow`); the viewer labels it UTC. - **systemd reads `EnvironmentFile` only at start** → `systemctl restart` after any `.env` edit; `daemon-reload` after unit edits. - **Real client IP behind nginx:** `request.remote_addr` is 127.0.0.1 without `ProxyFix`. We wrap `app.wsgi_app = ProxyFix(..., x_for=1, x_proto=1, x_host=1)` and nginx sets `X-Forwarded-For`/`-Proto`. gunicorn binds 127.0.0.1, so headers can't be spoofed externally. `x_proto=1` also fixes https redirects. ## fail2ban - App writes `logs/auth.log` via the `jqc.auth` logger (RotatingFileHandler, 1 MB × 5, own handler, `propagate=False`). - Line format: ` jqc.auth WARNING FAILED LOGIN user= from `. - Username is sanitized before logging (`\s+`→space, cap 64) to kill CR/LF log-injection; the real IP is the LAST token and the filter regex anchors `from \s*$`, so a crafted username can't spoof the ban target. - Filter `deploy/fail2ban/filter.d/jqc-admin.conf` (datepattern uses `%%` — ConfigParser escaping). Jail `deploy/fail2ban/jail.d/jqc-admin.local`: 5 fails / 10 min → 1 h ban. - CSRF-less POST floods get HTTP 400 before the view, so they don't reach the auth log — add nginx `limit_req` on `/admin/login` if that traffic matters. ## Deploy delta cheatsheet ```bash sudo mysql < add_admin.sql # audit_log (existing DBs) # .env: SECRET_KEY, ADMIN_USERNAME, ADMIN_PASSWORD_HASH, SESSION_COOKIE_SECURE=1 sudo ./venv/bin/pip install -r requirements.txt sudo systemctl restart jqc-features # fail2ban: sudo cp deploy/fail2ban/filter.d/jqc-admin.conf /etc/fail2ban/filter.d/ sudo cp deploy/fail2ban/jail.d/jqc-admin.local /etc/fail2ban/jail.d/ sudo systemctl restart fail2ban ``` Password hash: `./venv/bin/python -c "from werkzeug.security import generate_password_hash as g; print(g('PW'))"`