7.9 KiB
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(userjqc) - venv:
/home/jqc/jqc_features/venv .envbesideconfig.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/auditread-only audit viewer (filter by action/entity, 50/page).config.py— env-driven config +_load_dotenv()(no-expansion loader).templates/— publicindex.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 AFTERdb.session.commit()of the change.- Migrations are idempotent:
CREATE TABLE IF NOT EXISTS; seed usesINSERT ... ON DUPLICATE KEY UPDATE. Safe to re-run. - Every POST form includes
{{ csrf_token() }}; Flask-WTFCSRFProtectis global. - Content
body_htmlis admin-authored and trusted → rendered viaMarkup. - 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_requireddecorator gates all CRUD. nextredirect is restricted to paths starting/admin(open-redirect guard).- Username compared with
hmac.compare_digest; password withcheck_password_hash(constant-time).
Security learnings (this session)
.envis not read automatically. The app only sees process env. systemdEnvironmentFile=OR the in-app_load_dotenv()must supply vars. We added_load_dotenv()so the.envbesideconfig.pyis 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=1breaks login over plain HTTP. The session cookie (which holds the CSRF token) is markedSecure, so the browser drops it on HTTP → "CSRF session token is missing." Serve HTTPS in prod; only set0for local HTTP testing.- Empty
SECRET_KEYalso 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_LIMITconfig: blank →None(token valid for the whole session, cookie-bound); integer overrides.audit_log.created_atis UTC (datetime.utcnow); the viewer labels it UTC. - systemd reads
EnvironmentFileonly at start →systemctl restartafter any.envedit;daemon-reloadafter unit edits. - Real client IP behind nginx:
request.remote_addris 127.0.0.1 withoutProxyFix. We wrapapp.wsgi_app = ProxyFix(..., x_for=1, x_proto=1, x_host=1)and nginx setsX-Forwarded-For/-Proto. gunicorn binds 127.0.0.1, so headers can't be spoofed externally.x_proto=1also fixes https redirects.
fail2ban
- App writes
logs/auth.logvia thejqc.authlogger (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 anchorsfrom <HOST>\s*$, so a crafted username can't spoof the ban target. - Filter
deploy/fail2ban/filter.d/jqc-admin.conf(datepattern uses%%— ConfigParser escaping). Jaildeploy/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_reqon/admin/loginif that traffic matters.
Content editing (rich text / publish / reorder)
- Rich text: Quill 1.3.7 vendored at
static/vendor/(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-onis added only afternew Quill()succeeds (hides the textarea, shows the editor). If Quill fails to load, the textarea stays usable and a save never wipes the body. - Sanitize on save:
sanitize_html()(bleach) runs on everybody_htmlwrite — allowlistALLOWED_TAGS/ALLOWED_ATTRS,strip=True, and bleach restricts link protocols to http/https/mailto (blocksjavascript:). Empty /<p><br></p>editor content is stored as NULL. The public page still rendersbody_htmlviaMarkup, but the content is now sanitized at the source. - Draft/publish:
topic.is_published(default 1, so existing rows stay live). Public route filters toSection.published_topicsand drops sections with none. Admin: per-topic/topic/<id>/toggle(quick button) + a Published checkbox on the form; dashboard shows adraftbadge. New topics default published (least surprise); uncheck to stage a draft. - Drag reorder: SortableJS 1.15.2 vendored. Dashboard drags post JSON to
/admin/reorderwith the CSRF token from<meta name="csrf-token">sent as theX-CSRFTokenheader (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 filtersTopic.section_id == section_idso 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 # includes bleach
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'))"