Files

18 KiB
Raw Permalink 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, 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/audit read-only audit viewer (filter by action/entity, 50/page), /admin/demos appointment inbox (+ status / delete).
  • 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)
  • 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 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.
  • Paste / drop upload: static/js/image-upload.js (ours). Quill 2's built-in uploader module base64s a pasted/dropped file into the body — which sanitize_html() then strips on save (bleach blocks data: on img src), so the image looked fine in the editor and vanished on save. We override modules.uploader ({mimetypes, handler}) so paste AND drag-drop both POST to /admin/upload and embed the returned /static/uploads/... path. The toolbar image button now goes through the same insertFiles(), 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, so watch(quill) sweeps img[src^="data:image/"] after a user edit and rehosts each one (delete + re-insert the embed, preserving any width). Re-entrancy is guarded by a busy flag — our own edits fire text-change too. A failed rehost tags the node data-upload-failed so 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 (an alert() 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.
  • 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).
  • 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 in topic_form.html and initialised behind a window.JQCImageResize guard, so a failed load just means no resize UI.
    • Size is the width ATTRIBUTE, never inline stylestyle is not on the sanitizer's img allowlist, width is, so the size survives the save. height is 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 a null value as the source argument, so the (name, value) form silently no-ops when clearing the width (this is exactly what broke Reset). The width round-trips through clipboard.convert on load because Quill's Image blot lists width in its formats().
    • 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 is position:fixed, so a bar left below the fold could not be scrolled to.
  • 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.
    • Save/load round-trip (critical): save with deleteTableTemporary() then quill.root.innerHTMLNOT getSemanticHTML(), which rewrites Quill's ql-align-* class as an inline text-align style; quill-table-better's getAlign() reads only the class, so semantic HTML makes the cell-properties dialog always show "left". Load must NOT use dangerouslyPasteHTML/setContents either — that renders tables blank. Instead convert then apply: quill.updateContents(quill.clipboard.convert({html}), 'user'). The non-table fallback path still uses dangerouslyPasteHTML.
    • Alignment is a CSS class, not inline style. Cell alignment = Quill's default class-based align (ql-align-center on the cell <p>). The sanitizer therefore allows class on p/table tags (also needed for ql-table-block), and the public page styles .prose .ql-align-*. Do NOT register attributors/style/align — it breaks the properties-dialog readback described above.
  • 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.

Demo appointments (public booking + owner notification)

  • CTA button now goes to /demo (a real form), not the mailto:. DEMO_CONTACT_URL survives as the "email us directly" fallback link.
  • GET/POST /demotemplates/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:0016: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, within DEMO_MAX_DAYS_AHEAD, and be a weekday. Dates/times are stored exactly as picked; DEMO_TIMEZONE_LABEL is 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_status in app.py):
    1. GET /demo/slots?date=YYYY-MM-DD → JSON {open, reason, slots[]}; static/js/demo-booking.js rebuilds the time <select> on every date change, so a booked time is never offered. Closed days (weekend / past / beyond DEMO_MAX_DAYS_AHEAD) come back open:false with 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).
    2. 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.
    3. POST /demo remains the authority: a (date, time) with a non-cancelled row 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_key NULL-ed for cancelled rows + UNIQUE index), which we have not added.
  • Abuse control: hidden website honeypot (filled → fake success, nothing stored) + per-IP hourly cap DEMO_RATE_LIMIT held in _demo_hits (in-memory, therefore per gunicorn worker — it's a nuisance filter, not a DDoS defence; use nginx limit_req if that ever matters). CSRF via the global CSRFProtect.
  • Notification: _notify_demo() mails DEMO_NOTIFY_EMAIL with the details and a link to /admin/demos, Reply-To set to the customer; with DEMO_CONFIRM_CUSTOMER=1 the customer also gets a copy. Both go through send_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. Blank SMTP_HOST/MAIL_FROM = mail silently off (logged via jqc.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 shows new + scheduled, soonest first; chips filter by status; per-row status select and delete. Nav badge counts new. Status POSTs redirect via a back_status form field — never request.referrer (open-redirect). Every create/status/delete writes an audit row with entity demo (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'))"