Files
JQC_features/README.md
T

304 lines
11 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.
# JQC Features Site
Single-page, modern site for LT Services' JQC program. Numbered sections; each
topic shows title only until clicked, then expands to reveal text, links,
photos, or videos.
Stack: **Flask + MySQL + Gunicorn + systemd + Nginx** on Ubuntu 24.04.
```
app.py Flask app + Section/Topic/DemoRequest models + routes
admin.py /admin panel (content CRUD, audit log, demo inbox)
mailer.py stdlib SMTP notifications (best-effort, threaded)
config.py env-driven config (DB, demo booking, SMTP)
schema.sql MySQL DDL + seed content (safe to re-run)
add_demo.sql additive migration: demo_request table
templates/index.html
templates/demo.html booking form (+ demo_thanks.html)
static/css/style.css
static/js/main.js accordion expand/collapse
gunicorn.conf.py
deploy/jqc-features.service
deploy/nginx.conf
.env.example
```
---
## Deploy
### 1. Database (run once)
```bash
sudo mysql < schema.sql
sudo mysql -e "CREATE USER IF NOT EXISTS 'jqc_features'@'127.0.0.1' IDENTIFIED BY 'CHANGE_ME';
GRANT ALL PRIVILEGES ON jqc_features.* TO 'jqc_features'@'127.0.0.1';
FLUSH PRIVILEGES;"
```
`schema.sql` is idempotent — re-running updates seed content in place, never
duplicates rows.
### 2. Code
```bash
sudo useradd -r -s /usr/sbin/nologin jqcweb # service account
sudo mkdir -p /opt/jqc-features
sudo rsync -a ./ /opt/jqc-features/ # copy project here
cd /opt/jqc-features
sudo python3 -m venv venv
sudo ./venv/bin/pip install -r requirements.txt
sudo cp .env.example .env # then edit .env with the real DB_PASSWORD
sudo chown -R jqcweb:jqcweb /opt/jqc-features
```
### 3. Service (systemd — separate from DB step)
```bash
sudo cp deploy/jqc-features.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now jqc-features
sudo systemctl status jqc-features # confirm active (running)
curl -s http://127.0.0.1:8000/healthz # -> {"status":"ok"}
```
### 4. Nginx
```bash
# edit server_name in deploy/nginx.conf first
sudo cp deploy/nginx.conf /etc/nginx/sites-available/jqc-features
sudo ln -s /etc/nginx/sites-available/jqc-features /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
# TLS:
sudo certbot --nginx -d features.ltservicesinc.com
```
---
## Adding photos, videos, or links to a topic
Content lives in the `topic` table. No code change needed — update rows, then
the page picks them up on next load.
Text only (default):
```sql
UPDATE topic SET body_html='<p>New copy with <strong>bold</strong>.</p>'
WHERE slug='comprehensive-checklists';
```
Add a photo:
```sql
UPDATE topic SET media_type='image',
media_url='/static/img/checklist.jpg',
media_caption='Timestamped inspection photo'
WHERE slug='comprehensive-checklists';
```
(Put the file in `static/img/` — create the folder — or use any absolute URL,
e.g. a Cloudflare R2 link.)
Add an uploaded video:
```sql
UPDATE topic SET media_type='video', media_url='/static/vid/demo.mp4'
WHERE slug='real-time-issue-tracking';
```
Embed a YouTube clip:
```sql
UPDATE topic SET media_type='embed',
media_url='https://www.youtube.com/embed/VIDEO_ID'
WHERE slug='notifications-and-qr-access';
```
Add a button link (works alongside any media):
```sql
UPDATE topic SET link_url='https://jqc.ltservicesinc.com',
link_label='Open the portal'
WHERE slug='shared-inspector-access';
```
`media_type` values: `none`, `image`, `video`, `embed`.
## Add a whole new section or topic
```sql
INSERT INTO section (num,title,subtitle,sort_order)
VALUES (5,'New Section','Optional subtitle',50);
INSERT INTO topic (section_id,slug,title,body_html,media_type,sort_order)
VALUES ((SELECT id FROM section WHERE num=5),
'my-new-topic','My New Topic','<p>Content.</p>','none',10);
```
Sections order by `sort_order`; topics order by `sort_order` within a section.
---
## Admin panel (browser editing)
Instead of SQL, edit content at **`/admin`**. Single admin account, session login,
CSRF-protected forms, and every create/update/delete is written to `audit_log`.
### One-time setup
1. **Already-deployed DB?** add the audit table and the publish column:
```bash
sudo mysql < add_admin.sql # audit_log (admin panel)
sudo mysql < add_publish.sql # topic.is_published (draft/publish)
```
(New deploys skip this — `schema.sql` already includes both.)
2. Set these in `/opt/jqc-features/.env`:
```bash
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH=<paste hash from command below>
SESSION_COOKIE_SECURE=1 # keep 1 in production (HTTPS)
```
Generate the password hash (never store the plaintext):
```bash
./venv/bin/python -c "from werkzeug.security import generate_password_hash as g; print(g('YOUR_PASSWORD'))"
```
3. Install the new dependency and restart:
```bash
sudo ./venv/bin/pip install -r requirements.txt
sudo systemctl restart jqc-features
```
Then visit `https://your-domain/admin`, sign in, and manage content.
### What you can do
- **Dashboard** — every section with its topics; edit or delete inline.
**Drag the ⠿ handles** to reorder topics within a section, or reorder whole
sections; the new order saves instantly (no page reload).
- **Publish / Unpublish** — each topic has a one-click toggle, and a Published
checkbox on its edit form. Drafts show a `draft` badge and are hidden from the
public site. A section whose topics are all drafts is hidden entirely.
- **Add / edit topic** — section, title, slug (auto if blank), a **rich-text
body editor** (bold/italic/underline, H2/H3, lists, blockquote, links — no HTML
knowledge needed), media (image / video / embed) + caption, an optional link
button, and sort order.
- **Add / edit section** — number (`§NN`, unique), title, subtitle, sort order.
- Deleting a section cascades to its topics (with a confirm prompt).
- **Audit log** (`/admin/audit`, "Audit" in the nav) — read-only view of every
change, newest first, filterable by action and type, paginated 50/page. Times
are UTC.
The editor (Quill 2 + quill-table-better) and drag library (SortableJS) are
**vendored locally** under `static/vendor/` — no CDN dependency, so they work on a
locked-down server and survive a strict CSP. The body toolbar supports **inline
images** and **tables** with **drag-to-resize columns/rows** and a floating cell
menu for **alignment, borders and background** (merge/split too). Images are
uploaded via `POST /admin/upload` — the file is stored under `static/uploads/`
and referenced by URL, so the database stays small (no base64). Rich-text HTML is
sanitized on save (`bleach` + `tinycss2` CSS sanitizer): a tag allowlist covers
images and tables, and inline `style` is filtered to a small CSS-property allowlist
(width/alignment/border/background) so a paste can't inject markup, `javascript:`,
`data:`, or dangerous CSS into the public page. If the editor ever fails to load,
the body field degrades to a plain textarea — a save never wipes content.
`static/uploads/` must be writable by the app user (`jqc`) in production:
`sudo -u jqc mkdir -p static/uploads`. Uploaded files are gitignored.
### Notes
- `SESSION_COOKIE_SECURE=1` means the login cookie only sends over HTTPS. For a
quick plain-HTTP test on the box, set it to `0` — never in production.
- `SECRET_KEY` must be stable and secret; changing it logs everyone out.
- `WTF_CSRF_TIME_LIMIT` blank keeps a token valid for the whole session, so a
long edit never 400s on save; set an integer (seconds) to re-enable expiry.
- The admin routes live under `/admin`; the public page and `schema.sql` SQL
workflow above still work unchanged.
---
## Brute-force protection (fail2ban)
Every login attempt is written to `logs/auth.log` (rotating, 1 MB × 5) with the
real client IP:
```
2026-07-22 12:00:00,123 jqc.auth WARNING FAILED LOGIN user=admin from 203.0.113.5
2026-07-22 12:00:05,456 jqc.auth INFO LOGIN OK user=admin from 203.0.113.5
```
The real IP comes from `ProxyFix` reading nginx's `X-Forwarded-For` (gunicorn
binds 127.0.0.1, so the header can't be spoofed from outside). Config ships in
`deploy/fail2ban/`.
### Install
```bash
sudo apt-get install -y 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/
# edit logpath in the jail file if your app dir differs from /home/jqc/jqc_features
sudo systemctl enable --now fail2ban
sudo systemctl restart fail2ban
```
Default policy: **5 failures in 10 min → 1 h ban** (`maxretry`/`findtime`/`bantime`
in the jail file).
### Verify
```bash
# regex matches the log lines:
sudo fail2ban-regex logs/auth.log deploy/fail2ban/filter.d/jqc-admin.conf
# jail is live:
sudo fail2ban-client status jqc-admin
```
`fail2ban-regex` should report matches equal to the number of `FAILED LOGIN`
lines. `status` shows currently banned IPs.
### Scope note
This jail bans credential-guessing that reaches the password check (a real
browser session with a valid CSRF token). Dumb bots that POST without a CSRF
token get an HTTP 400 and never reach the check — they can't guess a password
anyway. To also throttle those, add an nginx `limit_req` on `/admin/login`.
---
## Demo appointments
The **Request a demo** button at the bottom of the public page opens `/demo`, a
booking form: name, company, email, phone, preferred date + time slot, and a
message. Valid submissions are stored in `demo_request` and the owner is
emailed; the customer gets a confirmation copy.
```bash
sudo mysql < add_demo.sql # existing databases only; schema.sql has it too
```
Then set in `.env` and restart (`sudo systemctl restart jqc-features`):
```
DEMO_NOTIFY_EMAIL=info@ltservicesinc.com
SMTP_HOST=smtp.yourprovider.com
SMTP_PORT=587
SMTP_USER=notifications@ltservicesinc.com
SMTP_PASSWORD=...
MAIL_FROM=notifications@ltservicesinc.com
DEMO_TIMEZONE_LABEL=Eastern Time
```
Bookable hours default to 08:0016:30 in 30-minute slots, weekdays, up to 90
days ahead (`DEMO_HOUR_START`, `DEMO_HOUR_END`, `DEMO_SLOT_MINUTES`,
`DEMO_MAX_DAYS_AHEAD`). A slot already taken by a non-cancelled request can't be
booked twice.
**Mail is best-effort:** it goes out on a background thread *after* the request
is saved, and a failure is logged rather than shown to the visitor. With
`SMTP_HOST` blank no mail is sent at all — requests still appear in the admin
panel, so nothing is ever lost.
Manage them at **`/admin/demos`**: open requests first, filter by status
(`new` / `scheduled` / `done` / `cancelled`), set a status or delete. The nav
badge counts unhandled ones. Every action is written to the audit log under the
`demo` type.