18 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,DemoRequest,AuditLog),log_action(), ProxyFix wrap,_configure_auth_logger(), demo helpers (demo_slots,_rate_limited,_notify_demo), public routes/,/demo,/demo/thanks,/healthz.mailer.py— stdlib SMTP sender;send_email()/send_email_async().admin.py— blueprint/admin: session login, section/topic CRUD,/admin/auditread-only audit viewer (filter by action/entity, 50/page),/admin/demosappointment inbox (+ status / delete).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)demo_request(id, name, company, email, phone, preferred_date, preferred_time, message, status[new|scheduled|done|cancelled], source_ip, 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. - Paste / drop upload:
static/js/image-upload.js(ours). Quill 2's built-inuploadermodule base64s a pasted/dropped file into the body — whichsanitize_html()then strips on save (bleach blocksdata:on img src), so the image looked fine in the editor and vanished on save. We overridemodules.uploader({mimetypes, handler}) so paste AND drag-drop both POST to/admin/uploadand embed the returned/static/uploads/...path. The toolbar image button now goes through the sameinsertFiles(), so all three routes behave identically.- Quill's uploader only sees pasted files. HTML pasted from Word/Docs carries
base64
<img>markup through the clipboard instead, sowatch(quill)sweepsimg[src^="data:image/"]after a user edit and rehosts each one (delete + re-insert the embed, preserving anywidth). Re-entrancy is guarded by abusyflag — our own edits firetext-changetoo. A failed rehost tags the nodedata-upload-failedso it isn't retried forever, and says plainly that the image won't be saved (we don't silently delete admin content). - Feedback is a non-blocking
.jqc-toast(analert()mid-paste interrupts typing). Client-side checks mirror the server: MIME allowlist + 8 MB cap. - Remote
http(s)images pasted from a web page are NOT rehosted — bleach allows those URLs, so they render, but they hotlink the original server.
- Quill's uploader only sees pasted files. HTML pasted from Word/Docs carries
base64
- 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). - Image resize:
static/js/image-resize.js— ours, not vendored (Quill 2 has no resize UI and the third-party modules target Quill 1). Click an image in the editor → fixed-positioned frame with 4 corner handles, S/M/L/Full presets (25/50/75/100 % of the containing block, so it also works inside a table cell) and a Reset. Loaded after Quill intopic_form.htmland initialised behind awindow.JQCImageResizeguard, so a failed load just means no resize UI.- Size is the
widthATTRIBUTE, never inlinestyle—styleis not on the sanitizer's img allowlist,widthis, so the size survives the save.heightis removed on every change; the public CSS (.prose img{max-width: 100%;height:auto}) keeps the aspect ratio and still shrinks on a phone. - Commit via
quill.formatText(i, 1, {width: v}, 'user')— pass a formats OBJECT, not(name, value). Quill's argument overload reads anullvalueas thesourceargument, so the(name, value)form silently no-ops when clearing the width (this is exactly what broke Reset). The width round-trips throughclipboard.converton load because Quill's Image blot listswidthin itsformats(). - A drag writes the attribute directly for live preview and commits once on
release → one undo step per drag, not dozens. The preset bar calls
preventDefault()on mousedown so it never steals the caret (otherwise Ctrl+Z after a resize stops reaching Quill), and it flips above the image / clamps to the viewport — the frame isposition:fixed, so a bar left below the fold could not be scrolled to.
- Size is the
- 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.root.innerHTML— NOTgetSemanticHTML(), which rewrites Quill'sql-align-*class as an inlinetext-alignstyle; quill-table-better'sgetAlign()reads only the class, so semantic HTML makes the cell-properties dialog always show "left". Load must NOT usedangerouslyPasteHTML/setContentseither — that renders tables blank. Instead convert then apply:quill.updateContents(quill.clipboard.convert({html}), 'user'). The non-table fallback path still usesdangerouslyPasteHTML. - Alignment is a CSS class, not inline style. Cell alignment = Quill's default
class-based align (
ql-align-centeron the cell<p>). The sanitizer therefore allowsclasson p/table tags (also needed forql-table-block), and the public page styles.prose .ql-align-*. Do NOT registerattributors/style/align— it breaks the properties-dialog readback described above.
- 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.
Demo appointments (public booking + owner notification)
- CTA button now goes to
/demo(a real form), not themailto:.DEMO_CONTACT_URLsurvives as the "email us directly" fallback link. GET/POST /demo→templates/demo.html; success redirects to/demo/thanks(POST-redirect-GET, so a refresh can't double-book).- Slots come from
demo_slots()—DEMO_HOUR_START/END+DEMO_SLOT_MINUTES(default 08:00–16:30 every 30 min). Server-side validation is the real gate: the posted time must be in that list, the date must parse, be today-or-later, withinDEMO_MAX_DAYS_AHEAD, and be a weekday. Dates/times are stored exactly as picked;DEMO_TIMEZONE_LABELis display-only (no tz conversion anywhere). - One appointment per slot, enforced in three places that share the same
helpers (
taken_slots,available_slots,demo_day_statusinapp.py):GET /demo/slots?date=YYYY-MM-DD→ JSON{open, reason, slots[]};static/js/demo-booking.jsrebuilds the time<select>on every date change, so a booked time is never offered. Closed days (weekend / past / beyondDEMO_MAX_DAYS_AHEAD) come backopen:falsewith the reason, and the picker is emptied + disabled. Stale responses are dropped by a request counter; a fetch failure fails OPEN (keeps the list, server still decides).- The server renders only free slots when the form already has a date (so a re-render after a validation error can't offer a slot taken meanwhile), and the whole grid when it doesn't — the no-JS path still works.
POST /demoremains the authority: a (date, time) with a non-cancelledrow is rejected with "that time was just taken". Cancelling frees the slot.
- The endpoint is public and read-only: it reveals which times are free — exactly what the form shows anyway — and nothing about who booked them.
- Two people submitting the same free slot in the same instant can still both
pass the check-then-insert; the second one is simply a duplicate row for the
owner to sort out. Closing that needs a DB-level constraint (a generated
slot_keyNULL-ed for cancelled rows + UNIQUE index), which we have not added.
- Abuse control: hidden
websitehoneypot (filled → fake success, nothing stored) + per-IP hourly capDEMO_RATE_LIMITheld in_demo_hits(in-memory, therefore per gunicorn worker — it's a nuisance filter, not a DDoS defence; use nginxlimit_reqif that ever matters). CSRF via the globalCSRFProtect. - Notification:
_notify_demo()mailsDEMO_NOTIFY_EMAILwith the details and a link to/admin/demos,Reply-Toset to the customer; withDEMO_CONFIRM_CUSTOMER=1the customer also gets a copy. Both go throughsend_email_async()on a daemon thread after the row is committed — mail is best-effort and never raises, so a dead SMTP host loses the email, never the appointment. BlankSMTP_HOST/MAIL_FROM= mail silently off (logged viajqc.mail); requests still land in the admin inbox. - Header values are CR/LF-stripped (
_clean_header) so a submitted name can't inject SMTP headers. - Admin inbox
/admin/demos: default view showsnew+scheduled, soonest first; chips filter by status; per-row status select and delete. Nav badge countsnew. Status POSTs redirect via aback_statusform field — neverrequest.referrer(open-redirect). Every create/status/delete writes an audit row with entitydemo(the audit filter accepts it).
Deploy delta cheatsheet
sudo mysql < add_admin.sql # audit_log (existing DBs)
sudo mysql < add_publish.sql # topic.is_published (existing DBs)
sudo mysql < add_demo.sql # demo_request table (existing DBs)
# .env: DEMO_NOTIFY_EMAIL + SMTP_HOST/PORT/USER/PASSWORD/MAIL_FROM for notifications
# .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'))"