10 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 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-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. The submit handler keeps the body when it contains animg/tableeven thoughgetText()is empty for embed-only content. - Images: the custom
#editor-toolbarhas aql-imagebutton. Images upload viaPOST /admin/upload(login_required, CSRF viaX-CSRFTokenheader): extension allowlist + magic-byte sniff (_sniff_image, SVG excluded), 8 MB cap, saved as a randomuuid4().hex.<ext>understatic/uploads/, returns{url}; the handlerinsertEmbeds it (no base64 → DB stays small). - Tables:
quill-table-better1.2.3 vendored (static/vendor/quill-table-better.js.css, UMD → reads globalQuill, exposesQuillTableBetter; self-contained, no CDN/CSP issues). Registered asmodules/table-better; Quill 2's basictablemodule is disabled (table: false). Theql-table-bettertoolbar 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 bytypeof QuillTableBetterso a failed vendor load still leaves a working editor + textarea. Registration and keyboard bindings (QuillTableBetter.keyboardBindings) live intopic_form.html.
- Save/load round-trip (critical): save with
deleteTableTemporary()thenquill.getSemanticHTML(). Load must NOT usedangerouslyPasteHTML/setContents— quill-table-better renders tables blank that way. Instead convert then apply:quill.updateContents(quill.clipboard.convert({html}), 'user'). The non-table fallback path still usesdangerouslyPasteHTML.
- Sanitize on save:
sanitize_html()(bleach) runs on everybody_htmlwrite —ALLOWED_TAGS/ALLOWED_ATTRScoverimg+ full table tags withcolspan/rowspan/data-*/style; aCSSSanitizer(bleach[css] +tinycss2) filters inlinestyletoALLOWED_CSS_PROPS(width/height/padding/text-align/ vertical-align/background-color/border*) so the table editor's sizing & alignment survive butposition,behavior,url(javascript:)etc. are stripped. bleach also restricts URL protocols onhref/img srcto http/https/mailto (blocksjavascript:/data:; uploads are relative/staticpaths). Empty /<p><br></p>is stored NULL. Public rendersbody_htmlviaMarkup, 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 # 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'))"