2026-07-22 14:50:16 -04:00
2026-07-22 14:50:16 -04:00

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)

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

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)

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

# 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

Content lives in the topic table. No code change needed — update rows, then the page picks them up on next load.

Text only (default):

UPDATE topic SET body_html='<p>New copy with <strong>bold</strong>.</p>'
WHERE slug='comprehensive-checklists';

Add a photo:

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:

UPDATE topic SET media_type='video', media_url='/static/vid/demo.mp4'
WHERE slug='real-time-issue-tracking';

Embed a YouTube clip:

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):

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

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:

    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:

    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):

    ./venv/bin/python -c "from werkzeug.security import generate_password_hash as g; print(g('YOUR_PASSWORD'))"
    
  3. Install the new dependency and restart:

    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

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

# 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.

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).

Only free times are offered. When the customer picks a date, the time dropdown reloads to show just that day's remaining slots — anything already booked disappears, so two people can't choose the same appointment. Weekends and out-of-range dates say why they're unavailable. If a time is taken while someone is still filling the form in, it drops off the list with a note when they change the date, and the server refuses it on submit regardless. Marking a request cancelled in the admin panel puts its slot back on offer.

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.

S
Description
No description provided
Readme
585 KiB
Languages
Python 32.4%
HTML 29.5%
CSS 21%
JavaScript 17.1%