Add newer CoalGov update

This commit is contained in:
CoalGov Deploy
2026-07-11 02:06:53 +00:00
parent dd69ab17a5
commit 963cdd2d9d
39 changed files with 2763 additions and 43 deletions

491
property-web/app.py Normal file
View File

@@ -0,0 +1,491 @@
#!/usr/bin/env python3
import base64
import hashlib
import hmac
import html
import json
import os
import secrets
import sqlite3
import threading
import time
from http import HTTPStatus
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
BASE_DIR = Path(__file__).resolve().parent
DB_PATH = Path(os.environ.get("COALGOV_DB", "/opt/mcworldgen-server/data/plugins/CoalGov/coalgov.db"))
SECRET_PATH = Path(os.environ.get("COALGOV_WEB_SECRET", str(BASE_DIR / "session.secret")))
HOST = os.environ.get("COALGOV_WEB_HOST", "0.0.0.0")
PORT = int(os.environ.get("COALGOV_WEB_PORT", "8088"))
SESSION_TTL = int(os.environ.get("COALGOV_WEB_SESSION_SECONDS", "43200"))
BLUEMAP_URL = os.environ.get("COALGOV_BLUEMAP_URL", "")
BLUEMAP_MARKERS_PATH = os.environ.get("COALGOV_BLUEMAP_MARKERS_PATH", "")
BLUEMAP_SYNC_SECONDS = int(os.environ.get("COALGOV_BLUEMAP_SYNC_SECONDS", "30"))
def secret():
if SECRET_PATH.exists():
return SECRET_PATH.read_bytes().strip()
value = secrets.token_bytes(32)
SECRET_PATH.write_bytes(base64.urlsafe_b64encode(value))
os.chmod(SECRET_PATH, 0o600)
return SECRET_PATH.read_bytes().strip()
SECRET = secret()
def db():
con = sqlite3.connect(DB_PATH)
con.row_factory = sqlite3.Row
return con
def now_ms():
return int(time.time() * 1000)
def token_hash(token):
return hashlib.sha256(token.upper().replace(" ", "").encode()).hexdigest()
def sign(payload):
raw = json.dumps(payload, separators=(",", ":")).encode()
body = base64.urlsafe_b64encode(raw).decode().rstrip("=")
sig = hmac.new(SECRET, body.encode(), hashlib.sha256).hexdigest()
return f"{body}.{sig}"
def unsign(value):
if not value or "." not in value:
return None
body, sig = value.rsplit(".", 1)
expected = hmac.new(SECRET, body.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
return None
try:
raw = base64.urlsafe_b64decode(body + "=" * (-len(body) % 4))
payload = json.loads(raw)
except (ValueError, json.JSONDecodeError):
return None
if payload.get("exp", 0) < int(time.time()):
return None
return payload
def money(cents):
cents = int(cents or 0)
coal, cc = divmod(abs(cents), 100)
prefix = "-" if cents < 0 else ""
if coal and cc:
return f"{prefix}{coal} coal {cc}cc"
if coal:
return f"{prefix}{coal} coal"
return f"{prefix}{cc}cc"
def rows(query, args=()):
with db() as con:
return [dict(row) for row in con.execute(query, args).fetchall()]
def row(query, args=()):
with db() as con:
found = con.execute(query, args).fetchone()
return dict(found) if found else None
def display_claim(claim):
name = claim.get("display_name") or f"Claim #{claim['id']}"
return {**claim, "display": name, "tax_due_text": money(claim.get("tax_due", 0))}
def can_manage(con, uuid, claim_id):
claim = con.execute("SELECT owner_uuid FROM claims WHERE id = ?", (claim_id,)).fetchone()
if not claim:
return False
if claim["owner_uuid"] == uuid:
return True
grant = con.execute("""
SELECT 1 FROM claim_permissions
WHERE claim_id = ? AND player_uuid = ? AND permission = 'MANAGE'
LIMIT 1
""", (claim_id, uuid)).fetchone()
return bool(grant)
def color(kind):
colors = {
"HOMESTEAD": {"r": 102, "g": 170, "b": 204},
"INDUSTRIAL": {"r": 204, "g": 153, "b": 102},
"GOVERNMENT": {"r": 221, "g": 102, "b": 102},
"PUBLIC": {"r": 119, "g": 204, "b": 119},
"PROTECTED_PRESERVE": {"r": 102, "g": 204, "b": 102},
"MINING_CONCESSION": {"r": 204, "g": 204, "b": 102},
"BORDER_ZONE": {"r": 204, "g": 102, "b": 204},
}
return colors.get(kind, {"r": 170, "g": 170, "b": 170})
def rect_shape(area):
x1, x2 = sorted((int(area["x1"]), int(area["x2"])))
z1, z2 = sorted((int(area["z1"]), int(area["z2"])))
return [
{"x": x1, "z": z1},
{"x": x2 + 1, "z": z1},
{"x": x2 + 1, "z": z2 + 1},
{"x": x1, "z": z2 + 1},
]
def center(shape):
return {
"x": sum(p["x"] for p in shape) / len(shape),
"y": 72,
"z": sum(p["z"] for p in shape) / len(shape),
}
def marker_detail(title, lines):
body = "".join(f"<div>{html.escape(str(line))}</div>" for line in lines)
return f"<strong>{html.escape(str(title))}</strong>{body}"
def build_bluemap_markers():
with db() as con:
claims = [display_claim(dict(r)) for r in con.execute("""
SELECT c.*, p.name AS owner_name FROM claims c
LEFT JOIN players p ON p.uuid = c.owner_uuid
WHERE c.world = 'world'
ORDER BY c.id
""")]
vertices = {}
for r in con.execute("SELECT claim_id, x, z FROM claim_vertices ORDER BY claim_id, vertex_order"):
vertices.setdefault(r["claim_id"], []).append({"x": int(r["x"]), "z": int(r["z"])})
lands = [dict(r) for r in con.execute("SELECT * FROM land_regions WHERE world = 'world' ORDER BY name")]
claim_markers = []
for claim in claims:
shape = vertices.get(claim["id"]) or rect_shape(claim)
c = color(claim["claim_type"])
claim_markers.append({
"id": f"claim-{claim['id']}",
"type": "shape",
"map": "overworld",
"position": center(shape),
"label": claim["display"],
"detail": marker_detail(claim["display"], [
f"Type: {claim['claim_type']}",
f"Owner: {claim.get('owner_name') or claim['owner_uuid']}",
f"Tax due: {claim['tax_due_text']}",
]),
"shape": shape,
"shapeY": 72,
"depthTest": False,
"lineWidth": 3,
"lineColor": {**c, "a": 1.0},
"fillColor": {**c, "a": 0.28},
"minDistance": 10.0,
"maxDistance": 10000000.0,
})
land_markers = []
for land in lands:
shape = rect_shape(land)
c = color(land["land_class"])
land_markers.append({
"id": f"land-{land['id']}",
"type": "shape",
"map": "overworld",
"position": center(shape),
"label": land["name"],
"detail": marker_detail(land["name"], [f"Class: {land['land_class']}"]),
"shape": shape,
"shapeY": 73,
"depthTest": False,
"lineWidth": 2,
"lineColor": {**c, "a": 1.0},
"fillColor": {**c, "a": 0.16},
"minDistance": 10.0,
"maxDistance": 10000000.0,
})
return {
"coalgov-claims": {
"label": "CoalGov Claims",
"toggleable": True,
"defaultHidden": False,
"sorting": 10,
"markers": {marker["id"]: {k: v for k, v in marker.items() if k != "id"} for marker in claim_markers},
},
"coalgov-land": {
"label": "CoalGov Land",
"toggleable": True,
"defaultHidden": False,
"sorting": 11,
"markers": {marker["id"]: {k: v for k, v in marker.items() if k != "id"} for marker in land_markers},
},
}
def sync_bluemap_markers_once():
if not BLUEMAP_MARKERS_PATH:
return False
target = Path(BLUEMAP_MARKERS_PATH)
target.parent.mkdir(parents=True, exist_ok=True)
tmp = target.with_suffix(target.suffix + ".tmp")
tmp.write_text(json.dumps(build_bluemap_markers(), separators=(",", ":")))
tmp.replace(target)
return True
def sync_bluemap_markers_loop():
while True:
try:
sync_bluemap_markers_once()
except Exception as exc:
print(f"BlueMap marker sync failed: {exc}", flush=True)
time.sleep(max(5, BLUEMAP_SYNC_SECONDS))
INDEX = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CoalGov Property Map</title>
<style>
body { margin: 0; font-family: system-ui, sans-serif; background: #111; color: #eee; }
header { display: flex; gap: 12px; align-items: center; padding: 10px 14px; background: #202020; border-bottom: 1px solid #333; }
h1 { font-size: 18px; margin: 0; }
main { display: grid; grid-template-columns: 1fr 360px; height: calc(100vh - 48px); }
#mapwrap { position: relative; min-width: 0; background: #151515; }
#bluemap { width: 100%; height: 100%; border: 0; display: block; }
aside { overflow: auto; padding: 14px; background: #1b1b1b; border-left: 1px solid #333; }
input, button, select { background: #272727; color: #eee; border: 1px solid #444; border-radius: 4px; padding: 8px; }
button { cursor: pointer; }
.row { display: flex; gap: 8px; margin: 8px 0; }
.card { border: 1px solid #333; border-radius: 6px; padding: 10px; margin: 10px 0; background: #202020; }
.muted { color: #aaa; font-size: 13px; }
.hidden { display: none; }
@media (max-width: 900px) { main { grid-template-columns: 1fr; grid-template-rows: 55vh auto; } aside { border-left: 0; border-top: 1px solid #333; } }
</style>
</head>
<body>
<header><h1>CoalGov Property Map</h1><span id="who" class="muted"></span></header>
<main>
<section id="mapwrap">
<iframe id="bluemap"></iframe>
</section>
<aside>
<section id="login">
<h2>Token Login</h2>
<p class="muted">Run <code>/coalgov webtoken</code> in game, then enter the single-use token here.</p>
<div class="row"><input id="token" placeholder="ABCD-1234-EFGH"><button onclick="login()">Log in</button></div>
</section>
<section id="panel" class="hidden">
<div class="row"><button onclick="loadAll()">Refresh</button><button onclick="syncBlueMap()">Sync BlueMap</button><button onclick="logout()">Log out</button></div>
<p class="muted">The main pane is BlueMap. CoalGov property and land overlays are published into BlueMap's marker layer.</p>
<h2>Your Claims</h2><div id="claims"></div>
<h2>Land Notices</h2><div id="notices"></div>
</section>
</aside>
</main>
<script>
const configuredBlueMapUrl = "__BLUEMAP_URL__";
bluemap.src = configuredBlueMapUrl || `${location.protocol}//${location.hostname}:8100/`;
let data = {claims: [], lands: []};
async function api(path, opts={}) {
const r = await fetch(path, {headers: {"Content-Type":"application/json"}, ...opts});
if (!r.ok) throw new Error((await r.json()).error || r.statusText);
return r.json();
}
async function login() {
try { await api("/api/login", {method:"POST", body:JSON.stringify({token:document.getElementById("token").value})}); await loadAll(); }
catch(e) { alert(e.message); }
}
async function logout() { await api("/api/logout", {method:"POST"}); location.reload(); }
async function syncBlueMap() { try { await api("/api/bluemap/sync", {method:"POST"}); const frame = document.getElementById("bluemap"); frame.src = frame.src; } catch(e) { alert(e.message); } }
async function loadAll() {
const me = await api("/api/me");
document.getElementById("who").textContent = me.name + " (" + me.uuid + ")";
document.getElementById("login").classList.add("hidden");
document.getElementById("panel").classList.remove("hidden");
data = await api("/api/map");
renderClaims();
}
function renderClaims() {
claims.innerHTML = "";
for (const c of data.claims.filter(c=>c.manageable)) {
const div = document.createElement("div"); div.className = "card";
div.innerHTML = `<b>${c.display}</b><div class="muted">${c.claim_type} ${c.world} X ${c.x1}..${c.x2} Z ${c.z1}..${c.z2}<br>Owner: ${c.owner_name || c.owner_uuid}<br>Tax due: ${c.tax_due_text}</div>
<div class="row"><input value="${c.display}" id="n${c.id}"><button onclick="renameClaim(${c.id})">Rename</button></div>
<div class="row"><input placeholder="target player name or UUID" id="t${c.id}"><button onclick="transferClaim(${c.id})">Transfer</button></div>
<button onclick="payTax(${c.id})">Pay Claim Tax</button>`;
claims.appendChild(div);
}
notices.innerHTML = data.lands.map(l => `<div class="card"><b>${l.name}</b><div class="muted">${l.land_class} ${l.world} X ${l.x1}..${l.x2} Z ${l.z1}..${l.z2}</div></div>`).join("");
}
async function renameClaim(id) { try { await api(`/api/claims/${id}/rename`, {method:"POST", body:JSON.stringify({name:document.getElementById("n"+id).value})}); await loadAll(); } catch(e) { alert(e.message); } }
async function transferClaim(id) { try { await api(`/api/claims/${id}/transfer`, {method:"POST", body:JSON.stringify({target:document.getElementById("t"+id).value})}); await loadAll(); } catch(e) { alert(e.message); } }
async function payTax(id) { try { const r = await api(`/api/claims/${id}/pay-tax`, {method:"POST"}); alert(r.message); await loadAll(); } catch(e) { alert(e.message); } }
loadAll().catch(()=>{});
</script>
</body></html>""".replace("__BLUEMAP_URL__", html.escape(BLUEMAP_URL, quote=True))
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
path = urlparse(self.path).path
if path == "/":
return self.html(INDEX)
if path == "/api/me":
user = self.require_user()
if user:
player = row("SELECT name FROM players WHERE uuid = ?", (user["uuid"],)) or {}
self.json({"uuid": user["uuid"], "name": player.get("name", user["uuid"])})
return
if path == "/api/map":
user = self.require_user()
if not user:
return
uuid = user["uuid"]
with db() as con:
claims = [display_claim(dict(r)) for r in con.execute("""
SELECT c.*, p.name AS owner_name FROM claims c
LEFT JOIN players p ON p.uuid = c.owner_uuid
ORDER BY c.id
""")]
vertices = {}
for r in con.execute("SELECT claim_id, x, z FROM claim_vertices ORDER BY claim_id, vertex_order"):
vertices.setdefault(r["claim_id"], []).append({"x": r["x"], "z": r["z"]})
for c in claims:
c["vertices"] = vertices.get(c["id"], [])
c["manageable"] = c["owner_uuid"] == uuid or bool(con.execute("""
SELECT 1 FROM claim_permissions WHERE claim_id = ? AND player_uuid = ? AND permission = 'MANAGE'
""", (c["id"], uuid)).fetchone())
lands = [dict(r) for r in con.execute("SELECT * FROM land_regions ORDER BY name")]
self.json({"claims": claims, "lands": lands})
return
self.error(HTTPStatus.NOT_FOUND, "Not found")
def do_POST(self):
path = urlparse(self.path).path
if path == "/api/login":
body = self.body()
token = body.get("token", "")
hashed = token_hash(token)
with db() as con:
found = con.execute("""
SELECT token_hash, player_uuid FROM web_tokens
WHERE token_hash = ? AND expires_at > ? AND used_at IS NULL
""", (hashed, now_ms())).fetchone()
if not found:
return self.error(HTTPStatus.UNAUTHORIZED, "Invalid or expired token")
con.execute("UPDATE web_tokens SET used_at = ? WHERE token_hash = ?", (now_ms(), hashed))
con.commit()
cookie = f"cg_session={sign({'uuid': found['player_uuid'], 'exp': int(time.time()) + SESSION_TTL})}; HttpOnly; SameSite=Lax; Path=/"
self.json({"ok": True}, headers={"Set-Cookie": cookie})
return
if path == "/api/logout":
self.json({"ok": True}, headers={"Set-Cookie": "cg_session=; Max-Age=0; Path=/"})
return
user = self.require_user()
if not user:
return
if path == "/api/bluemap/sync":
if sync_bluemap_markers_once():
return self.json({"ok": True})
return self.error(HTTPStatus.BAD_REQUEST, "BlueMap marker sync is not configured")
if path.startswith("/api/claims/") and path.endswith("/rename"):
claim_id = int(path.split("/")[3])
name = str(self.body().get("name", "")).strip()[:48]
if not name:
return self.error(HTTPStatus.BAD_REQUEST, "Name required")
with db() as con:
if not can_manage(con, user["uuid"], claim_id):
return self.error(HTTPStatus.FORBIDDEN, "You cannot manage that claim")
con.execute("UPDATE claims SET display_name = ? WHERE id = ?", (name, claim_id))
con.commit()
return self.json({"ok": True})
if path.startswith("/api/claims/") and path.endswith("/transfer"):
claim_id = int(path.split("/")[3])
target = str(self.body().get("target", "")).strip()
with db() as con:
if not can_manage(con, user["uuid"], claim_id):
return self.error(HTTPStatus.FORBIDDEN, "You cannot manage that claim")
player = con.execute("SELECT uuid, name FROM players WHERE uuid = ? OR lower(name) = lower(?)", (target, target)).fetchone()
if not player:
return self.error(HTTPStatus.BAD_REQUEST, "Target player not found in CoalGov")
con.execute("UPDATE claims SET owner_uuid = ? WHERE id = ?", (player["uuid"], claim_id))
con.execute("DELETE FROM claim_permissions WHERE claim_id = ?", (claim_id,))
con.commit()
return self.json({"ok": True})
if path.startswith("/api/claims/") and path.endswith("/pay-tax"):
claim_id = int(path.split("/")[3])
with db() as con:
if not can_manage(con, user["uuid"], claim_id):
return self.error(HTTPStatus.FORBIDDEN, "You cannot manage that claim")
claim = con.execute("SELECT tax_due FROM claims WHERE id = ?", (claim_id,)).fetchone()
due = int(claim["tax_due"] or 0) if claim else 0
if due <= 0:
return self.json({"ok": True, "message": "No tax due."})
updated = con.execute("UPDATE players SET coal_balance = coal_balance - ? WHERE uuid = ? AND coal_balance >= ?", (due, user["uuid"], due)).rowcount
if updated != 1:
return self.error(HTTPStatus.BAD_REQUEST, f"Balance too low. Need {money(due)}.")
con.execute("UPDATE claims SET tax_due = 0 WHERE id = ?", (claim_id,))
con.execute("INSERT INTO transactions(from_uuid,to_uuid,amount,reason,created_at) VALUES (?,?,?,?,?)", (user["uuid"], None, due, "claim_tax_web", now_ms()))
con.execute("INSERT INTO treasury(id,balance) VALUES ('main', ?) ON CONFLICT(id) DO UPDATE SET balance = balance + excluded.balance", (due,))
con.commit()
return self.json({"ok": True, "message": f"Paid {money(due)}."})
self.error(HTTPStatus.NOT_FOUND, "Not found")
def require_user(self):
cookie = SimpleCookie(self.headers.get("Cookie", ""))
payload = unsign(cookie.get("cg_session").value if cookie.get("cg_session") else "")
if not payload:
self.error(HTTPStatus.UNAUTHORIZED, "Login required")
return None
return payload
def body(self):
length = int(self.headers.get("Content-Length", "0"))
if length <= 0:
return {}
return json.loads(self.rfile.read(length) or b"{}")
def html(self, content):
raw = content.encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def json(self, value, headers=None):
raw = json.dumps(value).encode()
self.send_response(200)
for key, val in (headers or {}).items():
self.send_header(key, val)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def error(self, status, message):
raw = json.dumps({"error": message}).encode()
self.send_response(status.value)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
if __name__ == "__main__":
print(f"CoalGov property web listening on {HOST}:{PORT}, db={DB_PATH}")
if BLUEMAP_MARKERS_PATH:
threading.Thread(target=sync_bluemap_markers_loop, daemon=True).start()
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()