Rigel SOCKS5 proxy shop v1 — no-KYC auth, BTCPay, Nord+IPRoyal inventory
This commit is contained in:
38
README.md
Normal file
38
README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Rigel — no-KYC SOCKS5 Proxy Shop
|
||||
|
||||
Self-hosted SOCKS5 proxy storefront. Sells access to residential/mobile/datacenter proxies. Bitcoin via BTCPay. No KYC — username + email + password + arithmetic captcha.
|
||||
|
||||
## Stack
|
||||
- **App**: Flask single-page app, `/opt/rigel/app.py` (SQLite, no-KYC auth)
|
||||
- **CT**: 158 `rigel` @ `10.30.20.116`, Debian 12, nginx :80 → Flask :5000
|
||||
- **Service**: `rigel.service` (systemd)
|
||||
- **Payments**: BTCPay store `[redacted]` / key `[redacted]` (webhook → `/api/btcpay/webhook`)
|
||||
- **Public**: `https://rigel.thetempleofdoom.com` (fleet tunnel `1aeb1ac0`, REMOTE-MANAGED config via CF API)
|
||||
|
||||
## Inventory
|
||||
| Location | Type | Endpoint |
|
||||
|---|---|---|
|
||||
| Tokyo | datacenter (Nord exit) | CT680 `10.30.20.154:1080` |
|
||||
| London | datacenter (Nord exit) | CT681 `10.30.20.71:1080` |
|
||||
| Sydney | datacenter (Nord exit) | CT682 `10.30.20.189:1080` |
|
||||
| Residential | rotating/sticky | IPRoyal `geo.iproyal.com:12321` |
|
||||
| Mobile 4G | rotating | IPRoyal `4g.iproyal.com` |
|
||||
|
||||
## API
|
||||
- `POST /api/register` — `{username, email, password, captcha_id, captcha}`
|
||||
- `POST /api/login` — `{username, password}`
|
||||
- `GET /api/captcha` — arithmetic challenge
|
||||
- `POST /api/buy` — `{plan_id}` → creates BTCPay invoice + pending subscription
|
||||
- `GET /api/subscriptions` — user's active proxies
|
||||
- `POST /api/btcpay/webhook` — activates subscription + issues creds on payment
|
||||
|
||||
## Deploy
|
||||
```
|
||||
tar czf rigel.tar.gz app.py
|
||||
scp rigel.tar.gz root@10.30.20.85:/tmp/
|
||||
ssh root@10.30.20.85 "pct push 158 /tmp/rigel.tar.gz /tmp/rigel.tar.gz && pct exec 158 -- bash -c 'cd /opt/rigel && tar xzf /tmp/rigel.tar.gz && systemctl restart rigel'"
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
- Fleet tunnel is REMOTE-MANAGED — edit ingress via CF API (`/accounts/<id>/cfd_tunnel/1aeb1ac0.../configurations`), NOT the local `config-fleet.yml`. Local edits are ignored.
|
||||
- BTCPay webhook secret wired via `BTCPAY_WEBHOOK_SECRET` env in `rigel.service`.
|
||||
372
app.py
Normal file
372
app.py
Normal file
@@ -0,0 +1,372 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Rigel — no-KYC SOCKS5 proxy shop.
|
||||
Sells access to residential/mobile/datacenter proxies. Bitcoin via BTCPay.
|
||||
|
||||
Auth: no-KYC (username + email + password, no ID). Captcha-lite (math challenge).
|
||||
Inventory: proxied through gost SOCKS5 frontends with per-user auth.
|
||||
"""
|
||||
import os, sqlite3, json, hmac, hashlib, time, uuid, secrets
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Flask, request, jsonify, g, render_template_string, redirect, session
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32))
|
||||
|
||||
DB = os.path.join(os.path.dirname(os.path.abspath(__file__)), "rigel.db")
|
||||
|
||||
# ── config ──────────────────────────────────────────────────────────────
|
||||
# Proxy inventory: each location is a gost frontend. We issue per-user creds.
|
||||
# The actual upstream SOCKS5 endpoints (Nord CTs) are hidden behind a gost
|
||||
# auth layer so customers never see the raw CT IPs.
|
||||
LOCATIONS = {
|
||||
"tokyo": {"name": "Tokyo, JP", "upstream": "10.30.20.154:1080", "type": "datacenter"},
|
||||
"london": {"name": "London, UK", "upstream": "10.30.20.71:1080", "type": "datacenter"},
|
||||
"sydney": {"name": "Sydney, AU", "upstream": "10.30.20.189:1080", "type": "datacenter"},
|
||||
# IPRoyal residential + mobile are added when creds are provisioned
|
||||
"residential": {"name": "Residential (rotating)", "upstream": "geo.iproyal.com:12321", "type": "residential"},
|
||||
"mobile": {"name": "Mobile 4G (sticky)", "upstream": "us.4g.iproyal.com:7001", "type": "mobile"},
|
||||
}
|
||||
|
||||
PLANS = {
|
||||
"day": {"label": "1 Day", "hours": 24, "sats": 8000},
|
||||
"week": {"label": "1 Week", "hours": 168, "sats": 45000},
|
||||
"month": {"label": "1 Month", "hours": 720, "sats": 150000},
|
||||
}
|
||||
|
||||
BTCPAY_URL = os.environ.get("BTCPAY_URL", "https://10.30.20.140")
|
||||
BTCPAY_STORE = os.environ.get("BTCPAY_STORE_ID", "")
|
||||
BTCPAY_TOKEN = os.environ.get("BTCPAY_API_KEY", "")
|
||||
|
||||
def db():
|
||||
con = getattr(g, "_db", None)
|
||||
if con is None:
|
||||
con = sqlite3.connect(DB)
|
||||
con.row_factory = sqlite3.Row
|
||||
g._db = con
|
||||
return con
|
||||
|
||||
@app.teardown_appcontext
|
||||
def close_db(exc):
|
||||
con = getattr(g, "_db", None)
|
||||
if con is not None:
|
||||
con.close()
|
||||
|
||||
def init_db():
|
||||
con = sqlite3.connect(DB)
|
||||
con.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT,
|
||||
password_hash TEXT NOT NULL,
|
||||
balance_sats INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
location TEXT NOT NULL,
|
||||
plan TEXT NOT NULL,
|
||||
sats_paid INTEGER NOT NULL,
|
||||
starts_at TEXT,
|
||||
expires_at TEXT,
|
||||
proxy_user TEXT,
|
||||
proxy_pass TEXT,
|
||||
status TEXT DEFAULT 'pending', -- pending|active|expired
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
btcpay_invoice_id TEXT,
|
||||
amount_sats INTEGER,
|
||||
status TEXT DEFAULT 'new', -- new|paid|expired
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
""")
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
def hash_pw(pw, salt=None):
|
||||
salt = salt or secrets.token_hex(16)
|
||||
return salt + ":" + hmac.new(salt.encode(), pw.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
def check_pw(pw, stored):
|
||||
salt, digest = stored.split(":", 1)
|
||||
return hmac.new(salt.encode(), pw.encode(), hashlib.sha256).hexdigest() == digest
|
||||
|
||||
def current_user():
|
||||
uid = session.get("uid")
|
||||
if not uid:
|
||||
return None
|
||||
return db().execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
|
||||
|
||||
# ── routes ──────────────────────────────────────────────────────────────
|
||||
@app.route("/")
|
||||
def index():
|
||||
user = current_user()
|
||||
if not user:
|
||||
return render_template_string(AUTH_HTML, mode="login", error="")
|
||||
return render_template_string(DASH_HTML, user=user, locations=LOCATIONS, plans=PLANS)
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
return jsonify({"status": "ok", "service": "rigel"})
|
||||
|
||||
@app.route("/api/register", methods=["POST"])
|
||||
def register():
|
||||
d = request.get_json(force=True) if request.is_json else request.form
|
||||
username = (d.get("username") or "").strip()
|
||||
email = (d.get("email") or "").strip()
|
||||
pw = d.get("password") or ""
|
||||
captcha = d.get("captcha") or ""
|
||||
if not username or not pw or len(username) > 25 or len(pw) < 6:
|
||||
return jsonify({"error": "username + password (6+ chars) required"}), 400
|
||||
if not check_captcha(captcha):
|
||||
return jsonify({"error": "wrong captcha"}), 400
|
||||
con = db()
|
||||
try:
|
||||
con.execute("INSERT INTO users (username, email, password_hash) VALUES (?,?,?)",
|
||||
(username, email, hash_pw(pw)))
|
||||
con.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
return jsonify({"error": "username taken"}), 409
|
||||
return jsonify({"ok": True})
|
||||
|
||||
@app.route("/api/login", methods=["POST"])
|
||||
def login():
|
||||
d = request.get_json(force=True) if request.is_json else request.form
|
||||
username = (d.get("username") or "").strip()
|
||||
pw = d.get("password") or ""
|
||||
captcha = d.get("captcha") or ""
|
||||
if not check_captcha(captcha):
|
||||
return jsonify({"error": "wrong captcha"}), 400
|
||||
user = db().execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
|
||||
if not user or not check_pw(pw, user["password_hash"]):
|
||||
return jsonify({"error": "bad credentials"}), 401
|
||||
session["uid"] = user["id"]
|
||||
return jsonify({"ok": True})
|
||||
|
||||
@app.route("/api/logout", methods=["POST"])
|
||||
def logout():
|
||||
session.pop("uid", None)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
# Captcha-lite: math challenge rendered as text (no external captcha service)
|
||||
@app.route("/api/captcha")
|
||||
def captcha():
|
||||
a, b = secrets.randbelow(9) + 1, secrets.randbelow(9) + 1
|
||||
session["captcha"] = str(a + b)
|
||||
return jsonify({"question": f"What is {a} + {b}?", "id": "c1"})
|
||||
|
||||
def check_captcha(ans):
|
||||
return (ans or "").strip() == session.get("captcha", "")
|
||||
|
||||
@app.route("/api/buy", methods=["POST"])
|
||||
def buy():
|
||||
user = current_user()
|
||||
if not user:
|
||||
return jsonify({"error": "login required"}), 401
|
||||
d = request.get_json(force=True) if request.is_json else request.form
|
||||
location = d.get("location", "")
|
||||
plan = d.get("plan", "")
|
||||
if location not in LOCATIONS or plan not in PLANS:
|
||||
return jsonify({"error": "bad location/plan"}), 400
|
||||
sats = PLANS[plan]["sats"]
|
||||
# create BTCPay invoice
|
||||
invoice_id, checkout = create_btcpay_invoice(user, sats, f"Rigel {LOCATIONS[location]['name']} {PLANS[plan]['label']}")
|
||||
con = db()
|
||||
cur = con.execute("INSERT INTO subscriptions (user_id, location, plan, sats_paid, proxy_user) VALUES (?,?,?,?,?)",
|
||||
(user["id"], location, plan, sats, f"u{user['id']}{secrets.token_hex(4)}"))
|
||||
con.execute("INSERT INTO invoices (user_id, btcpay_invoice_id, amount_sats) VALUES (?,?,?)",
|
||||
(user["id"], invoice_id, sats))
|
||||
con.commit()
|
||||
return jsonify({"ok": True, "invoice_id": invoice_id, "checkout": checkout, "sats": sats})
|
||||
|
||||
def create_btcpay_invoice(user, sats, desc):
|
||||
if not BTCPAY_STORE or not BTCPAY_TOKEN:
|
||||
# fallback: no BTCPay configured — return a placeholder
|
||||
return "local-" + uuid.uuid4().hex[:12], None
|
||||
import urllib.request, ssl as _ssl
|
||||
ctx = _ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = _ssl.CERT_NONE
|
||||
body = json.dumps({"amount": str(sats/1e8), "currency": "BTC", "metadata": {"desc": desc, "user": user["username"]}})
|
||||
req = urllib.request.Request(f"{BTCPAY_URL}/api/v1/stores/{BTCPAY_STORE}/invoices",
|
||||
data=body.encode(), headers={"Authorization": f"token {BTCPAY_TOKEN}", "Content-Type": "application/json"}, method="POST")
|
||||
r = urllib.request.urlopen(req, timeout=20, context=ctx)
|
||||
inv = json.loads(r.read())
|
||||
return inv["id"], inv.get("checkoutLink")
|
||||
|
||||
@app.route("/api/subscriptions")
|
||||
def subscriptions():
|
||||
user = current_user()
|
||||
if not user:
|
||||
return jsonify({"error": "login required"}), 401
|
||||
subs = db().execute("SELECT * FROM subscriptions WHERE user_id=? ORDER BY id DESC", (user["id"],)).fetchall()
|
||||
return jsonify([dict(s) for s in subs])
|
||||
|
||||
@app.route("/api/status")
|
||||
def status():
|
||||
user = current_user()
|
||||
if not user:
|
||||
return jsonify({"authenticated": False})
|
||||
return jsonify({"authenticated": True, "username": user["username"], "balance_sats": user["balance_sats"]})
|
||||
|
||||
# ── BTCPay webhook: activate subscription on payment ─────────────────────
|
||||
WEBHOOK_SECRET = os.environ.get("BTCPAY_WEBHOOK_SECRET", "")
|
||||
|
||||
@app.route("/api/btcpay/webhook", methods=["POST"])
|
||||
def btcpay_webhook():
|
||||
# optional HMAC verification (raw body)
|
||||
if WEBHOOK_SECRET:
|
||||
raw = request.get_data()
|
||||
sig = request.headers.get("BTCPay-Sig", "")
|
||||
expected = "sha256=" + hmac.new(WEBHOOK_SECRET.encode(), raw, hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return jsonify({"error": "bad signature"}), 401
|
||||
payload = request.get_json(force=True, silent=True) or {}
|
||||
etype = payload.get("type", "")
|
||||
invoice_id = payload.get("invoiceId", "")
|
||||
status = payload.get("status", "")
|
||||
|
||||
if etype in ("InvoiceSettled", "InvoiceReceivedPayment") and invoice_id:
|
||||
# find matching invoice -> subscription
|
||||
con = sqlite3.connect(DB)
|
||||
con.row_factory = sqlite3.Row
|
||||
inv = con.execute("SELECT * FROM invoices WHERE btcpay_invoice_id=? AND status!='paid'", (invoice_id,)).fetchone()
|
||||
if inv:
|
||||
con.execute("UPDATE invoices SET status='paid' WHERE btcpay_invoice_id=?", (invoice_id,))
|
||||
# activate subscription + issue proxy creds
|
||||
sub = con.execute("SELECT * FROM subscriptions WHERE user_id=? AND status='pending' ORDER BY id DESC LIMIT 1",
|
||||
(inv["user_id"],)).fetchone()
|
||||
if sub:
|
||||
proxy_user = sub["proxy_user"] or f"u{inv['user_id']}{secrets.token_hex(4)}"
|
||||
proxy_pass = secrets.token_hex(12)
|
||||
hours = PLANS.get(sub["plan"], {}).get("hours", 24)
|
||||
now = datetime.utcnow()
|
||||
expires = now + timedelta(hours=hours)
|
||||
con.execute("""UPDATE subscriptions SET status='active', proxy_user=?, proxy_pass=?,
|
||||
starts_at=?, expires_at=? WHERE id=?""",
|
||||
(proxy_user, proxy_pass, now.isoformat(), expires.isoformat(), sub["id"]))
|
||||
# provision the proxy auth upstream (gost) — best-effort
|
||||
provision_proxy(sub["location"], proxy_user, proxy_pass)
|
||||
con.commit()
|
||||
con.close()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
def provision_proxy(location, user, pw):
|
||||
"""Best-effort: add user auth to the gost frontend for this location.
|
||||
The gost SOCKS5 frontends need per-user auth; for now we record the creds
|
||||
and the actual gost auth layer is wired per-location (see rigel-proxy setup)."""
|
||||
# Placeholder — the real gost auth is applied via the proxy gateway config.
|
||||
# Creds are persisted on the subscription row so the customer sees them.
|
||||
app.logger.info(f"provision proxy {location} for {user}")
|
||||
|
||||
# ── templates ───────────────────────────────────────────────────────────
|
||||
AUTH_HTML = r"""
|
||||
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Rigel — SOCKS5 Proxies</title>
|
||||
<style>
|
||||
:root{--bg:#0a0e1a;--card:#12182b;--accent:#4f8cff;--text:#e6ebf5;--muted:#8b95ad;--ok:#2ecc71}
|
||||
*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center}
|
||||
.card{background:var(--card);border:1px solid #1e2742;border-radius:14px;padding:34px;width:100%;max-width:400px;box-shadow:0 10px 40px rgba(0,0,0,.4)}
|
||||
h1{font-size:26px;margin:0 0 4px;color:#fff} .sub{color:var(--muted);font-size:13px;margin:0 0 24px}
|
||||
.star{color:var(--accent)} label{display:block;font-size:12px;color:var(--muted);margin:14px 0 4px}
|
||||
input{width:100%;padding:11px 12px;border-radius:8px;border:1px solid #26304f;background:#0d1325;color:var(--text);font-size:14px}
|
||||
input:focus{outline:none;border-color:var(--accent)}
|
||||
button{width:100%;padding:12px;margin-top:20px;border:0;border-radius:8px;background:var(--accent);color:#fff;font-size:15px;font-weight:600;cursor:pointer}
|
||||
button.alt{background:transparent;border:1px solid #26304f;color:var(--text);margin-top:10px}
|
||||
a{color:var(--accent);text-decoration:none;font-size:13px;display:block;text-align:center;margin-top:14px}
|
||||
.err{color:#e74c3c;font-size:13px;margin-top:10px;min-height:16px}
|
||||
.cap{display:flex;gap:10px;align-items:center;margin-top:14px}.cap .q{font-size:14px;color:var(--text);flex:1}.cap input{width:90px}
|
||||
</style></head><body><div class="card">
|
||||
<h1><span class="star">✦</span> Rigel</h1><p class="sub">No-KYC SOCKS5 & residential proxies. Pay in Bitcoin.</p>
|
||||
<div id="body"></div>
|
||||
<script>
|
||||
let mode="{{mode}}";
|
||||
function esc(s){return s.replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
||||
async function captcha(){let r=await fetch('/api/captcha').then(x=>x.json());document.getElementById('cq').innerText=r.question;}
|
||||
function loginForm(){
|
||||
return `<label>Username</label><input id="u" maxlength="25">
|
||||
<label>Password</label><input id="p" type="password" maxlength="30">
|
||||
<div class="cap"><span class="q" id="cq"></span><input id="c" maxlength="6" placeholder="answer"></div>
|
||||
<button onclick="doLogin()">Login</button>
|
||||
<a href="javascript:regForm()">Create an account</a><div class="err" id="err"></div>`;
|
||||
}
|
||||
function regForm(){
|
||||
return `<label>Username</label><input id="u" maxlength="25">
|
||||
<label>Email <span style="color:#5a6480">(optional, for restore)</span></label><input id="e" maxlength="75">
|
||||
<label>Password</label><input id="p" type="password" maxlength="30">
|
||||
<div class="cap"><span class="q" id="cq"></span><input id="c" maxlength="6" placeholder="answer"></div>
|
||||
<button onclick="doRegister()">Create account</button>
|
||||
<a href="javascript:loginForm()">Already have an account?</a><div class="err" id="err"></div>`;
|
||||
}
|
||||
function err(s){document.getElementById('err').innerText=s;}
|
||||
async function doLogin(){
|
||||
let r=await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:document.getElementById('u').value,password:document.getElementById('p').value,captcha:document.getElementById('c').value})});
|
||||
if(r.ok){location.reload();}else{let j=await r.json();err(j.error||'login failed');captcha();}
|
||||
}
|
||||
async function doRegister(){
|
||||
let r=await fetch('/api/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:document.getElementById('u').value,email:document.getElementById('e').value,password:document.getElementById('p').value,captcha:document.getElementById('c').value})});
|
||||
if(r.ok){loginForm();err('account created — log in');captcha();}else{let j=await r.json();err(j.error||'registration failed');captcha();}
|
||||
}
|
||||
if(mode==='login'){document.getElementById('body').innerHTML=loginForm();captcha();}
|
||||
</script></div></body></html>
|
||||
"""
|
||||
|
||||
DASH_HTML = r"""
|
||||
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Rigel — Dashboard</title>
|
||||
<style>
|
||||
:root{--bg:#0a0e1a;--card:#12182b;--accent:#4f8cff;--text:#e6ebf5;--muted:#8b95ad}
|
||||
*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||
header{display:flex;justify-content:space-between;align-items:center;padding:16px 28px;background:var(--card);border-bottom:1px solid #1e2742}
|
||||
header h1{margin:0;font-size:20px}header h1 span{color:var(--accent)}
|
||||
header button{background:transparent;border:1px solid #26304f;color:var(--text);padding:8px 14px;border-radius:8px;cursor:pointer}
|
||||
main{padding:28px;max-width:900px;margin:0 auto}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:16px}
|
||||
.loc{background:var(--card);border:1px solid #1e2742;border-radius:12px;padding:20px}
|
||||
.loc h3{margin:0 0 4px;font-size:17px;color:#fff}.loc .type{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px}
|
||||
.loc .plans{margin-top:14px}.loc .plan{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-top:1px solid #1e2742;font-size:13px}
|
||||
.loc button{background:var(--accent);color:#fff;border:0;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px}
|
||||
.subs{margin-top:32px}.subs h2{font-size:17px}.subs table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.subs th,.subs td{padding:10px;text-align:left;border-bottom:1px solid #1e2742}.subs th{color:var(--muted);font-weight:500}
|
||||
.badge{padding:3px 8px;border-radius:10px;font-size:11px}.active{background:#14321f;color:#2ecc71}.pending{background:#332a14;color:#f1c40f}.expired{background:#33171a;color:#e74c3c}
|
||||
</style></head><body>
|
||||
<header><h1><span>✦</span> Rigel <span style="color:var(--muted);font-weight:400;font-size:13px">— {{user["username"]}}</span></h1><button onclick="logout()">Logout</button></header>
|
||||
<main>
|
||||
<div class="grid">
|
||||
{% for key, loc in locations.items() %}
|
||||
<div class="loc">
|
||||
<h3>{{loc["name"]}}</h3><div class="type">{{loc["type"]}}</div>
|
||||
<div class="plans">
|
||||
{% for pk, p in plans.items() %}
|
||||
<div class="plan"><span>{{p["label"]}}</span><span><b>{{p["sats"]}}</b> sats</span><button onclick="buy('{{key}}','{{pk}}')">Buy</button></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="subs"><h2>Your subscriptions</h2><div id="subs">Loading…</div></div>
|
||||
</main>
|
||||
<script>
|
||||
async function buy(loc, plan){
|
||||
let r=await fetch('/api/buy',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:loc,plan:plan})});
|
||||
let j=await r.json();
|
||||
if(j.ok && j.checkout){window.location=j.checkout;}else{alert(j.error||'error');}
|
||||
}
|
||||
async function logout(){await fetch('/api/logout',{method:'POST'});location.reload();}
|
||||
async function loadSubs(){
|
||||
let r=await fetch('/api/subscriptions').then(x=>x.json());
|
||||
if(!r.length){document.getElementById('subs').innerHTML='<p style="color:var(--muted)">No subscriptions yet.</p>';return;}
|
||||
let html='<table><tr><th>Location</th><th>Plan</th><th>Status</th><th>Expires</th><th>Proxy creds</th></tr>';
|
||||
for(const s of r){html+=`<tr><td>${s.location}</td><td>${s.plan}</td><td><span class="badge ${s.status}">${s.status}</span></td><td>${s.expires_at||'—'}</td><td>${s.proxy_user||'—'}</td></tr>`;}
|
||||
html+='</table>';document.getElementById('subs').innerHTML=html;
|
||||
}
|
||||
loadSubs();
|
||||
</script></body></html>
|
||||
"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||
Reference in New Issue
Block a user