generated from nathanwoodburn/python-webserver-template
993 lines
33 KiB
Python
993 lines
33 KiB
Python
from flask import (
|
|
Flask,
|
|
make_response,
|
|
render_template,
|
|
send_from_directory,
|
|
send_file,
|
|
jsonify,
|
|
g,
|
|
request,
|
|
)
|
|
import os
|
|
import requests
|
|
import sqlite3
|
|
from datetime import datetime
|
|
from urllib.parse import urlencode
|
|
from io import BytesIO
|
|
from xml.sax.saxutils import escape
|
|
import importlib
|
|
import dotenv
|
|
from tools import hip2, wallet_txt
|
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
|
|
|
dotenv.load_dotenv()
|
|
|
|
app = Flask(__name__)
|
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
|
|
|
|
DATABASE = os.getenv("DATABASE_PATH", "fireexplorer.db")
|
|
HSD_API_BASE = os.getenv("HSD_API_BASE", "https://hsd.hns.au/api/v1")
|
|
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "").rstrip("/")
|
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
OG_FONT_DIR = os.path.join(BASE_DIR, "templates", "assets", "fonts")
|
|
|
|
|
|
def get_db():
|
|
db = getattr(g, "_database", None)
|
|
if db is None:
|
|
db = g._database = sqlite3.connect(DATABASE)
|
|
db.row_factory = sqlite3.Row
|
|
return db
|
|
|
|
|
|
@app.teardown_appcontext
|
|
def close_connection(exception):
|
|
db = getattr(g, "_database", None)
|
|
if db is not None:
|
|
db.close()
|
|
|
|
|
|
def init_db():
|
|
with app.app_context():
|
|
db = get_db()
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS names (
|
|
namehash TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
db.commit()
|
|
|
|
|
|
init_db()
|
|
|
|
|
|
def find(name, path):
|
|
for root, dirs, files in os.walk(path):
|
|
if name in files:
|
|
return os.path.join(root, name)
|
|
|
|
|
|
def _truncate(text: str, max_length: int) -> str:
|
|
if len(text) <= max_length:
|
|
return text
|
|
return text[: max_length - 1] + "…"
|
|
|
|
|
|
def _safe_hns_value(value) -> str:
|
|
try:
|
|
return f"{float(value) / 1e6:,.2f} HNS"
|
|
except Exception:
|
|
return "Unknown"
|
|
|
|
|
|
def _format_int(value, fallback: str = "?") -> str:
|
|
try:
|
|
return f"{int(value):,}"
|
|
except Exception:
|
|
return fallback
|
|
|
|
|
|
def _ellipsize_middle(text: str, max_length: int = 48) -> str:
|
|
if len(text) <= max_length:
|
|
return text
|
|
if max_length < 7:
|
|
return _truncate(text, max_length)
|
|
left = (max_length - 1) // 2
|
|
right = max_length - left - 1
|
|
return f"{text[:left]}…{text[-right:]}"
|
|
|
|
|
|
def _fetch_explorer_json(endpoint: str):
|
|
try:
|
|
req = requests.get(f"{HSD_API_BASE}/{endpoint}", timeout=4)
|
|
if req.status_code == 200:
|
|
return req.json(), None
|
|
return None, f"HTTP {req.status_code}"
|
|
except Exception as e:
|
|
return None, str(e)
|
|
|
|
|
|
def _get_public_base_url() -> str:
|
|
if PUBLIC_BASE_URL:
|
|
return PUBLIC_BASE_URL
|
|
forwarded_proto = request.headers.get("X-Forwarded-Proto")
|
|
forwarded_host = request.headers.get("X-Forwarded-Host")
|
|
if forwarded_proto and forwarded_host:
|
|
proto = forwarded_proto.split(",")[0].strip()
|
|
host = forwarded_host.split(",")[0].strip()
|
|
return f"{proto}://{host}"
|
|
return request.url_root.rstrip("/")
|
|
|
|
|
|
def _resolve_namehash(namehash: str) -> str | None:
|
|
try:
|
|
db = get_db()
|
|
cur = db.execute("SELECT name FROM names WHERE namehash = ?", (namehash,))
|
|
row = cur.fetchone()
|
|
if row and row["name"]:
|
|
return row["name"]
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
req = requests.get(f"{HSD_API_BASE}/namehash/{namehash}", timeout=3)
|
|
if req.status_code == 200:
|
|
name = req.json().get("result")
|
|
if name:
|
|
try:
|
|
db = get_db()
|
|
db.execute(
|
|
"INSERT OR REPLACE INTO names (namehash, name) VALUES (?, ?)",
|
|
(namehash, name),
|
|
)
|
|
db.commit()
|
|
except Exception:
|
|
pass
|
|
return name
|
|
except Exception:
|
|
pass
|
|
|
|
return None
|
|
|
|
|
|
def _summarize_transaction(tx: dict) -> str:
|
|
outputs = tx.get("outputs", []) if isinstance(tx, dict) else []
|
|
inputs = tx.get("inputs", []) if isinstance(tx, dict) else []
|
|
|
|
if not outputs:
|
|
return "Transaction details available"
|
|
|
|
action_counts: dict[str, int] = {}
|
|
for output in outputs:
|
|
covenant = output.get("covenant") or {}
|
|
action = (covenant.get("action") or "NONE").upper()
|
|
action_counts[action] = action_counts.get(action, 0) + 1
|
|
|
|
finalize_count = action_counts.get("FINALIZE", 0)
|
|
if finalize_count > 1:
|
|
return f"Finalized {finalize_count:,} domains"
|
|
|
|
# Covenant-aware summary for domain operations (e.g., BID)
|
|
for output in outputs:
|
|
covenant = output.get("covenant") or {}
|
|
action = (covenant.get("action") or "").upper()
|
|
if action and action != "NONE":
|
|
value = _safe_hns_value(output.get("value"))
|
|
items = covenant.get("items") or []
|
|
name = None
|
|
if items and isinstance(items[0], str):
|
|
name = _resolve_namehash(items[0])
|
|
|
|
if action == "BID":
|
|
if name:
|
|
return f"Bid {value} on {name}"
|
|
return f"Bid {value} on a domain"
|
|
|
|
if name:
|
|
if action.endswith("E"):
|
|
return f"{action.title()}d {name} • Value {value}"
|
|
else:
|
|
return f"{action.title()}ed {name} • Value {value}"
|
|
return f"{action.title()} covenant • Value {value}"
|
|
|
|
# Detect coinbase transaction
|
|
if inputs:
|
|
prevout = inputs[0].get("prevout") or {}
|
|
if (
|
|
prevout.get("hash")
|
|
== "0000000000000000000000000000000000000000000000000000000000000000"
|
|
and prevout.get("index") == 4294967295
|
|
):
|
|
reward = _safe_hns_value(sum((o.get("value") or 0) for o in outputs))
|
|
return f"Coinbase reward {reward}"
|
|
|
|
total_output_value = sum((o.get("value") or 0) for o in outputs)
|
|
total_output_hns = _safe_hns_value(total_output_value)
|
|
recipient_addresses = {
|
|
o.get("address") for o in outputs if o.get("address") and o.get("value", 0) > 0
|
|
}
|
|
recipient_count = len(recipient_addresses)
|
|
|
|
if recipient_count <= 1:
|
|
return f"Sent {total_output_hns}"
|
|
|
|
return f"Sent a total of {total_output_hns} to {recipient_count} addresses"
|
|
|
|
|
|
def _build_og_context(
|
|
search_type: str | None = None, search_value: str | None = None
|
|
) -> dict:
|
|
default_title = "Fire Explorer"
|
|
default_description = "A hot new Handshake Blockchain Explorer"
|
|
|
|
if not search_type or not search_value:
|
|
return {
|
|
"title": default_title,
|
|
"description": default_description,
|
|
"image_query": {"title": default_title, "subtitle": default_description},
|
|
}
|
|
|
|
type_labels = {
|
|
"block": "Block",
|
|
"header": "Header",
|
|
"tx": "Transaction",
|
|
"address": "Address",
|
|
"name": "Name",
|
|
}
|
|
label = type_labels.get(search_type, "Search")
|
|
|
|
fallback_title = f"{label} {search_value} | Fire Explorer"
|
|
fallback_description = f"View Handshake {label.lower()} details for {search_value}"
|
|
|
|
og = {
|
|
"title": _truncate(fallback_title, 100),
|
|
"description": _truncate(fallback_description, 200),
|
|
"image_query": {
|
|
"type": label,
|
|
"value": search_value,
|
|
"title": fallback_title,
|
|
"subtitle": fallback_description,
|
|
},
|
|
}
|
|
|
|
if search_type == "block":
|
|
block, err = _fetch_explorer_json(f"block/{search_value}")
|
|
if not err and block:
|
|
tx_count = len(block.get("txs", []))
|
|
height = block.get("height", "?")
|
|
timestamp = block.get("time")
|
|
time_str = (
|
|
datetime.utcfromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M UTC")
|
|
if timestamp
|
|
else "Unknown time"
|
|
)
|
|
height_fmt = _format_int(height)
|
|
tx_count_fmt = _format_int(tx_count, "0")
|
|
subtitle = f"Height {height_fmt} • {tx_count_fmt} txs • {time_str}"
|
|
og["title"] = _truncate(f"Block {height_fmt} | Fire Explorer", 100)
|
|
og["description"] = _truncate(subtitle, 200)
|
|
og["image_query"]["subtitle"] = subtitle
|
|
|
|
elif search_type == "header":
|
|
header, err = _fetch_explorer_json(f"header/{search_value}")
|
|
if not err and header:
|
|
height = header.get("height", "?")
|
|
bits = header.get("bits", "?")
|
|
subtitle = f"Height {height} • Bits {bits}"
|
|
og["title"] = _truncate(f"Header {height} | Fire Explorer", 100)
|
|
og["description"] = _truncate(subtitle, 200)
|
|
og["image_query"]["subtitle"] = subtitle
|
|
|
|
elif search_type == "tx":
|
|
tx, err = _fetch_explorer_json(f"tx/{search_value}")
|
|
if not err and tx:
|
|
tx_summary = _summarize_transaction(tx)
|
|
fee = _safe_hns_value(tx.get("fee"))
|
|
subtitle = f"{tx_summary} • Fee {fee}"
|
|
og["title"] = _truncate("Transaction | Fire Explorer", 100)
|
|
og["description"] = _truncate(subtitle, 200)
|
|
og["image_query"]["subtitle"] = subtitle
|
|
|
|
elif search_type == "address":
|
|
txs, tx_err = _fetch_explorer_json(f"tx/address/{search_value}")
|
|
coins, coin_err = _fetch_explorer_json(f"coin/address/{search_value}")
|
|
|
|
tx_count = len(txs) if isinstance(txs, list) else None
|
|
balance_value = (
|
|
sum((coin.get("value") or 0) for coin in coins)
|
|
if isinstance(coins, list)
|
|
else None
|
|
)
|
|
|
|
if tx_count is not None or balance_value is not None:
|
|
parts = []
|
|
if balance_value is not None:
|
|
parts.append(f"Balance {_safe_hns_value(balance_value)}")
|
|
if tx_count is not None:
|
|
parts.append(f"{_format_int(tx_count, '0')} related transactions")
|
|
|
|
subtitle = " • ".join(parts)
|
|
og["title"] = _truncate("Address | Fire Explorer", 100)
|
|
og["description"] = _truncate(subtitle, 200)
|
|
og["image_query"]["subtitle"] = subtitle
|
|
|
|
elif search_type == "name":
|
|
name, err = _fetch_explorer_json(f"name/{search_value}")
|
|
if not err and name:
|
|
info = name.get("info", name)
|
|
state = info.get("state", "Unknown")
|
|
owner = info.get("owner", {}).get("hash", "Unknown")
|
|
value = info.get("value")
|
|
cost_text = _safe_hns_value(value) if value is not None else "Unknown"
|
|
subtitle = (
|
|
f"State: {state} • Cost: {cost_text} • Owner: {_truncate(owner, 18)}"
|
|
)
|
|
og["title"] = _truncate(f"Name {search_value} | Fire Explorer", 100)
|
|
og["description"] = _truncate(subtitle, 200)
|
|
og["image_query"]["subtitle"] = subtitle
|
|
|
|
og["image_query"]["title"] = og["title"]
|
|
return og
|
|
|
|
|
|
def _render_index(search_type: str | None = None, search_value: str | None = None):
|
|
current_datetime = datetime.now().strftime("%d %b %Y %I:%M %p")
|
|
og_context = _build_og_context(search_type, search_value)
|
|
image_query = og_context["image_query"]
|
|
public_base_url = _get_public_base_url()
|
|
og_image_url = public_base_url + "/og-image.png?" + urlencode(image_query)
|
|
og_url = public_base_url + request.full_path
|
|
if og_url.endswith("?"):
|
|
og_url = og_url[:-1]
|
|
|
|
twitter_domain = request.host
|
|
if PUBLIC_BASE_URL and "//" in PUBLIC_BASE_URL:
|
|
twitter_domain = PUBLIC_BASE_URL.split("//", 1)[1]
|
|
|
|
return render_template(
|
|
"index.html",
|
|
datetime=current_datetime,
|
|
meta_title=og_context["title"],
|
|
meta_description=og_context["description"],
|
|
og_url=og_url,
|
|
og_image=og_image_url,
|
|
twitter_domain=twitter_domain,
|
|
)
|
|
|
|
|
|
def _get_og_image_context() -> dict:
|
|
title = _truncate(request.args.get("title", "Fire Explorer"), 90)
|
|
subtitle = _truncate(
|
|
request.args.get("subtitle", "A hot new Handshake Blockchain Explorer"),
|
|
140,
|
|
)
|
|
search_type = _truncate(request.args.get("type", "Explorer"), 24)
|
|
search_value = request.args.get("value", "")
|
|
|
|
normalized_type = search_type.strip().lower()
|
|
value_max_length = 44
|
|
if normalized_type in {"transaction", "tx"}:
|
|
value_max_length = 30
|
|
|
|
display_value = _ellipsize_middle(search_value, value_max_length)
|
|
if search_value.isdigit() and int(search_value) > 100:
|
|
display_value = f"{int(search_value):,}"
|
|
|
|
value_font_size = "30"
|
|
if len(display_value) > 34:
|
|
value_font_size = "26"
|
|
|
|
is_default_card = not search_value.strip() and normalized_type in {
|
|
"explorer",
|
|
"search",
|
|
"",
|
|
}
|
|
|
|
return {
|
|
"title": title,
|
|
"subtitle": subtitle,
|
|
"search_type": search_type,
|
|
"search_value": search_value,
|
|
"display_value": display_value,
|
|
"value_font_size": value_font_size,
|
|
"is_default_card": is_default_card,
|
|
}
|
|
|
|
|
|
def _load_og_font(size: int, bold: bool = False, mono: bool = False):
|
|
try:
|
|
image_font_module = importlib.import_module("PIL.ImageFont")
|
|
if mono:
|
|
candidates = [
|
|
os.path.join(OG_FONT_DIR, "DejaVuSansMono.ttf"),
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
|
"/usr/share/fonts/TTF/DejaVuSansMono.ttf",
|
|
"/usr/share/fonts/dejavu/DejaVuSansMono.ttf",
|
|
"/usr/share/fonts/truetype/liberation2/LiberationMono-Regular.ttf",
|
|
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
|
|
"DejaVuSansMono.ttf",
|
|
]
|
|
elif bold:
|
|
candidates = [
|
|
os.path.join(OG_FONT_DIR, "DejaVuSans-Bold.ttf"),
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
|
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
|
|
"/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf",
|
|
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
|
"DejaVuSans-Bold.ttf",
|
|
]
|
|
else:
|
|
candidates = [
|
|
os.path.join(OG_FONT_DIR, "DejaVuSans.ttf"),
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
|
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
|
|
"/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
|
|
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
|
"DejaVuSans.ttf",
|
|
]
|
|
|
|
for candidate in candidates:
|
|
try:
|
|
return image_font_module.truetype(candidate, size=size)
|
|
except Exception:
|
|
continue
|
|
|
|
return image_font_module.load_default()
|
|
except Exception:
|
|
try:
|
|
image_font_module = importlib.import_module("PIL.ImageFont")
|
|
return image_font_module.load_default()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _fit_text(draw, text: str, font, max_width: int) -> str:
|
|
candidate = text
|
|
while candidate:
|
|
bbox = draw.textbbox((0, 0), candidate, font=font)
|
|
if bbox[2] - bbox[0] <= max_width:
|
|
return candidate
|
|
candidate = candidate[:-1]
|
|
|
|
if text:
|
|
return "…"
|
|
return text
|
|
|
|
|
|
def _wrap_text(draw, text: str, font, max_width: int, max_lines: int = 2) -> list[str]:
|
|
if not text:
|
|
return [""]
|
|
|
|
words = text.split()
|
|
if not words:
|
|
return [_fit_text(draw, text, font, max_width)]
|
|
|
|
lines: list[str] = []
|
|
current = words[0]
|
|
|
|
for word in words[1:]:
|
|
candidate = f"{current} {word}"
|
|
bbox = draw.textbbox((0, 0), candidate, font=font)
|
|
if bbox[2] - bbox[0] <= max_width:
|
|
current = candidate
|
|
continue
|
|
|
|
lines.append(_fit_text(draw, current, font, max_width))
|
|
current = word
|
|
|
|
if len(lines) >= max_lines - 1:
|
|
break
|
|
|
|
remaining_words = words[len(" ".join(lines + [current]).split()) :]
|
|
tail = current
|
|
if remaining_words:
|
|
tail = f"{current} {' '.join(remaining_words)}"
|
|
|
|
if len(lines) < max_lines:
|
|
lines.append(_fit_text(draw, tail, font, max_width))
|
|
|
|
return lines[:max_lines]
|
|
|
|
|
|
def _draw_text_shadow(
|
|
draw, position: tuple[int, int], text: str, font, fill, shadow=(0, 0, 0, 140)
|
|
):
|
|
if not text:
|
|
return
|
|
|
|
x, y = position
|
|
draw.text((x + 2, y + 2), text, fill=shadow, font=font)
|
|
draw.text((x, y), text, fill=fill, font=font)
|
|
|
|
|
|
# Assets routes
|
|
@app.route("/assets/<path:path>")
|
|
def send_assets(path):
|
|
if path.endswith(".json"):
|
|
return send_from_directory(
|
|
"templates/assets", path, mimetype="application/json"
|
|
)
|
|
|
|
if os.path.isfile("templates/assets/" + path):
|
|
return send_from_directory("templates/assets", path)
|
|
|
|
# Try looking in one of the directories
|
|
filename: str = path.split("/")[-1]
|
|
if (
|
|
filename.endswith(".png")
|
|
or filename.endswith(".jpg")
|
|
or filename.endswith(".jpeg")
|
|
or filename.endswith(".svg")
|
|
):
|
|
if os.path.isfile("templates/assets/img/" + filename):
|
|
return send_from_directory("templates/assets/img", filename)
|
|
if os.path.isfile("templates/assets/img/favicon/" + filename):
|
|
return send_from_directory("templates/assets/img/favicon", filename)
|
|
|
|
return render_template("404.html"), 404
|
|
|
|
|
|
# region Special routes
|
|
@app.route("/favicon.png")
|
|
def faviconPNG():
|
|
return send_from_directory("templates/assets/img", "favicon.png")
|
|
|
|
|
|
@app.route("/.well-known/<path:path>")
|
|
def wellknown(path):
|
|
# Try to proxy to https://nathan.woodburn.au/.well-known/
|
|
req = requests.get(f"https://nathan.woodburn.au/.well-known/{path}")
|
|
return make_response(
|
|
req.content, 200, {"Content-Type": req.headers["Content-Type"]}
|
|
)
|
|
|
|
|
|
# endregion
|
|
|
|
|
|
# region Main routes
|
|
@app.route("/")
|
|
def index():
|
|
return _render_index()
|
|
|
|
|
|
@app.route("/tx/<path:tx_hash>")
|
|
def tx_route(tx_hash):
|
|
return _render_index("tx", tx_hash)
|
|
|
|
|
|
@app.route("/block/<path:block_id>")
|
|
def block_route(block_id):
|
|
return _render_index("block", block_id)
|
|
|
|
|
|
@app.route("/header/<path:block_id>")
|
|
def header_route(block_id):
|
|
return _render_index("header", block_id)
|
|
|
|
|
|
@app.route("/address/<path:address>")
|
|
def address_route(address):
|
|
return _render_index("address", address)
|
|
|
|
|
|
@app.route("/name/<path:name>")
|
|
def name_route(name):
|
|
return _render_index("name", name)
|
|
|
|
|
|
@app.route("/coin/<path:coin_hash>/<int:index>")
|
|
def coin_route(coin_hash, index):
|
|
return _render_index("coin", f"{coin_hash}:{index}")
|
|
|
|
|
|
@app.route("/og-image")
|
|
def og_image():
|
|
context = _get_og_image_context()
|
|
title = context["title"]
|
|
subtitle = context["subtitle"]
|
|
search_type = context["search_type"]
|
|
display_value = context["display_value"]
|
|
value_font_size = context["value_font_size"]
|
|
is_default_card = context["is_default_card"]
|
|
|
|
type_text = escape(search_type)
|
|
value_text = escape(display_value)
|
|
title_text = escape(title)
|
|
subtitle_text = escape(subtitle)
|
|
|
|
if is_default_card:
|
|
svg = f"""<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1200\" height=\"630\" viewBox=\"0 0 1200 630\">
|
|
<defs>
|
|
<linearGradient id=\"bg\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\">
|
|
<stop offset=\"0%\" stop-color=\"#040b1a\" />
|
|
<stop offset=\"100%\" stop-color=\"#160a24\" />
|
|
</linearGradient>
|
|
<linearGradient id=\"accent\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\">
|
|
<stop offset=\"0%\" stop-color=\"#cd408f\" />
|
|
<stop offset=\"100%\" stop-color=\"#0c4fc2\" />
|
|
</linearGradient>
|
|
</defs>
|
|
<rect width=\"1200\" height=\"630\" fill=\"url(#bg)\" />
|
|
<circle cx=\"1100\" cy=\"80\" r=\"180\" fill=\"#08398c\" opacity=\"0.12\" />
|
|
<circle cx=\"120\" cy=\"610\" r=\"240\" fill=\"#8e2d63\" opacity=\"0.11\" />
|
|
<rect x=\"86\" y=\"74\" width=\"1028\" height=\"482\" rx=\"28\" fill=\"#0b1426\" stroke=\"#64748b\" />
|
|
<rect x=\"130\" y=\"126\" width=\"176\" height=\"42\" rx=\"21\" fill=\"url(#accent)\" />
|
|
<text x=\"218\" y=\"153\" text-anchor=\"middle\" fill=\"#ffffff\" font-size=\"19\" font-family=\"Inter, Arial, sans-serif\" font-weight=\"700\">Handshake</text>
|
|
<text x=\"130\" y=\"246\" fill=\"#ffffff\" font-size=\"82\" font-family=\"Inter, Arial, sans-serif\" font-weight=\"800\">{title_text}</text>
|
|
<text x=\"130\" y=\"302\" fill=\"#e5e7eb\" font-size=\"32\" font-family=\"Inter, Arial, sans-serif\">{subtitle_text}</text>
|
|
<line x1=\"130\" y1=\"338\" x2=\"1070\" y2=\"338\" stroke=\"#64748b\" />
|
|
<text x=\"130\" y=\"402\" fill=\"#f9fafb\" font-size=\"34\" font-family=\"Inter, Arial, sans-serif\" font-weight=\"600\">Blocks • Transactions • Addresses • Names</text>
|
|
<text x=\"130\" y=\"452\" fill=\"#cbd5e1\" font-size=\"28\" font-family=\"Inter, Arial, sans-serif\">Real-time status, searchable history, and rich on-chain data.</text>
|
|
<text x=\"86\" y=\"608\" fill=\"#cbd5e1\" font-size=\"24\" font-family=\"Inter, Arial, sans-serif\">explorer.hns.au</text>
|
|
</svg>"""
|
|
else:
|
|
svg = f"""<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1200\" height=\"630\" viewBox=\"0 0 1200 630\">
|
|
<defs>
|
|
<linearGradient id=\"bg\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\">
|
|
<stop offset=\"0%\" stop-color=\"#040b1a\" />
|
|
<stop offset=\"100%\" stop-color=\"#160a24\" />
|
|
</linearGradient>
|
|
<linearGradient id=\"accent\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\">
|
|
<stop offset=\"0%\" stop-color=\"#cd408f\" />
|
|
<stop offset=\"100%\" stop-color=\"#0c4fc2\" />
|
|
</linearGradient>
|
|
<clipPath id=\"valueClip\">
|
|
<rect x=\"96\" y=\"496\" width=\"1008\" height=\"52\" rx=\"4\" />
|
|
</clipPath>
|
|
</defs>
|
|
<rect width=\"1200\" height=\"630\" fill=\"url(#bg)\" />
|
|
<circle cx=\"1040\" cy=\"120\" r=\"180\" fill=\"#08398c\" opacity=\"0.11\" />
|
|
<circle cx=\"160\" cy=\"580\" r=\"220\" fill=\"#8e2d63\" opacity=\"0.10\" />
|
|
<rect x=\"72\" y=\"64\" width=\"240\" height=\"44\" rx=\"22\" fill=\"url(#accent)\" />
|
|
<text x=\"192\" y=\"92\" text-anchor=\"middle\" fill=\"#ffffff\" font-size=\"20\" font-family=\"Inter, Arial, sans-serif\" font-weight=\"700\">{type_text}</text>
|
|
<text x=\"72\" y=\"188\" fill=\"#ffffff\" font-size=\"62\" font-family=\"Inter, Arial, sans-serif\" font-weight=\"700\">{title_text}</text>
|
|
<text x=\"72\" y=\"252\" fill=\"#e5e7eb\" font-size=\"33\" font-family=\"Inter, Arial, sans-serif\">{subtitle_text}</text>
|
|
<rect x=\"72\" y=\"472\" width=\"1056\" height=\"96\" rx=\"16\" fill=\"#0b1426\" stroke=\"#64748b\" />
|
|
<text x=\"96\" y=\"530\" clip-path=\"url(#valueClip)\" fill=\"#f9fafb\" font-size=\"{value_font_size}\" font-family=\"'JetBrains Mono', 'Consolas', monospace\">{value_text}</text>
|
|
<text x=\"72\" y=\"610\" fill=\"#cbd5e1\" font-size=\"24\" font-family=\"Inter, Arial, sans-serif\">explorer.hns.au</text>
|
|
</svg>"""
|
|
|
|
response = make_response(svg)
|
|
response.headers["Content-Type"] = "image/svg+xml; charset=utf-8"
|
|
response.headers["Cache-Control"] = "public, max-age=300"
|
|
return response
|
|
|
|
|
|
@app.route("/og-image.png")
|
|
def og_image_png():
|
|
try:
|
|
image_module = importlib.import_module("PIL.Image")
|
|
image_draw_module = importlib.import_module("PIL.ImageDraw")
|
|
except Exception:
|
|
if os.path.isfile("templates/assets/img/og.png"):
|
|
return send_from_directory("templates/assets/img", "og.png")
|
|
return og_image()
|
|
|
|
context = _get_og_image_context()
|
|
title = context["title"]
|
|
subtitle = context["subtitle"]
|
|
search_type = context["search_type"]
|
|
display_value = context["display_value"]
|
|
is_default_card = context["is_default_card"]
|
|
|
|
width, height = 1200, 630
|
|
image = image_module.new("RGBA", (width, height), "#040b1a")
|
|
draw = image_draw_module.Draw(image, "RGBA")
|
|
|
|
draw.rectangle((0, 0, width, height), fill="#040b1a")
|
|
draw.ellipse((920, -60, 1280, 300), fill=(8, 57, 140, 30))
|
|
draw.ellipse((-120, 360, 360, 840), fill=(142, 45, 99, 32))
|
|
|
|
title_font = _load_og_font(56, bold=True)
|
|
subtitle_font = _load_og_font(33, bold=False)
|
|
label_font = _load_og_font(20, bold=True)
|
|
mono_font = _load_og_font(30, bold=False, mono=True)
|
|
footer_font = _load_og_font(24, bold=False)
|
|
|
|
if is_default_card:
|
|
draw.rounded_rectangle(
|
|
(86, 74, 1114, 556), radius=28, fill="#0b1426", outline="#64748b", width=2
|
|
)
|
|
draw.rounded_rectangle((130, 126, 306, 168), radius=21, fill="#cd408f")
|
|
_draw_text_shadow(draw, (165, 133), "Handshake", label_font, "#ffffff")
|
|
|
|
nice_title_font = _load_og_font(74, bold=True)
|
|
nice_subtitle_font = _load_og_font(30, bold=False)
|
|
features_font = _load_og_font(34, bold=True)
|
|
subfeatures_font = _load_og_font(28, bold=False)
|
|
|
|
title_lines = _wrap_text(draw, title, nice_title_font, 940, max_lines=2)
|
|
subtitle_lines = _wrap_text(
|
|
draw, subtitle, nice_subtitle_font, 940, max_lines=2
|
|
)
|
|
|
|
title_y = 192
|
|
for line in title_lines:
|
|
_draw_text_shadow(draw, (130, title_y), line, nice_title_font, "#ffffff")
|
|
title_y += 76
|
|
|
|
subtitle_y = title_y + 8
|
|
for line in subtitle_lines:
|
|
_draw_text_shadow(
|
|
draw, (130, subtitle_y), line, nice_subtitle_font, "#e5e7eb"
|
|
)
|
|
subtitle_y += 38
|
|
|
|
divider_y = subtitle_y + 18
|
|
draw.line((130, divider_y, 1070, divider_y), fill="#64748b", width=2)
|
|
|
|
features_y = divider_y + 30
|
|
_draw_text_shadow(
|
|
draw,
|
|
(130, features_y),
|
|
"Blocks • Transactions • Addresses • Names",
|
|
features_font,
|
|
"#f9fafb",
|
|
)
|
|
_draw_text_shadow(
|
|
draw,
|
|
(130, features_y + 52),
|
|
"Real-time status, searchable history, and rich on-chain data.",
|
|
subfeatures_font,
|
|
"#cbd5e1",
|
|
)
|
|
_draw_text_shadow(draw, (86, 580), "explorer.hns.au", footer_font, "#cbd5e1")
|
|
else:
|
|
draw.rounded_rectangle((72, 64, 312, 108), radius=22, fill="#cd408f")
|
|
|
|
safe_type = _fit_text(draw, search_type, label_font, 220)
|
|
type_width = draw.textbbox((0, 0), safe_type, font=label_font)[2]
|
|
_draw_text_shadow(
|
|
draw, (192 - type_width // 2, 72), safe_type, label_font, "#ffffff"
|
|
)
|
|
|
|
title_lines = _wrap_text(draw, title, title_font, 1040, max_lines=2)
|
|
subtitle_lines = _wrap_text(draw, subtitle, subtitle_font, 1040, max_lines=2)
|
|
|
|
title_y = 132
|
|
for line in title_lines:
|
|
_draw_text_shadow(draw, (72, title_y), line, title_font, "#ffffff")
|
|
title_y += 66
|
|
|
|
subtitle_y = title_y + 8
|
|
for line in subtitle_lines:
|
|
_draw_text_shadow(draw, (72, subtitle_y), line, subtitle_font, "#e5e7eb")
|
|
subtitle_y += 38
|
|
|
|
draw.rounded_rectangle(
|
|
(72, 472, 1128, 568), radius=16, fill="#0b1426", outline="#64748b", width=2
|
|
)
|
|
value_max_px = 1008
|
|
safe_value = _fit_text(draw, display_value, mono_font, value_max_px)
|
|
_draw_text_shadow(draw, (96, 500), safe_value, mono_font, "#f9fafb")
|
|
|
|
_draw_text_shadow(draw, (72, 580), "explorer.hns.au", footer_font, "#cbd5e1")
|
|
|
|
output = BytesIO()
|
|
image.convert("RGB").save(output, format="PNG", optimize=True)
|
|
output.seek(0)
|
|
response = send_file(output, mimetype="image/png")
|
|
response.headers["Cache-Control"] = "public, max-age=300"
|
|
return response
|
|
|
|
|
|
@app.route("/<path:path>")
|
|
def catch_all(path: str):
|
|
if os.path.isfile("templates/" + path):
|
|
return render_template(path)
|
|
|
|
# Try with .html
|
|
if os.path.isfile("templates/" + path + ".html"):
|
|
return render_template(path + ".html")
|
|
|
|
if os.path.isfile("templates/" + path.strip("/") + ".html"):
|
|
return render_template(path.strip("/") + ".html")
|
|
|
|
# Try to find a file matching
|
|
if path.count("/") < 1:
|
|
# Try to find a file matching
|
|
filename = find(path, "templates")
|
|
if filename:
|
|
return send_file(filename)
|
|
|
|
return render_template("404.html"), 404
|
|
|
|
|
|
# endregion
|
|
|
|
|
|
# region API routes
|
|
@app.route("/api/v1/namehash/<namehash>")
|
|
def namehash_api(namehash):
|
|
db = get_db()
|
|
cur = db.execute("SELECT * FROM names WHERE namehash = ?", (namehash,))
|
|
row = cur.fetchone()
|
|
if row is None:
|
|
# Get namehash from hsd.hns.au
|
|
req = requests.get(f"https://hsd.hns.au/api/v1/namehash/{namehash}")
|
|
if req.status_code == 200:
|
|
name = req.json().get("result")
|
|
if not name:
|
|
return jsonify({"name": "Error", "namehash": namehash})
|
|
# Insert into database
|
|
db.execute(
|
|
"INSERT OR REPLACE INTO names (namehash, name) VALUES (?, ?)",
|
|
(namehash, name),
|
|
)
|
|
db.commit()
|
|
return jsonify({"name": name, "namehash": namehash})
|
|
return jsonify(dict(row))
|
|
|
|
|
|
@app.route("/api/v1/status")
|
|
def api_status():
|
|
# Count number of names in database
|
|
db = get_db()
|
|
cur = db.execute("SELECT COUNT(*) as count FROM names")
|
|
row = cur.fetchone()
|
|
name_count = row["count"] if row else 0
|
|
|
|
return jsonify(
|
|
{
|
|
"status": "ok",
|
|
"service": "FireExplorer",
|
|
"version": "1.0.0",
|
|
"names_cached": name_count,
|
|
}
|
|
)
|
|
|
|
|
|
@app.route("/api/v1/hip02/<domain>")
|
|
def hip02(domain: str):
|
|
hip2_record = hip2(domain)
|
|
if hip2_record:
|
|
return jsonify(
|
|
{
|
|
"success": True,
|
|
"address": hip2_record,
|
|
"method": "hip02",
|
|
"name": domain,
|
|
}
|
|
)
|
|
|
|
wallet_record = wallet_txt(domain)
|
|
if wallet_record:
|
|
return jsonify(
|
|
{
|
|
"success": True,
|
|
"address": wallet_record,
|
|
"method": "wallet_txt",
|
|
"name": domain,
|
|
}
|
|
)
|
|
return jsonify(
|
|
{
|
|
"success": False,
|
|
"name": domain,
|
|
"error": "No HIP02 or WALLET record found for this domain",
|
|
}
|
|
)
|
|
|
|
|
|
@app.route("/api/v1/covenant", methods=["POST"])
|
|
def covenant_api():
|
|
data = request.get_json()
|
|
|
|
if isinstance(data, list):
|
|
covenants = data
|
|
results = []
|
|
|
|
# Collect all namehashes needed
|
|
namehashes = set()
|
|
for cov in covenants:
|
|
items = cov.get("items", [])
|
|
if items:
|
|
namehashes.add(items[0])
|
|
|
|
# Batch DB lookup
|
|
db = get_db()
|
|
known_names = {}
|
|
if namehashes:
|
|
placeholders = ",".join("?" for _ in namehashes)
|
|
cur = db.execute(
|
|
f"SELECT namehash, name FROM names WHERE namehash IN ({placeholders})",
|
|
list(namehashes),
|
|
)
|
|
for row in cur:
|
|
known_names[row["namehash"]] = row["name"]
|
|
|
|
# Identify missing namehashes
|
|
missing_hashes = [nh for nh in namehashes if nh not in known_names]
|
|
|
|
# Fetch missing from HSD
|
|
session = requests.Session()
|
|
for nh in missing_hashes:
|
|
try:
|
|
req = session.get(f"https://hsd.hns.au/api/v1/namehash/{nh}")
|
|
if req.status_code == 200:
|
|
name = req.json().get("result")
|
|
if name:
|
|
known_names[nh] = name
|
|
# Update DB
|
|
db.execute(
|
|
"INSERT OR REPLACE INTO names (namehash, name) VALUES (?, ?)",
|
|
(nh, name),
|
|
)
|
|
except Exception as e:
|
|
print(f"Error fetching namehash {nh}: {e}")
|
|
|
|
db.commit()
|
|
|
|
# Build results
|
|
for cov in covenants:
|
|
action = cov.get("action")
|
|
items = cov.get("items", [])
|
|
|
|
if not action:
|
|
results.append({"covenant": cov, "display": "Unknown"})
|
|
continue
|
|
|
|
display = f"{action}"
|
|
if items:
|
|
nh = items[0]
|
|
if nh in known_names:
|
|
name = known_names[nh]
|
|
display += f' <a href="/name/{name}">{name}</a>'
|
|
|
|
results.append({"covenant": cov, "display": display})
|
|
|
|
return jsonify(results)
|
|
|
|
# Get the covenant data
|
|
action = data.get("action")
|
|
items = data.get("items", [])
|
|
|
|
if not action:
|
|
return jsonify({"success": False, "data": data})
|
|
|
|
display = f"{action}"
|
|
if len(items) > 0:
|
|
name_hash = items[0]
|
|
# Lookup name from database
|
|
db = get_db()
|
|
cur = db.execute("SELECT * FROM names WHERE namehash = ?", (name_hash,))
|
|
row = cur.fetchone()
|
|
if row:
|
|
name = row["name"]
|
|
display += f' <a href="/name/{name}">{name}</a>'
|
|
else:
|
|
req = requests.get(f"https://hsd.hns.au/api/v1/namehash/{name_hash}")
|
|
if req.status_code == 200:
|
|
name = req.json().get("result")
|
|
if name:
|
|
display += f" {name}"
|
|
# Insert into database
|
|
db.execute(
|
|
"INSERT OR REPLACE INTO names (namehash, name) VALUES (?, ?)",
|
|
(name_hash, name),
|
|
)
|
|
db.commit()
|
|
|
|
return jsonify({"success": True, "data": data, "display": display})
|
|
|
|
|
|
# endregion
|
|
|
|
|
|
# region Error Catching
|
|
# 404 catch all
|
|
@app.errorhandler(404)
|
|
def not_found(e):
|
|
return render_template("404.html"), 404
|
|
|
|
|
|
# endregion
|
|
if __name__ == "__main__":
|
|
app.run(debug=True, port=5000, host="127.0.0.1")
|