diff --git a/.gitignore b/.gitignore index 6fc113c..eee66de 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ .vs/ .venv/ fireexplorer.db +.vscode diff --git a/pyproject.toml b/pyproject.toml index 2596bee..88a8500 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/requirements.txt b/requirements.txt index 1d3df95..d8fd358 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ diff --git a/server.py b/server.py index a4e5415..02cf534 100644 --- a/server.py +++ b/server.py @@ -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/") 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/") 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/") 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/") 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/") 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/") 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//") 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""" + + + + + + + + + + + + + + + + Handshake + {title_text} + {subtitle_text} + + Blocks • Transactions • Addresses • Names + Real-time status, searchable history, and rich on-chain data. + explorer.hns.au +""" + else: + svg = f""" + + + + + + + + + + + + + + + + + + {type_text} + {title_text} + {subtitle_text} + + {value_text} + explorer.hns.au +""" + + 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("/") diff --git a/templates/assets/css/index.css b/templates/assets/css/index.css index d0155c4..5a02ca3 100644 --- a/templates/assets/css/index.css +++ b/templates/assets/css/index.css @@ -836,3 +836,7 @@ a:hover { .tx-item { animation: staggerFade 0.4s ease forwards; } + +small { + color: var(--text-secondary); +} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index d30d385..413ddc4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4,30 +4,30 @@ - Fire Explorer + {{ meta_title or 'Fire Explorer' }} - + - + - - - + + + - - - - - + + + + + @@ -119,6 +119,7 @@

Fire Explorer - Handshake Blockchain Explorer | Powered by HNSAU & Fire HSD

Last updated: {{ datetime }}

+ Note: Date and times are in UTC
diff --git a/uv.lock b/uv.lock index 4a1201f..045b2bb 100644 --- a/uv.lock +++ b/uv.lock @@ -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"