Files
JQC_features/CLAUDE.md
T

116 lines
5.5 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.
- `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'))"`