Files
JQC_features/CLAUDE.md
T

5.8 KiB
Raw Blame History

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 startsystemctl 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: <ts> jqc.auth WARNING FAILED LOGIN user=<u> from <ip>.
  • 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 <HOST>\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

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'))"