116 lines
5.5 KiB
Markdown
116 lines
5.5 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.
|
||
- `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)`
|
||
- `audit_log(id, actor, action, entity, entity_id, detail, created_at)`
|
||
|
||
Public page orders sections by `sort_order, num`; topics by `sort_order`.
|
||
|
||
## 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.
|
||
- **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.
|
||
|
||
## Deploy delta cheatsheet
|
||
|
||
```bash
|
||
sudo mysql < add_admin.sql # audit_log (existing DBs)
|
||
# .env: SECRET_KEY, ADMIN_USERNAME, ADMIN_PASSWORD_HASH, SESSION_COOKIE_SECURE=1
|
||
sudo ./venv/bin/pip install -r requirements.txt
|
||
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'))"`
|