Pest Nocturne — WSU EM019 study suite (freemium BTCPay paywall)

This commit is contained in:
drjones
2026-09-01 01:37:39 -07:00
commit f9242710eb
6 changed files with 2620 additions and 0 deletions

41
README.md Normal file
View File

@@ -0,0 +1,41 @@
# Pest Nocturne 🪲
A midnight specimen-cabinet study suite for the **WSU EM019** pest management
certification exam. The complete *Study Manual for Pest Management
Professionals* rebuilt as an interactive, illustrated single-page app.
## Live
**https://nocturne.thetempleofdoom.com**
## What's inside
- **Field guide** — all 13 chapters with illustrated plates
- **Identification keys** — branching keys, walked one couplet at a time
- **House faults** — 22 points of faulty construction/maintenance, in order
- **Flashcards** 🔒 — premium
- **Practice exam** 🔒 — premium (scored, with the official answer key)
- **Glossary** — every defined term, searchable as you type
## Monetization
Freemium: the field guide, keys, and house faults are free. **Flashcards and
the practice exam are a $5 Bitcoin unlock** via BTCPay.
- BTCPay store: `FDT6DHWEaA7DF5WFePkp4pufgQ9yy1G6JfzTCwey1jYn`
- Price: $5 USD (on-chain BTC)
- Flow: `/unlock` → BTCPay invoice → `/webhook/btcpay` (InvoiceSettled) → client polls `/api/status` → unlocks
## Stack
- Single self-contained `index.html` (vanilla JS SPA, no build step)
- Flask (`app.py`) for the paywall + BTCPay webhook
- nginx reverse proxy → Flask :5000
- SQLite (`orders.db`) for order state
## Deploy
- Proxmox LXC CT **117** (`nocturne`) @ 10.30.20.234
- systemd `nocturne.service`, nginx `sites-enabled/nocturne`
- Cloudflare tunnel `thetemple-fleet` (`1aeb1ac0`), ingress → `:80`
## Files
- `index.html` — the study suite + injected paywall gate + BMAC footer
- `app.py` — Flask backend (BTCPay invoice, webhook, unlock status)
- `inject.py` — one-shot script that injects the paywall + footer into the HTML
- `nocturne.service` / `nocturne.nginx` — service + nginx config

88
app.py Normal file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
# Pest Nocturne — WSU EM019 study suite, freemium paywall (BTCPay)
import os, json, uuid, time, sqlite3, hmac, hashlib
from flask import Flask, request, jsonify, send_from_directory, redirect
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(BASE_DIR, "orders.db")
HTML = os.path.join(BASE_DIR, "index.html")
# BTCPay config (store FDT6DHWEaA7DF5WFePkp4pufgQ9yy1G6JfzTCwey1jYn)
BTCPAY_URL = os.environ.get("BTCPAY_URL", "https://10.30.20.140")
BTCPAY_STORE = os.environ.get("BTCPAY_STORE", "FDT6DHWEaA7DF5WFePkp4pufgQ9yy1G6JfzTCwey1jYn")
BTCPAY_KEY = os.environ.get("BTCPAY_KEY", "206f08d0a8efc4cfbe524188756c5bd1b2fd277a")
PRICE_USD = os.environ.get("PRICE_USD", "5.00")
app = Flask(__name__)
def db():
c = sqlite3.connect(DB_PATH)
c.execute("CREATE TABLE IF NOT EXISTS orders (id TEXT PRIMARY KEY, created REAL, paid INTEGER DEFAULT 0)")
c.commit()
return c
def create_btcpay_invoice(order_id):
"""Create a BTCPay invoice and return its checkout URL, or None."""
import urllib.request, ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE # BTCPay uses a self-signed cert
payload = json.dumps({
"amount": PRICE_USD,
"currency": "USD",
"metadata": {"orderId": order_id},
"checkout": {"redirectURL": f"/?paid=1&order={order_id}"},
}).encode()
req = urllib.request.Request(
f"{BTCPAY_URL}/api/v1/stores/{BTCPAY_STORE}/invoices",
data=payload, method="POST",
headers={"Authorization": f"token {BTCPAY_KEY}", "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=20, context=ctx) as r:
data = json.loads(r.read())
return data.get("checkoutLink") or data.get("checkout_url")
except Exception as e:
app.logger.error(f"BTCPay invoice error: {e}")
return None
@app.route("/")
def index():
return send_from_directory(BASE_DIR, "index.html")
@app.route("/api/status")
def api_status():
oid = request.args.get("order", "")
c = db()
row = c.execute("SELECT paid FROM orders WHERE id=?", (oid,)).fetchone()
c.close()
return jsonify({"paid": bool(row and row[0])})
@app.route("/unlock", methods=["POST"])
def unlock():
order_id = uuid.uuid4().hex[:16]
c = db()
c.execute("INSERT INTO orders (id, created, paid) VALUES (?,?,0)", (order_id, time.time()))
c.commit()
c.close()
checkout_url = create_btcpay_invoice(order_id)
if not checkout_url:
return jsonify({"error": "could not create invoice"}), 502
return jsonify({"order_id": order_id, "checkout_url": checkout_url})
@app.route("/webhook/btcpay", methods=["POST"])
def webhook():
raw = request.get_data()
data = json.loads(raw) if raw else {}
etype = data.get("type", "")
order_id = (data.get("metadata") or {}).get("orderId")
if etype == "InvoiceSettled" and order_id:
c = db()
c.execute("UPDATE orders SET paid=1 WHERE id=?", (order_id,))
c.commit()
c.close()
app.logger.info(f"order {order_id} settled")
return jsonify({"ok": True})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)

2373
index.html Normal file

File diff suppressed because it is too large Load Diff

93
inject.py Normal file
View File

@@ -0,0 +1,93 @@
import io
p = "index.html"
s = open(p).read()
PAYWALL = r'''
<!-- ══════════════ PEST NOCTURNE — PREMIUM GATE (injected) ══════════════ -->
<style>
#pn-paywall{position:fixed;inset:0;z-index:99999;display:none;align-items:center;justify-content:center;
background:rgba(5,8,16,.86);backdrop-filter:blur(6px);padding:20px;font-family:Karla,system-ui,sans-serif}
#pn-paywall.on{display:flex}
.pn-card{max-width:460px;width:100%;background:var(--surface,#141A30);border:1px solid var(--line,#2C3455);
border-radius:16px;padding:34px 30px;text-align:center;box-shadow:0 30px 80px -30px rgba(0,0,0,.9)}
.pn-card .pn-lock{font-size:44px;line-height:1;margin-bottom:10px}
.pn-card h2{margin:0 0 10px;font-family:Fraunces,serif;font-size:1.6rem;color:var(--text,#EDE7D6)}
.pn-card p{margin:0 0 22px;color:var(--text-dim,#A3A99C);font-size:.95rem;line-height:1.5}
.pn-price{font-size:2rem;font-weight:700;color:var(--brass,#E3A53F);margin-bottom:4px}
.pn-price small{font-size:.8rem;color:var(--text-faint,#6E7688);font-weight:400}
#pn-unlock{display:block;width:100%;padding:14px;border:none;border-radius:10px;cursor:pointer;
background:linear-gradient(135deg,var(--brass,#E3A53F),var(--brass-deep,#B87F26));color:#1a1206;
font-weight:700;font-size:1.02rem;margin-bottom:12px}
#pn-unlock:hover{filter:brightness(1.06)}
#pn-close{background:none;border:none;color:var(--text-faint,#6E7688);cursor:pointer;font-size:.85rem;text-decoration:underline}
.pn-note{margin-top:16px;font-size:.72rem;color:var(--text-faint,#6E7688)}
</style>
<div id="pn-paywall" role="dialog" aria-modal="true">
<div class="pn-card">
<div class="pn-lock">🔒</div>
<h2>Unlock the full suite</h2>
<p>Flashcards &amp; the practice exam are premium. Unlock the complete
EM019 study set — all 13 chapters, every flashcard, and the scored
practice exam with the official answer key.</p>
<div class="pn-price">$5 <small>pay in Bitcoin</small></div>
<button id="pn-unlock" type="button">Unlock full access →</button>
<button id="pn-close" type="button">Continue free preview</button>
<div class="pn-note">Instant unlock after payment. No account needed.</div>
</div>
</div>
<script>
(function(){
var PREMIUM=['exam','cards'];
var unlocked=false;
try{ unlocked = localStorage.getItem('pn_unlocked')==='1'; }catch(e){}
function paywallEl(){ return document.getElementById('pn-paywall'); }
function show(){ var p=paywallEl(); if(p) p.classList.add('on'); }
function hide(){ var p=paywallEl(); if(p) p.classList.remove('on'); }
function setUnlocked(){ unlocked=true; try{localStorage.setItem('pn_unlocked','1')}catch(e){} hide(); }
function poll(){
if(unlocked) return;
var oid=null; try{ oid=localStorage.getItem('pn_order'); }catch(e){}
if(!oid) return;
fetch('/api/status?order='+encodeURIComponent(oid))
.then(function(r){return r.json()})
.then(function(j){ if(j&&j.paid){ setUnlocked(); } else { setTimeout(poll,3000); } })
.catch(function(){ setTimeout(poll,5000); });
}
function startUnlock(){
fetch('/unlock',{method:'POST'})
.then(function(r){return r.json()})
.then(function(j){
if(!j || !j.checkout_url){ return; }
try{ localStorage.setItem('pn_order', j.order_id); }catch(e){}
window.open(j.checkout_url,'_blank');
poll();
})
.catch(function(){});
}
if(typeof window.go==='function'){
var orig=window.go;
window.go=function(v){
if(PREMIUM.indexOf(v)>=0 && !unlocked){ show(); return; }
orig(v);
};
}
document.addEventListener('click',function(e){
if(e.target.closest('#pn-unlock')){ startUnlock(); }
else if(e.target.closest('#pn-close')){ hide(); }
});
poll();
})();
</script>
'''
# The file has no </body> — it's an HTML fragment ending in </script>. Append at EOF.
BMAC = r'''
<div style="text-align:center;padding:26px 14px 30px;font-family:Karla,system-ui,sans-serif;color:var(--text-faint,#6E7688);font-size:.78rem;line-height:1.6">
Pest Nocturne &mdash; a study suite for WSU EM019 &middot; <a href="https://buymeacoffee.com/r26xrthzttg" target="_blank" rel="noopener" style="color:var(--verdigris,#54BFA9)">Buy me a coffee</a>
</div>
'''
s = s.rstrip() + '\n' + PAYWALL + '\n' + BMAC + '\n'
open(p, 'w').write(s)
print(f"injected paywall + BMAC footer; new size {len(s)} bytes")

12
nocturne.nginx Normal file
View File

@@ -0,0 +1,12 @@
server {
listen 80;
server_name _;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

13
nocturne.service Normal file
View File

@@ -0,0 +1,13 @@
[Unit]
Description=Pest Nocturne study suite (Flask)
After=network.target
[Service]
WorkingDirectory=/opt/nocturne
ExecStart=/usr/bin/python3 /opt/nocturne/app.py
Restart=always
RestartSec=3
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target