feat: Add new OG images
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 2m17s
Build Docker / BuildImage (push) Successful in 2m39s

This commit is contained in:
2026-02-25 22:25:38 +11:00
parent 9a6748b156
commit f0d233b2f6
7 changed files with 325 additions and 65 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ __pycache__/
.vs/
.venv/
fireexplorer.db
.vscode

View File

@@ -1,7 +1,7 @@
[project]
name = "python-webserver-template"
name = "fireexplorer"
version = "0.1.0"
description = "Add your description here"
description = "A hot new Handshake Blockchain Explorer"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [

View File

@@ -147,7 +147,7 @@ cryptography==46.0.3 \
--hash=sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849 \
--hash=sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963 \
--hash=sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018
# via python-webserver-template
# via fireexplorer
distlib==0.4.0 \
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
@@ -163,11 +163,11 @@ filelock==3.20.0 \
flask==3.1.2 \
--hash=sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87 \
--hash=sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c
# via python-webserver-template
# via fireexplorer
gunicorn==23.0.0 \
--hash=sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d \
--hash=sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec
# via python-webserver-template
# via fireexplorer
h11==0.16.0 \
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
@@ -289,7 +289,7 @@ pysocks==1.7.1 \
python-dotenv==1.2.1 \
--hash=sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6 \
--hash=sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61
# via python-webserver-template
# via fireexplorer
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
@@ -328,7 +328,7 @@ requests==2.32.3 \
requests-doh==1.0.0 \
--hash=sha256:6ce8bc96245030a198ef20d2100b4dcb3b120a05a58df703f8be121a79f8f2fb \
--hash=sha256:eea6583b792b7d3dfde74fd28eedc2b95d6ea896368119eede31f0d6ff2c838c
# via python-webserver-template
# via fireexplorer
ruff==0.14.5 \
--hash=sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68 \
--hash=sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78 \

282
server.py
View File

@@ -12,6 +12,8 @@ import os
import requests
import sqlite3
from datetime import datetime
from urllib.parse import urlencode
from xml.sax.saxutils import escape
import dotenv
from tools import hip2, wallet_txt
@@ -20,6 +22,8 @@ dotenv.load_dotenv()
app = Flask(__name__)
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("/")
def get_db():
@@ -60,6 +64,175 @@ def find(name, path):
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
return request.url_root.rstrip("/")
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:
inputs = len(tx.get("inputs", []))
outputs = len(tx.get("outputs", []))
fee = _safe_hns_value(tx.get("fee"))
subtitle = f"{inputs} inputs • {outputs} outputs • Fee {fee}"
og["title"] = _truncate("Transaction | Fire Explorer", 100)
og["description"] = _truncate(subtitle, 200)
og["image_query"]["subtitle"] = subtitle
elif search_type == "address":
txs, err = _fetch_explorer_json(f"tx/address/{search_value}")
if not err and isinstance(txs, list):
subtitle = f"{len(txs)} related transactions found"
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")
subtitle = f"State: {state} • Owner: {_truncate(owner, 26)}"
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?" + 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,
)
# Assets routes
@app.route("/assets/<path:path>")
def send_assets(path):
@@ -108,44 +281,125 @@ def wellknown(path):
# region Main routes
@app.route("/")
def index():
current_datetime = datetime.now().strftime("%d %b %Y %I:%M %p")
return render_template("index.html", datetime=current_datetime)
return _render_index()
@app.route("/tx/<path:tx_hash>")
def tx_route(tx_hash):
current_datetime = datetime.now().strftime("%d %b %Y %I:%M %p")
return render_template("index.html", datetime=current_datetime)
return _render_index("tx", tx_hash)
@app.route("/block/<path:block_id>")
def block_route(block_id):
current_datetime = datetime.now().strftime("%d %b %Y %I:%M %p")
return render_template("index.html", datetime=current_datetime)
return _render_index("block", block_id)
@app.route("/header/<path:block_id>")
def header_route(block_id):
current_datetime = datetime.now().strftime("%d %b %Y %I:%M %p")
return render_template("index.html", datetime=current_datetime)
return _render_index("header", block_id)
@app.route("/address/<path:address>")
def address_route(address):
current_datetime = datetime.now().strftime("%d %b %Y %I:%M %p")
return render_template("index.html", datetime=current_datetime)
return _render_index("address", address)
@app.route("/name/<path:name>")
def name_route(name):
current_datetime = datetime.now().strftime("%d %b %Y %I:%M %p")
return render_template("index.html", datetime=current_datetime)
return _render_index("name", name)
@app.route("/coin/<path:coin_hash>/<int:index>")
def coin_route(coin_hash, index):
current_datetime = datetime.now().strftime("%d %b %Y %I:%M %p")
return render_template("index.html", datetime=current_datetime)
return _render_index("coin", f"{coin_hash}:{index}")
@app.route("/og-image")
def og_image():
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)
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",
"",
}
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=\"#0b1220\" />
<stop offset=\"100%\" stop-color=\"#1f2937\" />
</linearGradient>
<linearGradient id=\"accent\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\">
<stop offset=\"0%\" stop-color=\"#ef4444\" />
<stop offset=\"100%\" stop-color=\"#f97316\" />
</linearGradient>
</defs>
<rect width=\"1200\" height=\"630\" fill=\"url(#bg)\" />
<circle cx=\"1100\" cy=\"80\" r=\"180\" fill=\"#ef4444\" opacity=\"0.16\" />
<circle cx=\"120\" cy=\"610\" r=\"240\" fill=\"#f97316\" opacity=\"0.14\" />
<rect x=\"86\" y=\"74\" width=\"1028\" height=\"482\" rx=\"28\" fill=\"#0f172a\" stroke=\"#374151\" />
<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=\"#d1d5db\" font-size=\"32\" font-family=\"Inter, Arial, sans-serif\">{subtitle_text}</text>
<line x1=\"130\" y1=\"338\" x2=\"1070\" y2=\"338\" stroke=\"#374151\" />
<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=\"#9ca3af\" 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=\"#9ca3af\" 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=\"#111827\" />
<stop offset=\"100%\" stop-color=\"#1f2937\" />
</linearGradient>
<linearGradient id=\"accent\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\">
<stop offset=\"0%\" stop-color=\"#ef4444\" />
<stop offset=\"100%\" stop-color=\"#f97316\" />
</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=\"#ef4444\" opacity=\"0.14\" />
<circle cx=\"160\" cy=\"580\" r=\"220\" fill=\"#f97316\" opacity=\"0.12\" />
<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=\"#d1d5db\" font-size=\"33\" font-family=\"Inter, Arial, sans-serif\">{subtitle_text}</text>
<rect x=\"72\" y=\"472\" width=\"1056\" height=\"96\" rx=\"16\" fill=\"#0b1220\" stroke=\"#374151\" />
<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=\"#9ca3af\" 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("/<path:path>")

View File

@@ -836,3 +836,7 @@ a:hover {
.tx-item {
animation: staggerFade 0.4s ease forwards;
}
small {
color: var(--text-secondary);
}

View File

@@ -4,30 +4,30 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fire Explorer</title>
<title>{{ meta_title or 'Fire Explorer' }}</title>
<link rel="icon" href="/assets/img/favicon.png" type="image/png">
<link rel="stylesheet" href="/assets/css/index.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<meta name="description" content="A hot new Handshake Blockchain Explorer">
<meta name="description" content="{{ meta_description or 'A hot new Handshake Blockchain Explorer' }}">
<!-- Open Graph Meta Tags -->
<meta property="og:url" content="https://explorer.hns.au/">
<meta property="og:url" content="{{ og_url or 'https://explorer.hns.au/' }}">
<meta property="og:type" content="website">
<meta property="og:title" content="Fire Explorer">
<meta property="og:description" content="A hot new Handshake Blockchain Explorer">
<meta property="og:image" content="https://explorer.hns.au/assets/img/og.png">
<meta property="og:title" content="{{ meta_title or 'Fire Explorer' }}">
<meta property="og:description" content="{{ meta_description or 'A hot new Handshake Blockchain Explorer' }}">
<meta property="og:image" content="{{ og_image or 'https://explorer.hns.au/assets/img/og.png' }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<!-- Twitter Meta Tags -->
<meta name="twitter:card" content="summary_large_image">
<meta property="twitter:domain" content="explorer.hns.au">
<meta property="twitter:url" content="https://explorer.hns.au/">
<meta name="twitter:title" content="Fire Explorer">
<meta name="twitter:description" content="A hot new Handshake Blockchain Explorer">
<meta name="twitter:image" content="https://explorer.hns.au/assets/img/og.png">
<meta property="twitter:domain" content="{{ twitter_domain or 'explorer.hns.au' }}">
<meta property="twitter:url" content="{{ og_url or 'https://explorer.hns.au/' }}">
<meta name="twitter:title" content="{{ meta_title or 'Fire Explorer' }}">
<meta name="twitter:description" content="{{ meta_description or 'A hot new Handshake Blockchain Explorer' }}">
<meta name="twitter:image" content="{{ og_image or 'https://explorer.hns.au/assets/img/og.png' }}">
</head>
<body>
@@ -119,6 +119,7 @@
<div class="container">
<p>Fire Explorer - Handshake Blockchain Explorer | Powered by <a href="https://hns.au" target="_blank">HNSAU</a> & <a href="https://hsd.hns.au" target="_blank">Fire HSD</a></p>
<p class="timestamp">Last updated: {{ datetime }}</p>
<small>Note: Date and times are in UTC</small>
</div>
</footer>

66
uv.lock generated
View File

@@ -239,6 +239,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" },
]
[[package]]
name = "fireexplorer"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "cryptography" },
{ name = "flask" },
{ name = "gunicorn" },
{ name = "python-dotenv" },
{ name = "requests-doh" },
]
[package.dev-dependencies]
dev = [
{ name = "pre-commit" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "cryptography", specifier = ">=46.0.3" },
{ name = "flask", specifier = ">=3.1.2" },
{ name = "gunicorn", specifier = ">=23.0.0" },
{ name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "requests-doh", specifier = ">=1.0.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pre-commit", specifier = ">=4.4.0" },
{ name = "ruff", specifier = ">=0.14.5" },
]
[[package]]
name = "flask"
version = "3.1.2"
@@ -497,39 +530,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
name = "python-webserver-template"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "cryptography" },
{ name = "flask" },
{ name = "gunicorn" },
{ name = "python-dotenv" },
{ name = "requests-doh" },
]
[package.dev-dependencies]
dev = [
{ name = "pre-commit" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "cryptography", specifier = ">=46.0.3" },
{ name = "flask", specifier = ">=3.1.2" },
{ name = "gunicorn", specifier = ">=23.0.0" },
{ name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "requests-doh", specifier = ">=1.0.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pre-commit", specifier = ">=4.4.0" },
{ name = "ruff", specifier = ">=0.14.5" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"