150 lines
7.9 KiB
Markdown
150 lines
7.9 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`, `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 1.3.7 vendored at `static/vendor/` (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.
|
||
- **Sanitize on save:** `sanitize_html()` (bleach) runs on every `body_html`
|
||
write — allowlist `ALLOWED_TAGS`/`ALLOWED_ATTRS`, `strip=True`, and bleach
|
||
restricts link protocols to http/https/mailto (blocks `javascript:`). Empty /
|
||
`<p><br></p>` editor content is stored as NULL. The public page still renders
|
||
`body_html` via `Markup`, but the content is now 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 # includes bleach
|
||
sudo systemctl restart jqc-features
|
||
# 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'))"`
|