Files
JQC_features/README.md
T

7.3 KiB
Raw Blame History

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 models + routes
config.py           env-driven config (DB + demo contact URL)
schema.sql          MySQL DDL + seed content (safe to re-run)
templates/index.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:

    sudo mysql < add_admin.sql
    

    (New deploys skip this — schema.sql already includes audit_log.)

  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.
  • Add / edit topic — section, title, slug (auto if blank), body HTML, 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.

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.