Files
JQC_features/CLAUDE.md
T

176 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`, `AuditLog`), `log_action()`,
ProxyFix wrap, `_configure_auth_logger()`, public routes `/`, `/healthz`.
- `admin.py` — blueprint `/admin`: session login, section/topic CRUD, `/admin/audit`
read-only audit viewer (filter by action/entity, 50/page).
- `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)`
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.getSemanticHTML()`. **Load must NOT use `dangerouslyPasteHTML`/`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 uses `dangerouslyPasteHTML`.
- **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.
## Deploy delta cheatsheet
```bash
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'))"`