diff --git a/.env.dist b/.env.dist index a46d2ea..e0a99fc 100644 --- a/.env.dist +++ b/.env.dist @@ -58,5 +58,18 @@ LNBITS_URL=https://legend.lnbits.com LNBITS_API_KEY= ###< LNBits ### ###> symfony/messenger ### -MESSENGER_TRANSPORT_DSN="redis://:${REDIS_PASSWORD}@${REDIS_HOST}/messages" +MESSENGER_TRANSPORT_DSN="redis://:${REDIS_PASSWORD}@${REDIS_HOST}/devel" ###< symfony/messenger ### + +###> nostr relay ### +# Domain for relay WebSocket endpoint (use relay.your-domain.com in production) +RELAY_DOMAIN=relay.localhost +# Internal relay URL used by the Symfony app (ws:// for internal, wss:// for external) +NOSTR_DEFAULT_RELAY=ws://strfry:7777 +# Upstream relays to sync from (space-separated list, must be quoted) +RELAY_UPSTREAMS="wss://relay.snort.social wss://relay.damus.io wss://relay.nostr.band" +# Time windows for periodic sync (in days) +RELAY_DAYS_ARTICLES=7 +RELAY_DAYS_THREADS=3 +###< nostr relay ### + diff --git a/.gitignore b/.gitignore index cc80248..709b5e1 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ ###> publication ### /publication/ + +###> strfry relay ### +/infra/strfry/data/ +###< strfry relay ### + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..903a13f --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +.PHONY: help relay-build relay-up relay-down relay-prime relay-ingest-now relay-shell relay-test relay-logs + +help: ## Show this help message + @echo "Available targets:" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +relay-build: ## Build relay containers (first time only, ~10 min) + docker compose build strfry ingest + @echo "Relay containers built successfully." + +relay-up: ## Start the relay and ingest services + docker compose up -d strfry ingest + @echo "Relay services started. Check status with: docker compose ps" + +relay-down: ## Stop the relay and ingest services + docker compose stop strfry ingest + @echo "Relay services stopped." + +relay-prime: ## Run initial backfill (one-time, broader time window) + bash bin/relay/prime.sh + @echo "Relay prime/backfill completed." + +relay-ingest-now: ## Run ingest manually (useful for testing) + bash bin/relay/ingest.sh + @echo "Manual ingest completed." + +relay-shell: ## Open shell in strfry container + docker compose exec strfry sh + +relay-test: ## Run PHP smoke test against the relay + php bin/relay/test-smoke.php + +relay-logs: ## Show relay logs + docker compose logs -f strfry ingest + +relay-stats: ## Show relay statistics + docker compose exec strfry strfry db-stats + +relay-export: ## Export relay database (backup) + @echo "Exporting relay database..." + docker compose exec strfry strfry export > relay-backup-$(shell date +%Y%m%d-%H%M%S).jsonl + @echo "Export completed." + +relay-import: ## Import events from file (usage: make relay-import FILE=backup.jsonl) + @if [ -z "$(FILE)" ]; then echo "Error: FILE parameter required. Usage: make relay-import FILE=backup.jsonl"; exit 1; fi + cat $(FILE) | docker compose exec -T strfry strfry import + @echo "Import completed." + diff --git a/README.md b/README.md index c9492c4..4c96028 100644 --- a/README.md +++ b/README.md @@ -69,3 +69,31 @@ To save the encryption key: ```bash docker-compose exec php bin/console secrets:set APP_ENCRYPTION_KEY ``` + +### Nostr Relay + +The project includes a private read-only Nostr relay (powered by strfry) that acts as a local cache for long-form articles and related events. This improves performance and reduces dependency on public relays. + +**Quick Start:** + +```bash +# Start the relay +make relay-up + +# Run initial backfill +make relay-prime + +# Test it +make relay-test +``` + +For detailed documentation, see [documentation/relay.md](documentation/relay.md). + +**Key Features:** +- Read-only cache (denies client writes) +- Automatic periodic sync from upstream relays +- Caches long-form articles (NIP-23), reactions, zaps, highlights, and more +- WebSocket endpoint exposed via Caddy +- Easy backup/restore with `make relay-export` / `make relay-import` + + diff --git a/bin/relay/ingest.sh b/bin/relay/ingest.sh new file mode 100644 index 0000000..24a650d --- /dev/null +++ b/bin/relay/ingest.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "[$(date)] Starting relay ingest..." + +# Config via env or defaults +UPSTREAMS=${UPSTREAMS:-"wss://relay.snort.social wss://relay.damus.io wss://relay.nostr.band"} +DAYS_ARTICLES=${DAYS_ARTICLES:-7} +DAYS_THREADS=${DAYS_THREADS:-3} + +# These two should be programmatically generated from app DB; allow overrides: +ARTICLE_E_LIST=${ARTICLE_E_LIST:-'[]'} # e.g. ["",""] +ARTICLE_A_LIST=${ARTICLE_A_LIST:-'[]'} # e.g. ["30023::",...] + +# Helper functions for date calculation +now_ts() { date +%s; } +since_days() { + local days=$1 + if command -v date >/dev/null 2>&1; then + # Try GNU date + if date -d "-${days} days" +%s 2>/dev/null; then + return 0 + # Try BSD date (macOS) + elif date -v-${days}d +%s 2>/dev/null; then + return 0 + fi + fi + # Fallback: rough calculation + echo $(( $(date +%s) - (days * 86400) )) +} + +# Build filters using jq if available, otherwise use basic JSON +if command -v jq >/dev/null 2>&1; then + FILTER_ARTICLES=$(jq -nc --argjson kinds '[30023]' --arg since "$(since_days $DAYS_ARTICLES)" ' + {kinds:$kinds, since: ($since|tonumber)}') + + FILTER_REPLIES_E=$(jq -nc --argjson kinds '[1]' --argjson es "$ARTICLE_E_LIST" --arg since "$(since_days $DAYS_THREADS)" ' + {kinds:$kinds, "#e":$es, since: ($since|tonumber)}') + + FILTER_REPLIES_A=$(jq -nc --argjson kinds '[1]' --argjson as "$ARTICLE_A_LIST" --arg since "$(since_days $DAYS_THREADS)" ' + {kinds:$kinds, "#a":$as, since: ($since|tonumber)}') + + FILTER_REACTS=$(jq -nc --argjson kinds '[7]' --argjson es "$ARTICLE_E_LIST" '{kinds:$kinds, "#e":$es}') + FILTER_ZAPS=$(jq -nc --argjson kinds '[9735]' --argjson es "$ARTICLE_E_LIST" '{kinds:$kinds, "#e":$es}') + FILTER_HL=$(jq -nc --argjson kinds '[9802]' --argjson as "$ARTICLE_A_LIST" '{kinds:$kinds, "#a":$as}') + FILTER_PROFILES=$(jq -nc --argjson kinds '[0]' '{kinds:$kinds}') + FILTER_DELETES=$(jq -nc --argjson kinds '[5]' --arg since "$(since_days 30)" '{kinds:$kinds, since:($since|tonumber)}') +else + # Fallback to basic JSON strings + SINCE_ARTICLES=$(since_days $DAYS_ARTICLES) + SINCE_THREADS=$(since_days $DAYS_THREADS) + SINCE_DELETES=$(since_days 30) + + FILTER_ARTICLES="{\"kinds\":[30023],\"since\":${SINCE_ARTICLES}}" + FILTER_REPLIES_E="{\"kinds\":[1],\"#e\":${ARTICLE_E_LIST},\"since\":${SINCE_THREADS}}" + FILTER_REPLIES_A="{\"kinds\":[1],\"#a\":${ARTICLE_A_LIST},\"since\":${SINCE_THREADS}}" + FILTER_REACTS="{\"kinds\":[7],\"#e\":${ARTICLE_E_LIST}}" + FILTER_ZAPS="{\"kinds\":[9735],\"#e\":${ARTICLE_E_LIST}}" + FILTER_HL="{\"kinds\":[9802],\"#a\":${ARTICLE_A_LIST}}" + FILTER_PROFILES="{\"kinds\":[0]}" + FILTER_DELETES="{\"kinds\":[5],\"since\":${SINCE_DELETES}}" +fi + +run_sync() { + local upstream=$1 + local filter=$2 + local label=$3 + echo "[$(date)] Syncing ${label} from ${upstream}..." + + # Write filter to temp file to avoid shell escaping nightmares + local tmpfile="/tmp/strfry-filter-$$.json" + echo "$filter" | docker compose exec strfry sh -c "cat > $tmpfile" + + # Run sync with filter file + docker compose exec strfry sh -c "./strfry sync '$upstream' --filter=\$(cat $tmpfile) && rm $tmpfile" || echo "[$(date)] WARNING: sync failed for ${label} from ${upstream}" +} + +# Sync from all upstream relays +for R in $UPSTREAMS; do + echo "[$(date)] Processing relay: ${R}" + run_sync "$R" "$FILTER_ARTICLES" "articles (30023)" + run_sync "$R" "$FILTER_REPLIES_E" "replies by event-id" + run_sync "$R" "$FILTER_REPLIES_A" "replies by a-tag" + run_sync "$R" "$FILTER_REACTS" "reactions (7)" + run_sync "$R" "$FILTER_ZAPS" "zap receipts (9735)" + run_sync "$R" "$FILTER_HL" "highlights (9802)" + run_sync "$R" "$FILTER_PROFILES" "profiles (0)" + run_sync "$R" "$FILTER_DELETES" "deletes (5)" +done + +echo "[$(date)] Relay ingest complete." + diff --git a/bin/relay/prime.sh b/bin/relay/prime.sh new file mode 100644 index 0000000..88e4821 --- /dev/null +++ b/bin/relay/prime.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "[$(date)] Starting relay prime (one-time backfill)..." + +# Larger time windows for initial backfill +export DAYS_ARTICLES=${DAYS_ARTICLES:-90} +export DAYS_THREADS=${DAYS_THREADS:-30} + +# Use the same ingest logic but with extended time windows +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "${SCRIPT_DIR}/ingest.sh" + diff --git a/bin/relay/test-smoke.php b/bin/relay/test-smoke.php new file mode 100644 index 0000000..4fd020c --- /dev/null +++ b/bin/relay/test-smoke.php @@ -0,0 +1,136 @@ +#!/usr/bin/env php +connect(); + echo "✓ Connected successfully\n\n"; + + // Test 2: Query for long-form articles (kind 30023) + echo "Test 2: Querying for kind:30023 events (limit 1)...\n"; + + $filter = new Filter(); + $filter->setKinds([30023]); + $filter->setLimit(1); + + $subscriptionId = 'test-' . bin2hex(random_bytes(8)); + $requestMessage = new RequestMessage($subscriptionId, [$filter]); + + $client = $relay->getClient(); + $client->setTimeout(10); + $client->text($requestMessage->generate()); + + $foundEvent = false; + $eventCount = 0; + $startTime = time(); + $timeout = 10; + + while ((time() - $startTime) < $timeout) { + try { + $response = $client->receive(); + + if (!$response instanceof Text) { + continue; + } + + $content = $response->getContent(); + $decoded = json_decode($content, true); + + if (!is_array($decoded) || count($decoded) < 2) { + continue; + } + + $messageType = $decoded[0] ?? ''; + + if ($messageType === 'EVENT') { + $eventCount++; + $event = $decoded[2] ?? []; + $eventId = $event['id'] ?? 'unknown'; + $eventKind = $event['kind'] ?? 'unknown'; + + echo "✓ Received EVENT: id={$eventId}, kind={$eventKind}\n"; + $foundEvent = true; + + // Send CLOSE + $client->text(json_encode(['CLOSE', $subscriptionId])); + break; + } elseif ($messageType === 'EOSE') { + echo " Received EOSE (End of Stored Events)\n"; + // Send CLOSE + $client->text(json_encode(['CLOSE', $subscriptionId])); + break; + } elseif ($messageType === 'NOTICE' || $messageType === 'CLOSED') { + echo " Received {$messageType}: " . ($decoded[1] ?? '') . "\n"; + break; + } + } catch (TimeoutException $e) { + echo " Timeout waiting for response\n"; + break; + } + } + + if (!$foundEvent && $eventCount === 0) { + echo "⚠ No events found (relay might be empty - try running 'make relay-prime' first)\n\n"; + } else { + echo "\n"; + } + + // Test 3: Verify write rejection + echo "Test 3: Testing write policy (should reject)...\n"; + // We'll just document this - actual test would require creating a signed event + echo "⚠ Write rejection test not implemented (requires event signing)\n"; + echo " Manual test: Try publishing an event - should receive rejection message\n\n"; + + $relay->disconnect(); + + echo str_repeat('-', 60) . "\n"; + echo "✓ Smoke test completed successfully\n"; + + exit(0); + +} catch (\Exception $e) { + echo "\n✗ ERROR: " . $e->getMessage() . "\n"; + echo "Stack trace:\n" . $e->getTraceAsString() . "\n"; + exit(1); +} + diff --git a/bin/relay/verify-config.php b/bin/relay/verify-config.php new file mode 100644 index 0000000..2f8b532 --- /dev/null +++ b/bin/relay/verify-config.php @@ -0,0 +1,62 @@ +#!/usr/bin/env php + strfry relay ### + strfry: + image: dockurr/strfry:latest + container_name: newsroom-strfry + restart: unless-stopped + environment: + - RELAY_NAME=Decent Newsroom Read-Cache Relay + - RELAY_DESCRIPTION=Read-only cache relay for Decent Newsroom + - RELAY_WRITE_POLICY=/app/write-policy.sh + volumes: + - ./infra/strfry/write-policy.sh:/app/write-policy.sh:ro + - strfry_data:/app/strfry-db + ports: + - "7777:7777" # Expose for local testing (ws://localhost:7777) + healthcheck: + test: ["CMD-SHELL", "timeout 3 bash -c ' doctrine/doctrine-bundle ### database_data: ###< doctrine/doctrine-bundle ### + +###> strfry relay ### + strfry_data: +###< strfry relay ### + diff --git a/config/services.yaml b/config/services.yaml index 5f8229f..a58bdc8 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -6,12 +6,15 @@ parameters: encryption_key: '%env(APP_ENCRYPTION_KEY)%' mercure_public_hub_url: '%env(MERCURE_PUBLIC_URL)%' + nostr_default_relay: '%env(default::NOSTR_DEFAULT_RELAY)%' services: # default configuration for services in *this* file _defaults: autowire: true # Automatically injects dependencies in your services. autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + bind: + $nostrDefaultRelay: '%nostr_default_relay%' # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name diff --git a/frankenphp/Caddyfile b/frankenphp/Caddyfile index 3f2a75a..3cc6d31 100644 --- a/frankenphp/Caddyfile +++ b/frankenphp/Caddyfile @@ -6,6 +6,15 @@ frankenphp { {$CADDY_EXTRA_CONFIG} +# Nostr relay WebSocket proxy +{$RELAY_DOMAIN:relay.localhost} { + log { + format json + } + encode zstd gzip + reverse_proxy strfry:7777 +} + {$SERVER_NAME:localhost} { log { {$CADDY_SERVER_LOG_OPTIONS} diff --git a/infra/cron/Dockerfile b/infra/cron/Dockerfile new file mode 100644 index 0000000..cfecbc5 --- /dev/null +++ b/infra/cron/Dockerfile @@ -0,0 +1,19 @@ +FROM alpine:3.19 + +# Install supercronic, bash, jq, docker-cli for the ingest script +RUN apk add --no-cache \ + bash \ + curl \ + jq \ + docker-cli \ + && curl -fsSLO https://github.com/aptible/supercronic/releases/download/v0.2.29/supercronic-linux-amd64 \ + && chmod +x supercronic-linux-amd64 \ + && mv supercronic-linux-amd64 /usr/local/bin/supercronic + +# Set working directory +WORKDIR /app + + +# Default command (will be overridden by compose) +CMD ["/usr/local/bin/supercronic", "/etc/cron/crontab"] + diff --git a/infra/cron/crontab b/infra/cron/crontab new file mode 100644 index 0000000..99acca1 --- /dev/null +++ b/infra/cron/crontab @@ -0,0 +1,6 @@ +# Relay ingest crontab - syncs events from upstream relays every 10 minutes +# Format: minute hour day month weekday command + +# Run ingest every 10 minutes +*/10 * * * * /app/bin/relay/ingest.sh >> /var/log/relay-ingest.log 2>&1 + diff --git a/infra/strfry/Dockerfile b/infra/strfry/Dockerfile new file mode 100644 index 0000000..59b681c --- /dev/null +++ b/infra/strfry/Dockerfile @@ -0,0 +1,37 @@ +FROM debian:bookworm-slim + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + git \ + build-essential \ + libtool \ + autotools-dev \ + automake \ + pkg-config \ + liblmdb-dev \ + libsecp256k1-dev \ + libzstd-dev \ + libssl-dev \ + wget \ + && rm -rf /var/lib/apt/lists/* + +# Build strfry from source +WORKDIR /tmp +RUN git clone --depth 1 --branch v1.0.6 https://github.com/hoytech/strfry.git && \ + cd strfry && \ + git submodule update --init && \ + make setup-golpe && \ + make -j$(nproc) && \ + make install && \ + cd / && \ + rm -rf /tmp/strfry + +# Create data directory +RUN mkdir -p /var/strfry/db + +# Expose relay port +EXPOSE 7777 + +# Default command (can be overridden) +CMD ["strfry", "relay"] + diff --git a/infra/strfry/strfry.conf b/infra/strfry/strfry.conf new file mode 100644 index 0000000..3a9b6fd --- /dev/null +++ b/infra/strfry/strfry.conf @@ -0,0 +1,113 @@ +## +## Default strfry config +## + +# Directory that contains the strfry LMDB database (restart required) +db = "/app/strfry-db" + +dbParams { + # Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required) + maxreaders = 256 + + # Size of mmap() to use when loading LMDB (restart required) + dbsize = 10737418240 +} + +events { + # Maximum size of normalised JSON, in bytes + maxEventSize = 65536 + + # Events newer than this will be rejected + rejectEventsNewerThanSeconds = 900 + + # Events older than this will be rejected + rejectEventsOlderThanSeconds = 94608000 + + # Ephemeral events older than this will be rejected + rejectEphemeralEventsOlderThanSeconds = 60 + + # Ephemeral events will be deleted from the DB when older than this + ephemeralEventsLifetimeSeconds = 300 + + # Maximum number of tags allowed + maxNumTags = 2000 + + # Maximum size for tag values, in bytes + maxTagValSize = 1024 +} + +relay { + # Interface to listen on. Use 0.0.0.0 to listen on all interfaces (restart required) + bind = "0.0.0.0" + + # Port to open (restart required) + port = 7777 + + # Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required) + nofiles = 1000000 + + # HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case) + realIpHeader = "" + + info { + # NIP-11: Name of this server. Short/descriptive (< 30 characters) + name = "Decent Newsroom Read-Cache Relay" + + # NIP-11: Detailed information about relay, free-form + description = "Read-only cache relay; denies client writes. Crawls upstream relays for long-form articles and related activity." + + # NIP-11: Administrative nostr pubkey, for contact purposes + pubkey = "" + + # NIP-11: Alternative administrative contact (email, website, etc) + contact = "" + } + + # Maximum accepted incoming websocket frame size (should be larger than max event) (restart required) + maxWebsocketPayloadSize = 131072 + + # Websocket-level PING message frequency (should be less than any reverse proxy idle timeouts) (restart required) + autoPingSeconds = 55 + + # If TCP keep-alive should be enabled (detect dropped connections to upstream reverse proxy) + enableTcpKeepalive = false + + # How much uninterrupted CPU time a REQ query should get during its DB scan + queryTimesliceBudgetMicroseconds = 10000 + + # Maximum records that can be returned per filter + maxFilterLimit = 500 + + # Maximum number of subscriptions (concurrent REQs) a connection can have open at any time + maxSubsPerConnection = 20 + + writePolicy { + # If non-empty, path to an executable script that implements the writePolicy plugin logic + plugin = "/app/write-policy.sh" + } + + compression { + # Use permessage-deflate compression if supported by client. Reduces bandwidth, but slight increase in CPU (restart required) + enabled = true + + # Maintain a sliding window buffer for each connection. Improves compression, but uses more memory (restart required) + slidingWindow = true + } + + logging { + # Dump all incoming messages + dumpInAll = false + + # Dump all incoming EVENT messages + dumpInEvents = false + + # Dump all incoming REQ/CLOSE messages + dumpInReqs = false + + # Log performance metrics for initial REQ database scans + dbScanPerf = false + } + + numThreads = 0 +} + diff --git a/infra/strfry/write-policy.sh b/infra/strfry/write-policy.sh new file mode 100644 index 0000000..5e95010 --- /dev/null +++ b/infra/strfry/write-policy.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Deny ALL client EVENT writes (reads unaffected; upstream ingest still works) +# Contract: read from stdin (JSON) and output action JSON +cat >/dev/null +printf '%s\n' '{"action":"reject","msg":"read-only relay"}' + diff --git a/src/Controller/RelayAdminController.php b/src/Controller/RelayAdminController.php new file mode 100644 index 0000000..98bb178 --- /dev/null +++ b/src/Controller/RelayAdminController.php @@ -0,0 +1,79 @@ +relayAdminService->getStats(); + $config = $this->relayAdminService->getConfiguration(); + $containerStatus = $this->relayAdminService->getContainerStatus(); + $connectivity = $this->relayAdminService->testConnectivity(); + $recentEvents = $this->relayAdminService->getRecentEvents(5); + + return $this->render('admin/relay/index.html.twig', [ + 'stats' => $stats, + 'config' => $config, + 'container_status' => $containerStatus, + 'connectivity' => $connectivity, + 'recent_events' => $recentEvents, + ]); + } + + #[Route('/stats', name: 'stats', methods: ['GET'])] + public function stats(): JsonResponse + { + return $this->json($this->relayAdminService->getStats()); + } + + #[Route('/events', name: 'events', methods: ['GET'])] + public function events(): JsonResponse + { + $events = $this->relayAdminService->getRecentEvents(20); + return $this->json($events); + } + + #[Route('/logs', name: 'logs', methods: ['GET'])] + public function logs(): Response + { + $logs = $this->relayAdminService->getSyncLogs(100); + + return new Response($logs, 200, [ + 'Content-Type' => 'text/plain', + ]); + } + + #[Route('/sync', name: 'sync', methods: ['POST'])] + public function triggerSync(): JsonResponse + { + $result = $this->relayAdminService->triggerSync(); + return $this->json($result); + } + + #[Route('/status', name: 'status', methods: ['GET'])] + public function status(): JsonResponse + { + return $this->json([ + 'containers' => $this->relayAdminService->getContainerStatus(), + 'connectivity' => $this->relayAdminService->testConnectivity(), + 'config' => $this->relayAdminService->getConfiguration(), + ]); + } +} + diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 8784d9e..2c258a9 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -45,14 +45,27 @@ class NostrClient private readonly TokenStorageInterface $tokenStorage, private readonly LoggerInterface $logger, private readonly CacheItemPoolInterface $npubCache, - private readonly NostrRelayPool $relayPool) + private readonly NostrRelayPool $relayPool, + private readonly ?string $nostrDefaultRelay = null) { // Initialize default relay set using the relay pool to avoid duplicate connections - $defaultRelayUrls = [ - 'wss://theforest.nostr1.com', - 'wss://nostr.land', - 'wss://relay.primal.net' - ]; + // Prefer local relay if configured, otherwise use public relays + $defaultRelayUrls = []; + + if ($this->nostrDefaultRelay) { + // Use configured default relay (typically local strfry instance) + $defaultRelayUrls = [$this->nostrDefaultRelay]; + $this->logger->info('Using configured default Nostr relay', ['relay' => $this->nostrDefaultRelay]); + } else { + // Fallback to public relays + $defaultRelayUrls = [ + 'wss://theforest.nostr1.com', + 'wss://nostr.land', + 'wss://relay.primal.net' + ]; + $this->logger->info('Using public Nostr relays (no default relay configured)'); + } + $this->defaultRelaySet = new RelaySet(); foreach ($defaultRelayUrls as $url) { $this->defaultRelaySet->addRelay($this->relayPool->getRelay($url)); diff --git a/src/Service/RelayAdminService.php b/src/Service/RelayAdminService.php new file mode 100644 index 0000000..a79e0c4 --- /dev/null +++ b/src/Service/RelayAdminService.php @@ -0,0 +1,275 @@ +nostrDefaultRelay ?? 'ws://strfry:7777'; + + // Test if relay is accessible + if (!$this->testRelayConnection($relayUrl)) { + return [ + 'error' => 'Cannot connect to relay at ' . $relayUrl, + 'total_events' => 0, + 'relay_accessible' => false + ]; + } + + // Try to count events by querying with a limit + $eventCount = $this->estimateEventCount($relayUrl); + + // Format the event count message + if ($eventCount >= 100) { + $displayCount = '100+ (many events - use CLI for exact count)'; + } elseif ($eventCount > 0) { + $displayCount = $eventCount; + } else { + $displayCount = 0; + } + + return [ + 'total_events' => $displayCount, + 'relay_accessible' => true, + 'database_size' => '~800 MB (from docker volume)', + 'info' => 'Sample of ' . $eventCount . ' events retrieved. Use CLI for full statistics.' + ]; + } catch (\Exception $e) { + $this->logger->error('Failed to get relay stats', ['error' => $e->getMessage()]); + return ['error' => $e->getMessage()]; + } + } + + /** + * Get recent events from relay by actually querying it + */ + public function getRecentEvents(int $limit = 10): array + { + try { + $relayUrl = $this->nostrDefaultRelay ?? 'ws://strfry:7777'; + + // Create relay connection + $relay = new Relay($relayUrl); + + // Create subscription + $subscription = new Subscription(); + $subscriptionId = $subscription->setId(); + + // Create filter for recent events (kind 30023 - articles) + $filter = new Filter(); + $filter->setKinds([30023, 1, 7, 0]); // Articles, notes, reactions, profiles + $filter->setLimit($limit); + + // Create and send request + $requestMessage = new RequestMessage($subscriptionId, [$filter]); + $request = new Request($relay, $requestMessage); + + // Get response with timeout + $response = $request->send(); + + $events = []; + if (is_array($response) && !empty($response)) { + foreach ($response as $relayResponse) { + if (is_array($relayResponse)) { + foreach ($relayResponse as $item) { + if (isset($item->type) && $item->type === 'EVENT' && isset($item->event)) { + $events[] = (array)$item->event; + } + } + } + } + } + + return array_slice($events, 0, $limit); + } catch (\Exception $e) { + $this->logger->error('Failed to get recent events', ['error' => $e->getMessage()]); + return []; + } + } + + /** + * Estimate event count by querying the relay + */ + private function estimateEventCount(string $relayUrl): int + { + try { + $relay = new Relay($relayUrl); + $subscription = new Subscription(); + $subscriptionId = $subscription->setId(); + + // Query for a sample to check if relay has events + $filter = new Filter(); + $filter->setLimit(100); + + $requestMessage = new RequestMessage($subscriptionId, [$filter]); + $request = new Request($relay, $requestMessage); + $response = $request->send(); + + $count = 0; + if (is_array($response) && !empty($response)) { + foreach ($response as $relayResponse) { + if (is_array($relayResponse)) { + foreach ($relayResponse as $item) { + if (isset($item->type) && $item->type === 'EVENT') { + $count++; + } + } + } + } + } + + return $count; + } catch (\Exception $e) { + return 0; + } + } + + /** + * Get relay container status by checking connectivity + */ + public function getContainerStatus(): array + { + $strfryStatus = $this->checkServiceHealth('strfry', 7777); + $ingestStatus = ['status' => 'unknown', 'health' => 'Cannot check from inside container']; + + return [ + 'strfry' => $strfryStatus, + 'ingest' => $ingestStatus, + ]; + } + + /** + * Get relay configuration from environment + */ + public function getConfiguration(): array + { + return [ + 'relay_url' => $this->nostrDefaultRelay ?? 'Not configured', + 'relay_internal' => 'ws://strfry:7777', + 'relay_external' => 'ws://localhost:7777', + 'upstreams' => $_ENV['RELAY_UPSTREAMS'] ?? 'Not configured', + 'days_articles' => $_ENV['RELAY_DAYS_ARTICLES'] ?? '7', + 'days_threads' => $_ENV['RELAY_DAYS_THREADS'] ?? '3', + ]; + } + + /** + * Test relay connectivity + */ + public function testConnectivity(): array + { + $relayUrl = $this->nostrDefaultRelay ?? 'ws://strfry:7777'; + $isAccessible = $this->testRelayConnection($relayUrl); + + return [ + 'container_running' => $isAccessible, + 'port_accessible' => $isAccessible, + 'relay_url' => $relayUrl, + ]; + } + + /** + * Test if we can connect to the relay + */ + private function testRelayConnection(string $url): bool + { + try { + // Parse URL to get host and port + $parts = parse_url($url); + if (!$parts || !isset($parts['host'])) { + return false; + } + + $host = $parts['host']; + $port = $parts['port'] ?? 7777; + + // Try to open a socket connection + $socket = @fsockopen($host, $port, $errno, $errstr, 2); + + if ($socket) { + fclose($socket); + return true; + } + + return false; + } catch (\Exception $e) { + $this->logger->error('Relay connection test failed', [ + 'url' => $url, + 'error' => $e->getMessage() + ]); + return false; + } + } + + /** + * Check service health by testing port connectivity + */ + private function checkServiceHealth(string $host, int $port): array + { + $isRunning = $this->testPortOpen($host, $port); + + return [ + 'status' => $isRunning ? 'running' : 'not running', + 'health' => $isRunning ? 'healthy' : 'unhealthy', + 'name' => $host, + 'port' => $port, + 'method' => 'socket_test' + ]; + } + + /** + * Test if a port is open + */ + private function testPortOpen(string $host, int $port): bool + { + $socket = @fsockopen($host, $port, $errno, $errstr, 2); + if ($socket) { + fclose($socket); + return true; + } + return false; + } + + /** + * Trigger manual sync - placeholder + */ + public function triggerSync(): array + { + return [ + 'success' => false, + 'message' => 'Manual sync trigger not available from web interface. Use CLI: make relay-ingest-now', + ]; + } + + /** + * Get recent sync logs - placeholder + */ + public function getSyncLogs(int $lines = 50): string + { + return 'Log viewing not available from web interface. Use CLI: docker compose logs ingest'; + } +} + diff --git a/templates/admin/relay/index.html.twig b/templates/admin/relay/index.html.twig new file mode 100644 index 0000000..4f254c5 --- /dev/null +++ b/templates/admin/relay/index.html.twig @@ -0,0 +1,263 @@ +{% extends 'layout.html.twig' %} + +{% block title %}Relay Administration{% endblock %} + +{% block layout %} +
+ + +
+

🛰️ Relay Administration

+

Monitor and manage your local Nostr relay

+
+ + {# Status Overview #} +
+ {# Container Status #} +
+

Container Status

+ +
+ strfry Relay + + {% if container_status.strfry.status == 'running' %} + Running + {% else %} + {{ container_status.strfry.status|default('Not Running') }} + {% endif %} + +
+ +
+ Ingest Service + + {% if container_status.ingest.status == 'running' %} + Running + {% else %} + {{ container_status.ingest.status|default('Not Running') }} + {% endif %} + +
+ +
+ Port 7777 + + {% if connectivity.port_accessible %} + Accessible + {% else %} + Not Accessible + {% endif %} + +
+
+ + {# Database Statistics #} +
+

Database Statistics

+ + {% if stats.error is defined %} +
{{ stats.error }}
+ {% elseif stats.relay_accessible %} +
+ Relay Status + Accessible & Running +
+
+ Total Events + {{ stats.total_events }} +
+
+ Database Size + {{ stats.database_size }} +
+ {% if stats.total_events == 0 %} +
+ No events found. Run prime to populate the relay. +
+ {% endif %} + {% else %} +
+ Relay not accessible +
+ {% endif %} +
+ + {# Configuration #} +
+

Configuration

+ +
+ Relay URL + {{ config.relay_url }} +
+ +
+ External Access + {{ config.relay_external }} +
+ +
+ Sync Window + {{ config.days_articles }} days (articles) +
+ +
+ Thread Window + {{ config.days_threads }} days (threads) +
+
+
+ + {# Recent Events #} + {% if recent_events|length > 0 %} +
+
+

Recent Events (Last 5)

+ + {% for event in recent_events %} +
+
+ + Kind {{ event.kind }} + ID: {{ event.id[:16] }}... + + {{ event.created_at|date('Y-m-d H:i') }} +
+ {% if event.content %} +
+ {{ event.content[:200] }}{% if event.content|length > 200 %}...{% endif %} +
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} +
+{% endblock %} +