224 lines
13 KiB
Markdown
224 lines
13 KiB
Markdown
# 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 start** → `systemctl 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 `insertEmbed`s 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`.
|
||
- **Save/load round-trip (critical):** save with `deleteTableTemporary()` then
|
||
`quill.root.innerHTML` — **NOT `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 /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,
|
||
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:** a request for a (date, time) that already has a
|
||
non-`cancelled` row is rejected with a flash. Cancelling frees the slot.
|
||
- **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
|
||
|
||
```bash
|
||
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'))"`
|