Files
JQC_features/CLAUDE.md
T

9.7 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, is_published)
  • audit_log(id, actor, action, entity, entity_id, detail, created_at)

Public page orders sections by sort_order, num; topics by sort_order, and shows only is_published topics (sections with no published topics are hidden).

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.

Content editing (rich text / publish / reorder)

  • Rich text: Quill 2.0.3 vendored at static/vendor/quill.min.js + quill.snow.css (no CDN — survives a locked-down server or strict CSP). It's a progressive enhancement over a real <textarea name="body_html" id="body_src">: body.quill-on is added only after new Quill() succeeds (hides the textarea, shows the editor). If Quill fails to load, the textarea stays usable and a save never wipes the body. The submit handler keeps the body when it contains an img/table even though getText() is empty for embed-only content.
  • Images: the custom #editor-toolbar has a ql-image button. Images upload via POST /admin/upload (login_required, CSRF via X-CSRFToken header): extension allowlist + magic-byte sniff (_sniff_image, SVG excluded), 8 MB cap, saved as a random uuid4().hex.<ext> under static/uploads/, returns {url}; the handler insertEmbeds it (no base64 → DB stays small).
  • Tables: quill-table-better 1.2.3 vendored (static/vendor/quill-table-better.js
    • .css, UMD → reads global Quill, exposes QuillTableBetter; self-contained, no CDN/CSP issues). Registered as modules/table-better; Quill 2's basic table module is disabled (table: false). The ql-table-better toolbar button opens a size picker to insert; column/row drag-resize and a floating cell menu (alignment, borders, background, merge/split) are the module's own UI — no custom wiring. Init is guarded by typeof QuillTableBetter so a failed vendor load still leaves a working editor + textarea. Registration and keyboard bindings (QuillTableBetter.keyboardBindings) live in topic_form.html.
  • Sanitize on save: sanitize_html() (bleach) runs on every body_html write — ALLOWED_TAGS/ALLOWED_ATTRS cover img + full table tags with colspan/rowspan/data-*/style; a CSSSanitizer (bleach[css] + tinycss2) filters inline style to ALLOWED_CSS_PROPS (width/height/padding/text-align/ vertical-align/background-color/border*) so the table editor's sizing & alignment survive but position, behavior, url(javascript:) etc. are stripped. bleach also restricts URL protocols on href/img src to http/https/mailto (blocks javascript:/data:; uploads are relative /static paths). Empty / <p><br></p> is stored NULL. Public renders body_html via Markup, sanitized at the source.
  • Draft/publish: topic.is_published (default 1, so existing rows stay live). Public route filters to Section.published_topics and drops sections with none. Admin: per-topic /topic/<id>/toggle (quick button) + a Published checkbox on the form; dashboard shows a draft badge. New topics default published (least surprise); uncheck to stage a draft.
  • Drag reorder: SortableJS 1.15.2 vendored. Dashboard drags post JSON to /admin/reorder with the CSRF token from <meta name="csrf-token"> sent as the X-CSRFToken header (Flask-WTF accepts that header for AJAX). Payloads: {type:'topic',section_id,order:[ids]} (within-section only) or {type:'section',order:[ids]}; sort values rewritten 10,20,30…. The topic branch filters Topic.section_id == section_id so a spoofed cross-section id is ignored. Cross-section moves are done via the section dropdown on the form.
  • CDN caveat (testing): the build sandbox proxy 403s all external CDNs incl. Google Fonts — that's why libs are vendored. Fonts still load fine on the real server; they degrade to system fonts if blocked.

Deploy delta cheatsheet

sudo mysql < add_admin.sql                      # audit_log (existing DBs)
sudo mysql < add_publish.sql                    # topic.is_published (existing DBs)
# .env: SECRET_KEY, ADMIN_USERNAME, ADMIN_PASSWORD_HASH, SESSION_COOKIE_SECURE=1
sudo ./venv/bin/pip install -r requirements.txt  # bleach[css] + tinycss2 (CSS sanitize)
sudo -u jqc mkdir -p static/uploads              # in-body image uploads (writable)
sudo systemctl restart jqc-features              # Quill 2 assets are static — no other step
# 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'))"