From 908e366006b06cdabcaba799cc0fe51395690008 Mon Sep 17 00:00:00 2001 From: yaya Date: Tue, 6 Oct 2026 06:42:25 +0100 Subject: [PATCH] feat(apps): add Gashboard with native signing and viewer access --- app-catalog/catalog.json | 15 + apps/gashboard/README.md | 86 + apps/gashboard/manifest.yml | 89 + core/archipelago/src/fips/app_ports.rs | 1 + docker/gashboard/.dockerignore | 18 + docker/gashboard/.env.example | 36 + docker/gashboard/.gitignore | 13 + docker/gashboard/Dockerfile | 46 + docker/gashboard/README.md | 68 + docker/gashboard/apps/api/package.json | 35 + .../gashboard/apps/api/src/access/routes.ts | 44 + docker/gashboard/apps/api/src/access/store.ts | 41 + docker/gashboard/apps/api/src/auth/jwt.ts | 39 + .../gashboard/apps/api/src/auth/middleware.ts | 22 + docker/gashboard/apps/api/src/auth/routes.ts | 54 + docker/gashboard/apps/api/src/chat/routes.ts | 63 + docker/gashboard/apps/api/src/chat/store.ts | 97 + docker/gashboard/apps/api/src/config.ts | 84 + docker/gashboard/apps/api/src/datum/digest.ts | 114 + docker/gashboard/apps/api/src/datum/parse.ts | 162 ++ docker/gashboard/apps/api/src/datum/poller.ts | 471 ++++ .../gashboard/apps/api/src/datum/profiles.ts | 68 + docker/gashboard/apps/api/src/datum/routes.ts | 31 + docker/gashboard/apps/api/src/datum/types.ts | 84 + docker/gashboard/apps/api/src/errors.ts | 31 + docker/gashboard/apps/api/src/index.ts | 17 + docker/gashboard/apps/api/src/logger.ts | 142 + .../gashboard/apps/api/src/nostr/allowlist.ts | 7 + docker/gashboard/apps/api/src/nostr/nip98.ts | 69 + docker/gashboard/apps/api/src/server.ts | 124 + .../gashboard/apps/api/tests/access.test.ts | 88 + docker/gashboard/apps/api/tests/parse.test.ts | 15 + docker/gashboard/apps/api/tsconfig.json | 11 + docker/gashboard/apps/web/index.html | 20 + docker/gashboard/apps/web/package.json | 26 + .../gashboard/apps/web/public/icons/icon.svg | 7 + .../apps/web/public/icons/maskable.svg | 7 + .../apps/web/public/manifest.webmanifest | 34 + .../apps/web/public/miners/avalon-mini-3.svg | 14 + .../apps/web/public/miners/avalon-nano-3.svg | 12 + .../apps/web/public/miners/bigpapa-bitaxe.svg | 22 + .../apps/web/public/miners/bitaxe.svg | 15 + .../apps/web/public/miners/boomer-heater.svg | 12 + .../apps/web/public/miners/nerdqaxe.svg | 15 + .../apps/web/public/stickers/anon-miner.svg | 9 + .../apps/web/public/stickers/block-denied.svg | 8 + .../apps/web/public/stickers/block-hopium.svg | 10 + .../apps/web/public/stickers/boomer-unit.svg | 7 + .../apps/web/public/stickers/chad-hash.svg | 8 + .../apps/web/public/stickers/chart-crime.svg | 8 + .../apps/web/public/stickers/fan-panic.svg | 10 + .../apps/web/public/stickers/fiat-npc.svg | 10 + .../apps/web/public/stickers/heat-death.svg | 9 + .../web/public/stickers/money-printer.svg | 8 + .../apps/web/public/stickers/odds-wizard.svg | 9 + .../apps/web/public/stickers/printer-loop.svg | 11 + docker/gashboard/apps/web/public/sw.js | 95 + docker/gashboard/apps/web/src/App.vue | 230 ++ docker/gashboard/apps/web/src/chatStickers.ts | 143 + .../web/src/components/BlockCelebration.vue | 78 + .../apps/web/src/components/ChatDrawer.vue | 768 +++++ .../apps/web/src/components/LotteryWidget.vue | 105 + .../apps/web/src/components/MinerCard.vue | 231 ++ .../apps/web/src/components/MinerRace.vue | 257 ++ .../apps/web/src/components/PoolHero.vue | 131 + .../web/src/components/ReleaseNotesDrawer.vue | 115 + .../apps/web/src/components/Shameboard.vue | 372 +++ .../apps/web/src/components/ShareTicker.vue | 50 + .../web/src/composables/useRotatingCopy.ts | 18 + docker/gashboard/apps/web/src/copy.ts | 114 + docker/gashboard/apps/web/src/format.ts | 26 + docker/gashboard/apps/web/src/main.ts | 12 + docker/gashboard/apps/web/src/releaseNotes.ts | 96 + docker/gashboard/apps/web/src/router.ts | 45 + docker/gashboard/apps/web/src/services/api.ts | 115 + .../gashboard/apps/web/src/services/chat.ts | 274 ++ .../gashboard/apps/web/src/services/nip98.ts | 21 + docker/gashboard/apps/web/src/services/pwa.ts | 18 + .../gashboard/apps/web/src/services/signer.ts | 404 +++ docker/gashboard/apps/web/src/stores/auth.ts | 147 + docker/gashboard/apps/web/src/stores/stats.ts | 101 + docker/gashboard/apps/web/src/strings.ts | 177 ++ docker/gashboard/apps/web/src/style.css | 179 ++ docker/gashboard/apps/web/src/types.ts | 149 + .../apps/web/src/views/AccessView.vue | 83 + .../apps/web/src/views/DashboardView.vue | 237 ++ .../apps/web/src/views/GraphsView.vue | 447 +++ .../apps/web/src/views/LoginView.vue | 172 ++ .../apps/web/src/views/NostrCallbackView.vue | 49 + docker/gashboard/apps/web/tsconfig.json | 14 + docker/gashboard/apps/web/vite.config.ts | 21 + docker/gashboard/docker-compose.yml | 45 + docker/gashboard/nostr-provider.js | 400 +++ docker/gashboard/package.json | 16 + docker/gashboard/pnpm-lock.yaml | 2493 +++++++++++++++++ docker/gashboard/pnpm-workspace.yaml | 2 + docker/gashboard/tsconfig.base.json | 16 + .../public/assets/img/app-icons/gashboard.svg | 13 + neode-ui/public/catalog.json | 15 + .../appSession/generatedAppSessionConfig.ts | 2 + 100 files changed, 11072 insertions(+) create mode 100644 apps/gashboard/README.md create mode 100644 apps/gashboard/manifest.yml create mode 100644 docker/gashboard/.dockerignore create mode 100644 docker/gashboard/.env.example create mode 100644 docker/gashboard/.gitignore create mode 100644 docker/gashboard/Dockerfile create mode 100644 docker/gashboard/README.md create mode 100644 docker/gashboard/apps/api/package.json create mode 100644 docker/gashboard/apps/api/src/access/routes.ts create mode 100644 docker/gashboard/apps/api/src/access/store.ts create mode 100644 docker/gashboard/apps/api/src/auth/jwt.ts create mode 100644 docker/gashboard/apps/api/src/auth/middleware.ts create mode 100644 docker/gashboard/apps/api/src/auth/routes.ts create mode 100644 docker/gashboard/apps/api/src/chat/routes.ts create mode 100644 docker/gashboard/apps/api/src/chat/store.ts create mode 100644 docker/gashboard/apps/api/src/config.ts create mode 100644 docker/gashboard/apps/api/src/datum/digest.ts create mode 100644 docker/gashboard/apps/api/src/datum/parse.ts create mode 100644 docker/gashboard/apps/api/src/datum/poller.ts create mode 100644 docker/gashboard/apps/api/src/datum/profiles.ts create mode 100644 docker/gashboard/apps/api/src/datum/routes.ts create mode 100644 docker/gashboard/apps/api/src/datum/types.ts create mode 100644 docker/gashboard/apps/api/src/errors.ts create mode 100644 docker/gashboard/apps/api/src/index.ts create mode 100644 docker/gashboard/apps/api/src/logger.ts create mode 100644 docker/gashboard/apps/api/src/nostr/allowlist.ts create mode 100644 docker/gashboard/apps/api/src/nostr/nip98.ts create mode 100644 docker/gashboard/apps/api/src/server.ts create mode 100644 docker/gashboard/apps/api/tests/access.test.ts create mode 100644 docker/gashboard/apps/api/tests/parse.test.ts create mode 100644 docker/gashboard/apps/api/tsconfig.json create mode 100644 docker/gashboard/apps/web/index.html create mode 100644 docker/gashboard/apps/web/package.json create mode 100644 docker/gashboard/apps/web/public/icons/icon.svg create mode 100644 docker/gashboard/apps/web/public/icons/maskable.svg create mode 100644 docker/gashboard/apps/web/public/manifest.webmanifest create mode 100644 docker/gashboard/apps/web/public/miners/avalon-mini-3.svg create mode 100644 docker/gashboard/apps/web/public/miners/avalon-nano-3.svg create mode 100644 docker/gashboard/apps/web/public/miners/bigpapa-bitaxe.svg create mode 100644 docker/gashboard/apps/web/public/miners/bitaxe.svg create mode 100644 docker/gashboard/apps/web/public/miners/boomer-heater.svg create mode 100644 docker/gashboard/apps/web/public/miners/nerdqaxe.svg create mode 100644 docker/gashboard/apps/web/public/stickers/anon-miner.svg create mode 100644 docker/gashboard/apps/web/public/stickers/block-denied.svg create mode 100644 docker/gashboard/apps/web/public/stickers/block-hopium.svg create mode 100644 docker/gashboard/apps/web/public/stickers/boomer-unit.svg create mode 100644 docker/gashboard/apps/web/public/stickers/chad-hash.svg create mode 100644 docker/gashboard/apps/web/public/stickers/chart-crime.svg create mode 100644 docker/gashboard/apps/web/public/stickers/fan-panic.svg create mode 100644 docker/gashboard/apps/web/public/stickers/fiat-npc.svg create mode 100644 docker/gashboard/apps/web/public/stickers/heat-death.svg create mode 100644 docker/gashboard/apps/web/public/stickers/money-printer.svg create mode 100644 docker/gashboard/apps/web/public/stickers/odds-wizard.svg create mode 100644 docker/gashboard/apps/web/public/stickers/printer-loop.svg create mode 100644 docker/gashboard/apps/web/public/sw.js create mode 100644 docker/gashboard/apps/web/src/App.vue create mode 100644 docker/gashboard/apps/web/src/chatStickers.ts create mode 100644 docker/gashboard/apps/web/src/components/BlockCelebration.vue create mode 100644 docker/gashboard/apps/web/src/components/ChatDrawer.vue create mode 100644 docker/gashboard/apps/web/src/components/LotteryWidget.vue create mode 100644 docker/gashboard/apps/web/src/components/MinerCard.vue create mode 100644 docker/gashboard/apps/web/src/components/MinerRace.vue create mode 100644 docker/gashboard/apps/web/src/components/PoolHero.vue create mode 100644 docker/gashboard/apps/web/src/components/ReleaseNotesDrawer.vue create mode 100644 docker/gashboard/apps/web/src/components/Shameboard.vue create mode 100644 docker/gashboard/apps/web/src/components/ShareTicker.vue create mode 100644 docker/gashboard/apps/web/src/composables/useRotatingCopy.ts create mode 100644 docker/gashboard/apps/web/src/copy.ts create mode 100644 docker/gashboard/apps/web/src/format.ts create mode 100644 docker/gashboard/apps/web/src/main.ts create mode 100644 docker/gashboard/apps/web/src/releaseNotes.ts create mode 100644 docker/gashboard/apps/web/src/router.ts create mode 100644 docker/gashboard/apps/web/src/services/api.ts create mode 100644 docker/gashboard/apps/web/src/services/chat.ts create mode 100644 docker/gashboard/apps/web/src/services/nip98.ts create mode 100644 docker/gashboard/apps/web/src/services/pwa.ts create mode 100644 docker/gashboard/apps/web/src/services/signer.ts create mode 100644 docker/gashboard/apps/web/src/stores/auth.ts create mode 100644 docker/gashboard/apps/web/src/stores/stats.ts create mode 100644 docker/gashboard/apps/web/src/strings.ts create mode 100644 docker/gashboard/apps/web/src/style.css create mode 100644 docker/gashboard/apps/web/src/types.ts create mode 100644 docker/gashboard/apps/web/src/views/AccessView.vue create mode 100644 docker/gashboard/apps/web/src/views/DashboardView.vue create mode 100644 docker/gashboard/apps/web/src/views/GraphsView.vue create mode 100644 docker/gashboard/apps/web/src/views/LoginView.vue create mode 100644 docker/gashboard/apps/web/src/views/NostrCallbackView.vue create mode 100644 docker/gashboard/apps/web/tsconfig.json create mode 100644 docker/gashboard/apps/web/vite.config.ts create mode 100644 docker/gashboard/docker-compose.yml create mode 100644 docker/gashboard/nostr-provider.js create mode 100644 docker/gashboard/package.json create mode 100644 docker/gashboard/pnpm-lock.yaml create mode 100644 docker/gashboard/pnpm-workspace.yaml create mode 100644 docker/gashboard/tsconfig.base.json create mode 100644 neode-ui/public/assets/img/app-icons/gashboard.svg diff --git a/app-catalog/catalog.json b/app-catalog/catalog.json index b37c4146..7541589f 100644 --- a/app-catalog/catalog.json +++ b/app-catalog/catalog.json @@ -673,6 +673,21 @@ "tier": "optional", "icon": "/assets/img/app-icons/angor-green.png", "repoUrl": "https://github.com/hoytech/strfry" + }, + { + "id": "gashboard", + "author": "Gashboard contributors", + "requires": [ + "datum" + ], + "title": "Gashboard", + "version": "0.2.0", + "description": "A playful mining dashboard for your DATUM fleet, with live hashrates, share history, lottery odds, chat and a Nostr viewer access list.", + "dockerImage": "localhost/archipelago-gashboard:0.2.0", + "category": "bitcoin", + "tier": "optional", + "icon": "/assets/img/app-icons/gashboard.svg", + "repoUrl": "https://gitworkshop.dev/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy" } ] } diff --git a/apps/gashboard/README.md b/apps/gashboard/README.md new file mode 100644 index 00000000..7af803ed --- /dev/null +++ b/apps/gashboard/README.md @@ -0,0 +1,86 @@ +# Gashboard on Archipelago + +Install DATUM and configure its payout address, then install Gashboard. The app +connects to `http://datum:7152` on `archy-net`, so recreating DATUM does not require +copying a new container IP. The shared DATUM admin password is injected as a +platform secret and never sent to the browser. This PR depends on the DATUM app +package being merged and its build context being shipped. + +## Sign in and invite miners + +Use **Sign in with Nostr** inside Archipelago to choose a node identity through +the native signer. A standalone visitor can use a browser extension or remote +Nostr signer. No private key is entered into Gashboard. + +All user identities offered by Archipelago's signer are dashboard owners through +`NODE_IDENTITY_PUBKEYS`; the appliance identity is excluded. Owners can open +**Access**, paste another miner's `npub`, and add them as a viewer. Share the +Gashboard URL on port 1337 over your intended node access route. Viewers can read +the entire fleet and join dashboard chat; they cannot edit membership or configure +DATUM. Access is independent of the miner's Stratum worker name. Signing with an +unlisted identity is rejected even if that person mines through DATUM. + +Owners can remove a viewer in Access. Every authenticated request rechecks the +list, so an already-issued JWT stops working immediately. Removing a viewer does +not erase information or chat keys that their browser previously received. +Membership lives in `/var/lib/archipelago/gashboard/access.json`; preserve this +directory and the platform JWT secret across reinstall. Node owners are managed +in Archipelago identities, not in Gashboard. Restart Gashboard after changing +node identities to refresh owner access. + +The app gate uses `auth: open` because invited miners have Gashboard membership, +not node administrator accounts. Gashboard still authenticates every data API. +The gate supplies HTTPS and iframe header handling. A node administrator can +enable the gate's extra login if only node users should reach the app. + +## Source and native signer + +`docker/gashboard` vendors the user-supplied Gashboard source at commit +`68b606b` from `/home/yaya/Projects/gashboard`, with Archipelago integration and +membership changes reviewed here. Its configured remote, +`https://git.tx1138.com/lfg2025/gashboard.git`, was unavailable over TLS during +preparation; upstream freshness has not been verified. Vendoring makes the build +independent of that server. The original application declares the MIT license. + +The image bakes the canonical `neode-ui/public/nostr-provider.js`; the install +hook refreshes its persisted copy from the node. Refresh the baked copy when +updating the package. The server serves the provider uncached with +`data-app-id="gashboard"` and `data-no-nip98`, preserving Gashboard's own NIP-98 +login. The service worker never caches the signer. Existing NIP-07 browser +providers take precedence over the node provider. The CSP permits the native +signer broker frame in standalone/companion launches. + +`/healthz` checks that the dashboard API is serving. DATUM connection failures are +reported in the dashboard snapshot rather than disguised as a healthy mining +fleet. Contribution history persists under `/data`; live miner connections and +current hash rate recover through repeated DNS-based polling. Miner display names +come from their worker names, and history uses the full Stratum username so +multiple miners of the same model are not combined under a preset nickname. +Use a distinct worker name for each miner. Old Umbrel history should not be +copied blindly: its ledger used preset nicknames as identities. + +Before release, validate HTTP/HTTPS iframe launch, companion launch, native +identity consent, invited-user login, revocation, DATUM address change/recovery, +and install/start/stop/reinstall/reboot on a dedicated Archipelago test node. + +## Local validation (2026-10-06) + +API access-control and worker-identity tests, TypeScript checks, production builds, +manifest validation and generated catalog drift checks pass. Both container images +build and run with read-only roots, cap-drop ALL and no-new-privileges on Docker. +Gashboard's digest-authenticated polling recovered after DATUM moved from +172.22.0.2 to 172.22.0.4, without restarting or reconfiguring Gashboard, and after +another DATUM restart with preserved settings. + +The access/login flow passed Chromium 153, Firefox 155 and WebKit 26.6, each at +1440x900 and 390x844: native-provider NIP-98 through a simulated host frame, +invalid-key feedback, invitation, reload persistence, removal, and layout fit. +API tests also verify owner-only edits, rejected outsider login, persisted +membership, owner protection, corruption refusal, and revoked JWT/SSE access. +Browser scenarios and screenshots remain outside the repository in the shared +browser-check workspace. These browser tests simulate the app gate and signer +host; they do not establish real-node consent, HTTPS or Android acceptance. + +The generated storefront entries are review candidates. No signed catalog, +registry image, deployment or release was published. Keep actual-node acceptance +separate from these local results. diff --git a/apps/gashboard/manifest.yml b/apps/gashboard/manifest.yml new file mode 100644 index 00000000..3e520e6e --- /dev/null +++ b/apps/gashboard/manifest.yml @@ -0,0 +1,89 @@ +app: + id: gashboard + name: Gashboard + version: 0.2.0 + description: A playful mining dashboard for your DATUM fleet, with live hashrates, share history, lottery odds, chat and a Nostr viewer access list. + upstream: + kind: internal + container: + build: + context: /opt/archipelago/docker/gashboard + dockerfile: Dockerfile + tag: localhost/archipelago-gashboard:0.2.0 + network: archy-net + data_uid: "1000:1000" + derived_env: + - key: NOSTR_OWNER_PUBKEYS + template: "{{NODE_IDENTITY_PUBKEYS}}" + generated_secrets: + - name: gashboard-jwt-secret + kind: hex32 + secret_env: + - key: JWT_SECRET + secret_file: gashboard-jwt-secret + - key: DATUM_ADMIN_PASSWORD + secret_file: datum-admin-password + install_prerequisites: [datum] + dependencies: + - app_id: datum + - storage: 1Gi + resources: + cpu_limit: 1 + memory_limit: 512Mi + disk_limit: 1Gi + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + network_policy: isolated + ports: + - host: 1337 + container: 1337 + protocol: tcp + bind: 127.0.0.1 + auth: open + auth_rationale: Gashboard verifies NIP-98 signatures and an owner-managed Nostr access list before issuing sessions. Every data route rechecks membership; invited miners must not need a node administrator login. + volumes: + - type: bind + source: /var/lib/archipelago/gashboard + target: /data + options: [rw] + - type: tmpfs + target: /tmp + tmpfs_options: rw,noexec,nosuid,size=16m + environment: + - NODE_ENV=production + - PORT=1337 + - DATUM_URL=http://datum:7152 + - DATUM_ADMIN_USER=admin + - CONTRIBUTION_LEDGER_PATH=/data/contribution-ledger.json + - ACCESS_LIST_PATH=/data/access.json + - ARCHIPELAGO_PROVIDER_PATH=/data/nostr-provider.js + - MEMPOOL_API_URL=https://mempool.space/api + hooks: + post_install: + - copy_from_host: + src: web-ui/nostr-provider.js + dest: /data/nostr-provider.js + - exec: ["test", "-s", "/data/nostr-provider.js"] + health_check: + type: http + endpoint: http://localhost:1337 + path: /healthz + interval: 30s + timeout: 5s + retries: 3 + interfaces: + main: + name: Mining dashboard + type: ui + port: 1337 + protocol: http + path: / + metadata: + icon: /assets/img/app-icons/gashboard.svg + category: bitcoin + tier: optional + repo: https://gitworkshop.dev/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy + launch: + open_in_new_tab: false diff --git a/core/archipelago/src/fips/app_ports.rs b/core/archipelago/src/fips/app_ports.rs index 21b122eb..758717af 100644 --- a/core/archipelago/src/fips/app_ports.rs +++ b/core/archipelago/src/fips/app_ports.rs @@ -7,6 +7,7 @@ #[rustfmt::skip] pub const APP_LAUNCH_PORTS: &[u16] = &[ + 1337, 2283, 2342, 3000, diff --git a/docker/gashboard/.dockerignore b/docker/gashboard/.dockerignore new file mode 100644 index 00000000..bc43c035 --- /dev/null +++ b/docker/gashboard/.dockerignore @@ -0,0 +1,18 @@ +.git +.gitignore +node_modules +**/node_modules +dist +**/dist +.vite +**/.vite +.env +.env.* +!.env.example +*.log +coverage +.pnpm-store +*.tsbuildinfo +**/*.tsbuildinfo +.DS_Store +README.md diff --git a/docker/gashboard/.env.example b/docker/gashboard/.env.example new file mode 100644 index 00000000..96c5be1b --- /dev/null +++ b/docker/gashboard/.env.example @@ -0,0 +1,36 @@ +# gashboard configuration +# Copy to .env (or set in Portainer stack env) and fill in values. + +# ---- Server ---- +PORT=1337 +NODE_ENV=production +LOG_LEVEL=info +# Origin allowed by CORS. Leave unset to disable CORS entirely (single-origin +# deploy where the API serves the SPA from the same host). +# CORS_ORIGIN=https://gashboard.example.com +# Override the static dir the API serves the built SPA from. Default resolves +# to ../web/dist relative to the running api bundle. +# STATIC_DIR= + +# ---- Datum gateway (the Umbrel app we're polling) ---- +# Datum's admin UI/API is published by Umbrel on the host at port 21000. +DATUM_URL=http://127.0.0.1:21000 +DATUM_ADMIN_USER=admin +DATUM_ADMIN_PASSWORD= +# How often to scrape /clients (ms). Datum updates per-worker hashrate every +# few seconds; 5s is a sane default. +DATUM_POLL_INTERVAL_MS=5000 +CONTRIBUTION_LEDGER_PATH=/data/contribution-ledger.json + +# Sovereign mempool API used for block height, network hashrate, and difficulty. +MEMPOOL_API_URL=https://tx1138.com/api + +# ---- Nostr auth ---- +# Comma-separated bech32 npubs allowed to log in. Anything else is rejected +# at NIP-98 verification, before any session is issued. +NOSTR_ALLOWED_NPUBS=npub19tfnjfvxzt45jrz78mr3cldrtlg8pj5kp6gshp37582xcj7a0ctq7c8d7j,npub10wzfa7jkqj6c65xyr93hhxrns37ml9tss82jvymv8fymwdtu6cts3h6pvr + +# ---- Sessions ---- +# 32+ random bytes, hex. Generate with: openssl rand -hex 32 +JWT_SECRET= +JWT_TTL_SECONDS=86400 diff --git a/docker/gashboard/.gitignore b/docker/gashboard/.gitignore new file mode 100644 index 00000000..30628de5 --- /dev/null +++ b/docker/gashboard/.gitignore @@ -0,0 +1,13 @@ +# This vendored app is source, not the repository-wide web build output. +!apps/web/ +node_modules/ +dist/ +build/ +.env +.env.local +*.log +.DS_Store +.vite/ +coverage/ +*.tsbuildinfo +.pnpm-store/ diff --git a/docker/gashboard/Dockerfile b/docker/gashboard/Dockerfile new file mode 100644 index 00000000..03aaf975 --- /dev/null +++ b/docker/gashboard/Dockerfile @@ -0,0 +1,46 @@ +# TODO(security): pin this base by SHA256 before shipping to production. +# Resolve with: +# docker pull node:22.12.0-alpine +# docker inspect --format='{{index .RepoDigests 0}}' node:22.12.0-alpine +# then replace `node:22.12.0-alpine` below with `node@sha256:`. +ARG NODE_IMAGE=node:22.12.0-alpine@sha256:51eff88af6dff26f59316b6e356188ffa2c422bd3c3b76f2556a2e7e89d080bd + +FROM ${NODE_IMAGE} AS deps +WORKDIR /app +# Avoid Corepack — Node 22 ships a Corepack that strict-validates pnpm +# signatures and breaks behind builders that can't reach the signing host. +RUN npm install -g pnpm@9.12.3 --no-fund --no-audit && npm cache clean --force +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.base.json ./ +COPY apps/api/package.json apps/api/ +COPY apps/web/package.json apps/web/ +RUN pnpm install --frozen-lockfile + +FROM deps AS build-api +WORKDIR /app +COPY apps/api apps/api +RUN pnpm --filter @gashboard/api build + +FROM deps AS build-web +WORKDIR /app +COPY apps/web apps/web +RUN pnpm --filter @gashboard/web build + +FROM ${NODE_IMAGE} AS runtime +WORKDIR /app +ENV NODE_ENV=production +RUN apk add --no-cache wget tini \ + && npm install -g pnpm@9.12.3 --no-fund --no-audit \ + && npm cache clean --force + +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ +COPY apps/api/package.json apps/api/ +RUN pnpm install --filter @gashboard/api --prod --frozen-lockfile + +COPY --from=build-api /app/apps/api/dist apps/api/dist +COPY --from=build-web /app/apps/web/dist apps/web/dist +COPY nostr-provider.js /app/nostr-provider.js + +USER node +EXPOSE 1337 +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["node", "apps/api/dist/index.js"] diff --git a/docker/gashboard/README.md b/docker/gashboard/README.md new file mode 100644 index 00000000..8325d488 --- /dev/null +++ b/docker/gashboard/README.md @@ -0,0 +1,68 @@ +# gashboard + +> a custom dashboard for the datum gateway running on umbrel. +> for the four little boards that probably won't find a block, but are trying their best. + +Polls a local Datum gateway (OCEAN's open-source mining gateway), shows live per-miner hashrate, share counts, lottery odds, and a tongue-in-cheek read on how unlikely it all is. Designed for a small fleet of solo-style hobby miners (Bitaxe, NerdQAxe, Avalon Nano 3, Avalon Mini 3) hashing into one Datum on Umbrel. + +## What it shows + +- **Pool hero** — combined hashrate, current block height being templated, mempool fee snapshot, datum connection status. +- **Per-miner cards** — nickname, ASIC type, location, live hashrate, accepted/rejected shares, last-share age, status light, firmware string from `useragent`. +- **Lottery widget** — P(block in 24h) at current network difficulty, with rotating self-deprecating commentary. +- **Live share ticker** — sparkline + scrolling feed of recent accepted shares. +- **Sats-today counter** — earnings projection from current hashrate × network reward. +- **Map panel** — pins for each location (split by remote IP from Datum's `/clients`), pulse-per-share. +- **Block celebration** — confetti + sound the day it finally happens. + +## Auth + +Nostr-only login (NIP-98 over HTTP). Two signers supported: +- **Remote signer** (NIP-46) — covers Primal app, Amber, nsecbunker. +- **Browser extension** (NIP-07) — Alby, nos2x, etc. + +Allowlist of npubs is set via `NOSTR_ALLOWED_NPUBS`. Anything else is rejected before a session token is issued. + +## Stack + +- **Frontend** — Vue 3 + Vite + Pinia + TypeScript +- **Backend** — Express + TypeScript +- **Auth** — `nostr-tools` (NIP-98 verify), `applesauce-*` (signer abstraction, lifted from indeehub) +- **Container** — multi-stage `node:22.12.0-alpine` (pinned by SHA256 in Dockerfile) + +## Deploy on Portainer (the way it'll actually run) + +1. **Enable Datum admin API** on your Umbrel — edit + `~/umbrel/app-data/datum-gateway/data/datum_gateway/datum_gateway_config.json` + and add `"admin_password": ""` inside the `"api"` block. + Restart the Datum app. + +2. **Push this repo** to your gitea/whatever: + ```bash + git push -u origin main + ``` + +3. **In Portainer → Stacks → Add stack → Repository**: + - Repository URL: `https://git.tx1138.com/lfg2025/gashboard` + - Compose path: `docker-compose.yml` + - Add env vars (see `.env.example`) + - Remove any old `DATUM_URL=http://10.21...`, + `DATUM_URL=http://datum...`, or `DATUM_URL=http://datum_datum_1...` value + from the stack env vars. + - By default the stack uses host networking and polls Datum through Umbrel's + host-published admin port: `DATUM_URL=http://127.0.0.1:21000`. + - Reward contribution accounting is persisted in the Docker volume + `gashboard_data` at `/data/contribution-ledger.json`. + +4. Open the dashboard, log in with one of the allowed npubs, watch your boards lose at hashing in style. + +## Local dev + +```bash +pnpm install +pnpm dev # runs api + web concurrently +``` + +## License + +MIT. diff --git a/docker/gashboard/apps/api/package.json b/docker/gashboard/apps/api/package.json new file mode 100644 index 00000000..05cbe54d --- /dev/null +++ b/docker/gashboard/apps/api/package.json @@ -0,0 +1,35 @@ +{ + "name": "@gashboard/api", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "tsx --test tests/*.test.ts" + }, + "dependencies": { + "cors": "2.8.5", + "express": "4.21.1", + "express-rate-limit": "7.4.1", + "helmet": "8.0.0", + "jsonwebtoken": "9.0.2", + "node-html-parser": "6.1.13", + "nostr-tools": "2.10.4", + "pino": "9.5.0", + "pino-http": "10.3.0", + "zod": "3.23.8" + }, + "devDependencies": { + "@types/cors": "2.8.17", + "@types/express": "5.0.0", + "@types/express-serve-static-core": "5.0.2", + "@types/jsonwebtoken": "9.0.7", + "@types/node": "22.9.0", + "tsx": "4.19.2", + "typescript": "5.6.3" + } +} diff --git a/docker/gashboard/apps/api/src/access/routes.ts b/docker/gashboard/apps/api/src/access/routes.ts new file mode 100644 index 00000000..47e54be6 --- /dev/null +++ b/docker/gashboard/apps/api/src/access/routes.ts @@ -0,0 +1,44 @@ +import { Router } from "express"; +import { nip19 } from "nostr-tools"; +import { requireAuth } from "../auth/middleware.js"; +import { accessList } from "../nostr/allowlist.js"; +import { badRequest, forbidden } from "../errors.js"; + +export const accessRouter = Router(); +accessRouter.use(requireAuth); +accessRouter.get("/", (req, res) => { + const isOwner = accessList.isOwner(req.session!.pubkey); + res.setHeader("Cache-Control", "no-store"); + res.json({ isOwner, members: isOwner ? accessList.members() : [] }); +}); +accessRouter.use((req, _res, next) => { + if (!accessList.isOwner(req.session!.pubkey)) return next(forbidden("owner_required")); + next(); +}); + +function publicKey(value: unknown): string { + if (typeof value !== "string" || value.length > 100) throw badRequest("invalid_npub", "Enter a valid npub public key."); + try { + const decoded = nip19.decode(value.trim()); + if (decoded.type === "npub") return decoded.data; + } catch { /* Map decoding failures to a client error. */ } + throw badRequest("invalid_npub", "Enter a valid npub public key."); +} + +function update(npub: unknown, enabled: boolean): void { + const pubkey = publicKey(npub); + if (accessList.isOwner(pubkey)) throw badRequest("node_owner", "Manage node owners in Archipelago identities."); + if (enabled && !accessList.isAllowed(pubkey) && accessList.members().filter(m => m.role === "viewer").length >= 500) { + throw badRequest("list_full", "The access list is limited to 500 viewers."); + } + accessList.setViewer(pubkey, enabled); +} + +accessRouter.post("/", (req, res) => { + update(req.body?.npub, true); + res.json({ isOwner: true, members: accessList.members() }); +}); +accessRouter.delete("/:npub", (req, res) => { + update(req.params.npub, false); + res.json({ isOwner: true, members: accessList.members() }); +}); diff --git a/docker/gashboard/apps/api/src/access/store.ts b/docker/gashboard/apps/api/src/access/store.ts new file mode 100644 index 00000000..e31b2e65 --- /dev/null +++ b/docker/gashboard/apps/api/src/access/store.ts @@ -0,0 +1,41 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { nip19 } from "nostr-tools"; + +export class AccessList { + private readonly owners: Set; + private viewers: Set; + + constructor(private readonly file: string, owners: string[], initialViewers: string[]) { + this.owners = new Set(owners); + try { + const saved: unknown = JSON.parse(readFileSync(file, "utf8")); + if (!Array.isArray(saved) || saved.length > 500 || saved.some(k => typeof k !== "string" || !/^[0-9a-f]{64}$/.test(k))) { + throw new Error("Invalid saved Gashboard access list"); + } + this.viewers = new Set(saved as string[]); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + this.viewers = new Set(initialViewers); + } + } + + isOwner(pubkey: string): boolean { return this.owners.has(pubkey); } + isAllowed(pubkey: string): boolean { return this.isOwner(pubkey) || this.viewers.has(pubkey); } + keys(): string[] { return [...new Set([...this.owners, ...this.viewers])].sort(); } + members(): Array<{ npub: string; role: "owner" | "viewer" }> { + return this.keys().map(pubkey => ({ npub: nip19.npubEncode(pubkey), role: this.isOwner(pubkey) ? "owner" : "viewer" })); + } + + setViewer(pubkey: string, enabled: boolean): void { + if (this.isOwner(pubkey)) throw new Error("Node owners are managed in Archipelago identities"); + const next = new Set(this.viewers); + if (enabled) next.add(pubkey); else next.delete(pubkey); + if (next.size > 500) throw new Error("The access list is limited to 500 viewers"); + mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 }); + // Small synchronous writes serialize mutations; replace before updating memory. + writeFileSync(`${this.file}.tmp`, JSON.stringify([...next].sort()) + "\n", { mode: 0o600 }); + renameSync(`${this.file}.tmp`, this.file); + this.viewers = next; + } +} diff --git a/docker/gashboard/apps/api/src/auth/jwt.ts b/docker/gashboard/apps/api/src/auth/jwt.ts new file mode 100644 index 00000000..62db9c13 --- /dev/null +++ b/docker/gashboard/apps/api/src/auth/jwt.ts @@ -0,0 +1,39 @@ +import jwt from "jsonwebtoken"; +import { nip19 } from "nostr-tools"; +import { config } from "../config.js"; + +export type SessionPayload = { + pubkey: string; + npub: string; + iat: number; + exp: number; +}; + +export type IssuedSession = { + token: string; + npub: string; + expiresAt: number; +}; + +export function issueSession(hexPubkey: string): IssuedSession { + const issuedAt = Math.floor(Date.now() / 1000); + const expiresAt = issuedAt + config.jwt.ttlSeconds; + const npub = nip19.npubEncode(hexPubkey); + const token = jwt.sign( + { pubkey: hexPubkey, npub, iat: issuedAt, exp: expiresAt }, + config.jwt.secret, + { algorithm: "HS256" }, + ); + return { token, npub, expiresAt }; +} + +export function verifySession(token: string): SessionPayload | null { + try { + const decoded = jwt.verify(token, config.jwt.secret, { algorithms: ["HS256"] }); + if (typeof decoded === "string") return null; + if (typeof decoded.pubkey !== "string" || typeof decoded.npub !== "string") return null; + return decoded as SessionPayload; + } catch { + return null; + } +} diff --git a/docker/gashboard/apps/api/src/auth/middleware.ts b/docker/gashboard/apps/api/src/auth/middleware.ts new file mode 100644 index 00000000..26d3f9cb --- /dev/null +++ b/docker/gashboard/apps/api/src/auth/middleware.ts @@ -0,0 +1,22 @@ +import type { NextFunction, Request, Response } from "express"; +import { unauthorized } from "../errors.js"; +import { isPubkeyAllowed } from "../nostr/allowlist.js"; +import { verifySession } from "./jwt.js"; + +declare module "express-serve-static-core" { + interface Request { + session?: { pubkey: string; npub: string }; + } +} + +export function requireAuth(req: Request, _res: Response, next: NextFunction): void { + const header = req.header("authorization"); + const match = header?.match(/^Bearer\s+(.+)$/i); + if (!match) return next(unauthorized("no_session")); + + const session = verifySession(match[1]!.trim()); + if (!session || !isPubkeyAllowed(session.pubkey)) return next(unauthorized("bad_session")); + + req.session = { pubkey: session.pubkey, npub: session.npub }; + next(); +} diff --git a/docker/gashboard/apps/api/src/auth/routes.ts b/docker/gashboard/apps/api/src/auth/routes.ts new file mode 100644 index 00000000..48a6358b --- /dev/null +++ b/docker/gashboard/apps/api/src/auth/routes.ts @@ -0,0 +1,54 @@ +import { Router } from "express"; +import rateLimit from "express-rate-limit"; +import { verifyNip98 } from "../nostr/nip98.js"; +import { isPubkeyAllowed, allowedPubkeys, isOwner } from "../nostr/allowlist.js"; +import { issueSession } from "./jwt.js"; +import { requireAuth } from "./middleware.js"; +import { unauthorized, forbidden } from "../errors.js"; +import { logger } from "../logger.js"; + +export const authRouter = Router(); + +const loginLimiter = rateLimit({ + windowMs: 60_000, + limit: 10, + standardHeaders: "draft-7", + legacyHeaders: false, + message: { error: { code: "rate_limited", message: "Too many login attempts." } }, +}); + +function fullUrl(req: import("express").Request): string { + const proto = (req.headers["x-forwarded-proto"] as string | undefined)?.split(",")[0] ?? req.protocol; + const host = (req.headers["x-forwarded-host"] as string | undefined) ?? req.headers.host; + return `${proto}://${host}${req.originalUrl}`; +} + +authRouter.post("/login", loginLimiter, (req, res, next) => { + const result = verifyNip98({ + authHeader: req.headers.authorization, + method: req.method, + fullUrl: fullUrl(req), + }); + if (!result.ok) { + logger.warn({ reason: result.reason }, "nip98_rejected"); + return next(unauthorized("nip98_failed")); + } + if (!isPubkeyAllowed(result.pubkey)) { + logger.warn({ pub: `${result.pubkey.slice(0, 8)}…` }, "pubkey_not_allowlisted"); + return next(forbidden("not_allowlisted")); + } + const session = issueSession(result.pubkey); + res.json({ + token: session.token, + npub: session.npub, + expiresAt: session.expiresAt, + }); +}); + +authRouter.get("/me", requireAuth, (req, res) => { + res.json({ npub: req.session!.npub, isOwner: isOwner(req.session!.pubkey) }); +}); + +authRouter.get("/chat-config", requireAuth, (_req, res) => { + res.json({ pubkeys: allowedPubkeys() }); +}); diff --git a/docker/gashboard/apps/api/src/chat/routes.ts b/docker/gashboard/apps/api/src/chat/routes.ts new file mode 100644 index 00000000..160d3912 --- /dev/null +++ b/docker/gashboard/apps/api/src/chat/routes.ts @@ -0,0 +1,63 @@ +import { Router } from "express"; +import { z } from "zod"; +import { requireAuth } from "../auth/middleware.js"; +import { addMessage, addReaction, listChat, upsertKeyWrap } from "./store.js"; + +export const chatRouter = Router(); + +const MessageBody = z.object({ + content: z.string().trim().min(1).max(12000), + replyToId: z.string().max(128).optional(), + replyToPubkey: z.string().max(128).optional(), +}); + +const ReactionBody = z.object({ + messageId: z.string().min(1).max(128), + content: z.string().trim().min(1).max(12000), +}); + +const KeyWrapBody = z.object({ + wraps: z.array(z.object({ + recipientPubkey: z.string().regex(/^[0-9a-f]{64}$/), + ciphertext: z.string().trim().min(1).max(12000), + })).min(1).max(10), +}); + +chatRouter.use(requireAuth); + +chatRouter.get("/", (_req, res) => { + res.json(listChat()); +}); + +chatRouter.post("/messages", (req, res) => { + const body = MessageBody.parse(req.body); + const message = addMessage({ + pubkey: req.session!.pubkey, + content: body.content, + ...(body.replyToId ? { replyToId: body.replyToId } : {}), + ...(body.replyToPubkey ? { replyToPubkey: body.replyToPubkey } : {}), + }); + res.status(201).json(message); +}); + +chatRouter.post("/reactions", (req, res) => { + const body = ReactionBody.parse(req.body); + const reaction = addReaction({ + pubkey: req.session!.pubkey, + messageId: body.messageId, + content: body.content, + }); + res.status(201).json(reaction); +}); + +chatRouter.post("/key-wraps", (req, res) => { + const body = KeyWrapBody.parse(req.body); + const wraps = body.wraps.map((wrap) => + upsertKeyWrap({ + recipientPubkey: wrap.recipientPubkey, + senderPubkey: req.session!.pubkey, + ciphertext: wrap.ciphertext, + }), + ); + res.status(201).json({ wraps }); +}); diff --git a/docker/gashboard/apps/api/src/chat/store.ts b/docker/gashboard/apps/api/src/chat/store.ts new file mode 100644 index 00000000..65f93b24 --- /dev/null +++ b/docker/gashboard/apps/api/src/chat/store.ts @@ -0,0 +1,97 @@ +export type StoredChatMessage = { + id: string; + pubkey: string; + content: string; + createdAt: number; + replyToId: string; + replyToPubkey: string; +}; + +export type StoredChatReaction = { + id: string; + pubkey: string; + messageId: string; + content: string; + createdAt: number; +}; + +export type StoredChatKeyWrap = { + recipientPubkey: string; + senderPubkey: string; + ciphertext: string; + updatedAt: number; +}; + +const MAX_MESSAGES = 500; +const MAX_REACTIONS = 2000; + +const messages: StoredChatMessage[] = []; +const reactions: StoredChatReaction[] = []; +const keyWraps: StoredChatKeyWrap[] = []; + +export function listChat(): { + messages: StoredChatMessage[]; + reactions: StoredChatReaction[]; + keyWraps: StoredChatKeyWrap[]; +} { + return { messages: [...messages], reactions: [...reactions], keyWraps: [...keyWraps] }; +} + +export function addMessage(input: { + pubkey: string; + content: string; + replyToId?: string; + replyToPubkey?: string; +}): StoredChatMessage { + const message: StoredChatMessage = { + id: crypto.randomUUID(), + pubkey: input.pubkey, + content: input.content, + createdAt: Math.floor(Date.now() / 1000), + replyToId: input.replyToId ?? "", + replyToPubkey: input.replyToPubkey ?? "", + }; + messages.push(message); + if (messages.length > MAX_MESSAGES) messages.splice(0, messages.length - MAX_MESSAGES); + return message; +} + +export function addReaction(input: { + pubkey: string; + messageId: string; + content: string; +}): StoredChatReaction { + const reaction: StoredChatReaction = { + id: crypto.randomUUID(), + pubkey: input.pubkey, + messageId: input.messageId, + content: input.content, + createdAt: Math.floor(Date.now() / 1000), + }; + reactions.push(reaction); + if (reactions.length > MAX_REACTIONS) reactions.splice(0, reactions.length - MAX_REACTIONS); + return reaction; +} + +export function upsertKeyWrap(input: { + recipientPubkey: string; + senderPubkey: string; + ciphertext: string; +}): StoredChatKeyWrap { + const updatedAt = Math.floor(Date.now() / 1000); + const existing = keyWraps.find((wrap) => wrap.recipientPubkey === input.recipientPubkey); + if (existing) { + existing.senderPubkey = input.senderPubkey; + existing.ciphertext = input.ciphertext; + existing.updatedAt = updatedAt; + return existing; + } + const wrap: StoredChatKeyWrap = { + recipientPubkey: input.recipientPubkey, + senderPubkey: input.senderPubkey, + ciphertext: input.ciphertext, + updatedAt, + }; + keyWraps.push(wrap); + return wrap; +} diff --git a/docker/gashboard/apps/api/src/config.ts b/docker/gashboard/apps/api/src/config.ts new file mode 100644 index 00000000..59de475e --- /dev/null +++ b/docker/gashboard/apps/api/src/config.ts @@ -0,0 +1,84 @@ +import { nip19 } from "nostr-tools"; +import { z } from "zod"; + +const RawEnv = z.object({ + PORT: z.coerce.number().int().min(1).max(65535).default(1337), + NODE_ENV: z.enum(["development", "production", "test"]).default("production"), + LOG_LEVEL: z + .enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]) + .default("info"), + + CORS_ORIGIN: z.string().optional(), + STATIC_DIR: z.string().optional(), + + DATUM_URL: z.string().url().optional(), + DATUM_ADMIN_USER: z.string().default("admin"), + DATUM_ADMIN_PASSWORD: z.string().min(1), + DATUM_POLL_INTERVAL_MS: z.coerce.number().int().min(1000).default(5000), + CONTRIBUTION_LEDGER_PATH: z.string().min(1).default("/data/contribution-ledger.json"), + MEMPOOL_API_URL: z.string().url().default("https://tx1138.com/api"), + + NOSTR_ALLOWED_NPUBS: z.string().default(""), + NOSTR_OWNER_PUBKEYS: z.string().default(""), + ACCESS_LIST_PATH: z.string().default("/data/access.json"), + ARCHIPELAGO_PROVIDER_PATH: z.string().optional(), + + JWT_SECRET: z + .string() + .min(32, "JWT_SECRET must be at least 32 chars (use openssl rand -hex 32)"), + JWT_TTL_SECONDS: z.coerce.number().int().min(60).default(86400), +}); + +function parseAllowedNpubs(input: string): string[] { + const out: string[] = []; + for (const raw of input.split(",").map((s) => s.trim()).filter(Boolean)) { + const decoded = nip19.decode(raw); + if (decoded.type !== "npub") { + throw new Error(`NOSTR_ALLOWED_NPUBS entry is not an npub: ${raw}`); + } + const hex = decoded.data; + if (!/^[0-9a-f]{64}$/.test(hex)) { + throw new Error(`Decoded npub is not 64-char hex: ${raw}`); + } + out.push(hex); + } + return out; +} + +const parsed = RawEnv.parse(process.env); +const ownerHexPubkeys = parsed.NOSTR_OWNER_PUBKEYS.split(",").map(k => k.trim()).filter(Boolean); +if (ownerHexPubkeys.some(k => !/^[0-9a-f]{64}$/.test(k))) { + throw new Error("NOSTR_OWNER_PUBKEYS must contain comma-separated 64-character hex public keys"); +} +const allowedHexPubkeys = parseAllowedNpubs(parsed.NOSTR_ALLOWED_NPUBS); +if (!ownerHexPubkeys.length && !allowedHexPubkeys.length) { + throw new Error("Configure at least one owner or allowlisted npub before starting Gashboard"); +} + +export const config = { + port: parsed.PORT, + nodeEnv: parsed.NODE_ENV, + logLevel: parsed.LOG_LEVEL, + corsOrigin: parsed.CORS_ORIGIN, + staticDir: parsed.STATIC_DIR, + providerPath: parsed.ARCHIPELAGO_PROVIDER_PATH, + datum: { + url: (parsed.DATUM_URL ?? "http://127.0.0.1:21000").replace(/\/$/, ""), + adminUser: parsed.DATUM_ADMIN_USER, + adminPassword: parsed.DATUM_ADMIN_PASSWORD, + pollIntervalMs: parsed.DATUM_POLL_INTERVAL_MS, + contributionLedgerPath: parsed.CONTRIBUTION_LEDGER_PATH, + }, + mempool: { + url: parsed.MEMPOOL_API_URL.replace(/\/$/, ""), + }, + nostr: { + allowedHexPubkeys, + ownerHexPubkeys, + accessListPath: parsed.ACCESS_LIST_PATH, + }, + jwt: { + secret: parsed.JWT_SECRET, + ttlSeconds: parsed.JWT_TTL_SECONDS, + }, +} as const; diff --git a/docker/gashboard/apps/api/src/datum/digest.ts b/docker/gashboard/apps/api/src/datum/digest.ts new file mode 100644 index 00000000..ca7be934 --- /dev/null +++ b/docker/gashboard/apps/api/src/datum/digest.ts @@ -0,0 +1,114 @@ +import { createHash, randomBytes } from "node:crypto"; + +export type DigestCreds = { + username: string; + password: string; +}; + +export type DigestFetchOptions = { + url: string; + method?: string; + creds: DigestCreds; + signal?: AbortSignal; + headers?: Record; +}; + +function sha256Hex(input: string): string { + return createHash("sha256").update(input).digest("hex"); +} + +type Challenge = { + realm: string; + nonce: string; + qop: string; + algorithm: string; + opaque?: string; +}; + +function parseChallenge(header: string): Challenge | null { + if (!/^digest\s/i.test(header)) return null; + const body = header.slice(7); + const out: Record = {}; + const re = /(\w+)\s*=\s*(?:"([^"]*)"|([^,\s]+))/g; + let m: RegExpExecArray | null; + while ((m = re.exec(body)) !== null) { + out[m[1]!.toLowerCase()] = m[2] !== undefined ? m[2] : (m[3] ?? ""); + } + if (!out["realm"] || !out["nonce"]) return null; + return { + realm: out["realm"], + nonce: out["nonce"], + qop: out["qop"] ?? "auth", + algorithm: out["algorithm"] ?? "MD5", + ...(out["opaque"] !== undefined ? { opaque: out["opaque"] } : {}), + }; +} + +function buildAuthHeader(args: { + user: string; + password: string; + challenge: Challenge; + uri: string; + method: string; +}): string { + const algoUpper = args.challenge.algorithm.toUpperCase(); + if (!algoUpper.includes("SHA-256") && !algoUpper.includes("SHA256")) { + throw new Error(`Unsupported Datum digest algorithm: ${args.challenge.algorithm}`); + } + const nc = "00000001"; + const cnonce = randomBytes(8).toString("hex"); + const ha1 = sha256Hex(`${args.user}:${args.challenge.realm}:${args.password}`); + const ha2 = sha256Hex(`${args.method}:${args.uri}`); + const response = sha256Hex( + `${ha1}:${args.challenge.nonce}:${nc}:${cnonce}:${args.challenge.qop}:${ha2}`, + ); + const parts = [ + `username="${args.user}"`, + `realm="${args.challenge.realm}"`, + `nonce="${args.challenge.nonce}"`, + `uri="${args.uri}"`, + `algorithm=${args.challenge.algorithm}`, + `response="${response}"`, + `qop=${args.challenge.qop}`, + `nc=${nc}`, + `cnonce="${cnonce}"`, + ]; + if (args.challenge.opaque) parts.push(`opaque="${args.challenge.opaque}"`); + return "Digest " + parts.join(", "); +} + +export async function digestFetch(opts: DigestFetchOptions): Promise { + const method = opts.method ?? "GET"; + const url = new URL(opts.url); + const uri = url.pathname + url.search; + const baseInit: RequestInit = { + method, + redirect: "manual", + headers: { ...(opts.headers ?? {}) }, + ...(opts.signal ? { signal: opts.signal } : {}), + }; + + const first = await fetch(opts.url, baseInit); + if (first.status !== 401) return first; + + const challengeHdr = first.headers.get("www-authenticate"); + if (!challengeHdr) return first; + + const challenge = parseChallenge(challengeHdr); + if (!challenge) return first; + + await first.body?.cancel().catch(() => {}); + + const auth = buildAuthHeader({ + user: opts.creds.username, + password: opts.creds.password, + challenge, + uri, + method, + }); + + return fetch(opts.url, { + ...baseInit, + headers: { ...(opts.headers ?? {}), authorization: auth }, + }); +} diff --git a/docker/gashboard/apps/api/src/datum/parse.ts b/docker/gashboard/apps/api/src/datum/parse.ts new file mode 100644 index 00000000..b03343ca --- /dev/null +++ b/docker/gashboard/apps/api/src/datum/parse.ts @@ -0,0 +1,162 @@ +import { parse, type HTMLElement } from "node-html-parser"; +import { MINER_PROFILES } from "./profiles.js"; +import type { MinerProfile, MinerStat } from "./types.js"; + +const UNKNOWN_PROFILE: MinerProfile = { + slug: "unknown", + nickname: "Unknown miner", + model: "Unknown", + ownerLabel: "unknown", + locationLabel: "unknown", + expectedHashrateThs: 0, + watts: 0, + workerNameMatchers: [], +}; + +export type ThreadRow = { + tid: number; + connections: number; + subscriptions: number; + hashrateThs: number; +}; + +function num(s: string | undefined | null): number { + if (!s) return 0; + const m = s.replace(/,/g, "").match(/-?\d+(\.\d+)?/); + return m ? Number(m[0]) : 0; +} + +function thsFromHashrateField(field: string): number { + const numMatch = field.match(/-?\d+(?:\.\d+)?/); + if (!numMatch) return 0; + const v = Number(numMatch[0]); + if (/Gh\/s/i.test(field)) return v / 1_000; + if (/Mh\/s/i.test(field)) return v / 1_000_000; + if (/Kh\/s/i.test(field)) return v / 1_000_000_000; + return v; +} + +function parseAgeS(s: string): number | null { + const m = s.match(/-?\d+(?:\.\d+)?/); + return m ? Number(m[0]) : null; +} + +function findTableContaining(html: string, marker: string): HTMLElement | null { + const root = parse(html); + for (const t of root.querySelectorAll("table")) { + if (t.text.includes(marker)) return t; + } + return null; +} + +export function matchProfile(userAgent: string, authUsername: string): MinerProfile { + if (/nerdq?axe/i.test(userAgent)) { + const p = MINER_PROFILES.find((x) => x.slug === "nerdqaxe"); + if (p) return p; + } + if (/bitaxe/i.test(userAgent)) { + const p = MINER_PROFILES.find((x) => x.slug === "bitaxe"); + if (p) return p; + } + const dotIdx = authUsername.indexOf("."); + if (dotIdx >= 0) { + const worker = authUsername.slice(dotIdx + 1).toLowerCase(); + for (const p of MINER_PROFILES) { + for (const m of p.workerNameMatchers) { + if (worker.includes(m.toLowerCase())) return p; + } + } + } + return UNKNOWN_PROFILE; +} + +export function parseClientsHtml(html: string): MinerStat[] { + const table = findTableContaining(html, "Auth Username"); + if (!table) return []; + const out: MinerStat[] = []; + const trs = table.querySelectorAll("tr"); + for (let i = 1; i < trs.length; i++) { + const tds = trs[i]!.querySelectorAll("td"); + if (tds.length < 11) continue; + + const remoteHost = tds[1]!.text.trim(); + const authUsername = tds[2]!.text.trim(); + const subbedField = tds[3]!.text.trim(); + const lastAcceptedField = tds[4]!.text.trim(); + const vdiffField = tds[5]!.text.trim(); + const diffAField = tds[6]!.text.trim(); + const diffRField = tds[7]!.text.trim(); + const hashrateField = tds[8]!.text.trim(); + const userAgent = tds[10]!.text.trim(); + + const subscribed = !subbedField.toLowerCase().startsWith("not subscribed"); + const lastShareAgeS = lastAcceptedField === "N/A" ? null : parseAgeS(lastAcceptedField); + const vdiff = num(vdiffField); + + const diffAMatch = diffAField.match(/(-?\d+(?:\.\d+)?)\s*\((\d+)\)/); + const diffAcceptedSum = diffAMatch ? Number(diffAMatch[1]) : 0; + const diffAcceptedCount = diffAMatch ? Number(diffAMatch[2]) : 0; + + const diffRMatch = diffRField.match(/(-?\d+(?:\.\d+)?)\s*\((\d+)\)\s*([\d.]+)%/); + const diffRejectedSum = diffRMatch ? Number(diffRMatch[1]) : 0; + const diffRejectedCount = diffRMatch ? Number(diffRMatch[2]) : 0; + const rejectPct = diffRMatch ? Number(diffRMatch[3]) : 0; + + let hashrateThs = 0; + let hashrateAgeS: number | null = null; + if (hashrateField !== "N/A") { + hashrateThs = thsFromHashrateField(hashrateField); + const ageMatch = hashrateField.match(/\(\s*(-?\d+(?:\.\d+)?)\s*s\s*\)/); + hashrateAgeS = ageMatch ? Number(ageMatch[1]) : null; + } + + const profile = matchProfile(userAgent, authUsername); + + let status: MinerStat["status"]; + if (!subscribed) status = "idle"; + else if (hashrateThs <= 0) status = "idle"; + else if (hashrateAgeS !== null && hashrateAgeS > 180) status = "stale"; + else status = "hashing"; + + out.push({ + authUsername, + remoteHost, + nickname: authUsername.split(".").slice(1).join(".") || authUsername || remoteHost || "Unknown miner", + model: profile.model, + location: "unassigned", + expectedHashrateThs: profile.expectedHashrateThs, + watts: profile.watts, + hashrateThs, + hashrateAgeS, + lastShareAgeS, + diffAcceptedSum, + diffAcceptedCount, + diffRejectedSum, + diffRejectedCount, + rejectPct, + vdiff, + userAgent, + subscribed, + status, + }); + } + return out; +} + +export function parseThreadsHtml(html: string): ThreadRow[] { + const table = findTableContaining(html, "Approx. Hashrate"); + if (!table) return []; + const out: ThreadRow[] = []; + const trs = table.querySelectorAll("tr"); + for (let i = 1; i < trs.length; i++) { + const tds = trs[i]!.querySelectorAll("td"); + if (tds.length < 4) continue; + out.push({ + tid: num(tds[0]!.text), + connections: num(tds[1]!.text), + subscriptions: num(tds[2]!.text), + hashrateThs: thsFromHashrateField(tds[3]!.text.trim()), + }); + } + return out; +} diff --git a/docker/gashboard/apps/api/src/datum/poller.ts b/docker/gashboard/apps/api/src/datum/poller.ts new file mode 100644 index 00000000..86135460 --- /dev/null +++ b/docker/gashboard/apps/api/src/datum/poller.ts @@ -0,0 +1,471 @@ +import { config } from "../config.js"; +import { logger } from "../logger.js"; +import { digestFetch } from "./digest.js"; +import { parseClientsHtml, parseThreadsHtml } from "./parse.js"; +import type { CurrentJob, DatumSnapshot, MinerStat, NetworkStat, PoolStat } from "./types.js"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +const DEFAULT_TIMEOUT_MS = 10_000; + +const EMPTY_POOL: PoolStat = { + combinedHashrateThs: 0, + totalConnections: 0, + totalSubscriptions: 0, + activeThreads: 0, + sharesAccepted: 0, + sharesRejected: 0, + uptimeSeconds: 0, + connectionStatus: "unknown", + poolHost: "", + poolTag: "", + minerTag: "", + poolDifficulty: 0, +}; + +const EMPTY_JOB: CurrentJob = { + blockHeight: 0, + blockValueSats: 0, + difficulty: 0, + bits: "", + prevBlock: "", + target: "", + version: "", + weight: 0, + size: 0, + txnCount: 0, + timeInfo: "", +}; + +const EMPTY_NETWORK: NetworkStat = { + blockHeight: 0, + hashrateEh: 0, + difficulty: 0, + source: "", +}; + +let lastSnapshot: DatumSnapshot = { + ok: false, + fetchedAt: 0, + pool: EMPTY_POOL, + job: EMPTY_JOB, + network: EMPTY_NETWORK, + miners: [], + error: { code: "NOT_YET_POLLED", message: "Poller has not run yet" }, +}; +let timer: NodeJS.Timeout | null = null; +let pollInFlight = false; + +type ContributionEntry = { + miner: MinerStat; + lastRawAcceptedWork: number; + lastRawAcceptedShares: number; + totalAcceptedWork: number; + totalAcceptedShares: number; + firstSeenAt: number; + lastSeenAt: number; + currentlyConnected: boolean; +}; + +const contributionLedger = new Map(); +let ledgerLoaded = false; +let ledgerDirty = false; +let ledgerWriteInFlight: Promise | null = null; + +type PersistedContributionLedger = { + version: 1; + updatedAt: number; + entries: Array<[string, ContributionEntry]>; +}; + +function formatErr(err: unknown): string { + if (!(err instanceof Error)) return String(err); + const cause = (err as Error & { cause?: unknown }).cause; + if (cause instanceof Error) { + const code = (cause as Error & { code?: string }).code; + const hostname = (cause as Error & { hostname?: string }).hostname; + if (code === "ENOTFOUND" && hostname) { + const datumHost = new URL(config.datum.url).hostname; + const hint = + hostname === datumHost + ? "; make sure gashboard is attached to Umbrel's Docker network or set DATUM_URL to a hostname/IP reachable from the API container" + : ""; + return `${err.message}: DNS could not resolve ${hostname} (${code})${hint}`; + } + return code ? `${err.message}: ${cause.message} (${code})` : `${err.message}: ${cause.message}`; + } + return err.message; +} + +function abortableSignal(timeoutMs: number): { signal: AbortSignal; cancel: () => void } { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(new Error("upstream_timeout")), timeoutMs); + return { signal: ctrl.signal, cancel: () => clearTimeout(t) }; +} + +function datumHttpMessage(res: Response, path: string): string { + const location = res.headers.get("location"); + if (res.status >= 300 && res.status < 400) { + return location + ? `Datum ${path} redirected to ${location}; DATUM_URL is probably pointing at the Umbrel web proxy, not the Datum admin API` + : `Datum ${path} redirected; DATUM_URL is probably pointing at the Umbrel web proxy, not the Datum admin API`; + } + return `Datum ${path} returned ${res.status}`; +} + +function isUmbrelShellHtml(html: string): boolean { + return /\s*Umbrel\s*<\/title>/i.test(html) || /<div\s+id=["']root["']/i.test(html); +} + +type MempoolHashrate = { + currentHashrate?: number; + currentDifficulty?: number; +}; + +function minerIdentity(m: MinerStat): string { + return m.authUsername || m.remoteHost || m.nickname; +} + +function validContributionEntry(entry: ContributionEntry): boolean { + return ( + entry && + typeof entry === "object" && + typeof entry.lastRawAcceptedWork === "number" && + typeof entry.lastRawAcceptedShares === "number" && + typeof entry.totalAcceptedWork === "number" && + typeof entry.totalAcceptedShares === "number" && + typeof entry.firstSeenAt === "number" && + typeof entry.lastSeenAt === "number" && + entry.miner && + typeof entry.miner === "object" + ); +} + +async function loadContributionLedger(): Promise<void> { + if (ledgerLoaded) return; + ledgerLoaded = true; + try { + const raw = await readFile(config.datum.contributionLedgerPath, "utf8"); + const parsed = JSON.parse(raw) as PersistedContributionLedger; + if (parsed.version !== 1 || !Array.isArray(parsed.entries)) { + throw new Error("unsupported contribution ledger format"); + } + contributionLedger.clear(); + for (const [key, entry] of parsed.entries) { + if (typeof key === "string" && validContributionEntry(entry)) { + contributionLedger.set(key, { ...entry, currentlyConnected: false }); + } + } + logger.info( + { entries: contributionLedger.size, path: config.datum.contributionLedgerPath }, + "contribution_ledger_loaded", + ); + } catch (err) { + const code = err instanceof Error ? (err as Error & { code?: string }).code : undefined; + if (code === "ENOENT") return; + logger.warn({ reason: formatErr(err), path: config.datum.contributionLedgerPath }, "contribution_ledger_load_failed"); + } +} + +function scheduleContributionLedgerSave(): void { + ledgerDirty = true; + if (ledgerWriteInFlight) return; + ledgerWriteInFlight = saveContributionLedger() + .catch((err) => { + logger.warn({ reason: formatErr(err), path: config.datum.contributionLedgerPath }, "contribution_ledger_save_failed"); + }) + .finally(() => { + ledgerWriteInFlight = null; + if (ledgerDirty) scheduleContributionLedgerSave(); + }); +} + +async function saveContributionLedger(): Promise<void> { + if (!ledgerDirty) return; + ledgerDirty = false; + const path = config.datum.contributionLedgerPath; + await mkdir(dirname(path), { recursive: true }); + const tmpPath = `${path}.${process.pid}.tmp`; + const payload: PersistedContributionLedger = { + version: 1, + updatedAt: Date.now(), + entries: [...contributionLedger.entries()], + }; + await writeFile(tmpPath, `${JSON.stringify(payload)}\n`, "utf8"); + await rename(tmpPath, path); +} + +async function applyContributionLedger(miners: MinerStat[], fetchedAt: number): Promise<MinerStat[]> { + await loadContributionLedger(); + let changed = false; + + for (const entry of contributionLedger.values()) { + entry.currentlyConnected = false; + } + + const connectedKeys = new Set<string>(); + for (const miner of miners) { + const key = minerIdentity(miner); + connectedKeys.add(key); + const existing = contributionLedger.get(key); + + if (!existing) { + contributionLedger.set(key, { + miner, + lastRawAcceptedWork: miner.diffAcceptedSum, + lastRawAcceptedShares: miner.diffAcceptedCount, + totalAcceptedWork: miner.diffAcceptedSum, + totalAcceptedShares: miner.diffAcceptedCount, + firstSeenAt: fetchedAt, + lastSeenAt: fetchedAt, + currentlyConnected: true, + }); + changed = true; + continue; + } + + const workDelta = miner.diffAcceptedSum - existing.lastRawAcceptedWork; + const shareDelta = miner.diffAcceptedCount - existing.lastRawAcceptedShares; + + existing.totalAcceptedWork += workDelta >= 0 ? workDelta : miner.diffAcceptedSum; + existing.totalAcceptedShares += shareDelta >= 0 ? shareDelta : miner.diffAcceptedCount; + if (workDelta > 0 || shareDelta > 0 || workDelta < 0 || shareDelta < 0) changed = true; + existing.lastRawAcceptedWork = miner.diffAcceptedSum; + existing.lastRawAcceptedShares = miner.diffAcceptedCount; + existing.lastSeenAt = fetchedAt; + existing.currentlyConnected = true; + existing.miner = miner; + } + + if (changed) scheduleContributionLedgerSave(); + + const totalWork = [...contributionLedger.values()].reduce((sum, entry) => sum + entry.totalAcceptedWork, 0); + const decorate = (miner: MinerStat, entry: ContributionEntry): MinerStat => ({ + ...miner, + contributionAcceptedWork: entry.totalAcceptedWork, + contributionAcceptedShares: entry.totalAcceptedShares, + contributionPct: totalWork > 0 ? (entry.totalAcceptedWork / totalWork) * 100 : 0, + contributionFirstSeenAt: entry.firstSeenAt, + contributionLastSeenAt: entry.lastSeenAt, + currentlyConnected: entry.currentlyConnected, + }); + + const live = miners.map((miner) => decorate(miner, contributionLedger.get(minerIdentity(miner))!)); + const offline = [...contributionLedger.entries()] + .filter(([key]) => !connectedKeys.has(key)) + .map(([, entry]) => + decorate( + { + ...entry.miner, + hashrateThs: 0, + hashrateAgeS: null, + subscribed: false, + status: "idle", + }, + entry, + ), + ); + + return [...live, ...offline].sort((a, b) => (b.contributionAcceptedWork ?? 0) - (a.contributionAcceptedWork ?? 0)); +} + +async function fetchNetworkStats(): Promise<NetworkStat> { + const timeout = abortableSignal(DEFAULT_TIMEOUT_MS); + try { + const [heightRes, hashrateRes] = await Promise.all([ + fetch(`${config.mempool.url}/blocks/tip/height`, { signal: timeout.signal }), + fetch(`${config.mempool.url}/v1/mining/hashrate/3d`, { signal: timeout.signal }), + ]); + if (!heightRes.ok || !hashrateRes.ok) { + throw new Error(`mempool returned ${heightRes.status}/${hashrateRes.status}`); + } + const [heightText, hashrateJson] = await Promise.all([ + heightRes.text(), + hashrateRes.json() as Promise<MempoolHashrate>, + ]); + const blockHeight = Number(heightText); + return { + blockHeight: Number.isFinite(blockHeight) ? blockHeight : 0, + hashrateEh: (hashrateJson.currentHashrate ?? 0) / 1e18, + difficulty: hashrateJson.currentDifficulty ?? 0, + source: config.mempool.url, + }; + } finally { + timeout.cancel(); + } +} + +async function pollOnce(): Promise<DatumSnapshot> { + const fetchedAt = Date.now(); + const datumUrl = config.datum.url; + + // /clients (admin Digest-gated) and /threads (public) in parallel. + const clientsTimeout = abortableSignal(DEFAULT_TIMEOUT_MS); + const threadsTimeout = abortableSignal(DEFAULT_TIMEOUT_MS); + + try { + const [clientsResSettled, threadsResSettled] = await Promise.allSettled([ + digestFetch({ + url: `${datumUrl}/clients`, + creds: { username: config.datum.adminUser, password: config.datum.adminPassword }, + signal: clientsTimeout.signal, + }), + fetch(`${datumUrl}/threads`, { signal: threadsTimeout.signal, redirect: "manual" }), + ]); + clientsTimeout.cancel(); + threadsTimeout.cancel(); + + if (clientsResSettled.status === "rejected") { + const reason = formatErr(clientsResSettled.reason); + logger.warn({ reason, url: `${datumUrl}/clients` }, "datum_clients_fetch_failed"); + return { + ...lastSnapshot, + ok: false, + fetchedAt, + error: { code: "DATUM_UNREACHABLE", message: reason }, + }; + } + const clientsRes = clientsResSettled.value; + if (clientsRes.status === 401) { + return { + ...lastSnapshot, + ok: false, + fetchedAt, + error: { + code: "DATUM_AUTH_FAIL", + message: "Datum rejected admin credentials (check DATUM_ADMIN_PASSWORD)", + }, + }; + } + if (clientsRes.status !== 200) { + const message = datumHttpMessage(clientsRes, "/clients"); + logger.warn( + { status: clientsRes.status, url: `${datumUrl}/clients`, location: clientsRes.headers.get("location") }, + "datum_clients_bad_status", + ); + return { + ...lastSnapshot, + ok: false, + fetchedAt, + error: { code: "DATUM_HTTP", message }, + }; + } + + const clientsHtml = await clientsRes.text(); + if (isUmbrelShellHtml(clientsHtml)) { + const message = + "DATUM_URL returned the Umbrel web shell, not Datum /clients; use the Datum container admin API URL"; + logger.warn({ url: `${datumUrl}/clients` }, "datum_clients_wrong_html"); + return { + ...lastSnapshot, + ok: false, + fetchedAt, + error: { code: "DATUM_BAD_RESPONSE", message }, + }; + } + + let threadsHtml = ""; + if (threadsResSettled.status === "fulfilled" && threadsResSettled.value.ok) { + threadsHtml = await threadsResSettled.value.text(); + } else if ( + threadsResSettled.status === "fulfilled" && + threadsResSettled.value.status >= 300 && + threadsResSettled.value.status < 400 + ) { + logger.warn( + { + status: threadsResSettled.value.status, + url: `${datumUrl}/threads`, + location: threadsResSettled.value.headers.get("location"), + }, + "datum_threads_redirected", + ); + } else if (threadsResSettled.status === "rejected") { + logger.warn( + { reason: formatErr(threadsResSettled.reason) }, + "datum_threads_fetch_failed", + ); + } + + const miners = await applyContributionLedger(parseClientsHtml(clientsHtml), fetchedAt); + const threads = threadsHtml ? parseThreadsHtml(threadsHtml) : []; + if (miners.length === 0) { + logger.warn( + { url: `${datumUrl}/clients`, bytes: clientsHtml.length }, + "datum_clients_no_miners_parsed", + ); + } + + const combinedHashrateThs = miners.reduce((s, m) => s + m.hashrateThs, 0); + const totalSubscriptions = + threads.reduce((s, t) => s + t.subscriptions, 0) || + miners.filter((m) => m.subscribed).length; + const totalConnections = + threads.reduce((s, t) => s + t.connections, 0) || miners.length; + const sharesAccepted = miners.reduce((s, m) => s + m.diffAcceptedCount, 0); + const sharesRejected = miners.reduce((s, m) => s + m.diffRejectedCount, 0); + let network = lastSnapshot.network.blockHeight > 0 ? lastSnapshot.network : EMPTY_NETWORK; + try { + network = await fetchNetworkStats(); + } catch (err) { + logger.warn({ reason: formatErr(err), url: config.mempool.url }, "mempool_fetch_failed"); + } + + return { + ok: true, + fetchedAt, + pool: { + ...EMPTY_POOL, + combinedHashrateThs, + totalConnections, + totalSubscriptions, + activeThreads: threads.length, + sharesAccepted, + sharesRejected, + connectionStatus: "ok", + }, + job: { + ...EMPTY_JOB, + blockHeight: network.blockHeight, + difficulty: network.difficulty, + }, + network, + miners, + }; + } catch (err) { + clientsTimeout.cancel(); + threadsTimeout.cancel(); + const reason = formatErr(err); + logger.warn({ reason }, "datum_poll_unexpected_error"); + return { + ...lastSnapshot, + ok: false, + fetchedAt, + error: { code: "POLL_ERROR", message: reason }, + }; + } +} + +export function startPoller(): void { + if (timer) return; + const tick = async (): Promise<void> => { + if (pollInFlight) return; + pollInFlight = true; + try { + lastSnapshot = await pollOnce(); + } finally { + pollInFlight = false; + } + }; + void tick(); + timer = setInterval(() => void tick(), config.datum.pollIntervalMs); +} + +export function stopPoller(): void { + if (timer) clearInterval(timer); + timer = null; +} + +export function getSnapshot(): DatumSnapshot { + return lastSnapshot; +} diff --git a/docker/gashboard/apps/api/src/datum/profiles.ts b/docker/gashboard/apps/api/src/datum/profiles.ts new file mode 100644 index 00000000..987394f2 --- /dev/null +++ b/docker/gashboard/apps/api/src/datum/profiles.ts @@ -0,0 +1,68 @@ +import type { MinerProfile } from "./types.js"; + +// Live UserAgent strings observed on this Umbrel (2026-05-06): +// NerdQAxe → "NerdQAxe/BM1370/v1.0.36" (self-identifies) +// Bitaxe → "bitaxe/BM1368/v2.7.1" or "cgminer/4.11.1" depending on firmware +// Avalon Nano 3 / Avalon Mini 3 → "cgminer/4.11.1" (match via worker-name suffix). +export const MINER_PROFILES: readonly MinerProfile[] = [ + { + slug: "nerdqaxe", + nickname: "QU4CK", + model: "NerdQAxe+", + ownerLabel: "shared", + locationLabel: "Site A", + expectedHashrateThs: 4.5, + watts: 80, + workerNameMatchers: ["nerdqaxe", "qu4ck", "quack"], + }, + { + slug: "bitaxe-bigpapa", + nickname: "BigPapa", + model: "Bitaxe", + ownerLabel: "shared", + locationLabel: "Site A", + expectedHashrateThs: 1.2, + watts: 18, + workerNameMatchers: ["bigpapa", "big-papa", "big_papa"], + }, + { + slug: "bitaxe", + nickname: "P1XEL", + model: "Bitaxe", + ownerLabel: "shared", + locationLabel: "Site A", + expectedHashrateThs: 1.2, + watts: 18, + workerNameMatchers: ["bitaxe", "p1xel", "pixel"], + }, + { + slug: "avalon-nano-3", + nickname: "N4N0", + model: "Avalon Canaan Nano 3", + ownerLabel: "shared", + locationLabel: "Site B", + expectedHashrateThs: 4.0, + watts: 140, + workerNameMatchers: ["nano", "nano3", "n4n0"], + }, + { + slug: "avalon-mini-3", + nickname: "M1N1", + model: "Avalon Mini 3", + ownerLabel: "shared", + locationLabel: "Site B", + expectedHashrateThs: 37.0, + watts: 800, + workerNameMatchers: ["mini", "mini3", "m1n1"], + }, +] as const; + +export function profileForWorker(workerName: string): MinerProfile | null { + const lower = workerName.toLowerCase(); + for (const p of MINER_PROFILES) { + for (const m of p.workerNameMatchers) { + if (lower.includes(m)) return p; + } + } + return null; +} diff --git a/docker/gashboard/apps/api/src/datum/routes.ts b/docker/gashboard/apps/api/src/datum/routes.ts new file mode 100644 index 00000000..64e5134c --- /dev/null +++ b/docker/gashboard/apps/api/src/datum/routes.ts @@ -0,0 +1,31 @@ +import { Router } from "express"; +import { isPubkeyAllowed } from "../nostr/allowlist.js"; +import { requireAuth } from "../auth/middleware.js"; +import { getSnapshot } from "./poller.js"; +import { upstreamUnavailable } from "../errors.js"; + +export const datumRouter = Router(); + +datumRouter.use(requireAuth); + +datumRouter.get("/stats", (_req, res, next) => { + const snap = getSnapshot(); + if (!snap) return next(upstreamUnavailable()); + res.json(snap); +}); + +datumRouter.get("/stream", (req, res) => { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.flushHeaders?.(); + + const send = () => { + if (!isPubkeyAllowed(req.session!.pubkey)) { res.end(); return; } + const snap = getSnapshot(); + if (snap) res.write(`data: ${JSON.stringify(snap)}\n\n`); + }; + send(); + const interval = setInterval(send, 5_000); + res.on("close", () => clearInterval(interval)); +}); diff --git a/docker/gashboard/apps/api/src/datum/types.ts b/docker/gashboard/apps/api/src/datum/types.ts new file mode 100644 index 00000000..642496cd --- /dev/null +++ b/docker/gashboard/apps/api/src/datum/types.ts @@ -0,0 +1,84 @@ +export type MinerProfile = { + slug: string; + nickname: string; + model: string; + ownerLabel: string; + locationLabel: string; + expectedHashrateThs: number; + watts: number; + workerNameMatchers: readonly string[]; +}; + +export type MinerStat = { + authUsername: string; + remoteHost: string; + nickname: string; + model: string; + location: string; + expectedHashrateThs: number; + watts: number; + hashrateThs: number; + hashrateAgeS: number | null; + lastShareAgeS: number | null; + diffAcceptedSum: number; + diffAcceptedCount: number; + diffRejectedSum: number; + diffRejectedCount: number; + rejectPct: number; + vdiff: number; + userAgent: string; + subscribed: boolean; + status: 'hashing' | 'stale' | 'idle'; + contributionAcceptedWork?: number; + contributionAcceptedShares?: number; + contributionPct?: number; + contributionFirstSeenAt?: number; + contributionLastSeenAt?: number; + currentlyConnected?: boolean; +}; + +export type PoolStat = { + combinedHashrateThs: number; + totalConnections: number; + totalSubscriptions: number; + activeThreads: number; + sharesAccepted: number; + sharesRejected: number; + uptimeSeconds: number; + connectionStatus: string; + poolHost: string; + poolTag: string; + minerTag: string; + poolDifficulty: number; +}; + +export type CurrentJob = { + blockHeight: number; + blockValueSats: number; + difficulty: number; + bits: string; + prevBlock: string; + target: string; + version: string; + weight: number; + size: number; + txnCount: number; + timeInfo: string; +}; + +export type NetworkStat = { + blockHeight: number; + hashrateEh: number; + difficulty: number; + source: string; +}; + +export type DatumSnapshot = { + ok: boolean; + fetchedAt: number; + pool: PoolStat; + job: CurrentJob; + network: NetworkStat; + miners: MinerStat[]; + error?: { code: string; message: string }; +}; diff --git a/docker/gashboard/apps/api/src/errors.ts b/docker/gashboard/apps/api/src/errors.ts new file mode 100644 index 00000000..41b91de3 --- /dev/null +++ b/docker/gashboard/apps/api/src/errors.ts @@ -0,0 +1,31 @@ +import type { Request, Response, NextFunction } from "express"; +import { logger } from "./logger.js"; + +export class AppError extends Error { + readonly status: number; + readonly code: string; + constructor(status: number, code: string, message: string) { + super(message); + this.status = status; + this.code = code; + } +} + +export const unauthorized = (code = "unauthorized") => + new AppError(401, code, "Authentication required."); +export const forbidden = (code = "forbidden") => + new AppError(403, code, "Access denied."); +export const badRequest = (code = "bad_request", message = "Invalid request.") => + new AppError(400, code, message); +export const upstreamUnavailable = () => + new AppError(503, "upstream_unavailable", "Upstream temporarily unavailable."); + +export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) { + if (res.headersSent) return; + if (err instanceof AppError) { + res.status(err.status).json({ error: { code: err.code, message: err.message } }); + return; + } + logger.error({ err }, "unhandled_error"); + res.status(500).json({ error: { code: "internal_error", message: "Something went wrong." } }); +} diff --git a/docker/gashboard/apps/api/src/index.ts b/docker/gashboard/apps/api/src/index.ts new file mode 100644 index 00000000..ae135871 --- /dev/null +++ b/docker/gashboard/apps/api/src/index.ts @@ -0,0 +1,17 @@ +import { config } from "./config.js"; +import { logger } from "./logger.js"; +import { startPoller } from "./datum/poller.js"; +import { buildApp } from "./server.js"; + +startPoller(); + +buildApp().listen(config.port, () => { + logger.info( + { + port: config.port, + datum: config.datum.url, + allowedNpubs: config.nostr.allowedHexPubkeys.length, + }, + "gashboard api listening", + ); +}); diff --git a/docker/gashboard/apps/api/src/logger.ts b/docker/gashboard/apps/api/src/logger.ts new file mode 100644 index 00000000..69ca839a --- /dev/null +++ b/docker/gashboard/apps/api/src/logger.ts @@ -0,0 +1,142 @@ +import { pino } from "pino"; +import { Writable } from "node:stream"; +import { config } from "./config.js"; + +const levelNames: Record<number, string> = { + 10: "trace", + 20: "debug", + 30: "info", + 40: "warn", + 50: "error", + 60: "fatal", +}; + +let buffered = ""; + +const logStream = new Writable({ + write(chunk, _encoding, callback) { + buffered += chunk.toString(); + const lines = buffered.split("\n"); + buffered = lines.pop() ?? ""; + + for (const line of lines) { + if (!line) continue; + process.stdout.write(`${formatLogLine(line)}\n`); + } + + callback(); + }, +}); + +export const logger = pino({ + level: config.logLevel, + redact: { + paths: [ + 'req.headers.authorization', + 'req.headers.cookie', + '*.password', + '*.token', + '*.jwt', + '*.sig', + ], + censor: "[REDACTED]", + }, + base: { app: "gashboard" }, +}, logStream); + +function formatLogLine(line: string): string { + try { + const record = JSON.parse(line) as Record<string, unknown>; + const handled = new Set([ + "level", + "time", + "pid", + "hostname", + "app", + "req", + "res", + "responseTime", + "port", + "datum", + "allowedNpubs", + "staticDir", + "url", + "reason", + "err", + "msg", + ]); + const parts: string[] = [ + `time=${formatTime(record.time)}`, + `level=${formatLevel(record.level)}`, + ]; + + pushField(parts, "app", record.app); + pushRequestFields(parts, record.req); + pushResponseFields(parts, record.res); + pushField(parts, "responseTime", record.responseTime); + pushField(parts, "port", record.port); + pushField(parts, "datum", record.datum); + pushField(parts, "allowedNpubs", record.allowedNpubs); + pushField(parts, "staticDir", record.staticDir); + pushField(parts, "url", record.url); + pushField(parts, "reason", record.reason); + + if (record.err && typeof record.err === "object") { + const err = record.err as Record<string, unknown>; + pushField(parts, "err", err.message ?? err.type ?? record.err); + } + + for (const [key, value] of Object.entries(record)) { + if (handled.has(key)) continue; + if (!isScalar(value)) continue; + pushField(parts, key, value); + } + + pushField(parts, "msg", record.msg); + return parts.join(" "); + } catch { + return line; + } +} + +function formatTime(value: unknown): string { + if (typeof value === "number") return new Date(value).toISOString(); + if (typeof value === "string") return value; + return new Date().toISOString(); +} + +function formatLevel(value: unknown): string { + if (typeof value === "number") return levelNames[value] ?? String(value); + if (typeof value === "string") return value; + return "info"; +} + +function pushRequestFields(parts: string[], value: unknown): void { + if (!value || typeof value !== "object") return; + const req = value as Record<string, unknown>; + pushField(parts, "method", req.method); + pushField(parts, "path", req.url); + pushField(parts, "remote", req.remoteAddress); +} + +function pushResponseFields(parts: string[], value: unknown): void { + if (!value || typeof value !== "object") return; + const res = value as Record<string, unknown>; + pushField(parts, "status", res.statusCode); +} + +function pushField(parts: string[], key: string, value: unknown): void { + if (value === undefined || value === null || value === "") return; + parts.push(`${key}=${formatValue(value)}`); +} + +function isScalar(value: unknown): value is string | number | boolean { + return typeof value === "string" || typeof value === "number" || typeof value === "boolean"; +} + +function formatValue(value: unknown): string { + const raw = typeof value === "string" ? value : JSON.stringify(value); + if (!raw) return '""'; + if (/^[A-Za-z0-9_./:@+-]+$/.test(raw)) return raw; + return JSON.stringify(raw); +} diff --git a/docker/gashboard/apps/api/src/nostr/allowlist.ts b/docker/gashboard/apps/api/src/nostr/allowlist.ts new file mode 100644 index 00000000..118d93fc --- /dev/null +++ b/docker/gashboard/apps/api/src/nostr/allowlist.ts @@ -0,0 +1,7 @@ +import { config } from "../config.js"; +import { AccessList } from "../access/store.js"; + +export const accessList = new AccessList(config.nostr.accessListPath, config.nostr.ownerHexPubkeys, config.nostr.allowedHexPubkeys); +export const isPubkeyAllowed = (pubkey: string): boolean => accessList.isAllowed(pubkey); +export const isOwner = (pubkey: string): boolean => accessList.isOwner(pubkey); +export const allowedPubkeys = (): string[] => accessList.keys(); diff --git a/docker/gashboard/apps/api/src/nostr/nip98.ts b/docker/gashboard/apps/api/src/nostr/nip98.ts new file mode 100644 index 00000000..489f3824 --- /dev/null +++ b/docker/gashboard/apps/api/src/nostr/nip98.ts @@ -0,0 +1,69 @@ +import { verifyEvent, type Event as NostrEvent } from "nostr-tools"; + +export type Nip98Input = { + authHeader: string | undefined; + method: string; + fullUrl: string; +}; + +export type Nip98Result = + | { ok: true; pubkey: string } + | { ok: false; reason: string }; + +const KIND_HTTP_AUTH = 27235; +const FRESHNESS_WINDOW_S = 120; + +function findTag(event: NostrEvent, name: string): string | undefined { + for (const tag of event.tags) { + if (tag[0] === name && typeof tag[1] === "string") return tag[1]; + } + return undefined; +} + +function timingSafeEqualStr(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} + +export function verifyNip98(input: Nip98Input): Nip98Result { + const { authHeader, method, fullUrl } = input; + + if (!authHeader) return { ok: false, reason: "missing_authorization" }; + const match = authHeader.match(/^Nostr\s+(.+)$/i); + if (!match) return { ok: false, reason: "wrong_scheme" }; + const b64 = match[1]!.trim(); + + let event: NostrEvent; + try { + const json = Buffer.from(b64, "base64").toString("utf8"); + event = JSON.parse(json) as NostrEvent; + } catch { + return { ok: false, reason: "decode_failed" }; + } + + if (event.kind !== KIND_HTTP_AUTH) return { ok: false, reason: "wrong_kind" }; + if (!verifyEvent(event)) return { ok: false, reason: "bad_signature" }; + + const nowS = Math.floor(Date.now() / 1000); + if (Math.abs(nowS - event.created_at) > FRESHNESS_WINDOW_S) { + return { ok: false, reason: "stale" }; + } + + const tagMethod = findTag(event, "method"); + const tagUrl = findTag(event, "u"); + if (!tagMethod || !tagUrl) return { ok: false, reason: "missing_tags" }; + + if (!timingSafeEqualStr(tagMethod.toUpperCase(), method.toUpperCase())) { + return { ok: false, reason: "method_mismatch" }; + } + if (!timingSafeEqualStr(tagUrl, fullUrl)) { + return { ok: false, reason: "url_mismatch" }; + } + if (!/^[0-9a-f]{64}$/.test(event.pubkey)) { + return { ok: false, reason: "malformed_pubkey" }; + } + + return { ok: true, pubkey: event.pubkey }; +} diff --git a/docker/gashboard/apps/api/src/server.ts b/docker/gashboard/apps/api/src/server.ts new file mode 100644 index 00000000..69efa037 --- /dev/null +++ b/docker/gashboard/apps/api/src/server.ts @@ -0,0 +1,124 @@ +import express from "express"; +import helmet from "helmet"; +import cors from "cors"; +import { pinoHttp } from "pino-http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import fs from "node:fs"; + +import { accessRouter } from "./access/routes.js"; +import { config } from "./config.js"; +import { logger } from "./logger.js"; +import { authRouter } from "./auth/routes.js"; +import { chatRouter } from "./chat/routes.js"; +import { datumRouter } from "./datum/routes.js"; +import { errorHandler } from "./errors.js"; + +export function buildApp() { + const app = express(); + app.disable("x-powered-by"); + app.set("trust proxy", 1); + + app.use( + helmet({ + contentSecurityPolicy: { + useDefaults: true, + directives: { + "default-src": ["'self'"], + "script-src": ["'self'"], + "style-src": ["'self'", "'unsafe-inline'"], + "img-src": ["'self'", "data:", "https:"], + "connect-src": ["'self'", "wss://relay.primal.net"], + "font-src": ["'self'", "data:"], + "manifest-src": ["'self'"], + "worker-src": ["'self'"], + "frame-ancestors": ["'none'"], + // The canonical provider validates the same-host dashboard broker. + "frame-src": config.providerPath ? ["'self'", "http:", "https:"] : ["'self'"], + "upgrade-insecure-requests": null, + }, + }, + crossOriginEmbedderPolicy: false, + crossOriginOpenerPolicy: false, + originAgentCluster: false, + }), + ); + + if (config.corsOrigin) { + app.use(cors({ origin: config.corsOrigin, credentials: false })); + } + + app.use(express.json({ limit: "32kb" })); + app.use( + pinoHttp({ + logger, + autoLogging: { + ignore: (req) => + req.url === "/healthz" || + req.url === "/favicon.ico" || + req.url.startsWith("/assets/"), + }, + }), + ); + + app.get("/healthz", (_req, res) => { + res.json({ ok: true }); + }); + app.use("/api/auth", authRouter); + app.use("/api/access", accessRouter); + app.use("/api/chat", chatRouter); + app.use("/api/datum", datumRouter); + + app.get("/nostr-provider.js", (_req, res) => { + res.setHeader("Cache-Control", "no-cache, no-store"); + const provider = config.providerPath && (fs.existsSync(config.providerPath) ? config.providerPath : "/app/nostr-provider.js"); + if (!provider || !fs.existsSync(provider)) { + res.status(503).type("application/javascript").send("/* Archipelago signer is not installed. */"); + return; + } + res.type("application/javascript").send("if (!window.nostr) {\n" + fs.readFileSync(provider, "utf8") + "\n}"); + }); + // Unknown API paths must never become a successful HTML SPA response. + app.use("/api", (_req, res) => { res.status(404).json({ error: { code: "not_found" } }); }); + + const staticDir = config.staticDir + ? config.staticDir + : resolveDefaultStaticDir(); + + if (staticDir && fs.existsSync(staticDir)) { + logger.info({ staticDir }, "serving web assets"); + app.use( + express.static(staticDir, { + index: false, + maxAge: "1h", + setHeaders: (res, filePath) => { + if (filePath.endsWith("sw.js") || filePath.endsWith("manifest.webmanifest")) { + res.setHeader("Cache-Control", "no-cache"); + } + }, + }), + ); + app.get(/.*/, (_req, res, next) => { + const indexFile = path.join(staticDir, "index.html"); + if (!fs.existsSync(indexFile)) return next(); + res.setHeader("Cache-Control", "no-cache"); + if (config.providerPath) { + const html = fs.readFileSync(indexFile, "utf8"); + res.type("html").send(html.replace("</head>", + '<script src="/nostr-provider.js?v=archipelago-v1" data-app-id="gashboard" data-no-nip98></script></head>')); + } else { + res.sendFile(indexFile); + } + }); + } else { + logger.warn({ staticDir }, "web assets directory not found"); + } + + app.use(errorHandler); + return app; +} + +function resolveDefaultStaticDir(): string { + const here = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(here, "../../web/dist"); +} diff --git a/docker/gashboard/apps/api/tests/access.test.ts b/docker/gashboard/apps/api/tests/access.test.ts new file mode 100644 index 00000000..a8eb5809 --- /dev/null +++ b/docker/gashboard/apps/api/tests/access.test.ts @@ -0,0 +1,88 @@ +import { after, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { once } from "node:events"; +import { finalizeEvent, generateSecretKey, getPublicKey, nip19 } from "nostr-tools"; +import { AccessList } from "../src/access/store.js"; + +const dir = mkdtempSync(join(tmpdir(), "gashboard-access-")); +after(() => rmSync(dir, { recursive: true, force: true })); +const owner = generateSecretKey(); +const viewer = generateSecretKey(); +const outsider = generateSecretKey(); +const ownerHex = getPublicKey(owner); +const viewerHex = getPublicKey(viewer); +const viewerNpub = nip19.npubEncode(viewerHex); + +test("membership survives restart; owners cannot be removed and corruption fails closed", () => { + const file = join(dir, "store.json"); + const store = new AccessList(file, [ownerHex], []); + store.setViewer(viewerHex, true); + assert.equal(new AccessList(file, [ownerHex], []).isAllowed(viewerHex), true); + assert.throws(() => store.setViewer(ownerHex, false)); + store.setViewer(viewerHex, false); + assert.equal(new AccessList(file, [ownerHex], []).isAllowed(viewerHex), false); + // Removed platform owners are not retained in the persisted viewer list. + assert.equal(new AccessList(file, [], []).isAllowed(ownerHex), false); + writeFileSync(file, "{}"); + assert.throws(() => new AccessList(file, [ownerHex], [])); +}); + +test("signed logins, owner-only invitations, immediate revocation, and provider cache policy", async () => { + process.env.JWT_SECRET = "local-test-only-secret-32-characters-long"; + process.env.DATUM_ADMIN_PASSWORD = "local-test-only"; + process.env.NOSTR_OWNER_PUBKEYS = ownerHex; + process.env.ACCESS_LIST_PATH = join(dir, "api-access.json"); + process.env.ARCHIPELAGO_PROVIDER_PATH = join(dir, "provider.js"); + process.env.LOG_LEVEL = "silent"; + writeFileSync(process.env.ARCHIPELAGO_PROVIDER_PATH, "window.nostr = { test: true };"); + const { buildApp } = await import("../src/server.js"); + const server = buildApp().listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + const base = `http://127.0.0.1:${address.port}`; + const call = (path: string, token = "", method = "GET", body?: unknown) => fetch(base + path, { + method, + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + async function login(key: Uint8Array) { + const event = finalizeEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), tags: [["u", base + "/api/auth/login"], ["method", "POST"]], content: "" }, key); + return fetch(base + "/api/auth/login", { method: "POST", headers: { authorization: "Nostr " + Buffer.from(JSON.stringify(event)).toString("base64") } }); + } + try { + assert.equal((await call("/api/access")).status, 401); + assert.equal((await login(outsider)).status, 403); + const ownerLogin = await login(owner); + assert.equal(ownerLogin.status, 200); + const ownerToken = (await ownerLogin.json()).token as string; + assert.equal((await call("/api/access", ownerToken, "POST", { npub: "not-a-key" })).status, 400); + assert.equal((await call("/api/access", ownerToken, "POST", { npub: viewerNpub })).status, 200); + const viewerLogin = await login(viewer); + assert.equal(viewerLogin.status, 200); + const viewerToken = (await viewerLogin.json()).token as string; + assert.equal((await call("/api/datum/stats", viewerToken)).status, 200); + assert.equal((await call("/api/access", viewerToken, "POST", { npub: nip19.npubEncode(getPublicKey(outsider)) })).status, 403); + assert.deepEqual(await (await call("/api/access", viewerToken)).json(), { isOwner: false, members: [] }); + assert.equal((await call(`/api/access/${nip19.npubEncode(ownerHex)}`, ownerToken, "DELETE")).status, 400); + const stream = await call("/api/datum/stream", viewerToken); + const reader = stream.body!.getReader(); + assert.equal((await reader.read()).done, false); + assert.equal((await call(`/api/access/${viewerNpub}`, ownerToken, "DELETE")).status, 200); + assert.equal((await reader.read()).done, true); + assert.equal((await call("/api/datum/stats", viewerToken)).status, 401); + assert.equal((await call("/api/chat", viewerToken)).status, 401); + assert.equal((await login(viewer)).status, 403); + assert.deepEqual(JSON.parse(readFileSync(process.env.ACCESS_LIST_PATH, "utf8")), []); + const provider = await call("/nostr-provider.js"); + assert.equal(provider.headers.get("cache-control"), "no-cache, no-store"); + assert.match(await provider.text(), /if \(!window.nostr\)/); + assert.equal((await call("/api/not-real")).status, 404); + } finally { + server.closeAllConnections(); + await new Promise<void>(resolve => server.close(() => resolve())); + } +}); diff --git a/docker/gashboard/apps/api/tests/parse.test.ts b/docker/gashboard/apps/api/tests/parse.test.ts new file mode 100644 index 00000000..a7d0c943 --- /dev/null +++ b/docker/gashboard/apps/api/tests/parse.test.ts @@ -0,0 +1,15 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseClientsHtml } from "../src/datum/parse.js"; + +test("multiple miners of one model keep their worker names and full identities", () => { + const row = (username: string, agent: string) => "<tr>" + + ["0", "192.0.2.3", username, "Subscribed", "2 s", "16384", "32768 (2)", "0 (0) 0%", "1.2 Th/s (1 s)", "", agent] + .map(value => `<td>${value}</td>`).join("") + "</tr>"; + const miners = parseClientsHtml('<table><tr><th>Auth Username</th></tr>' + + row("addressA.kitchen", "bitaxe/BM1368") + row("addressA.garage", "bitaxe/BM1368") + row("addressB.kitchen", "unknown") + '</table>'); + assert.equal(miners.length, 3); + assert.deepEqual(miners.map(m => m.nickname), ["kitchen", "garage", "kitchen"]); + assert.equal(new Set(miners.map(m => m.authUsername)).size, 3); + assert.ok(miners.every(m => m.location === "unassigned")); +}); diff --git a/docker/gashboard/apps/api/tsconfig.json b/docker/gashboard/apps/api/tsconfig.json new file mode 100644 index 00000000..d9807a5e --- /dev/null +++ b/docker/gashboard/apps/api/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "declaration": false, + "sourceMap": true + }, + "include": ["src/**/*.ts"] +} diff --git a/docker/gashboard/apps/web/index.html b/docker/gashboard/apps/web/index.html new file mode 100644 index 00000000..4d29fdf1 --- /dev/null +++ b/docker/gashboard/apps/web/index.html @@ -0,0 +1,20 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <meta name="theme-color" content="#0a0e1a" /> + <meta name="application-name" content="gashboard" /> + <meta name="apple-mobile-web-app-title" content="gashboard" /> + <meta name="apple-mobile-web-app-capable" content="yes" /> + <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> + <link rel="manifest" href="/manifest.webmanifest" /> + <link rel="icon" href="/icons/icon.svg" type="image/svg+xml" /> + <link rel="apple-touch-icon" href="/icons/icon.svg" /> + <title>gashboard + + +
+ + + diff --git a/docker/gashboard/apps/web/package.json b/docker/gashboard/apps/web/package.json new file mode 100644 index 00000000..da0a8a54 --- /dev/null +++ b/docker/gashboard/apps/web/package.json @@ -0,0 +1,26 @@ +{ + "name": "@gashboard/web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "applesauce-accounts": "5.1.0", + "applesauce-signers": "5.1.0", + "nostr-tools": "2.10.4", + "pinia": "2.3.0", + "vue": "3.5.13", + "vue-router": "4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "5.2.1", + "typescript": "5.7.2", + "vite": "6.0.3", + "vue-tsc": "2.1.10" + } +} diff --git a/docker/gashboard/apps/web/public/icons/icon.svg b/docker/gashboard/apps/web/public/icons/icon.svg new file mode 100644 index 00000000..d6ec4360 --- /dev/null +++ b/docker/gashboard/apps/web/public/icons/icon.svg @@ -0,0 +1,7 @@ + + + + + + G + diff --git a/docker/gashboard/apps/web/public/icons/maskable.svg b/docker/gashboard/apps/web/public/icons/maskable.svg new file mode 100644 index 00000000..242e9b7b --- /dev/null +++ b/docker/gashboard/apps/web/public/icons/maskable.svg @@ -0,0 +1,7 @@ + + + + + + G + diff --git a/docker/gashboard/apps/web/public/manifest.webmanifest b/docker/gashboard/apps/web/public/manifest.webmanifest new file mode 100644 index 00000000..a19024ed --- /dev/null +++ b/docker/gashboard/apps/web/public/manifest.webmanifest @@ -0,0 +1,34 @@ +{ + "name": "gashboard", + "short_name": "gashboard", + "description": "Solo mining dashboard for Datum miners.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#07090f", + "theme_color": "#0a0e1a", + "categories": ["utilities", "finance"], + "icons": [ + { + "src": "/icons/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/icons/maskable.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "maskable" + } + ], + "shortcuts": [ + { + "name": "Graphs", + "short_name": "Graphs", + "url": "/graphs", + "description": "Open telemetry graphs" + } + ] +} diff --git a/docker/gashboard/apps/web/public/miners/avalon-mini-3.svg b/docker/gashboard/apps/web/public/miners/avalon-mini-3.svg new file mode 100644 index 00000000..a08c9ec6 --- /dev/null +++ b/docker/gashboard/apps/web/public/miners/avalon-mini-3.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/docker/gashboard/apps/web/public/miners/avalon-nano-3.svg b/docker/gashboard/apps/web/public/miners/avalon-nano-3.svg new file mode 100644 index 00000000..8689c6ff --- /dev/null +++ b/docker/gashboard/apps/web/public/miners/avalon-nano-3.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/docker/gashboard/apps/web/public/miners/bigpapa-bitaxe.svg b/docker/gashboard/apps/web/public/miners/bigpapa-bitaxe.svg new file mode 100644 index 00000000..80c3cb35 --- /dev/null +++ b/docker/gashboard/apps/web/public/miners/bigpapa-bitaxe.svg @@ -0,0 +1,22 @@ + + + + + + + + + BIG + + + + + + + BIGPAPA BITAXE + + + + + + diff --git a/docker/gashboard/apps/web/public/miners/bitaxe.svg b/docker/gashboard/apps/web/public/miners/bitaxe.svg new file mode 100644 index 00000000..88731d69 --- /dev/null +++ b/docker/gashboard/apps/web/public/miners/bitaxe.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/docker/gashboard/apps/web/public/miners/boomer-heater.svg b/docker/gashboard/apps/web/public/miners/boomer-heater.svg new file mode 100644 index 00000000..ec62e46f --- /dev/null +++ b/docker/gashboard/apps/web/public/miners/boomer-heater.svg @@ -0,0 +1,12 @@ + + + + + + + + + NO HASHES. JUST GHEI. + + + diff --git a/docker/gashboard/apps/web/public/miners/nerdqaxe.svg b/docker/gashboard/apps/web/public/miners/nerdqaxe.svg new file mode 100644 index 00000000..8f0ac275 --- /dev/null +++ b/docker/gashboard/apps/web/public/miners/nerdqaxe.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/docker/gashboard/apps/web/public/stickers/anon-miner.svg b/docker/gashboard/apps/web/public/stickers/anon-miner.svg new file mode 100644 index 00000000..664bf610 --- /dev/null +++ b/docker/gashboard/apps/web/public/stickers/anon-miner.svg @@ -0,0 +1,9 @@ + + + + + + + + ANON FOUND HASHES + diff --git a/docker/gashboard/apps/web/public/stickers/block-denied.svg b/docker/gashboard/apps/web/public/stickers/block-denied.svg new file mode 100644 index 00000000..59313a1c --- /dev/null +++ b/docker/gashboard/apps/web/public/stickers/block-denied.svg @@ -0,0 +1,8 @@ + + + + + BLOCK + DENIED + TRY ANOTHER 2 BILLION TIMES + diff --git a/docker/gashboard/apps/web/public/stickers/block-hopium.svg b/docker/gashboard/apps/web/public/stickers/block-hopium.svg new file mode 100644 index 00000000..8e6f7bec --- /dev/null +++ b/docker/gashboard/apps/web/public/stickers/block-hopium.svg @@ -0,0 +1,10 @@ + + + + + + ? + + + THIS ONE IS OURS + diff --git a/docker/gashboard/apps/web/public/stickers/boomer-unit.svg b/docker/gashboard/apps/web/public/stickers/boomer-unit.svg new file mode 100644 index 00000000..42c2e326 --- /dev/null +++ b/docker/gashboard/apps/web/public/stickers/boomer-unit.svg @@ -0,0 +1,7 @@ + + + + + + 0 H/s AND PROUD + diff --git a/docker/gashboard/apps/web/public/stickers/chad-hash.svg b/docker/gashboard/apps/web/public/stickers/chad-hash.svg new file mode 100644 index 00000000..f6f87c8e --- /dev/null +++ b/docker/gashboard/apps/web/public/stickers/chad-hash.svg @@ -0,0 +1,8 @@ + + + + + + + HASHING. UNBOTHERED. + diff --git a/docker/gashboard/apps/web/public/stickers/chart-crime.svg b/docker/gashboard/apps/web/public/stickers/chart-crime.svg new file mode 100644 index 00000000..6710ab03 --- /dev/null +++ b/docker/gashboard/apps/web/public/stickers/chart-crime.svg @@ -0,0 +1,8 @@ + + + + + + + CHART CRIME + diff --git a/docker/gashboard/apps/web/public/stickers/fan-panic.svg b/docker/gashboard/apps/web/public/stickers/fan-panic.svg new file mode 100644 index 00000000..49e59d87 --- /dev/null +++ b/docker/gashboard/apps/web/public/stickers/fan-panic.svg @@ -0,0 +1,10 @@ + + + + + + + + + FAN PANIC + diff --git a/docker/gashboard/apps/web/public/stickers/fiat-npc.svg b/docker/gashboard/apps/web/public/stickers/fiat-npc.svg new file mode 100644 index 00000000..07e0021c --- /dev/null +++ b/docker/gashboard/apps/web/public/stickers/fiat-npc.svg @@ -0,0 +1,10 @@ + + + + + + + + I TRUST BANKS + NORMAL HEATER USER + diff --git a/docker/gashboard/apps/web/public/stickers/heat-death.svg b/docker/gashboard/apps/web/public/stickers/heat-death.svg new file mode 100644 index 00000000..265bf372 --- /dev/null +++ b/docker/gashboard/apps/web/public/stickers/heat-death.svg @@ -0,0 +1,9 @@ + + + + + + + + PROOF OF WARMTH + diff --git a/docker/gashboard/apps/web/public/stickers/money-printer.svg b/docker/gashboard/apps/web/public/stickers/money-printer.svg new file mode 100644 index 00000000..8abeae8e --- /dev/null +++ b/docker/gashboard/apps/web/public/stickers/money-printer.svg @@ -0,0 +1,8 @@ + + + + + + BRRR + FIAT PATCH NOTES + diff --git a/docker/gashboard/apps/web/public/stickers/odds-wizard.svg b/docker/gashboard/apps/web/public/stickers/odds-wizard.svg new file mode 100644 index 00000000..f622058a --- /dev/null +++ b/docker/gashboard/apps/web/public/stickers/odds-wizard.svg @@ -0,0 +1,9 @@ + + + + + + + + THE ODDS SAID LOL + diff --git a/docker/gashboard/apps/web/public/stickers/printer-loop.svg b/docker/gashboard/apps/web/public/stickers/printer-loop.svg new file mode 100644 index 00000000..ee8d477c --- /dev/null +++ b/docker/gashboard/apps/web/public/stickers/printer-loop.svg @@ -0,0 +1,11 @@ + + + + + + + FIAT + + BRRR + INFINITE PATCH + diff --git a/docker/gashboard/apps/web/public/sw.js b/docker/gashboard/apps/web/public/sw.js new file mode 100644 index 00000000..a040778b --- /dev/null +++ b/docker/gashboard/apps/web/public/sw.js @@ -0,0 +1,95 @@ +const CACHE_VERSION = "gashboard-pwa-v4"; +const APP_SHELL = ["/", "/manifest.webmanifest", "/icons/icon.svg", "/icons/maskable.svg"]; +const MINER_ASSETS = [ + "/miners/nerdqaxe.svg", + "/miners/bitaxe.svg", + "/miners/bigpapa-bitaxe.svg", + "/miners/avalon-nano-3.svg", + "/miners/avalon-mini-3.svg", + "/miners/boomer-heater.svg", +]; +const STICKER_ASSETS = [ + "/stickers/anon-miner.svg", + "/stickers/block-denied.svg", + "/stickers/block-hopium.svg", + "/stickers/boomer-unit.svg", + "/stickers/chad-hash.svg", + "/stickers/chart-crime.svg", + "/stickers/fan-panic.svg", + "/stickers/fiat-npc.svg", + "/stickers/heat-death.svg", + "/stickers/money-printer.svg", + "/stickers/odds-wizard.svg", + "/stickers/printer-loop.svg", +]; + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(CACHE_VERSION).then((cache) => cache.addAll([...APP_SHELL, ...MINER_ASSETS, ...STICKER_ASSETS]))); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => Promise.all(keys.filter((key) => key !== CACHE_VERSION).map((key) => caches.delete(key)))) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener("fetch", (event) => { + const req = event.request; + if (req.method !== "GET") return; + + const url = new URL(req.url); + if (url.origin !== self.location.origin) return; + if (url.pathname === "/nostr-provider.js" || url.pathname === "/nostr-signer") return; + if (url.pathname.startsWith("/api/") || url.pathname === "/healthz") return; + + if (req.mode === "navigate") { + event.respondWith(networkFirst(req, "/")); + return; + } + + if ( + url.pathname.startsWith("/assets/") + || url.pathname.startsWith("/icons/") + || url.pathname.startsWith("/miners/") + || url.pathname.startsWith("/stickers/") + ) { + event.respondWith(cacheFirst(req)); + return; + } + + event.respondWith(networkFirst(req)); +}); + +self.addEventListener("message", (event) => { + if (event.data?.type === "SKIP_WAITING") self.skipWaiting(); +}); + +async function cacheFirst(req) { + const cache = await caches.open(CACHE_VERSION); + const cached = await cache.match(req); + if (cached) return cached; + const res = await fetch(req); + if (res.ok) cache.put(req, res.clone()); + return res; +} + +async function networkFirst(req, fallbackPath) { + const cache = await caches.open(CACHE_VERSION); + try { + const res = await fetch(req); + if (res.ok) cache.put(req, res.clone()); + return res; + } catch { + const cached = await cache.match(req); + if (cached) return cached; + if (fallbackPath) { + const fallback = await cache.match(fallbackPath); + if (fallback) return fallback; + } + throw new Error("offline"); + } +} diff --git a/docker/gashboard/apps/web/src/App.vue b/docker/gashboard/apps/web/src/App.vue new file mode 100644 index 00000000..14c5cea7 --- /dev/null +++ b/docker/gashboard/apps/web/src/App.vue @@ -0,0 +1,230 @@ + + + + + diff --git a/docker/gashboard/apps/web/src/chatStickers.ts b/docker/gashboard/apps/web/src/chatStickers.ts new file mode 100644 index 00000000..3f1299d9 --- /dev/null +++ b/docker/gashboard/apps/web/src/chatStickers.ts @@ -0,0 +1,143 @@ +export type ChatSticker = { + label: string; + text: string; + kind?: "sticker" | "gif"; + image?: string; +}; + +export const STICKER_PREFIX = "[gashboard sticker]"; +const GIF_MARKER = "[gif]"; +const IMAGE_MARKER = "[img:"; + +export const CHAT_STICKERS: ChatSticker[] = [ + { label: "fiat heater", text: "FIAT HEATER DETECTED: warm room, cold wallet." }, + { label: "no hashes", text: "NO HASHES. JUST GHEI. just fiat and a pension." }, + { label: "cope block", text: "BLOCK FOUND: not by us, obviously. carry on pretending." }, + { label: "desk miner", text: "DESK MINER ENERGY: tiny fan, enormous delusion." }, + { label: "pool bully", text: "BIG POOL ENTERED CHAT: scaled up to not shame the children." }, + { label: "paper hands", text: "PAPER HANDS, PLASTIC HEATER, ZERO SATS." }, + { label: "nonce cult", text: "NONCE CULT MEETING: attendance low, conviction insane." }, + { label: "fiat friend", text: "YOUR FIAT FRIEND THINKS THE HEATER IS AN INVESTMENT." }, + { label: "warm tonight", text: "SOMEONE'S GETTING WARMED TONIGHT. maybe not paid." }, + { label: "difficulty", text: "DIFFICULTY ADJUSTMENT: reality filed another complaint." }, + { label: "best calc", text: "BEST CALC: almost important, spiritually devastating." }, + { label: "hash high", text: "HASHRATE UP. ODDS STILL NEED A TELESCOPE." }, + { label: "pension", text: "JUST FIAT AND A PENSION: the saddest proof-of-warmth." }, + { label: "sovereign", text: "SOVEREIGN MINER: no block, no boss, no apology." }, + { label: "amber", text: "SIGNED BY A REAL SIGNER, UNLIKE YOUR BOOMER'S DIRECT DEBIT." }, + { label: "mempool", text: "MEMPOOL SAYS NO. DASHBOARD SAYS LOL." }, + { label: "reject", text: "REJECTED SHARE: even the server had standards." }, + { label: "bigpapa", text: "BIGPAPA IS WATCHING. probably collecting fuck all." }, + { label: "lottery", text: "SOLO LOTTERY TICKET PURCHASED WITH ELECTRICITY AND DENIAL." }, + { label: "fiat people", text: "FIAT PEOPLE SEE A HEATER. MINERS SEE A COWARD." }, + { label: "gif cope", kind: "gif", text: "LIVE FOOTAGE OF ME PRETENDING THE ODDS ARE FINE." }, + { label: "gif panic", kind: "gif", text: "THE HASHRATE MOVED 0.01 TH/S AND I HAVE BECOME UNBEARABLE." }, + { label: "gif refresh", kind: "gif", text: "REFRESHING THE DASHBOARD LIKE IT OWES ME MONEY." }, + { label: "gif chart", kind: "gif", text: "GRAPH GOES UP. BRAIN GOES OFF." }, + { label: "gif no block", kind: "gif", text: "NO BLOCK? MUST BE A CONSPIRACY AGAINST MY USB PORT." }, + { label: "gif office", kind: "gif", text: "CORPORATE NEEDS YOU TO FIND THE DIFFERENCE: HEATER / MINER." }, + { label: "gif maths", kind: "gif", text: "ME EXPLAINING 1 IN 2 BILLION LIKE IT IS A PLAN." }, + { label: "gif deploy", kind: "gif", text: "DEPLOYED TO PRODUCTION. MAY GOD REVIEW THE DIFF." }, + { label: "gif smug", kind: "gif", text: "I MINE SOLO, WHICH IS HOW YOU KNOW I AM DIFFICULT AT DINNER." }, + { label: "gif invoice", kind: "gif", text: "ELECTRIC BILL ARRIVED. IT IS NOW A WHITEPAPER." }, + { label: "ngmi heater", text: "NGMI: NOT GENERATING MINING INCOME." }, + { label: "printer money", text: "FIAT PRINTER WENT BRRR. MINER WENT PLEASE NOTICE ME." }, + { label: "normie mode", text: "NORMIE MODE: PAYING FOR HEAT WITHOUT EVEN ATTEMPTING A BLOCK." }, + { label: "bullish fan", text: "LOUD FAN, QUIET BALANCE, MAXIMUM CONVICTION." }, + { label: "chart crime", text: "THIS CHART IS A CRIME SCENE WITH AXES." }, + { label: "share cope", text: "ACCEPTED SHARE: A PARTICIPATION TROPHY WITH VOLTAGE." }, + { label: "stack sats", text: "STACKING SATS? NO. STACKING RECEIPTS FROM THE POWER COMPANY." }, + { label: "main character", text: "MAIN CHARACTER ENERGY, BACKGROUND HASHRATE." }, + { label: "block soon", text: "BLOCK SOON. SOURCE: THE SAME PART OF MY BRAIN THAT BUYS TOPS." }, + { label: "heater max", text: "HEATER ON MAX. HASHES ON VACATION." }, + { + label: "anon miner", + text: "ANON DISCOVERS SOLO MINING AND IMMEDIATELY BECOMES A PROBLEM.", + image: "/stickers/anon-miner.svg", + }, + { + label: "fiat npc", + text: "I JUST USE A NORMAL HEATER BECAUSE THE BANK SAID IT WAS FINE.", + image: "/stickers/fiat-npc.svg", + }, + { + label: "chad hash", + text: "LOUD, WARM, WRONG, AND STILL MORE SOVEREIGN THAN YOUR SAVINGS ACCOUNT.", + image: "/stickers/chad-hash.svg", + }, + { + label: "money printer", + text: "CENTRAL BANK MONETARY POLICY, ARTIST'S IMPRESSION.", + image: "/stickers/money-printer.svg", + }, + { + label: "block denied", + text: "THE NETWORK REVIEWED YOUR NONCE AND CHOSE VIOLENCE.", + image: "/stickers/block-denied.svg", + }, + { + label: "boomer unit", + text: "LEGACY HEATER ACHIEVES ZERO HASHES PER RETIREMENT.", + image: "/stickers/boomer-unit.svg", + }, + { + label: "chart crime", + text: "TECHNICAL ANALYSIS CONDUCTED BY A FAN WITH A WALLET.", + image: "/stickers/chart-crime.svg", + }, + { + label: "odds wizard", + text: "I HAVE CONSULTED THE ODDS AND THEY SAID LOL.", + image: "/stickers/odds-wizard.svg", + }, + { + label: "gif fan panic", + kind: "gif", + text: "LIVE FAN FOOTAGE AFTER HASHRATE MOVES BY A DECIMAL.", + image: "/stickers/fan-panic.svg", + }, + { + label: "gif printer", + kind: "gif", + text: "FIAT POLICY IN ONE LOOP.", + image: "/stickers/printer-loop.svg", + }, + { + label: "gif block hopium", + kind: "gif", + text: "SOLO MINER WATCHING EVERY NEW BLOCK LIKE IT OWES HIM MONEY.", + image: "/stickers/block-hopium.svg", + }, + { + label: "gif heat death", + kind: "gif", + text: "THE ROOM IS WARMER THAN THE INVESTMENT THESIS.", + image: "/stickers/heat-death.svg", + }, +]; + +export function stickerContent(sticker: ChatSticker): string { + const image = sticker.image ? `${IMAGE_MARKER}${sticker.image}] ` : ""; + return `${STICKER_PREFIX} ${image}${sticker.kind === "gif" ? `${GIF_MARKER} ` : ""}${sticker.text}`; +} + +export function unsticker( + content: string, +): { text: string; kind: "sticker" | "gif"; image?: string } | null { + if (!content.startsWith(`${STICKER_PREFIX} `)) return null; + let body = content.slice(STICKER_PREFIX.length + 1); + let image: string | undefined; + if (body.startsWith(IMAGE_MARKER)) { + const end = body.indexOf("] "); + if (end > IMAGE_MARKER.length) { + image = body.slice(IMAGE_MARKER.length, end); + body = body.slice(end + 2); + } + } + if (body.startsWith(`${GIF_MARKER} `)) { + const parsed = { text: body.slice(GIF_MARKER.length + 1), kind: "gif" as const }; + return image ? { ...parsed, image } : parsed; + } + const parsed = { text: body, kind: "sticker" as const }; + return image ? { ...parsed, image } : parsed; +} diff --git a/docker/gashboard/apps/web/src/components/BlockCelebration.vue b/docker/gashboard/apps/web/src/components/BlockCelebration.vue new file mode 100644 index 00000000..5cb453aa --- /dev/null +++ b/docker/gashboard/apps/web/src/components/BlockCelebration.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/docker/gashboard/apps/web/src/components/ChatDrawer.vue b/docker/gashboard/apps/web/src/components/ChatDrawer.vue new file mode 100644 index 00000000..d308761d --- /dev/null +++ b/docker/gashboard/apps/web/src/components/ChatDrawer.vue @@ -0,0 +1,768 @@ + + +