304 lines
11 KiB
Markdown
304 lines
11 KiB
Markdown
# 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:00–16: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.
|