15 Commits

Author SHA1 Message Date
867df2f8c9 feat: Add cost to name
All checks were successful
Build Docker / BuildImage (push) Successful in 42s
Check Code Quality / RuffCheck (push) Successful in 57s
2026-02-25 23:16:44 +11:00
9beb3f2918 feat: Add info for address
All checks were successful
Build Docker / BuildImage (push) Successful in 49s
Check Code Quality / RuffCheck (push) Successful in 54s
2026-02-25 23:12:48 +11:00
47e8c24219 feat: Cleanup some text
All checks were successful
Build Docker / BuildImage (push) Successful in 48s
Check Code Quality / RuffCheck (push) Successful in 51s
2026-02-25 23:07:53 +11:00
54ce15baa8 feat: Add custom fonts
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 1m0s
Build Docker / BuildImage (push) Successful in 1m14s
2026-02-25 23:02:49 +11:00
612ead6e63 feat: Move from svg to png
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 45s
Build Docker / BuildImage (push) Successful in 49s
2026-02-25 22:55:41 +11:00
25506da02c feat: Cleanup transaction image
All checks were successful
Build Docker / BuildImage (push) Successful in 38s
Check Code Quality / RuffCheck (push) Successful in 45s
2026-02-25 22:41:21 +11:00
2df0001352 feat: Add commas to block number OG image 2026-02-25 22:31:33 +11:00
f0d233b2f6 feat: Add new OG images
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 2m17s
Build Docker / BuildImage (push) Successful in 2m39s
2026-02-25 22:25:38 +11:00
9a6748b156 feat: Speed up covanent using bulk
Some checks failed
Build Docker / BuildImage (push) Has been cancelled
Check Code Quality / RuffCheck (push) Has been cancelled
2025-11-21 13:34:19 +11:00
90de6042b1 fix: Cleanup TXT record display 2025-11-21 13:28:50 +11:00
eea558361c feat: Add better covenant display
All checks were successful
Build Docker / BuildImage (push) Successful in 38s
Check Code Quality / RuffCheck (push) Successful in 49s
2025-11-21 13:24:21 +11:00
b6662f400a feat: Add support for DATABASE in dockerfile volume
All checks were successful
Build Docker / BuildImage (push) Successful in 39s
Check Code Quality / RuffCheck (push) Successful in 49s
2025-11-21 13:13:35 +11:00
1c51e97354 feat: Add database for namehash caching 2025-11-21 13:11:44 +11:00
206b323be6 feat: Update mobile layout
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 2m0s
Build Docker / BuildImage (push) Successful in 2m13s
2025-11-21 12:45:09 +11:00
400897319f feat: Update OG image
All checks were successful
Build Docker / BuildImage (push) Successful in 42s
Check Code Quality / RuffCheck (push) Successful in 50s
2025-11-21 00:53:07 +11:00
12 changed files with 1121 additions and 83 deletions

2
.gitignore vendored
View File

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

View File

@@ -22,6 +22,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# Add mount point for data volume # Add mount point for data volume
ENV BASE_DIR=/data ENV BASE_DIR=/data
ENV DATABASE_PATH=/data/fireexplorer.db
VOLUME /data VOLUME /data
EXPOSE 5000 EXPOSE 5000

View File

@@ -1,13 +1,14 @@
[project] [project]
name = "python-webserver-template" name = "fireexplorer"
version = "0.1.0" version = "0.1.0"
description = "Add your description here" description = "A hot new Handshake Blockchain Explorer"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"cryptography>=46.0.3", "cryptography>=46.0.3",
"flask>=3.1.2", "flask>=3.1.2",
"gunicorn>=23.0.0", "gunicorn>=23.0.0",
"pillow>=11.3.0",
"python-dotenv>=1.2.1", "python-dotenv>=1.2.1",
"requests-doh>=1.0.0", "requests-doh>=1.0.0",
] ]

View File

@@ -147,7 +147,7 @@ cryptography==46.0.3 \
--hash=sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849 \ --hash=sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849 \
--hash=sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963 \ --hash=sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963 \
--hash=sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018 --hash=sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018
# via python-webserver-template # via fireexplorer
distlib==0.4.0 \ distlib==0.4.0 \
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \ --hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d --hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
@@ -163,11 +163,11 @@ filelock==3.20.0 \
flask==3.1.2 \ flask==3.1.2 \
--hash=sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87 \ --hash=sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87 \
--hash=sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c --hash=sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c
# via python-webserver-template # via fireexplorer
gunicorn==23.0.0 \ gunicorn==23.0.0 \
--hash=sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d \ --hash=sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d \
--hash=sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec --hash=sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec
# via python-webserver-template # via fireexplorer
h11==0.16.0 \ h11==0.16.0 \
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
@@ -271,6 +271,59 @@ packaging==25.0 \
--hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \ --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \
--hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
# via gunicorn # via gunicorn
pillow==12.1.1 \
--hash=sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9 \
--hash=sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da \
--hash=sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f \
--hash=sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642 \
--hash=sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850 \
--hash=sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9 \
--hash=sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8 \
--hash=sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd \
--hash=sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c \
--hash=sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1 \
--hash=sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af \
--hash=sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60 \
--hash=sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986 \
--hash=sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13 \
--hash=sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717 \
--hash=sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b \
--hash=sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15 \
--hash=sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a \
--hash=sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb \
--hash=sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e \
--hash=sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a \
--hash=sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f \
--hash=sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce \
--hash=sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc \
--hash=sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586 \
--hash=sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f \
--hash=sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8 \
--hash=sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60 \
--hash=sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334 \
--hash=sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524 \
--hash=sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf \
--hash=sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2 \
--hash=sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7 \
--hash=sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4 \
--hash=sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b \
--hash=sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c \
--hash=sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e \
--hash=sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029 \
--hash=sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f \
--hash=sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f \
--hash=sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8 \
--hash=sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3 \
--hash=sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e \
--hash=sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36 \
--hash=sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f \
--hash=sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f \
--hash=sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6 \
--hash=sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20 \
--hash=sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202 \
--hash=sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0 \
--hash=sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289
# via fireexplorer
platformdirs==4.5.0 \ platformdirs==4.5.0 \
--hash=sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312 \ --hash=sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312 \
--hash=sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3 --hash=sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3
@@ -289,7 +342,7 @@ pysocks==1.7.1 \
python-dotenv==1.2.1 \ python-dotenv==1.2.1 \
--hash=sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6 \ --hash=sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6 \
--hash=sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61 --hash=sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61
# via python-webserver-template # via fireexplorer
pyyaml==6.0.3 \ pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
@@ -328,7 +381,7 @@ requests==2.32.3 \
requests-doh==1.0.0 \ requests-doh==1.0.0 \
--hash=sha256:6ce8bc96245030a198ef20d2100b4dcb3b120a05a58df703f8be121a79f8f2fb \ --hash=sha256:6ce8bc96245030a198ef20d2100b4dcb3b120a05a58df703f8be121a79f8f2fb \
--hash=sha256:eea6583b792b7d3dfde74fd28eedc2b95d6ea896368119eede31f0d6ff2c838c --hash=sha256:eea6583b792b7d3dfde74fd28eedc2b95d6ea896368119eede31f0d6ff2c838c
# via python-webserver-template # via fireexplorer
ruff==0.14.5 \ ruff==0.14.5 \
--hash=sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68 \ --hash=sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68 \
--hash=sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78 \ --hash=sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78 \

829
server.py
View File

@@ -5,16 +5,63 @@ from flask import (
send_from_directory, send_from_directory,
send_file, send_file,
jsonify, jsonify,
g,
request,
) )
import os import os
import requests import requests
import sqlite3
from datetime import datetime from datetime import datetime
from urllib.parse import urlencode
from io import BytesIO
from xml.sax.saxutils import escape
import importlib
import dotenv import dotenv
from tools import hip2, wallet_txt from tools import hip2, wallet_txt
from werkzeug.middleware.proxy_fix import ProxyFix
dotenv.load_dotenv() dotenv.load_dotenv()
app = Flask(__name__) 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): def find(name, path):
@@ -23,6 +70,438 @@ def find(name, path):
return os.path.join(root, name) 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:
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 # Assets routes
@app.route("/assets/<path:path>") @app.route("/assets/<path:path>")
def send_assets(path): def send_assets(path):
@@ -71,44 +550,228 @@ def wellknown(path):
# region Main routes # region Main routes
@app.route("/") @app.route("/")
def index(): def index():
current_datetime = datetime.now().strftime("%d %b %Y %I:%M %p") return _render_index()
return render_template("index.html", datetime=current_datetime)
@app.route("/tx/<path:tx_hash>") @app.route("/tx/<path:tx_hash>")
def tx_route(tx_hash): def tx_route(tx_hash):
current_datetime = datetime.now().strftime("%d %b %Y %I:%M %p") return _render_index("tx", tx_hash)
return render_template("index.html", datetime=current_datetime)
@app.route("/block/<path:block_id>") @app.route("/block/<path:block_id>")
def block_route(block_id): def block_route(block_id):
current_datetime = datetime.now().strftime("%d %b %Y %I:%M %p") return _render_index("block", block_id)
return render_template("index.html", datetime=current_datetime)
@app.route("/header/<path:block_id>") @app.route("/header/<path:block_id>")
def header_route(block_id): def header_route(block_id):
current_datetime = datetime.now().strftime("%d %b %Y %I:%M %p") return _render_index("header", block_id)
return render_template("index.html", datetime=current_datetime)
@app.route("/address/<path:address>") @app.route("/address/<path:address>")
def address_route(address): def address_route(address):
current_datetime = datetime.now().strftime("%d %b %Y %I:%M %p") return _render_index("address", address)
return render_template("index.html", datetime=current_datetime)
@app.route("/name/<path:name>") @app.route("/name/<path:name>")
def name_route(name): def name_route(name):
current_datetime = datetime.now().strftime("%d %b %Y %I:%M %p") return _render_index("name", name)
return render_template("index.html", datetime=current_datetime)
@app.route("/coin/<path:coin_hash>/<int:index>") @app.route("/coin/<path:coin_hash>/<int:index>")
def coin_route(coin_hash, index): def coin_route(coin_hash, index):
current_datetime = datetime.now().strftime("%d %b %Y %I:%M %p") return _render_index("coin", f"{coin_hash}:{index}")
return render_template("index.html", datetime=current_datetime)
@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>") @app.route("/<path:path>")
@@ -133,13 +796,46 @@ def catch_all(path: str):
return render_template("404.html"), 404 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") @app.route("/api/v1/status")
def api_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( return jsonify(
{ {
"status": "ok", "status": "ok",
"service": "FireExplorer", "service": "FireExplorer",
"version": "1.0.0", "version": "1.0.0",
"names_cached": name_count,
} }
) )
@@ -153,6 +849,7 @@ def hip02(domain: str):
"success": True, "success": True,
"address": hip2_record, "address": hip2_record,
"method": "hip02", "method": "hip02",
"name": domain,
} }
) )
@@ -163,16 +860,120 @@ def hip02(domain: str):
"success": True, "success": True,
"address": wallet_record, "address": wallet_record,
"method": "wallet_txt", "method": "wallet_txt",
"name": domain,
} }
) )
return jsonify( return jsonify(
{ {
"success": False, "success": False,
"name": domain,
"error": "No HIP02 or WALLET record found for this 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 # endregion

View File

@@ -109,6 +109,7 @@ section {
transition: transform 0.2s ease, box-shadow 0.2s ease; transition: transform 0.2s ease, box-shadow 0.2s ease;
animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards; animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
opacity: 0; /* Start hidden for animation */ opacity: 0; /* Start hidden for animation */
min-width: 0; /* Prevent grid overflow */
} }
.card:hover { .card:hover {
@@ -159,7 +160,7 @@ section {
/* Info Grid */ /* Info Grid */
.info-grid { .info-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem; gap: 1rem;
} }
@@ -168,6 +169,7 @@ section {
background: rgba(15, 23, 42, 0.4); background: rgba(15, 23, 42, 0.4);
border-radius: 12px; border-radius: 12px;
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
min-width: 0; /* Prevent grid overflow */
} }
.info-item.no-border { .info-item.no-border {
@@ -265,6 +267,7 @@ section {
transition: all 0.2s ease; transition: all 0.2s ease;
animation: staggerFade 0.4s ease forwards; animation: staggerFade 0.4s ease forwards;
cursor: pointer; cursor: pointer;
min-width: 0; /* Prevent flex overflow */
} }
.tx-item:hover { .tx-item:hover {
@@ -282,6 +285,7 @@ section {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
min-width: 0; /* Prevent flex overflow */
} }
.tx-view-btn { .tx-view-btn {
@@ -388,6 +392,7 @@ section {
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
margin: 0; margin: 0;
word-break: break-all;
} }
/* Transaction Details */ /* Transaction Details */
@@ -418,6 +423,7 @@ section {
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 8px; border-radius: 8px;
padding: 1rem; padding: 1rem;
min-width: 0; /* Prevent overflow */
} }
.tx-io-header { .tx-io-header {
@@ -611,6 +617,7 @@ section {
font-size: 0.9rem; font-size: 0.9rem;
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
word-break: break-all;
} }
.result-box .error { .result-box .error {
@@ -622,6 +629,16 @@ section {
border: 1px solid rgba(239, 68, 68, 0.2); border: 1px solid rgba(239, 68, 68, 0.2);
} }
.success-message {
color: var(--success-color);
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.2);
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
word-break: break-all;
}
/* Scrollbar */ /* Scrollbar */
.result-box::-webkit-scrollbar { .result-box::-webkit-scrollbar {
width: 8px; width: 8px;
@@ -700,6 +717,26 @@ a:hover {
padding: 0.5rem; padding: 0.5rem;
font-size: 0.85rem; font-size: 0.85rem;
} }
.info-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.card {
padding: 1rem;
}
.container {
padding: 0 1rem;
}
.tx-io-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
} }
/* Loading Animation */ /* Loading Animation */
@@ -799,3 +836,7 @@ a:hover {
.tx-item { .tx-item {
animation: staggerFade 0.4s ease forwards; animation: staggerFade 0.4s ease forwards;
} }
small {
color: var(--text-secondary);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -4,30 +4,31 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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="icon" href="/assets/img/favicon.png" type="image/png">
<link rel="stylesheet" href="/assets/css/index.css"> <link rel="stylesheet" href="/assets/css/index.css">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <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 --> <!-- 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:type" content="website">
<meta property="og:title" content="Fire Explorer"> <meta property="og:title" content="{{ meta_title or 'Fire Explorer' }}">
<meta property="og:description" content="A hot new Handshake Blockchain Explorer"> <meta property="og:description" content="{{ meta_description or 'A hot new Handshake Blockchain Explorer' }}">
<meta property="og:image" content="https://explorer.hns.au/assets/img/og.png"> <meta property="og:image" content="{{ og_image or 'https://explorer.hns.au/assets/img/og.png' }}">
<meta property="og:image:secure_url" content="{{ og_image or 'https://explorer.hns.au/assets/img/og.png' }}">
<meta property="og:image:width" content="1200"> <meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630"> <meta property="og:image:height" content="630">
<!-- Twitter Meta Tags --> <!-- Twitter Meta Tags -->
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta property="twitter:domain" content="explorer.hns.au"> <meta property="twitter:domain" content="{{ twitter_domain or 'explorer.hns.au' }}">
<meta property="twitter:url" content="https://explorer.hns.au/"> <meta property="twitter:url" content="{{ og_url or 'https://explorer.hns.au/' }}">
<meta name="twitter:title" content="Fire Explorer"> <meta name="twitter:title" content="{{ meta_title or 'Fire Explorer' }}">
<meta name="twitter:description" content="A hot new Handshake Blockchain Explorer"> <meta name="twitter:description" content="{{ meta_description or 'A hot new Handshake Blockchain Explorer' }}">
<meta name="twitter:image" content="https://explorer.hns.au/assets/img/og.png"> <meta name="twitter:image" content="{{ og_image or 'https://explorer.hns.au/assets/img/og.png' }}">
</head> </head>
<body> <body>
@@ -119,6 +120,7 @@
<div class="container"> <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>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> <p class="timestamp">Last updated: {{ datetime }}</p>
<small>Note: Date and times are in UTC</small>
</div> </div>
</footer> </footer>
@@ -469,7 +471,7 @@
<div style="display: flex; justify-content: space-between; margin-top: 0.5rem; font-size: 0.85rem; color: #b0b0b0;"> <div style="display: flex; justify-content: space-between; margin-top: 0.5rem; font-size: 0.85rem; color: #b0b0b0;">
<span>Height: ${coin.height.toLocaleString()}</span> <span>Height: ${coin.height.toLocaleString()}</span>
<span>Coinbase: ${coin.coinbase ? 'Yes' : 'No'}</span> <span>Coinbase: ${coin.coinbase ? 'Yes' : 'No'}</span>
<span>Covenant: ${coin.covenant.action}</span> <span>Covenant: <span data-covenant-action="${coin.covenant.action}" data-covenant="${encodeURIComponent(JSON.stringify(coin.covenant))}">${coin.covenant.action}</span></span>
</div> </div>
</div> </div>
`).join('')} `).join('')}
@@ -521,7 +523,7 @@
</div> </div>
<div class="info-item"> <div class="info-item">
<label>Covenant:</label> <label>Covenant:</label>
<span>${coin.covenant.action}</span> <span data-covenant-action="${coin.covenant.action}" data-covenant="${encodeURIComponent(JSON.stringify(coin.covenant))}">${coin.covenant.action}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -696,7 +698,7 @@
recordDetails += `<br><span style="font-size: 0.9rem;">KeyTag: ${record.keyTag}, Algorithm: ${record.algorithm}, DigestType: ${record.digestType}</span><br><span class="mono" style="font-size: 0.85rem; color: #b0b0b0; word-break: break-all;">${record.digest}</span>`; recordDetails += `<br><span style="font-size: 0.9rem;">KeyTag: ${record.keyTag}, Algorithm: ${record.algorithm}, DigestType: ${record.digestType}</span><br><span class="mono" style="font-size: 0.85rem; color: #b0b0b0; word-break: break-all;">${record.digest}</span>`;
break; break;
case 'TXT': case 'TXT':
recordDetails += `<br><span style="color: #b0b0b0;">${record.txt.map(t => `"${t}"`).join('<br>')}</span>`; recordDetails += `<br><span style="color: #b0b0b0;">${record.txt.join('<br>')}</span>`;
break; break;
case 'GLUE4': case 'GLUE4':
case 'GLUE6': case 'GLUE6':
@@ -779,7 +781,7 @@
recordDetails += `<br><span style="font-size: 0.9rem;">KeyTag: ${record.keyTag}, Algorithm: ${record.algorithm}</span>`; recordDetails += `<br><span style="font-size: 0.9rem;">KeyTag: ${record.keyTag}, Algorithm: ${record.algorithm}</span>`;
break; break;
case 'TXT': case 'TXT':
recordDetails += `<br><span style="color: #b0b0b0;">${record.txt.map(t => `"${t}"`).join('<br>')}</span>`; recordDetails += `<br><span style="color: #b0b0b0;">${record.txt.join('<br>')}</span>`;
break; break;
case 'GLUE4': case 'GLUE4':
case 'GLUE6': case 'GLUE6':
@@ -861,7 +863,7 @@
<span class="tx-io-value">${input.coin ? formatValue(input.coin.value) : 'Unknown'}</span> <span class="tx-io-value">${input.coin ? formatValue(input.coin.value) : 'Unknown'}</span>
</div> </div>
<div class="tx-io-address">${input.coin ? input.coin.address : (input.address || 'Unknown')}</div> <div class="tx-io-address">${input.coin ? input.coin.address : (input.address || 'Unknown')}</div>
${input.coin && input.coin.covenant.action !== 'NONE' ? `<div class="tx-covenant">Covenant: ${input.coin.covenant.action}</div>` : ''} ${input.coin && input.coin.covenant.action !== 'NONE' ? `<div class="tx-covenant" data-covenant-action="${input.coin.covenant.action}" data-covenant="${encodeURIComponent(JSON.stringify(input.coin.covenant))}">Covenant: ${input.coin.covenant.action}</div>` : ''}
</div> </div>
`; `;
} }
@@ -879,7 +881,7 @@
<span class="tx-io-value">${formatValue(output.value)}</span> <span class="tx-io-value">${formatValue(output.value)}</span>
</div> </div>
<div class="tx-io-address">${output.address}</div> <div class="tx-io-address">${output.address}</div>
${output.covenant.action !== 'NONE' ? `<div class="tx-covenant">Covenant: ${output.covenant.action}</div>` : ''} ${output.covenant.action !== 'NONE' ? `<div class="tx-covenant" data-covenant-action="${output.covenant.action}" data-covenant="${encodeURIComponent(JSON.stringify(output.covenant))}">Covenant: ${output.covenant.action}</div>` : ''}
</div> </div>
`).join('')} `).join('')}
</div> </div>
@@ -919,6 +921,7 @@
</div> </div>
`; `;
document.body.appendChild(modal); document.body.appendChild(modal);
if (!data.error) updateCovenants();
} }
// Display helper // Display helper
@@ -940,6 +943,74 @@
} }
} }
// Update covenant information from API
async function updateCovenants() {
const elements = document.querySelectorAll('[data-covenant-action]');
const covenantsToFetch = [];
const elementMap = new Map(); // Map JSON string -> Array of elements
for (const el of elements) {
const action = el.dataset.covenantAction;
if (action === 'NONE') continue;
// Skip if already updated
if (el.dataset.covenantUpdated) continue;
// Get full covenant data
let covenantData = null;
if (el.dataset.covenant) {
try {
covenantData = JSON.parse(decodeURIComponent(el.dataset.covenant));
} catch (e) {
console.error('Failed to parse covenant data:', e);
}
}
if (!covenantData) continue;
const key = JSON.stringify(covenantData);
if (!elementMap.has(key)) {
elementMap.set(key, []);
covenantsToFetch.push(covenantData);
}
elementMap.get(key).push(el);
}
if (covenantsToFetch.length === 0) return;
try {
const res = await fetch(`/api/v1/covenant`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(covenantsToFetch)
});
if (res.ok) {
const results = await res.json();
for (const result of results) {
const key = JSON.stringify(result.covenant);
const els = elementMap.get(key);
if (els) {
for (const el of els) {
if (el.classList.contains('tx-covenant')) {
el.innerHTML = `Covenant: ${result.display}`;
} else {
el.innerHTML = result.display;
}
el.dataset.covenantUpdated = "true";
}
}
}
}
} catch (e) {
console.error('Failed to fetch covenant info:', e);
}
}
// Show loading animation // Show loading animation
function showLoading(elementId) { function showLoading(elementId) {
const element = document.getElementById(elementId); const element = document.getElementById(elementId);
@@ -1031,6 +1102,7 @@
resultElement.innerHTML = `<div class="error">Error: ${data.error}</div>`; resultElement.innerHTML = `<div class="error">Error: ${data.error}</div>`;
} else { } else {
resultElement.innerHTML = formatTransactionData(data); resultElement.innerHTML = formatTransactionData(data);
updateCovenants();
} }
} }
@@ -1055,7 +1127,7 @@
const note = document.createElement('div'); const note = document.createElement('div');
note.className = 'success-message'; note.className = 'success-message';
note.style.marginBottom = '1rem'; note.style.marginBottom = '1rem';
note.innerHTML = `Resolved alias <strong>${hip02Result.name || address}</strong> to address`; note.innerHTML = `Resolved <strong>${hip02Result.name || address}</strong> to address <br><span class="mono">${address}</span>`;
resultElement.parentNode.insertBefore(note, resultElement); resultElement.parentNode.insertBefore(note, resultElement);
setTimeout(() => note.remove(), 5000); setTimeout(() => note.remove(), 5000);
} else { } else {
@@ -1106,6 +1178,7 @@
resultElement.innerHTML = `<div class="error">Error: ${data.error}</div>`; resultElement.innerHTML = `<div class="error">Error: ${data.error}</div>`;
} else { } else {
resultElement.innerHTML = formatAddressCoins(data); resultElement.innerHTML = formatAddressCoins(data);
updateCovenants();
} }
} }
@@ -1178,17 +1251,23 @@
} }
showLoading('name-result'); showLoading('name-result');
const data = await apiCall(`namehash/${nameHash}`);
// Check if result is valid and redirect to name page try {
const resultElement = document.getElementById('name-result'); const response = await fetch(`/api/v1/namehash/${nameHash}`);
if (data.error) { const data = await response.json();
resultElement.innerHTML = `<div class="error">Error: ${data.error.message ? data.error.message : "Failed to lookup hash"}</div>`;
} else if (data.result && typeof data.result === 'string') { const resultElement = document.getElementById('name-result');
// Valid name found, redirect to name page if (data.error) {
window.location.href = `/name/${data.result}`; resultElement.innerHTML = `<div class="error">Error: ${data.error}</div>`;
} else { } else if (data.name) {
resultElement.innerHTML = `<div class="error">No name found for this hash</div>`; // Valid name found, redirect to name page
window.location.href = `/name/${data.name}`;
} else {
resultElement.innerHTML = `<div class="error">No name found for this hash</div>`;
}
} catch (e) {
const resultElement = document.getElementById('name-result');
resultElement.innerHTML = `<div class="error">Error: ${e.message}</div>`;
} }
} }

126
uv.lock generated
View File

@@ -239,6 +239,41 @@ 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" }, { 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 = "pillow" },
{ 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 = "pillow", specifier = ">=11.3.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]] [[package]]
name = "flask" name = "flask"
version = "3.1.2" version = "3.1.2"
@@ -445,6 +480,64 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
] ]
[[package]]
name = "pillow"
version = "12.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
{ url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
{ url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
{ url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
{ url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
{ url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
]
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.5.0" version = "4.5.0"
@@ -497,39 +590,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" }, { 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]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" version = "6.0.3"