# 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='

New copy with bold.

' 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','

Content.

','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= 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. **Adding a photo:** three ways, all equivalent — the toolbar's image button, **pasting** an image (a screenshot, or a copied picture), or **dragging** an image file into the editor. In every case the file is uploaded to the server and referenced by URL. Pasting from Word or Google Docs works too: those arrive as embedded base64 data, which is uploaded and swapped for a real file automatically, with the surrounding text preserved. A short notice appears while an upload runs, or if one is refused (max 8 MB; PNG, JPEG, GIF and WebP only). One exception: an image copied straight from another website keeps pointing at that site (it isn't copied to our server), so save the file and paste it in if you need it to last. **Resizing a photo:** click any image in the body editor. A frame appears with corner handles — drag one to scale it — plus quick sizes **S / M / L / Full** (25 %, 50 %, 75 %, 100 % of the text width) and **Reset** for the original size. The live readout shows the current width in pixels. An image can't be dragged wider than the text column, and inside a table it scales to the cell. Ctrl+Z undoes a resize. The size is stored as the image's `width` attribute, so it survives the save and is what the public page renders; height stays automatic, so the photo keeps its proportions and still shrinks to fit a phone screen. 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.