20 changed files with 1280 additions and 7 deletions
@ -0,0 +1,48 @@
@@ -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."
|
||||
|
||||
@ -0,0 +1,92 @@
@@ -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. ["<eventid1>","<eventid2>"] |
||||
ARTICLE_A_LIST=${ARTICLE_A_LIST:-'[]'} # e.g. ["30023:<authorhex>:<d>",...] |
||||
|
||||
# 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." |
||||
|
||||
@ -0,0 +1,13 @@
@@ -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" |
||||
|
||||
@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env php |
||||
<?php |
||||
/** |
||||
* Smoke test for the local relay |
||||
* Tests that the relay is up and can serve basic queries |
||||
*/ |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
// Bootstrap Symfony autoloader if available, or try to use vendor autoload directly |
||||
$possibleAutoloaders = [ |
||||
__DIR__ . '/../../vendor/autoload.php', |
||||
__DIR__ . '/../../../vendor/autoload.php', |
||||
]; |
||||
|
||||
$autoloaderFound = false; |
||||
foreach ($possibleAutoloaders as $autoloader) { |
||||
if (file_exists($autoloader)) { |
||||
require_once $autoloader; |
||||
$autoloaderFound = true; |
||||
break; |
||||
} |
||||
} |
||||
|
||||
if (!$autoloaderFound) { |
||||
fwrite(STDERR, "ERROR: Could not find autoloader. Run 'composer install' first.\n"); |
||||
exit(1); |
||||
} |
||||
|
||||
use swentel\nostr\Relay\Relay; |
||||
use swentel\nostr\Message\RequestMessage; |
||||
use swentel\nostr\Filter; |
||||
use WebSocket\Message\Text; |
||||
use WebSocket\Exception\TimeoutException; |
||||
|
||||
// Get relay URL from environment or use default |
||||
$relayUrl = getenv('NOSTR_DEFAULT_RELAY') ?: 'ws://localhost:7777'; |
||||
|
||||
echo "Testing relay: {$relayUrl}\n"; |
||||
echo str_repeat('-', 60) . "\n"; |
||||
|
||||
try { |
||||
// Test 1: Basic connection |
||||
echo "Test 1: Connecting to relay...\n"; |
||||
$relay = new Relay($relayUrl); |
||||
$relay->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); |
||||
} |
||||
|
||||
@ -0,0 +1,19 @@
@@ -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"] |
||||
|
||||
@ -0,0 +1,6 @@
@@ -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 |
||||
|
||||
@ -0,0 +1,37 @@
@@ -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"] |
||||
|
||||
@ -0,0 +1,113 @@
@@ -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 |
||||
} |
||||
|
||||
@ -0,0 +1,6 @@
@@ -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"}' |
||||
|
||||
@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Controller; |
||||
|
||||
use App\Service\RelayAdminService; |
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||
use Symfony\Component\HttpFoundation\JsonResponse; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
use Symfony\Component\Routing\Attribute\Route; |
||||
|
||||
#[Route('/admin/relay', name: 'admin_relay_')] |
||||
class RelayAdminController extends AbstractController |
||||
{ |
||||
public function __construct( |
||||
private readonly RelayAdminService $relayAdminService |
||||
) { |
||||
} |
||||
|
||||
#[Route('', name: 'index')] |
||||
public function index(): Response |
||||
{ |
||||
$stats = $this->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(), |
||||
]); |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,275 @@
@@ -0,0 +1,275 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Service; |
||||
|
||||
use Psr\Log\LoggerInterface; |
||||
use swentel\nostr\Filter\Filter; |
||||
use swentel\nostr\Message\RequestMessage; |
||||
use swentel\nostr\Relay\Relay; |
||||
use swentel\nostr\Request\Request; |
||||
use swentel\nostr\Subscription\Subscription; |
||||
|
||||
/** |
||||
* Service to interact with the strfry relay |
||||
*/ |
||||
class RelayAdminService |
||||
{ |
||||
public function __construct( |
||||
private readonly LoggerInterface $logger, |
||||
private readonly ?string $nostrDefaultRelay = null |
||||
) { |
||||
} |
||||
|
||||
/** |
||||
* Get relay statistics by actually querying the relay |
||||
*/ |
||||
public function getStats(): array |
||||
{ |
||||
try { |
||||
$relayUrl = $this->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'; |
||||
} |
||||
} |
||||
|
||||
Loading…
Reference in new issue