Pest Nocturne — WSU EM019 study suite (freemium BTCPay paywall)
This commit is contained in:
41
README.md
Normal file
41
README.md
Normal 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
88
app.py
Normal 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
2373
index.html
Normal file
File diff suppressed because it is too large
Load Diff
93
inject.py
Normal file
93
inject.py
Normal 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 & 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 — a study suite for WSU EM019 · <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
12
nocturne.nginx
Normal 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
13
nocturne.service
Normal 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
|
||||||
Reference in New Issue
Block a user