From 36c298b118389f0433500e236687dc503d79cb76 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 16 Mar 2026 13:12:02 +0100 Subject: [PATCH] implement nip-66 add default-ON option to always add three random public relays, when publishing add relay-type info to the relay selection box run a cronjob for relay monitoring on the server --- Dockerfile | 15 +- docker-compose.prod.yml | 31 ++- docker-entrypoint.sh | 9 + docs/NIP66-MONITOR-SECURITY.md | 30 +++ nip66-cron/Dockerfile | 12 + nip66-cron/index.mjs | 191 +++++++++++++++ nip66-cron/package-lock.json | 141 +++++++++++ nip66-cron/package.json | 14 ++ .../PostEditor/PostRelaySelector.tsx | 18 +- src/components/RelayIcon/index.tsx | 6 +- src/components/RelayInfo/index.tsx | 132 +++++++++- src/constants.ts | 28 ++- src/i18n/locales/de.ts | 29 +++ src/i18n/locales/en.ts | 29 +++ src/main.tsx | 35 ++- .../DiscussionsPage/CreateThreadDialog.tsx | 23 +- .../secondary/GeneralSettingsPage/index.tsx | 20 +- src/providers/UserPreferencesProvider.tsx | 17 +- src/services/client.service.ts | 56 ++++- src/services/indexed-db.service.ts | 106 +++++++- src/services/local-storage.service.ts | 13 + src/services/nip66-monitor.ts | 107 ++++++++ src/services/nip66.service.ts | 231 ++++++++++++++++++ src/services/relay-info.service.ts | 70 +++++- src/services/relay-selection.service.ts | 96 +++++--- src/types/index.d.ts | 18 ++ 26 files changed, 1404 insertions(+), 73 deletions(-) create mode 100644 docker-entrypoint.sh create mode 100644 docs/NIP66-MONITOR-SECURITY.md create mode 100644 nip66-cron/Dockerfile create mode 100644 nip66-cron/index.mjs create mode 100644 nip66-cron/package-lock.json create mode 100644 nip66-cron/package.json create mode 100644 src/services/nip66-monitor.ts create mode 100644 src/services/nip66.service.ts diff --git a/Dockerfile b/Dockerfile index 2fac28a5..180df1e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,8 +17,12 @@ RUN npm run build # Step 2: Final container with Nginx and embedded config FROM nginx:alpine +RUN apk add --no-cache jq + # Copy only the generated static files COPY --from=builder /app/dist /usr/share/nginx/html +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh # Embed Nginx configuration directly RUN printf "server {\n\ @@ -34,10 +38,10 @@ RUN printf "server {\n\ }\n\ \n\ location / {\n\ - # For scrapers, always serve index.html so they see static og/twitter meta tags\n\ - if (\$is_scraper = 1) {\n\ - rewrite ^ /index.html last;\n\ - }\n\ + # For scrapers, serve index.html so they see static og/twitter meta tags (skip if already requesting index.html to avoid redirect loop)\n\ + set \$rewrite_scraper \$is_scraper;\n\ + if (\$uri = /index.html) { set \$rewrite_scraper 0; }\n\ + if (\$rewrite_scraper = 1) { rewrite ^ /index.html last; }\n\ try_files \$uri \$uri/ /index.html;\n\ }\n\ \n\ @@ -56,4 +60,5 @@ RUN printf "server {\n\ EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] +# Entrypoint writes /config.json (e.g. NIP66_MONITOR_NPUB for relay info page) then starts nginx +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6f2e1347..47684676 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,5 +1,9 @@ # Minimal compose for running the published image (e.g. on remote server). # Usage: docker compose -f docker-compose.prod.yml up -d +# +# NIP-66 monitor: set NIP66_MONITOR_NSEC (and optionally NIP66_MONITOR_NPUB) in the host env or .env. +# - Cron (jumble-nip66-monitor) uses NIP66_MONITOR_NSEC to publish 30166/10166; nsec never goes to the client. +# - Set NIP66_MONITOR_NPUB (npub1... derived from the same key) so the relay info page shows the monitor's avatar and handle in the NIP-66 liveliness section. services: jumble: @@ -8,7 +12,9 @@ services: ports: - "8089:80" restart: unless-stopped - # Cap container impact: log size + optional memory limit + # Do NOT pass NIP66_MONITOR_NSEC to the web app; only npub is needed for the relay info page. + environment: + - NIP66_MONITOR_NPUB=${NIP66_MONITOR_NPUB} logging: driver: json-file options: @@ -18,3 +24,26 @@ services: resources: limits: memory: 512M + + # NIP-66 relay monitor cron: publishes 30166 (relay discovery) and 10166 (announcement). + # Starts and stops with the app. Requires NIP66_MONITOR_NSEC to do anything. + jumble-nip66-monitor: + build: + context: ./nip66-cron + dockerfile: Dockerfile + container_name: imwald-jumble-nip66-monitor + restart: unless-stopped + environment: + - NIP66_MONITOR_NSEC=${NIP66_MONITOR_NSEC} + # Optional: RELAYS_TO_MONITOR=wss://relay1,wss://relay2 (default: built-in list) + # Optional: PUBLISH_RELAYS=wss://... (default: built-in list) + # Optional: INTERVAL_MS=3600000 (default: 1 hour between 30166 runs) + logging: + driver: json-file + options: + max-size: "5m" + max-file: "2" + deploy: + resources: + limits: + memory: 128M diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 00000000..86ce5a04 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Runtime config for the SPA. NIP-66 monitor runs in a separate cron container; nsec is never sent to the client. +# Optional: NIP66_MONITOR_NPUB (npub of the monitor) can be exposed so the relay info page shows who runs the monitor. +if [ -n "$NIP66_MONITOR_NPUB" ]; then + echo "$NIP66_MONITOR_NPUB" | jq -n -R '{NIP66_MONITOR_NPUB: .}' > /usr/share/nginx/html/config.json +else + echo '{}' > /usr/share/nginx/html/config.json +fi +exec nginx -g "daemon off;" diff --git a/docs/NIP66-MONITOR-SECURITY.md b/docs/NIP66-MONITOR-SECURITY.md new file mode 100644 index 00000000..4b115801 --- /dev/null +++ b/docs/NIP66-MONITOR-SECURITY.md @@ -0,0 +1,30 @@ +# NIP-66 monitor – security audit (nsec handling) + +## Summary + +The monitor **nsec** (`NIP66_MONITOR_NSEC`) is used only in the **nip66-cron** container. It is **never** sent to the web app container, written to config.json, or exposed to the client. + +## Where the nsec may exist + +| Location | Allowed? | Notes | +|----------|----------|--------| +| **Host env** (e.g. `.env`) | ✅ | Operator sets it; not in repo. | +| **jumble-nip66-monitor container env** | ✅ | Only service that needs it. | +| **jumble container env** | ❌ | Removed: nsec is not passed to the web app. | +| **config.json** (served to browser) | ❌ | Entrypoint writes only `NIP66_MONITOR_NPUB` or `{}`; never nsec. | +| **Frontend (Window.__RUNTIME_CONFIG__)** | ❌ | Type and fetch only include `NIP66_MONITOR_NPUB`. | +| **Vite / build** | ❌ | No `VITE_NIP66_*` or nsec in bundle. | + +## Checks performed + +1. **docker-entrypoint.sh** – Writes config.json only from `NIP66_MONITOR_NPUB`; does not read or write `NIP66_MONITOR_NSEC`. +2. **docker-compose.prod.yml** – `NIP66_MONITOR_NSEC` is set only on the **jumble-nip66-monitor** service; **jumble** has only `NIP66_MONITOR_NPUB`. +3. **main.tsx** – Fetches config and types only `NIP66_MONITOR_NPUB`; no nsec in `Window.__RUNTIME_CONFIG__`. +4. **nip66-monitor.ts** (frontend) – Stub only; `getMonitorSecretKey()` always returns `null`; no env or config read for nsec. +5. **nip66-cron/index.mjs** – Reads nsec from `process.env.NIP66_MONITOR_NSEC` only; never logs it or passes it to `log()`; comment added to never log or expose it. +6. **RelayInfo / RelayLivelinessSection** – Use only `window.__RUNTIME_CONFIG__.NIP66_MONITOR_NPUB` (npub) for display. + +## Recommendation + +- Keep **NIP66_MONITOR_NSEC** only in the host env and in the **jumble-nip66-monitor** service. +- Do not add nsec to the jumble service env, config.json, or any client-exposed config. diff --git a/nip66-cron/Dockerfile b/nip66-cron/Dockerfile new file mode 100644 index 00000000..9f47c976 --- /dev/null +++ b/nip66-cron/Dockerfile @@ -0,0 +1,12 @@ +# NIP-66 monitor cron: runs alongside the app, keeps nsec on server only. +FROM node:20-alpine + +WORKDIR /app + +COPY package.json ./ +RUN npm install --omit=dev + +COPY index.mjs ./ + +ENV NODE_ENV=production +CMD ["node", "index.mjs"] diff --git a/nip66-cron/index.mjs b/nip66-cron/index.mjs new file mode 100644 index 00000000..3ffd52f1 --- /dev/null +++ b/nip66-cron/index.mjs @@ -0,0 +1,191 @@ +/** + * NIP-66 relay monitor cron. Runs on the server; nsec stays in env, never sent to client. + * - On startup: publish kind 10166 (monitor announcement) once. + * - Every INTERVAL_MS: for each relay in RELAYS_TO_MONITOR, fetch NIP-11, build & publish 30166. + * + * Env: + * NIP66_MONITOR_NSEC - required; nsec for signing 30166/10166 + * RELAYS_TO_MONITOR - optional; comma-separated wss:// URLs. Default: built-in list. + * PUBLISH_RELAYS - optional; comma-separated relays to publish to. Default: built-in list. + * INTERVAL_MS - optional; ms between full monitor runs (default 3600000 = 1h) + */ + +import { finalizeEvent, nip19 } from 'nostr-tools' +import WebSocket from 'ws' + +const RELAY_DISCOVERY_KIND = 30166 +const RELAY_MONITOR_ANNOUNCEMENT_KIND = 10166 + +const DEFAULT_RELAYS_TO_MONITOR = [ + 'wss://theforest.nostr1.com', + 'wss://orly-relay.imwald.eu', + 'wss://nostr.land', + 'wss://thecitadel.nostr1.com', + 'wss://relay.damus.io', + 'wss://relay.primal.net', + 'wss://nos.lol' +] + +const DEFAULT_PUBLISH_RELAYS = [ + 'wss://theforest.nostr1.com', + 'wss://thecitadel.nostr1.com', + 'wss://relay.damus.io', + 'wss://relay.nostr.watch' +] + +const INTERVAL_MS = Number(process.env.INTERVAL_MS) || 3600000 // 1 hour + +function log (msg, data = {}) { + const ts = new Date().toISOString() + console.log(ts, '[nip66-cron]', msg, Object.keys(data).length ? JSON.stringify(data) : '') +} + +function normalizeRelayUrl (url) { + try { + const u = url.replace(/^ws:\/\//, 'wss://') + const p = new URL(u.startsWith('wss://') ? u : `wss://${u}`) + p.pathname = p.pathname.replace(/\/+/g, '/').replace(/\/$/, '') || '/' + return p.toString() + } catch { + return url + } +} + +/** Returns decoded secret key from env. Never log or expose process.env.NIP66_MONITOR_NSEC. */ +function getSecretKey () { + const raw = process.env.NIP66_MONITOR_NSEC + if (!raw || typeof raw !== 'string') return null + try { + const { type, data } = nip19.decode(raw) + if (type !== 'nsec') return null + return data + } catch { + return null + } +} + +async function fetchNip11 (relayUrl) { + const httpUrl = relayUrl.replace(/^wss:\/\//, 'https://').replace(/^ws:\/\//, 'http://') + try { + const res = await fetch(httpUrl, { headers: { Accept: 'application/nostr+json' } }) + if (!res.ok) return null + return await res.json() + } catch (err) { + log('NIP-11 fetch failed', { url: relayUrl, err: err.message }) + return null + } +} + +function build30166 (relayUrl, nip11, sk) { + const d = normalizeRelayUrl(relayUrl) + const tags = [['d', d]] + const nips = nip11?.supported_nips + if (Array.isArray(nips)) { + for (const n of nips) tags.push(['N', String(n)]) + } + const lim = nip11?.limitation + tags.push(['R', lim?.auth_required ? 'auth' : '!auth']) + tags.push(['R', lim?.payment_required ? 'payment' : '!payment']) + const draft = { + kind: RELAY_DISCOVERY_KIND, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags + } + return finalizeEvent(draft, sk) +} + +function build10166 (sk) { + const draft = { + kind: RELAY_MONITOR_ANNOUNCEMENT_KIND, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags: [['frequency', '3600'], ['c', 'nip11'], ['c', 'ws']] + } + return finalizeEvent(draft, sk) +} + +function parseListEnv (envVar, defaultList) { + const raw = process.env[envVar] + if (!raw || typeof raw !== 'string') return defaultList + return raw.split(',').map(s => s.trim()).filter(Boolean) +} + +async function publishEvent (relayUrls, event) { + const msg = JSON.stringify(['EVENT', event]) + let ok = 0 + const conns = [] + for (const url of relayUrls) { + try { + const ws = new WebSocket(url, { handshakeTimeout: 8000 }) + await new Promise((resolve, reject) => { + ws.on('open', resolve) + ws.on('error', reject) + setTimeout(() => reject(new Error('open timeout')), 10000) + }) + conns.push(ws) + ws.send(msg) + await new Promise((resolve) => { + const onResp = (data) => { + try { + const j = JSON.parse(data.toString()) + if (j[0] === 'OK' && j[1] === event.id) { + ok++ + if (j[2] === true) { /* accepted */ } else { log('Relay rejected event', { url, reason: j[2] }) } + } + } finally { + resolve() + } + } + ws.once('message', onResp) + setTimeout(resolve, 3000) + }) + } catch (err) { + log('Publish relay error', { url, err: err.message }) + } + } + for (const ws of conns) { + try { ws.close() } catch (_) {} + } + return ok +} + +async function run10166 (sk, publishRelays) { + const event = build10166(sk) + log('Publishing 10166 (monitor announcement)') + const count = await publishEvent(publishRelays, event) + log('Published 10166', { successCount: count }) +} + +async function run30166Round (sk, relaysToMonitor, publishRelays) { + for (const relayUrl of relaysToMonitor) { + const nip11 = await fetchNip11(relayUrl) + if (!nip11) continue + const event = build30166(relayUrl, nip11, sk) + const count = await publishEvent(publishRelays, event) + log('Published 30166', { url: relayUrl, successCount: count }) + } +} + +async function main () { + const sk = getSecretKey() + if (!sk) { + log('No NIP66_MONITOR_NSEC set; exiting') + process.exit(0) + } + log('NIP-66 monitor cron started (nsec configured)') + + const relaysToMonitor = parseListEnv('RELAYS_TO_MONITOR', DEFAULT_RELAYS_TO_MONITOR) + const publishRelays = parseListEnv('PUBLISH_RELAYS', DEFAULT_PUBLISH_RELAYS) + + await run10166(sk, publishRelays) + + const run = () => run30166Round(sk, relaysToMonitor, publishRelays) + await run() + setInterval(run, INTERVAL_MS) +} + +main().catch((err) => { + console.error('[nip66-cron]', err) + process.exit(1) +}) diff --git a/nip66-cron/package-lock.json b/nip66-cron/package-lock.json new file mode 100644 index 00000000..98296c8c --- /dev/null +++ b/nip66-cron/package-lock.json @@ -0,0 +1,141 @@ +{ + "name": "jumble-nip66-cron", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jumble-nip66-cron", + "version": "1.0.0", + "dependencies": { + "nostr-tools": "^2.17.0", + "ws": "^8.18.0" + } + }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-tools": { + "version": "2.23.3", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.3.tgz", + "integrity": "sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA==", + "license": "Unlicense", + "dependencies": { + "@noble/ciphers": "2.1.1", + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0", + "@scure/bip32": "2.0.1", + "@scure/bip39": "2.0.1", + "nostr-wasm": "0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/nip66-cron/package.json b/nip66-cron/package.json new file mode 100644 index 00000000..c8bc6cce --- /dev/null +++ b/nip66-cron/package.json @@ -0,0 +1,14 @@ +{ + "name": "jumble-nip66-cron", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "NIP-66 relay monitor cron: publishes 30166/10166 from server, nsec never exposed to client", + "scripts": { + "start": "node index.mjs" + }, + "dependencies": { + "nostr-tools": "^2.17.0", + "ws": "^8.18.0" + } +} diff --git a/src/components/PostEditor/PostRelaySelector.tsx b/src/components/PostEditor/PostRelaySelector.tsx index eda3b319..cdda98d2 100644 --- a/src/components/PostEditor/PostRelaySelector.tsx +++ b/src/components/PostEditor/PostRelaySelector.tsx @@ -11,7 +11,7 @@ import { NostrEvent } from 'nostr-tools' import { Dispatch, SetStateAction, useCallback, useEffect, useState, useMemo } from 'react' import { useTranslation } from 'react-i18next' import RelayIcon from '../RelayIcon' -import relaySelectionService from '@/services/relay-selection.service' +import relaySelectionService, { type RelaySourceType } from '@/services/relay-selection.service' import { Button } from '@/components/ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet' @@ -42,6 +42,7 @@ export default function PostRelaySelector({ const { pubkey, relayList } = useNostr() const [selectedRelayUrls, setSelectedRelayUrls] = useState([]) const [selectableRelays, setSelectableRelays] = useState([]) + const [relayTypes, setRelayTypes] = useState>({}) const [description, setDescription] = useState('') const [isLoading, setIsLoading] = useState(true) const [hasManualSelection, setHasManualSelection] = useState(false) @@ -133,6 +134,7 @@ export default function PostRelaySelector({ const selectableRelaysChanged = newSelectableCount !== previousSelectableCount setSelectableRelays(result.selectableRelays) + setRelayTypes(result.relayTypes ?? {}) setPreviousSelectableCount(newSelectableCount) // Only update selected relays if: @@ -225,6 +227,7 @@ export default function PostRelaySelector({ const selectableRelaysChanged = newSelectableCount !== previousSelectableCount setSelectableRelays(result.selectableRelays) + setRelayTypes(result.relayTypes ?? {}) setPreviousSelectableCount(newSelectableCount) // Only update selected relays if: @@ -326,17 +329,24 @@ export default function PostRelaySelector({ return sortedRelays.map((url) => { const isChecked = selectedRelayUrls.includes(url) + const sourceType = relayTypes[url] + const typeLabel = sourceType ? t(`relayType_${sourceType}`) : '' return (
handleRelayCheckedChange(!isChecked, url)} > -
+
{isChecked && }
- - {simplifyUrl(url)} + + {simplifyUrl(url)} + {typeLabel && ( + + {typeLabel} + + )}
) }) diff --git a/src/components/RelayIcon/index.tsx b/src/components/RelayIcon/index.tsx index 8b64746a..c2bf9c62 100644 --- a/src/components/RelayIcon/index.tsx +++ b/src/components/RelayIcon/index.tsx @@ -15,8 +15,10 @@ export default function RelayIcon({ }) { const { relayInfo } = useFetchRelayInfo(url) const iconUrl = useMemo(() => { - if (relayInfo?.icon && typeof relayInfo.icon === 'string' && relayInfo.icon.startsWith('http')) { - return relayInfo.icon + const raw = relayInfo?.icon && typeof relayInfo.icon === 'string' ? relayInfo.icon : undefined + // Only use HTTP(S) URLs for images; reject ws(s):// (e.g. some relays return relay URL as icon) + if (raw && (raw.startsWith('https://') || raw.startsWith('http://'))) { + return raw } if (!url) return undefined try { diff --git a/src/components/RelayInfo/index.tsx b/src/components/RelayInfo/index.tsx index c9014414..2d43dc53 100644 --- a/src/components/RelayInfo/index.tsx +++ b/src/components/RelayInfo/index.tsx @@ -3,10 +3,12 @@ import { Button } from '@/components/ui/button' import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' import { useFetchRelayInfo } from '@/hooks' import { normalizeHttpUrl } from '@/lib/url' +import client from '@/services/client.service' import { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' -import { Check, Copy, GitBranch, Link, Mail, SquareCode } from 'lucide-react' -import { useState } from 'react' +import { nip66Service } from '@/services/nip66.service' +import { Check, Copy, GitBranch, Link, Mail, SquareCode, Activity } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import PostEditor from '../PostEditor' @@ -15,12 +17,31 @@ import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu' import UserAvatar from '../UserAvatar' import Username from '../Username' import RelayReviewsPreview from './RelayReviewsPreview' +import type { TNip66RelayDiscovery } from '@/types' export default function RelayInfo({ url, className }: { url: string; className?: string }) { const { t } = useTranslation() const { checkLogin } = useNostr() const { relayInfo, isFetching } = useFetchRelayInfo(url) const [open, setOpen] = useState(false) + const [discovery, setDiscovery] = useState(() => nip66Service.getDiscovery(url)) + + useEffect(() => { + setDiscovery(nip66Service.getDiscovery(url)) + let cancelled = false + nip66Service.getDiscoveryCached(url).then((cached) => { + if (!cancelled && cached) setDiscovery(cached) + }) + nip66Service.isDiscoveryStaleForRelay(url).then((stale) => { + if (cancelled) return + if (stale) { + client.fetchNip66DiscoveryForRelay(url).then(() => { + if (!cancelled) setDiscovery(nip66Service.getDiscovery(url)) + }) + } + }) + return () => { cancelled = true } + }, [url]) if (isFetching || !relayInfo) { return null @@ -102,9 +123,21 @@ export default function RelayInfo({ url, className }: { url: string; className?:
)} + {typeof window !== 'undefined' && window.__RUNTIME_CONFIG__?.NIP66_MONITOR_NPUB && ( +
+
{t('Relay monitor (NIP-66)')}
+
+ + +
+
+ )} + {discovery && ( + + )}