Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81c5339063 | ||
|
|
5ec5ed9bd4 | ||
|
|
bd5150cd09 | ||
|
|
2e1d731de4 | ||
|
|
75d983b2df | ||
|
|
a766f08738 | ||
|
|
23c37abe3a | ||
|
|
5b3ccfd1f4 | ||
|
|
38a027e626 | ||
|
|
a042aca524 |
+1
-1
@@ -11,4 +11,4 @@ copy_executable_local.sh
|
||||
nostr_login_lite/
|
||||
style_guide/
|
||||
nostr-tools
|
||||
.test_keys
|
||||
.test_keysadmin/cache/
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
[submodule "nostr_core_lib"]
|
||||
path = nostr_core_lib
|
||||
url = https://git.laantungir.net/laantungir/nostr_core_lib.git
|
||||
[submodule "c_utils_lib"]
|
||||
path = c_utils_lib
|
||||
url = ssh://git@git.laantungir.net:2222/laantungir/c_utils_lib.git
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ADMIN_PUBKEY='8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e'
|
||||
SERVER_PRIVKEY='1111111111111111111111111111111111111111111111111111111111111111'
|
||||
@@ -91,12 +91,13 @@ RUN cd c_utils_lib && \
|
||||
|
||||
# Build nostr_core_lib with required NIPs (cached unless nostr_core_lib changes)
|
||||
# Disable fortification in build.sh to prevent __*_chk symbol issues
|
||||
# NIPs: 001(Basic), 006(Keys), 013(PoW), 017(DMs), 019(Bech32), 044(Encryption), 059(Gift Wrap - required by NIP-17)
|
||||
# NIPs: 001(Basic), 004(Encryption), 006(Keys), 013(PoW), 017(DMs), 019(Bech32), 044(Encryption), 059(Gift Wrap - required by NIP-17)
|
||||
# NIP-04 is required by nostr_signer.c (NIP-46 bunker signer uses NIP-04 encryption)
|
||||
RUN cd nostr_core_lib && \
|
||||
chmod +x build.sh && \
|
||||
sed -i 's/CFLAGS="-Wall -Wextra -std=c99 -fPIC -O2"/CFLAGS="-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -Wall -Wextra -std=c99 -fPIC -O2"/' build.sh && \
|
||||
rm -f *.o *.a 2>/dev/null || true && \
|
||||
./build.sh --nips=1,6,13,17,19,44,59
|
||||
./build.sh --nips=1,4,6,13,17,19,44,59
|
||||
|
||||
# Copy c-relay-pg source files LAST (only this layer rebuilds on source changes)
|
||||
COPY src/ /build/src/
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
/** admin2/api/auth.php — Auth rules + WoT status. */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$action = $input['action'] ?? '';
|
||||
try {
|
||||
if ($action === 'add') {
|
||||
$pdo->prepare("INSERT INTO auth_rules (rule_type, pattern_type, pattern_value) VALUES (?, 'pubkey', ?)")->execute([$input['rule_type'], $input['pattern_value']]);
|
||||
json_response(['message' => 'Rule added']);
|
||||
} elseif ($action === 'remove') {
|
||||
$pdo->prepare("DELETE FROM auth_rules WHERE id = ?")->execute([intval($input['id'])]);
|
||||
json_response(['message' => 'Rule removed']);
|
||||
} elseif ($action === 'set_wot_level') {
|
||||
$pdo->prepare("UPDATE config SET value = ? WHERE key = 'wot_level'")->execute([strval($input['level'])]);
|
||||
json_response(['message' => 'WoT level set']);
|
||||
} elseif ($action === 'sync_wot') {
|
||||
json_response(['message' => 'WoT sync not available via PHP (requires relay)']);
|
||||
}
|
||||
} catch (PDOException $e) { json_response(['error' => $e->getMessage()]); }
|
||||
}
|
||||
|
||||
// GET: WoT status
|
||||
if (isset($_GET['action']) && $_GET['action'] === 'wot') {
|
||||
$level = $pdo->query("SELECT value FROM config WHERE key = 'wot_level'")->fetchColumn() ?: '0';
|
||||
$wl_count = 0;
|
||||
try { $wl_count = intval($pdo->query("SELECT count(*) FROM auth_rules WHERE rule_type = 'whitelist'")->fetchColumn()); } catch (PDOException $e) {}
|
||||
$descriptions = ['0' => 'Level 0: Open relay — anyone can read and write', '1' => 'Level 1: Write only — WoT users can write', '2' => 'Level 2: Full — WoT users only'];
|
||||
json_response(['kind3_status' => 'N/A (PHP admin)', 'whitelist_count' => $wl_count, 'level_description' => $descriptions[$level] ?? $descriptions['0']]);
|
||||
}
|
||||
|
||||
// GET: auth rules
|
||||
$rules = [];
|
||||
try { $rules = $pdo->query("SELECT id, rule_type, pattern_type, pattern_value FROM auth_rules ORDER BY id")->fetchAll(); } catch (PDOException $e) {}
|
||||
json_response(['rules' => $rules]);
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
/** admin2/api/caching.php — Caching service status + follows + reset actions. */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
// --- POST actions: reset backfill (all or per-user) ---
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$action = $input['action'] ?? '';
|
||||
$pubkey = $input['pubkey'] ?? '';
|
||||
|
||||
if ($action === 'reset_all') {
|
||||
// Reset backfill progress for ALL followed authors, then bump config
|
||||
// generation so the running caching service hot-reloads and re-drains.
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
$pdo->exec("UPDATE caching_followed_pubkeys
|
||||
SET until_cursor = 0, backfill_complete = FALSE,
|
||||
events_fetched = 0,
|
||||
updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT");
|
||||
// Also reset per-relay progress rows
|
||||
$pdo->exec("UPDATE caching_backfill_relay_progress
|
||||
SET complete = FALSE, events_fetched = 0,
|
||||
until_cursor = 0, last_status = 'reset',
|
||||
consecutive_errors = 0,
|
||||
updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT");
|
||||
// Bump config generation to trigger hot-reload
|
||||
$pdo->exec("UPDATE config SET value = (COALESCE(value::int, 0) + 1)::text
|
||||
WHERE key = 'caching_config_generation'");
|
||||
$pdo->commit();
|
||||
json_response(['ok' => true, 'message' => 'All backfill progress reset. Caching service will re-drain.']);
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
json_response(['ok' => false, 'error' => $e->getMessage()], 500);
|
||||
}
|
||||
} elseif ($action === 'reset_user' && $pubkey) {
|
||||
// Reset backfill progress for a single followed author.
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
$stmt = $pdo->prepare("UPDATE caching_followed_pubkeys
|
||||
SET until_cursor = 0, backfill_complete = FALSE,
|
||||
events_fetched = 0,
|
||||
updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT
|
||||
WHERE pubkey = ?");
|
||||
$stmt->execute([$pubkey]);
|
||||
// Also reset per-relay progress for this author
|
||||
$stmt2 = $pdo->prepare("UPDATE caching_backfill_relay_progress
|
||||
SET complete = FALSE, events_fetched = 0,
|
||||
until_cursor = 0, last_status = 'reset',
|
||||
consecutive_errors = 0,
|
||||
updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT
|
||||
WHERE author_pubkey = ?");
|
||||
$stmt2->execute([$pubkey]);
|
||||
// Bump config generation to trigger hot-reload
|
||||
$pdo->exec("UPDATE config SET value = (COALESCE(value::int, 0) + 1)::text
|
||||
WHERE key = 'caching_config_generation'");
|
||||
$pdo->commit();
|
||||
json_response(['ok' => true, 'message' => "Backfill reset for pubkey. Caching service will re-fetch."]);
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
json_response(['ok' => false, 'error' => $e->getMessage()], 500);
|
||||
}
|
||||
} else {
|
||||
json_response(['ok' => false, 'error' => 'Unknown action'], 400);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// Caching config values (for toggle controls)
|
||||
$config = [];
|
||||
try {
|
||||
$cfg_rows = $pdo->query("SELECT key, value FROM config WHERE key IN ('caching_enabled','caching_inbox_enabled','caching_live_enabled','caching_backfill_enabled')")->fetchAll();
|
||||
foreach ($cfg_rows as $r) { $config[$r['key']] = $r['value']; }
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Service state
|
||||
$state = [];
|
||||
try { $state = $pdo->query("SELECT * FROM caching_service_state WHERE id = 1")->fetch() ?: []; } catch (PDOException $e) {}
|
||||
|
||||
// Active target
|
||||
$active = ['pubkey' => '', 'relay' => ''];
|
||||
try { $active = $pdo->query("SELECT active_pubkey AS pubkey, active_relay AS relay FROM caching_backfill_active WHERE id = 1")->fetch() ?: $active; } catch (PDOException $e) {}
|
||||
|
||||
// Inbox
|
||||
$inbox = ['pending' => 0, 'live' => 0, 'backfill' => 0];
|
||||
try {
|
||||
$inbox = $pdo->query("SELECT COUNT(*) AS pending, COUNT(*) FILTER (WHERE source_class='live') AS live, COUNT(*) FILTER (WHERE source_class='backfill') AS backfill FROM caching_event_inbox")->fetch() ?: $inbox;
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Follows (paginated, with names from profiles cache + per-relay progress)
|
||||
$follows = [];
|
||||
try {
|
||||
$follows = $pdo->query("
|
||||
SELECT fp.pubkey, fp.is_root, fp.backfill_complete, fp.events_fetched,
|
||||
fp.last_event_at,
|
||||
p.name, p.display_name,
|
||||
(SELECT count(*) FROM events WHERE pubkey = fp.pubkey) AS total_events,
|
||||
(SELECT count(*) FROM caching_backfill_relay_progress WHERE author_pubkey = fp.pubkey) AS relay_count,
|
||||
(SELECT count(*) FROM caching_backfill_relay_progress WHERE author_pubkey = fp.pubkey AND complete = false) AS relay_incomplete
|
||||
FROM caching_followed_pubkeys fp
|
||||
LEFT JOIN profiles p ON p.pubkey = fp.pubkey
|
||||
ORDER BY fp.is_root DESC, fp.events_fetched DESC LIMIT 1000
|
||||
")->fetchAll();
|
||||
// Fetch per-relay progress for all followed pubkeys in one query
|
||||
$relayProgress = [];
|
||||
try {
|
||||
$rpRows = $pdo->query("
|
||||
SELECT author_pubkey, relay_url, complete, events_fetched, until_cursor, last_status, updated_at
|
||||
FROM caching_backfill_relay_progress
|
||||
ORDER BY author_pubkey, events_fetched DESC
|
||||
")->fetchAll();
|
||||
foreach ($rpRows as $rp) {
|
||||
$relayProgress[$rp['author_pubkey']][] = $rp;
|
||||
}
|
||||
} catch (PDOException $e) {}
|
||||
foreach ($follows as &$f) {
|
||||
$f['name'] = profile_display_name($f);
|
||||
$f['npub'] = function_exists('hex_to_npub') ? hex_to_npub($f['pubkey']) : substr($f['pubkey'], 0, 20);
|
||||
$f['relays'] = $relayProgress[$f['pubkey']] ?? [];
|
||||
}
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
json_response(['state' => $state, 'active' => $active, 'inbox' => $inbox, 'follows' => $follows, 'config' => $config]);
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
/**
|
||||
* admin2/api/chart.php — Standalone ASCII chart endpoint.
|
||||
*
|
||||
* Returns a plain-text ASCII X-bar chart of event counts over time.
|
||||
* Works in both the browser (injected into a <div>) and the terminal:
|
||||
*
|
||||
* curl http://localhost:8088/api/chart.php?range=hour
|
||||
* curl http://localhost:8088/api/chart.php?range=day
|
||||
* curl http://localhost:8088/api/chart.php?range=month
|
||||
* curl http://localhost:8088/api/chart.php?range=year
|
||||
*
|
||||
* Caching: the hour chart is never cached (live). Day/month/year are
|
||||
* cached to file with TTLs to avoid expensive queries on every request.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
require_once __DIR__ . '/../lib/ascii_chart.php';
|
||||
|
||||
// --- Range configuration ---
|
||||
$ranges = [
|
||||
'hour' => ['span' => 3600, 'bin' => 45, 'bins' => 80, 'ttl' => 0, 'title' => 'New Events — Last Hour'],
|
||||
'day' => ['span' => 86400, 'bin' => 1080, 'bins' => 80, 'ttl' => 3600, 'title' => 'New Events — Last 24 Hours'],
|
||||
'month' => ['span' => 2592000, 'bin' => 32400, 'bins' => 80, 'ttl' => 86400, 'title' => 'New Events — Last 30 Days'],
|
||||
'year' => ['span' => 31536000, 'bin' => 394200, 'bins' => 80, 'ttl' => 2592000, 'title' => 'New Events — Last Year'],
|
||||
];
|
||||
|
||||
$range = $_GET['range'] ?? 'hour';
|
||||
if (!isset($ranges[$range])) {
|
||||
http_response_code(400);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo "Invalid range. Use: hour, day, month, or year.\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
$cfg = $ranges[$range];
|
||||
|
||||
// --- Set content type ---
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
|
||||
// --- Check cache ---
|
||||
if ($cfg['ttl'] > 0) {
|
||||
$cached = get_cached_chart($range, $cfg['ttl']);
|
||||
if ($cached !== null) {
|
||||
echo $cached;
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Run binning query ---
|
||||
$pdo = db();
|
||||
$now = time();
|
||||
$epoch = $now - $cfg['span'];
|
||||
$bin_size = $cfg['bin'];
|
||||
$num_bins = $cfg['bins'];
|
||||
|
||||
try {
|
||||
// Calculate the bin offset so the rightmost bin aligns with "now"
|
||||
// bin index 0 = oldest, bin index (num_bins-1) = newest
|
||||
$base_bin = (int)floor($epoch / $bin_size);
|
||||
|
||||
$sql = "SELECT FLOOR(created_at / {$bin_size})::BIGINT - {$base_bin} AS bin, COUNT(*) AS cnt
|
||||
FROM events
|
||||
WHERE created_at >= {$epoch}
|
||||
GROUP BY bin
|
||||
ORDER BY bin";
|
||||
$rows = $pdo->query($sql)->fetchAll();
|
||||
} catch (PDOException $e) {
|
||||
echo "Chart query failed: " . $e->getMessage() . "\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- Build bin array (zero-filled, fixed length) ---
|
||||
// Index 0 = oldest, index N-1 = newest (natural time order from SQL)
|
||||
$bins = build_bin_array($rows, $num_bins);
|
||||
|
||||
// Reverse so newest bin is at index 0 (leftmost) and oldest at right.
|
||||
// This matches the original text_graph.js display: new data enters
|
||||
// from the left and proceeds rightward as it ages.
|
||||
$bins = array_reverse($bins);
|
||||
|
||||
// --- Render ASCII chart ---
|
||||
$ascii = render_ascii_chart($bins, [
|
||||
'title' => $cfg['title'],
|
||||
'max_height' => 11,
|
||||
'bin_duration' => $bin_size,
|
||||
'label_interval' => max(1, (int)floor($num_bins / 15)), // ~15 labels across
|
||||
]);
|
||||
|
||||
// --- Write to cache (if TTL > 0) ---
|
||||
if ($cfg['ttl'] > 0) {
|
||||
set_cached_chart($range, $ascii);
|
||||
}
|
||||
|
||||
echo $ascii;
|
||||
|
||||
|
||||
// ================================
|
||||
// CACHE HELPERS
|
||||
// ================================
|
||||
|
||||
function get_cache_dir(): string {
|
||||
return __DIR__ . '/../cache';
|
||||
}
|
||||
|
||||
function get_cached_chart(string $range, int $ttl): ?string {
|
||||
$file = get_cache_dir() . "/chart_{$range}.txt";
|
||||
if (!file_exists($file)) return null;
|
||||
if (filemtime($file) < (time() - $ttl)) return null;
|
||||
$content = @file_get_contents($file);
|
||||
return ($content !== false) ? $content : null;
|
||||
}
|
||||
|
||||
function set_cached_chart(string $range, string $ascii): void {
|
||||
$dir = get_cache_dir();
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0775, true);
|
||||
}
|
||||
@file_put_contents($dir . "/chart_{$range}.txt", $ascii);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
/** admin2/api/config.php — Config table read/edit (all keys). */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$key = $input['key'] ?? '';
|
||||
$value = $input['value'] ?? '';
|
||||
if (!$key) json_response(['error' => 'Missing key']);
|
||||
try {
|
||||
$pdo->prepare("UPDATE config SET value = ? WHERE key = ?")->execute([$value, $key]);
|
||||
json_response(['message' => "Updated $key"]);
|
||||
} catch (PDOException $e) {
|
||||
json_response(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
$rows = $pdo->query("SELECT key, value FROM config ORDER BY key")->fetchAll();
|
||||
json_response(['config' => $rows]);
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
/** admin2/api/dm.php — Direct messages (kind 4/14/15). */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
$limit = min(200, intval($_GET['limit'] ?? 50));
|
||||
$rows = $pdo->prepare("SELECT id, pubkey, kind, created_at, content FROM events WHERE kind IN (4, 14, 15) ORDER BY created_at DESC LIMIT $limit");
|
||||
$rows->execute();
|
||||
$messages = $rows->fetchAll();
|
||||
|
||||
foreach ($messages as &$m) {
|
||||
$m['created_at'] = date('Y-m-d H:i:s', intval($m['created_at']));
|
||||
$m['content'] = substr($m['content'] ?? '', 0, 300);
|
||||
}
|
||||
|
||||
json_response(['messages' => $messages]);
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
/** admin2/api/events.php — Recent relay events (paginated). */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
$limit = min(200, intval($_GET['limit'] ?? 50));
|
||||
$offset = intval($_GET['offset'] ?? 0);
|
||||
$kind = $_GET['kind'] ?? '';
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($kind !== '') { $where[] = 'kind = ?'; $params[] = intval($kind); }
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$rows = $pdo->prepare("SELECT e.id, e.pubkey, e.kind, e.created_at, e.content,
|
||||
p.name, p.display_name
|
||||
FROM events e
|
||||
LEFT JOIN profiles p ON p.pubkey = e.pubkey
|
||||
$where_sql
|
||||
ORDER BY e.created_at DESC LIMIT $limit OFFSET $offset");
|
||||
$rows->execute($params);
|
||||
$events = $rows->fetchAll();
|
||||
|
||||
foreach ($events as &$e) {
|
||||
$e['created_at'] = date('Y-m-d H:i:s', intval($e['created_at']));
|
||||
$e['content'] = substr($e['content'] ?? '', 0, 200);
|
||||
// Resolve display name from profiles cache; fall back to truncated pubkey.
|
||||
$best = profile_display_name($e);
|
||||
$e['display_name'] = $best !== '' ? $best : substr($e['pubkey'] ?? '', 0, 16) . '…';
|
||||
}
|
||||
|
||||
json_response(['events' => $events]);
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
/** admin2/api/ipbans.php — IP ban management. */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$action = $input['action'] ?? '';
|
||||
try {
|
||||
if ($action === 'add') {
|
||||
$until = time() + intval($input['duration'] ?? 86400);
|
||||
$pdo->prepare("INSERT INTO ip_bans (ip, banned_until, ban_count) VALUES (?, ?, 1) ON CONFLICT (ip) DO UPDATE SET banned_until = ?, ban_count = ip_bans.ban_count + 1")->execute([$input['ip'], $until, $until]);
|
||||
json_response(['message' => 'IP banned']);
|
||||
} elseif ($action === 'remove') {
|
||||
$pdo->prepare("DELETE FROM ip_bans WHERE ip = ?")->execute([$input['ip']]);
|
||||
json_response(['message' => 'Ban removed']);
|
||||
}
|
||||
} catch (PDOException $e) { json_response(['error' => $e->getMessage()]); }
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$total = 0; $active = 0; $issued = 0;
|
||||
$bans = [];
|
||||
try {
|
||||
$total = intval($pdo->query("SELECT count(*) FROM ip_bans")->fetchColumn());
|
||||
$active = intval($pdo->query("SELECT count(*) FROM ip_bans WHERE banned_until > $now")->fetchColumn());
|
||||
$issued = intval($pdo->query("SELECT COALESCE(sum(ban_count),0) FROM ip_bans")->fetchColumn());
|
||||
$bans = $pdo->query("SELECT ip, banned_until, ban_count, failures, authed_successfully, connection_attempts FROM ip_bans ORDER BY banned_until DESC LIMIT 100")->fetchAll();
|
||||
foreach ($bans as &$b) {
|
||||
$b['status'] = intval($b['banned_until']) > $now ? 'banned' : 'expired';
|
||||
$b['banned_until'] = date('Y-m-d H:i:s', intval($b['banned_until']));
|
||||
}
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
json_response(['total' => $total, 'active' => $active, 'issued' => $issued, 'bans' => $bans]);
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/api/profile.php — Single-profile lookup from the profiles cache.
|
||||
* Returns cached kind-0 metadata for a given pubkey.
|
||||
*
|
||||
* Usage: profile.php?pubkey=<64-char-hex>
|
||||
* Returns: { "pubkey": "...", "name": "...", "display_name": "...",
|
||||
* "best_name": "...", "picture": "...", "nip05": "...",
|
||||
* "about": "...", "website": "...", "lud16": "..." }
|
||||
* or { "error": "not found" } with HTTP 404 if no profile is cached.
|
||||
*/
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
|
||||
$pubkey = trim($_GET['pubkey'] ?? '');
|
||||
if (!preg_match('/^[0-9a-fA-F]{64}$/', $pubkey)) {
|
||||
http_response_code(400);
|
||||
json_response(['error' => 'Invalid pubkey']);
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
try {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT pubkey, name, display_name, about, picture, banner,
|
||||
nip05, website, lud16, lud06
|
||||
FROM profiles WHERE pubkey = ?"
|
||||
);
|
||||
$stmt->execute([$pubkey]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) {
|
||||
http_response_code(404);
|
||||
json_response(['error' => 'not found']);
|
||||
}
|
||||
$row['best_name'] = profile_display_name($row);
|
||||
json_response($row);
|
||||
} catch (PDOException $e) {
|
||||
http_response_code(500);
|
||||
json_response(['error' => 'Database error']);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
/** admin2/api/query.php — Direct SQL query (SELECT only, admin only). */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
json_response(['error' => 'POST only']);
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$sql = trim($input['sql'] ?? '');
|
||||
|
||||
if (!$sql) json_response(['error' => 'No query provided']);
|
||||
if (!preg_match('/^\s*SELECT/i', $sql)) json_response(['error' => 'Only SELECT queries are allowed']);
|
||||
|
||||
$start = microtime(true);
|
||||
try {
|
||||
$stmt = $pdo->query($sql);
|
||||
$rows = $stmt->fetchAll();
|
||||
$time_ms = round((microtime(true) - $start) * 1000, 1);
|
||||
json_response(['rows' => $rows, 'row_count' => count($rows), 'time_ms' => $time_ms]);
|
||||
} catch (PDOException $e) {
|
||||
json_response(['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
/**
|
||||
* admin2/api/stats.php — Statistics page data.
|
||||
* Returns DB size, event counts, kind distribution, top pubkeys,
|
||||
* time-based stats, and event-rate delta (for the chart).
|
||||
*/
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
|
||||
$pdo = db();
|
||||
|
||||
// Database size
|
||||
$db_size = '-';
|
||||
try {
|
||||
$bytes = $pdo->query("SELECT pg_database_size(current_database())")->fetchColumn();
|
||||
$db_size = format_bytes(intval($bytes));
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Total events
|
||||
$total_events = intval($pdo->query("SELECT COUNT(*) FROM events")->fetchColumn());
|
||||
|
||||
// Event rate: events with first_seen in last 10 seconds
|
||||
$events_delta = 0;
|
||||
try {
|
||||
$events_delta = intval($pdo->query("SELECT COUNT(*) FROM events WHERE first_seen >= EXTRACT(EPOCH FROM NOW())::BIGINT - 10")->fetchColumn());
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Process info (from pg_stat_activity)
|
||||
$process_id = '-';
|
||||
$ws_connections = '-';
|
||||
try {
|
||||
$pid = $pdo->query("SELECT pg_backend_pid()")->fetchColumn();
|
||||
$process_id = strval($pid);
|
||||
$ws_connections = intval($pdo->query("SELECT count(*) FROM pg_stat_activity WHERE state = 'active' AND pid != pg_backend_pid()")->fetchColumn());
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Active subscriptions
|
||||
$active_subscriptions = 0;
|
||||
try {
|
||||
$active_subscriptions = intval($pdo->query("SELECT count(*) FROM subscriptions WHERE active = true")->fetchColumn());
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Memory/CPU (from /proc on Linux)
|
||||
$memory_usage = '-';
|
||||
$cpu_usage = '-';
|
||||
$cpu_core = '-';
|
||||
if (is_readable('/proc/meminfo')) {
|
||||
$mem = parse_ini_file('/proc/meminfo');
|
||||
$total = intval($mem['MemTotal'] ?? 0) * 1024;
|
||||
$avail = intval($mem['MemAvailable'] ?? 0) * 1024;
|
||||
if ($total > 0) $memory_usage = format_bytes($total - $avail) . ' / ' . format_bytes($total);
|
||||
}
|
||||
if (is_readable('/proc/cpuinfo')) {
|
||||
$cores = intval(shell_exec('nproc 2>/dev/null') ?: 1);
|
||||
$cpu_core = strval($cores) . ' cores';
|
||||
}
|
||||
$cpu_usage = @file_get_contents('/proc/loadavg');
|
||||
if ($cpu_usage !== false) $cpu_usage = trim(explode(' ', $cpu_usage)[0] ?? '-');
|
||||
|
||||
// Oldest / newest event
|
||||
$oldest_event = '-';
|
||||
$newest_event = '-';
|
||||
try {
|
||||
$oldest = $pdo->query("SELECT to_timestamp(MIN(created_at)) FROM events")->fetchColumn();
|
||||
$newest = $pdo->query("SELECT to_timestamp(MAX(created_at)) FROM events")->fetchColumn();
|
||||
if ($oldest) $oldest_event = substr($oldest, 0, 19);
|
||||
if ($newest) $newest_event = substr($newest, 0, 19);
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Time-based stats
|
||||
$now = time();
|
||||
$events_24h = 0; $events_7d = 0; $events_30d = 0;
|
||||
try {
|
||||
$events_24h = intval($pdo->query("SELECT COUNT(*) FROM events WHERE created_at >= $now - 86400")->fetchColumn());
|
||||
$events_7d = intval($pdo->query("SELECT COUNT(*) FROM events WHERE created_at >= $now - 604800")->fetchColumn());
|
||||
$events_30d = intval($pdo->query("SELECT COUNT(*) FROM events WHERE created_at >= $now - 2592000")->fetchColumn());
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Kind distribution
|
||||
$kinds = [];
|
||||
try {
|
||||
$rows = $pdo->query("SELECT kind, COUNT(*) AS cnt FROM events GROUP BY kind ORDER BY cnt DESC LIMIT 20")->fetchAll();
|
||||
foreach ($rows as $r) {
|
||||
$kinds[] = ['kind' => intval($r['kind']), 'count' => intval($r['cnt']), 'pct' => $total_events > 0 ? round(intval($r['cnt']) / $total_events * 100, 1) : 0];
|
||||
}
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Top pubkeys (with names from profiles cache)
|
||||
$top_pubkeys = [];
|
||||
try {
|
||||
$rows = $pdo->query("
|
||||
SELECT pubkey, COUNT(*) AS cnt
|
||||
FROM events
|
||||
GROUP BY pubkey
|
||||
ORDER BY cnt DESC LIMIT 20
|
||||
")->fetchAll();
|
||||
// Batch-resolve profile names from the profiles cache table.
|
||||
$pubkeys = array_column($rows, 'pubkey');
|
||||
$pmap = profile_map($pubkeys);
|
||||
foreach ($rows as $r) {
|
||||
$pk = $r['pubkey'];
|
||||
$prof = $pmap[$pk] ?? null;
|
||||
$top_pubkeys[] = [
|
||||
'pubkey' => $pk,
|
||||
'name' => $prof ? $prof['best_name'] : '',
|
||||
'count' => intval($r['cnt']),
|
||||
'pct' => $total_events > 0 ? round(intval($r['cnt']) / $total_events * 100, 1) : 0,
|
||||
];
|
||||
}
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Name-field usage stats (from profiles cache)
|
||||
$name_field_usage = ['both' => 0, 'name_only' => 0, 'display_only' => 0, 'neither' => 0, 'both_differ' => 0, 'total' => 0];
|
||||
try {
|
||||
$row = $pdo->query("
|
||||
SELECT count(*) FILTER (WHERE name <> '' AND display_name <> '') AS both,
|
||||
count(*) FILTER (WHERE name <> '' AND display_name = '') AS name_only,
|
||||
count(*) FILTER (WHERE name = '' AND display_name <> '') AS display_only,
|
||||
count(*) FILTER (WHERE name = '' AND display_name = '') AS neither,
|
||||
count(*) FILTER (WHERE name <> '' AND display_name <> '' AND name <> display_name) AS both_differ,
|
||||
count(*) AS total
|
||||
FROM profiles
|
||||
")->fetch();
|
||||
if ($row) {
|
||||
$name_field_usage = array_map('intval', $row);
|
||||
}
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
json_response([
|
||||
'db_size' => $db_size,
|
||||
'total_events' => $total_events,
|
||||
'events_delta' => $events_delta,
|
||||
'process_id' => $process_id,
|
||||
'ws_connections' => $ws_connections,
|
||||
'active_subscriptions' => $active_subscriptions,
|
||||
'memory_usage' => $memory_usage,
|
||||
'cpu_usage' => $cpu_usage,
|
||||
'cpu_core' => $cpu_core,
|
||||
'oldest_event' => $oldest_event,
|
||||
'newest_event' => $newest_event,
|
||||
'events_24h' => $events_24h,
|
||||
'events_7d' => $events_7d,
|
||||
'events_30d' => $events_30d,
|
||||
'kinds' => $kinds,
|
||||
'top_pubkeys' => $top_pubkeys,
|
||||
'name_field_usage' => $name_field_usage,
|
||||
]);
|
||||
|
||||
/** Format bytes as human-readable. */
|
||||
function format_bytes(int $bytes): string {
|
||||
if ($bytes < 1024) return $bytes . ' B';
|
||||
if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB';
|
||||
if ($bytes < 1073741824) return round($bytes / 1048576, 1) . ' MB';
|
||||
return round($bytes / 1073741824, 2) . ' GB';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
/** admin2/api/subscriptions.php — Active subscription details. */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
$subs = [];
|
||||
try {
|
||||
$subs = $pdo->query("SELECT id, sub_id, pubkey, filters, created_at, events_sent FROM subscriptions ORDER BY created_at DESC LIMIT 100")->fetchAll();
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
json_response(['subscriptions' => $subs]);
|
||||
@@ -0,0 +1,956 @@
|
||||
/*
|
||||
* admin2 — Full PHP admin for C-Relay-PG.
|
||||
* Replaces the 7457-line api/index.js with a lightweight AJAX-based
|
||||
* controller that fetches data from PHP API endpoints (api/*.php)
|
||||
* instead of Nostr WebSocket admin commands.
|
||||
*/
|
||||
|
||||
const REFRESH_MS = 10000;
|
||||
let currentPage = 'statistics';
|
||||
let statsInterval = null;
|
||||
|
||||
// HTML-escape helper for safe interpolation into innerHTML.
|
||||
// Prevents stored XSS from user-controlled data (profile names, event
|
||||
// content, config values, etc.) being parsed as HTML by the browser.
|
||||
const esc = (s) => {
|
||||
const str = String(s ?? '');
|
||||
return str.replace(/[&<>"']/g, (ch) => {
|
||||
return '&#' + ch.charCodeAt(0) + ';';
|
||||
});
|
||||
};
|
||||
|
||||
// Auth state
|
||||
let nlLite = null;
|
||||
let userPubkey = null;
|
||||
let isLoggedIn = false;
|
||||
let relayAnimationTimer = null;
|
||||
|
||||
// Server-rendered ASCII chart state
|
||||
let currentChartRange = 'hour';
|
||||
let chartLoadTimer = null;
|
||||
|
||||
// ================================
|
||||
// NAVIGATION
|
||||
// ================================
|
||||
|
||||
function toggleNav() {
|
||||
document.getElementById('side-nav').classList.toggle('open');
|
||||
document.getElementById('side-nav-overlay').classList.toggle('show');
|
||||
}
|
||||
|
||||
document.getElementById('side-nav-overlay')?.addEventListener('click', toggleNav);
|
||||
|
||||
document.querySelectorAll('.nav-item').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const page = btn.getAttribute('data-page');
|
||||
switchPage(page);
|
||||
document.getElementById('side-nav').classList.remove('open');
|
||||
document.getElementById('side-nav-overlay').classList.remove('show');
|
||||
});
|
||||
});
|
||||
|
||||
function switchPage(pageName) {
|
||||
currentPage = pageName;
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
if (item.getAttribute('data-page') === pageName) item.classList.add('active');
|
||||
});
|
||||
|
||||
const sections = [
|
||||
'databaseStatisticsSection', 'subscriptionDetailsSection', 'div_config',
|
||||
'authRulesSection', 'wotSection', 'ipBansSection', 'relayEventsSection',
|
||||
'cachingSection', 'nip17DMSection', 'sqlQuerySection'
|
||||
];
|
||||
sections.forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; });
|
||||
|
||||
const pageMap = {
|
||||
'statistics': 'databaseStatisticsSection',
|
||||
'subscriptions': 'subscriptionDetailsSection',
|
||||
'configuration': 'div_config',
|
||||
'ip-bans': 'ipBansSection',
|
||||
'relay-events': 'relayEventsSection',
|
||||
'caching': 'cachingSection',
|
||||
'dm': 'nip17DMSection',
|
||||
'database': 'sqlQuerySection'
|
||||
};
|
||||
|
||||
if (pageName === 'authorization') {
|
||||
document.getElementById('authRulesSection').style.display = 'block';
|
||||
document.getElementById('wotSection').style.display = 'block';
|
||||
loadAuthRules();
|
||||
loadWotStatus();
|
||||
} else {
|
||||
const target = pageMap[pageName];
|
||||
if (target) document.getElementById(target).style.display = 'block';
|
||||
}
|
||||
|
||||
// Load data for the page
|
||||
const loaders = {
|
||||
'statistics': loadStats,
|
||||
'subscriptions': loadSubscriptions,
|
||||
'configuration': loadConfig,
|
||||
'ip-bans': loadIpBans,
|
||||
'relay-events': loadEvents,
|
||||
'caching': loadCaching,
|
||||
'dm': loadDMs,
|
||||
};
|
||||
if (loaders[pageName]) loaders[pageName]();
|
||||
|
||||
// Start/stop auto-refresh for statistics
|
||||
if (pageName === 'statistics') {
|
||||
if (!statsInterval) {
|
||||
loadStats();
|
||||
statsInterval = setInterval(loadStats, REFRESH_MS);
|
||||
console.log('[admin2] auto-refresh started, interval:', REFRESH_MS, 'ms');
|
||||
}
|
||||
} else {
|
||||
if (statsInterval) { clearInterval(statsInterval); statsInterval = null; }
|
||||
}
|
||||
}
|
||||
|
||||
// ================================
|
||||
// STATISTICS
|
||||
// ================================
|
||||
|
||||
async function loadStats() {
|
||||
console.log('[admin2] loading stats at', new Date().toLocaleTimeString());
|
||||
// Fire the RELAY letter animation on every refresh — visual indicator the page is updating
|
||||
startRelayAnimation();
|
||||
try {
|
||||
const res = await fetch('api/stats.php');
|
||||
if (!res.ok) { console.warn('[admin2] stats.php returned', res.status); return; }
|
||||
const d = await res.json();
|
||||
console.log('[admin2] stats loaded — events:', d.total_events, 'rate:', d.events_delta, '/10s');
|
||||
|
||||
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
|
||||
set('db-size', d.db_size || '-');
|
||||
set('total-events', (d.total_events ?? 0).toLocaleString());
|
||||
set('process-id', d.process_id || '-');
|
||||
set('websocket-connections', d.ws_connections ?? '-');
|
||||
set('active-subscriptions', d.active_subscriptions ?? '-');
|
||||
set('memory-usage', d.memory_usage || '-');
|
||||
set('cpu-usage', d.cpu_usage || '-');
|
||||
set('cpu-core', d.cpu_core || '-');
|
||||
set('oldest-event', d.oldest_event || '-');
|
||||
set('newest-event', d.newest_event || '-');
|
||||
set('events-24h', (d.events_24h ?? 0).toLocaleString());
|
||||
set('events-7d', (d.events_7d ?? 0).toLocaleString());
|
||||
set('events-30d', (d.events_30d ?? 0).toLocaleString());
|
||||
|
||||
// Kind distribution
|
||||
if (d.kinds) {
|
||||
const tbody = document.getElementById('stats-kinds-table-body');
|
||||
tbody.innerHTML = d.kinds.map(k =>
|
||||
`<tr><td>${k.kind}</td><td>${k.count.toLocaleString()}</td><td>${k.pct}%</td></tr>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Top pubkeys
|
||||
if (d.top_pubkeys) {
|
||||
const tbody = document.getElementById('stats-pubkeys-table-body');
|
||||
tbody.innerHTML = d.top_pubkeys.map((p, i) =>
|
||||
`<tr><td>${i+1}</td><td><bdi>${esc(p.name) || '<i>unknown</i>'}</bdi></td><td class="pubkey">${esc(p.pubkey.substring(0,16))}…</td><td>${p.count.toLocaleString()}</td><td>${p.pct}%</td></tr>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Name-field usage stats (from profiles cache)
|
||||
if (d.name_field_usage && d.name_field_usage.total > 0) {
|
||||
const u = d.name_field_usage;
|
||||
const el = document.getElementById('name-field-usage');
|
||||
if (el) {
|
||||
el.innerHTML = `
|
||||
<div class="name-usage-stats">
|
||||
<span class="name-usage-label">Profile name fields (${u.total} profiles):</span>
|
||||
<span class="name-usage-item">Both: ${u.both}</span>
|
||||
<span class="name-usage-item">name only: ${u.name_only}</span>
|
||||
<span class="name-usage-item">display_name only: ${u.display_only}</span>
|
||||
<span class="name-usage-item name-usage-differ">Differ: ${u.both_differ}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the chart on every stats poll (server handles caching for non-hour ranges)
|
||||
loadChart(currentChartRange);
|
||||
} catch (e) { console.error('[admin2] stats error:', e); }
|
||||
}
|
||||
|
||||
// ================================
|
||||
// EVENT RATE CHART (server-rendered ASCII via chart.php)
|
||||
// ================================
|
||||
|
||||
// Fetch the ASCII chart from the server and inject it into the div.
|
||||
// The chart is rendered server-side as plain text — works in both
|
||||
// browser and terminal (curl http://localhost:8088/api/chart.php?range=hour)
|
||||
async function loadChart(range) {
|
||||
const el = document.getElementById('event-rate-chart');
|
||||
if (!el) return;
|
||||
try {
|
||||
const res = await fetch('api/chart.php?range=' + encodeURIComponent(range));
|
||||
if (!res.ok) { el.textContent = 'Chart load failed (HTTP ' + res.status + ')'; return; }
|
||||
const text = await res.text();
|
||||
el.textContent = text;
|
||||
// Newest data is at the left (index 0) — scroll to start so it's visible
|
||||
el.scrollLeft = 0;
|
||||
} catch (e) {
|
||||
console.error('[admin2] chart load error:', e);
|
||||
el.textContent = 'Chart load error: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Chart range tab click handlers
|
||||
document.querySelectorAll('.chart-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const range = tab.getAttribute('data-range');
|
||||
if (!range || range === currentChartRange) return;
|
||||
currentChartRange = range;
|
||||
document.querySelectorAll('.chart-tab').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
loadChart(range);
|
||||
});
|
||||
});
|
||||
|
||||
// ================================
|
||||
// SUBSCRIPTIONS
|
||||
// ================================
|
||||
|
||||
async function loadSubscriptions() {
|
||||
try {
|
||||
const res = await fetch('api/subscriptions.php');
|
||||
const d = await res.json();
|
||||
const tbody = document.getElementById('subscription-details-table-body');
|
||||
if (!d.subscriptions || d.subscriptions.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;font-style:italic">No subscriptions active</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = d.subscriptions.map(s =>
|
||||
`<tr><td>${esc(s.sub_id) || '-'}</td><td class="pubkey">${esc((s.pubkey||'-').substring(0,16))}</td><td>${esc(s.filters) || '-'}</td><td>${esc(s.created) || '-'}</td><td>${s.events_sent ?? 0}</td></tr>`
|
||||
).join('');
|
||||
} catch (e) { console.error('[admin2] subscriptions error:', e); }
|
||||
}
|
||||
|
||||
// ================================
|
||||
// CONFIGURATION
|
||||
// ================================
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const res = await fetch('api/config.php');
|
||||
const d = await res.json();
|
||||
const tbody = document.getElementById('config-table-body');
|
||||
if (!d.config || d.config.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;font-style:italic">No config entries</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = d.config.map(c =>
|
||||
`<tr><td>${esc(c.key)}</td><td>${esc(c.value)}</td><td><button onclick="editConfig('${esc(c.key)}')">EDIT</button></td></tr>`
|
||||
).join('');
|
||||
} catch (e) { console.error('[admin2] config error:', e); }
|
||||
}
|
||||
|
||||
function editConfig(key) {
|
||||
const newVal = prompt('Enter new value for ' + key + ':');
|
||||
if (newVal === null) return;
|
||||
fetch('api/config.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({key, value: newVal})
|
||||
}).then(r => r.json()).then(d => {
|
||||
alert(d.message || 'Saved');
|
||||
loadConfig();
|
||||
}).catch(e => alert('Error: ' + e));
|
||||
}
|
||||
|
||||
// ================================
|
||||
// AUTH RULES
|
||||
// ================================
|
||||
|
||||
async function loadAuthRules() {
|
||||
try {
|
||||
const res = await fetch('api/auth.php');
|
||||
const d = await res.json();
|
||||
const tbody = document.getElementById('authRulesTableBody');
|
||||
if (!d.rules || d.rules.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;font-style:italic">No auth rules</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = d.rules.map(r =>
|
||||
`<tr><td>${esc(r.rule_type)}</td><td>${esc(r.pattern_type)}</td><td>${esc(r.pattern_value)}</td><td>${esc(r.status) || 'active'}</td><td><button onclick="removeAuthRule(${r.id})">REMOVE</button></td></tr>`
|
||||
).join('');
|
||||
} catch (e) { console.error('[admin2] auth error:', e); }
|
||||
}
|
||||
|
||||
async function addAuthRule(type) {
|
||||
const pk = document.getElementById('authRulePubkey').value.trim();
|
||||
if (!pk) { alert('Enter a pubkey first'); return; }
|
||||
const res = await fetch('api/auth.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'add', rule_type: type, pattern_value: pk})
|
||||
});
|
||||
const d = await res.json();
|
||||
alert(d.message || 'Done');
|
||||
loadAuthRules();
|
||||
}
|
||||
|
||||
async function removeAuthRule(id) {
|
||||
const res = await fetch('api/auth.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'remove', id})
|
||||
});
|
||||
const d = await res.json();
|
||||
alert(d.message || 'Done');
|
||||
loadAuthRules();
|
||||
}
|
||||
|
||||
// ================================
|
||||
// WEB OF TRUST
|
||||
// ================================
|
||||
|
||||
async function loadWotStatus() {
|
||||
try {
|
||||
const res = await fetch('api/auth.php?action=wot');
|
||||
const d = await res.json();
|
||||
const ind = document.getElementById('wotKind3Indicator');
|
||||
if (ind) ind.textContent = d.kind3_status || 'Unknown';
|
||||
const cnt = document.getElementById('wotWhitelistCount');
|
||||
if (cnt) cnt.textContent = d.whitelist_count ?? '—';
|
||||
const desc = document.getElementById('wotLevelDescription');
|
||||
if (desc) desc.textContent = d.level_description || '';
|
||||
} catch (e) { console.error('[admin2] wot error:', e); }
|
||||
}
|
||||
|
||||
async function setWotLevel(level) {
|
||||
const res = await fetch('api/auth.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'set_wot_level', level})
|
||||
});
|
||||
const d = await res.json();
|
||||
alert(d.message || 'Done');
|
||||
loadWotStatus();
|
||||
}
|
||||
|
||||
async function syncWot() {
|
||||
const res = await fetch('api/auth.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'sync_wot'})
|
||||
});
|
||||
const d = await res.json();
|
||||
alert(d.message || 'Done');
|
||||
loadWotStatus();
|
||||
}
|
||||
|
||||
// ================================
|
||||
// IP BANS
|
||||
// ================================
|
||||
|
||||
async function loadIpBans() {
|
||||
try {
|
||||
const res = await fetch('api/ipbans.php');
|
||||
const d = await res.json();
|
||||
document.getElementById('ip-bans-total').textContent = d.total ?? '-';
|
||||
document.getElementById('ip-bans-active').textContent = d.active ?? '-';
|
||||
document.getElementById('ip-bans-issued').textContent = d.issued ?? '-';
|
||||
const tbody = document.getElementById('ip-bans-tbody');
|
||||
if (!d.bans || d.bans.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center">No IP bans</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = d.bans.map(b =>
|
||||
`<tr><td>${esc(b.ip)}</td><td>${esc(b.status)}</td><td>${esc(b.banned_until)}</td><td>${b.failures ?? 0}</td><td><button onclick="removeBan('${esc(b.ip)}')">REMOVE</button></td></tr>`
|
||||
).join('');
|
||||
} catch (e) { console.error('[admin2] ipbans error:', e); }
|
||||
}
|
||||
|
||||
async function addBan() {
|
||||
const ip = document.getElementById('ban-ip-input').value.trim();
|
||||
const duration = document.getElementById('ban-duration-select').value;
|
||||
if (!ip) { alert('Enter an IP address'); return; }
|
||||
const res = await fetch('api/ipbans.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'add', ip, duration: parseInt(duration)})
|
||||
});
|
||||
const d = await res.json();
|
||||
document.getElementById('add-ban-status').textContent = d.message || 'Done';
|
||||
loadIpBans();
|
||||
}
|
||||
|
||||
async function removeBan(ip) {
|
||||
const res = await fetch('api/ipbans.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'remove', ip})
|
||||
});
|
||||
const d = await res.json();
|
||||
alert(d.message || 'Done');
|
||||
loadIpBans();
|
||||
}
|
||||
|
||||
// ================================
|
||||
// RELAY EVENTS
|
||||
// ================================
|
||||
|
||||
async function loadEvents() {
|
||||
try {
|
||||
const res = await fetch('api/events.php?limit=50');
|
||||
const d = await res.json();
|
||||
const tbody = document.getElementById('live-relay-events-table-body');
|
||||
if (!d.events || d.events.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center">No events</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = d.events.map(e =>
|
||||
`<tr><td>${esc(e.created_at)}</td><td>${e.kind}</td><td><bdi>${esc(e.display_name || (e.pubkey||'').substring(0,16) + '…')}</bdi></td><td class="pubkey">${esc((e.id||'').substring(0,16))}…</td><td>${esc((e.content||'').substring(0,80))}</td></tr>`
|
||||
).join('');
|
||||
} catch (err) { console.error('[admin2] events error:', err); }
|
||||
}
|
||||
|
||||
// ================================
|
||||
// CACHING
|
||||
// ================================
|
||||
|
||||
async function loadCaching() {
|
||||
try {
|
||||
const res = await fetch('api/caching.php');
|
||||
const d = await res.json();
|
||||
// Config toggle state
|
||||
if (d.config) {
|
||||
const enabled = d.config.caching_enabled === 'true';
|
||||
const inboxEnabled = d.config.caching_inbox_enabled === 'true';
|
||||
const enLabel = document.getElementById('caching-enabled-label');
|
||||
const enBtn = document.getElementById('caching-toggle-btn');
|
||||
const inLabel = document.getElementById('caching-inbox-enabled-label');
|
||||
const inBtn = document.getElementById('caching-inbox-toggle-btn');
|
||||
if (enLabel) enLabel.textContent = 'Caching: ' + (enabled ? 'ON' : 'OFF');
|
||||
if (enBtn) enBtn.textContent = enabled ? 'Turn OFF' : 'Turn ON';
|
||||
if (inLabel) inLabel.textContent = 'Inbox: ' + (inboxEnabled ? 'ON' : 'OFF');
|
||||
if (inBtn) inBtn.textContent = inboxEnabled ? 'Turn OFF' : 'Turn ON';
|
||||
}
|
||||
// Service status
|
||||
const ssEl = document.getElementById('caching-service-status');
|
||||
if (ssEl && d.state) {
|
||||
const s = d.state;
|
||||
const hb = s.heartbeat_at ? new Date(s.heartbeat_at * 1000).toLocaleTimeString() : '—';
|
||||
ssEl.innerHTML = `<p>State: <strong>${esc(s.service_state)}</strong> | Follows: ${s.followed_author_count ?? 0} | Connected relays: ${s.connected_relay_count ?? 0}/${s.selected_relay_count ?? 0} | Backfill: ${s.backfill_authors_complete ?? 0}/${s.backfill_authors_total ?? 0} | Events fetched: ${s.events_fetched ?? 0} | Inbox inserts: ${s.inbox_inserts ?? 0} | Heartbeat: ${hb}</p>`;
|
||||
}
|
||||
// Inbox status
|
||||
const isEl = document.getElementById('caching-inbox-status');
|
||||
if (isEl && d.inbox) {
|
||||
isEl.innerHTML = `<p>Pending: ${d.inbox.pending} | Live: ${d.inbox.live} | Backfill: ${d.inbox.backfill}</p>`;
|
||||
}
|
||||
// Follows table
|
||||
const tbody = document.getElementById('caching-follows-table-body');
|
||||
if (tbody && d.follows) {
|
||||
if (d.follows.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;font-style:italic">No followed pubkeys</td></tr>';
|
||||
} else {
|
||||
const activePk = d.active && d.active.pubkey ? d.active.pubkey : '';
|
||||
tbody.innerHTML = d.follows.map((f, i) => {
|
||||
const npub = f.npub || f.pubkey.substring(0, 20);
|
||||
const isActive = activePk && f.pubkey === activePk;
|
||||
const relaySummary = f.relay_incomplete > 0
|
||||
? `<span class="status-working">${f.relay_count - f.relay_incomplete}/${f.relay_count} done</span>`
|
||||
: `${f.relay_count ?? 0} relays`;
|
||||
// Per-relay detail row (hidden by default, toggle via click)
|
||||
let relayDetail = '';
|
||||
if (f.relays && f.relays.length > 0) {
|
||||
relayDetail = '<div class="relay-progress-list">' +
|
||||
f.relays.map(r => {
|
||||
const statusIcon = r.complete ? '✓' : '⏳';
|
||||
const statusBadge = r.complete
|
||||
? `<span class="relay-status relay-done">${esc(r.last_status || 'eose')}</span>`
|
||||
: `<span class="relay-status relay-pending">${esc(r.last_status || 'pending')}</span>`;
|
||||
const relayHost = r.relay_url.replace(/^wss?:\/\//, '').replace(/\/relay$/, '');
|
||||
return `<div class="relay-progress-row"><span class="relay-icon">${statusIcon}</span><span class="relay-url" title="${esc(r.relay_url)}">${esc(relayHost)}</span><span class="relay-events">${r.events_fetched} evts</span>${statusBadge}</div>`;
|
||||
}).join('') +
|
||||
'</div>';
|
||||
}
|
||||
const refreshBtn = `<button type="button" class="refresh-user-btn" onclick="event.stopPropagation(); refreshCachingUser('${esc(f.pubkey)}', '${esc(f.name || '')}')">↻ Refresh this user</button>`;
|
||||
const rowClass = isActive ? 'follows-row follows-row-active' : 'follows-row';
|
||||
const activeIcon = isActive ? '⚡ ' : '';
|
||||
return `<tr class="${rowClass}" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? '' : 'none'"><td><bdi>${activeIcon}${esc(f.name) || '<i>unknown</i>'}</bdi></td><td class="npub-link">${esc(npub)}…</td><td>${f.is_root ? '✓' : ''}</td><td>${f.total_events ?? 0}</td><td>${f.backfill_complete ? '✓' : '…'}</td><td>${relaySummary}</td></tr><tr class="follows-detail" style="display:none"><td colspan="6"><div class="follows-detail-content">${relayDetail || '<i>No relay progress data</i>'}<div class="follows-detail-actions">${refreshBtn}</div></div></td></tr>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
// Active target
|
||||
const fsEl = document.getElementById('caching-follows-status');
|
||||
if (fsEl) {
|
||||
if (d.active && d.active.pubkey && d.active.relay) {
|
||||
fsEl.innerHTML = `<span class="status-working">⚡ Backfilling: ${d.active.pubkey.substring(0,16)}… @ ${esc(d.active.relay)}</span>`;
|
||||
} else if (d.state && d.state.service_state === 'running') {
|
||||
const incomplete = d.state.backfill_authors_complete < d.state.backfill_authors_total;
|
||||
fsEl.innerHTML = incomplete
|
||||
? `<span class="status-working">⚡ Backfill in progress…</span>`
|
||||
: `<span class="status-complete">✓ Backfill complete — listening for live events</span>`;
|
||||
} else {
|
||||
fsEl.innerHTML = `<span class="status-complete">✓ Caching complete</span>`;
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error('[admin2] caching error:', e); }
|
||||
}
|
||||
|
||||
// Toggle caching_enabled config via the config API.
|
||||
async function toggleCachingEnabled() {
|
||||
try {
|
||||
const res = await fetch('api/caching.php');
|
||||
const d = await res.json();
|
||||
const current = d.config?.caching_enabled === 'true';
|
||||
const newVal = current ? 'false' : 'true';
|
||||
await fetch('api/config.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({key: 'caching_enabled', value: newVal})
|
||||
});
|
||||
loadCaching();
|
||||
} catch (e) { console.error('[admin2] toggle caching error:', e); }
|
||||
}
|
||||
|
||||
// Toggle caching_inbox_enabled config via the config API.
|
||||
async function toggleCachingInboxEnabled() {
|
||||
try {
|
||||
const res = await fetch('api/caching.php');
|
||||
const d = await res.json();
|
||||
const current = d.config?.caching_inbox_enabled === 'true';
|
||||
const newVal = current ? 'false' : 'true';
|
||||
await fetch('api/config.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({key: 'caching_inbox_enabled', value: newVal})
|
||||
});
|
||||
loadCaching();
|
||||
} catch (e) { console.error('[admin2] toggle inbox error:', e); }
|
||||
}
|
||||
|
||||
// Re-run all caching: resets backfill progress for all followed authors and
|
||||
// bumps caching_config_generation so the running service hot-reloads and
|
||||
// re-drains from the beginning.
|
||||
async function rerunAllCaching() {
|
||||
if (!confirm('Reset backfill progress for ALL followed authors? The caching service will re-download everything from scratch.')) return;
|
||||
const btn = document.getElementById('caching-rerun-all-btn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Resetting…'; }
|
||||
try {
|
||||
const res = await fetch('api/caching.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'reset_all'})
|
||||
});
|
||||
const d = await res.json();
|
||||
if (d.ok) {
|
||||
if (btn) { btn.textContent = '✓ Reset — re-draining'; }
|
||||
setTimeout(() => { if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Caching'; } loadCaching(); }, 2000);
|
||||
} else {
|
||||
alert('Reset failed: ' + (d.error || 'unknown error'));
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Caching'; }
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[admin2] rerunAllCaching error:', e);
|
||||
alert('Reset failed: ' + e.message);
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Caching'; }
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh a single user: resets backfill progress for one followed author
|
||||
// and bumps caching_config_generation so the service re-fetches that author.
|
||||
async function refreshCachingUser(pubkey, name) {
|
||||
if (!confirm('Reset backfill progress for ' + (name || pubkey.substring(0, 16) + '…') + '? The caching service will re-download this user\'s events from scratch.')) return;
|
||||
try {
|
||||
const res = await fetch('api/caching.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'reset_user', pubkey: pubkey})
|
||||
});
|
||||
const d = await res.json();
|
||||
if (d.ok) {
|
||||
loadCaching();
|
||||
} else {
|
||||
alert('Refresh failed: ' + (d.error || 'unknown error'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[admin2] refreshCachingUser error:', e);
|
||||
alert('Refresh failed: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================
|
||||
// DMs
|
||||
// ================================
|
||||
|
||||
async function loadDMs() {
|
||||
try {
|
||||
const res = await fetch('api/dm.php?limit=50');
|
||||
const d = await res.json();
|
||||
const el = document.getElementById('dm-inbox');
|
||||
if (!d.messages || d.messages.length === 0) {
|
||||
el.innerHTML = '<div class="log-entry">No messages found.</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = d.messages.map(m =>
|
||||
`<div class="log-entry"><strong>kind ${m.kind}</strong> from <span class="pubkey">${esc((m.pubkey||'').substring(0,16))}…</span> at ${esc(m.created_at)}:<br>${esc((m.content||'').substring(0,200))}</div>`
|
||||
).join('');
|
||||
} catch (e) { console.error('[admin2] dm error:', e); }
|
||||
}
|
||||
|
||||
// ================================
|
||||
// SQL QUERY
|
||||
// ================================
|
||||
|
||||
async function executeQuery() {
|
||||
const sql = document.getElementById('sql-input').value.trim();
|
||||
if (!sql) { alert('Enter a SQL query'); return; }
|
||||
if (!sql.toUpperCase().startsWith('SELECT')) { alert('Only SELECT queries are allowed'); return; }
|
||||
try {
|
||||
const res = await fetch('api/query.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({sql})
|
||||
});
|
||||
const d = await res.json();
|
||||
const info = document.getElementById('query-info');
|
||||
const tableDiv = document.getElementById('query-table');
|
||||
if (d.error) {
|
||||
info.textContent = 'Error: ' + d.error;
|
||||
tableDiv.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
info.textContent = `${d.row_count} rows in ${d.time_ms}ms`;
|
||||
if (d.rows && d.rows.length > 0) {
|
||||
const cols = Object.keys(d.rows[0]);
|
||||
let html = '<table class="config-table"><thead><tr>';
|
||||
cols.forEach(c => html += `<th>${c}</th>`);
|
||||
html += '</tr></thead><tbody>';
|
||||
d.rows.forEach(r => {
|
||||
html += '<tr>';
|
||||
cols.forEach(c => html += `<td>${r[c] ?? ''}</td>`);
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
tableDiv.innerHTML = html;
|
||||
} else {
|
||||
tableDiv.innerHTML = '<p>No rows returned.</p>';
|
||||
}
|
||||
} catch (e) { console.error('[admin2] query error:', e); }
|
||||
}
|
||||
|
||||
// ================================
|
||||
// DARK MODE
|
||||
// ================================
|
||||
|
||||
document.getElementById('nav-dark-mode-btn')?.addEventListener('click', () => {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('admin2-dark-mode', document.body.classList.contains('dark-mode'));
|
||||
const btn = document.getElementById('nav-dark-mode-btn');
|
||||
if (btn) btn.textContent = document.body.classList.contains('dark-mode') ? 'LIGHT MODE' : 'DARK MODE';
|
||||
});
|
||||
|
||||
if (localStorage.getItem('admin2-dark-mode') === 'true') {
|
||||
document.body.classList.add('dark-mode');
|
||||
const btn = document.getElementById('nav-dark-mode-btn');
|
||||
if (btn) btn.textContent = 'LIGHT MODE';
|
||||
}
|
||||
|
||||
// ================================
|
||||
// RELAY LETTER ANIMATION
|
||||
// ================================
|
||||
|
||||
// Animate the RELAY letters with an underline sweep. Fires on every stats
|
||||
// refresh so the user gets a visual cue that the page is updating.
|
||||
function startRelayAnimation() {
|
||||
const letters = document.querySelectorAll('.relay-letter');
|
||||
if (letters.length === 0) return;
|
||||
|
||||
// Cancel any in-flight animation so rapid refreshes don't overlap
|
||||
if (relayAnimationTimer) { clearTimeout(relayAnimationTimer); relayAnimationTimer = null; }
|
||||
|
||||
let currentIndex = 0;
|
||||
letters.forEach(l => l.classList.remove('underlined'));
|
||||
|
||||
function animateLetter() {
|
||||
letters.forEach(letter => letter.classList.remove('underlined'));
|
||||
if (letters[currentIndex]) {
|
||||
letters[currentIndex].classList.add('underlined');
|
||||
}
|
||||
currentIndex++;
|
||||
if (currentIndex > letters.length) {
|
||||
// Sweep complete — clear underlines and pause before next refresh
|
||||
letters.forEach(letter => letter.classList.remove('underlined'));
|
||||
relayAnimationTimer = null;
|
||||
return;
|
||||
}
|
||||
relayAnimationTimer = setTimeout(animateLetter, 100);
|
||||
}
|
||||
animateLetter();
|
||||
}
|
||||
|
||||
// ================================
|
||||
// RELAY PUBKEY COPY-TO-CLIPBOARD
|
||||
// ================================
|
||||
|
||||
document.getElementById('relay-pubkey-container')?.addEventListener('click', async () => {
|
||||
const el = document.getElementById('relay-pubkey');
|
||||
if (!el || !el.textContent.trim()) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(el.textContent.replace(/\s+/g, ''));
|
||||
const container = document.getElementById('relay-pubkey-container');
|
||||
container.classList.add('copied');
|
||||
setTimeout(() => container.classList.remove('copied'), 500);
|
||||
} catch (e) { console.warn('[admin2] clipboard copy failed:', e); }
|
||||
});
|
||||
|
||||
// ================================
|
||||
// AUTH — nostr_login_lite modal
|
||||
// ================================
|
||||
|
||||
const loginModal = document.getElementById('login-modal');
|
||||
const loginModalContainer = document.getElementById('login-modal-container');
|
||||
const profileArea = document.getElementById('profile-area');
|
||||
const headerUserName = document.getElementById('header-user-name');
|
||||
const headerUserImage = document.getElementById('header-user-image');
|
||||
const logoutDropdown = document.getElementById('logout-dropdown');
|
||||
|
||||
function showLoginModal() {
|
||||
if (loginModal && loginModalContainer) {
|
||||
if (window.NOSTR_LOGIN_LITE && typeof window.NOSTR_LOGIN_LITE.embed === 'function') {
|
||||
// Clear previous embed before re-embedding
|
||||
loginModalContainer.innerHTML = '';
|
||||
window.NOSTR_LOGIN_LITE.embed('#login-modal-container', { seamless: true });
|
||||
}
|
||||
loginModal.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function hideLoginModal() {
|
||||
if (loginModal) loginModal.style.display = 'none';
|
||||
}
|
||||
|
||||
function showProfileInHeader() {
|
||||
if (profileArea) profileArea.style.display = 'flex';
|
||||
}
|
||||
|
||||
function hideProfileFromHeader() {
|
||||
if (profileArea) profileArea.style.display = 'none';
|
||||
}
|
||||
|
||||
// Toggle logout dropdown when clicking the profile area
|
||||
profileArea?.addEventListener('click', (e) => {
|
||||
// Only toggle if the click wasn't on the logout button itself
|
||||
if (e.target.closest('.logout-btn')) return;
|
||||
if (logoutDropdown) {
|
||||
logoutDropdown.style.display = (logoutDropdown.style.display === 'none' || !logoutDropdown.style.display) ? 'block' : 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Hide logout dropdown when clicking elsewhere
|
||||
document.addEventListener('click', (e) => {
|
||||
if (logoutDropdown && logoutDropdown.style.display === 'block' && !e.target.closest('#profile-area')) {
|
||||
logoutDropdown.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Update header profile display from logged-in user's pubkey.
|
||||
// Sets a placeholder from the npub immediately, then fetches the kind 0
|
||||
// profile event from public relays to populate the name + picture.
|
||||
function updateProfileDisplay(pubkey) {
|
||||
if (!pubkey) return;
|
||||
let npub = '';
|
||||
try {
|
||||
if (pubkey.length === 64 && /^[0-9a-fA-F]+$/.test(pubkey)) {
|
||||
npub = window.NostrTools.nip19.npubEncode(pubkey);
|
||||
}
|
||||
} catch (err) { console.warn('[admin2] npub encode failed:', err); }
|
||||
|
||||
// Placeholder until the profile fetch resolves
|
||||
if (headerUserName) {
|
||||
headerUserName.textContent = npub ? npub.substring(0, 16) + '…' : pubkey.substring(0, 16) + '…';
|
||||
}
|
||||
if (headerUserImage) headerUserImage.style.display = 'none';
|
||||
|
||||
// Fetch kind 0 profile from public relays (same approach as original api page)
|
||||
loadUserProfile(pubkey, npub);
|
||||
}
|
||||
|
||||
// Apply profile data to the header name + profile picture.
|
||||
// Uses best_name from the server (resolved per profile_name_preference).
|
||||
function applyProfileToHeader(name, picture) {
|
||||
if (headerUserName) headerUserName.textContent = name || 'Anonymous User';
|
||||
if (headerUserImage && picture && typeof picture === 'string' &&
|
||||
(picture.startsWith('http://') || picture.startsWith('https://'))) {
|
||||
headerUserImage.src = picture;
|
||||
headerUserImage.style.display = 'block';
|
||||
headerUserImage.onerror = function() { this.style.display = 'none'; };
|
||||
} else if (headerUserImage) {
|
||||
headerUserImage.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the user's profile. Tries the local profiles cache first
|
||||
// (admin/api/profile.php), then falls back to public relays if the
|
||||
// relay has no cached kind-0 for this pubkey.
|
||||
async function loadUserProfile(pubkey, npub) {
|
||||
if (!pubkey) return;
|
||||
|
||||
// Try local profiles cache first.
|
||||
try {
|
||||
const res = await fetch('api/profile.php?pubkey=' + encodeURIComponent(pubkey));
|
||||
if (res.ok) {
|
||||
const profile = await res.json();
|
||||
if (profile && profile.best_name !== undefined) {
|
||||
applyProfileToHeader(profile.best_name, profile.picture);
|
||||
console.log('[admin2] profile loaded from local cache for', profile.best_name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Local endpoint not available — fall through to public relays.
|
||||
}
|
||||
|
||||
// Fall back to public relays.
|
||||
if (!window.NostrTools || !window.NostrTools.SimplePool) {
|
||||
if (headerUserName) headerUserName.textContent = npub ? npub.substring(0, 16) + '…' : 'Anonymous User';
|
||||
return;
|
||||
}
|
||||
const relays = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relay.snort.social'
|
||||
];
|
||||
try {
|
||||
const pool = new window.NostrTools.SimplePool();
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Profile query timeout')), 5000)
|
||||
);
|
||||
const queryPromise = pool.querySync(relays, {
|
||||
kinds: [0],
|
||||
authors: [pubkey],
|
||||
limit: 1
|
||||
});
|
||||
const events = await Promise.race([queryPromise, timeoutPromise]);
|
||||
try { await pool.close(relays); } catch (e) {}
|
||||
|
||||
if (events && events.length > 0) {
|
||||
const profile = JSON.parse(events[0].content);
|
||||
// Use best_name resolution: display_name first, then name.
|
||||
const name = profile.display_name || profile.name || profile.displayName || 'Anonymous User';
|
||||
const picture = profile.picture || profile.image || null;
|
||||
applyProfileToHeader(name, picture);
|
||||
console.log('[admin2] profile loaded from public relays for', name);
|
||||
} else {
|
||||
if (headerUserName) headerUserName.textContent = 'Anonymous User';
|
||||
console.log('[admin2] no profile event found for', pubkey);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[admin2] profile load failed:', err.message);
|
||||
if (headerUserName) headerUserName.textContent = npub ? npub.substring(0, 16) + '…' : 'Error loading profile';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize nostr_login_lite and show modal if not already authenticated
|
||||
async function initializeAuth() {
|
||||
if (!window.NOSTR_LOGIN_LITE) {
|
||||
console.warn('[admin2] NOSTR_LOGIN_LITE not loaded — skipping auth modal');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await window.NOSTR_LOGIN_LITE.init({
|
||||
theme: 'default',
|
||||
methods: {
|
||||
extension: true,
|
||||
local: true,
|
||||
seedphrase: true,
|
||||
readonly: true,
|
||||
connect: true,
|
||||
remote: true,
|
||||
otp: false
|
||||
},
|
||||
floatingTab: { enabled: false }
|
||||
});
|
||||
nlLite = window.NOSTR_LOGIN_LITE;
|
||||
console.log('[admin2] nostr_login_lite initialized');
|
||||
|
||||
// Check for existing auth state
|
||||
let alreadyLoggedIn = false;
|
||||
try {
|
||||
const stored = localStorage.getItem('nostr_login_lite_auth');
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed && parsed.pubkey) {
|
||||
userPubkey = parsed.pubkey;
|
||||
isLoggedIn = true;
|
||||
alreadyLoggedIn = true;
|
||||
showProfileInHeader();
|
||||
updateProfileDisplay(userPubkey);
|
||||
hideLoginModal();
|
||||
console.log('[admin2] existing auth restored for', userPubkey);
|
||||
}
|
||||
}
|
||||
} catch (e) { /* no stored auth */ }
|
||||
|
||||
if (!alreadyLoggedIn) {
|
||||
console.log('[admin2] no existing auth — showing login modal');
|
||||
showLoginModal();
|
||||
}
|
||||
|
||||
// Listen for auth events
|
||||
window.addEventListener('nlMethodSelected', (event) => {
|
||||
const { pubkey, method, error } = event.detail || {};
|
||||
if (method && pubkey) {
|
||||
userPubkey = pubkey;
|
||||
isLoggedIn = true;
|
||||
console.log('[admin2] login success via', method, pubkey);
|
||||
hideLoginModal();
|
||||
showProfileInHeader();
|
||||
updateProfileDisplay(pubkey);
|
||||
} else if (error) {
|
||||
console.warn('[admin2] auth error:', error);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('nlLogout', () => {
|
||||
console.log('[admin2] logout event received');
|
||||
userPubkey = null;
|
||||
isLoggedIn = false;
|
||||
hideProfileFromHeader();
|
||||
if (logoutDropdown) logoutDropdown.style.display = 'none';
|
||||
showLoginModal();
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('[admin2] nostr_login_lite init failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Logout function — clears auth state and re-shows login modal
|
||||
async function logout() {
|
||||
console.log('[admin2] logging out...');
|
||||
try {
|
||||
if (nlLite && typeof nlLite.logout === 'function') {
|
||||
await nlLite.logout();
|
||||
}
|
||||
} catch (e) { console.warn('[admin2] nlLite.logout error:', e); }
|
||||
userPubkey = null;
|
||||
isLoggedIn = false;
|
||||
hideProfileFromHeader();
|
||||
if (logoutDropdown) logoutDropdown.style.display = 'none';
|
||||
showLoginModal();
|
||||
console.log('[admin2] logged out');
|
||||
}
|
||||
|
||||
// ================================
|
||||
// INIT
|
||||
// ================================
|
||||
|
||||
switchPage('statistics');
|
||||
|
||||
// Start the RELAY animation immediately on page load
|
||||
startRelayAnimation();
|
||||
|
||||
// Initialize auth + load initial chart on DOM ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(initializeAuth, 100);
|
||||
// Load the default (hour) chart immediately
|
||||
loadChart(currentChartRange);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
New Events — Last 24 Hours
|
||||
|
||||
11 | X
|
||||
10 | XX
|
||||
9 | XX
|
||||
8 | XX
|
||||
7 | XX X X
|
||||
6 | XX X X
|
||||
5 | XX X X
|
||||
4 | X XX X XX
|
||||
3 | X XXX X XX
|
||||
2 | X X X XXX X XX
|
||||
1 | X XX X X X X X X X X X X X X XX X X XXXX X X X X X XX
|
||||
+--------------------------------------------------------------------------------
|
||||
0s 1h 3h 4h 6h 7h 9h 10h 12h 13h 15h 16h 18h 19h 21h 22h
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
New Events — Last 30 Days
|
||||
|
||||
81 | X
|
||||
73 | XX X
|
||||
65 | XX X
|
||||
57 | XX X X X
|
||||
49 | X X XX XX X X X
|
||||
41 | X X X XX X X X XXX XXX XXX X X X X
|
||||
33 | X X X XXXX XX X X X XX XXX XXXXXXXX XX X X X X X XX
|
||||
25 |X XX XX XX XXXX XX XXX X XX XXXXXXXXXXXXXXXXXXXX XX XXX X XX XX XXXXX
|
||||
17 |X XXXXX XX XXXXXXXXXXXXXXX X XX XXXXXXXXXXXXXXXXXXXXXXXX XXXXXXXXX XX XXXXXXXX
|
||||
9 |XXX XXXXXXXX XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX XXXXXXXXXXXXXXXXXXXXX
|
||||
1 |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
+--------------------------------------------------------------------------------
|
||||
0s 1d 3d 5d 7d 9d 11d 13d 15d 16d 18d 20d 22d 24d 26d 28d
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
New Events — Last Year
|
||||
|
||||
531 | X XX
|
||||
478 | X X X XXX X
|
||||
425 | X X XX X XX XXX X
|
||||
372 | XXX XX X XX XXX X
|
||||
319 |XXXXXXXXXXX X X X XXXXXXX X
|
||||
266 |XXXXXXXXXXXX X XX XXX XXXXXXXXX X
|
||||
213 |XXXXXXXXXXXXXX XXX XXXXXXXXXXXXX XX X
|
||||
160 |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX X X XXXX X
|
||||
107 |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX XX XX XX X X X XX XXX X XXXXXXX X
|
||||
54 |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX XXXXXXXXXXXXXXXXXXXXX XXXXXXXXXXX
|
||||
1 |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
+--------------------------------------------------------------------------------
|
||||
0s 22d 45d 68d 91d 114d 136d 159d 182d 205d 228d 250d 273d 296d 319d 342d
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
<?php
|
||||
/**
|
||||
* admin2 — Full PHP admin for C-Relay-PG.
|
||||
* Recreates the original api/ admin interface with a PHP/PDO backend
|
||||
* instead of Nostr WebSocket admin commands.
|
||||
*/
|
||||
require_once __DIR__ . '/lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
// Fetch relay info for the header (from config table)
|
||||
$relay_name = '';
|
||||
$relay_desc = '';
|
||||
$relay_pubkey = '';
|
||||
$relay_version = '';
|
||||
try {
|
||||
$relay_name = $pdo->query("SELECT value FROM config WHERE key = 'relay_name'")->fetchColumn() ?: 'C-Relay-PG';
|
||||
$relay_desc = $pdo->query("SELECT value FROM config WHERE key = 'relay_description'")->fetchColumn() ?: '';
|
||||
$relay_pubkey = $pdo->query("SELECT value FROM config WHERE key = 'relay_pubkey'")->fetchColumn() ?: '';
|
||||
$relay_version = $pdo->query("SELECT value FROM config WHERE key = 'relay_version'")->fetchColumn() ?: '';
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Convert relay pubkey (hex) to npub and format into 3 lines of 3 groups of 7 chars
|
||||
$relay_npub = $relay_pubkey;
|
||||
if ($relay_pubkey && preg_match('/^[0-9a-fA-F]{64}$/', $relay_pubkey)) {
|
||||
$relay_npub = hex_to_npub($relay_pubkey);
|
||||
}
|
||||
// Format npub (63 chars) into 3 lines of "xxxxxxx xxxxxxx xxxxxxx"
|
||||
$formatted_npub = $relay_npub;
|
||||
if (strlen($relay_npub) === 63) {
|
||||
$line1 = substr($relay_npub, 0, 7) . ' ' . substr($relay_npub, 7, 7) . ' ' . substr($relay_npub, 14, 7);
|
||||
$line2 = substr($relay_npub, 21, 7) . ' ' . substr($relay_npub, 28, 7) . ' ' . substr($relay_npub, 35, 7);
|
||||
$line3 = substr($relay_npub, 42, 7) . ' ' . substr($relay_npub, 49, 7) . ' ' . substr($relay_npub, 56, 7);
|
||||
$formatted_npub = $line1 . "\n" . $line2 . "\n" . $line3;
|
||||
}
|
||||
$display_name = $relay_version ? ($relay_name . ' ' . $relay_version) : $relay_name;
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>C-Relay-PG Admin</title>
|
||||
<link rel="stylesheet" href="assets/index.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Side Navigation Menu -->
|
||||
<nav class="side-nav" id="side-nav">
|
||||
<ul class="nav-menu">
|
||||
<li><button class="nav-item" data-page="statistics">Statistics</button></li>
|
||||
<li><button class="nav-item" data-page="subscriptions">Subscriptions</button></li>
|
||||
<li><button class="nav-item" data-page="configuration">Configuration</button></li>
|
||||
<li><button class="nav-item" data-page="authorization">Authorization</button></li>
|
||||
<li><button class="nav-item" data-page="ip-bans">IP BAN</button></li>
|
||||
<li><button class="nav-item" data-page="relay-events">Relay Events</button></li>
|
||||
<li><button class="nav-item" data-page="caching">Caching</button></li>
|
||||
<li><button class="nav-item" data-page="dm">DM</button></li>
|
||||
<li><button class="nav-item" data-page="database">Database Query</button></li>
|
||||
</ul>
|
||||
<div class="nav-footer">
|
||||
<button class="nav-footer-btn" id="nav-dark-mode-btn">DARK MODE</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Side Navigation Overlay -->
|
||||
<div class="side-nav-overlay" id="side-nav-overlay"></div>
|
||||
|
||||
<!-- Header with title and profile display -->
|
||||
<div class="section">
|
||||
<div class="header-content">
|
||||
<div class="header-title clickable" id="header-title" onclick="toggleNav()">
|
||||
<span class="relay-letter" data-letter="R">R</span>
|
||||
<span class="relay-letter" data-letter="E">E</span>
|
||||
<span class="relay-letter" data-letter="L">L</span>
|
||||
<span class="relay-letter" data-letter="A">A</span>
|
||||
<span class="relay-letter" data-letter="Y">Y</span>
|
||||
</div>
|
||||
<div class="relay-info">
|
||||
<div id="relay-name" class="relay-name"><?= e($display_name) ?></div>
|
||||
<div id="relay-description" class="relay-description"><?= e($relay_desc) ?></div>
|
||||
<div id="relay-pubkey-container" class="relay-pubkey-container" title="Click to copy npub">
|
||||
<div id="relay-pubkey" class="relay-pubkey"><?= e($formatted_npub) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-area" id="profile-area" style="display: none;">
|
||||
<div class="admin-label">admin</div>
|
||||
<div class="profile-container">
|
||||
<img id="header-user-image" class="header-user-image" alt="Profile" style="display: none;">
|
||||
<span id="header-user-name" class="header-user-name">Loading...</span>
|
||||
</div>
|
||||
<!-- Logout dropdown (under admin picture) -->
|
||||
<div class="logout-dropdown" id="logout-dropdown" style="display: none;">
|
||||
<button type="button" class="logout-btn" onclick="logout()">LOGOUT</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Modal Overlay (nostr_login_lite) -->
|
||||
<div id="login-modal" class="login-modal-overlay" style="display: none;">
|
||||
<div class="login-modal-content">
|
||||
<div id="login-modal-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Denied Overlay (shown when non-admin user logs in) -->
|
||||
<div id="access-denied-overlay" class="access-denied-overlay" style="display: none;">
|
||||
<div class="access-denied-content">
|
||||
<div class="access-denied-icon">⛔</div>
|
||||
<h2>ACCESS DENIED</h2>
|
||||
<p class="access-denied-message">This interface is restricted to the relay administrator.</p>
|
||||
<p class="access-denied-submessage">The logged-in account does not have admin privileges.</p>
|
||||
<button type="button" class="access-denied-logout-btn" onclick="logout()">LOGOUT</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DATABASE STATISTICS Section -->
|
||||
<div class="section flex-section" id="databaseStatisticsSection">
|
||||
<div class="section-header">DATABASE STATISTICS</div>
|
||||
|
||||
<!-- Event Rate Graph — range selector tabs + ASCII chart (server-rendered) -->
|
||||
<div class="chart-range-tabs">
|
||||
<button class="chart-tab active" data-range="hour">1H</button>
|
||||
<button class="chart-tab" data-range="day">1D</button>
|
||||
<button class="chart-tab" data-range="month">1M</button>
|
||||
<button class="chart-tab" data-range="year">1Y</button>
|
||||
</div>
|
||||
<div id="event-rate-chart" class="event-rate-chart-container">Loading chart...</div>
|
||||
|
||||
<!-- Database Overview Table -->
|
||||
<div class="input-group">
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="stats-overview-table">
|
||||
<thead><tr><th>Metric</th><th>Value</th></tr></thead>
|
||||
<tbody id="stats-overview-table-body">
|
||||
<tr><td>Database Size</td><td id="db-size">-</td></tr>
|
||||
<tr><td>Total Events</td><td id="total-events">-</td></tr>
|
||||
<tr><td>Process ID</td><td id="process-id">-</td></tr>
|
||||
<tr><td>WebSocket Connections</td><td id="websocket-connections">-</td></tr>
|
||||
<tr><td>Active Subscriptions</td><td id="active-subscriptions">-</td></tr>
|
||||
<tr><td>Memory Usage</td><td id="memory-usage">-</td></tr>
|
||||
<tr><td>CPU Usage</td><td id="cpu-usage">-</td></tr>
|
||||
<tr><td>CPU Core</td><td id="cpu-core">-</td></tr>
|
||||
<tr><td>Oldest Event</td><td id="oldest-event">-</td></tr>
|
||||
<tr><td>Newest Event</td><td id="newest-event">-</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Kind Distribution Table -->
|
||||
<div class="input-group">
|
||||
<label>Event Kind Distribution:</label>
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="stats-kinds-table">
|
||||
<thead><tr><th>Event Kind</th><th>Count</th><th>Percentage</th></tr></thead>
|
||||
<tbody id="stats-kinds-table-body">
|
||||
<tr><td colspan="3" style="text-align: center; font-style: italic;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time-based Statistics Table -->
|
||||
<div class="input-group">
|
||||
<label>Time-based Statistics:</label>
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="stats-time-table">
|
||||
<thead><tr><th>Period</th><th>Events</th></tr></thead>
|
||||
<tbody id="stats-time-table-body">
|
||||
<tr><td>Last 24 Hours</td><td id="events-24h">-</td></tr>
|
||||
<tr><td>Last 7 Days</td><td id="events-7d">-</td></tr>
|
||||
<tr><td>Last 30 Days</td><td id="events-30d">-</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Pubkeys Table -->
|
||||
<div class="input-group">
|
||||
<label>Top Pubkeys by Event Count:</label>
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="stats-pubkeys-table">
|
||||
<thead><tr><th>Rank</th><th>Name</th><th>Pubkey</th><th>Event Count</th><th>Percentage</th></tr></thead>
|
||||
<tbody id="stats-pubkeys-table-body">
|
||||
<tr><td colspan="5" style="text-align: center; font-style: italic;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Name-field usage stats (from profiles cache) -->
|
||||
<div class="input-group" id="name-field-usage" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- SUBSCRIPTION DETAILS Section -->
|
||||
<div class="section flex-section" id="subscriptionDetailsSection" style="display: none;">
|
||||
<div class="section-header">ACTIVE SUBSCRIPTION DETAILS</div>
|
||||
<div class="input-group">
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="subscription-details-table">
|
||||
<thead><tr><th>Sub ID</th><th>Pubkey</th><th>Filters</th><th>Created</th><th>Events Sent</th></tr></thead>
|
||||
<tbody id="subscription-details-table-body">
|
||||
<tr><td colspan="5" style="text-align: center; font-style: italic;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CONFIGURATION Section -->
|
||||
<div id="div_config" class="section flex-section" style="display: none;">
|
||||
<div class="section-header">RELAY CONFIGURATION</div>
|
||||
<div id="config-display">
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="config-table">
|
||||
<thead><tr><th>Parameter</th><th>Value</th><th>Actions</th></tr></thead>
|
||||
<tbody id="config-table-body">
|
||||
<tr><td colspan="3" style="text-align: center; font-style: italic;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="inline-buttons">
|
||||
<button type="button" id="fetch-config-btn" onclick="loadConfig()">REFRESH</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AUTH RULES Section -->
|
||||
<div class="section flex-section" id="authRulesSection" style="display: none;">
|
||||
<div class="section-header">AUTH RULES MANAGEMENT</div>
|
||||
<div id="authRulesTableContainer">
|
||||
<table class="config-table" id="authRulesTable">
|
||||
<thead><tr><th>Rule Type</th><th>Pattern Type</th><th>Pattern Value</th><th>Status</th><th>Actions</th></tr></thead>
|
||||
<tbody id="authRulesTableBody">
|
||||
<tr><td colspan="5" style="text-align: center; font-style: italic;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="authRuleInputSections" style="display: block;">
|
||||
<div class="input-group">
|
||||
<label for="authRulePubkey">Public Key (npub or hex):</label>
|
||||
<input type="text" id="authRulePubkey" placeholder="npub1... or 64-character hex pubkey">
|
||||
</div>
|
||||
<div class="inline-buttons">
|
||||
<button type="button" onclick="addAuthRule('whitelist')">ADD TO WHITELIST</button>
|
||||
<button type="button" onclick="addAuthRule('blacklist')">ADD TO BLACKLIST</button>
|
||||
<button type="button" onclick="loadAuthRules()">REFRESH</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WEB OF TRUST Section -->
|
||||
<div class="section flex-section" id="wotSection" style="display: none;">
|
||||
<div class="section-header">WEB OF TRUST</div>
|
||||
<div id="wotKind3Status" class="wot-status-row">
|
||||
<span>Admin Contact List (kind 3):</span>
|
||||
<span id="wotKind3Indicator" class="wot-indicator wot-unknown">Loading...</span>
|
||||
</div>
|
||||
<div class="wot-level-selector">
|
||||
<label>WoT Level:</label>
|
||||
<div class="inline-buttons">
|
||||
<button type="button" class="wot-level-btn" onclick="setWotLevel(0)">OFF</button>
|
||||
<button type="button" class="wot-level-btn" onclick="setWotLevel(1)">WRITE ONLY</button>
|
||||
<button type="button" class="wot-level-btn" onclick="setWotLevel(2)">FULL</button>
|
||||
</div>
|
||||
<div class="wot-level-description" id="wotLevelDescription">Loading...</div>
|
||||
</div>
|
||||
<div class="wot-stats-row">
|
||||
<span>Whitelisted Pubkeys:</span>
|
||||
<span id="wotWhitelistCount">—</span>
|
||||
</div>
|
||||
<div class="inline-buttons">
|
||||
<button type="button" onclick="syncWot()">SYNC FROM KIND 3</button>
|
||||
<button type="button" onclick="loadWotStatus()">REFRESH STATUS</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NIP-17 DIRECT MESSAGES Section -->
|
||||
<div class="section" id="nip17DMSection" style="display: none;">
|
||||
<div class="section-header"><h2>NIP-17 DIRECT MESSAGES</h2></div>
|
||||
<div class="input-group">
|
||||
<label>Received Messages (kind 4/14/15):</label>
|
||||
<div id="dm-inbox" class="log-panel" style="height: 300px; overflow-y: auto;">
|
||||
<div class="log-entry">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IP BANS Section -->
|
||||
<div class="section" id="ipBansSection" style="display: none;">
|
||||
<div class="section-header">IP BAN MANAGEMENT</div>
|
||||
<div class="input-group">
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="ip-bans-stats-table">
|
||||
<thead><tr><th>Total IPs Tracked</th><th>Currently Banned</th><th>Total Bans Issued</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td id="ip-bans-total">-</td><td id="ip-bans-active">-</td><td id="ip-bans-issued">-</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<h3>Manually Ban IP Address</h3>
|
||||
<div class="form-group">
|
||||
<label for="ban-ip-input">IP Address:</label>
|
||||
<input type="text" id="ban-ip-input" placeholder="192.168.1.100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ban-duration-select">Ban Duration:</label>
|
||||
<select id="ban-duration-select">
|
||||
<option value="3600">1 Hour</option>
|
||||
<option value="86400" selected>24 Hours</option>
|
||||
<option value="604800">7 Days</option>
|
||||
<option value="2592000">30 Days</option>
|
||||
<option value="31536000">1 Year</option>
|
||||
<option value="999999999">Permanent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="inline-buttons">
|
||||
<button type="button" onclick="addBan()">BAN IP</button>
|
||||
<button type="button" onclick="loadIpBans()">REFRESH</button>
|
||||
</div>
|
||||
<div id="add-ban-status" class="status-message"></div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>Banned IP Addresses:</label>
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="ip-bans-table">
|
||||
<thead><tr><th>IP Address</th><th>Status</th><th>Banned Until</th><th>Failures</th><th>Actions</th></tr></thead>
|
||||
<tbody id="ip-bans-tbody">
|
||||
<tr><td colspan="5" style="text-align: center;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RELAY EVENTS Section -->
|
||||
<div class="section" id="relayEventsSection" style="display: none;">
|
||||
<div class="section-header">RELAY EVENTS MANAGEMENT</div>
|
||||
<div class="input-group">
|
||||
<h3>Recent Events</h3>
|
||||
<div class="inline-buttons">
|
||||
<button type="button" onclick="loadEvents()">REFRESH</button>
|
||||
</div>
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="live-relay-events-table">
|
||||
<thead><tr><th>Time</th><th>Kind</th><th>Name</th><th>ID</th><th>Content Preview</th></tr></thead>
|
||||
<tbody id="live-relay-events-table-body">
|
||||
<tr><td colspan="5" style="text-align: center;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CACHING Section -->
|
||||
<div class="section" id="cachingSection" style="display: none;">
|
||||
<div class="section-header">CACHING</div>
|
||||
<div class="input-group">
|
||||
<h3>Caching Controls</h3>
|
||||
<div id="caching-controls" class="inline-buttons">
|
||||
<span id="caching-enabled-label">Caching: ?</span>
|
||||
<button type="button" id="caching-toggle-btn" onclick="toggleCachingEnabled()">Toggle Caching</button>
|
||||
<span id="caching-inbox-enabled-label">Inbox: ?</span>
|
||||
<button type="button" id="caching-inbox-toggle-btn" onclick="toggleCachingInboxEnabled()">Toggle Inbox</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<h3>Caching Service Status</h3>
|
||||
<div id="caching-service-status" class="status-display">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
<div class="inline-buttons" style="margin-top:8px">
|
||||
<button type="button" id="caching-rerun-all-btn" onclick="rerunAllCaching()">Re-run All Caching</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<h3>Relay Inbox Status</h3>
|
||||
<div id="caching-inbox-status" class="status-display">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<h3>Followed Pubkeys <span style="font-size:11px;font-weight:normal;color:var(--text-muted,#888)">(click a row for per-relay details)</span></h3>
|
||||
<div id="caching-follows-status" class="status-message"></div>
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="caching-follows-table">
|
||||
<thead><tr><th>Name</th><th>npub</th><th>Root?</th><th>Events in DB</th><th>Backfill</th><th>Relays</th></tr></thead>
|
||||
<tbody id="caching-follows-table-body">
|
||||
<tr><td colspan="6" style="text-align: center; font-style: italic;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline-buttons">
|
||||
<button type="button" onclick="loadCaching()">REFRESH</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SQL QUERY Section -->
|
||||
<div class="section" id="sqlQuerySection" style="display: none;">
|
||||
<div class="section-header"><h2>SQL QUERY CONSOLE</h2></div>
|
||||
<div class="input-group">
|
||||
<label for="sql-input">SQL Query (SELECT only):</label>
|
||||
<textarea id="sql-input" rows="5" placeholder="SELECT * FROM events LIMIT 10"></textarea>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<div class="inline-buttons">
|
||||
<button type="button" onclick="executeQuery()">EXECUTE QUERY</button>
|
||||
<button type="button" onclick="document.getElementById('sql-input').value=''">CLEAR</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>Query Results:</label>
|
||||
<div id="query-info" class="info-box"></div>
|
||||
<div id="query-table" class="config-table-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nostr libraries for nostr_login_lite auth modal -->
|
||||
<script src="assets/nostr.bundle.js"></script>
|
||||
<script src="assets/nostr-lite.js"></script>
|
||||
<script src="assets/app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
/**
|
||||
* admin2/lib/ascii_chart.php — Server-side ASCII X-bar chart renderer.
|
||||
*
|
||||
* Produces a monospaced ASCII bar chart (using 'X' characters) from an
|
||||
* array of bin counts. Mirrors the layout of the original text_graph.js
|
||||
* ASCIIBarChart: Y-axis count labels, X-bar columns, X-axis with
|
||||
* elapsed-time labels.
|
||||
*
|
||||
* The output is a plain text string suitable for both browser <div>
|
||||
* injection (white-space: pre) and terminal display via curl.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Render an ASCII bar chart from an array of bin counts.
|
||||
*
|
||||
* @param array $bins Array of integer counts (one per time bin, left=oldest)
|
||||
* @param array $options {
|
||||
* @var string $title Chart title (centered at top)
|
||||
* @var int $max_height Chart height in rows (default 11)
|
||||
* @var string $x_axis_label Label below the X-axis (default '')
|
||||
* @var int $bin_duration Seconds per bin — for X-axis elapsed labels
|
||||
* @var int $label_interval Label every N bins (default 5)
|
||||
* }
|
||||
* @return string The ASCII chart as a multi-line string.
|
||||
*/
|
||||
function render_ascii_chart(array $bins, array $options = []): string {
|
||||
$title = $options['title'] ?? 'New Events';
|
||||
$max_height = $options['max_height'] ?? 11;
|
||||
$x_axis_label = $options['x_axis_label'] ?? '';
|
||||
$bin_duration = $options['bin_duration'] ?? 10;
|
||||
$label_interval = $options['label_interval'] ?? 5;
|
||||
|
||||
$num_bins = count($bins);
|
||||
if ($num_bins === 0) {
|
||||
return "No data available.\n";
|
||||
}
|
||||
|
||||
$max_count = max($bins);
|
||||
if ($max_count < 1) $max_count = 1; // Avoid division by zero for all-empty bins
|
||||
|
||||
// Scaling: each X represents scale_factor counts
|
||||
$scale_factor = max(1, (int)ceil($max_count / $max_height));
|
||||
$scaled_max = (int)ceil($max_count / $scale_factor) * $scale_factor;
|
||||
|
||||
$output = '';
|
||||
|
||||
// --- Title (centered) ---
|
||||
$chart_width = 4 + $num_bins; // 4 = Y-axis number width (3) + separator (1)
|
||||
if ($title !== '') {
|
||||
$title_padding = (int)floor(($chart_width - strlen($title)) / 2);
|
||||
if ($title_padding < 0) $title_padding = 0;
|
||||
$output .= str_repeat(' ', $title_padding) . $title . "\n\n";
|
||||
}
|
||||
|
||||
// --- Bar rows (top to bottom) ---
|
||||
for ($row = $max_height; $row > 0; $row--) {
|
||||
$row_count = ($row - 1) * $scale_factor + 1;
|
||||
$line = str_pad((string)$row_count, 3, ' ', STR_PAD_LEFT) . ' |';
|
||||
|
||||
for ($i = 0; $i < $num_bins; $i++) {
|
||||
$count = $bins[$i];
|
||||
$scaled_height = ($count > 0) ? (int)ceil($count / $scale_factor) : 0;
|
||||
$line .= ($scaled_height >= $row) ? 'X' : ' ';
|
||||
}
|
||||
$output .= $line . "\n";
|
||||
}
|
||||
|
||||
// --- X-axis line ---
|
||||
$output .= ' +' . str_repeat('-', $num_bins) . "\n";
|
||||
|
||||
// --- X-axis labels (elapsed time every label_interval bins) ---
|
||||
$label_line = ' ';
|
||||
$labels = [];
|
||||
for ($i = 0; $i < $num_bins; $i++) {
|
||||
if ($i % $label_interval === 0) {
|
||||
$elapsed_sec = $i * $bin_duration;
|
||||
$labels[] = format_elapsed_time($elapsed_sec);
|
||||
}
|
||||
}
|
||||
// Build label line with spacing
|
||||
for ($i = 0; $i < count($labels); $i++) {
|
||||
$label_line .= $labels[$i];
|
||||
if ($i < count($labels) - 1) {
|
||||
$spacing = $label_interval - strlen($labels[$i]);
|
||||
if ($spacing < 1) $spacing = 1;
|
||||
$label_line .= str_repeat(' ', $spacing);
|
||||
}
|
||||
}
|
||||
// Pad to match X-axis dash line length
|
||||
$min_label_len = 4 + $num_bins;
|
||||
if (strlen($label_line) < $min_label_len) {
|
||||
$label_line .= str_repeat(' ', $min_label_len - strlen($label_line));
|
||||
}
|
||||
$output .= $label_line . "\n";
|
||||
|
||||
// --- X-axis label (if provided) ---
|
||||
if ($x_axis_label !== '') {
|
||||
$label_pad = (int)floor(($num_bins - strlen($x_axis_label)) / 2);
|
||||
if ($label_pad < 0) $label_pad = 0;
|
||||
$output .= "\n" . ' ' . str_repeat(' ', $label_pad) . $x_axis_label . "\n";
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an elapsed time in seconds as a compact label.
|
||||
* < 60s → "Ns"
|
||||
* < 1h → "Nm"
|
||||
* < 1d → "Nh"
|
||||
* else → "Nd"
|
||||
*/
|
||||
function format_elapsed_time(int $seconds): string {
|
||||
if ($seconds < 60) {
|
||||
return $seconds . 's';
|
||||
} elseif ($seconds < 3600) {
|
||||
return (int)floor($seconds / 60) . 'm';
|
||||
} elseif ($seconds < 86400) {
|
||||
return (int)floor($seconds / 3600) . 'h';
|
||||
} else {
|
||||
return (int)floor($seconds / 86400) . 'd';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fixed-length bin array from SQL query rows.
|
||||
*
|
||||
* The query returns only non-empty bins. This function creates a
|
||||
* zero-filled array of $num_bins length and overlays the counts
|
||||
* at the correct positions, so empty time slots show as blank
|
||||
* columns — the chart always advances in time.
|
||||
*
|
||||
* @param array $rows Query rows with 'bin' (int) and 'cnt' (int)
|
||||
* @param int $num_bins Total number of bins (fixed length)
|
||||
* @return array Zero-filled array of counts
|
||||
*/
|
||||
function build_bin_array(array $rows, int $num_bins): array {
|
||||
$bins = array_fill(0, $num_bins, 0);
|
||||
foreach ($rows as $r) {
|
||||
$idx = (int)$r['bin'];
|
||||
if ($idx >= 0 && $idx < $num_bins) {
|
||||
$bins[$idx] = (int)$r['cnt'];
|
||||
}
|
||||
}
|
||||
return $bins;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
/**
|
||||
* C-Relay-PG Admin — Database connection configuration.
|
||||
*
|
||||
* This file is NOT web-accessible (denied by nginx location block).
|
||||
* It contains the PostgreSQL connection string used by all admin pages.
|
||||
*
|
||||
* Edit these values to match your relay's PostgreSQL credentials.
|
||||
*/
|
||||
|
||||
// PostgreSQL connection parameters.
|
||||
// These should match the --db-* flags passed to the c-relay-pg binary
|
||||
// or the connection string passed to caching_relay -p "...".
|
||||
return [
|
||||
'db_host' => 'localhost',
|
||||
'db_port' => '5432',
|
||||
'db_name' => 'crelay',
|
||||
'db_user' => 'crelay',
|
||||
'db_password' => 'crelay',
|
||||
|
||||
// Page sizes for paginated tables.
|
||||
'follows_per_page' => 50,
|
||||
'relays_per_page' => 50,
|
||||
|
||||
// Auto-refresh interval for dashboard polling (milliseconds).
|
||||
'refresh_ms' => 10000,
|
||||
];
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
/**
|
||||
* C-Relay-PG Admin — PDO database connection helper.
|
||||
*
|
||||
* Provides a singleton PDO connection to the relay's PostgreSQL database.
|
||||
* All admin pages use db() to get the connection.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
function db(): PDO {
|
||||
static $pdo = null;
|
||||
if ($pdo === null) {
|
||||
$cfg = require __DIR__ . '/config.php';
|
||||
// Build DSN — supports both TCP (host=localhost) and Unix socket
|
||||
// (host=/var/run/postgresql). Omit password if empty (peer auth).
|
||||
$dsn = sprintf(
|
||||
'pgsql:host=%s;port=%d;dbname=%s;user=%s',
|
||||
$cfg['db_host'],
|
||||
$cfg['db_port'],
|
||||
$cfg['db_name'],
|
||||
$cfg['db_user']
|
||||
);
|
||||
if (!empty($cfg['db_password'])) {
|
||||
$dsn .= ';password=' . $cfg['db_password'];
|
||||
}
|
||||
try {
|
||||
$pdo = new PDO($dsn, null, null, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
http_response_code(500);
|
||||
die('Database connection failed: ' . htmlspecialchars($e->getMessage()));
|
||||
}
|
||||
}
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
/** Get a config value from the admin config file. */
|
||||
function cfg(string $key, $default = null) {
|
||||
static $cfg = null;
|
||||
if ($cfg === null) {
|
||||
$cfg = require __DIR__ . '/config.php';
|
||||
}
|
||||
return $cfg[$key] ?? $default;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
/**
|
||||
* C-Relay-PG Admin — Shared helper functions.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
/** HTML-escape a string. */
|
||||
function e(?string $s): string {
|
||||
return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
/** Format a Unix timestamp as a human-readable relative time. */
|
||||
function time_ago(int $ts): string {
|
||||
if ($ts <= 0) return 'never';
|
||||
$diff = time() - $ts;
|
||||
if ($diff < 60) return $diff . 's ago';
|
||||
if ($diff < 3600) return floor($diff / 60) . 'm ago';
|
||||
if ($diff < 86400) return floor($diff / 3600) . 'h ago';
|
||||
return floor($diff / 86400) . 'd ago';
|
||||
}
|
||||
|
||||
/** Format a Unix timestamp as a date/time string. */
|
||||
function fmt_date(int $ts): string {
|
||||
if ($ts <= 0) return '-';
|
||||
return date('Y-m-d H:i:s', $ts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a 64-char hex pubkey to npub (bech32).
|
||||
* Uses the standard bech32 encoding (NIP-19).
|
||||
*/
|
||||
function hex_to_npub(string $hex): string {
|
||||
if (!preg_match('/^[0-9a-fA-F]{64}$/', $hex)) return $hex;
|
||||
$hex = strtolower($hex);
|
||||
|
||||
// Bech32 constants
|
||||
$charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
||||
$data = [];
|
||||
for ($i = 0; $i < strlen($hex); $i += 2) {
|
||||
$data[] = intval(substr($hex, $i, 2), 16);
|
||||
}
|
||||
// Convert 8-bit groups to 5-bit groups
|
||||
$conv = [];
|
||||
$buffer = 0;
|
||||
$bits = 0;
|
||||
foreach ($data as $byte) {
|
||||
$buffer = ($buffer << 8) | $byte;
|
||||
$bits += 8;
|
||||
while ($bits >= 5) {
|
||||
$conv[] = ($buffer >> ($bits - 5)) & 31;
|
||||
$bits -= 5;
|
||||
}
|
||||
}
|
||||
if ($bits > 0) {
|
||||
$conv[] = ($buffer << (5 - $bits)) & 31;
|
||||
}
|
||||
|
||||
// Compute checksum — HRP values are positions of each char in the charset.
|
||||
$values = array_merge(
|
||||
array_map(function($c) {
|
||||
return strpos('qpzry9x8gf2tvdw0s3jn54khce6mua7l', $c);
|
||||
}, str_split('npub')),
|
||||
$conv
|
||||
);
|
||||
$gen = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
||||
$chk = 1;
|
||||
foreach ($values as $v) {
|
||||
$top = $chk >> 25;
|
||||
$chk = (($chk & 0x1ffffff) << 5) ^ $v;
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
if (($top >> $i) & 1) $chk ^= $gen[$i];
|
||||
}
|
||||
}
|
||||
$chk ^= 1;
|
||||
$checksum = [];
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$checksum[] = ($chk >> (5 * (5 - $i))) & 31;
|
||||
}
|
||||
|
||||
$result = 'npub1';
|
||||
foreach (array_merge($conv, $checksum) as $v) {
|
||||
$result .= $charset[$v];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** Truncate a string to $len chars with ellipsis. */
|
||||
function trunc(string $s, int $len = 16): string {
|
||||
if (strlen($s) <= $len) return $s;
|
||||
return substr($s, 0, $len) . '…';
|
||||
}
|
||||
|
||||
/** Build pagination links HTML. */
|
||||
function pagination(int $page, int $per_page, int $total, string $base_url): string {
|
||||
$total_pages = max(1, ceil($total / $per_page));
|
||||
if ($total_pages <= 1) return '';
|
||||
|
||||
$html = '<div class="pagination">';
|
||||
if ($page > 1) {
|
||||
$html .= '<a href="' . e($base_url) . '?page=' . ($page - 1) . '">← Prev</a>';
|
||||
}
|
||||
$html .= '<span>Page ' . $page . ' of ' . $total_pages . ' (' . $total . ' total)</span>';
|
||||
if ($page < $total_pages) {
|
||||
$html .= '<a href="' . e($base_url) . '?page=' . ($page + 1) . '">Next →</a>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
/** Get the current page number from the query string. */
|
||||
function current_page(): int {
|
||||
$p = intval($_GET['page'] ?? 1);
|
||||
return max(1, $p);
|
||||
}
|
||||
|
||||
/** JSON response helper for AJAX endpoints. */
|
||||
function json_response($data): void {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
/** Get a query parameter with a default. */
|
||||
function query_param(string $key, $default = null) {
|
||||
return $_GET[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-resolve profiles from the profiles cache table.
|
||||
* Returns [pubkey_hex => ['name'=>..., 'display_name'=>...,
|
||||
* 'best_name'=>..., 'picture'=>..., 'nip05'=>...]].
|
||||
* Pubkeys with no cached profile are absent from the result.
|
||||
*/
|
||||
function profile_map(array $pubkeys): array {
|
||||
if (empty($pubkeys)) return [];
|
||||
$pdo = db();
|
||||
$map = [];
|
||||
try {
|
||||
// Build parameterized IN clause: WHERE pubkey = ANY(?)
|
||||
// PDO PostgreSQL supports passing an array as a string literal.
|
||||
$placeholders = implode(',', array_fill(0, count($pubkeys), '?'));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT pubkey, name, display_name, picture, nip05 FROM profiles "
|
||||
. "WHERE pubkey IN ($placeholders)"
|
||||
);
|
||||
$stmt->execute(array_values($pubkeys));
|
||||
$rows = $stmt->fetchAll();
|
||||
foreach ($rows as $r) {
|
||||
$map[$r['pubkey']] = [
|
||||
'name' => $r['name'] ?? '',
|
||||
'display_name' => $r['display_name'] ?? '',
|
||||
'best_name' => profile_display_name($r),
|
||||
'picture' => $r['picture'] ?? '',
|
||||
'nip05' => $r['nip05'] ?? '',
|
||||
];
|
||||
}
|
||||
} catch (PDOException $e) {}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the profile_name_preference config key to resolve the display name.
|
||||
* Never returns null. Returns '' if neither field is set.
|
||||
*/
|
||||
function profile_display_name(array $profile): string {
|
||||
$name = $profile['name'] ?? '';
|
||||
$display_name = $profile['display_name'] ?? '';
|
||||
static $pref = null;
|
||||
if ($pref === null) {
|
||||
try {
|
||||
$pdo = db();
|
||||
$pref = $pdo->query("SELECT value FROM config WHERE key = 'profile_name_preference'")->fetchColumn() ?: 'display_name';
|
||||
} catch (PDOException $e) {
|
||||
$pref = 'display_name';
|
||||
}
|
||||
}
|
||||
if ($pref === 'name') {
|
||||
return $name !== '' ? $name : $display_name;
|
||||
}
|
||||
// Default: prefer display_name, fall back to name.
|
||||
return $display_name !== '' ? $display_name : $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the shared HTML header with side navigation.
|
||||
* Pass the active page name to highlight the current nav item.
|
||||
*/
|
||||
function admin_header(string $active = '', string $title = 'C-Relay-PG Admin'): void {
|
||||
$pages = [
|
||||
'dashboard' => ['index.php', 'Dashboard'],
|
||||
'follows' => ['follows.php', 'Follows'],
|
||||
'relays' => ['relays.php', 'Relays'],
|
||||
'config' => ['config-edit.php', 'Config'],
|
||||
'inbox' => ['inbox.php', 'Inbox'],
|
||||
];
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= e($title) ?></title>
|
||||
<link rel="stylesheet" href="assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Side Navigation -->
|
||||
<nav class="side-nav" id="side-nav">
|
||||
<ul class="nav-menu">
|
||||
<?php foreach ($pages as $key => [$url, $label]): ?>
|
||||
<li><a class="nav-item<?= $active === $key ? ' active' : '' ?>" href="<?= e($url) ?>"><?= e($label) ?></a></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="side-nav-overlay" id="side-nav-overlay" onclick="closeNav()"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="main-header">
|
||||
<div class="header-content">
|
||||
<button class="menu-btn" onclick="openNav()">☰</button>
|
||||
<div class="header-title" onclick="location.href='index.php'">
|
||||
<span class="relay-letter">R</span><span class="relay-letter">E</span><span class="relay-letter">L</span><span class="relay-letter">A</span><span class="relay-letter">Y</span>
|
||||
</div>
|
||||
<div style="width:40px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openNav() {
|
||||
document.getElementById('side-nav').classList.add('open');
|
||||
document.getElementById('side-nav-overlay').classList.add('show');
|
||||
}
|
||||
function closeNav() {
|
||||
document.getElementById('side-nav').classList.remove('open');
|
||||
document.getElementById('side-nav-overlay').classList.remove('show');
|
||||
}
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
|
||||
/** Render the shared HTML footer. */
|
||||
function admin_footer(): void {
|
||||
?>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# Launch the admin2 PHP built-in server, fully detached from the calling shell.
|
||||
# Usage: ./admin2/serve.sh [port] (default port 8088)
|
||||
PORT="${1:-8088}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Kill any existing instance on this port.
|
||||
pkill -f "php -S 127.0.0.1:$PORT" 2>/dev/null
|
||||
sleep 1
|
||||
|
||||
# Start fully detached in its own session so SIGTERM to the launcher
|
||||
# does not propagate to the PHP server.
|
||||
setsid php -S 127.0.0.1:$PORT -t "$SCRIPT_DIR" > "$SCRIPT_DIR/php_server.log" 2>&1 < /dev/null &
|
||||
PHP_PID=$!
|
||||
disown $PHP_PID 2>/dev/null
|
||||
|
||||
# Give it a moment to bind, then report.
|
||||
sleep 1
|
||||
if kill -0 "$PHP_PID" 2>/dev/null; then
|
||||
echo "admin PHP server started on http://127.0.0.1:$PORT (PID $PHP_PID)"
|
||||
echo "Serving from: $SCRIPT_DIR"
|
||||
echo "Log: $SCRIPT_DIR/php_server.log"
|
||||
else
|
||||
echo "ERROR: PHP server failed to start. Log:"
|
||||
cat "$SCRIPT_DIR/php_server.log" 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
+24
-2
@@ -204,6 +204,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Name</th>
|
||||
<th>Pubkey</th>
|
||||
<th>Event Count</th>
|
||||
<th>Percentage</th>
|
||||
@@ -211,7 +212,7 @@
|
||||
</thead>
|
||||
<tbody id="stats-pubkeys-table-body">
|
||||
<tr>
|
||||
<td colspan="4" style="text-align: center; font-style: italic;">No data loaded</td>
|
||||
<td colspan="5" style="text-align: center; font-style: italic;">No data loaded</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -676,7 +677,7 @@ wss://laantungir.net/relay</textarea>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="caching-inbox-batch-size">Inbox Batch Size:</label>
|
||||
<input type="number" id="caching-inbox-batch-size" placeholder="25">
|
||||
<input type="number" id="caching-inbox-batch-size" placeholder="150">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -715,6 +716,27 @@ wss://laantungir.net/relay</textarea>
|
||||
</div>
|
||||
<div id="caching-service-control-status" class="status-message"></div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<h3>Followed Pubkeys</h3>
|
||||
<p>Pubkeys being cached with their profile names, event counts, outbox relays, and backfill status.</p>
|
||||
<div id="caching-follows-status" class="status-message"></div>
|
||||
<table id="caching-follows-table" style="width: 100%; border-collapse: collapse; font-size: 13px;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 2px solid var(--border-color, #444); text-align: left;">
|
||||
<th style="padding: 6px 8px;">Name</th>
|
||||
<th style="padding: 6px 8px;">npub</th>
|
||||
<th style="padding: 6px 8px;">Root?</th>
|
||||
<th style="padding: 6px 8px;">Events in DB</th>
|
||||
<th style="padding: 6px 8px;">Backfill</th>
|
||||
<th style="padding: 6px 8px; width: 40%;">Relays (status, events fetched)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="caching-follows-table-body">
|
||||
<tr><td colspan="6" style="text-align: center; font-style: italic; padding: 12px;">Loading follows status...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SQL QUERY Section -->
|
||||
|
||||
+239
-8
@@ -2092,6 +2092,11 @@ function handleSystemCommandResponse(responseData) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle caching follows status response
|
||||
if (responseData.command === 'caching_follows_status') {
|
||||
handleCachingFollowsResponse(responseData);
|
||||
}
|
||||
|
||||
if (typeof logTestEvent === 'function') {
|
||||
logTestEvent('RECV', `System command response: ${responseData.command} - ${responseData.status}`, 'SYSTEM_CMD');
|
||||
}
|
||||
@@ -4849,7 +4854,7 @@ function populateStatsPubkeys(data) {
|
||||
|
||||
if (data.top_pubkeys.length === 0) {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = '<td colspan="4" style="text-align: center; font-style: italic;">No pubkey data</td>';
|
||||
row.innerHTML = '<td colspan="5" style="text-align: center; font-style: italic;">No pubkey data</td>';
|
||||
tableBody.appendChild(row);
|
||||
return;
|
||||
}
|
||||
@@ -4868,8 +4873,12 @@ function populateStatsPubkeys(data) {
|
||||
} catch (error) {
|
||||
console.log('Failed to encode pubkey to npub:', error.message);
|
||||
}
|
||||
// Name from kind-0 metadata (enriched by backend)
|
||||
const name = pubkey.name || '';
|
||||
const nameDisplay = name ? escapeHtml(name) : '<span style="color: var(--text-muted, #888); font-style: italic;">unknown</span>';
|
||||
row.innerHTML = `
|
||||
<td>${index + 1}</td>
|
||||
<td>${nameDisplay}</td>
|
||||
<td style="font-family: 'Courier New', monospace; font-size: 12px; word-break: break-all;">${npubLink}</td>
|
||||
<td>${pubkey.event_count}</td>
|
||||
<td>${pubkey.percentage}%</td>
|
||||
@@ -4887,7 +4896,7 @@ function populateStatsPubkeysFromMonitoring(pubkeysData, totalEvents) {
|
||||
|
||||
if (pubkeysData.length === 0) {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = '<td colspan="4" style="text-align: center; font-style: italic;">No pubkey data</td>';
|
||||
row.innerHTML = '<td colspan="5" style="text-align: center; font-style: italic;">No pubkey data</td>';
|
||||
tableBody.appendChild(row);
|
||||
return;
|
||||
}
|
||||
@@ -4910,8 +4919,13 @@ function populateStatsPubkeysFromMonitoring(pubkeysData, totalEvents) {
|
||||
// Calculate percentage using totalEvents parameter
|
||||
const percentage = totalEvents > 0 ? ((pubkey.event_count / totalEvents) * 100).toFixed(1) : '0.0';
|
||||
|
||||
// Name from kind-0 metadata (enriched by backend)
|
||||
const name = pubkey.name || '';
|
||||
const nameDisplay = name ? escapeHtml(name) : '<span style="color: var(--text-muted, #888); font-style: italic;">unknown</span>';
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${index + 1}</td>
|
||||
<td>${nameDisplay}</td>
|
||||
<td style="font-family: 'Courier New', monospace; font-size: 12px; word-break: break-all;">${npubLink}</td>
|
||||
<td>${pubkey.event_count}</td>
|
||||
<td>${percentage}%</td>
|
||||
@@ -5446,6 +5460,9 @@ function switchPage(pageName) {
|
||||
fetchCachingStatus().catch(error => {
|
||||
console.log('Failed to refresh caching status on page switch: ' + error.message);
|
||||
});
|
||||
fetchCachingFollowsStatus().catch(error => {
|
||||
console.log('Failed to refresh caching follows status on page switch: ' + error.message);
|
||||
});
|
||||
// Start periodic refresh every 10 seconds while the caching page is visible
|
||||
if (cachingRefreshInterval) {
|
||||
clearInterval(cachingRefreshInterval);
|
||||
@@ -5455,6 +5472,9 @@ function switchPage(pageName) {
|
||||
fetchCachingStatus().catch(error => {
|
||||
console.log('Periodic caching status refresh failed: ' + error.message);
|
||||
});
|
||||
fetchCachingFollowsStatus().catch(error => {
|
||||
console.log('Periodic caching follows refresh failed: ' + error.message);
|
||||
});
|
||||
}
|
||||
}, 10000);
|
||||
} else {
|
||||
@@ -6882,7 +6902,14 @@ async function fetchCachingStatus() {
|
||||
waited += 200;
|
||||
}
|
||||
|
||||
// Populate form fields from currentConfig
|
||||
// Populate form fields from currentConfig.
|
||||
// IMPORTANT: skip fields the user is actively editing so the periodic
|
||||
// auto-refresh (every 10s via cachingRefreshInterval) does not clobber
|
||||
// in-progress edits before "Apply configuration" is pressed. A field is
|
||||
// considered "being edited" if it currently has focus OR it has been
|
||||
// modified by the user since the last populate (dirty). Dirty state is
|
||||
// tracked by the input/change listeners installed in
|
||||
// initCachingEventListeners() via markCachingFieldDirty().
|
||||
CACHING_CONFIG_FIELDS.forEach(field => {
|
||||
const value = getCachingConfigValue(field.key);
|
||||
const el = document.getElementById(field.field);
|
||||
@@ -6892,6 +6919,15 @@ async function fetchCachingStatus() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't stomp on a field the user is currently focused on.
|
||||
if (document.activeElement === el) {
|
||||
return;
|
||||
}
|
||||
// Don't stomp on a field the user has edited but not yet applied.
|
||||
if (el.dataset.cachingDirty === '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.type === 'textarea-list') {
|
||||
// Config stores comma-separated; display as newline-separated
|
||||
el.value = String(value).split(',').map(s => s.trim()).filter(s => s.length > 0).join('\n');
|
||||
@@ -6965,20 +7001,28 @@ async function applyCachingConfig() {
|
||||
// Convert newlines back to comma-separated for storage
|
||||
value = value.split('\n').map(s => s.trim()).filter(s => s.length > 0).join(',');
|
||||
} else if (field.type === 'integer') {
|
||||
// Normalize empty to 0
|
||||
// Trim whitespace; leave empty for the skip check below.
|
||||
// Do NOT coerce empty to '0' — the backend validates numeric
|
||||
// ranges (e.g. caching_inbox_batch_size 1-500) and rejects 0,
|
||||
// and config_update is atomic, so a single rejected field
|
||||
// rolls back the entire batch (including unrelated boolean
|
||||
// toggles like caching_enabled). Empty here means the form
|
||||
// field was not populated, so we skip it and preserve the
|
||||
// existing stored value.
|
||||
value = String(value).trim();
|
||||
if (value === '') value = '0';
|
||||
} else {
|
||||
value = String(value).trim();
|
||||
}
|
||||
|
||||
// Skip empty values for optional string/textarea-list fields.
|
||||
// Skip empty values for optional string/textarea-list/integer fields.
|
||||
// The backend (update_config_in_table) rejects empty strings to
|
||||
// prevent accidental data loss, and config_update is atomic —
|
||||
// one rejected field rolls back the entire batch. Fields like
|
||||
// caching_root_npubs and caching_bootstrap_relays are optional
|
||||
// and may legitimately be empty until configured.
|
||||
if (value === '' && (field.type === 'textarea-list' || field.type === 'string')) {
|
||||
// and may legitimately be empty until configured. Integer fields
|
||||
// are also skipped when empty so their existing DB value is
|
||||
// preserved instead of being overwritten with an out-of-range 0.
|
||||
if (value === '' && (field.type === 'textarea-list' || field.type === 'string' || field.type === 'integer')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7022,6 +7066,10 @@ async function applyCachingConfig() {
|
||||
}
|
||||
log('Caching configuration applied successfully', 'INFO');
|
||||
|
||||
// Clear dirty flags so the upcoming refresh can repopulate fields from
|
||||
// the freshly-applied DB values without skipping user-edited fields.
|
||||
clearCachingFieldsDirty();
|
||||
|
||||
// Refresh status to reflect applied values
|
||||
setTimeout(() => fetchCachingStatus().catch(() => {}), 1500);
|
||||
|
||||
@@ -7101,12 +7149,18 @@ function handleCachingStatusResponse(responseData) {
|
||||
const running = pick(service.running, data.running);
|
||||
const connectedRelays = pick(service.connected_relays, data.connected_relays);
|
||||
const eventsCached = pick(service.events_cached, data.events_cached);
|
||||
// Per-author until-cursor drain backfill progress.
|
||||
const bfComplete = pick(service.backfill_authors_complete, data.backfill_authors_complete);
|
||||
const bfTotal = pick(service.backfill_authors_total, data.backfill_authors_total);
|
||||
|
||||
let html = '<ul style="list-style:none;padding:0;margin:0;">';
|
||||
if (enabled !== null) html += `<li><strong>Enabled:</strong> ${escapeHtml(String(enabled))}</li>`;
|
||||
if (running !== null) html += `<li><strong>Running:</strong> ${escapeHtml(String(running))}</li>`;
|
||||
if (connectedRelays !== null) html += `<li><strong>Connected Relays:</strong> ${escapeHtml(String(connectedRelays))}</li>`;
|
||||
if (eventsCached !== null) html += `<li><strong>Events Cached:</strong> ${escapeHtml(String(eventsCached))}</li>`;
|
||||
if (bfComplete !== null && bfTotal !== null) {
|
||||
html += `<li><strong>Backfill:</strong> ${escapeHtml(String(bfComplete))}/${escapeHtml(String(bfTotal))} authors complete</li>`;
|
||||
}
|
||||
html += '</ul>';
|
||||
serviceStatusEl.innerHTML = html;
|
||||
}
|
||||
@@ -7149,6 +7203,156 @@ function handleCachingStatusResponse(responseData) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch caching follows status (per-follow detail with usernames, event counts, relays)
|
||||
async function fetchCachingFollowsStatus() {
|
||||
try {
|
||||
if (!isLoggedIn || !userPubkey) {
|
||||
throw new Error('Must be logged in to fetch caching follows status');
|
||||
}
|
||||
await sendAdminCommand(['system_command', 'caching_follows_status']);
|
||||
} catch (e) {
|
||||
console.log('caching_follows_status command failed: ' + e.message);
|
||||
const tbody = document.getElementById('caching-follows-table-body');
|
||||
if (tbody) {
|
||||
tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; color: red; padding: 8px;">Failed to load: ${escapeHtml(e.message || 'Unknown error')}</td></tr>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle caching_follows_status response and render the follows table
|
||||
function handleCachingFollowsResponse(responseData) {
|
||||
const tbody = document.getElementById('caching-follows-table-body');
|
||||
const statusEl = document.getElementById('caching-follows-status');
|
||||
if (!tbody) return;
|
||||
|
||||
if (responseData.status !== 'success') {
|
||||
tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; color: red; padding: 8px;">Error: ${escapeHtml(responseData.message || 'Unknown error')}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const follows = responseData.follows || [];
|
||||
const totalFollows = responseData.total_follows || follows.length;
|
||||
const totalEvents = responseData.total_events_in_db || 0;
|
||||
|
||||
// Active backfill target (written by the caching_relay process to PG).
|
||||
const activePk = responseData.active_backfill_pubkey || '';
|
||||
const activeRelay = responseData.active_backfill_relay || '';
|
||||
|
||||
if (statusEl) {
|
||||
let html = `<span style="color: var(--text-muted, #888);">${totalFollows} follows, ${totalEvents} total events in DB</span>`;
|
||||
if (activePk && activeRelay) {
|
||||
html += `<br><span style="color: var(--accent-color, #ff0);">⚡ Working on: ${escapeHtml(activeRelay)}</span>`;
|
||||
} else if (activePk) {
|
||||
html += `<br><span style="color: var(--accent-color, #ff0);">⚡ Working on: ${escapeHtml(activePk.substring(0,12))}...</span>`;
|
||||
} else {
|
||||
// No active backfill target — check if all follows are complete.
|
||||
const allComplete = follows.length > 0 && follows.every(f => f.backfill_complete);
|
||||
if (allComplete) {
|
||||
html += `<br><span style="color: green;">✓ Caching complete</span>`;
|
||||
}
|
||||
}
|
||||
statusEl.innerHTML = html;
|
||||
}
|
||||
|
||||
if (follows.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; font-style: italic; padding: 12px;">No followed pubkeys found.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by total_events_in_db descending (most active first)
|
||||
follows.sort((a, b) => (b.total_events_in_db || 0) - (a.total_events_in_db || 0));
|
||||
|
||||
tbody.innerHTML = '';
|
||||
follows.forEach((follow, index) => {
|
||||
const row = document.createElement('tr');
|
||||
row.style.borderBottom = '1px solid var(--border-color, #333)';
|
||||
|
||||
// Highlight the row if this is the currently-active backfill target.
|
||||
const isActiveAuthor = activePk && follow.pubkey === activePk;
|
||||
if (isActiveAuthor) {
|
||||
row.style.borderLeft = '4px solid red';
|
||||
row.style.borderRight = '4px solid red';
|
||||
row.style.borderTop = '2px solid red';
|
||||
row.style.borderBottom = '2px solid red';
|
||||
}
|
||||
|
||||
// Name column
|
||||
const name = follow.name || '';
|
||||
const nameDisplay = name ? escapeHtml(name) : '<span style="color: var(--text-muted, #888); font-style: italic;">unknown</span>';
|
||||
|
||||
// npub column (convert hex to npub)
|
||||
let npubLink = follow.pubkey ? follow.pubkey.substring(0, 12) + '...' : '-';
|
||||
try {
|
||||
if (follow.pubkey && follow.pubkey.length === 64 && /^[0-9a-fA-F]+$/.test(follow.pubkey)) {
|
||||
const npub = window.NostrTools.nip19.npubEncode(follow.pubkey);
|
||||
npubLink = `<a href="https://njump.me/${npub}" target="_blank" class="npub-link" style="font-family: 'Courier New', monospace; font-size: 11px;">${npub.substring(0, 20)}...</a>`;
|
||||
}
|
||||
} catch (e) { /* keep short hex */ }
|
||||
|
||||
// Root badge
|
||||
const rootBadge = follow.is_root
|
||||
? '<span style="color: var(--accent-color, #ff0); font-weight: bold;">★ Root</span>'
|
||||
: '<span style="color: var(--text-muted, #888);">—</span>';
|
||||
|
||||
// Events in DB
|
||||
const totalInDb = follow.total_events_in_db || 0;
|
||||
|
||||
// Backfill status
|
||||
const relayProgress = follow.relay_progress || [];
|
||||
const doneRelays = relayProgress.filter(r => r.complete).length;
|
||||
const totalRelays = relayProgress.length;
|
||||
const bfStatus = follow.backfill_complete
|
||||
? `<span style="color: green;">✓ Complete</span>`
|
||||
: `<span style="color: orange;">${doneRelays}/${totalRelays} relays</span>`;
|
||||
|
||||
// Outbox relays with per-relay event counts from relay_progress.
|
||||
// Normalize URLs by stripping trailing slashes for dedup.
|
||||
const normUrl = u => u ? u.replace(/\/+$/, '') : u;
|
||||
const relayProgressMap = {};
|
||||
(follow.relay_progress || []).forEach(rp => {
|
||||
relayProgressMap[normUrl(rp.relay_url)] = rp;
|
||||
});
|
||||
const outboxRelays = (follow.outbox_relays || []).map(normUrl);
|
||||
// Merge outbox relays with relay_progress relays, deduplicating by
|
||||
// normalized URL. Relays in relay_progress that aren't in outbox_relays
|
||||
// are bootstrap relays that were queried but aren't outbox relays.
|
||||
const allRelays = [...new Set([...outboxRelays, ...Object.keys(relayProgressMap)])];
|
||||
const outboxDisplay = allRelays.length > 0
|
||||
? allRelays.map(r => {
|
||||
const short = escapeHtml(r.replace('wss://', '').replace('https://', '').replace('ws://', ''));
|
||||
const rp = relayProgressMap[r];
|
||||
// Highlight if this is the relay currently being queried.
|
||||
const isActiveRelay = isActiveAuthor && activeRelay && r === normUrl(activeRelay);
|
||||
const activePrefix = isActiveRelay ? '⚡ ' : '';
|
||||
if (rp) {
|
||||
const evCount = rp.events_fetched || 0;
|
||||
const done = rp.complete ? '✓' : '⏳';
|
||||
const status = rp.last_status || '';
|
||||
const statusDisplay = status ? ` <span style="color: var(--text-muted, #888); font-size: 10px;">[${escapeHtml(status)}]</span>` : '';
|
||||
const color = isActiveRelay
|
||||
? 'color: red; font-weight: bold;'
|
||||
: (evCount > 0 ? 'color: green;' : 'color: var(--text-muted, #888);');
|
||||
return `<span style="${color}">${activePrefix}${done} ${short} (${evCount})${statusDisplay}</span>`;
|
||||
}
|
||||
const color = isActiveRelay
|
||||
? 'color: red; font-weight: bold;'
|
||||
: 'color: var(--text-muted, #888);';
|
||||
return `<span style="${color}">${activePrefix}${short}</span>`;
|
||||
}).join('<br>')
|
||||
: '<span style="color: var(--text-muted, #888); font-style: italic;">none</span>';
|
||||
|
||||
row.innerHTML = `
|
||||
<td style="padding: 6px 8px;">${nameDisplay}</td>
|
||||
<td style="padding: 6px 8px;">${npubLink}</td>
|
||||
<td style="padding: 6px 8px; text-align: center;">${rootBadge}</td>
|
||||
<td style="padding: 6px 8px; text-align: right;">${totalInDb}</td>
|
||||
<td style="padding: 6px 8px;">${bfStatus}</td>
|
||||
<td style="padding: 6px 8px; font-size: 11px;">${outboxDisplay}</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Start the external caching service
|
||||
async function startCachingService() {
|
||||
const statusDiv = document.getElementById('caching-service-control-status');
|
||||
@@ -7199,6 +7403,21 @@ async function stopCachingService() {
|
||||
}
|
||||
}
|
||||
|
||||
// Mark a caching config field as dirty (user-modified) so the periodic
|
||||
// fetchCachingStatus() refresh does not overwrite in-progress edits.
|
||||
function markCachingFieldDirty() {
|
||||
this.dataset.cachingDirty = '1';
|
||||
}
|
||||
|
||||
// Clear the dirty flag on all caching config fields. Called after a successful
|
||||
// apply so subsequent refreshes can repopulate fields from the DB again.
|
||||
function clearCachingFieldsDirty() {
|
||||
CACHING_CONFIG_FIELDS.forEach(field => {
|
||||
const el = document.getElementById(field.field);
|
||||
if (el) delete el.dataset.cachingDirty;
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize Caching page event listeners
|
||||
function initCachingEventListeners() {
|
||||
const applyBtn = document.getElementById('caching-apply-btn');
|
||||
@@ -7219,6 +7438,18 @@ function initCachingEventListeners() {
|
||||
stopServiceBtn.addEventListener('click', stopCachingService);
|
||||
}
|
||||
|
||||
// Track user edits to caching config fields so the 10s auto-refresh
|
||||
// (fetchCachingStatus -> populate form) does not clobber in-progress
|
||||
// edits before "Apply configuration" is pressed. Both 'input' (fires
|
||||
// on every keystroke) and 'change' (fires on blur/commit for selects
|
||||
// and number inputs) are tracked.
|
||||
CACHING_CONFIG_FIELDS.forEach(field => {
|
||||
const el = document.getElementById(field.field);
|
||||
if (!el) return;
|
||||
el.addEventListener('input', markCachingFieldDirty);
|
||||
el.addEventListener('change', markCachingFieldDirty);
|
||||
});
|
||||
|
||||
console.log('Caching page event listeners initialized');
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ RUN apk add --no-cache \
|
||||
curl-dev \
|
||||
curl-static \
|
||||
sqlite-dev \
|
||||
postgresql-dev \
|
||||
linux-headers \
|
||||
wget \
|
||||
bash
|
||||
@@ -103,13 +104,15 @@ RUN if [ "$DEBUG_BUILD" = "true" ]; then \
|
||||
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
|
||||
-I. -Isrc -Inostr_core_lib -Inostr_core_lib/nostr_core \
|
||||
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \
|
||||
-I/usr/include/postgresql \
|
||||
src/main.c src/debug.c src/jsonc_strip.c src/config.c src/state.c \
|
||||
src/follow_graph.c src/relay_sink.c src/live_subscriber.c \
|
||||
src/backfill.c src/relay_discovery.c \
|
||||
src/pg_inbox.c src/pg_config.c src/forward_catchup.c \
|
||||
-o /build/caching_relay_static \
|
||||
nostr_core_lib/libnostr_core.a \
|
||||
-lwebsockets -lssl -lcrypto -lsecp256k1 \
|
||||
-lcurl -lz -lpthread -lm -ldl && \
|
||||
-lcurl -lz -lpthread -lm -ldl -lpq -lpgcommon -lpgport && \
|
||||
eval "$STRIP_CMD"
|
||||
|
||||
# Verify it's truly static
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ SHELL := /bin/bash
|
||||
CC = gcc
|
||||
CFLAGS = -Wall -Wextra -std=c99 -g -O2
|
||||
|
||||
# nostr_core_lib is a sibling of c-relay-pg (one dir up from caching/).
|
||||
# nostr_core_lib is inside the c-relay-pg project root (one dir up from caching/).
|
||||
NOSTR_CORE_DIR = ../nostr_core_lib
|
||||
|
||||
INCLUDES = -I. -Isrc -I$(NOSTR_CORE_DIR) -I$(NOSTR_CORE_DIR)/nostr_core \
|
||||
@@ -22,7 +22,7 @@ LIBS = -lwebsockets -lssl -lcrypto -lsecp256k1 -lcurl -lz -ldl -lpthread -lm -lp
|
||||
|
||||
MAIN_SRC = src/main.c src/debug.c src/jsonc_strip.c src/config.c src/state.c \
|
||||
src/follow_graph.c src/relay_sink.c src/live_subscriber.c \
|
||||
src/backfill.c src/relay_discovery.c \
|
||||
src/backfill.c src/forward_catchup.c src/relay_discovery.c \
|
||||
src/pg_inbox.c src/pg_config.c
|
||||
|
||||
# Architecture detection
|
||||
|
||||
@@ -83,9 +83,8 @@ else
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# nostr_core_lib is a sibling directory; copy it into the build context.
|
||||
# Docker cannot follow symlinks outside the context, so we do a real copy
|
||||
# (excluding .git and build artifacts) and clean up afterwards.
|
||||
# nostr_core_lib is inside the c-relay-pg project root (one dir up from caching/).
|
||||
# Copy it into the Docker build context (excluding .git and build artifacts).
|
||||
NOSTR_CORE_DIR="$SCRIPT_DIR/../nostr_core_lib"
|
||||
if [ ! -d "$NOSTR_CORE_DIR" ]; then
|
||||
echo "ERROR: nostr_core_lib not found at $NOSTR_CORE_DIR"
|
||||
|
||||
+319
-295
@@ -1,10 +1,16 @@
|
||||
/*
|
||||
* caching_relay - progressive window-expansion backfill
|
||||
* caching_relay - relay-by-relay per-author until-cursor drain backfill.
|
||||
*
|
||||
* Uses until-based pagination so authors with more events than the page size
|
||||
* are fully drained across multiple ticks instead of being skipped. In
|
||||
* PostgreSQL mode, per-author/window progress is persisted to the
|
||||
* caching_backfill_progress table so the service can resume after restart.
|
||||
* Each (author, relay) pair has its own persistent until_cursor stored in
|
||||
* the caching_backfill_relay_progress table. Each backfill tick picks the
|
||||
* next author that still has at least one incomplete relay (round-robin),
|
||||
* then queries each incomplete relay one at a time with
|
||||
* since=0 & until=cursor & limit=page_size
|
||||
* publishes the page, and advances that relay's cursor to
|
||||
* oldest_event_created_at - 1. A relay is marked complete when a page
|
||||
* returns fewer than page_size events (the relay is drained for this
|
||||
* author). An author is marked complete in caching_followed_pubkeys when
|
||||
* all their relay progress rows are complete.
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include "backfill.h"
|
||||
@@ -18,6 +24,84 @@
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Global access for status reporting */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Singleton pointer set in cr_backfill_init, used by the accessor
|
||||
* functions below so the main loop / status reporter can see what
|
||||
* the backfill is currently working on. */
|
||||
static cr_backfill_t *g_bf = NULL;
|
||||
|
||||
const char *cr_backfill_active_pubkey(void) {
|
||||
return g_bf ? g_bf->active_pubkey : "";
|
||||
}
|
||||
const char *cr_backfill_active_relay(void) {
|
||||
return g_bf ? g_bf->active_relay : "";
|
||||
}
|
||||
int cr_backfill_active_got_eose(void) {
|
||||
return g_bf ? g_bf->active_relay_got_eose : 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* EOSE callback for synchronous_query_relays_with_progress */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Context passed to the query callback. Records whether the relay sent
|
||||
* EOSE (we got everything) or timed out (partial result). */
|
||||
typedef struct {
|
||||
int got_eose; /* 1 if EOSE received, 0 if timed out/errored */
|
||||
int events_count; /* events reported by callback */
|
||||
char last_status[256]; /* last status string from callback (may include message) */
|
||||
} backfill_query_ctx_t;
|
||||
|
||||
static void backfill_query_callback(const char *relay_url, const char *status,
|
||||
const char *event_id, int events_received,
|
||||
int total_relays, int completed_relays,
|
||||
void *user_data) {
|
||||
(void)relay_url; (void)total_relays; (void)completed_relays;
|
||||
backfill_query_ctx_t *ctx = (backfill_query_ctx_t *)user_data;
|
||||
if (!ctx || !status) return;
|
||||
|
||||
/* Ignore the synthetic "all_complete" status from core_relays.c — it
|
||||
* is sent AFTER the real EOSE/timeout and would overwrite the real
|
||||
* status with a misleading "all_complete" string, and clobber
|
||||
* events_count with total_unique_events. The real per-relay status
|
||||
* (eose/timeout/error) is what we need for completion decisions. */
|
||||
if (strcmp(status, "all_complete") == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* For NOTICE/CLOSED/error, event_id carries the descriptive message
|
||||
* content (relay's own text for NOTICE/CLOSED, or our transport-level
|
||||
* diagnosis for error). Include it in the status for display.
|
||||
* Priority: relay's own response (NOTICE/CLOSED) > our error > bare
|
||||
* status string. */
|
||||
if ((strcmp(status, "NOTICE") == 0 || strcmp(status, "CLOSED") == 0) &&
|
||||
event_id && event_id[0] != '\0') {
|
||||
snprintf(ctx->last_status, sizeof(ctx->last_status), "%s: %s", status, event_id);
|
||||
} else if (strcmp(status, "error") == 0) {
|
||||
if (event_id && event_id[0] != '\0') {
|
||||
snprintf(ctx->last_status, sizeof(ctx->last_status), "error: %s", event_id);
|
||||
} else {
|
||||
snprintf(ctx->last_status, sizeof(ctx->last_status), "error");
|
||||
}
|
||||
} else {
|
||||
snprintf(ctx->last_status, sizeof(ctx->last_status), "%s", status);
|
||||
}
|
||||
|
||||
if (strcmp(status, "eose") == 0) {
|
||||
ctx->got_eose = 1;
|
||||
} else if (strcmp(status, "timeout") == 0) {
|
||||
ctx->got_eose = 0;
|
||||
} else if (strcmp(status, "error") == 0) {
|
||||
ctx->got_eose = 0;
|
||||
}
|
||||
/* NOTICE/CLOSED don't change got_eose — the relay might still send
|
||||
* events or EOSE after a NOTICE. */
|
||||
ctx->events_count = events_received;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -50,14 +134,22 @@ static int find_oldest_created_at(cJSON **events, int count, long *out_oldest) {
|
||||
return found;
|
||||
}
|
||||
|
||||
/* Count how many events in the array have created_at == ts. */
|
||||
static int count_at_timestamp(cJSON **events, int count, long ts) {
|
||||
int n = 0;
|
||||
/* Find the newest (maximum) created_at among an array of event JSON objects.
|
||||
* Returns 1 and sets *out_newest, or 0 if no events / no valid created_at. */
|
||||
static int find_newest_created_at(cJSON **events, int count, long *out_newest) {
|
||||
long newest = 0;
|
||||
int found = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
cJSON *ca = cJSON_GetObjectItem(events[i], "created_at");
|
||||
if (ca && cJSON_IsNumber(ca) && (long)ca->valuedouble == ts) n++;
|
||||
if (!ca || !cJSON_IsNumber(ca)) continue;
|
||||
long ts = (long)ca->valuedouble;
|
||||
if (!found || ts > newest) {
|
||||
newest = ts;
|
||||
found = 1;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
if (found && out_newest) *out_newest = newest;
|
||||
return found;
|
||||
}
|
||||
|
||||
/* Build the kinds array for a given pubkey (admin vs regular).
|
||||
@@ -89,146 +181,90 @@ static int resolve_relays(nostr_relay_pool_t *upstream,
|
||||
*out_urls = NULL;
|
||||
*out_n = 0;
|
||||
|
||||
/* Collect outbox relays for this author (from kind-10002). */
|
||||
int outbox_count = 0;
|
||||
const cr_outbox_entry_t *oe = NULL;
|
||||
if (relay_map) {
|
||||
const cr_outbox_entry_t *oe = cr_relay_map_get_outbox(relay_map, pk);
|
||||
if (oe && oe->relay_count > 0) {
|
||||
const char **urls = malloc(oe->relay_count * sizeof(char *));
|
||||
if (!urls) return -1;
|
||||
for (int j = 0; j < oe->relay_count; j++) urls[j] = oe->relays[j];
|
||||
*out_urls = urls;
|
||||
*out_n = oe->relay_count;
|
||||
return 0;
|
||||
oe = cr_relay_map_get_outbox(relay_map, pk);
|
||||
if (oe) outbox_count = oe->relay_count;
|
||||
}
|
||||
|
||||
/* Also collect all upstream (bootstrap) relays. We query BOTH the
|
||||
* author's outbox relays AND the bootstrap relays to maximize coverage.
|
||||
* Different relays may have different subsets of the author's events. */
|
||||
char **listed = NULL;
|
||||
nostr_pool_relay_status_t *statuses = NULL;
|
||||
int upstream_count = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
|
||||
|
||||
/* Merge outbox + upstream, deduplicating by URL. */
|
||||
int max_total = outbox_count + upstream_count;
|
||||
if (max_total <= 0) {
|
||||
free(listed);
|
||||
free(statuses);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char **urls = malloc(max_total * sizeof(char *));
|
||||
if (!urls) { free(listed); free(statuses); return -1; }
|
||||
|
||||
int n = 0;
|
||||
|
||||
/* Add outbox relays first. */
|
||||
if (oe) {
|
||||
for (int j = 0; j < oe->relay_count && n < max_total; j++) {
|
||||
urls[n++] = oe->relays[j];
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback: all upstream relays. */
|
||||
char **listed = NULL;
|
||||
nostr_pool_relay_status_t *statuses = NULL;
|
||||
int n = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
|
||||
if (n > 0) {
|
||||
const char **urls = malloc(n * sizeof(char *));
|
||||
if (!urls) { free(listed); free(statuses); return -1; }
|
||||
for (int j = 0; j < n; j++) urls[j] = listed[j];
|
||||
*out_urls = urls;
|
||||
*out_n = n;
|
||||
/* Add upstream relays, skipping duplicates. */
|
||||
for (int j = 0; j < upstream_count && n < max_total; j++) {
|
||||
int dup = 0;
|
||||
for (int k = 0; k < n; k++) {
|
||||
if (strcmp(urls[k], listed[j]) == 0) { dup = 1; break; }
|
||||
}
|
||||
if (!dup) {
|
||||
urls[n++] = listed[j];
|
||||
}
|
||||
}
|
||||
|
||||
free(listed);
|
||||
free(statuses);
|
||||
|
||||
*out_urls = urls;
|
||||
*out_n = n;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Issue a query_sync for one author with the given since/until/limit.
|
||||
* Returns the events array (caller frees each event + the array) or NULL.
|
||||
* Sets *out_count. */
|
||||
static cJSON **query_author(nostr_relay_pool_t *upstream,
|
||||
const cr_relay_map_t *relay_map,
|
||||
cr_config_t *cfg, const char *pk,
|
||||
long since, long until, int limit,
|
||||
int *out_count) {
|
||||
cJSON *filter = cJSON_CreateObject();
|
||||
cJSON *authors = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(pk));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
|
||||
cJSON *kinds = build_kinds(cfg, pk);
|
||||
if (kinds) cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
|
||||
cJSON_AddItemToObject(filter, "since", cJSON_CreateNumber((double)since));
|
||||
cJSON_AddItemToObject(filter, "until", cJSON_CreateNumber((double)until));
|
||||
cJSON_AddItemToObject(filter, "limit", cJSON_CreateNumber((double)limit));
|
||||
|
||||
const char **urls = NULL;
|
||||
int n = 0;
|
||||
resolve_relays(upstream, relay_map, pk, &urls, &n);
|
||||
|
||||
cJSON **events = nostr_relay_pool_query_sync(upstream, urls, n, filter,
|
||||
out_count, 15000);
|
||||
free(urls);
|
||||
cJSON_Delete(filter);
|
||||
return events;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Window advancement */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void advance_window(cr_backfill_t *bf, cr_config_t *cfg) {
|
||||
/* Record that we've backfilled up to (now - current_window). */
|
||||
cfg->state.backfilled_until = (long)time(NULL) - bf->current_window_s;
|
||||
cfg->state.current_window_index = bf->window_index + 1;
|
||||
cfg->state.backfill_cursor = 0;
|
||||
if (!pg_mode_active()) {
|
||||
cr_config_save_state(cfg);
|
||||
}
|
||||
|
||||
DEBUG_INFO("backfill: window %d complete (%ld events). backfilled_until=%ld",
|
||||
bf->window_index, bf->events_this_window, cfg->state.backfilled_until);
|
||||
|
||||
bf->window_index++;
|
||||
if (bf->window_index >= cfg->backfill.window_count) {
|
||||
DEBUG_INFO("backfill: all windows complete, entering steady-state");
|
||||
bf->in_progress = 0;
|
||||
return;
|
||||
}
|
||||
bf->current_window_s = cfg->backfill.window_schedule_seconds[bf->window_index];
|
||||
bf->cursor = 0;
|
||||
bf->until_cursor = 0;
|
||||
bf->author_complete = 0;
|
||||
bf->blocked = 0;
|
||||
bf->blocked_until_ts = 0;
|
||||
bf->events_this_window = 0;
|
||||
bf->window_started = time(NULL);
|
||||
DEBUG_INFO("backfill: advancing to window %d (%ld seconds)",
|
||||
bf->window_index, bf->current_window_s);
|
||||
}
|
||||
|
||||
/* Persist the current cursor state. In PG mode we rely on the per-author
|
||||
* progress table; in legacy mode we write the .jsonc state. */
|
||||
static void persist_cursor(cr_backfill_t *bf, cr_config_t *cfg) {
|
||||
cfg->state.backfill_cursor = bf->cursor;
|
||||
if (!pg_mode_active()) {
|
||||
cr_config_save_state(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Save progress for the current author to PG (no-op outside PG mode). */
|
||||
static void pg_save_current(cr_backfill_t *bf, cr_config_t *cfg,
|
||||
const char *pk, int complete) {
|
||||
(void)cfg; /* window anchor is derived from bf + time(), cfg not needed */
|
||||
if (!pg_mode_active() || !pk) return;
|
||||
long anchor = (long)time(NULL) - bf->current_window_s;
|
||||
pg_inbox_save_backfill_progress(pk, bf->window_index, anchor,
|
||||
bf->until_cursor, complete);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Init */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
void cr_backfill_init(cr_backfill_t *bf, cr_config_t *cfg) {
|
||||
(void)cfg;
|
||||
memset(bf, 0, sizeof(*bf));
|
||||
bf->window_index = cfg->state.current_window_index;
|
||||
if (bf->window_index >= cfg->backfill.window_count) {
|
||||
/* Already done; steady state. */
|
||||
bf->in_progress = 0;
|
||||
DEBUG_INFO("backfill: already complete (window index %d >= %d), steady-state",
|
||||
bf->window_index, cfg->backfill.window_count);
|
||||
g_bf = bf; /* Set singleton for status accessors */
|
||||
|
||||
/* In PG mode, check whether any incomplete authors remain in the DB.
|
||||
* If so, backfill is in progress; otherwise we're in steady state.
|
||||
* Outside PG mode there is no per-author table, so we just start. */
|
||||
if (pg_mode_active()) {
|
||||
int complete = 0, total = 0;
|
||||
if (pg_inbox_count_backfill_progress(&complete, &total) == 0) {
|
||||
bf->in_progress = (total > 0 && complete < total) ? 1 : 0;
|
||||
DEBUG_INFO("backfill: init complete=%d/%d -> in_progress=%d",
|
||||
complete, total, bf->in_progress);
|
||||
return;
|
||||
}
|
||||
/* Count query failed - assume in progress so we keep trying. */
|
||||
bf->in_progress = 1;
|
||||
DEBUG_WARN("backfill: init could not read progress counts, "
|
||||
"defaulting to in_progress=1");
|
||||
return;
|
||||
}
|
||||
bf->current_window_s = cfg->backfill.window_schedule_seconds[bf->window_index];
|
||||
bf->cursor = cfg->state.backfill_cursor;
|
||||
bf->until_cursor = 0;
|
||||
bf->author_complete = 0;
|
||||
bf->blocked = 0;
|
||||
bf->blocked_until_ts = 0;
|
||||
bf->in_progress = 1;
|
||||
bf->window_started = time(NULL);
|
||||
|
||||
/* In PG mode, per-author restore is deferred to the first tick (the
|
||||
* followed set is not resolved yet at init time). The tick loop skips
|
||||
* already-complete authors via the progress table. */
|
||||
DEBUG_INFO("backfill: starting at window %d (%ld seconds), cursor %d",
|
||||
bf->window_index, bf->current_window_s, bf->cursor);
|
||||
/* Legacy (non-PG) mode: no per-author table; just start. */
|
||||
bf->in_progress = 1;
|
||||
DEBUG_INFO("backfill: init (legacy mode) in_progress=1");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -236,196 +272,184 @@ void cr_backfill_init(cr_backfill_t *bf, cr_config_t *cfg) {
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
int cr_backfill_tick(cr_backfill_t *bf, cr_config_t *cfg,
|
||||
nostr_relay_pool_t *upstream, cr_pubkey_set_t *followed,
|
||||
nostr_relay_pool_t *upstream,
|
||||
cr_pubkey_set_t *followed,
|
||||
cr_sink_t *sink, const cr_relay_map_t *relay_map) {
|
||||
(void)followed; /* followed set is tracked in the DB now */
|
||||
(void)relay_map; /* relays are tracked per-author in the DB */
|
||||
|
||||
if (!cfg->backfill.enabled) return -2;
|
||||
if (!bf->in_progress) return -2;
|
||||
if (followed->count == 0) return 0;
|
||||
|
||||
/* Throttle: only one query per tick_interval. */
|
||||
/* Throttle: one query per tick_interval. */
|
||||
time_t now = time(NULL);
|
||||
if (bf->last_tick && (now - bf->last_tick) < cfg->backfill.tick_interval_seconds) {
|
||||
if (bf->last_tick && (now - bf->last_tick) < cfg->backfill.tick_interval_seconds)
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* In PG mode, skip past authors already marked complete for this window
|
||||
* (resume case). Restore the until_cursor for the first incomplete author. */
|
||||
if (pg_mode_active()) {
|
||||
while (bf->cursor < followed->count) {
|
||||
const char *pk = followed->items[bf->cursor];
|
||||
long uc = 0;
|
||||
int complete = 0, found = 0;
|
||||
if (pg_inbox_load_backfill_progress(pk, bf->window_index,
|
||||
&uc, &complete, &found) == 0
|
||||
&& found && complete) {
|
||||
DEBUG_TRACE("backfill: author[%d] %s already complete, skipping",
|
||||
bf->cursor, pk);
|
||||
bf->cursor++;
|
||||
cfg->state.backfill_cursor = bf->cursor;
|
||||
continue;
|
||||
}
|
||||
if (found && uc > 0 && bf->until_cursor == 0) {
|
||||
bf->until_cursor = uc;
|
||||
DEBUG_TRACE("backfill: resumed author[%d] %s at until=%ld",
|
||||
bf->cursor, pk, bf->until_cursor);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (bf->cursor >= followed->count) {
|
||||
advance_window(bf, cfg);
|
||||
return 1;
|
||||
}
|
||||
/* Pick the next author that has at least one incomplete relay progress
|
||||
* row (round-robin). */
|
||||
char pk[CR_HEX_LEN];
|
||||
if (pg_inbox_pick_next_author_with_incomplete_relays(pk, sizeof(pk),
|
||||
&bf->author_round_cursor) != 0) {
|
||||
/* No incomplete authors - steady state. */
|
||||
bf->in_progress = 0;
|
||||
DEBUG_INFO("backfill: no authors with incomplete relays, steady-state");
|
||||
return -2;
|
||||
}
|
||||
|
||||
const char *pk = followed->items[bf->cursor];
|
||||
|
||||
/* Determine the until cursor for this query. First query for an author:
|
||||
* until = now. Subsequent queries: until = oldest event created_at - 1
|
||||
* from the previous page (set at the end of the previous tick). */
|
||||
long until = bf->until_cursor;
|
||||
if (until == 0) {
|
||||
until = (long)now;
|
||||
}
|
||||
long since = (long)now - bf->current_window_s;
|
||||
|
||||
int page_size = cfg->backfill.events_per_tick;
|
||||
if (page_size < 1) page_size = 50;
|
||||
int max_cap = CR_BACKFILL_MAX_PAGE_CAP;
|
||||
if (max_cap < page_size) max_cap = page_size;
|
||||
if (page_size < 1) page_size = 500;
|
||||
|
||||
bf->last_tick = now;
|
||||
|
||||
DEBUG_TRACE("backfill: querying %s (window %ld, since %ld, until %ld, limit %d)",
|
||||
pk, bf->current_window_s, since, until, page_size);
|
||||
|
||||
int ev_count = 0;
|
||||
cJSON **events = query_author(upstream, relay_map, cfg, pk,
|
||||
since, until, page_size, &ev_count);
|
||||
|
||||
/* Saturation handling: if we got exactly page_size events, the page may
|
||||
* be truncated. Bump the limit up to max_cap and retry once. */
|
||||
int limit_used = page_size;
|
||||
if (events && ev_count == page_size && page_size < max_cap) {
|
||||
for (int k = 0; k < ev_count; k++) cJSON_Delete(events[k]);
|
||||
free(events);
|
||||
|
||||
DEBUG_TRACE("backfill: saturated page for %s, retrying with limit %d",
|
||||
pk, max_cap);
|
||||
events = query_author(upstream, relay_map, cfg, pk,
|
||||
since, until, max_cap, &ev_count);
|
||||
limit_used = max_cap;
|
||||
}
|
||||
|
||||
/* If still saturated at the cap, we cannot safely paginate past the
|
||||
* oldest timestamp (the relay may have more events at that exact ts).
|
||||
* Mark the author blocked at that timestamp and move on; it will be
|
||||
* retried in a later window sweep. */
|
||||
if (events && ev_count == max_cap && max_cap > page_size) {
|
||||
long oldest = 0;
|
||||
find_oldest_created_at(events, ev_count, &oldest);
|
||||
int at_oldest = count_at_timestamp(events, ev_count, oldest);
|
||||
|
||||
DEBUG_WARN("backfill: author %s saturated at limit %d (oldest ts %ld, "
|
||||
"%d events at that ts) - marking blocked, will retry next window",
|
||||
pk, max_cap, oldest, at_oldest);
|
||||
|
||||
/* Publish what we have. */
|
||||
cr_sink_set_source_class(sink, CR_SINK_CLASS_BACKFILL);
|
||||
for (int k = 0; k < ev_count; k++) {
|
||||
cr_sink_publish(sink, events[k]);
|
||||
cJSON_Delete(events[k]);
|
||||
bf->events_this_window++;
|
||||
bf->events_total++;
|
||||
}
|
||||
free(events);
|
||||
|
||||
bf->blocked = 1;
|
||||
bf->blocked_until_ts = oldest;
|
||||
bf->until_cursor = oldest; /* resume point for next window */
|
||||
pg_save_current(bf, cfg, pk, 0); /* not complete - retry next window */
|
||||
|
||||
bf->cursor++;
|
||||
persist_cursor(bf, cfg);
|
||||
if (bf->cursor >= followed->count) {
|
||||
advance_window(bf, cfg);
|
||||
}
|
||||
/* Get the incomplete relays for this author. */
|
||||
cJSON *relays = pg_inbox_get_incomplete_relays(pk);
|
||||
if (!relays) {
|
||||
DEBUG_WARN("backfill: no incomplete relays returned for %s", pk);
|
||||
/* Author has no relay rows - mark complete so we don't loop. */
|
||||
pg_inbox_check_and_mark_author_complete(pk);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Normal page processing. Capture the oldest timestamp BEFORE publishing
|
||||
* so we can paginate if saturated. */
|
||||
long oldest_ts = 0;
|
||||
int have_oldest = 0;
|
||||
if (events && ev_count > 0) {
|
||||
have_oldest = find_oldest_created_at(events, ev_count, &oldest_ts);
|
||||
}
|
||||
int relay_count = cJSON_GetArraySize(relays);
|
||||
DEBUG_TRACE("backfill: author %s has %d incomplete relay(s)", pk, relay_count);
|
||||
|
||||
cr_sink_set_source_class(sink, CR_SINK_CLASS_BACKFILL);
|
||||
|
||||
/* Query each incomplete relay one at a time. */
|
||||
for (int r = 0; r < relay_count; r++) {
|
||||
cJSON *entry = cJSON_GetArrayItem(relays, r);
|
||||
if (!entry) continue;
|
||||
cJSON *url_node = cJSON_GetObjectItem(entry, "relay_url");
|
||||
cJSON *cur_node = cJSON_GetObjectItem(entry, "until_cursor");
|
||||
if (!url_node || !cJSON_IsString(url_node)) continue;
|
||||
const char *relay_url = cJSON_GetStringValue(url_node);
|
||||
long until_cursor = (cur_node && cJSON_IsNumber(cur_node))
|
||||
? (long)cur_node->valuedouble : 0;
|
||||
if (until_cursor == 0) until_cursor = (long)now;
|
||||
|
||||
/* Build the filter for this single relay. */
|
||||
cJSON *filter = cJSON_CreateObject();
|
||||
cJSON *authors = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(pk));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
cJSON *kinds = build_kinds(cfg, pk);
|
||||
if (kinds) cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddItemToObject(filter, "since", cJSON_CreateNumber(0.0));
|
||||
cJSON_AddItemToObject(filter, "until", cJSON_CreateNumber((double)until_cursor));
|
||||
cJSON_AddItemToObject(filter, "limit", cJSON_CreateNumber((double)page_size));
|
||||
|
||||
DEBUG_TRACE("backfill: %s @ %s (until=%ld, limit=%d)",
|
||||
pk, relay_url, until_cursor, page_size);
|
||||
|
||||
/* Record the active target for UI status display (both
|
||||
* in-memory for local accessors and in PG for the relay). */
|
||||
strncpy(bf->active_pubkey, pk, sizeof(bf->active_pubkey) - 1);
|
||||
bf->active_pubkey[sizeof(bf->active_pubkey) - 1] = '\0';
|
||||
strncpy(bf->active_relay, relay_url, sizeof(bf->active_relay) - 1);
|
||||
bf->active_relay[sizeof(bf->active_relay) - 1] = '\0';
|
||||
bf->active_relay_got_eose = 0;
|
||||
pg_inbox_set_active_target(pk, relay_url);
|
||||
|
||||
/* Query this single relay using synchronous_query_relays_with_progress
|
||||
* which gives us an EOSE/timeout callback — the relay's own signal
|
||||
* of whether it sent everything or we timed out. */
|
||||
backfill_query_ctx_t qctx = {0, 0};
|
||||
const char *one_url = relay_url;
|
||||
int ev_count = 0;
|
||||
cJSON **events = synchronous_query_relays_with_progress(
|
||||
&one_url, 1, filter, RELAY_QUERY_ALL_RESULTS,
|
||||
&ev_count, 30, /* timeout in seconds */
|
||||
backfill_query_callback, &qctx,
|
||||
0, NULL /* nip42 disabled, no private key */);
|
||||
cJSON_Delete(filter);
|
||||
|
||||
bf->active_relay_got_eose = qctx.got_eose;
|
||||
|
||||
if (!events || ev_count == 0) {
|
||||
/* No events from this relay - it is drained for this author.
|
||||
* (ev_count == 0 with EOSE means truly empty; without EOSE it
|
||||
* means a connection failure — keep incomplete to retry.
|
||||
* The pg_inbox_update_relay_progress SQL will auto-mark
|
||||
* complete once consecutive_errors reaches 3.) */
|
||||
int mark_complete = qctx.got_eose ? 1 : 0;
|
||||
pg_inbox_update_relay_progress(pk, relay_url, until_cursor,
|
||||
mark_complete, 0, qctx.last_status);
|
||||
free(events);
|
||||
if (!qctx.got_eose) {
|
||||
/* Error or timeout with no events — surface the
|
||||
* descriptive status at WARN so operators can see why. */
|
||||
DEBUG_WARN("backfill: %s @ %s failed: %s (will auto-complete after 3 consecutive errors)",
|
||||
pk, relay_url, qctx.last_status);
|
||||
} else {
|
||||
DEBUG_LOG("backfill: %s @ %s complete (no events, eose)",
|
||||
pk, relay_url);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Find oldest and newest timestamps, then publish every event. */
|
||||
long oldest_ts = 0;
|
||||
long newest_ts = 0;
|
||||
find_oldest_created_at(events, ev_count, &oldest_ts);
|
||||
find_newest_created_at(events, ev_count, &newest_ts);
|
||||
|
||||
if (events && ev_count > 0) {
|
||||
cr_sink_set_source_class(sink, CR_SINK_CLASS_BACKFILL);
|
||||
for (int k = 0; k < ev_count; k++) {
|
||||
cr_sink_publish(sink, events[k]);
|
||||
cJSON_Delete(events[k]);
|
||||
bf->events_this_window++;
|
||||
bf->events_total++;
|
||||
}
|
||||
free(events);
|
||||
bf->events_total += ev_count;
|
||||
|
||||
/* Update last_event_at for forward catch-up gap tracking. */
|
||||
if (newest_ts > 0) {
|
||||
pg_inbox_update_last_event_at(pk, newest_ts);
|
||||
}
|
||||
|
||||
DEBUG_LOG("backfill: %s @ %s -> %d events (until=%ld, oldest=%ld, eose=%d)",
|
||||
pk, relay_url, ev_count, until_cursor, oldest_ts, qctx.got_eose);
|
||||
|
||||
/* Completion decision based on EOSE status:
|
||||
* - ev_count >= page_size: saturated, advance cursor, keep incomplete
|
||||
* - ev_count < page_size AND got EOSE: relay is truly drained
|
||||
* - ev_count < page_size AND no EOSE (timeout): partial result,
|
||||
* advance cursor but keep incomplete to retry next tick
|
||||
* - Guard: if cursor didn't advance (oldest_ts >= until_cursor),
|
||||
* mark complete to avoid infinite loop on identical timestamps. */
|
||||
long next_cursor = (oldest_ts > 0) ? oldest_ts - 1 : 0;
|
||||
int cursor_advanced = (oldest_ts > 0 && oldest_ts < until_cursor);
|
||||
|
||||
if (ev_count >= page_size) {
|
||||
pg_inbox_update_relay_progress(pk, relay_url, next_cursor, 0, ev_count, qctx.last_status);
|
||||
} else if (qctx.got_eose) {
|
||||
/* Relay sent EOSE with < page_size events — truly drained. */
|
||||
pg_inbox_update_relay_progress(pk, relay_url, next_cursor, 1, ev_count, qctx.last_status);
|
||||
} else {
|
||||
/* Timed out before EOSE — partial result. Keep incomplete
|
||||
* to retry, unless the cursor didn't advance (stall guard).
|
||||
* Timeouts DO count toward the consecutive_errors limit
|
||||
* (a relay that consistently times out is effectively dead
|
||||
* for backfill purposes), so the SQL will auto-mark
|
||||
* complete after 3 consecutive timeouts/errors. */
|
||||
int mark_complete = cursor_advanced ? 0 : 1;
|
||||
pg_inbox_update_relay_progress(pk, relay_url, next_cursor,
|
||||
mark_complete, ev_count, qctx.last_status);
|
||||
DEBUG_WARN("backfill: %s @ %s partial: %s (%d events, will auto-complete after 3 consecutive timeouts/errors)",
|
||||
pk, relay_url, qctx.last_status, ev_count);
|
||||
}
|
||||
}
|
||||
|
||||
DEBUG_LOG("backfill: pubkey[%d/%d] %s -> %d events (until=%ld)",
|
||||
bf->cursor, followed->count, pk, ev_count, until);
|
||||
/* Clear active target — done with this author. */
|
||||
bf->active_relay[0] = '\0';
|
||||
pg_inbox_set_active_target(pk, "");
|
||||
|
||||
int saturated = (ev_count == limit_used) && (ev_count > 0);
|
||||
cJSON_Delete(relays);
|
||||
|
||||
if (ev_count == 0) {
|
||||
/* No more events for this author in the window. */
|
||||
bf->author_complete = 1;
|
||||
bf->until_cursor = 0;
|
||||
pg_save_current(bf, cfg, pk, 1);
|
||||
bf->cursor++;
|
||||
persist_cursor(bf, cfg);
|
||||
if (bf->cursor >= followed->count) {
|
||||
advance_window(bf, cfg);
|
||||
}
|
||||
} else if (!saturated) {
|
||||
/* Last page for this author. */
|
||||
bf->author_complete = 1;
|
||||
bf->until_cursor = 0;
|
||||
pg_save_current(bf, cfg, pk, 1);
|
||||
bf->cursor++;
|
||||
persist_cursor(bf, cfg);
|
||||
if (bf->cursor >= followed->count) {
|
||||
advance_window(bf, cfg);
|
||||
}
|
||||
} else {
|
||||
/* Saturated: paginate. Set until = oldest - 1 and keep the same
|
||||
* author for the next tick. The `until` parameter is inclusive, so
|
||||
* subtracting 1 avoids re-fetching the oldest event. */
|
||||
if (have_oldest) {
|
||||
bf->until_cursor = oldest_ts - 1;
|
||||
if (bf->until_cursor < since) {
|
||||
/* Walked back past the window start; author is done. */
|
||||
bf->author_complete = 1;
|
||||
bf->until_cursor = 0;
|
||||
pg_save_current(bf, cfg, pk, 1);
|
||||
bf->cursor++;
|
||||
persist_cursor(bf, cfg);
|
||||
if (bf->cursor >= followed->count) {
|
||||
advance_window(bf, cfg);
|
||||
}
|
||||
} else {
|
||||
pg_save_current(bf, cfg, pk, 0);
|
||||
}
|
||||
} else {
|
||||
/* Could not determine oldest (no valid created_at); mark done. */
|
||||
bf->author_complete = 1;
|
||||
bf->until_cursor = 0;
|
||||
pg_save_current(bf, cfg, pk, 1);
|
||||
bf->cursor++;
|
||||
persist_cursor(bf, cfg);
|
||||
if (bf->cursor >= followed->count) {
|
||||
advance_window(bf, cfg);
|
||||
}
|
||||
}
|
||||
/* After querying all incomplete relays for this author, check whether
|
||||
* the author is now fully complete. */
|
||||
int crc = pg_inbox_check_and_mark_author_complete(pk);
|
||||
if (crc == 1) {
|
||||
DEBUG_LOG("backfill: author %s fully complete (all relays drained)", pk);
|
||||
}
|
||||
|
||||
return 1;
|
||||
|
||||
+33
-40
@@ -1,17 +1,13 @@
|
||||
/*
|
||||
* caching_relay - progressive window-expansion backfill.
|
||||
* caching_relay - per-author until-cursor drain backfill.
|
||||
*
|
||||
* Round-robin through the followed-pubkey set, issuing query_sync per pubkey
|
||||
* for the current window (since = now - window_seconds). When a full pass
|
||||
* completes, advance the window and persist state to the config file.
|
||||
*
|
||||
* Pagination: each author is queried with an `until` cursor that walks
|
||||
* backwards in time. If a query returns exactly `page_size` events (saturated),
|
||||
* the limit is bumped up to a configurable cap and retried; if still saturated
|
||||
* the author is marked "blocked" at that timestamp and retried in a later
|
||||
* window sweep. Otherwise the until cursor advances to
|
||||
* (oldest_event_created_at - 1) and the same author is queried again on the
|
||||
* next tick until no more events are returned.
|
||||
* Each followed author has a single persistent cursor (starting at `now`)
|
||||
* stored in the caching_followed_pubkeys table. Each backfill tick picks
|
||||
* the next incomplete author (round-robin), queries
|
||||
* since=0 & until=cursor & limit=page_size
|
||||
* publishes the page, and sets cursor = oldest_event_created_at - 1.
|
||||
* Repeat until a page returns fewer than page_size events (beginning of
|
||||
* the author's history reached), then mark the author complete.
|
||||
*/
|
||||
#ifndef CACHING_RELAY_BACKFILL_H
|
||||
#define CACHING_RELAY_BACKFILL_H
|
||||
@@ -22,44 +18,41 @@
|
||||
#include "relay_discovery.h"
|
||||
#include "../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
/* Maximum limit we will bump a saturated page to before marking an author
|
||||
* blocked at a timestamp. Keeps a single query bounded. */
|
||||
#define CR_BACKFILL_MAX_PAGE_CAP 200
|
||||
|
||||
typedef struct {
|
||||
int window_index;
|
||||
long current_window_s;
|
||||
int cursor; /* current index into followed set */
|
||||
long until_cursor; /* current until timestamp for the current author */
|
||||
int in_progress; /* 1 if a window pass is in progress */
|
||||
long events_this_window; /* events pulled in current window */
|
||||
long events_total; /* events pulled across all windows */
|
||||
time_t last_tick; /* last time a pubkey was queried */
|
||||
time_t window_started; /* when current window pass started */
|
||||
int author_complete; /* 1 when current author is done, advance cursor */
|
||||
int blocked; /* 1 when current author is blocked at a saturated timestamp */
|
||||
long blocked_until_ts; /* timestamp where the author is blocked */
|
||||
int in_progress; /* 1 if any incomplete authors remain */
|
||||
long events_total; /* events pulled across all ticks (diagnostic) */
|
||||
time_t last_tick; /* last time an author was queried (throttle) */
|
||||
int author_round_cursor;/* round-robin cursor for author selection */
|
||||
/* Current backfill target (for UI status display). */
|
||||
char active_pubkey[65]; /* pubkey currently being backfilled (or "") */
|
||||
char active_relay[256]; /* relay URL currently being queried (or "") */
|
||||
int active_relay_got_eose; /* 1 if last query got EOSE, 0 if timed out */
|
||||
} cr_backfill_t;
|
||||
|
||||
/* Initialize backfill state from the persisted config state.
|
||||
* In PostgreSQL mode (pg_inbox initialized), restores per-author progress
|
||||
* for the current window from the caching_backfill_progress table. */
|
||||
/* Global access to the current backfill target for status reporting.
|
||||
* Returns pointers to static storage in the singleton cr_backfill_t.
|
||||
* Safe to call from the main loop; not thread-safe but backfill is
|
||||
* single-threaded. */
|
||||
const char *cr_backfill_active_pubkey(void);
|
||||
const char *cr_backfill_active_relay(void);
|
||||
int cr_backfill_active_got_eose(void);
|
||||
|
||||
/* Initialize backfill state. Checks the caching_followed_pubkeys table for
|
||||
* any incomplete authors; sets in_progress accordingly. */
|
||||
void cr_backfill_init(cr_backfill_t *bf, cr_config_t *cfg);
|
||||
|
||||
/* Perform one backfill tick. This queries ONE followed pubkey (the one at
|
||||
* cfg->state.backfill_cursor) and publishes results to the sink. Uses
|
||||
* until-based pagination so authors with more events than the page size are
|
||||
* fully drained across multiple ticks. When a full round-robin pass completes,
|
||||
* advances the window and saves state.
|
||||
/* Perform one backfill tick. Picks the next incomplete author from the DB
|
||||
* (round-robin), queries one page of history, publishes it to the sink, and
|
||||
* updates the per-author cursor in the DB.
|
||||
*
|
||||
* Returns:
|
||||
* 1 if a tick was performed (a pubkey was queried)
|
||||
* 1 if a tick was performed (an author was queried)
|
||||
* 0 if throttled (tick_interval not elapsed) - caller should pump pools
|
||||
* -1 on hard error
|
||||
* -2 if backfill is complete (all windows done) - caller should go steady-state
|
||||
* -2 if backfill is complete (no incomplete authors) - caller goes steady-state
|
||||
*/
|
||||
int cr_backfill_tick(cr_backfill_t *bf, cr_config_t *cfg,
|
||||
nostr_relay_pool_t *upstream, cr_pubkey_set_t *followed,
|
||||
nostr_relay_pool_t *upstream,
|
||||
cr_pubkey_set_t *followed,
|
||||
cr_sink_t *sink, const cr_relay_map_t *relay_map);
|
||||
|
||||
#endif /* CACHING_RELAY_BACKFILL_H */
|
||||
|
||||
+3
-28
@@ -146,15 +146,10 @@ int cr_config_load(cr_config_t *cfg, const char *path) {
|
||||
cJSON *bf = cJSON_GetObjectItem(root, "backfill");
|
||||
if (bf) {
|
||||
cfg->backfill.enabled = cJSON_IsTrue(cJSON_GetObjectItem(bf, "enabled"));
|
||||
copy_long_array(cJSON_GetObjectItem(bf, "window_schedule_seconds"),
|
||||
cfg->backfill.window_schedule_seconds, CR_MAX_WINDOWS,
|
||||
&cfg->backfill.window_count);
|
||||
cJSON *ept = cJSON_GetObjectItem(bf, "events_per_tick");
|
||||
if (ept) cfg->backfill.events_per_tick = (int)cJSON_GetNumberValue(ept);
|
||||
cJSON *tis = cJSON_GetObjectItem(bf, "tick_interval_seconds");
|
||||
if (tis) cfg->backfill.tick_interval_seconds = (int)cJSON_GetNumberValue(tis);
|
||||
cJSON *wcs = cJSON_GetObjectItem(bf, "window_cooldown_seconds");
|
||||
if (wcs) cfg->backfill.window_cooldown_seconds = (int)cJSON_GetNumberValue(wcs);
|
||||
}
|
||||
|
||||
/* live */
|
||||
@@ -174,25 +169,13 @@ int cr_config_load(cr_config_t *cfg, const char *path) {
|
||||
if (st) {
|
||||
cJSON *bu = cJSON_GetObjectItem(st, "backfilled_until");
|
||||
if (bu) cfg->state.backfilled_until = (long)cJSON_GetNumberValue(bu);
|
||||
cJSON *cwi = cJSON_GetObjectItem(st, "current_window_index");
|
||||
if (cwi) cfg->state.current_window_index = (int)cJSON_GetNumberValue(cwi);
|
||||
cJSON *bc = cJSON_GetObjectItem(st, "backfill_cursor");
|
||||
if (bc) cfg->state.backfill_cursor = (int)cJSON_GetNumberValue(bc);
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
|
||||
/* Defaults if missing. */
|
||||
if (cfg->backfill.window_count == 0) {
|
||||
static const long def_windows[] = {86400, 604800, 2592000, 7776000, 31536000};
|
||||
int n = (int)(sizeof(def_windows) / sizeof(def_windows[0]));
|
||||
if (n > CR_MAX_WINDOWS) n = CR_MAX_WINDOWS;
|
||||
for (int i = 0; i < n; i++) cfg->backfill.window_schedule_seconds[i] = def_windows[i];
|
||||
cfg->backfill.window_count = n;
|
||||
}
|
||||
if (cfg->backfill.events_per_tick == 0) cfg->backfill.events_per_tick = 50;
|
||||
if (cfg->backfill.events_per_tick == 0) cfg->backfill.events_per_tick = 500;
|
||||
if (cfg->backfill.tick_interval_seconds == 0) cfg->backfill.tick_interval_seconds = 5;
|
||||
if (cfg->backfill.window_cooldown_seconds == 0) cfg->backfill.window_cooldown_seconds = 60;
|
||||
if (cfg->live.resubscribe_interval_seconds == 0) cfg->live.resubscribe_interval_seconds = 300;
|
||||
if (cfg->follow_graph_refresh_seconds == 0) cfg->follow_graph_refresh_seconds = 600;
|
||||
|
||||
@@ -253,13 +236,8 @@ int cr_config_save_state(cr_config_t *cfg) {
|
||||
|
||||
cJSON *bf = cJSON_CreateObject();
|
||||
cJSON_AddItemToObject(bf, "enabled", cJSON_CreateBool(cfg->backfill.enabled));
|
||||
cJSON *ws = cJSON_CreateArray();
|
||||
for (int i = 0; i < cfg->backfill.window_count; i++)
|
||||
cJSON_AddItemToArray(ws, cJSON_CreateNumber(cfg->backfill.window_schedule_seconds[i]));
|
||||
cJSON_AddItemToObject(bf, "window_schedule_seconds", ws);
|
||||
cJSON_AddItemToObject(bf, "events_per_tick", cJSON_CreateNumber(cfg->backfill.events_per_tick));
|
||||
cJSON_AddItemToObject(bf, "tick_interval_seconds", cJSON_CreateNumber(cfg->backfill.tick_interval_seconds));
|
||||
cJSON_AddItemToObject(bf, "window_cooldown_seconds", cJSON_CreateNumber(cfg->backfill.window_cooldown_seconds));
|
||||
cJSON_AddItemToObject(root, "backfill", bf);
|
||||
|
||||
cJSON *lv = cJSON_CreateObject();
|
||||
@@ -272,8 +250,6 @@ int cr_config_save_state(cr_config_t *cfg) {
|
||||
|
||||
cJSON *st = cJSON_CreateObject();
|
||||
cJSON_AddItemToObject(st, "backfilled_until", cJSON_CreateNumber(cfg->state.backfilled_until));
|
||||
cJSON_AddItemToObject(st, "current_window_index", cJSON_CreateNumber(cfg->state.current_window_index));
|
||||
cJSON_AddItemToObject(st, "backfill_cursor", cJSON_CreateNumber(cfg->state.backfill_cursor));
|
||||
cJSON_AddItemToObject(root, "state", st);
|
||||
|
||||
char *json = cJSON_Print(root);
|
||||
@@ -304,9 +280,8 @@ int cr_config_save_state(cr_config_t *cfg) {
|
||||
DEBUG_ERROR("config save: rename failed: %s", strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
DEBUG_LOG("config state saved: backfilled_until=%ld window=%d cursor=%d",
|
||||
cfg->state.backfilled_until, cfg->state.current_window_index,
|
||||
cfg->state.backfill_cursor);
|
||||
DEBUG_LOG("config state saved: backfilled_until=%ld",
|
||||
cfg->state.backfilled_until);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,18 +11,14 @@
|
||||
#define CR_MAX_ROOT_NPUBS 16
|
||||
#define CR_MAX_UPSTREAM 32
|
||||
#define CR_MAX_KINDS 32
|
||||
#define CR_MAX_WINDOWS 16
|
||||
#define CR_NPUB_LEN 64 /* npub1... bech32, generous */
|
||||
#define CR_URL_LEN 256
|
||||
#define CR_HEX_PUBKEY_LEN 65 /* 64 hex chars + NUL */
|
||||
|
||||
typedef struct {
|
||||
int enabled;
|
||||
long window_schedule_seconds[CR_MAX_WINDOWS];
|
||||
int window_count;
|
||||
int events_per_tick;
|
||||
int events_per_tick; /* per-query page size (default 500) */
|
||||
int tick_interval_seconds;
|
||||
int window_cooldown_seconds;
|
||||
} cr_backfill_config_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -30,10 +26,12 @@ typedef struct {
|
||||
int resubscribe_interval_seconds;
|
||||
} cr_live_config_t;
|
||||
|
||||
/* Persistent state. The per-author until-cursor drain model stores all
|
||||
* backfill progress in the caching_followed_pubkeys table, so the only
|
||||
* state kept here is the legacy-mode first-time flag (used to drive relay
|
||||
* discovery caching behavior). */
|
||||
typedef struct {
|
||||
long backfilled_until; /* unix ts; 0 = nothing backfilled yet */
|
||||
int current_window_index; /* index into window_schedule */
|
||||
int backfill_cursor; /* round-robin cursor over followed pubkeys */
|
||||
long backfilled_until; /* legacy: 0 = first-time startup */
|
||||
} cr_state_t;
|
||||
|
||||
typedef struct {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <ctype.h>
|
||||
|
||||
int cr_follow_is_root(const cr_config_t *cfg, const char *hex) {
|
||||
if (!cfg->root_hex_ready) return 0;
|
||||
@@ -16,17 +17,35 @@ int cr_follow_is_root(const cr_config_t *cfg, const char *hex) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Check if a string is a 64-character hex pubkey (no npub prefix). */
|
||||
static int is_hex_pubkey(const char *s) {
|
||||
if (!s || strlen(s) != 64) return 0;
|
||||
for (int i = 0; i < 64; i++) {
|
||||
if (!isxdigit((unsigned char)s[i])) return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
int cr_follow_decode_roots(cr_config_t *cfg) {
|
||||
for (int i = 0; i < cfg->root_npub_count; i++) {
|
||||
unsigned char pubkey[32];
|
||||
if (nostr_decode_npub(cfg->root_npubs[i], pubkey) != NOSTR_SUCCESS) {
|
||||
DEBUG_ERROR("follow: failed to decode npub '%s'", cfg->root_npubs[i]);
|
||||
return -1;
|
||||
const char *input = cfg->root_npubs[i];
|
||||
if (is_hex_pubkey(input)) {
|
||||
/* Already a 64-char hex pubkey — copy directly. */
|
||||
strncpy(cfg->root_hex[i], input, 64);
|
||||
cfg->root_hex[i][64] = '\0';
|
||||
} else {
|
||||
/* Assume npub (bech32) format — decode to hex. */
|
||||
unsigned char pubkey[32];
|
||||
if (nostr_decode_npub(input, pubkey) != NOSTR_SUCCESS) {
|
||||
DEBUG_ERROR("follow: failed to decode root pubkey '%s' "
|
||||
"(not a valid npub or 64-char hex)", input);
|
||||
return -1;
|
||||
}
|
||||
nostr_bytes_to_hex(pubkey, 32, cfg->root_hex[i]);
|
||||
}
|
||||
nostr_bytes_to_hex(pubkey, 32, cfg->root_hex[i]);
|
||||
}
|
||||
cfg->root_hex_ready = 1;
|
||||
DEBUG_INFO("follow: decoded %d root npubs", cfg->root_npub_count);
|
||||
DEBUG_INFO("follow: decoded %d root pubkeys", cfg->root_npub_count);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* caching_relay - forward catch-up implementation.
|
||||
*
|
||||
* See forward_catchup.h for design.
|
||||
*/
|
||||
#include "forward_catchup.h"
|
||||
#include "pg_inbox.h"
|
||||
#include "config.h"
|
||||
#include "follow_graph.h"
|
||||
#include "relay_sink.h"
|
||||
#include "debug.h"
|
||||
|
||||
#include <cjson/cJSON.h>
|
||||
#include <time.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/* Find the newest (maximum) created_at among an array of event JSON objects.
|
||||
* Returns 1 and sets *out_newest, or 0 if no events / no valid created_at. */
|
||||
static int find_newest_created_at(cJSON **events, int count, long *out_newest) {
|
||||
long newest = 0;
|
||||
int found = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
cJSON *ca = cJSON_GetObjectItem(events[i], "created_at");
|
||||
if (!ca || !cJSON_IsNumber(ca)) continue;
|
||||
long ts = (long)ca->valuedouble;
|
||||
if (!found || ts > newest) {
|
||||
newest = ts;
|
||||
found = 1;
|
||||
}
|
||||
}
|
||||
if (found && out_newest) *out_newest = newest;
|
||||
return found;
|
||||
}
|
||||
|
||||
/* Build the kinds array for a given pubkey (admin vs regular).
|
||||
* Returns NULL if no kinds filter should be applied (admin_all_kinds). */
|
||||
static cJSON *build_kinds(cr_config_t *cfg, const char *pk) {
|
||||
int is_admin = cr_follow_is_root(cfg, pk);
|
||||
if (is_admin && cfg->admin_all_kinds) {
|
||||
return NULL;
|
||||
}
|
||||
cJSON *kinds = cJSON_CreateArray();
|
||||
if (is_admin && cfg->admin_kind_count > 0) {
|
||||
for (int i = 0; i < cfg->admin_kind_count; i++)
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->admin_kinds[i]));
|
||||
} else {
|
||||
for (int i = 0; i < cfg->kind_count; i++)
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->kinds[i]));
|
||||
}
|
||||
return kinds;
|
||||
}
|
||||
|
||||
/* Get relay URLs for an author from the relay progress table.
|
||||
* Returns a cJSON array of relay_url strings. Caller must cJSON_Delete().
|
||||
* Returns NULL on error or if no relays found. */
|
||||
static cJSON *get_author_relays(const char *pk) {
|
||||
/* Use pg_inbox_get_incomplete_relays for incomplete authors.
|
||||
* For completed authors, we need all relays — query directly. */
|
||||
cJSON *incomplete = pg_inbox_get_incomplete_relays(pk);
|
||||
if (incomplete && cJSON_GetArraySize(incomplete) > 0) {
|
||||
/* Extract just the relay_url strings. */
|
||||
cJSON *urls = cJSON_CreateArray();
|
||||
int n = cJSON_GetArraySize(incomplete);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON *entry = cJSON_GetArrayItem(incomplete, i);
|
||||
cJSON *url = cJSON_GetObjectItemCaseSensitive(entry, "relay_url");
|
||||
if (url && cJSON_IsString(url) && url->valuestring[0]) {
|
||||
cJSON_AddItemToArray(urls, cJSON_CreateString(url->valuestring));
|
||||
}
|
||||
}
|
||||
cJSON_Delete(incomplete);
|
||||
if (cJSON_GetArraySize(urls) > 0) return urls;
|
||||
cJSON_Delete(urls);
|
||||
}
|
||||
if (incomplete) cJSON_Delete(incomplete);
|
||||
|
||||
/* For completed authors, fall back to the upstream pool's connected
|
||||
* relays. The caller will use the pool directly. */
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int cr_forward_catchup(cr_config_t *cfg,
|
||||
nostr_relay_pool_t *upstream,
|
||||
cr_sink_t *sink) {
|
||||
if (!cfg || !upstream || !sink) return -1;
|
||||
|
||||
cJSON *authors = pg_inbox_get_authors_for_catchup();
|
||||
if (!authors) {
|
||||
DEBUG_INFO("forward_catchup: no authors with last_event_at > 0");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int author_count = cJSON_GetArraySize(authors);
|
||||
if (author_count == 0) {
|
||||
cJSON_Delete(authors);
|
||||
DEBUG_INFO("forward_catchup: no authors need catch-up");
|
||||
return 0;
|
||||
}
|
||||
|
||||
DEBUG_INFO("forward_catchup: checking %d authors for missed events", author_count);
|
||||
|
||||
long now = (long)time(NULL);
|
||||
int page_size = cfg->backfill.events_per_tick;
|
||||
if (page_size < 1) page_size = 500;
|
||||
|
||||
cr_sink_set_source_class(sink, CR_SINK_CLASS_BACKFILL);
|
||||
|
||||
int total_events_published = 0;
|
||||
int authors_caught_up = 0;
|
||||
|
||||
for (int i = 0; i < author_count; i++) {
|
||||
cJSON *entry = cJSON_GetArrayItem(authors, i);
|
||||
if (!entry) continue;
|
||||
|
||||
cJSON *pk_node = cJSON_GetObjectItemCaseSensitive(entry, "pubkey");
|
||||
cJSON *lea_node = cJSON_GetObjectItemCaseSensitive(entry, "last_event_at");
|
||||
if (!pk_node || !cJSON_IsString(pk_node) || !lea_node || !cJSON_IsNumber(lea_node))
|
||||
continue;
|
||||
|
||||
const char *pk = pk_node->valuestring;
|
||||
long last_event_at = (long)lea_node->valuedouble;
|
||||
|
||||
if (last_event_at <= 0 || last_event_at >= now) continue;
|
||||
|
||||
/* Build filter: since = last_event_at + 1, until = now. */
|
||||
cJSON *filter = cJSON_CreateObject();
|
||||
cJSON *authors_arr = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(authors_arr, cJSON_CreateString(pk));
|
||||
cJSON_AddItemToObject(filter, "authors", authors_arr);
|
||||
cJSON *kinds = build_kinds(cfg, pk);
|
||||
if (kinds) cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddItemToObject(filter, "since", cJSON_CreateNumber((double)(last_event_at + 1)));
|
||||
cJSON_AddItemToObject(filter, "until", cJSON_CreateNumber((double)now));
|
||||
cJSON_AddItemToObject(filter, "limit", cJSON_CreateNumber((double)page_size));
|
||||
|
||||
/* Try to get author-specific relays first; fall back to upstream pool. */
|
||||
cJSON *author_relays = get_author_relays(pk);
|
||||
|
||||
int ev_count = 0;
|
||||
cJSON **events = NULL;
|
||||
|
||||
if (author_relays && cJSON_GetArraySize(author_relays) > 0) {
|
||||
/* Query using the author's outbox relays. */
|
||||
int n_relays = cJSON_GetArraySize(author_relays);
|
||||
const char **urls = malloc(sizeof(char *) * n_relays);
|
||||
for (int r = 0; r < n_relays; r++) {
|
||||
cJSON *u = cJSON_GetArrayItem(author_relays, r);
|
||||
urls[r] = cJSON_IsString(u) ? u->valuestring : "";
|
||||
}
|
||||
events = synchronous_query_relays_with_progress(
|
||||
urls, n_relays, filter, RELAY_QUERY_ALL_RESULTS,
|
||||
&ev_count, 30, NULL, NULL, 0, NULL);
|
||||
free(urls);
|
||||
} else {
|
||||
/* Fall back: query all connected upstream relays.
|
||||
* Use the pool's relay list. We pass NULL for urls to let
|
||||
* the pool use all connected relays. */
|
||||
events = synchronous_query_relays_with_progress(
|
||||
NULL, 0, filter, RELAY_QUERY_ALL_RESULTS,
|
||||
&ev_count, 30, NULL, NULL, 0, NULL);
|
||||
}
|
||||
cJSON_Delete(author_relays);
|
||||
cJSON_Delete(filter);
|
||||
|
||||
if (events && ev_count > 0) {
|
||||
/* Publish all events. */
|
||||
long newest = 0;
|
||||
find_newest_created_at(events, ev_count, &newest);
|
||||
|
||||
for (int k = 0; k < ev_count; k++) {
|
||||
cr_sink_publish(sink, events[k]);
|
||||
cJSON_Delete(events[k]);
|
||||
}
|
||||
free(events);
|
||||
total_events_published += ev_count;
|
||||
authors_caught_up++;
|
||||
|
||||
/* Update last_event_at to the newest event we saw. */
|
||||
if (newest > 0) {
|
||||
pg_inbox_update_last_event_at(pk, newest);
|
||||
}
|
||||
|
||||
DEBUG_LOG("forward_catchup: %s -> %d events (since=%ld, until=%ld)",
|
||||
pk, ev_count, last_event_at + 1, now);
|
||||
} else {
|
||||
/* No events in the gap — update last_event_at to now so we
|
||||
* don't re-query the same empty window next time. */
|
||||
pg_inbox_update_last_event_at(pk, now);
|
||||
if (events) free(events);
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(authors);
|
||||
|
||||
DEBUG_INFO("forward_catchup: %d authors checked, %d had events, %d total events published",
|
||||
author_count, authors_caught_up, total_events_published);
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* caching_relay - forward catch-up for gap bridging.
|
||||
*
|
||||
* When the caching service starts (or caching is re-enabled after being
|
||||
* off), events posted by followed authors during the downtime are missed
|
||||
* by the backward-drain backfill (which walks toward the beginning of
|
||||
* time) and by the live subscriber (which starts at `since = now`).
|
||||
*
|
||||
* The forward catch-up queries `since = last_event_at + 1, until = now`
|
||||
* for each followed author to bridge that gap. `last_event_at` is the
|
||||
* `created_at` of the most recent event we've ever seen for the author,
|
||||
* maintained by the backfill and live subscriber.
|
||||
*/
|
||||
#ifndef CACHING_RELAY_FORWARD_CATCHUP_H
|
||||
#define CACHING_RELAY_FORWARD_CATCHUP_H
|
||||
|
||||
#include "config.h"
|
||||
#include "relay_sink.h"
|
||||
#include "../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
/* Run forward catch-up for all followed authors with last_event_at > 0.
|
||||
* For each author, queries events from last_event_at + 1 to now on one
|
||||
* outbox relay and publishes them to the sink.
|
||||
*
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int cr_forward_catchup(cr_config_t *cfg,
|
||||
nostr_relay_pool_t *upstream,
|
||||
cr_sink_t *sink);
|
||||
|
||||
#endif /* CACHING_RELAY_FORWARD_CATCHUP_H */
|
||||
@@ -4,6 +4,7 @@
|
||||
#define _GNU_SOURCE
|
||||
#include "live_subscriber.h"
|
||||
#include "follow_graph.h"
|
||||
#include "pg_inbox.h"
|
||||
#include "debug.h"
|
||||
|
||||
#include <string.h>
|
||||
@@ -12,8 +13,9 @@
|
||||
|
||||
/* Context passed as user_data to the subscription callbacks. */
|
||||
typedef struct {
|
||||
cr_sink_t *sink;
|
||||
cr_live_t *live;
|
||||
cr_sink_t *sink;
|
||||
cr_live_t *live;
|
||||
cr_config_t *cfg;
|
||||
} cr_live_ctx_t;
|
||||
|
||||
static cr_live_ctx_t g_live_ctx;
|
||||
@@ -23,6 +25,31 @@ static void live_on_event(cJSON *event, const char *relay_url, void *user_data)
|
||||
(void)relay_url;
|
||||
ctx->live->events_received++;
|
||||
cr_sink_publish(ctx->sink, event);
|
||||
|
||||
/* Update last_event_at for forward catch-up gap tracking. */
|
||||
cJSON *ev_pubkey = cJSON_GetObjectItem(event, "pubkey");
|
||||
cJSON *ev_created = cJSON_GetObjectItem(event, "created_at");
|
||||
if (ev_pubkey && cJSON_IsString(ev_pubkey) &&
|
||||
ev_created && cJSON_IsNumber(ev_created)) {
|
||||
pg_inbox_update_last_event_at(ev_pubkey->valuestring,
|
||||
(long)ev_created->valuedouble);
|
||||
}
|
||||
|
||||
/* Detect admin kind-3 (contact list) changes from a root pubkey and
|
||||
* signal the main loop to refresh the follow graph immediately. */
|
||||
if (ctx->cfg) {
|
||||
cJSON *kind = cJSON_GetObjectItem(event, "kind");
|
||||
cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey");
|
||||
if (kind && cJSON_IsNumber(kind) && kind->valuedouble == 3.0 &&
|
||||
pubkey && cJSON_IsString(pubkey)) {
|
||||
if (cr_follow_is_root(ctx->cfg, cJSON_GetStringValue(pubkey))) {
|
||||
ctx->live->follow_graph_changed = 1;
|
||||
DEBUG_INFO("live: root kind-3 detected from %s, "
|
||||
"signaling follow graph refresh",
|
||||
cJSON_GetStringValue(pubkey));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void live_on_eose(cJSON **events, int event_count, void *user_data) {
|
||||
@@ -102,6 +129,7 @@ static int open_subs(cr_live_t *live, cr_config_t *cfg, nostr_relay_pool_t *upst
|
||||
cr_pubkey_set_t *followed, cr_sink_t *sink) {
|
||||
g_live_ctx.sink = sink;
|
||||
g_live_ctx.live = live;
|
||||
g_live_ctx.cfg = cfg;
|
||||
|
||||
int admin_count = 0;
|
||||
int follows_count = 0;
|
||||
|
||||
@@ -21,6 +21,7 @@ typedef struct {
|
||||
nostr_pool_subscription_t *admin_sub; /* admin pubkeys, admin_kinds */
|
||||
long events_received;
|
||||
time_t last_resubscribe;
|
||||
int follow_graph_changed; /* set when a root npub publishes a kind-3 */
|
||||
} cr_live_t;
|
||||
|
||||
/* Open the live subscription(s). Returns 0 on success. */
|
||||
|
||||
+194
-18
@@ -17,6 +17,7 @@
|
||||
#include "relay_sink.h"
|
||||
#include "live_subscriber.h"
|
||||
#include "backfill.h"
|
||||
#include "forward_catchup.h"
|
||||
#include "relay_discovery.h"
|
||||
#include "pg_inbox.h"
|
||||
#include "pg_config.h"
|
||||
@@ -50,6 +51,65 @@ static void nostr_log_forwarder(int level, const char *component,
|
||||
}
|
||||
}
|
||||
|
||||
/* Initialize per-relay backfill progress rows for every followed pubkey.
|
||||
* For each author, merges its discovered outbox relays (from relay_map)
|
||||
* with the bootstrap (upstream) relays from cfg, then inserts one
|
||||
* caching_backfill_relay_progress row per relay (ON CONFLICT DO NOTHING).
|
||||
* Safe to call repeatedly - existing rows are left untouched. */
|
||||
static void init_relay_progress_for_all(const cr_config_t *cfg,
|
||||
const cr_relay_map_t *relay_map,
|
||||
const cr_pubkey_set_t *followed) {
|
||||
if (!followed || followed->count <= 0) return;
|
||||
|
||||
/* Bootstrap relay URLs from cfg->upstream_relays. */
|
||||
const char **bootstrap = NULL;
|
||||
int bootstrap_n = cfg->upstream_count;
|
||||
if (bootstrap_n > 0) {
|
||||
bootstrap = malloc(bootstrap_n * sizeof(char *));
|
||||
if (!bootstrap) return;
|
||||
for (int i = 0; i < bootstrap_n; i++)
|
||||
bootstrap[i] = cfg->upstream_relays[i];
|
||||
}
|
||||
|
||||
for (int i = 0; i < followed->count; i++) {
|
||||
const char *pk = followed->items[i];
|
||||
if (!pk || !*pk) continue;
|
||||
|
||||
/* Collect this author's outbox relays. */
|
||||
const cr_outbox_entry_t *oe = NULL;
|
||||
int outbox_n = 0;
|
||||
if (relay_map) {
|
||||
oe = cr_relay_map_get_outbox(relay_map, pk);
|
||||
if (oe) outbox_n = oe->relay_count;
|
||||
}
|
||||
|
||||
int total = outbox_n + bootstrap_n;
|
||||
if (total <= 0) continue;
|
||||
|
||||
const char **urls = malloc(total * sizeof(char *));
|
||||
if (!urls) continue;
|
||||
int n = 0;
|
||||
if (oe) {
|
||||
for (int j = 0; j < oe->relay_count && n < total; j++)
|
||||
urls[n++] = oe->relays[j];
|
||||
}
|
||||
for (int j = 0; j < bootstrap_n && n < total; j++) {
|
||||
int dup = 0;
|
||||
for (int k = 0; k < n; k++) {
|
||||
if (strcmp(urls[k], bootstrap[j]) == 0) { dup = 1; break; }
|
||||
}
|
||||
if (!dup) urls[n++] = bootstrap[j];
|
||||
}
|
||||
|
||||
if (n > 0) {
|
||||
pg_inbox_init_relay_progress_for_author(pk, urls, n);
|
||||
}
|
||||
free(urls);
|
||||
}
|
||||
|
||||
free(bootstrap);
|
||||
}
|
||||
|
||||
volatile sig_atomic_t g_shutdown = 0;
|
||||
static volatile sig_atomic_t g_reload = 0;
|
||||
|
||||
@@ -175,10 +235,10 @@ int main(int argc, char **argv) {
|
||||
if (restart) {
|
||||
DEBUG_INFO("RESTART: resetting state to first-time startup");
|
||||
cfg.state.backfilled_until = 0;
|
||||
cfg.state.current_window_index = 0;
|
||||
cfg.state.backfill_cursor = 0;
|
||||
if (pg_conn) {
|
||||
/* Clear per-author backfill progress so the next run starts fresh. */
|
||||
/* Reset per-author backfill progress (caching_followed_pubkeys)
|
||||
* so the next run starts fresh. The followed set itself is
|
||||
* preserved; only cursor/completion state is reset. */
|
||||
pg_inbox_reset_backfill_progress();
|
||||
} else {
|
||||
cr_config_save_state(&cfg);
|
||||
@@ -242,6 +302,20 @@ int main(int argc, char **argv) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Sync the followed set to the DB so the backfill tick can iterate
|
||||
* over it (per-author until-cursor drain). Also run the one-time
|
||||
* migration from the old caching_backfill_progress table. */
|
||||
if (pg_conn) {
|
||||
pg_inbox_migrate_backfill_progress();
|
||||
const char **root_pks = malloc(cfg.root_npub_count * sizeof(char *));
|
||||
for (int i = 0; i < cfg.root_npub_count; i++)
|
||||
root_pks[i] = cfg.root_hex[i];
|
||||
pg_inbox_sync_followed_pubkeys((const char **)followed.items,
|
||||
followed.count, root_pks,
|
||||
cfg.root_npub_count);
|
||||
free(root_pks);
|
||||
}
|
||||
|
||||
if (g_shutdown) goto shutdown;
|
||||
|
||||
/* Discover outbox relays (NIP-65) and compute minimum covering set. */
|
||||
@@ -287,6 +361,22 @@ int main(int argc, char **argv) {
|
||||
free(statuses);
|
||||
}
|
||||
|
||||
/* Create per-relay backfill progress rows for every followed pubkey
|
||||
* (outbox relays + bootstrap relays). Existing rows are left untouched
|
||||
* so cursor/completion state is preserved across restarts. */
|
||||
if (pg_conn) {
|
||||
init_relay_progress_for_all(&cfg, &relay_map, &followed);
|
||||
}
|
||||
|
||||
/* Forward catch-up: bridge the gap for events posted while caching
|
||||
* was off. Runs once at startup (not on --restart, which does a full
|
||||
* re-drain from now). For each followed author with last_event_at > 0,
|
||||
* queries since = last_event_at + 1, until = now. */
|
||||
if (pg_conn && !restart && cfg.backfill.enabled) {
|
||||
DEBUG_INFO("forward catch-up: bridging gap for followed authors");
|
||||
cr_forward_catchup(&cfg, upstream, &sink);
|
||||
}
|
||||
|
||||
/* Open live subscription. */
|
||||
cr_live_t live;
|
||||
if (cfg.live.enabled) {
|
||||
@@ -307,10 +397,11 @@ int main(int argc, char **argv) {
|
||||
|
||||
/* Initial status heartbeat in PostgreSQL mode. */
|
||||
if (pg_conn) {
|
||||
int bf_complete = 0, bf_total = 0;
|
||||
pg_inbox_count_backfill_progress(&bf_complete, &bf_total);
|
||||
pg_inbox_update_status("starting", config_generation, (long)time(NULL),
|
||||
followed.count, relay_map.selected_count, 0,
|
||||
cfg.state.current_window_index,
|
||||
cfg.state.backfill_cursor, 0, 0, NULL, 0);
|
||||
bf_complete, bf_total, 0, 0, NULL, 0);
|
||||
}
|
||||
|
||||
DEBUG_INFO("entering main loop");
|
||||
@@ -322,7 +413,8 @@ int main(int argc, char **argv) {
|
||||
cr_config_t newcfg;
|
||||
int reload_ok = 0;
|
||||
if (pg_conn) {
|
||||
if (pg_config_load(&newcfg) == 0) reload_ok = 1;
|
||||
if (pg_config_load(&newcfg) == 0 &&
|
||||
cr_follow_decode_roots(&newcfg) == 0) reload_ok = 1;
|
||||
} else if (config_path) {
|
||||
if (cr_config_load(&newcfg, config_path) == 0) reload_ok = 1;
|
||||
}
|
||||
@@ -332,6 +424,9 @@ int main(int argc, char **argv) {
|
||||
cr_config_free(&cfg);
|
||||
cfg = newcfg;
|
||||
DEBUG_INFO("config reloaded");
|
||||
/* Force an immediate follow-graph refresh so any new
|
||||
* root npubs are picked up right away. */
|
||||
last_follow_refresh = 0;
|
||||
} else {
|
||||
DEBUG_ERROR("config reload failed, keeping old config");
|
||||
}
|
||||
@@ -346,14 +441,29 @@ int main(int argc, char **argv) {
|
||||
config_generation, new_gen);
|
||||
cr_config_t newcfg;
|
||||
if (pg_config_load(&newcfg) == 0) {
|
||||
newcfg.state = cfg.state;
|
||||
cr_config_free(&cfg);
|
||||
cfg = newcfg;
|
||||
config_generation = new_gen;
|
||||
DEBUG_INFO("config reloaded from PostgreSQL");
|
||||
/* Resubscribe live with new config. */
|
||||
if (cfg.live.enabled) {
|
||||
cr_live_resubscribe(&live, &cfg, upstream, &followed, &sink);
|
||||
/* Decode root npubs to hex — pg_config_load fills
|
||||
* root_npubs[] but NOT root_hex[] / root_hex_ready.
|
||||
* Without this, cr_follow_is_root() returns 0 for
|
||||
* everything and the new root's follows are never
|
||||
* resolved. */
|
||||
if (cr_follow_decode_roots(&newcfg) != 0) {
|
||||
DEBUG_ERROR("config reload: failed to decode root npubs, keeping old config");
|
||||
cr_config_free(&newcfg);
|
||||
} else {
|
||||
newcfg.state = cfg.state;
|
||||
cr_config_free(&cfg);
|
||||
cfg = newcfg;
|
||||
config_generation = new_gen;
|
||||
DEBUG_INFO("config reloaded from PostgreSQL");
|
||||
/* Force an immediate follow-graph refresh so the
|
||||
* new root npub's follows are picked up right
|
||||
* away (instead of waiting up to
|
||||
* follow_graph_refresh_seconds). */
|
||||
last_follow_refresh = 0;
|
||||
/* Resubscribe live with new config. */
|
||||
if (cfg.live.enabled) {
|
||||
cr_live_resubscribe(&live, &cfg, upstream, &followed, &sink);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DEBUG_ERROR("PostgreSQL config reload failed, keeping old config");
|
||||
@@ -373,6 +483,49 @@ int main(int argc, char **argv) {
|
||||
int brc = cr_backfill_tick(&bf, &cfg, upstream, &followed, &sink, &relay_map);
|
||||
(void)brc;
|
||||
|
||||
/* Immediate follow-graph refresh when a root npub publishes a new
|
||||
* kind-3 contact list (detected by the live subscriber). */
|
||||
if (cfg.live.enabled && live.follow_graph_changed) {
|
||||
live.follow_graph_changed = 0;
|
||||
DEBUG_INFO("live: admin kind-3 detected, refreshing follow graph immediately");
|
||||
cr_pubkey_set_t new_followed;
|
||||
cr_pubkey_set_init(&new_followed);
|
||||
if (cr_follow_resolve(&cfg, upstream, &new_followed) == 0) {
|
||||
int changed = (new_followed.count != followed.count);
|
||||
if (!changed) {
|
||||
for (int i = 0; i < followed.count; i++) {
|
||||
if (!cr_pubkey_set_contains(&new_followed, followed.items[i])) {
|
||||
changed = 1; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
cr_pubkey_set_free(&followed);
|
||||
followed = new_followed;
|
||||
if (pg_conn) {
|
||||
const char **root_pks = malloc(cfg.root_npub_count * sizeof(char *));
|
||||
for (int i = 0; i < cfg.root_npub_count; i++)
|
||||
root_pks[i] = cfg.root_hex[i];
|
||||
pg_inbox_sync_followed_pubkeys((const char **)followed.items,
|
||||
followed.count, root_pks,
|
||||
cfg.root_npub_count);
|
||||
free(root_pks);
|
||||
/* Create relay progress rows for newly added follows.
|
||||
* Existing rows are preserved (ON CONFLICT DO NOTHING). */
|
||||
init_relay_progress_for_all(&cfg, &relay_map, &followed);
|
||||
}
|
||||
if (changed && cfg.live.enabled) {
|
||||
cr_live_resubscribe(&live, &cfg, upstream, &followed, &sink);
|
||||
}
|
||||
/* Reset the backfill in_progress flag in case we had entered
|
||||
* steady-state; new follows need to be drained. */
|
||||
bf.in_progress = 1;
|
||||
last_follow_refresh = time(NULL);
|
||||
} else {
|
||||
DEBUG_WARN("immediate follow graph refresh failed");
|
||||
cr_pubkey_set_free(&new_followed);
|
||||
}
|
||||
}
|
||||
|
||||
/* Periodic follow-graph refresh. */
|
||||
time_t now = time(NULL);
|
||||
if (cfg.follow_graph_refresh_seconds > 0 &&
|
||||
@@ -392,9 +545,30 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
cr_pubkey_set_free(&followed);
|
||||
followed = new_followed;
|
||||
/* Sync the refreshed followed set to the DB so newly added
|
||||
* follows get backfilled and dropped follows stop. Existing
|
||||
* cursor/completion state is preserved (upsert only touches
|
||||
* is_root and last_seen). */
|
||||
if (pg_conn) {
|
||||
const char **root_pks = malloc(cfg.root_npub_count * sizeof(char *));
|
||||
for (int i = 0; i < cfg.root_npub_count; i++)
|
||||
root_pks[i] = cfg.root_hex[i];
|
||||
pg_inbox_sync_followed_pubkeys((const char **)followed.items,
|
||||
followed.count, root_pks,
|
||||
cfg.root_npub_count);
|
||||
free(root_pks);
|
||||
/* Create relay progress rows for newly added follows.
|
||||
* Existing rows are preserved (ON CONFLICT DO NOTHING). */
|
||||
init_relay_progress_for_all(&cfg, &relay_map, &followed);
|
||||
}
|
||||
if (changed && cfg.live.enabled) {
|
||||
cr_live_resubscribe(&live, &cfg, upstream, &followed, &sink);
|
||||
}
|
||||
/* Reset the backfill in_progress flag in case we had entered
|
||||
* steady-state; new follows need to be drained. The backfill
|
||||
* tick will quickly re-enter steady-state if there are no
|
||||
* incomplete authors, so this is cheap. */
|
||||
bf.in_progress = 1;
|
||||
} else {
|
||||
DEBUG_WARN("follow graph refresh failed");
|
||||
cr_pubkey_set_free(&new_followed);
|
||||
@@ -429,10 +603,11 @@ int main(int argc, char **argv) {
|
||||
free(listed);
|
||||
free(statuses);
|
||||
}
|
||||
int bf_complete = 0, bf_total = 0;
|
||||
pg_inbox_count_backfill_progress(&bf_complete, &bf_total);
|
||||
pg_inbox_update_status("running", config_generation, (long)now,
|
||||
followed.count, relay_map.selected_count,
|
||||
connected, cfg.state.current_window_index,
|
||||
cfg.state.backfill_cursor,
|
||||
connected, bf_complete, bf_total,
|
||||
events_fetched, inbox_inserts, NULL, 0);
|
||||
last_status_heartbeat = now;
|
||||
}
|
||||
@@ -444,10 +619,11 @@ int main(int argc, char **argv) {
|
||||
cr_live_close(&live);
|
||||
if (!pg_conn) cr_config_save_state(&cfg);
|
||||
if (pg_conn) {
|
||||
int bf_complete = 0, bf_total = 0;
|
||||
pg_inbox_count_backfill_progress(&bf_complete, &bf_total);
|
||||
pg_inbox_update_status("stopped", config_generation, (long)time(NULL),
|
||||
followed.count, relay_map.selected_count, 0,
|
||||
cfg.state.current_window_index,
|
||||
cfg.state.backfill_cursor,
|
||||
bf_complete, bf_total,
|
||||
live.events_received + bf.events_total,
|
||||
sink.published_ok, NULL, 0);
|
||||
}
|
||||
|
||||
+5
-23
@@ -166,49 +166,31 @@ int pg_config_load(cr_config_t *cfg) {
|
||||
|
||||
/* backfill */
|
||||
cfg->backfill.enabled = get_bool("caching_backfill_enabled", 1);
|
||||
char *windows = get_key("caching_backfill_windows");
|
||||
split_csv_long(windows, cfg->backfill.window_schedule_seconds,
|
||||
CR_MAX_WINDOWS, &cfg->backfill.window_count);
|
||||
free(windows);
|
||||
cfg->backfill.events_per_tick =
|
||||
get_int("caching_backfill_page_size", 50);
|
||||
get_int("caching_backfill_page_size", 500);
|
||||
/* tick interval is stored in ms in the config table; convert to seconds. */
|
||||
int tick_ms = get_int("caching_backfill_tick_interval_ms", 5000);
|
||||
cfg->backfill.tick_interval_seconds = (tick_ms + 999) / 1000;
|
||||
if (cfg->backfill.tick_interval_seconds < 1)
|
||||
cfg->backfill.tick_interval_seconds = 1;
|
||||
cfg->backfill.window_cooldown_seconds =
|
||||
get_int("caching_backfill_window_cooldown_seconds", 60);
|
||||
|
||||
/* follow graph refresh */
|
||||
cfg->follow_graph_refresh_seconds =
|
||||
get_int("caching_follow_graph_refresh_seconds", 600);
|
||||
|
||||
/* Defaults if missing (mirror cr_config_load). */
|
||||
if (cfg->backfill.window_count == 0) {
|
||||
static const long def_windows[] = {86400, 604800, 2592000, 7776000, 31536000};
|
||||
int n = (int)(sizeof(def_windows) / sizeof(def_windows[0]));
|
||||
if (n > CR_MAX_WINDOWS) n = CR_MAX_WINDOWS;
|
||||
for (int i = 0; i < n; i++)
|
||||
cfg->backfill.window_schedule_seconds[i] = def_windows[i];
|
||||
cfg->backfill.window_count = n;
|
||||
}
|
||||
/* Defaults if missing. */
|
||||
if (cfg->backfill.events_per_tick == 0)
|
||||
cfg->backfill.events_per_tick = 50;
|
||||
cfg->backfill.events_per_tick = 500;
|
||||
if (cfg->backfill.tick_interval_seconds == 0)
|
||||
cfg->backfill.tick_interval_seconds = 5;
|
||||
if (cfg->backfill.window_cooldown_seconds == 0)
|
||||
cfg->backfill.window_cooldown_seconds = 60;
|
||||
if (cfg->live.resubscribe_interval_seconds == 0)
|
||||
cfg->live.resubscribe_interval_seconds = 300;
|
||||
if (cfg->follow_graph_refresh_seconds == 0)
|
||||
cfg->follow_graph_refresh_seconds = 600;
|
||||
|
||||
/* State: initialize to defaults for this phase. Phase 7 will read
|
||||
* caching_backfill_progress / caching_service_state. */
|
||||
/* State: per-author backfill progress lives in caching_followed_pubkeys.
|
||||
* backfilled_until is only used as a first-time flag in legacy mode. */
|
||||
cfg->state.backfilled_until = 0;
|
||||
cfg->state.current_window_index = 0;
|
||||
cfg->state.backfill_cursor = 0;
|
||||
|
||||
/* Validation. */
|
||||
if (cfg->root_npub_count == 0) {
|
||||
|
||||
+889
-189
File diff suppressed because it is too large
Load Diff
+105
-28
@@ -38,8 +38,8 @@ int pg_inbox_update_status(const char *service_state,
|
||||
int followed_author_count,
|
||||
int selected_relay_count,
|
||||
int connected_relay_count,
|
||||
int current_window_index,
|
||||
int backfill_cursor,
|
||||
int backfill_authors_complete,
|
||||
int backfill_authors_total,
|
||||
long events_fetched,
|
||||
long inbox_inserts,
|
||||
const char *last_error,
|
||||
@@ -54,35 +54,112 @@ char *pg_inbox_get_config_value(const char *key);
|
||||
long pg_inbox_get_config_generation(void);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Backfill progress persistence */
|
||||
/* Per-author until-cursor drain backfill model */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Save backfill progress for a specific author+window (UPSERT).
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int pg_inbox_save_backfill_progress(const char *author_pubkey,
|
||||
int window_index,
|
||||
long window_anchor,
|
||||
long until_cursor,
|
||||
int complete);
|
||||
|
||||
/* Load backfill progress for a specific author+window.
|
||||
* Returns 0 on success (found), -1 on error.
|
||||
* Sets *out_until_cursor and *out_complete. If not found, sets *out_found = 0
|
||||
* and returns 0. */
|
||||
int pg_inbox_load_backfill_progress(const char *author_pubkey,
|
||||
int window_index,
|
||||
long *out_until_cursor,
|
||||
int *out_complete,
|
||||
int *out_found);
|
||||
|
||||
/* Load all incomplete backfill progress rows for a given window.
|
||||
* Returns a cJSON array of objects with "author_pubkey", "until_cursor" and
|
||||
* "complete" fields. Caller must cJSON_Delete the result.
|
||||
* Returns NULL on error or empty. */
|
||||
cJSON *pg_inbox_load_incomplete_progress(int window_index);
|
||||
|
||||
/* Reset all backfill progress (delete all rows).
|
||||
/* Reset all backfill progress: set until_cursor=0 and backfill_complete=FALSE
|
||||
* for all rows in caching_followed_pubkeys (the followed set itself is
|
||||
* preserved). Used by the "Reset Backfill" API button and --restart.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int pg_inbox_reset_backfill_progress(void);
|
||||
|
||||
/* Upsert all followed pubkeys into caching_followed_pubkeys.
|
||||
* For each pubkey: INSERT if new (until_cursor=0, backfill_complete=FALSE),
|
||||
* or UPDATE is_root and last_seen if existing (until_cursor and
|
||||
* backfill_complete are NOT touched). root_pubkeys marks which entries are
|
||||
* root follows. Returns 0 on success, -1 on error. */
|
||||
int pg_inbox_sync_followed_pubkeys(const char **pubkeys, int count,
|
||||
const char **root_pubkeys, int root_count);
|
||||
|
||||
/* Pick the next incomplete author for backfill, round-robin.
|
||||
* On success fills out_pk, out_until_cursor and out_is_root, advances
|
||||
* *round_cursor, and returns 0. Returns -1 if no incomplete authors or
|
||||
* on error. */
|
||||
int pg_inbox_pick_next_backfill_author(char *out_pk, int pk_len,
|
||||
long *out_until_cursor,
|
||||
int *out_is_root,
|
||||
int *round_cursor);
|
||||
|
||||
/* Update the cursor and completion state for an author.
|
||||
* events_this_page is added to the running events_fetched counter.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int pg_inbox_update_backfill_progress(const char *pk, long until_cursor,
|
||||
int complete, int events_this_page);
|
||||
|
||||
/* One-time migration from the old caching_backfill_progress table to the
|
||||
* new caching_followed_pubkeys table. Only runs if the new table is empty
|
||||
* and the old table has rows. Returns 0 on success (including no-op),
|
||||
* -1 on error. */
|
||||
int pg_inbox_migrate_backfill_progress(void);
|
||||
|
||||
/* Count complete/total authors for status reporting.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int pg_inbox_count_backfill_progress(int *out_complete, int *out_total);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Per-relay backfill progress (relay-by-relay drain model) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Initialize relay progress rows for an author. Creates one row per relay
|
||||
* URL with until_cursor=0, complete=FALSE. Skips rows that already exist
|
||||
* (ON CONFLICT DO NOTHING). Returns 0 on success, -1 on error. */
|
||||
int pg_inbox_init_relay_progress_for_author(const char *pk,
|
||||
const char **relay_urls,
|
||||
int relay_count);
|
||||
|
||||
/* Pick the next author that has at least one incomplete relay progress row.
|
||||
* Round-robin via *round_cursor. On success fills out_pk and returns 0.
|
||||
* Returns -1 if no incomplete authors or on error. */
|
||||
int pg_inbox_pick_next_author_with_incomplete_relays(char *out_pk, int pk_len,
|
||||
int *round_cursor);
|
||||
|
||||
/* Get incomplete relays for an author. Returns a cJSON array of
|
||||
* {relay_url, until_cursor} objects (caller frees with cJSON_Delete).
|
||||
* Returns NULL on error or if no incomplete relays. */
|
||||
cJSON *pg_inbox_get_incomplete_relays(const char *pk);
|
||||
|
||||
/* Update relay progress: advance cursor and/or mark complete.
|
||||
* events_this_page is added to the running events_fetched counter.
|
||||
* last_status is a short string like "eose", "timeout", "error", or NULL
|
||||
* to leave unchanged. Returns 0 on success, -1 on error. */
|
||||
int pg_inbox_update_relay_progress(const char *pk, const char *relay_url,
|
||||
long until_cursor, int complete,
|
||||
int events_this_page,
|
||||
const char *last_status);
|
||||
|
||||
/* Check if all relays for an author are complete. If so, mark the author
|
||||
* as backfill_complete=TRUE in caching_followed_pubkeys.
|
||||
* Returns 1 if marked complete, 0 if still incomplete, -1 on error. */
|
||||
int pg_inbox_check_and_mark_author_complete(const char *pk);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Active backfill target (for UI status display) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Set the currently-active backfill target (pubkey + relay being queried).
|
||||
* Either parameter may be NULL/empty to clear. Creates the
|
||||
* caching_backfill_active table if needed (single-row singleton).
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int pg_inbox_set_active_target(const char *pubkey, const char *relay_url);
|
||||
|
||||
/* Read the active backfill target. Fills out_pubkey/out_relay with
|
||||
* the current values (or empty strings if none). Buffers must be
|
||||
* at least 65 and 256 bytes respectively.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int pg_inbox_get_active_target(char *out_pubkey, int pk_len,
|
||||
char *out_relay, int relay_len);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Forward catch-up support (last_event_at tracking) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Returns a cJSON array of {pubkey, last_event_at} objects for all
|
||||
* followed authors where last_event_at > 0. Caller must cJSON_Delete().
|
||||
* Returns NULL on error. */
|
||||
cJSON* pg_inbox_get_authors_for_catchup(void);
|
||||
|
||||
/* Update last_event_at for a pubkey to the GREATEST of current and
|
||||
* the given value. Returns 0 on success, -1 on error. */
|
||||
int pg_inbox_update_last_event_at(const char *pk, long event_created_at);
|
||||
|
||||
#endif /* CACHING_RELAY_PG_INBOX_H */
|
||||
|
||||
@@ -239,8 +239,8 @@ static void compute_covering_set(cr_relay_map_t *map, cr_config_t *cfg,
|
||||
* Returns a cJSON array of events (caller must cJSON_Delete each + free).
|
||||
* Sets *out_count to the number of events returned. */
|
||||
static cJSON **query_kind10002_batch(nostr_relay_pool_t *pool,
|
||||
const char **pubkeys, int pubkey_count,
|
||||
int *out_count) {
|
||||
const char **pubkeys, int pubkey_count,
|
||||
int *out_count) {
|
||||
cJSON *filter = cJSON_CreateObject();
|
||||
cJSON *authors = cJSON_CreateArray();
|
||||
for (int i = 0; i < pubkey_count; i++)
|
||||
@@ -276,6 +276,44 @@ static cJSON **query_kind10002_batch(nostr_relay_pool_t *pool,
|
||||
return events;
|
||||
}
|
||||
|
||||
/* ---- batched kind-3 fallback query ---- */
|
||||
|
||||
/* Query kind 3 (contact list) for a batch of pubkeys from a pool.
|
||||
* Kind-3 events historically contain "r" tags with relay URLs (pre-NIP-65).
|
||||
* We use this as a fallback for pubkeys that don't have kind-10002.
|
||||
* Returns a cJSON array of events (caller must cJSON_Delete each + free).
|
||||
* Sets *out_count to the number of events returned. */
|
||||
static cJSON **query_kind3_batch(nostr_relay_pool_t *pool,
|
||||
const char **pubkeys, int pubkey_count,
|
||||
int *out_count) {
|
||||
cJSON *filter = cJSON_CreateObject();
|
||||
cJSON *authors = cJSON_CreateArray();
|
||||
for (int i = 0; i < pubkey_count; i++)
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(pubkeys[i]));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
cJSON *kinds = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(3));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
/* limit=1 per author: we only need the most recent kind-3.
|
||||
* The relay pool query_sync returns the most recent events. */
|
||||
cJSON_AddItemToObject(filter, "limit", cJSON_CreateNumber(pubkey_count));
|
||||
|
||||
const char **urls = NULL;
|
||||
int n = get_pool_urls(pool, &urls);
|
||||
|
||||
DEBUG_TRACE("query_kind3_batch: querying %d relays for %d pubkeys", n, pubkey_count);
|
||||
|
||||
int ev_count = 0;
|
||||
cJSON **events = nostr_relay_pool_query_sync(pool, urls, n, filter, &ev_count, 30000);
|
||||
free(urls);
|
||||
cJSON_Delete(filter);
|
||||
|
||||
DEBUG_TRACE("query_kind3_batch: returned %d events", ev_count);
|
||||
|
||||
*out_count = ev_count;
|
||||
return events;
|
||||
}
|
||||
|
||||
/* Process a batch of kind-10002 events: parse r-tags, populate outbox entries,
|
||||
* publish to local relay. Returns count of events processed. */
|
||||
static int process_10002_batch(cJSON **events, int ev_count,
|
||||
@@ -438,9 +476,61 @@ int cr_relay_discovery_run(cr_relay_map_t *map, cr_config_t *cfg,
|
||||
free(remaining_pk);
|
||||
}
|
||||
|
||||
/* Phase 3: Kind-3 fallback for pubkeys still without relay lists.
|
||||
* Kind-3 (contact list) events historically contain "r" tags with relay
|
||||
* URLs (pre-NIP-65). This catches older users who never published kind-10002. */
|
||||
int remaining_k3 = 0;
|
||||
for (int i = 0; i < followed->count; i++) if (!found[i]) remaining_k3++;
|
||||
|
||||
if (remaining_k3 > 0) {
|
||||
DEBUG_INFO("relay_discovery: querying bootstrap relays for kind-3 fallback (%d pubkeys)...",
|
||||
remaining_k3);
|
||||
|
||||
const char **remaining_pk = malloc(remaining_k3 * sizeof(char *));
|
||||
int ridx = 0;
|
||||
for (int i = 0; i < followed->count; i++) {
|
||||
if (!found[i]) remaining_pk[ridx++] = followed->items[i];
|
||||
}
|
||||
|
||||
int found_k3 = 0;
|
||||
for (int start = 0; start < remaining_k3 && !g_shutdown; start += CR_10002_BATCH_SIZE) {
|
||||
int batch = remaining_k3 - start;
|
||||
if (batch > CR_10002_BATCH_SIZE) batch = CR_10002_BATCH_SIZE;
|
||||
|
||||
int ev_count = 0;
|
||||
cJSON **events = query_kind3_batch(upstream,
|
||||
&remaining_pk[start],
|
||||
batch, &ev_count);
|
||||
if (events && ev_count > 0) {
|
||||
for (int k = 0; k < ev_count; k++) {
|
||||
cJSON *pk = cJSON_GetObjectItem(events[k], "pubkey");
|
||||
if (pk && cJSON_IsString(pk)) {
|
||||
const char *hex = cJSON_GetStringValue(pk);
|
||||
for (int i = 0; i < followed->count; i++) {
|
||||
if (strcmp(followed->items[i], hex) == 0) {
|
||||
if (!found[i]) { found[i] = 1; found_k3++; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/* process_10002_batch works for kind-3 too — same r-tag format */
|
||||
process_10002_batch(events, ev_count, map, sink, "bootstrap");
|
||||
cr_sink_pump(sink, 500);
|
||||
} else {
|
||||
free(events);
|
||||
}
|
||||
}
|
||||
free(remaining_pk);
|
||||
DEBUG_INFO("relay_discovery: kind-3 fallback found %d additional pubkeys with relays", found_k3);
|
||||
}
|
||||
|
||||
found_none = followed->count - found_local - found_bootstrap;
|
||||
/* Recalculate found_none after kind-3 fallback */
|
||||
int still_none = 0;
|
||||
for (int i = 0; i < followed->count; i++) if (!found[i]) still_none++;
|
||||
DEBUG_INFO("relay_discovery: %d local, %d bootstrap, %d none",
|
||||
found_local, found_bootstrap, found_none);
|
||||
found_local, found_bootstrap, still_none);
|
||||
|
||||
free(found);
|
||||
|
||||
|
||||
+105
@@ -8,8 +8,10 @@ set -euo pipefail
|
||||
# Configuration
|
||||
REMOTE_HOST="ubuntu@laantungir.net"
|
||||
LOCAL_BINARY="build/c_relay_pg_static_x86_64"
|
||||
LOCAL_CACHING_BINARY="build/caching_relay"
|
||||
REMOTE_BINARY_DIR="/usr/local/bin/c_relay_pg"
|
||||
REMOTE_BINARY_PATH="/usr/local/bin/c_relay_pg/c_relay_pg"
|
||||
REMOTE_CACHING_BINARY_PATH="/opt/c-relay-pg/caching_relay"
|
||||
SERVICE_NAME="c-relay-pg"
|
||||
|
||||
LOCAL_SERVICE_FILE="systemd/c-relay.service"
|
||||
@@ -35,6 +37,25 @@ echo "==> Uploading artifacts to $REMOTE_HOST"
|
||||
scp "$LOCAL_BINARY" "$REMOTE_HOST:/tmp/c_relay_pg.tmp"
|
||||
scp "$LOCAL_SERVICE_FILE" "$REMOTE_HOST:/tmp/c-relay-pg.service"
|
||||
scp "$LOCAL_PG_SETUP_SCRIPT" "$REMOTE_HOST:/tmp/setup_postgres_18.sh"
|
||||
# Upload the caching_relay binary if it was built locally. The caching
|
||||
# service is NOT started by this deploy (the systemd unit does not pass
|
||||
# --start-caching), but the binary is placed in the relay's WorkingDirectory
|
||||
# so it is ready for when caching is enabled via event-based config.
|
||||
if [ -f "$LOCAL_CACHING_BINARY" ]; then
|
||||
scp "$LOCAL_CACHING_BINARY" "$REMOTE_HOST:/tmp/caching_relay.tmp"
|
||||
else
|
||||
echo "WARNING: caching_relay binary not found locally ($LOCAL_CACHING_BINARY) — skipping caching binary upload"
|
||||
fi
|
||||
|
||||
# Upload admin PHP files (tar to preserve directory structure, exclude
|
||||
# cache logs and the dev-only serve.sh / php_server.log).
|
||||
echo "==> Packaging admin files"
|
||||
tar czf /tmp/admin_deploy.tar.gz \
|
||||
--exclude='admin/serve.sh' \
|
||||
--exclude='admin/php_server.log' \
|
||||
--exclude='admin/cache' \
|
||||
-C . admin/
|
||||
scp /tmp/admin_deploy.tar.gz "$REMOTE_HOST:/tmp/admin_deploy.tar.gz"
|
||||
|
||||
echo "==> Running remote install/configuration"
|
||||
ssh "$REMOTE_HOST" 'bash -s' <<'EOF'
|
||||
@@ -44,6 +65,7 @@ SERVICE_NAME="c-relay-pg"
|
||||
RELAY_USER="c-relay-pg"
|
||||
REMOTE_BINARY_DIR="/usr/local/bin/c_relay_pg"
|
||||
REMOTE_BINARY_PATH="/usr/local/bin/c_relay_pg/c_relay_pg"
|
||||
REMOTE_CACHING_BINARY_PATH="/opt/c-relay-pg/caching_relay"
|
||||
|
||||
echo "[remote] Ensuring service user exists"
|
||||
if ! id "$RELAY_USER" >/dev/null 2>&1; then
|
||||
@@ -80,11 +102,76 @@ sudo mv /tmp/c_relay_pg.tmp "$REMOTE_BINARY_PATH"
|
||||
sudo chown "$RELAY_USER:$RELAY_USER" "$REMOTE_BINARY_PATH"
|
||||
sudo chmod +x "$REMOTE_BINARY_PATH"
|
||||
|
||||
echo "[remote] Installing caching_relay binary (if uploaded)"
|
||||
if [ -f /tmp/caching_relay.tmp ]; then
|
||||
sudo mv /tmp/caching_relay.tmp "$REMOTE_CACHING_BINARY_PATH"
|
||||
sudo chown "$RELAY_USER:$RELAY_USER" "$REMOTE_CACHING_BINARY_PATH"
|
||||
sudo chmod +x "$REMOTE_CACHING_BINARY_PATH"
|
||||
echo " -> caching_relay installed at $REMOTE_CACHING_BINARY_PATH (not started; enable via config when ready)"
|
||||
else
|
||||
echo " -> no caching_relay binary uploaded, skipping"
|
||||
fi
|
||||
|
||||
echo "[remote] Installing admin PHP files"
|
||||
# Back up existing config.php and .htpasswd so we don't lose production
|
||||
# credentials (DB password, basic-auth) across deploys.
|
||||
if [ -f /opt/c-relay-pg/admin/lib/config.php ]; then
|
||||
sudo cp /opt/c-relay-pg/admin/lib/config.php /tmp/admin_config_backup
|
||||
echo " -> backed up config.php"
|
||||
fi
|
||||
if [ -f /opt/c-relay-pg/admin/.htpasswd ]; then
|
||||
sudo cp /opt/c-relay-pg/admin/.htpasswd /tmp/admin_htpasswd_backup
|
||||
echo " -> backed up .htpasswd"
|
||||
fi
|
||||
# Extract the new admin files (overwrites old admin at /opt/c-relay-pg/admin/).
|
||||
sudo mkdir -p /opt/c-relay-pg/admin
|
||||
sudo tar xzf /tmp/admin_deploy.tar.gz -C /opt/c-relay-pg/
|
||||
# Restore config.php and .htpasswd if they were backed up.
|
||||
if [ -f /tmp/admin_config_backup ]; then
|
||||
sudo cp /tmp/admin_config_backup /opt/c-relay-pg/admin/lib/config.php
|
||||
echo " -> restored config.php"
|
||||
fi
|
||||
if [ -f /tmp/admin_htpasswd_backup ]; then
|
||||
sudo cp /tmp/admin_htpasswd_backup /opt/c-relay-pg/admin/.htpasswd
|
||||
echo " -> restored .htpasswd"
|
||||
fi
|
||||
# Set ownership: www-data (nginx/php-fpm) for web-served files,
|
||||
# but keep .htpasswd readable by nginx.
|
||||
sudo chown -R www-data:www-data /opt/c-relay-pg/admin/
|
||||
sudo chmod 640 /opt/c-relay-pg/admin/.htpasswd 2>/dev/null || true
|
||||
# Ensure the cache directory exists (for chart text files).
|
||||
sudo mkdir -p /opt/c-relay-pg/admin/cache
|
||||
sudo chown www-data:www-data /opt/c-relay-pg/admin/cache
|
||||
echo " -> admin files installed at /opt/c-relay-pg/admin/"
|
||||
|
||||
# Also update admin2 if it exists (symlink or copy to keep it in sync).
|
||||
if [ -d /opt/c-relay-pg/admin2 ]; then
|
||||
sudo rm -rf /opt/c-relay-pg/admin2
|
||||
sudo cp -a /opt/c-relay-pg/admin /opt/c-relay-pg/admin2
|
||||
sudo chown -R www-data:www-data /opt/c-relay-pg/admin2/
|
||||
echo " -> admin2 synced at /opt/c-relay-pg/admin2/"
|
||||
fi
|
||||
|
||||
echo "[remote] Installing systemd unit"
|
||||
sudo mv /tmp/c-relay-pg.service /etc/systemd/system/c-relay-pg.service
|
||||
sudo chown root:root /etc/systemd/system/c-relay-pg.service
|
||||
sudo chmod 644 /etc/systemd/system/c-relay-pg.service
|
||||
|
||||
echo "[remote] Killing stale caching_relay processes (safety)"
|
||||
# The caching service is forked by the relay with setsid(), so it detaches
|
||||
# from the relay's process group and survives when the relay is killed.
|
||||
# Without this, every restart orphans the previous caching_relay child and
|
||||
# a new relay forks another, leading to many stale processes with dead PG
|
||||
# connections spamming errors. Kill them here so the new relay starts clean.
|
||||
CACHING_PIDS=$(pgrep -f "caching_relay" || echo "")
|
||||
if [ -n "$CACHING_PIDS" ]; then
|
||||
echo " -> killing stale caching_relay PIDs: $CACHING_PIDS"
|
||||
kill -9 $CACHING_PIDS 2>/dev/null || true
|
||||
sleep 1
|
||||
else
|
||||
echo " -> no stale caching_relay processes found"
|
||||
fi
|
||||
|
||||
echo "[remote] Reloading and restarting service"
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable "$SERVICE_NAME"
|
||||
@@ -93,6 +180,24 @@ sudo systemctl restart "$SERVICE_NAME"
|
||||
echo "[remote] Health checks"
|
||||
sudo systemctl --no-pager --full status "$SERVICE_NAME" | sed -n '1,25p'
|
||||
sudo -u "$RELAY_USER" psql -d crelay -c "SELECT current_user, current_database();"
|
||||
|
||||
# Verify admin page is accessible (HTTP 200 or 401 = auth required, both are OK).
|
||||
ADMIN_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1/admin/" 2>/dev/null || echo "000")
|
||||
if [ "$ADMIN_CODE" = "200" ] || [ "$ADMIN_CODE" = "401" ]; then
|
||||
echo " -> admin page accessible (HTTP $ADMIN_CODE)"
|
||||
else
|
||||
echo " -> WARNING: admin page returned HTTP $ADMIN_CODE (expected 200 or 401)"
|
||||
fi
|
||||
|
||||
# Clean up temp files.
|
||||
rm -f /tmp/admin_deploy.tar.gz
|
||||
sudo rm -f /tmp/admin_htpasswd_backup /tmp/admin_config_backup
|
||||
EOF
|
||||
|
||||
# Clean up local temp tarball.
|
||||
rm -f /tmp/admin_deploy.tar.gz
|
||||
|
||||
echo "Deployment complete: $REMOTE_HOST"
|
||||
echo ""
|
||||
echo "Admin interface: https://laantungir.net/admin/"
|
||||
echo "Caching is NOT auto-started. Enable it via the admin UI when ready."
|
||||
|
||||
@@ -155,6 +155,34 @@ http {
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
# PHP admin page for caching service (direct PostgreSQL access).
|
||||
# Bypasses the NIP-44 64KB encryption limit of the Nostr admin API.
|
||||
# Requires: php-fpm + php-pgsql installed and configured.
|
||||
location /admin/ {
|
||||
alias /opt/c-relay-pg/admin/;
|
||||
index index.php;
|
||||
|
||||
# HTTP Basic Auth
|
||||
auth_basic "Relay Admin";
|
||||
auth_basic_user_file /opt/c-relay-pg/admin/.htpasswd;
|
||||
|
||||
# Pass .php files to PHP-FPM
|
||||
location ~ \.php$ {
|
||||
# Adjust socket path for your distro:
|
||||
# Debian/Ubuntu: unix:/run/php/php8.2-fpm.sock
|
||||
# RHEL/Fedora: unix:/run/php-fpm/www.sock
|
||||
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
|
||||
fastcgi_index index.php;
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $request_filename;
|
||||
}
|
||||
|
||||
# Deny access to the lib/ directory (contains DB credentials)
|
||||
location ^~ /admin/lib/ {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
|
||||
# Optional: Metrics endpoint (if implemented)
|
||||
location /metrics {
|
||||
proxy_pass http://c_relay_pg_backend/metrics;
|
||||
|
||||
@@ -13,6 +13,8 @@ ADMIN_KEY=""
|
||||
RELAY_KEY=""
|
||||
PORT_OVERRIDE=""
|
||||
DEBUG_LEVEL="5"
|
||||
START_CACHING=false
|
||||
RESET_BACKFILL=false
|
||||
DB_BACKEND="postgres"
|
||||
DB_CONNSTRING=""
|
||||
DB_HOST=""
|
||||
@@ -75,6 +77,14 @@ while [[ $# -gt 0 ]]; do
|
||||
PRESERVE_DATABASE=true
|
||||
shift
|
||||
;;
|
||||
--start-caching)
|
||||
START_CACHING=true
|
||||
shift
|
||||
;;
|
||||
--reset-backfill)
|
||||
RESET_BACKFILL=true
|
||||
shift
|
||||
;;
|
||||
--test-keys|-t)
|
||||
USE_TEST_KEYS=true
|
||||
# Read keys from .test_keys file
|
||||
@@ -359,6 +369,8 @@ if [ "$HELP" = true ]; then
|
||||
echo " --db-name <name> PostgreSQL database name"
|
||||
echo " --db-user <user> PostgreSQL database user"
|
||||
echo " --db-password <pass> PostgreSQL database password"
|
||||
echo " --start-caching Auto-start the caching service on startup"
|
||||
echo " --reset-backfill Clear caching backfill progress (use with --start-caching)"
|
||||
echo " --help, -h Show this help message"
|
||||
echo ""
|
||||
echo "Event-Based Configuration:"
|
||||
@@ -482,6 +494,21 @@ else
|
||||
echo "No existing relay processes found"
|
||||
fi
|
||||
|
||||
# Kill any stale caching_relay processes from previous runs.
|
||||
# The caching service is forked by the relay with setsid(), so it detaches
|
||||
# from the relay's process group and survives when the relay is killed -9.
|
||||
# Without this, every restart orphans the previous caching_relay child and
|
||||
# a new relay forks another, leading to many stale processes with dead PG
|
||||
# connections spamming errors. Kill them here so the new relay starts fresh.
|
||||
CACHING_PIDS=$(pgrep -f "caching_relay" || echo "")
|
||||
if [ -n "$CACHING_PIDS" ]; then
|
||||
echo "Killing stale caching_relay processes: $CACHING_PIDS"
|
||||
kill -9 $CACHING_PIDS 2>/dev/null || true
|
||||
sleep 1
|
||||
else
|
||||
echo "No existing caching_relay processes found"
|
||||
fi
|
||||
|
||||
# Ensure port 8888 is completely free with retry loop
|
||||
echo "Ensuring port 8888 is available..."
|
||||
for attempt in {1..15}; do
|
||||
@@ -512,14 +539,21 @@ for attempt in {1..15}; do
|
||||
fi
|
||||
done
|
||||
|
||||
# Final safety check - ensure no relay processes remain
|
||||
# Final safety check - ensure no relay or caching processes remain
|
||||
FINAL_PIDS=$(pgrep -f "c_relay_pg_" || echo "")
|
||||
if [ -n "$FINAL_PIDS" ]; then
|
||||
echo "Final cleanup: killing processes $FINAL_PIDS"
|
||||
echo "Final cleanup: killing relay processes $FINAL_PIDS"
|
||||
kill -9 $FINAL_PIDS 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
FINAL_CACHING_PIDS=$(pgrep -f "caching_relay" || echo "")
|
||||
if [ -n "$FINAL_CACHING_PIDS" ]; then
|
||||
echo "Final cleanup: killing stale caching_relay processes $FINAL_CACHING_PIDS"
|
||||
kill -9 $FINAL_CACHING_PIDS 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
# Clean up PID file
|
||||
rm -f relay.pid
|
||||
|
||||
@@ -579,6 +613,16 @@ if [ -n "$DB_PASSWORD" ]; then
|
||||
RELAY_ARGS="$RELAY_ARGS --db-password '$DB_PASSWORD'"
|
||||
fi
|
||||
|
||||
if [ "$START_CACHING" = true ]; then
|
||||
RELAY_ARGS="$RELAY_ARGS --start-caching"
|
||||
echo "Auto-starting caching service on startup"
|
||||
fi
|
||||
|
||||
if [ "$RESET_BACKFILL" = true ]; then
|
||||
RELAY_ARGS="$RELAY_ARGS --reset-backfill"
|
||||
echo "Resetting caching backfill progress before start"
|
||||
fi
|
||||
|
||||
# Change to build directory before starting relay so database files are created there
|
||||
cd build
|
||||
# Start relay in background and capture its PID
|
||||
@@ -660,8 +704,10 @@ if ps -p "$RELAY_PID" >/dev/null 2>&1; then
|
||||
echo "Configuration: Event-based (kind 33334 Nostr events)"
|
||||
echo "Database: Automatically created with relay pubkey naming"
|
||||
echo "To kill relay: pkill -f 'c_relay_pg_'"
|
||||
echo "To kill caching service: pkill -f 'caching_relay'"
|
||||
echo "To check status: ps aux | grep c_relay_pg_"
|
||||
echo "To view logs: tail -f relay.log"
|
||||
echo "To view caching logs: tail -f build/caching_relay.log"
|
||||
echo "Binary: $BINARY_PATH (zero configuration needed)"
|
||||
echo "Ready for Nostr client connections!"
|
||||
else
|
||||
|
||||
Submodule nostr_core_lib deleted from 98cfd81b01
@@ -0,0 +1,206 @@
|
||||
# admin2: Full PHP Admin for C-Relay-PG
|
||||
|
||||
## Goal
|
||||
|
||||
Recreate the entire c-relay-pg admin interface as a PHP application in a new
|
||||
`admin2/` directory, starting from the existing `api/` HTML/CSS/JS files and
|
||||
replacing the Nostr admin command backend with direct PostgreSQL queries via
|
||||
PHP PDO. This covers ALL admin sections, not just caching.
|
||||
|
||||
## Architecture
|
||||
|
||||
Same as `admin/` (PHP + nginx + PDO + 10s polling), but with the full
|
||||
section set from the original `api/index.html`:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Browser -->|HTTPS /admin2/| Nginx
|
||||
Nginx -->|*.php| PHP_FPM
|
||||
Nginx -->|/relay/| Relay[C-Relay-PG]
|
||||
PHP_FPM -->|PDO| PostgreSQL
|
||||
Relay -->|libpq| PostgreSQL
|
||||
```
|
||||
|
||||
## Sections (9 pages, matching original nav)
|
||||
|
||||
| # | Nav Label | Section ID | Data Source | PHP API Endpoint |
|
||||
|---|-----------|------------|-------------|------------------|
|
||||
| 1 | Statistics | databaseStatisticsSection | `pg_database_size()`, `events` count, `pg_stat_activity`, process stats | `api/stats.php` |
|
||||
| 2 | Subscriptions | subscriptionDetailsSection | `subscriptions` + `subscription_metrics` tables | `api/subscriptions.php` |
|
||||
| 3 | Configuration | div_config | `config` table (read/edit all keys) | `api/config.php` |
|
||||
| 4 | Authorization | authRulesSection + wotSection | `auth_rules` table | `api/auth.php` |
|
||||
| 5 | IP BAN | ipBansSection | `ip_bans` table | `api/ipbans.php` |
|
||||
| 6 | Relay Events | relayEventsSection | `events` table (recent events, kind filter) | `api/events.php` |
|
||||
| 7 | Caching | cachingSection | `caching_*` tables (already built in `admin/`) | `api/caching.php` |
|
||||
| 8 | DM | nip17DMSection | `events` table (kind 4/14/15 DMs) | `api/dm.php` |
|
||||
| 9 | Database Query | sqlQuerySection | Direct SQL execution (admin only) | `api/query.php` |
|
||||
|
||||
## Graph on Statistics Page
|
||||
|
||||
The original has an event-rate chart. For the PHP version:
|
||||
- **X-axis:** one tick = 10 seconds (the refresh interval)
|
||||
- **Y-axis:** events per interval
|
||||
- **Data source:** `api/stats.php` returns `events_delta` (count of new events since last poll)
|
||||
- **JS:** maintains a rolling array of the last 60 data points (10 minutes of history), appends a new point on each 10s refresh, renders as a simple canvas/SVG line chart
|
||||
- **No external chart library** — use a lightweight inline canvas renderer to keep it dependency-free
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
admin2/
|
||||
├── index.php ← Single-page app (all 9 sections in one page, show/hide via JS)
|
||||
├── assets/
|
||||
│ ├── index.css ← Copied from api/index.css (unchanged)
|
||||
│ └── app.js ← Adapted from api/index.js (replace Nostr commands with fetch() to PHP APIs)
|
||||
├── lib/
|
||||
│ ├── config.php ← DB connection config (same as admin/)
|
||||
│ ├── db.php ← PDO helper (same as admin/)
|
||||
│ └── helpers.php ← Shared helpers (same as admin/)
|
||||
├── api/
|
||||
│ ├── stats.php ← Statistics: DB size, event counts, process info, event-rate delta
|
||||
│ ├── subscriptions.php ← Subscription details + metrics
|
||||
│ ├── config.php ← Config table read/edit (all keys, not just caching)
|
||||
│ ├── auth.php ← Auth rules + WoT status
|
||||
│ ├── ipbans.php ← IP ban list (read/add/remove)
|
||||
│ ├── events.php ← Recent relay events (paginated, kind filter)
|
||||
│ ├── caching.php ← Caching status (reuses admin/api/status.php logic)
|
||||
│ ├── dm.php ← DM list (kind 4/14/15 events)
|
||||
│ └── query.php ← Direct SQL query execution (admin only, read-only by default)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1 — Scaffold admin2/ directory
|
||||
- Create `admin2/` with `lib/` (copy from `admin/`), `assets/`, `api/`
|
||||
- Copy `api/index.css` → `admin2/assets/index.css` (unchanged)
|
||||
- Copy `api/index.html` → `admin2/index.php` (will be adapted)
|
||||
|
||||
### Step 2 — Adapt index.php from api/index.html
|
||||
- Remove Nostr login/auth (NIP-07 extension, kind-23456 admin commands)
|
||||
- Remove WebSocket connection logic (replaced by PHP AJAX polling)
|
||||
- Keep all 9 section HTML structures exactly as-is
|
||||
- Keep the side-nav, header, section show/hide JS
|
||||
- Replace `sendAdminCommand()` calls with `fetch('api/*.php')` calls
|
||||
- Add 10s auto-refresh via `setInterval` for statistics page
|
||||
- Add event-rate chart canvas renderer
|
||||
|
||||
### Step 3 — Adapt app.js from api/index.js
|
||||
- Keep: `switchPage()`, nav handling, section show/hide, config table rendering
|
||||
- Remove: WebSocket connection, NIP-07 login, NIP-44 encryption, kind-23456 event sending
|
||||
- Replace: each `sendAdminCommand(['system_command', '...'])` with `fetch('api/....php')`
|
||||
- Add: `refreshStats()` function that polls `api/stats.php` every 10s
|
||||
- Add: event-rate chart rendering on canvas (rolling 60-point window)
|
||||
|
||||
### Step 4 — Build PHP API endpoints (9 files)
|
||||
Each endpoint returns JSON, queried directly from PostgreSQL via PDO:
|
||||
|
||||
**api/stats.php** — Statistics page data:
|
||||
```sql
|
||||
SELECT pg_database_size(current_database()) AS db_size;
|
||||
SELECT COUNT(*) FROM events;
|
||||
SELECT COUNT(*) FROM pg_stat_activity WHERE state = 'active';
|
||||
-- Event rate: count events with first_seen in last 10s
|
||||
SELECT COUNT(*) FROM events WHERE first_seen >= EXTRACT(EPOCH FROM NOW())::BIGINT - 10;
|
||||
-- Kind distribution
|
||||
SELECT kind, COUNT(*) FROM events GROUP BY kind ORDER BY count DESC;
|
||||
```
|
||||
|
||||
**api/config.php** — Configuration (all config keys, not just caching):
|
||||
```sql
|
||||
SELECT key, value FROM config ORDER BY key;
|
||||
-- POST: UPDATE config SET value = ? WHERE key = ?
|
||||
```
|
||||
|
||||
**api/subscriptions.php** — Subscription details:
|
||||
```sql
|
||||
SELECT * FROM subscriptions ORDER BY created_at DESC LIMIT 100;
|
||||
SELECT * FROM subscription_metrics ORDER BY id DESC LIMIT 100;
|
||||
```
|
||||
|
||||
**api/auth.php** — Auth rules:
|
||||
```sql
|
||||
SELECT * FROM auth_rules ORDER BY id;
|
||||
```
|
||||
|
||||
**api/ipbans.php** — IP bans:
|
||||
```sql
|
||||
SELECT * FROM ip_bans ORDER BY banned_at DESC;
|
||||
-- POST: INSERT/DELETE
|
||||
```
|
||||
|
||||
**api/events.php** — Recent relay events (paginated):
|
||||
```sql
|
||||
SELECT id, pubkey, kind, created_at, content, tags
|
||||
FROM events ORDER BY created_at DESC LIMIT 50 OFFSET ?;
|
||||
-- Filter by kind, pubkey
|
||||
```
|
||||
|
||||
**api/caching.php** — Caching status (reuse from admin/):
|
||||
```sql
|
||||
SELECT * FROM caching_service_state;
|
||||
SELECT * FROM caching_followed_pubkeys LIMIT 50 OFFSET ?;
|
||||
SELECT * FROM caching_backfill_relay_progress LIMIT 50 OFFSET ?;
|
||||
```
|
||||
|
||||
**api/dm.php** — Direct messages:
|
||||
```sql
|
||||
SELECT id, pubkey, kind, created_at, content
|
||||
FROM events WHERE kind IN (4, 14, 15) ORDER BY created_at DESC LIMIT 50;
|
||||
```
|
||||
|
||||
**api/query.php** — SQL query (admin only):
|
||||
```sql
|
||||
-- Execute arbitrary SELECT, return JSON
|
||||
-- Read-only by default; write mode behind a flag
|
||||
```
|
||||
|
||||
### Step 5 — Event-rate chart
|
||||
- Canvas element in the statistics section
|
||||
- JS maintains `eventRateHistory = []` (max 60 points)
|
||||
- On each 10s refresh, push new event count, shift if > 60
|
||||
- Render as line chart on canvas (no external library)
|
||||
- X-axis labels: time (mm:ss), one tick per 10s
|
||||
- Y-axis labels: event count per interval
|
||||
|
||||
### Step 6 — nginx config
|
||||
Add `/admin2/` location block (same pattern as `/admin/`):
|
||||
```nginx
|
||||
location ^~ /admin2/ {
|
||||
alias /opt/c-relay-pg/admin2/;
|
||||
index index.php;
|
||||
auth_basic "Relay Admin";
|
||||
auth_basic_user_file /opt/c-relay-pg/admin/.htpasswd;
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $request_filename;
|
||||
}
|
||||
location ^~ /admin2/lib/ { deny all; }
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7 — Deploy and test
|
||||
- Copy `admin2/` to server
|
||||
- Add nginx location block, reload
|
||||
- Test all 9 sections
|
||||
- Verify 10s auto-refresh on statistics page
|
||||
- Verify event-rate chart updates
|
||||
|
||||
## Key Differences from Original api/
|
||||
|
||||
| Aspect | Original (api/) | New (admin2/) |
|
||||
|--------|-----------------|---------------|
|
||||
| Auth | NIP-07 Nostr extension login | HTTP Basic Auth (nginx) |
|
||||
| Data fetch | WebSocket + kind-23456 encrypted admin commands | PHP AJAX `fetch()` to `api/*.php` |
|
||||
| Encryption | NIP-44 (64KB limit) | None (direct DB, no limit) |
|
||||
| Real-time | WebSocket push | 10s polling |
|
||||
| Backend | C relay binary (embedded JS) | PHP-FPM + PostgreSQL PDO |
|
||||
| Sections | 9 (same) | 9 (same HTML, different data source) |
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- NIP-07 Nostr login (use HTTP Basic Auth)
|
||||
- WebSocket push (use 10s polling)
|
||||
- Write operations that require relay-level logic (event publishing, NIP-42 auth) — those stay on the Nostr admin API
|
||||
- The original `api/` page remains unchanged and functional
|
||||
@@ -0,0 +1,368 @@
|
||||
# Backfill Redesign: Per-Author Until-Cursor Drain (Option B)
|
||||
|
||||
## Problem
|
||||
|
||||
The current window-expansion backfill (`caching/src/backfill.c`) has four
|
||||
compounding structural defects that make cache coverage unacceptably poor
|
||||
(~10-28% of upstream availability even for a 7-pubkey follow set):
|
||||
|
||||
1. **Overlapping windows re-fetch the same events.** Window N+1's time range
|
||||
fully contains window N's range. The only dedup is a 4096-entry in-memory
|
||||
ring + inbox `ON CONFLICT DO NOTHING`, which overflows for high-volume
|
||||
authors, wasting query budget on duplicates.
|
||||
2. **Broken saturation detection.** When a relay caps at 500 events, the code
|
||||
retries with `limit=5000`, still gets 500, compares `500 == 5000` (false),
|
||||
and incorrectly concludes the page was not saturated — marking the author
|
||||
complete without paginating.
|
||||
3. **`until`-cursor walk-back never triggers** because it's gated on the
|
||||
broken saturation check.
|
||||
4. **Window 4 (full history) was skipped for 5/7 pubkeys** and the loop won't
|
||||
retry once `current_window_index` advances past the last window.
|
||||
|
||||
## Solution
|
||||
|
||||
Replace the window-expansion model with a **per-author `until`-cursor drain**:
|
||||
each followed author has a single persistent cursor (starting at `now`). Each
|
||||
backfill tick queries `since=0&until=cursor&limit=500` for one author,
|
||||
publishes the page, and sets `cursor = oldest_event_created_at - 1`. Repeat
|
||||
until a page returns fewer than the relay's cap (beginning of history reached).
|
||||
The live subscriber stays as-is (it's already correct).
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[cr_follow_resolve] --> B[caching_followed_pubkeys table]
|
||||
B --> C[Backfill tick: pick next incomplete author]
|
||||
C --> D[Query since=0 and until=cursor and limit=500]
|
||||
D --> E{ev_count == relay_cap?}
|
||||
E -- yes --> F[Publish page, set cursor = oldest - 1]
|
||||
F --> D
|
||||
E -- no < relay_cap --> G[Publish page, mark backfill_complete = true]
|
||||
G --> H{More incomplete authors?}
|
||||
H -- yes --> C
|
||||
H -- no --> I[Steady state: live sub only]
|
||||
J[Live subscriber] --> K[caching_event_inbox]
|
||||
K --> L[c-relay-pg inbox poller]
|
||||
L --> M[events table]
|
||||
```
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. New table: `caching_followed_pubkeys`
|
||||
|
||||
Replaces both the ephemeral in-memory `cr_pubkey_set_t` (for persistence) and
|
||||
the window-coupled `caching_backfill_progress` (for cursor state).
|
||||
|
||||
**Schema** (add to `src/pg_schema.sql`):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS caching_followed_pubkeys (
|
||||
pubkey TEXT PRIMARY KEY,
|
||||
is_root BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
until_cursor BIGINT NOT NULL DEFAULT 0,
|
||||
backfill_complete BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
events_fetched INTEGER NOT NULL DEFAULT 0,
|
||||
first_seen BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
|
||||
last_seen BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
|
||||
updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_caching_followed_incomplete
|
||||
ON caching_followed_pubkeys(backfill_complete, last_seen)
|
||||
WHERE backfill_complete = FALSE;
|
||||
```
|
||||
|
||||
- `until_cursor`: the `until` timestamp for the next backfill query. `0` means
|
||||
"not started yet" (treat as `now` on first query). After each page, set to
|
||||
`oldest_event_created_at - 1`.
|
||||
- `backfill_complete`: set true when a page returns fewer than the relay cap
|
||||
(we've reached the beginning of the author's history).
|
||||
- `is_root`: true for root/admin npubs (used to apply `admin_kinds`).
|
||||
- `events_fetched`: cumulative count for diagnostics.
|
||||
- `last_seen`: updated each follow-graph refresh (prune stale follows).
|
||||
|
||||
**Migration**: On startup, if `caching_followed_pubkeys` is empty but
|
||||
`caching_backfill_progress` has rows, migrate: for each distinct
|
||||
`author_pubkey` in the old table, insert a row with `until_cursor = 0`,
|
||||
`backfill_complete = FALSE` (re-backfill from scratch — the old data was
|
||||
incomplete anyway). Drop `caching_backfill_progress` after migration.
|
||||
|
||||
### 2. Follow graph resolver: persist to `caching_followed_pubkeys`
|
||||
|
||||
**File**: `caching/src/follow_graph.c` + `caching/src/pg_inbox.c`
|
||||
|
||||
After `cr_follow_resolve()` builds the in-memory set, sync it to the table:
|
||||
|
||||
```c
|
||||
/* For each pubkey in the followed set: */
|
||||
/* INSERT INTO caching_followed_pubkeys (pubkey, is_root, last_seen)
|
||||
* VALUES ($1, $2, now)
|
||||
* ON CONFLICT (pubkey) DO UPDATE SET
|
||||
* is_root = EXCLUDED.is_root,
|
||||
* last_seen = EXCLUDED.last_seen;
|
||||
*
|
||||
* Mark follows not seen this refresh as stale (for pruning / diagnostics).
|
||||
* Do NOT touch until_cursor or backfill_complete on existing rows. */
|
||||
```
|
||||
|
||||
New function: `pg_inbox_sync_followed_pubkeys(cr_pubkey_set_t *followed,
|
||||
cr_config_t *cfg)` — upserts all followed pubkeys, updates `last_seen`, sets
|
||||
`is_root` based on `cr_follow_is_root()`.
|
||||
|
||||
New function: `pg_inbox_prune_stale_follows(long threshold_seconds)` —
|
||||
optionally deletes follows not seen in `threshold_seconds` (e.g. 24h). This is
|
||||
off by default; stale follows just stop getting backfilled.
|
||||
|
||||
The in-memory `cr_pubkey_set_t followed` stays as the runtime working set
|
||||
(used by the live subscriber). The table is the durable source of truth for
|
||||
backfill iteration.
|
||||
|
||||
### 3. Rewrite `cr_backfill_tick` — per-author until-cursor drain
|
||||
|
||||
**File**: `caching/src/backfill.c`
|
||||
|
||||
Replace the entire window-expansion logic. New `cr_backfill_tick`:
|
||||
|
||||
```c
|
||||
int cr_backfill_tick(cr_backfill_t *bf, cr_config_t *cfg,
|
||||
nostr_relay_pool_t *upstream,
|
||||
cr_sink_t *sink, const cr_relay_map_t *relay_map) {
|
||||
if (!cfg->backfill.enabled) return -2;
|
||||
|
||||
/* Throttle: one query per tick_interval. */
|
||||
time_t now = time(NULL);
|
||||
if (bf->last_tick && (now - bf->last_tick) < cfg->backfill.tick_interval_seconds)
|
||||
return 0;
|
||||
bf->last_tick = now;
|
||||
|
||||
/* Pick the next incomplete author from the DB (round-robin via
|
||||
* last_seen ordering, or a cursor we persist in bf). */
|
||||
char pk[CR_HEX_LEN];
|
||||
long until_cursor = 0;
|
||||
int is_root = 0;
|
||||
if (pg_inbox_pick_next_backfill_author(pk, &until_cursor, &is_root,
|
||||
&bf->author_round_cursor) != 0) {
|
||||
/* No incomplete authors — steady state. */
|
||||
bf->in_progress = 0;
|
||||
return -2;
|
||||
}
|
||||
|
||||
/* First-time: cursor 0 means start from now. */
|
||||
if (until_cursor == 0) until_cursor = (long)now;
|
||||
|
||||
int page_size = 500; /* matches typical relay cap */
|
||||
int ev_count = 0;
|
||||
cJSON **events = query_author(upstream, relay_map, cfg, pk,
|
||||
0 /* since=0 full history */,
|
||||
until_cursor, page_size, &ev_count);
|
||||
|
||||
if (!events || ev_count == 0) {
|
||||
/* No events at all (or no more) — author is complete. */
|
||||
pg_inbox_update_backfill_progress(pk, 0, 1 /* complete */, 0);
|
||||
free(events);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Find oldest timestamp BEFORE publishing. */
|
||||
long oldest_ts = 0;
|
||||
find_oldest_created_at(events, ev_count, &oldest_ts);
|
||||
|
||||
/* Publish all events. */
|
||||
cr_sink_set_source_class(sink, CR_SINK_CLASS_BACKFILL);
|
||||
for (int k = 0; k < ev_count; k++) {
|
||||
cr_sink_publish(sink, events[k]);
|
||||
cJSON_Delete(events[k]);
|
||||
}
|
||||
free(events);
|
||||
bf->events_total += ev_count;
|
||||
|
||||
/* Saturation check: if we got exactly page_size, the page may be
|
||||
* truncated. Set cursor = oldest - 1 and keep going next tick.
|
||||
* If we got fewer than page_size, we've reached the beginning. */
|
||||
if (ev_count >= page_size) {
|
||||
/* Saturated — more history likely exists. Advance cursor. */
|
||||
long next_cursor = (oldest_ts > 0) ? oldest_ts - 1 : 0;
|
||||
pg_inbox_update_backfill_progress(pk, next_cursor, 0 /* not complete */,
|
||||
ev_count);
|
||||
} else {
|
||||
/* Not saturated — beginning of history reached. */
|
||||
pg_inbox_update_backfill_progress(pk, until_cursor, 1 /* complete */,
|
||||
ev_count);
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
```
|
||||
|
||||
Key differences from the old code:
|
||||
- **No windows.** Single `since=0` query per author, always full history.
|
||||
- **Saturation check compares against `page_size` (500)**, not `max_cap`
|
||||
(5000). This fixes Defect 2 — if the relay returns 500, we paginate.
|
||||
- **Cursor persisted per-author** in `caching_followed_pubkeys.until_cursor`,
|
||||
not in a window-coupled table. This fixes Defect 4 — no window to skip.
|
||||
- **No overlapping queries.** Each event is fetched exactly once as the cursor
|
||||
walks backwards. This fixes Defect 1.
|
||||
- **`pg_inbox_pick_next_backfill_author`** selects the next
|
||||
`backfill_complete = FALSE` author, round-robin, so all authors get fair
|
||||
backfill time.
|
||||
|
||||
### 4. New PG inbox functions
|
||||
|
||||
**File**: `caching/src/pg_inbox.c` + `caching/src/pg_inbox.h`
|
||||
|
||||
```c
|
||||
/* Sync the followed set to the DB (upsert + update last_seen).
|
||||
* Does NOT touch until_cursor or backfill_complete on existing rows. */
|
||||
int pg_inbox_sync_followed_pubkeys(const cr_pubkey_set_t *followed,
|
||||
const cr_config_t *cfg);
|
||||
|
||||
/* Pick the next incomplete author for backfill (round-robin).
|
||||
* Sets out_pk, out_until_cursor, out_is_root.
|
||||
* Returns 0 on success, -1 if no incomplete authors remain. */
|
||||
int pg_inbox_pick_next_backfill_author(char *out_pk,
|
||||
long *out_until_cursor,
|
||||
int *out_is_root,
|
||||
int *round_cursor);
|
||||
|
||||
/* Update backfill progress for an author. */
|
||||
int pg_inbox_update_backfill_progress(const char *pk,
|
||||
long until_cursor,
|
||||
int complete,
|
||||
int events_this_page);
|
||||
|
||||
/* Migrate old caching_backfill_progress rows to caching_followed_pubkeys.
|
||||
* Called once on startup if the new table is empty and the old one has rows. */
|
||||
int pg_inbox_migrate_backfill_progress(void);
|
||||
|
||||
/* Reset all backfill progress (set until_cursor=0, backfill_complete=FALSE
|
||||
* for all followed pubkeys). Used by the "Reset Backfill" API button. */
|
||||
int pg_inbox_reset_backfill_progress(void);
|
||||
```
|
||||
|
||||
### 5. Simplify `cr_backfill_t` and `cr_config_t` state
|
||||
|
||||
**File**: `caching/src/backfill.h`, `caching/src/config.h`
|
||||
|
||||
Remove from `cr_backfill_t`:
|
||||
- `window_index`, `current_window_s`, `events_this_window`, `window_started`
|
||||
- `author_complete`, `blocked`, `blocked_until_ts`
|
||||
|
||||
Keep:
|
||||
- `cursor` (now: round-robin index into followed set, for fair scheduling)
|
||||
- `until_cursor` (transient, per-tick — not persisted here, read from DB)
|
||||
- `in_progress` (1 if any incomplete authors remain)
|
||||
- `events_total` (cumulative diagnostic)
|
||||
- `last_tick` (throttle)
|
||||
|
||||
Remove from `cr_config_t` state:
|
||||
- `state.backfilled_until` (no longer meaningful)
|
||||
- `state.current_window_index` (no windows)
|
||||
|
||||
Remove from `cr_config_t` backfill config:
|
||||
- `backfill.window_schedule_seconds[]`, `backfill.window_count`
|
||||
- `backfill.window_cooldown_seconds`
|
||||
- `backfill.events_per_tick` → rename to `backfill.page_size` (default 500)
|
||||
|
||||
Keep:
|
||||
- `backfill.enabled`
|
||||
- `backfill.page_size` (was `events_per_tick`)
|
||||
- `backfill.tick_interval_seconds`
|
||||
|
||||
### 6. Update `caching_service_state` table
|
||||
|
||||
**File**: `src/pg_schema.sql`
|
||||
|
||||
The `current_window_index` and `backfill_cursor` columns are no longer
|
||||
meaningful. Replace with:
|
||||
|
||||
```sql
|
||||
ALTER TABLE caching_service_state
|
||||
DROP COLUMN IF EXISTS current_window_index,
|
||||
DROP COLUMN IF EXISTS backfill_cursor,
|
||||
ADD COLUMN IF NOT EXISTS backfill_authors_complete INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS backfill_authors_total INTEGER NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
Update `pg_inbox_update_status()` to report
|
||||
`backfill_authors_complete / backfill_authors_total` instead of
|
||||
`current_window_index / backfill_cursor`. The API status panel shows this as a
|
||||
progress bar (e.g. "Backfill: 3/7 authors complete").
|
||||
|
||||
### 7. Update main loop
|
||||
|
||||
**File**: `caching/src/main.c`
|
||||
|
||||
- Remove `advance_window` calls (no windows).
|
||||
- After `cr_follow_resolve()`, call `pg_inbox_sync_followed_pubkeys()`.
|
||||
- On startup, call `pg_inbox_migrate_backfill_progress()` if needed.
|
||||
- `cr_backfill_init` just sets `in_progress = 1` (check if any incomplete
|
||||
authors exist in the DB).
|
||||
- The follow-graph refresh at [`main.c:378`](caching/src/main.c:378) now also
|
||||
calls `pg_inbox_sync_followed_pubkeys()` so new follows get backfilled and
|
||||
unfollowed pubkeys stop getting backfilled.
|
||||
|
||||
### 8. Update API status display
|
||||
|
||||
**File**: `api/index.js`, `src/config.c` (caching_status handler)
|
||||
|
||||
- Show "Backfill: X/Y authors complete" instead of "Window N, cursor M".
|
||||
- The "Reset Backfill Progress" button now calls
|
||||
`pg_inbox_reset_backfill_progress()` which sets
|
||||
`until_cursor=0, backfill_complete=FALSE` for all rows in
|
||||
`caching_followed_pubkeys`.
|
||||
|
||||
### 9. Config changes
|
||||
|
||||
**File**: `src/pg_schema.sql` (default config inserts), `caching/src/pg_config.c`
|
||||
|
||||
Remove config keys:
|
||||
- `caching_backfill_windows` (no window schedule)
|
||||
- `caching_backfill_window_cooldown_seconds` (no windows)
|
||||
|
||||
Add config key:
|
||||
- `caching_backfill_page_size` already exists (value 500) — repurpose as the
|
||||
per-query limit (was already used this way, just rename in docs).
|
||||
|
||||
Keep:
|
||||
- `caching_backfill_enabled`
|
||||
- `caching_backfill_tick_interval_ms` (throttle between queries)
|
||||
- `caching_backfill_page_size` (per-query limit, default 500)
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Schema**: Add `caching_followed_pubkeys` table + alter
|
||||
`caching_service_state` in `src/pg_schema.sql`.
|
||||
2. **PG inbox functions**: Add `pg_inbox_sync_followed_pubkeys`,
|
||||
`pg_inbox_pick_next_backfill_author`, `pg_inbox_update_backfill_progress`,
|
||||
`pg_inbox_migrate_backfill_progress`, `pg_inbox_reset_backfill_progress`
|
||||
in `caching/src/pg_inbox.c` + `.h`.
|
||||
3. **Follow graph sync**: Call `pg_inbox_sync_followed_pubkeys()` after
|
||||
`cr_follow_resolve()` in `caching/src/main.c`.
|
||||
4. **Rewrite backfill**: Replace `cr_backfill_tick` in
|
||||
`caching/src/backfill.c`. Simplify `cr_backfill_t` in `backfill.h`.
|
||||
5. **Config cleanup**: Remove window config from `caching/src/pg_config.c`
|
||||
and `caching/src/config.c`. Update defaults in `src/pg_schema.sql`.
|
||||
6. **State reporting**: Update `pg_inbox_update_status()` in
|
||||
`caching/src/pg_inbox.c` to report authors complete/total.
|
||||
7. **API status**: Update `api/index.js` and `src/config.c` status handler
|
||||
to show backfill author progress.
|
||||
8. **Migration**: Implement `pg_inbox_migrate_backfill_progress()` and call
|
||||
on startup.
|
||||
9. **Test**: Rebuild, restart with `--reset-backfill`, verify 100% coverage
|
||||
for all 7 followed pubkeys via `nak req` comparison against upstream.
|
||||
|
||||
## Risk and Mitigation
|
||||
|
||||
- **Relays that don't honor `until`**: If a relay ignores the `until` filter,
|
||||
the cursor won't advance and we'll re-fetch the same page. Mitigation:
|
||||
detect when `oldest_ts` doesn't decrease between consecutive pages for the
|
||||
same author and mark the author complete (or skip to a different relay).
|
||||
- **High-volume authors take many ticks**: At 500 events/tick and 5s
|
||||
tick_interval, a 10,000-event author takes ~100s. This is acceptable — the
|
||||
live subscriber keeps you current, and the backfill makes steady progress.
|
||||
If faster backfill is needed later, Option C (parallel workers) can be added
|
||||
on top of this design.
|
||||
- **Follow graph churn**: If follows change frequently, the sync upsert is
|
||||
O(N) per refresh (every 10 min). Fine for personal-scale (hundreds of
|
||||
follows). For thousands, batch the upsert.
|
||||
@@ -0,0 +1,307 @@
|
||||
# Backfill v2: Relay-by-Relay Per-Author Until-Cursor Drain
|
||||
|
||||
## Overview
|
||||
|
||||
Refine the per-author until-cursor backfill to query each relay **one at a
|
||||
time** with **per-relay cursor tracking**, and pick up new follows
|
||||
**immediately** when the admin's kind-3 contact list changes.
|
||||
|
||||
## Problem with the current implementation
|
||||
|
||||
The current backfill (`caching/src/backfill.c`) calls `nostr_relay_pool_query_sync`
|
||||
with **all relay URLs at once**. This function:
|
||||
|
||||
1. Sends the same REQ to all connected relays
|
||||
2. Collects and deduplicates events from all relays into one merged set
|
||||
3. Waits until ALL relays send EOSE, or the timeout (now 30s) expires
|
||||
4. Returns the merged set — but **does not tell the caller whether it exited
|
||||
via all-EOSE or timeout**
|
||||
|
||||
If one relay is slow (e.g. nos.lol streaming 500+ events), the timeout can
|
||||
expire before that relay sends EOSE. The result is a partial event set, but
|
||||
the backfill code can't distinguish "partial due to timeout" from "complete
|
||||
because the author only has 225 events." This causes premature completion
|
||||
marking.
|
||||
|
||||
Additionally, the current design has a single `until_cursor` per author in
|
||||
`caching_followed_pubkeys`, which doesn't track per-relay progress. And new
|
||||
follows are only discovered on the 10-minute follow-graph refresh cycle.
|
||||
|
||||
## Solution
|
||||
|
||||
### 1. Per-relay cursor tracking
|
||||
|
||||
Add a new table to track the backfill cursor for each author on each relay
|
||||
independently:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS caching_backfill_relay_progress (
|
||||
author_pubkey TEXT NOT NULL,
|
||||
relay_url TEXT NOT NULL,
|
||||
until_cursor BIGINT NOT NULL DEFAULT 0,
|
||||
complete BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
events_fetched INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
|
||||
PRIMARY KEY (author_pubkey, relay_url)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_caching_relay_progress_incomplete
|
||||
ON caching_backfill_relay_progress(author_pubkey)
|
||||
WHERE complete = FALSE;
|
||||
```
|
||||
|
||||
- `until_cursor`: the `until` timestamp for the next query to this specific
|
||||
relay for this author. `0` means "not started" (treat as `now`).
|
||||
- `complete`: set true when this relay returns < page_size events for the
|
||||
current cursor range (this relay is drained for this author).
|
||||
- `events_fetched`: cumulative count for diagnostics.
|
||||
|
||||
The existing `caching_followed_pubkeys.backfill_complete` becomes a derived
|
||||
value: an author is complete when all their relay progress rows are complete
|
||||
(or when they have no relay progress rows at all — no relays to query).
|
||||
|
||||
### 2. Relay-by-relay querying
|
||||
|
||||
Instead of one `query_sync` call with N relays, make N `query_sync` calls
|
||||
with 1 relay each. Each call waits for that single relay's EOSE (or timeout).
|
||||
No merge/dedup needed — publish every event directly to the inbox; the
|
||||
database handles deduplication (`ON CONFLICT DO NOTHING`).
|
||||
|
||||
### 3. Immediate new-follow detection
|
||||
|
||||
When the admin publishes a new kind-3 contact list, the live subscriber's
|
||||
`on_event` callback receives it in real-time. We detect this and trigger an
|
||||
immediate follow-graph refresh, which:
|
||||
- Resolves the new follow set
|
||||
- Syncs new pubkeys to `caching_followed_pubkeys`
|
||||
- Creates relay progress rows for new follows (cursor=0, complete=false)
|
||||
- The backfill loop picks them up on the next tick
|
||||
|
||||
## Algorithm
|
||||
|
||||
### Per backfill tick:
|
||||
|
||||
```
|
||||
1. Pick next author that has at least one incomplete relay progress row
|
||||
(round-robin from caching_followed_pubkeys WHERE backfill_complete=FALSE)
|
||||
2. Get incomplete relays for this author:
|
||||
SELECT relay_url, until_cursor FROM caching_backfill_relay_progress
|
||||
WHERE author_pubkey = $1 AND complete = FALSE
|
||||
3. For each incomplete relay (one at a time):
|
||||
a. cursor = until_cursor (0 = now)
|
||||
b. query_sync(pool, &relay_url, 1, filter, &ev_count, 30000)
|
||||
filter = {authors: [pk], kinds: [configured], since: 0,
|
||||
until: cursor, limit: 500}
|
||||
c. Publish every event directly to inbox (cr_sink_publish)
|
||||
d. Find oldest_ts in this batch
|
||||
e. If ev_count == 0:
|
||||
- Mark this relay complete for this author
|
||||
(UPDATE caching_backfill_relay_progress SET complete=TRUE)
|
||||
f. If ev_count >= page_size (saturated):
|
||||
- Advance this relay's cursor: cursor = oldest_ts - 1
|
||||
(UPDATE caching_backfill_relay_progress SET until_cursor = oldest-1)
|
||||
g. If ev_count < page_size but > 0:
|
||||
- This relay is drained for this cursor range
|
||||
- Advance cursor to oldest_ts - 1 AND mark complete
|
||||
(the next query at this lower cursor would return 0 anyway,
|
||||
but we mark complete to avoid an extra round-trip)
|
||||
4. After querying all incomplete relays for this author:
|
||||
Check if ALL relays for this author are now complete:
|
||||
SELECT COUNT(*) WHERE author_pubkey=$1 AND complete=FALSE
|
||||
If 0: mark author complete in caching_followed_pubkeys
|
||||
```
|
||||
|
||||
### Why per-relay cursors are better
|
||||
|
||||
- **Relay A has 500+ events, relay B has 50**: Relay A gets paginated across
|
||||
multiple ticks (cursor walks back 500 at a time). Relay B is drained in one
|
||||
query and marked complete. The author stays incomplete until relay A is
|
||||
also drained. With a single shared cursor, we'd advance the cursor based on
|
||||
the oldest across both relays, potentially skipping events on relay A.
|
||||
|
||||
- **Resume after restart**: each relay's cursor is persisted independently.
|
||||
On restart, we resume each relay exactly where it left off.
|
||||
|
||||
- **Relay goes offline**: if a relay is unreachable, its progress row stays
|
||||
incomplete. The author stays incomplete. When the relay comes back, we
|
||||
resume querying it. No events are lost.
|
||||
|
||||
### Completion criteria
|
||||
|
||||
An author is marked `backfill_complete = TRUE` in `caching_followed_pubkeys`
|
||||
when ALL their relay progress rows in `caching_backfill_relay_progress` are
|
||||
marked `complete = TRUE`. This means every known relay has been queried down
|
||||
to the beginning of the author's history.
|
||||
|
||||
### New follow detection
|
||||
|
||||
In `caching/src/live_subscriber.c`, the `live_on_event` callback currently
|
||||
just publishes events. We add a check:
|
||||
|
||||
```c
|
||||
static void live_on_event(cJSON *event, const char *relay_url, void *user_data) {
|
||||
cr_live_ctx_t *ctx = (cr_live_ctx_t *)user_data;
|
||||
ctx->live->events_received++;
|
||||
cr_sink_publish(ctx->sink, event);
|
||||
|
||||
/* Detect admin kind-3 (contact list) changes → trigger follow refresh */
|
||||
cJSON *kind = cJSON_GetObjectItem(event, "kind");
|
||||
cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey");
|
||||
if (kind && cJSON_IsNumber(kind) && kind->valuedouble == 3 &&
|
||||
pubkey && cJSON_IsString(pubkey)) {
|
||||
if (cr_follow_is_root(ctx->cfg, cJSON_GetStringValue(pubkey))) {
|
||||
ctx->live->follow_graph_changed = 1; /* signal to main loop */
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The main loop checks this flag each iteration:
|
||||
|
||||
```c
|
||||
if (live.follow_graph_changed) {
|
||||
live.follow_graph_changed = 0;
|
||||
/* Immediate follow-graph refresh */
|
||||
cr_pubkey_set_t new_followed;
|
||||
cr_pubkey_set_init(&new_followed);
|
||||
if (cr_follow_resolve(&cfg, upstream, &new_followed) == 0) {
|
||||
/* Sync to DB — new follows get relay progress rows automatically */
|
||||
pg_inbox_sync_followed_pubkeys(...);
|
||||
/* Create relay progress rows for new follows */
|
||||
pg_inbox_init_relay_progress_for_new_follows(&new_followed, &relay_map);
|
||||
/* Resubscribe live with new follow set */
|
||||
cr_live_resubscribe(&live, &cfg, upstream, &new_followed, &sink);
|
||||
cr_pubkey_set_free(&followed);
|
||||
followed = new_followed;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Relay progress row creation
|
||||
|
||||
When a new follow is discovered, we need to create relay progress rows for
|
||||
it. The relays to query are determined by the relay discovery phase (NIP-65
|
||||
outbox relays + bootstrap relays). New function:
|
||||
|
||||
```c
|
||||
int pg_inbox_init_relay_progress_for_author(const char *pk,
|
||||
const char **relay_urls,
|
||||
int relay_count);
|
||||
```
|
||||
|
||||
For each relay URL, insert a row with `until_cursor=0, complete=FALSE` (if
|
||||
not already present). This is called:
|
||||
- After follow-graph resolution for new follows
|
||||
- After relay discovery for newly discovered outbox relays
|
||||
|
||||
## Database schema changes
|
||||
|
||||
### New table: `caching_backfill_relay_progress`
|
||||
|
||||
(see SQL above)
|
||||
|
||||
### `caching_followed_pubkeys` — unchanged
|
||||
|
||||
The `until_cursor` and `backfill_complete` columns remain. `until_cursor`
|
||||
becomes less important (per-relay cursors are the source of truth), but we
|
||||
keep it for backward compatibility and as a quick "has this author started"
|
||||
flag. `backfill_complete` is updated when all relay progress rows are
|
||||
complete.
|
||||
|
||||
## New PG inbox functions
|
||||
|
||||
### `caching/src/pg_inbox.c` + `.h`
|
||||
|
||||
```c
|
||||
/* Initialize relay progress rows for an author. Creates one row per relay
|
||||
* with until_cursor=0, complete=FALSE. Skips rows that already exist. */
|
||||
int pg_inbox_init_relay_progress_for_author(const char *pk,
|
||||
const char **relay_urls,
|
||||
int relay_count);
|
||||
|
||||
/* Pick the next author that has at least one incomplete relay progress row.
|
||||
* Round-robin via round_cursor. Returns 0 on success, -1 if none. */
|
||||
int pg_inbox_pick_next_author_with_incomplete_relays(char *out_pk, int pk_len,
|
||||
int *round_cursor);
|
||||
|
||||
/* Get incomplete relays for an author. Returns a JSON array of
|
||||
* {relay_url, until_cursor} objects. Caller frees. */
|
||||
cJSON *pg_inbox_get_incomplete_relays(const char *pk);
|
||||
|
||||
/* Update relay progress: advance cursor and/or mark complete. */
|
||||
int pg_inbox_update_relay_progress(const char *pk, const char *relay_url,
|
||||
long until_cursor, int complete,
|
||||
int events_this_page);
|
||||
|
||||
/* Check if all relays for an author are complete. Returns 1 if all complete,
|
||||
* 0 if some incomplete, -1 on error. */
|
||||
int pg_inbox_check_author_relays_complete(const char *pk);
|
||||
|
||||
/* Mark author complete in caching_followed_pubkeys if all relay progress
|
||||
* rows are complete. Called after each relay update. */
|
||||
int pg_inbox_check_and_mark_author_complete(const char *pk);
|
||||
```
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. `src/pg_schema.sql` + `src/pg_schema.h`
|
||||
|
||||
Add the `caching_backfill_relay_progress` table and index.
|
||||
|
||||
### 2. `caching/src/pg_inbox.c` + `.h`
|
||||
|
||||
Add the new relay progress functions listed above.
|
||||
|
||||
### 3. `caching/src/backfill.c` — rewrite `cr_backfill_tick`
|
||||
|
||||
Replace the single `query_author` call with a relay-by-relay loop using
|
||||
per-relay cursors from the database.
|
||||
|
||||
### 4. `caching/src/live_subscriber.c` — detect admin kind-3 changes
|
||||
|
||||
Add `follow_graph_changed` flag to `cr_live_t`. Set it in `live_on_event`
|
||||
when a root npub publishes a kind-3.
|
||||
|
||||
### 5. `caching/src/live_subscriber.h` — add flag to struct
|
||||
|
||||
Add `int follow_graph_changed;` to `cr_live_t`.
|
||||
|
||||
### 6. `caching/src/main.c` — handle follow_graph_changed signal
|
||||
|
||||
Check `live.follow_graph_changed` each main loop iteration. If set, trigger
|
||||
immediate follow-graph refresh + relay progress initialization for new
|
||||
follows.
|
||||
|
||||
### 7. `caching/src/main.c` — create relay progress rows after relay discovery
|
||||
|
||||
After `cr_relay_discovery_run()`, call `pg_inbox_init_relay_progress_for_author`
|
||||
for each followed pubkey with its discovered outbox relays + bootstrap relays.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Schema: add `caching_backfill_relay_progress` table
|
||||
2. PG inbox functions: relay progress CRUD
|
||||
3. Backfill rewrite: relay-by-relay loop with per-relay cursors
|
||||
4. Live subscriber: kind-3 detection + `follow_graph_changed` flag
|
||||
5. Main loop: handle `follow_graph_changed`, create relay progress rows
|
||||
6. Build and test
|
||||
|
||||
## Testing
|
||||
|
||||
1. Rebuild: `cd caching && make`
|
||||
2. Reset backfill: restart relay with `--reset-backfill`
|
||||
3. Monitor relay progress:
|
||||
```sql
|
||||
SELECT author_pubkey, relay_url, until_cursor, complete, events_fetched
|
||||
FROM caching_backfill_relay_progress
|
||||
ORDER BY author_pubkey, relay_url;
|
||||
```
|
||||
4. Monitor author completion:
|
||||
```sql
|
||||
SELECT pubkey, backfill_complete
|
||||
FROM caching_followed_pubkeys ORDER BY pubkey;
|
||||
```
|
||||
5. Compare coverage: `nak req -k 1 -a <pk> ws://localhost:7777` vs upstream
|
||||
6. Test new-follow detection: follow a new pubkey from the admin account,
|
||||
verify it appears in `caching_followed_pubkeys` and gets backfilled within
|
||||
seconds (not 10 minutes)
|
||||
@@ -0,0 +1,338 @@
|
||||
# Caching Service: Descriptive Errors + Retry Limit
|
||||
|
||||
## Problem Summary
|
||||
|
||||
Two issues with the caching service backfill:
|
||||
|
||||
1. **Errors recorded as just "Error"** — When a relay fails, the
|
||||
`caching_backfill_relay_progress.last_status` column stores the literal
|
||||
string `"error"` with no detail. Investigation with `nak` showed the
|
||||
relays currently flagged as `error` actually fail for **three different
|
||||
reasons** that we conflate:
|
||||
- TCP/connect timeout (e.g. `wss://relay.orly.dev`,
|
||||
`wss://relay.snort.social` — "connection took too long")
|
||||
- Query timeout (relay connected, REQ sent, but no EOSE within 30s —
|
||||
e.g. `wss://nostr.wine`, `wss://nostr.land` which actually work fine
|
||||
via `nak` but our 30s timeout is too aggressive)
|
||||
- Connection refused / TLS handshake failure
|
||||
|
||||
Root cause: [`nostr_core_lib/nostr_core/core_relays.c:140-147`](../nostr_core_lib/nostr_core/core_relays.c:140)
|
||||
invokes the progress callback with `status="error"`, `message=NULL`
|
||||
for **all** failure paths (connect failure, REQ send failure). The
|
||||
underlying [`nostr_ws_connect()`](../nostr_core_lib/nostr_websocket/nostr_websocket_openssl.c:146)
|
||||
returns `NULL` with no error string surfaced. The caching callback in
|
||||
[`caching/src/backfill.c:88`](../caching/src/backfill.c:88) just stores
|
||||
`"error"` verbatim.
|
||||
|
||||
2. **No retry limit — infinite retry loop** —
|
||||
[`caching/src/backfill.c:342-354`](../caching/src/backfill.c:342) keeps
|
||||
`complete=false` when a relay errors with no events, so the next tick
|
||||
[`pg_inbox_pick_next_author_with_incomplete_relays`](../caching/src/pg_inbox.c)
|
||||
picks the same author+relay again. The schema
|
||||
`caching_backfill_relay_progress` has no `attempt_count` /
|
||||
`consecutive_errors` / `last_attempt_at` column, so a permanently-dead
|
||||
relay (e.g. `relay.orly.dev`) is retried **every tick forever**. The DB
|
||||
confirms this — error rows have `updated_at` bumped repeatedly within
|
||||
the same minute.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Surface **descriptive error messages** from the relay (or our end) all
|
||||
the way to the `last_status` column so an operator can see *why* a
|
||||
relay is failing.
|
||||
2. Stop retrying a (author, relay) pair after **3 consecutive errors**,
|
||||
marking it `complete=true` so backfill moves on. Log a `WARN`.
|
||||
3. The existing **"Reset Backfill Progress"** button (UI +
|
||||
`caching_reset_progress` admin command) must also clear the new error
|
||||
counters so a manual re-run starts fresh. (Duplicates are deduped by
|
||||
the inbox, so re-running is safe — this matches the user's mental
|
||||
model: "once we cache everyone's back events we never have to do it
|
||||
again, but if a relay was down we can re-run".)
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[backfill_tick] --> B[nostr_ws_connect]
|
||||
B -->|fail| C[core_relays callback]
|
||||
C -->|status=error, msg=descriptive| D[backfill_query_callback]
|
||||
D --> E[pg_inbox_update_relay_progress]
|
||||
E --> F{consecutive_errors >= 3?}
|
||||
F -->|yes| G[mark complete=true, WARN log]
|
||||
F -->|no| H[keep incomplete, retry next tick]
|
||||
I[Reset Backfill button] --> J[caching_reset_progress]
|
||||
J --> K[clear consecutive_errors=0, complete=false, until_cursor=0]
|
||||
```
|
||||
|
||||
## Error Message Priority Order
|
||||
|
||||
The `last_status` column should reflect the **most descriptive** failure
|
||||
reason available, in this priority order:
|
||||
|
||||
1. **Relay's own response text** (highest priority) — when a relay sends
|
||||
a `NOTICE` or `CLOSED` message with content like
|
||||
`"auth-required: subscribers only"`, `"private relay"`,
|
||||
`"rate-limited: please slow down"`, etc. This **already works today**:
|
||||
[`core_relays.c:326-342`](../nostr_core_lib/nostr_core/core_relays.c:326)
|
||||
captures the message and
|
||||
[`backfill.c:75-82`](../caching/src/backfill.c:75) formats it as
|
||||
`"NOTICE: <msg>"` / `"CLOSED: <msg>"` into `last_status`. No change
|
||||
needed for this path.
|
||||
|
||||
**DB verification (2026-07-28):** Querying the live DB shows only
|
||||
`eose` (49 rows) and `error` (6 rows) — **zero NOTICE/CLOSED rows**.
|
||||
So the relay-response path is wired but not currently firing for the
|
||||
followed set. The 6 `error` rows are all connection/timeout failures
|
||||
(confirmed by `nak`: `relay.orly.dev` and `relay.snort.social`
|
||||
genuinely time out at TCP; the others work via `nak` but hit our
|
||||
30s query timeout). This means **the descriptive-transport-error
|
||||
work in Steps 1-3 is what will actually populate `last_status` for
|
||||
the errors we see today**. The NOTICE/CLOSED path remains as a
|
||||
ready fallback for when a relay does respond with a restriction
|
||||
message.
|
||||
2. **Our connection/transport error** (this plan, Steps 1-3) — when the
|
||||
relay never responds at all (TCP timeout, TLS handshake failure,
|
||||
connection refused), surface a descriptive string like
|
||||
`"error: connect failed: Connection refused"` instead of just
|
||||
`"error"`.
|
||||
3. **Generic fallback** — `"error"` / `"timeout"` only if no more
|
||||
specific information is available.
|
||||
|
||||
So after this change, an operator querying the DB will see:
|
||||
- `NOTICE: auth-required: please authenticate` (relay spoke, we listened)
|
||||
- `CLOSED: rate-limited: too many requests` (relay spoke, we listened)
|
||||
- `error: connect failed: Connection timed out` (relay never responded)
|
||||
- `error: tls handshake failed` (relay never responded)
|
||||
- `timeout` (relay connected but didn't send EOSE in 30s)
|
||||
|
||||
The relay's own words are always preferred over our transport-level
|
||||
diagnosis.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1 — Surface descriptive errors from `nostr_ws_connect`
|
||||
|
||||
**File:** [`nostr_core_lib/nostr_websocket/nostr_websocket_openssl.c`](../nostr_core_lib/nostr_websocket/nostr_websocket_openssl.c)
|
||||
**File:** [`nostr_core_lib/nostr_websocket/nostr_websocket_tls.h`](../nostr_core_lib/nostr_websocket/nostr_websocket_tls.h)
|
||||
|
||||
Add a new variant that fills an error buffer:
|
||||
|
||||
```c
|
||||
/* Connect and on failure write a short descriptive error into err_buf
|
||||
* (at most err_buf_len-1 bytes, always null-terminated). err_buf may be
|
||||
* NULL. Returns client handle or NULL. */
|
||||
nostr_ws_client_t* nostr_ws_connect_with_error(const char* url,
|
||||
char* err_buf,
|
||||
size_t err_buf_len);
|
||||
```
|
||||
|
||||
Implementation reuses the existing `nostr_ws_connect` body but, on each
|
||||
early-return failure path, writes a descriptive string:
|
||||
- URL parse failure → `"invalid url"`
|
||||
- TCP connect failure → `"connect failed: <errno string>"`
|
||||
- TLS handshake failure → `"tls handshake failed"`
|
||||
- WebSocket handshake failure → `"ws handshake failed"`
|
||||
- Memory allocation failure → `"out of memory"`
|
||||
|
||||
Keep `nostr_ws_connect()` as a thin wrapper that calls the new function
|
||||
with `err_buf=NULL` (no behaviour change for existing callers).
|
||||
|
||||
### Step 2 — Pass descriptive error through `core_relays.c`
|
||||
|
||||
**File:** [`nostr_core_lib/nostr_core/core_relays.c`](../nostr_core_lib/nostr_core/core_relays.c)
|
||||
|
||||
In `synchronous_query_relays_with_progress` (lines 125-148), replace:
|
||||
|
||||
```c
|
||||
relays[i].client = nostr_ws_connect(relays[i].url);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```c
|
||||
char connect_err[128] = {0};
|
||||
relays[i].client = nostr_ws_connect_with_error(relays[i].url,
|
||||
connect_err, sizeof(connect_err));
|
||||
```
|
||||
|
||||
Then on the error paths, pass the descriptive string as the `message`
|
||||
(4th) argument to the callback instead of `NULL`:
|
||||
|
||||
```c
|
||||
if (callback) {
|
||||
callback(relays[i].url, "error",
|
||||
connect_err[0] ? connect_err : "connect failed",
|
||||
0, relay_count, 0, user_data);
|
||||
}
|
||||
```
|
||||
|
||||
Also distinguish the REQ-send-failure path (line 138-142) with message
|
||||
`"req send failed"`.
|
||||
|
||||
For the **timeout** path (line 184-189), the callback already sends
|
||||
`status="timeout"` with `message=NULL` — leave that as-is (the caching
|
||||
callback already records `"timeout"` distinctly, which is the right
|
||||
semantic).
|
||||
|
||||
### Step 3 — Capture descriptive error in caching backfill callback
|
||||
|
||||
**File:** [`caching/src/backfill.c`](../caching/src/backfill.c)
|
||||
|
||||
Update `backfill_query_callback` (lines 58-94) so that when
|
||||
`status == "error"`, the `event_id` parameter (which now carries the
|
||||
descriptive message from Step 2) is included in `last_status`, the same
|
||||
way NOTICE/CLOSED already are:
|
||||
|
||||
```c
|
||||
if (strcmp(status, "error") == 0) {
|
||||
ctx->got_eose = 0;
|
||||
if (event_id && event_id[0] != '\0') {
|
||||
snprintf(ctx->last_status, sizeof(ctx->last_status),
|
||||
"error: %s", event_id);
|
||||
} else {
|
||||
snprintf(ctx->last_status, sizeof(ctx->last_status), "error");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This makes `last_status` store e.g. `"error: connect failed: Connection refused"`
|
||||
or `"error: tls handshake failed"` instead of just `"error"`.
|
||||
|
||||
### Step 4 — Add error counter column to the schema
|
||||
|
||||
**File:** [`caching/src/pg_inbox.c`](../caching/src/pg_inbox.c)
|
||||
|
||||
In `pg_inbox_update_relay_progress` (around line 866, where
|
||||
`last_status` is already ensured via `ALTER TABLE ... ADD COLUMN IF NOT
|
||||
EXISTS`), add the same idempotent ALTER for the new column:
|
||||
|
||||
```c
|
||||
"ALTER TABLE caching_backfill_relay_progress "
|
||||
"ADD COLUMN IF NOT EXISTS consecutive_errors INTEGER NOT NULL DEFAULT 0"
|
||||
```
|
||||
|
||||
Extend the UPDATE statement to bump `consecutive_errors` on error and
|
||||
reset it to 0 on success (eose/timeout-with-events). The cleanest
|
||||
approach: add a new parameter `int is_error` to
|
||||
`pg_inbox_update_relay_progress` (or infer from `last_status` starting
|
||||
with `"error"` — simpler, no signature change). We'll infer from
|
||||
`last_status`:
|
||||
|
||||
```sql
|
||||
UPDATE caching_backfill_relay_progress
|
||||
SET until_cursor = $3,
|
||||
complete = $4 OR (consecutive_errors >= 3),
|
||||
events_fetched = events_fetched + $5,
|
||||
last_status = CASE WHEN $6 = '' THEN last_status ELSE $6 END,
|
||||
consecutive_errors = CASE
|
||||
WHEN $6 LIKE 'error%' THEN consecutive_errors + 1
|
||||
ELSE 0
|
||||
END,
|
||||
updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT
|
||||
WHERE author_pubkey = $1 AND relay_url = $2
|
||||
```
|
||||
|
||||
The `complete = $4 OR (consecutive_errors >= 3)` clause auto-marks
|
||||
complete once the threshold is hit, **without** requiring the caller to
|
||||
know the count. This keeps the backfill.c logic simple.
|
||||
|
||||
> Note: `consecutive_errors >= 3` in the SET refers to the **pre-update**
|
||||
> value (the value before this tick's increment). To mark complete on
|
||||
> the *same* tick that the 3rd error is recorded, we want
|
||||
> `consecutive_errors + 1 >= 3` when `$6 LIKE 'error%'`. So the clause
|
||||
> becomes:
|
||||
> ```sql
|
||||
> complete = $4 OR ($6 LIKE 'error%' AND consecutive_errors + 1 >= 3)
|
||||
> ```
|
||||
|
||||
### Step 5 — Log a WARN when a relay is auto-completed by error limit
|
||||
|
||||
**File:** [`caching/src/backfill.c`](../caching/src/backfill.c)
|
||||
|
||||
After each `pg_inbox_update_relay_progress` call in `cr_backfill_tick`,
|
||||
check the returned status. The simplest approach: after the loop over
|
||||
incomplete relays, re-query `pg_inbox_get_incomplete_relays` is not
|
||||
needed — instead, in the per-relay branch where we currently log
|
||||
`"complete"` vs `"incomplete (will retry)"` (lines 350-353 and 381-391),
|
||||
detect the auto-complete case:
|
||||
|
||||
```c
|
||||
int will_auto_complete = (strncmp(qctx.last_status, "error", 5) == 0);
|
||||
/* The SQL will mark complete if consecutive_errors+1 >= 3. We can't
|
||||
* see the count from here without an extra query, so log at WARN
|
||||
* whenever an error path leaves the row incomplete — and at WARN
|
||||
* when the row would be auto-completed. Simplest: just log WARN
|
||||
* for every error result with the descriptive status. */
|
||||
if (will_auto_complete) {
|
||||
DEBUG_WARN("backfill: %s @ %s error: %s (will auto-complete after 3 consecutive)",
|
||||
pk, relay_url, qctx.last_status);
|
||||
}
|
||||
```
|
||||
|
||||
Optionally, add a `pg_inbox_get_relay_error_count(pk, relay_url)` helper
|
||||
to log the exact count. This is a nice-to-have, not required for the
|
||||
fix.
|
||||
|
||||
### Step 6 — Reset error counters in "Reset Backfill"
|
||||
|
||||
**File:** [`caching/src/pg_inbox.c`](../caching/src/pg_inbox.c) — `pg_inbox_reset_backfill_progress`
|
||||
|
||||
The existing UPDATE on `caching_followed_pubkeys` is fine, but we also
|
||||
need to reset the per-relay table. The relay-side admin command in
|
||||
[`src/config.c:4411`](../src/config.c:4411) already does
|
||||
`DELETE FROM caching_backfill_relay_progress` — which clears the new
|
||||
column too (since the rows are gone). So **no change needed** here:
|
||||
when the user clicks "Reset Backfill Progress", the relay progress
|
||||
rows are deleted, and `cr_backfill_tick` will re-create them via
|
||||
`pg_inbox_init_relay_progress_for_author` with
|
||||
`consecutive_errors=0` (column default).
|
||||
|
||||
Verify this by re-reading
|
||||
[`caching/src/pg_inbox.c`](../caching/src/pg_inbox.c)
|
||||
`pg_inbox_init_relay_progress_for_author` — the INSERT must not
|
||||
explicitly set `consecutive_errors` so the DEFAULT 0 applies. (If it
|
||||
uses an explicit column list, add `consecutive_errors` omission
|
||||
check.)
|
||||
|
||||
### Step 7 — Build and verify
|
||||
|
||||
1. Build nostr_core_lib: `cd nostr_core_lib && make`
|
||||
2. Build caching: `cd caching && make`
|
||||
3. Build relay: `./make_and_restart_relay.sh --start-caching --reset-backfill`
|
||||
4. Watch `build/caching_relay.log` for `WARN` lines mentioning
|
||||
`auto-complete after 3 consecutive`.
|
||||
5. Query the DB:
|
||||
```sql
|
||||
SELECT author_pubkey, relay_url, complete, consecutive_errors, last_status
|
||||
FROM caching_backfill_relay_progress
|
||||
WHERE last_status LIKE 'error%'
|
||||
ORDER BY consecutive_errors DESC;
|
||||
```
|
||||
Expect `last_status` values like `error: connect failed: ...` and
|
||||
rows with `consecutive_errors >= 3` marked `complete=true`.
|
||||
6. Manually verify a known-dead relay (`wss://relay.orly.dev`) hits the
|
||||
limit and stops being retried (no more `updated_at` bumps on
|
||||
subsequent ticks).
|
||||
7. Click "Reset Backfill Progress" in the UI → confirm
|
||||
`caching_backfill_relay_progress` is emptied and backfill restarts
|
||||
from scratch.
|
||||
|
||||
## Files Touched
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `nostr_core_lib/nostr_websocket/nostr_websocket_tls.h` | Add `nostr_ws_connect_with_error()` decl |
|
||||
| `nostr_core_lib/nostr_websocket/nostr_websocket_openssl.c` | Implement `nostr_ws_connect_with_error()`, make `nostr_ws_connect()` a wrapper |
|
||||
| `nostr_core_lib/nostr_core/core_relays.c` | Use new connect variant, pass descriptive msg in error callback |
|
||||
| `caching/src/backfill.c` | Capture descriptive msg in `last_status` for error status; WARN log on error |
|
||||
| `caching/src/pg_inbox.c` | Add `consecutive_errors` column (idempotent ALTER); extend UPDATE to auto-complete at 3 |
|
||||
|
||||
## Out of Scope (deliberately)
|
||||
|
||||
- No exponential backoff — the user explicitly chose "3 errors then
|
||||
stop". Backoff can be added later if needed.
|
||||
- No change to the 30s query timeout — separate concern; could be
|
||||
raised in a follow-up if `nostr.wine`/`nostr.land` keep timing out
|
||||
despite actually being reachable.
|
||||
- No new UI button — the existing "Reset Backfill Progress" button
|
||||
already covers the "re-run the whole cache" use case.
|
||||
@@ -0,0 +1,275 @@
|
||||
# Caching Follows Status UI with Username Resolution
|
||||
|
||||
## Goal
|
||||
|
||||
Show a list of all followed/cached pubkeys with their **usernames**, **event counts by kind**, **outbox relays**, and **backfill status** in the relay admin web UI. Also add username resolution to the existing stats top-pubkeys table.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Admin Web UI] -->|system_command: caching_follows_status| B[relay config.c]
|
||||
B -->|SQL query| C[(PostgreSQL)]
|
||||
C -->|caching_followed_pubkeys| D[follows + backfill status]
|
||||
C -->|events kind=0| E[metadata: name, picture, about]
|
||||
C -->|events kind=10002| F[outbox relay URLs]
|
||||
C -->|events GROUP BY kind| G[event counts per kind]
|
||||
B -->|JSON response| A
|
||||
```
|
||||
|
||||
The relay already has DB access and an admin command pipeline. We add one new admin command that joins the caching tables with the events table to produce a rich per-follow status response.
|
||||
|
||||
## Data Sources
|
||||
|
||||
All data is already in PostgreSQL — no new tables needed:
|
||||
|
||||
| Data | Source Table | Query |
|
||||
|---|---|---|
|
||||
| Followed pubkeys + backfill status | `caching_followed_pubkeys` | `SELECT pubkey, is_root, backfill_complete, events_fetched, first_seen, last_seen` |
|
||||
| Per-relay backfill progress | `caching_backfill_relay_progress` | `SELECT relay_url, until_cursor, complete, events_fetched WHERE author_pubkey = $1` |
|
||||
| Username / profile metadata | `events` (kind=0) | `SELECT content FROM events WHERE pubkey = $1 AND kind = 0 ORDER BY created_at DESC LIMIT 1` — content is JSON with `name`, `picture`, `about`, `nip05` |
|
||||
| Outbox relays | `events` (kind=10002) | `SELECT content, tags FROM events WHERE pubkey = $1 AND kind = 10002 ORDER BY created_at DESC LIMIT 1` — tags contain `["r", "wss://..."]` entries |
|
||||
| Event counts by kind | `events` | `SELECT kind, count(*) FROM events WHERE pubkey = $1 GROUP BY kind ORDER BY kind` |
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Username/Profile Resolution Helper
|
||||
|
||||
**File:** `src/db_ops_postgres.c` (and `src/db_ops.h` for the declaration)
|
||||
|
||||
Add a reusable function that, given a pubkey, returns the most recent kind-0 metadata as a JSON object with `name`, `picture`, `about`, `nip05`, `display_name` fields parsed from the event content.
|
||||
|
||||
```c
|
||||
// Returns a cJSON object: {"name":"...", "picture":"...", "about":"...", "nip05":"...", "display_name":"..."}
|
||||
// Returns NULL if no kind-0 event exists for this pubkey.
|
||||
cJSON* db_get_profile_metadata(const char* pubkey);
|
||||
```
|
||||
|
||||
**SQL:**
|
||||
```sql
|
||||
SELECT content FROM events
|
||||
WHERE pubkey = $1 AND kind = 0
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
```
|
||||
|
||||
The `content` column is a JSON string (NIP-01 metadata). Parse it with cJSON and extract the fields. This function will be used by both the caching follows command and the stats top-pubkeys enrichment.
|
||||
|
||||
**Also add to SQLite backend** (`src/db_ops_sqlite.c`) for parity, though the primary target is PostgreSQL.
|
||||
|
||||
### Step 2: Outbox Relay Resolution Helper
|
||||
|
||||
**File:** `src/db_ops_postgres.c` (and `src/db_ops.h`)
|
||||
|
||||
Add a function that returns the outbox relay URLs from the most recent kind-10002 event for a pubkey.
|
||||
|
||||
```c
|
||||
// Returns a cJSON array of relay URL strings: ["wss://relay1", "wss://relay2", ...]
|
||||
// Returns NULL if no kind-10002 event exists.
|
||||
cJSON* db_get_outbox_relays(const char* pubkey);
|
||||
```
|
||||
|
||||
**SQL:**
|
||||
```sql
|
||||
SELECT tags FROM events
|
||||
WHERE pubkey = $1 AND kind = 10002
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
```
|
||||
|
||||
The `tags` column is JSONB. Extract all `["r", "url"]` tag entries. In PostgreSQL this can be done in SQL:
|
||||
```sql
|
||||
SELECT tag->>1 AS relay_url
|
||||
FROM events e,
|
||||
jsonb_array_elements(e.tags) AS tag
|
||||
WHERE e.pubkey = $1 AND e.kind = 10002
|
||||
AND jsonb_typeof(tag) = 'array'
|
||||
AND jsonb_array_length(tag) >= 2
|
||||
AND tag->>0 = 'r'
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT 1
|
||||
```
|
||||
Actually, simpler: fetch the tags JSONB for the latest kind-10002 and parse in C with cJSON (consistent with how the rest of the codebase works).
|
||||
|
||||
### Step 3: New Admin Command `caching_follows_status`
|
||||
|
||||
**File:** `src/config.c` (in `handle_system_command_unified()`, near the existing `caching_status` handler at line 4128)
|
||||
|
||||
Add a new command `caching_follows_status` that:
|
||||
|
||||
1. Queries `caching_followed_pubkeys` for all followed pubkeys
|
||||
2. For each pubkey, calls `db_get_profile_metadata()` to get the username
|
||||
3. For each pubkey, queries event counts by kind: `SELECT kind, count(*) FROM events WHERE pubkey = $1 GROUP BY kind ORDER BY kind`
|
||||
4. For each pubkey, calls `db_get_outbox_relays()` to get outbox relay list
|
||||
5. For each pubkey, queries `caching_backfill_relay_progress` for per-relay status
|
||||
6. Builds a JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "caching_follows_status",
|
||||
"status": "success",
|
||||
"timestamp": 1785234000,
|
||||
"follows": [
|
||||
{
|
||||
"pubkey": "4d7842051782...",
|
||||
"npub": "npub1f4uyypgh...",
|
||||
"name": "username_or_null",
|
||||
"picture": "url_or_null",
|
||||
"is_root": false,
|
||||
"backfill_complete": false,
|
||||
"events_fetched": 6965,
|
||||
"first_seen": 1785233973,
|
||||
"last_seen": 1785233973,
|
||||
"event_counts": [
|
||||
{"kind": 0, "count": 1},
|
||||
{"kind": 1, "count": 658},
|
||||
{"kind": 6, "count": 1}
|
||||
],
|
||||
"total_events_in_db": 660,
|
||||
"outbox_relays": [
|
||||
"wss://nostr-01.yakihonne.com",
|
||||
"wss://nostr.land",
|
||||
"wss://relay.snort.social"
|
||||
],
|
||||
"relay_progress": [
|
||||
{"relay_url": "wss://nos.lol", "until_cursor": 1782686796, "complete": false, "events_fetched": 1000},
|
||||
{"relay_url": "wss://relay.damus.io", "until_cursor": 1785234229, "complete": true, "events_fetched": 0}
|
||||
]
|
||||
}
|
||||
],
|
||||
"total_follows": 8,
|
||||
"total_events_in_db": 9843
|
||||
}
|
||||
```
|
||||
|
||||
**Performance consideration:** With up to 5000 followed pubkeys (`caching_max_followed_pubkeys`), running per-pubkey queries in a loop could be slow. Two approaches:
|
||||
|
||||
- **Option A (simple, fine for <100 follows):** Loop in C, call the helper functions per pubkey. With 8-50 follows this is fast enough.
|
||||
- **Option B (scalable, for large follow sets):** Use a single SQL query with CTEs and LEFT JOINs to get everything in one round-trip. This is more complex but handles 5000 follows.
|
||||
|
||||
**Recommendation:** Start with Option A. If follow counts grow large, optimize to Option B later. The per-pubkey queries are all indexed (`idx_events_pubkey_kind`, `idx_events_pubkey`).
|
||||
|
||||
### Step 4: Frontend — Caching Follows Table
|
||||
|
||||
**File:** `api/index.html`
|
||||
|
||||
Add a new section to the caching page (below the existing config form and service/inbox status), containing a table:
|
||||
|
||||
```html
|
||||
<div id="cachingFollowsSection">
|
||||
<h3>Followed Pubkeys</h3>
|
||||
<table id="caching-follows-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>npub</th>
|
||||
<th>Root?</th>
|
||||
<th>Events in DB</th>
|
||||
<th>Backfill</th>
|
||||
<th>Outbox Relays</th>
|
||||
<th>Event Counts by Kind</th>
|
||||
<th>Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="caching-follows-table-body">
|
||||
<tr><td colspan="8">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
```
|
||||
|
||||
**File:** `api/index.js`
|
||||
|
||||
1. Add a `fetchCachingFollowsStatus()` function that sends `['system_command', 'caching_follows_status']` via the admin WebSocket.
|
||||
|
||||
2. Add a `handleCachingFollowsResponse(responseData)` function that renders the table:
|
||||
- Each row shows: name (or "unknown" with a muted style), npub (as a clickable njump.me link), root badge, total events in DB, backfill status (complete/pending with relay count), outbox relays (comma-separated or expandable), event counts by kind (e.g. "k0:1, k1:658, k6:1"), last seen timestamp.
|
||||
- Sort by total_events_in_db descending (most active first).
|
||||
|
||||
3. Hook into the existing caching page auto-refresh: in the `if (pageName === 'caching')` block at line 5445, also call `fetchCachingFollowsStatus()` and add it to the `cachingRefreshInterval` polling.
|
||||
|
||||
4. Add response routing in `handleSystemCommandResponse()` near line 2082:
|
||||
```js
|
||||
if (responseData.command === 'caching_follows_status') {
|
||||
handleCachingFollowsResponse(responseData);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Frontend — Username in Stats Top-Pubkeys Table
|
||||
|
||||
**File:** `api/index.js`
|
||||
|
||||
Modify `populateStatsPubkeysFromMonitoring()` (line 4882) and `populateStatsPubkeys()` (line 4844) to add a "Name" column.
|
||||
|
||||
**Challenge:** The top-pubkeys monitoring data (`src/api.c:470` `query_top_pubkeys()`) currently returns only `{pubkey, event_count}`. To add usernames, we have two options:
|
||||
|
||||
- **Option A (backend enrichment):** Modify `query_top_pubkeys()` in `src/api.c` to also call `db_get_profile_metadata()` for each of the top 10 pubkeys and include `name` in the response. This is the cleanest approach — 10 extra queries on a 30-second polling cycle is negligible.
|
||||
|
||||
- **Option B (frontend enrichment):** The frontend separately queries kind-0 metadata for each pubkey. This is messier and adds latency.
|
||||
|
||||
**Recommendation:** Option A. Modify `query_top_pubkeys()` to include `name` and `picture` fields.
|
||||
|
||||
**Changes:**
|
||||
1. `src/api.c:470` — in `query_top_pubkeys()`, after getting each pubkey row, call `db_get_profile_metadata(pubkey)` and add `name` to the pubkey object.
|
||||
2. `api/index.js:4882` — in `populateStatsPubkeysFromMonitoring()`, add a "Name" column before the npub column. Display `pubkey.name || 'unknown'`.
|
||||
3. `api/index.js:4844` — same change in `populateStatsPubkeys()`.
|
||||
4. `api/index.html` — add a `<th>Name</th>` column to the top-pubkeys table header.
|
||||
|
||||
### Step 6: npub Conversion
|
||||
|
||||
The frontend already has `window.NostrTools.nip19.npubEncode()` available (used at `api/index.js:4902`). The backend `caching_follows_status` response should include both the hex pubkey and the npub for convenience, but the frontend can also convert. Including npub in the backend response is preferred since the backend has access to the same nostr library.
|
||||
|
||||
**File:** `src/config.c` — use `nostr_bytes_to_hex` / `nostr_decode_npub` from nostr_core_lib, or simply return the hex pubkey and let the frontend convert (it already does this in the stats page). **Recommendation: let the frontend convert** — it already has the pattern at line 4902, and it avoids adding nostr_core_lib dependency to the config.c command handler.
|
||||
|
||||
## File Change Summary
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/db_ops.h` | Declare `db_get_profile_metadata()` and `db_get_outbox_relays()` |
|
||||
| `src/db_ops_postgres.c` | Implement both functions (PostgreSQL) |
|
||||
| `src/db_ops_sqlite.c` | Implement both functions (SQLite, for parity) |
|
||||
| `src/db_ops.c` | Wire up dispatch for both functions |
|
||||
| `src/config.c` | Add `caching_follows_status` command handler in `handle_system_command_unified()` |
|
||||
| `src/api.c` | Enrich `query_top_pubkeys()` with `name` field |
|
||||
| `api/index.html` | Add follows table to caching page; add Name column to stats top-pubkeys table |
|
||||
| `api/index.js` | Add `fetchCachingFollowsStatus()`, `handleCachingFollowsResponse()`, route the response, add Name column to pubkey tables, hook into caching page auto-refresh |
|
||||
|
||||
## Mermaid: Data Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as Admin Web UI
|
||||
participant Relay as relay config.c
|
||||
participant DB as PostgreSQL
|
||||
participant Cache as caching_relay process
|
||||
|
||||
UI->>Relay: system_command: caching_follows_status
|
||||
Relay->>DB: SELECT * FROM caching_followed_pubkeys
|
||||
DB-->>Relay: 8 rows
|
||||
loop each pubkey
|
||||
Relay->>DB: SELECT content FROM events WHERE kind=0 AND pubkey=$1
|
||||
DB-->>Relay: metadata JSON
|
||||
Relay->>DB: SELECT kind,count(*) FROM events WHERE pubkey=$1 GROUP BY kind
|
||||
DB-->>Relay: event counts
|
||||
Relay->>DB: SELECT tags FROM events WHERE kind=10002 AND pubkey=$1
|
||||
DB-->>Relay: outbox relay tags
|
||||
Relay->>DB: SELECT * FROM caching_backfill_relay_progress WHERE author_pubkey=$1
|
||||
DB-->>Relay: per-relay status
|
||||
end
|
||||
Relay-->>UI: JSON response with follows array
|
||||
UI->>UI: render table with usernames, event counts, relays
|
||||
```
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **No kind-0 metadata for a pubkey:** Show "unknown" in the name column with muted styling. The pubkey is still followed and being cached; we just don't have their profile yet. The backfill should eventually fetch their kind-0.
|
||||
- **No kind-10002 for a pubkey:** Show "none" in the outbox relays column. The caching service falls back to bootstrap relays for these authors.
|
||||
- **Large follow sets (5000):** The per-pubkey loop could be slow. Add a `LIMIT` parameter to the command (default 100, configurable). The UI can paginate or show "showing first 100 of 5000".
|
||||
- **Kind-0 content is not valid JSON:** Parse defensively — if cJSON parsing fails, treat as no metadata.
|
||||
- **Replaceable events:** Kind 0 is replaceable, so `ORDER BY created_at DESC LIMIT 1` gets the latest. The unique index `uq_events_replaceable_pubkey_kind` ensures only one kind-0 per pubkey is stored, so this is already handled at the DB level.
|
||||
|
||||
## Future Enhancements (not in this plan)
|
||||
|
||||
- Add/remove follows manually via admin commands (currently follows come from root npub's kind-3)
|
||||
- Delete individual events via admin command
|
||||
- Real-time backfill tick log streaming
|
||||
- Per-follow detail view (click a row to see all events for that author)
|
||||
- Search/filter the follows table by name or npub
|
||||
@@ -0,0 +1,306 @@
|
||||
# Forward Catch-Up Plan: Bridging the Gap When Caching Resumes
|
||||
|
||||
## Problem
|
||||
|
||||
When caching is turned off (or the caching service stops), events posted by
|
||||
followed authors during the downtime are missed. The current backfill walks
|
||||
**backward** from `until_cursor` toward the beginning of time — it does not
|
||||
cover events **newer** than the cursor. The live subscriber uses
|
||||
`since = time(NULL)`, so it only catches events from the moment it connects.
|
||||
Events in the gap between "last event we know about" and "caching resumed"
|
||||
are lost.
|
||||
|
||||
## Schema Change: `last_event_at` column
|
||||
|
||||
Add a `last_event_at BIGINT NOT NULL DEFAULT 0` column to
|
||||
`caching_followed_pubkeys`. This records the `created_at` of the most
|
||||
recent event we've ever seen for this author — not a proxy like
|
||||
`updated_at` (which records when we last *touched* the row, which could
|
||||
be a zero-event progress write or a follow-graph refresh).
|
||||
|
||||
`since = last_event_at + 1, until = now` is exact: it catches every
|
||||
event the relay doesn't yet have, with no assumptions about when the
|
||||
last event occurred relative to when we last checked.
|
||||
|
||||
### Population
|
||||
|
||||
- **Backfill**: when publishing a page of events, compute
|
||||
`max(created_at)` across the page (we already compute
|
||||
`min(created_at)` for the cursor advance — add a parallel max).
|
||||
Update `last_event_at = GREATEST(last_event_at, page_max_created_at)`.
|
||||
- **Live subscriber**: when an event is received and published, update
|
||||
`last_event_at = GREATEST(last_event_at, event.created_at)`.
|
||||
- **One-time seed from `events` table**: on schema upgrade, set
|
||||
`last_event_at = COALESCE((SELECT MAX(created_at) FROM events WHERE
|
||||
pubkey = caching_followed_pubkeys.pubkey), 0)` for all existing rows.
|
||||
This seeds the column from the relay's own data.
|
||||
|
||||
### Migration
|
||||
|
||||
Added to `src/pg_schema.sql` and `src/pg_schema.h`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE caching_followed_pubkeys
|
||||
ADD COLUMN IF NOT EXISTS last_event_at BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
-- One-time seed from the events table.
|
||||
UPDATE caching_followed_pubkeys fp
|
||||
SET last_event_at = COALESCE(
|
||||
(SELECT MAX(e.created_at) FROM events e WHERE e.pubkey = fp.pubkey),
|
||||
0
|
||||
)
|
||||
WHERE fp.last_event_at = 0;
|
||||
```
|
||||
|
||||
No schema version bump needed — `ALTER TABLE ADD COLUMN IF NOT EXISTS`
|
||||
is idempotent and the seed `UPDATE` is guarded by `WHERE last_event_at = 0`.
|
||||
|
||||
## Current Architecture
|
||||
|
||||
### Tables
|
||||
|
||||
**`caching_followed_pubkeys`** (per-author state):
|
||||
| Column | Purpose |
|
||||
|---|---|
|
||||
| `pubkey` | PK |
|
||||
| `until_cursor` | Unix timestamp; backfill queries `until = this`, walks backward |
|
||||
| `backfill_complete` | TRUE when drained to the beginning of time |
|
||||
| `events_fetched` | Cumulative count |
|
||||
| `last_seen` | Updated on follow-graph refresh |
|
||||
| `updated_at` | Updated on every backfill progress write and completion mark |
|
||||
| `last_event_at` | **NEW**: `created_at` of the most recent event we've seen for this author |
|
||||
|
||||
**`caching_backfill_relay_progress`** (per-author-per-relay state):
|
||||
| Column | Purpose |
|
||||
|---|---|
|
||||
| `author_pubkey, relay_url` | Composite PK |
|
||||
| `until_cursor` | Per-relay backward-walk cursor |
|
||||
| `complete` | TRUE when this relay is drained for this author |
|
||||
| `updated_at` | Updated on every relay progress write |
|
||||
|
||||
### Backfill flow ([`caching/src/backfill.c`](../caching/src/backfill.c:256))
|
||||
|
||||
1. Pick next incomplete author (round-robin)
|
||||
2. For each incomplete relay for that author:
|
||||
- Query `authors=[pk], until=until_cursor, limit=page_size`
|
||||
- Publish events to the relay via the sink
|
||||
- Advance `until_cursor` to `oldest_event_created_at - 1`
|
||||
- If < page_size events + EOSE: mark relay `complete = TRUE`
|
||||
3. If all relays complete: mark author `backfill_complete = TRUE`
|
||||
|
||||
### Restart behavior
|
||||
|
||||
- **Normal restart** (no `--restart`): cursor and completion state preserved.
|
||||
Backfill resumes the backward walk from stored cursors. **Gap not covered.**
|
||||
- **`--restart`**: all cursors reset to 0, all completion flags cleared. Full
|
||||
re-drain from `now` backward. **Re-fetches everything** but covers the gap
|
||||
incidentally (since it starts from `now`).
|
||||
|
||||
### Live subscriber ([`caching/src/live_subscriber.c`](../caching/src/live_subscriber.c:86))
|
||||
|
||||
Uses `since = time(NULL)` — only catches events from the moment it connects.
|
||||
No gap-bridging.
|
||||
|
||||
## The Gap
|
||||
|
||||
```
|
||||
Time ──────────────────────────────────────────────────────►
|
||||
│ │ │
|
||||
last_event_at caching now
|
||||
(most recent event turned (caching
|
||||
we know about) off resumed)
|
||||
│
|
||||
└── events posted here are missed
|
||||
```
|
||||
|
||||
For **completed authors** (`backfill_complete = TRUE`): the backward walk is
|
||||
done. `last_event_at` tells us the most recent event we have. Events posted
|
||||
after `last_event_at` are in the gap.
|
||||
|
||||
For **incomplete authors**: the backward walk is still in progress. The cursor
|
||||
is somewhere in the past, walking backward. Events newer than the cursor are
|
||||
not fetched by backfill. The live subscriber covers events from `now` forward.
|
||||
The gap is between `last_event_at` and `now`.
|
||||
|
||||
## Solution: Forward Catch-Up Phase
|
||||
|
||||
Add a **forward catch-up** phase that runs once when the caching service
|
||||
starts (or when backfill is re-enabled), before the normal backward-drain
|
||||
backfill loop begins.
|
||||
|
||||
### Logic
|
||||
|
||||
For **every** followed author (both complete and incomplete):
|
||||
1. Read `last_event_at` from `caching_followed_pubkeys`.
|
||||
2. If `last_event_at = 0`, skip (no events known yet — the backward drain
|
||||
will handle it).
|
||||
3. Query one or two outbox relays:
|
||||
`authors=[pk], since=last_event_at + 1, until=now, limit=page_size`
|
||||
4. Publish all returned events to the relay via the sink.
|
||||
5. Update `last_event_at` to the max `created_at` seen (or `now` if no
|
||||
events were returned, to avoid re-querying the same empty window next
|
||||
time).
|
||||
|
||||
This is safe for both complete and incomplete authors:
|
||||
- **Complete authors**: the backward drain is done, so the forward catch-up
|
||||
is the only thing needed.
|
||||
- **Incomplete authors**: the backward drain walks *below* `until_cursor`,
|
||||
so events above `until_cursor` up to `last_event_at` were already fetched
|
||||
during the initial drain (when `until_cursor` started at `now`). The
|
||||
forward catch-up fills from `last_event_at + 1` to `now` — the gap that
|
||||
formed while caching was off.
|
||||
|
||||
### When to run
|
||||
|
||||
- **On caching service startup** (not `--restart`, which does a full reset).
|
||||
- The caching service process starts when `caching_enabled` is turned on,
|
||||
so this covers the "caching was off, now it's on" case.
|
||||
|
||||
### Implementation
|
||||
|
||||
#### 1. Schema: add `last_event_at` column
|
||||
|
||||
In `src/pg_schema.sql` and `src/pg_schema.h`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE caching_followed_pubkeys
|
||||
ADD COLUMN IF NOT EXISTS last_event_at BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
UPDATE caching_followed_pubkeys fp
|
||||
SET last_event_at = COALESCE(
|
||||
(SELECT MAX(e.created_at) FROM events e WHERE e.pubkey = fp.pubkey),
|
||||
0
|
||||
)
|
||||
WHERE fp.last_event_at = 0;
|
||||
```
|
||||
|
||||
#### 2. New function: `pg_inbox_get_authors_for_catchup()`
|
||||
|
||||
In `caching/src/pg_inbox.c`:
|
||||
|
||||
```c
|
||||
/* Returns a cJSON array of {pubkey, last_event_at} objects for all
|
||||
* followed authors where last_event_at > 0. Caller must cJSON_Delete().
|
||||
* Returns NULL on error. */
|
||||
cJSON* pg_inbox_get_authors_for_catchup(void);
|
||||
```
|
||||
|
||||
SQL:
|
||||
```sql
|
||||
SELECT pubkey, last_event_at
|
||||
FROM caching_followed_pubkeys
|
||||
WHERE last_event_at > 0
|
||||
ORDER BY last_event_at ASC
|
||||
```
|
||||
|
||||
#### 3. New function: `pg_inbox_update_last_event_at()`
|
||||
|
||||
```c
|
||||
/* Update last_event_at for a pubkey to the max of current and new value. */
|
||||
int pg_inbox_update_last_event_at(const char *pk, long event_created_at);
|
||||
```
|
||||
|
||||
SQL:
|
||||
```sql
|
||||
UPDATE caching_followed_pubkeys
|
||||
SET last_event_at = GREATEST(last_event_at, $2::BIGINT)
|
||||
WHERE pubkey = $1
|
||||
```
|
||||
|
||||
#### 4. New function: `cr_forward_catchup()`
|
||||
|
||||
In a new file `caching/src/forward_catchup.c`:
|
||||
|
||||
```c
|
||||
/* Run forward catch-up for all followed authors.
|
||||
* For each author with last_event_at > 0, query events from
|
||||
* last_event_at + 1 to now and publish them to the sink.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int cr_forward_catchup(cr_config_t *cfg,
|
||||
nostr_relay_pool_t *upstream,
|
||||
cr_sink_t *sink);
|
||||
```
|
||||
|
||||
Flow:
|
||||
1. Call `pg_inbox_get_authors_for_catchup()` to get the list.
|
||||
2. For each author:
|
||||
a. Get the author's outbox relays from `caching_backfill_relay_progress`
|
||||
(any relay, since we just need one good source).
|
||||
b. Query `authors=[pk], since=last_event_at + 1, until=now,
|
||||
limit=page_size` on one relay.
|
||||
c. Publish all returned events to the sink.
|
||||
d. If events were returned, update `last_event_at` to the max
|
||||
`created_at` in the batch. If no events, update `last_event_at`
|
||||
to `now` (so we don't re-query the same empty window).
|
||||
3. Log: "forward catch-up: N authors checked, M events published".
|
||||
|
||||
#### 5. Update backfill to maintain `last_event_at`
|
||||
|
||||
In `caching/src/backfill.c`, in the page-publishing loop (around line 375):
|
||||
- Add a `find_newest_created_at()` helper (parallel to the existing
|
||||
`find_oldest_created_at()`).
|
||||
- After publishing a page, call
|
||||
`pg_inbox_update_last_event_at(pk, newest_created_at)`.
|
||||
|
||||
#### 6. Update live subscriber to maintain `last_event_at`
|
||||
|
||||
In `caching/src/live_subscriber.c`, in the event-received callback:
|
||||
- Extract `created_at` from the event.
|
||||
- Call `pg_inbox_update_last_event_at(pubkey, created_at)`.
|
||||
|
||||
#### 7. Call from `main.c`
|
||||
|
||||
In `caching/src/main.c`, after relay discovery and followed-set sync,
|
||||
before the main loop:
|
||||
|
||||
```c
|
||||
/* Forward catch-up: bridge the gap for all followed authors. */
|
||||
if (cfg->backfill.enabled && pg_conn && !restart) {
|
||||
DEBUG_INFO("forward catch-up: checking for missed events");
|
||||
cr_forward_catchup(&cfg, upstream, &sink);
|
||||
}
|
||||
```
|
||||
|
||||
This runs once at startup. It's not throttled — it's a one-time pass.
|
||||
|
||||
### Edge cases
|
||||
|
||||
- **`--restart` flag**: full reset already starts from `now`, so forward
|
||||
catch-up is skipped. The `last_event_at` seed from the `events` table
|
||||
will set it to the most recent known event, and the backward drain from
|
||||
`now` will cover everything.
|
||||
- **`last_event_at = 0`**: author has no known events. Skip — the backward
|
||||
drain handles it.
|
||||
- **Very large gap** (caching off for months): the forward catch-up query
|
||||
may return many events. Use `limit = page_size` and paginate if needed.
|
||||
The relay's own dedup (unique index on event ID) handles duplicates.
|
||||
- **Relay doesn't support `since`**: most Nostr relays support `since`/
|
||||
`until` (NIP-01). If ignored, the relay returns all events — dedup
|
||||
handles it.
|
||||
|
||||
### Files to change
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/pg_schema.sql` | `ALTER TABLE` add `last_event_at` + seed from `events` |
|
||||
| `src/pg_schema.h` | Mirror the above |
|
||||
| `caching/src/forward_catchup.c` | New: `cr_forward_catchup()` |
|
||||
| `caching/src/forward_catchup.h` | New: declaration |
|
||||
| `caching/src/pg_inbox.c` | New: `pg_inbox_get_authors_for_catchup()`, `pg_inbox_update_last_event_at()` |
|
||||
| `caching/src/pg_inbox.h` | New: declarations |
|
||||
| `caching/src/backfill.c` | Add `find_newest_created_at()`, call `pg_inbox_update_last_event_at()` after each page |
|
||||
| `caching/src/live_subscriber.c` | Call `pg_inbox_update_last_event_at()` on event receipt |
|
||||
| `caching/src/main.c` | Call `cr_forward_catchup()` at startup |
|
||||
| `caching/Makefile` | Add `forward_catchup.c` to sources |
|
||||
|
||||
### Sequencing
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Caching service starts] --> B{Is --restart?}
|
||||
B -- Yes --> C[Reset all progress, full re-drain from now]
|
||||
B -- No --> D[Forward catch-up: all authors with last_event_at > 0]
|
||||
D --> E[Normal backward-drain backfill loop]
|
||||
E --> F[Live subscriber: since = now, ongoing]
|
||||
F --> G[Live subscriber updates last_event_at on each event]
|
||||
E --> H[Backfill updates last_event_at on each page]
|
||||
@@ -0,0 +1,305 @@
|
||||
# PHP Admin Page for Caching Service
|
||||
|
||||
## Problem
|
||||
|
||||
The existing Nostr-based admin API (`caching_follows_status` kind-23456
|
||||
command) encrypts its JSON response with NIP-44, which has a hard 64KB
|
||||
plaintext limit. With 679+ followed pubkeys, the response exceeds this
|
||||
limit by ~2-4×, causing `Encryption result code: -15`
|
||||
(`NOSTR_ERROR_NIP44_BUFFER_TOO_SMALL`) and the UI shows
|
||||
"Failed to load".
|
||||
|
||||
Rather than paginating the NIP-44 admin command (complex, still limited),
|
||||
we build a **separate traditional admin page** using PHP + nginx that
|
||||
queries PostgreSQL directly. This bypasses the NIP-44 limit entirely,
|
||||
scales to thousands of follows, and runs in parallel with the existing
|
||||
Nostr admin API (no changes to the relay binary needed).
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Browser[Admin Browser] -->|HTTPS| Nginx
|
||||
Nginx -->|/admin/ *.php| PHP_FPM[PHP-FPM]
|
||||
Nginx -->|/ and /api| Relay[C-Relay-PG :8888]
|
||||
PHP_FPM -->|PDO| PostgreSQL[(PostgreSQL crelay)]
|
||||
Relay -->|libpq| PostgreSQL
|
||||
```
|
||||
|
||||
- **nginx** fronts everything on 443 (existing SSL setup).
|
||||
- `/` and `/api` → proxy to C-Relay-PG (existing, unchanged)
|
||||
- `/admin/` → PHP-FPM (new)
|
||||
- **PHP-FPM** runs as a pool (e.g. `www` or a dedicated `relay-admin`
|
||||
pool). PHP files live in `/opt/c-relay-pg/admin/` (or similar).
|
||||
- **PDO** connects to PostgreSQL using the same `crelay` credentials
|
||||
the relay uses. Read-only queries for display; write queries only for
|
||||
config updates (with auth guard).
|
||||
- **No relay restart needed** — this is purely nginx + PHP-FPM
|
||||
configuration + static files.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **PHP 8.x** with PDO PostgreSQL extension (`php-pgsql`)
|
||||
- **PHP-FPM** managed by systemd (or the existing nginx PHP-FPM pool)
|
||||
- **nginx** `location /admin/` block with `fastcgi_pass` to PHP-FPM
|
||||
- **Frontend**: vanilla HTML + JS (no build step), same visual style as
|
||||
the existing `api/index.html` admin page. Optionally use HTMX or
|
||||
Alpine.js for lightweight interactivity, but vanilla JS is sufficient.
|
||||
|
||||
## Authentication
|
||||
|
||||
The PHP admin page needs its own auth since it's not going through the
|
||||
Nostr admin command path. Options (in order of simplicity):
|
||||
|
||||
1. **HTTP Basic Auth** (nginx-level) — simplest, sufficient for a
|
||||
single-admin relay. Add `auth_basic` to the `/admin/` location block
|
||||
with a `.htpasswd` file.
|
||||
2. **Session-based login** — PHP login form, password hash in a config
|
||||
file, session cookie. More flexible but more code.
|
||||
3. **Nostr login (NIP-07 extension)** — the page could verify a signed
|
||||
auth event from the admin's browser extension, matching against the
|
||||
`admin_pubkey` in the config table. Most aligned with the existing
|
||||
system but most complex.
|
||||
|
||||
**Recommendation:** Start with HTTP Basic Auth (option 1) for the MVP,
|
||||
upgrade to Nostr login later if desired.
|
||||
|
||||
## Database Access
|
||||
|
||||
PHP connects via PDO with the same credentials the relay uses:
|
||||
```
|
||||
host=localhost port=5432 dbname=crelay user=crelay password=crelay
|
||||
```
|
||||
|
||||
Store the connection string in a PHP config file
|
||||
(`/opt/c-relay-pg/admin/config.php`) that is NOT web-accessible (outside
|
||||
the document root or protected by nginx).
|
||||
|
||||
## Pages / Endpoints
|
||||
|
||||
### 1. Dashboard (`/admin/index.php`)
|
||||
Overview cards:
|
||||
- Service state, heartbeat age, config generation (from
|
||||
`caching_service_state`)
|
||||
- Followed author count, selected relay count, connected relay count
|
||||
- Events fetched, inbox inserts, inbox pending count
|
||||
- Backfill progress: X/Y authors complete
|
||||
- Active backfill target (pubkey + relay) from `caching_backfill_active`
|
||||
- Error relays summary (count of rows with `consecutive_errors >= 3`)
|
||||
|
||||
### 2. Follows (`/admin/follows.php`)
|
||||
The main table that was failing via NIP-44. Paginated server-side:
|
||||
- Page size: 50 or 100 follows per page
|
||||
- Columns: Name (from kind-0), npub, Root?, Events in DB, Backfill
|
||||
Complete, Relays (expandable), Last Seen
|
||||
- JOIN with `events` table for kind-0 profile metadata (name,
|
||||
display_name, picture, nip05)
|
||||
- JOIN with `caching_backfill_relay_progress` for per-relay status
|
||||
- Filter: all / incomplete only / root only / error relays only
|
||||
- Search by name or pubkey prefix
|
||||
- Sort by: events_fetched desc, name, last_seen, backfill_complete
|
||||
|
||||
SQL (paginated):
|
||||
```sql
|
||||
SELECT fp.pubkey, fp.is_root, fp.backfill_complete, fp.events_fetched,
|
||||
fp.first_seen, fp.last_seen,
|
||||
e.content::json->>'name' AS name,
|
||||
e.content::json->>'display_name' AS display_name,
|
||||
e.content::json->>'picture' AS picture,
|
||||
e.content::json->>'nip05' AS nip05,
|
||||
(SELECT COUNT(*) FROM events WHERE pubkey = fp.pubkey) AS total_events
|
||||
FROM caching_followed_pubkeys fp
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT content FROM events
|
||||
WHERE pubkey = fp.pubkey AND kind = 0
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) e ON true
|
||||
ORDER BY fp.is_root DESC, fp.events_fetched DESC
|
||||
LIMIT $page_size OFFSET $offset;
|
||||
```
|
||||
|
||||
### 3. Relay Progress (`/admin/relays.php`)
|
||||
Per (author, relay) backfill status from
|
||||
`caching_backfill_relay_progress`:
|
||||
- Columns: Author (name + npub), Relay URL, Until Cursor, Complete,
|
||||
Events Fetched, Consecutive Errors, Last Status, Updated At
|
||||
- Filter: incomplete only / error relays / complete / all
|
||||
- Highlight rows with `consecutive_errors >= 3`
|
||||
- Show `last_status` (now descriptive: `error: tls/ws handshake failed`,
|
||||
`eose`, `timeout`, etc.)
|
||||
|
||||
### 4. Config (`/admin/config.php`)
|
||||
Read and edit caching config values from the `config` table:
|
||||
- Read: `caching_root_npubs`, `caching_bootstrap_relays`,
|
||||
`caching_kinds`, `caching_admin_kinds`, all `caching_*` keys
|
||||
- Edit: form to update values, bumps `caching_config_generation` on
|
||||
save (so the caching service hot-reloads)
|
||||
- This replaces the "Apply Configuration" button for caching settings
|
||||
|
||||
### 5. Inbox (`/admin/inbox.php`)
|
||||
Monitor the `caching_event_inbox` queue:
|
||||
- Pending count by source_class (live / backfill / discovery)
|
||||
- Oldest pending age
|
||||
- Recent events preview (last 20, with kind + pubkey + source)
|
||||
- This helps diagnose if the poller is keeping up
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
/opt/c-relay-pg/admin/ # PHP document root (or symlink)
|
||||
├── config.php # DB connection config (NOT web-served)
|
||||
├── index.php # Dashboard
|
||||
├── follows.php # Followed pubkeys table (paginated)
|
||||
├── relays.php # Per-relay backfill progress
|
||||
├── config.php # Caching config editor
|
||||
├── inbox.php # Inbox queue monitor
|
||||
├── api/
|
||||
│ ├── follows.php # JSON endpoint for follows (AJAX)
|
||||
│ ├── relays.php # JSON endpoint for relay progress
|
||||
│ ├── status.php # JSON endpoint for dashboard stats
|
||||
│ └── config.php # JSON endpoint for config get/set
|
||||
├── assets/
|
||||
│ ├── style.css # Shared CSS (match existing admin theme)
|
||||
│ └── app.js # Shared JS (pagination, search, refresh)
|
||||
└── .htpasswd # HTTP Basic Auth passwords
|
||||
```
|
||||
|
||||
In the repo, these live under `admin/` (new top-level directory,
|
||||
parallel to `api/`).
|
||||
|
||||
## nginx Configuration
|
||||
|
||||
Add to the existing `server` block in
|
||||
[`examples/deployment/nginx-proxy/nginx.conf`](../examples/deployment/nginx-proxy/nginx.conf):
|
||||
|
||||
```nginx
|
||||
# PHP admin page for caching service (direct PostgreSQL access)
|
||||
location /admin/ {
|
||||
root /opt/c-relay-pg;
|
||||
index index.php;
|
||||
|
||||
# HTTP Basic Auth
|
||||
auth_basic "Relay Admin";
|
||||
auth_basic_user_file /opt/c-relay-pg/admin/.htpasswd;
|
||||
|
||||
# Pass .php files to PHP-FPM
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
|
||||
fastcgi_index index.php;
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
}
|
||||
|
||||
# Deny access to config.php (contains DB credentials)
|
||||
location = /admin/config.php {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1 — Server setup (one-time)
|
||||
- Install PHP 8.x + PHP-FPM + `php-pgsql` on the relay server
|
||||
- Create `/opt/c-relay-pg/admin/` directory
|
||||
- Configure PHP-FPM pool (or use default `www` pool)
|
||||
- Add the nginx `location /admin/` block
|
||||
- Create `.htpasswd` with `htpasswd` / `openssl passwd`
|
||||
- Reload nginx
|
||||
|
||||
### Step 2 — PHP config + DB connection helper
|
||||
- `admin/lib/db.php` — PDO connection helper, read-only by default
|
||||
- `admin/config.php` — connection string (protected from web access)
|
||||
- Test: `php -r "echo 'ok';"` + simple `SELECT 1` query
|
||||
|
||||
### Step 3 — Dashboard page (`index.php`)
|
||||
- Query `caching_service_state` for overview stats
|
||||
- Query `caching_backfill_active` for active target
|
||||
- Query `caching_backfill_relay_progress` for error summary
|
||||
- Display as cards + summary table
|
||||
- Auto-refresh every 15s via JS `setInterval` + `fetch()`
|
||||
|
||||
### Step 4 — Follows page (`follows.php`)
|
||||
- Server-side pagination (page param in URL)
|
||||
- JOIN with `events` for kind-0 profile metadata
|
||||
- Sub-query for per-pubkey event count
|
||||
- Expandable relay progress per follow (AJAX load on click)
|
||||
- Search + filter controls
|
||||
- This is the page that replaces the broken NIP-44 `caching_follows_status`
|
||||
|
||||
### Step 5 — Relay progress page (`relays.php`)
|
||||
- Full `caching_backfill_relay_progress` table with pagination
|
||||
- Filter by complete/incomplete/error
|
||||
- Show `last_status` and `consecutive_errors` columns
|
||||
- Color-code: green=eose, yellow=timeout, red=error, gray=complete
|
||||
|
||||
### Step 6 — Config editor page (`config.php` → `admin/config-edit.php`)
|
||||
- Read all `caching_*` config keys
|
||||
- Form to edit `caching_root_npubs`, `caching_bootstrap_relays`,
|
||||
`caching_kinds`, etc.
|
||||
- On save: `UPDATE config SET value = $1 WHERE key = $2` + bump
|
||||
`caching_config_generation`
|
||||
- Confirmation dialog before applying
|
||||
|
||||
### Step 7 — Inbox monitor page (`inbox.php`)
|
||||
- Pending counts by source_class
|
||||
- Oldest pending age
|
||||
- Recent 20 events preview
|
||||
- Poller stats (from relay log or a new stats table)
|
||||
|
||||
### Step 8 — Styling + polish
|
||||
- Match the existing `api/index.css` dark theme
|
||||
- Responsive layout (works on mobile)
|
||||
- Auto-refresh for dashboard and follows pages
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **HTTP Basic Auth** on `/admin/` — minimum barrier
|
||||
2. **`config.php` denied** via nginx `location` block (contains DB password)
|
||||
3. **PDO prepared statements** everywhere — no SQL injection
|
||||
4. **Read-only DB user** for display pages (optional: create a
|
||||
`crelay_readonly` role with SELECT-only on caching tables; use the
|
||||
full `crelay` user only for the config editor)
|
||||
5. **HTTPS only** — the existing nginx SSL config covers this
|
||||
6. **No CORS needed** — same origin as the relay
|
||||
|
||||
## Out of Scope (for MVP)
|
||||
|
||||
- Nostr NIP-07 extension login (use HTTP Basic Auth first)
|
||||
- Write operations beyond config editing (reset backfill, etc. — those
|
||||
stay on the Nostr admin API)
|
||||
- Real-time WebSocket updates (use polling instead — 15s interval is
|
||||
sufficient for a monitoring dashboard)
|
||||
- Multi-user / role-based access
|
||||
|
||||
## Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `admin/config.php` | DB connection config (protected) |
|
||||
| `admin/lib/db.php` | PDO connection helper |
|
||||
| `admin/lib/helpers.php` | Shared helpers (pagination, formatting) |
|
||||
| `admin/index.php` | Dashboard |
|
||||
| `admin/follows.php` | Followed pubkeys table (paginated) |
|
||||
| `admin/relays.php` | Per-relay backfill progress |
|
||||
| `admin/config-edit.php` | Caching config editor |
|
||||
| `admin/inbox.php` | Inbox queue monitor |
|
||||
| `admin/api/status.php` | JSON: dashboard stats |
|
||||
| `admin/api/follows.php` | JSON: paginated follows |
|
||||
| `admin/api/relays.php` | JSON: relay progress |
|
||||
| `admin/assets/style.css` | Shared CSS |
|
||||
| `admin/assets/app.js` | Shared JS |
|
||||
| `admin/.htpasswd` | HTTP Basic Auth passwords |
|
||||
| `examples/deployment/nginx-proxy/nginx.conf` | Add `/admin/` location block |
|
||||
|
||||
## Deployment
|
||||
|
||||
1. Copy `admin/` to `/opt/c-relay-pg/admin/` on the server
|
||||
2. Install `php8.2-fpm php8.2-pgsql` (or distro-equivalent)
|
||||
3. Create `.htpasswd`: `htpasswd -c /opt/c-relay-pg/admin/.htpasswd admin`
|
||||
4. Edit `admin/config.php` with the PostgreSQL connection string
|
||||
5. Add the nginx `/admin/` location block, reload nginx
|
||||
6. Visit `https://relay.yourdomain.com/admin/`
|
||||
|
||||
No relay restart required. The PHP admin page runs entirely in parallel
|
||||
with the existing relay and Nostr admin API.
|
||||
@@ -0,0 +1,708 @@
|
||||
# Profile (kind-0) Cache Plan
|
||||
|
||||
Make username/profile resolution a first-class c-relay-pg feature backed by a
|
||||
dedicated `profiles` table, replacing the three independent ad-hoc
|
||||
implementations that exist today. Phase 1 (name/metadata cache) is scoped for
|
||||
implementation now; Phase 2 (image caching) is designed here but deferred.
|
||||
|
||||
## 0. Scope Boundary
|
||||
|
||||
**In scope:** `src/` (schema + C data layer) and `admin/` (the PHP admin).
|
||||
|
||||
**Out of scope:** the top-level [`api/`](../api/index.js) directory — the legacy
|
||||
embedded JS admin compiled into the binary via
|
||||
[`src/embedded_web_content.h`](../src/embedded_web_content.h) and served by
|
||||
[`handle_embedded_file_request()`](../src/api.c:1006). It is being replaced by
|
||||
`admin/` and is deliberately left untouched.
|
||||
|
||||
Two consequences worth being explicit about:
|
||||
|
||||
1. **Do not "fix" the duplicated logic in `api/index.js`.** It carries its own
|
||||
copies of the profile-name preference ([`api/index.js:962`](../api/index.js:962))
|
||||
and profile-picture handling ([`api/index.js:986`](../api/index.js:986)), plus
|
||||
the same class of unescaped-`innerHTML` issue described in §2A.2. These are
|
||||
knowingly left as-is because the whole tree is slated for removal. Note this
|
||||
means the XSS exposure persists for as long as the embedded UI remains
|
||||
reachable on the relay's HTTP port — a reason to prioritize retiring `api/`,
|
||||
tracked separately from this plan.
|
||||
2. **The C-side changes still matter to both.** [`src/api.c`](../src/api.c) serves
|
||||
the JSON that the embedded UI consumes, so the batching work in §2.5 benefits
|
||||
`api/` incidentally. The C API must therefore stay backward-compatible: keep
|
||||
emitting the existing `name` field (now the resolved value) alongside the new
|
||||
`display_name` / `best_name` fields, so the legacy frontend keeps working
|
||||
unchanged until it is deleted.
|
||||
|
||||
---
|
||||
|
||||
## 1. Current State
|
||||
|
||||
Profile display-name resolution was introduced alongside the caching service and
|
||||
never generalized. There are **three separate implementations**, none cached:
|
||||
|
||||
### 1.1 C backend — per-pubkey query inside a loop
|
||||
|
||||
[`postgres_db_get_profile_metadata()`](../src/db_ops_postgres.c:2523) runs:
|
||||
|
||||
```sql
|
||||
SELECT content FROM events WHERE pubkey = $1 AND kind = 0
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
```
|
||||
|
||||
then `cJSON_Parse`es `content` and copies out eight known fields
|
||||
(`name`, `display_name`, `picture`, `about`, `nip05`, `website`, `lud16`, `lud06`).
|
||||
|
||||
Dispatched through [`db_get_profile_metadata()`](../src/db_ops.c:189); the SQLite
|
||||
backend is a `NULL` stub ([`src/db_ops.c:389`](../src/db_ops.c:389)).
|
||||
|
||||
Called from three places, **always inside a row loop** — a classic N+1:
|
||||
|
||||
| Call site | Loop over | Queries per response |
|
||||
|---|---|---|
|
||||
| [`query_top_pubkeys()`](../src/api.c:503) | top 10 pubkeys | 10 |
|
||||
| [`src/api.c:1646`](../src/api.c:1646) | top pubkeys (2nd copy) | 10 |
|
||||
| caching follows status [`src/config.c:4298`](../src/config.c:4298) | every followed pubkey | 1 per follow (unbounded) |
|
||||
|
||||
The `config.c` loop additionally issues a per-pubkey kind-count query
|
||||
([`src/config.c:4326`](../src/config.c:4326)), an outbox lookup
|
||||
([`src/config.c:4346`](../src/config.c:4346)) and a relay-progress query
|
||||
([`src/config.c:4356`](../src/config.c:4356)) — so a relay following 500 authors
|
||||
performs ~2000 queries to render one admin panel.
|
||||
|
||||
The `display_name || name` preference logic is **duplicated verbatim** at all
|
||||
three call sites.
|
||||
|
||||
### 1.2 PHP admin — repeated LATERAL joins
|
||||
|
||||
[`admin/api/stats.php:90`](../admin/api/stats.php:90) and
|
||||
[`admin/api/caching.php:23`](../admin/api/caching.php:23) each hand-roll:
|
||||
|
||||
```sql
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT content FROM events WHERE pubkey = e.pubkey AND kind = 0
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) p ON true
|
||||
... p.content::json->>'name', p.content::json->>'display_name'
|
||||
```
|
||||
|
||||
with the same `$display_name ?: $name` fallback repeated in PHP. `content::json`
|
||||
re-parses the JSON text on every single admin page poll. The cast is also
|
||||
fragile: a malformed kind-0 `content` raises a PostgreSQL error that aborts the
|
||||
whole query (the `try/catch` then silently returns an empty result set).
|
||||
|
||||
### 1.3 Browser JS — fetches from public relays
|
||||
|
||||
[`loadUserProfile()`](../admin/assets/app.js:626) opens WebSocket connections to
|
||||
**third-party public relays** to fetch the logged-in admin's own kind-0, even
|
||||
though the relay's own database very likely has it. A third variant of the
|
||||
name-preference logic lives at [`admin/assets/app.js:650`](../admin/assets/app.js:650)
|
||||
(`profile.name || profile.display_name || profile.displayName`) — note this one
|
||||
prefers `name` over `display_name`, the **opposite** of the C and PHP versions,
|
||||
so the same user can render under two different names in one UI.
|
||||
|
||||
Profile images are hotlinked straight to whatever URL the kind-0 contains
|
||||
([`admin/assets/app.js:653`](../admin/assets/app.js:653)), which leaks the admin's
|
||||
IP to arbitrary hosts and breaks silently on dead links.
|
||||
|
||||
### 1.4 Conclusion
|
||||
|
||||
A cache table is clearly warranted:
|
||||
|
||||
- Kind 0 is **replaceable** — the unique index
|
||||
[`uq_events_replaceable_pubkey_kind`](../src/pg_schema.sql:82) guarantees at
|
||||
most one kind-0 row per pubkey. A `profiles` table is therefore a strict 1:1
|
||||
projection of existing data and can be rebuilt from scratch at any time. No
|
||||
risk of divergence-by-design.
|
||||
- Profiles change rarely but are read constantly.
|
||||
- Parsing JSON at write time (once per profile update) instead of read time
|
||||
(every page poll × every row) is a large, cheap win.
|
||||
- One canonical name-preference rule fixes the inconsistency across the three
|
||||
layers.
|
||||
|
||||
---
|
||||
|
||||
## 2. Phase 1 — `profiles` Table
|
||||
|
||||
### 2.1 Schema
|
||||
|
||||
Added to [`src/pg_schema.sql`](../src/pg_schema.sql) before the `COMMIT;` at
|
||||
[line 405](../src/pg_schema.sql:405), and mirrored into
|
||||
[`src/pg_schema.h`](../src/pg_schema.h) as escaped C string literals.
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
pubkey TEXT PRIMARY KEY,
|
||||
event_id TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
about TEXT NOT NULL DEFAULT '',
|
||||
picture TEXT NOT NULL DEFAULT '',
|
||||
banner TEXT NOT NULL DEFAULT '',
|
||||
nip05 TEXT NOT NULL DEFAULT '',
|
||||
website TEXT NOT NULL DEFAULT '',
|
||||
lud16 TEXT NOT NULL DEFAULT '',
|
||||
lud06 TEXT NOT NULL DEFAULT '',
|
||||
raw_content TEXT NOT NULL DEFAULT '',
|
||||
parse_ok BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_name ON profiles(name)
|
||||
WHERE name <> '';
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_display_name ON profiles(display_name)
|
||||
WHERE display_name <> '';
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_nip05 ON profiles(nip05)
|
||||
WHERE nip05 <> '';
|
||||
```
|
||||
|
||||
Notes:
|
||||
- **`name` and `display_name` are both stored verbatim, always.** Storing both
|
||||
is free (they are short strings on a table with one row per pubkey), and it
|
||||
means the question "which field do Nostr clients actually populate?" can be
|
||||
answered from real data later rather than guessed at now — see
|
||||
[§2.9](#29-which-field-do-people-actually-use). Neither field is ever
|
||||
discarded, overwritten by the other, or collapsed into a single value at write
|
||||
time.
|
||||
- There is deliberately **no generated `best_name` column.** An earlier draft
|
||||
had one; it was wrong. A `STORED` generated column freezes the preference rule
|
||||
into the schema, so changing which field is displayed would require a schema
|
||||
migration and a full-table rewrite. Display preference is a presentation
|
||||
decision and belongs at read time.
|
||||
- `raw_content` keeps the original JSON so non-standard fields (including
|
||||
`displayName`, the camelCase variant some clients emit — see
|
||||
[`admin/assets/app.js:650`](../admin/assets/app.js:650)) remain reachable
|
||||
without re-querying `events`.
|
||||
- `parse_ok = FALSE` records "we saw a kind-0 but its content was not valid
|
||||
JSON" — distinct from "no profile at all" (row absent). This makes the
|
||||
malformed-JSON case explicit instead of an aborted query.
|
||||
- Empty-string defaults rather than `NULL` keep the C accessors branch-free.
|
||||
|
||||
### 2.1.1 Display preference as configuration
|
||||
|
||||
The preference rule lives in the existing `config` table
|
||||
([`src/pg_schema.sql:192`](../src/pg_schema.sql:192)) so it can be changed at
|
||||
runtime through the normal admin config path, with no migration:
|
||||
|
||||
```sql
|
||||
INSERT INTO config (key, value, data_type, description, category, requires_restart)
|
||||
VALUES ('profile_name_preference', 'display_name',
|
||||
'string', 'Which kind-0 field to prefer for display: display_name or name',
|
||||
'display', 0)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
```
|
||||
|
||||
Valid values: `display_name` (prefer `display_name`, fall back to `name`) or
|
||||
`name` (the reverse). Default `display_name`, matching the current C and PHP
|
||||
behaviour so nothing visibly changes on upgrade.
|
||||
|
||||
Each layer gets **one** resolver that reads this key — replacing the four
|
||||
scattered inline copies with one function per layer, while keeping the choice
|
||||
adjustable:
|
||||
|
||||
```c
|
||||
// Applies profile_name_preference; falls back to the other field when the
|
||||
// preferred one is empty. Returns "" when neither is set (never NULL).
|
||||
const char* profile_display_name(const cJSON* profile);
|
||||
```
|
||||
|
||||
Every profile object returned to a UI carries `name`, `display_name`, **and** the
|
||||
resolved `best_name`, so a consumer can render the resolved label while still
|
||||
having both raw values available.
|
||||
|
||||
### 2.2 Population — PostgreSQL trigger
|
||||
|
||||
A trigger keeps the table correct regardless of which process writes the event
|
||||
(relay ingest, the caching inbox poller, or a manual `psql` insert), so no
|
||||
writer can bypass it.
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION sync_profile_from_event() RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
j JSONB;
|
||||
BEGIN
|
||||
IF NEW.kind <> 0 THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
BEGIN
|
||||
j := NEW.content::jsonb;
|
||||
IF jsonb_typeof(j) <> 'object' THEN
|
||||
j := NULL;
|
||||
END IF;
|
||||
EXCEPTION WHEN others THEN
|
||||
j := NULL;
|
||||
END;
|
||||
|
||||
INSERT INTO profiles (
|
||||
pubkey, event_id, created_at,
|
||||
name, display_name, about, picture, banner,
|
||||
nip05, website, lud16, lud06,
|
||||
raw_content, parse_ok, updated_at
|
||||
) VALUES (
|
||||
NEW.pubkey, NEW.id, NEW.created_at,
|
||||
COALESCE(j->>'name',''),
|
||||
COALESCE(j->>'display_name',''),
|
||||
COALESCE(j->>'about',''),
|
||||
COALESCE(j->>'picture',''),
|
||||
COALESCE(j->>'banner',''),
|
||||
COALESCE(j->>'nip05',''),
|
||||
COALESCE(j->>'website',''),
|
||||
COALESCE(j->>'lud16',''),
|
||||
COALESCE(j->>'lud06',''),
|
||||
NEW.content, (j IS NOT NULL),
|
||||
EXTRACT(EPOCH FROM NOW())::BIGINT
|
||||
)
|
||||
ON CONFLICT (pubkey) DO UPDATE SET
|
||||
event_id = EXCLUDED.event_id,
|
||||
created_at = EXCLUDED.created_at,
|
||||
name = EXCLUDED.name,
|
||||
display_name = EXCLUDED.display_name,
|
||||
about = EXCLUDED.about,
|
||||
picture = EXCLUDED.picture,
|
||||
banner = EXCLUDED.banner,
|
||||
nip05 = EXCLUDED.nip05,
|
||||
website = EXCLUDED.website,
|
||||
lud16 = EXCLUDED.lud16,
|
||||
lud06 = EXCLUDED.lud06,
|
||||
raw_content = EXCLUDED.raw_content,
|
||||
parse_ok = EXCLUDED.parse_ok,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
-- Never let an older kind-0 overwrite a newer one.
|
||||
WHERE EXCLUDED.created_at >= profiles.created_at;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_events_sync_profile ON events;
|
||||
CREATE TRIGGER trg_events_sync_profile
|
||||
AFTER INSERT OR UPDATE OF content ON events
|
||||
FOR EACH ROW EXECUTE FUNCTION sync_profile_from_event();
|
||||
```
|
||||
|
||||
The `NEW.kind <> 0` early return means the cost for the 99.9% of events that are
|
||||
not profiles is one integer comparison — negligible next to the two triggers
|
||||
already firing on every insert
|
||||
([`trg_events_set_derived_fields`](../src/pg_schema.sql:160),
|
||||
[`trg_events_sync_event_tags`](../src/pg_schema.sql:166),
|
||||
[`trg_notify_event_stored`](../src/pg_schema.sql:283)).
|
||||
|
||||
**Deletion:** add a companion `AFTER DELETE` trigger removing the `profiles` row
|
||||
when its backing kind-0 is deleted (NIP-09 via [`src/nip009.c`](../src/nip009.c)),
|
||||
guarded on `OLD.kind = 0 AND profiles.event_id = OLD.id` so a delete of a
|
||||
superseded event does not drop a current profile.
|
||||
|
||||
### 2.3 One-time backfill
|
||||
|
||||
Existing databases already hold kind-0 events. Following the established
|
||||
guarded-migration pattern used for `d_tag_value`
|
||||
([`src/pg_schema.sql:57`](../src/pg_schema.sql:57)), the backfill runs only on
|
||||
the version transition, not on every boot:
|
||||
|
||||
```sql
|
||||
DO $$
|
||||
BEGIN
|
||||
IF COALESCE((SELECT value FROM schema_info WHERE key = 'version'), '0') < '6' THEN
|
||||
INSERT INTO profiles (pubkey, event_id, created_at, name, display_name,
|
||||
about, picture, banner, nip05, website, lud16, lud06,
|
||||
raw_content, parse_ok)
|
||||
SELECT e.pubkey, e.id, e.created_at,
|
||||
COALESCE(c.j->>'name',''), COALESCE(c.j->>'display_name',''),
|
||||
COALESCE(c.j->>'about',''), COALESCE(c.j->>'picture',''),
|
||||
COALESCE(c.j->>'banner',''), COALESCE(c.j->>'nip05',''),
|
||||
COALESCE(c.j->>'website',''), COALESCE(c.j->>'lud16',''),
|
||||
COALESCE(c.j->>'lud06',''),
|
||||
e.content, (c.j IS NOT NULL)
|
||||
FROM events e
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT CASE WHEN e.content ~ '^\s*\{' THEN
|
||||
(SELECT x FROM jsonb(e.content::jsonb) AS x)
|
||||
END AS j
|
||||
) c ON true
|
||||
WHERE e.kind = 0
|
||||
ON CONFLICT (pubkey) DO NOTHING;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
```
|
||||
|
||||
The `::jsonb` cast can still raise on malformed content. Implementation should
|
||||
use a small `PL/pgSQL` loop with a per-row `EXCEPTION` block, or a
|
||||
`safe_jsonb(text)` helper function marked `IMMUTABLE` that returns `NULL` on
|
||||
parse failure — cleaner and reusable by the trigger too. Prefer the
|
||||
`safe_jsonb()` helper and use it in both the trigger and the backfill.
|
||||
|
||||
Bump `EMBEDDED_PG_SCHEMA_VERSION` from `"5"` to `"6"` in
|
||||
[`src/pg_schema.h:4`](../src/pg_schema.h:4) and the `schema_info` insert at
|
||||
[`src/pg_schema.sql:260`](../src/pg_schema.sql:260).
|
||||
|
||||
`postgres_db_apply_schema()` ([`src/db_ops_postgres.c:138`](../src/db_ops_postgres.c:138))
|
||||
runs the whole embedded script at startup, so existing deployments upgrade
|
||||
automatically. There is no generator script for `pg_schema.h` — it is a
|
||||
hand-maintained mirror, so **both files must be edited and kept identical**.
|
||||
|
||||
### 2.4 C API
|
||||
|
||||
Replace the single-row helper with a batch-capable pair in
|
||||
[`src/db_ops.h`](../src/db_ops.h:144):
|
||||
|
||||
```c
|
||||
// Single profile from the profiles cache. NULL if no profile is cached.
|
||||
// Result object always contains "name", "display_name" (each possibly "")
|
||||
// and the resolved "best_name". Caller must cJSON_Delete().
|
||||
cJSON* db_get_profile(const char* pubkey);
|
||||
|
||||
// Batch lookup: one query for many pubkeys. Returns an object keyed by
|
||||
// pubkey hex -> profile object. Pubkeys with no cached profile are absent.
|
||||
// Caller must cJSON_Delete().
|
||||
cJSON* db_get_profiles(const char** pubkeys, int count);
|
||||
```
|
||||
|
||||
`db_get_profiles()` issues a single `WHERE pubkey = ANY($1::text[])` query,
|
||||
collapsing the N+1 loops into one round trip.
|
||||
|
||||
`db_get_profile_metadata()` is retained as a deprecated thin wrapper over
|
||||
`db_get_profile()` so nothing breaks mid-refactor, then removed once all call
|
||||
sites are migrated.
|
||||
|
||||
SQLite stubs in [`src/db_ops.c:389`](../src/db_ops.c:389) continue returning
|
||||
`NULL` — the `profiles` table is PostgreSQL-only, consistent with how the
|
||||
caching tables are handled.
|
||||
|
||||
### 2.5 C call-site migration
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| [`src/api.c:502`](../src/api.c:502) | Collect the 10 pubkeys, one `db_get_profiles()` call, then attach `name`/`display_name`/`best_name`/`picture` from the map. Replace the inline preference logic with `profile_display_name()`. |
|
||||
| [`src/api.c:1645`](../src/api.c:1645) | Same. Consider factoring the two near-identical blocks into one shared `api_attach_profile_fields()` helper. |
|
||||
| [`src/config.c:4297`](../src/config.c:4297) | Batch all followed pubkeys up front (they are already fully enumerated by the outer query) and look them up from the returned map inside the loop. |
|
||||
|
||||
The `config.c` loop's other per-row queries (kind counts, relay progress) are
|
||||
out of scope here but are noted as the next optimization target — they can
|
||||
become two `GROUP BY` queries executed once.
|
||||
|
||||
### 2.6 PHP migration
|
||||
|
||||
Add one helper to [`admin/lib/helpers.php`](../admin/lib/helpers.php):
|
||||
|
||||
```php
|
||||
/**
|
||||
* Batch-resolve profiles from the cache.
|
||||
* Returns [pubkey_hex => ['name'=>..., 'display_name'=>...,
|
||||
* 'best_name'=>..., 'picture'=>..., 'nip05'=>...]].
|
||||
*/
|
||||
function profile_map(array $pubkeys): array
|
||||
|
||||
/** Applies the profile_name_preference config key. Never returns null. */
|
||||
function profile_display_name(array $profile): string
|
||||
```
|
||||
|
||||
`profile_map()` is a single parameterized `WHERE pubkey = ANY(...)` query against
|
||||
`profiles`, returning both raw name fields plus the resolved label. Then:
|
||||
|
||||
- [`admin/api/stats.php:90`](../admin/api/stats.php:90) — drop the
|
||||
`LEFT JOIN LATERAL` and the `content::json` casts; the top-pubkeys query
|
||||
becomes a plain `GROUP BY e.pubkey`, and names come from `profile_map()`.
|
||||
This also removes the `GROUP BY e.pubkey, p.content` grouping-by-a-JSON-blob
|
||||
wart.
|
||||
- [`admin/api/caching.php:23`](../admin/api/caching.php:23) — same; or simply
|
||||
`LEFT JOIN profiles p ON p.pubkey = fp.pubkey` and select `p.name,
|
||||
p.display_name`, which is a cheap indexed join now that no subquery or parsing
|
||||
is involved.
|
||||
- Replace both copies of the `$display_name ?: $name` fallback with
|
||||
`profile_display_name()`.
|
||||
|
||||
### 2.7 JS migration
|
||||
|
||||
- Add a read-only admin endpoint (`admin/api/profile.php?pubkey=...`) returning
|
||||
the cached profile.
|
||||
- [`loadUserProfile()`](../admin/assets/app.js:626) tries that endpoint first and
|
||||
only falls back to public relays if the relay has no cached kind-0 for the
|
||||
logged-in admin (a real possibility for a fresh relay), then keeps the existing
|
||||
render path.
|
||||
- Consume the server-provided `best_name` instead of re-deriving a preference in
|
||||
the browser, so all three layers finally agree and the JS copy at
|
||||
[`admin/assets/app.js:650`](../admin/assets/app.js:650) — which currently
|
||||
prefers `name`, the opposite of C and PHP — stops disagreeing. Keep
|
||||
`displayName` (camelCase) handling only in the public-relay fallback path,
|
||||
where raw client JSON is parsed directly.
|
||||
|
||||
---
|
||||
|
||||
## 2A. Hostile Characters in Names
|
||||
|
||||
Nostr names are attacker-controlled free-form UTF-8. The guiding principle:
|
||||
|
||||
> **Store bytes verbatim. Neutralize at the point of rendering.**
|
||||
|
||||
Sanitizing at write time would be wrong — it is lossy, irreversible, and the
|
||||
"correct" transformation differs per output context (HTML body vs. attribute vs.
|
||||
JSON vs. CSV vs. terminal log). A name mangled on the way into the cache can
|
||||
never be recovered, and the cache would no longer faithfully mirror the kind-0
|
||||
event. So the cache table stores exactly what the user published.
|
||||
|
||||
**But "it's a frontend issue" is only ~90% true.** There is one true storage-layer
|
||||
concern, and one place where the current frontend is actively unsafe.
|
||||
|
||||
### 2A.1 Storage-layer concern: NUL bytes (must handle at write time)
|
||||
|
||||
PostgreSQL `TEXT` **cannot** store `U+0000`. A kind-0 containing `\u0000` in its
|
||||
JSON string makes `->>` yield a value that PostgreSQL refuses to store, raising
|
||||
`ERROR: unsupported Unicode escape sequence` — which would abort the trigger and
|
||||
therefore **reject the whole event insert**. That turns a cosmetic nuisance into
|
||||
a denial-of-service on event ingestion.
|
||||
|
||||
This is not a presentation problem and must be handled in the trigger:
|
||||
|
||||
```sql
|
||||
-- Strip NUL only; everything else is preserved byte-for-byte.
|
||||
replace(COALESCE(j->>'name',''), E'\\u0000', '')
|
||||
```
|
||||
|
||||
Implement as a small `sanitize_pg_text(text)` helper used for every extracted
|
||||
string column. It removes **only** characters PostgreSQL structurally cannot
|
||||
store — not "weird" characters generally. Invalid UTF-8 byte sequences are
|
||||
already rejected earlier by `cJSON` parsing and by the `safe_jsonb()` helper
|
||||
(the row lands with `parse_ok = FALSE`), so no additional handling is needed.
|
||||
|
||||
A defensive `byte_size` guard is also worth adding: cap stored name fields at a
|
||||
sane length (e.g. 1 KB) so a megabyte-long "name" cannot bloat the table or the
|
||||
admin JSON payloads. Truncation is recorded in `raw_content`, which keeps the
|
||||
full original.
|
||||
|
||||
### 2A.2 Live vulnerability: stored XSS in the admin UI
|
||||
|
||||
This must be fixed as part of this work, because the whole point of the change is
|
||||
to route more user-controlled names into more admin pages.
|
||||
|
||||
[`admin/assets/app.js:142`](../admin/assets/app.js:142) interpolates the name
|
||||
directly into `innerHTML`:
|
||||
|
||||
```js
|
||||
tbody.innerHTML = d.top_pubkeys.map((p, i) =>
|
||||
`<tr><td>${i+1}</td><td>${p.name || '<i>unknown</i>'}</td>...`
|
||||
```
|
||||
|
||||
and [`admin/assets/app.js:411`](../admin/assets/app.js:411) does the same for the
|
||||
caching-follows table. A user who sets their kind-0 `name` to
|
||||
`<img src=x onerror="...">` achieves **script execution in the relay
|
||||
administrator's authenticated browser session** merely by posting enough events
|
||||
to appear in the top-pubkeys list. No privileged access is required.
|
||||
|
||||
The codebase is already inconsistent about this: the header name at
|
||||
[`app.js:652`](../admin/assets/app.js:652) correctly uses `textContent` and is
|
||||
safe. The table renderers are not.
|
||||
|
||||
**Fix:** add an escaping helper and apply it to every interpolated
|
||||
user-controlled value in `innerHTML` template strings:
|
||||
|
||||
```js
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g,
|
||||
c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
```
|
||||
|
||||
Auditing the surrounding rows shows the same pattern applied to other
|
||||
user-controlled fields — event `content` ([`app.js:380`](../admin/assets/app.js:380)),
|
||||
DM content ([`app.js:441`](../admin/assets/app.js:441)), config values
|
||||
([`app.js:219`](../admin/assets/app.js:219)), and auth-rule `pattern_value`
|
||||
([`app.js:251`](../admin/assets/app.js:251)) — so the sweep should cover all of
|
||||
them, not just names. Preferring `textContent` / `createElement` over `innerHTML`
|
||||
in these renderers is the more durable fix where it is not too invasive.
|
||||
|
||||
Note the PHP side is already correct: [`e()`](../admin/lib/helpers.php:9) wraps
|
||||
`htmlspecialchars(..., ENT_QUOTES, 'UTF-8')` and is used for server-rendered
|
||||
output. The gap is purely in the JS-built tables.
|
||||
|
||||
### 2A.3 Presentation-layer nuisances (frontend, cosmetic)
|
||||
|
||||
These stay unsanitized in the database and are handled with CSS/formatting:
|
||||
|
||||
| Issue | Effect | Mitigation |
|
||||
|---|---|---|
|
||||
| Bidi overrides (`U+202E` RTL) | Reverses surrounding text, spoofs other names | Render names in a `<bdi>` element — purpose-built for exactly this, isolates bidi without altering the value |
|
||||
| Zalgo / stacked combining marks | Vertical overflow past row bounds | `overflow: hidden` + fixed line-height on the name cell |
|
||||
| Zero-width chars (`U+200B`, `U+FEFF`) | Invisible; two names look identical | Optional: reveal-on-hover indicator; do not strip |
|
||||
| Newlines / tabs | Break single-line table layout | CSS `white-space: nowrap` + `text-overflow: ellipsis` |
|
||||
| Very long names | Blow out column width | CSS `max-width` + ellipsis (value stays intact in a `title` tooltip) |
|
||||
| Emoji / astral-plane chars | None — legitimate usage | Nothing; ensure JS length math uses code points, not UTF-16 units, when truncating |
|
||||
|
||||
Truncation in JS deserves care: `substring()` on a UTF-16 string can split a
|
||||
surrogate pair and emit a replacement glyph. Use `Array.from(str).slice(0, n)` or
|
||||
CSS-based ellipsis (preferred — no string surgery at all).
|
||||
|
||||
### 2A.4 Terminal/log safety
|
||||
|
||||
Names flow into `DEBUG_*` output. ANSI escape sequences in a name can manipulate
|
||||
a maintainer's terminal. Log rendering should escape non-printable bytes, or
|
||||
simply avoid logging profile names at all — the pubkey is the useful identifier
|
||||
in logs anyway.
|
||||
|
||||
### 2.9 Which field do people actually use?
|
||||
|
||||
Storing both fields turns this into an empirical question rather than a guess.
|
||||
Once the table is populated, one aggregate query answers it against real data
|
||||
from your relay's own corpus:
|
||||
|
||||
```sql
|
||||
SELECT count(*) FILTER (WHERE name <> '' AND display_name <> '') AS both,
|
||||
count(*) FILTER (WHERE name <> '' AND display_name = '') AS name_only,
|
||||
count(*) FILTER (WHERE name = '' AND display_name <> '') AS display_only,
|
||||
count(*) FILTER (WHERE name = '' AND display_name = '') AS neither,
|
||||
count(*) FILTER (WHERE name <> '' AND display_name <> ''
|
||||
AND name <> display_name) AS both_differ,
|
||||
count(*) AS total
|
||||
FROM profiles;
|
||||
```
|
||||
|
||||
`both_differ` is the number that matters: it counts profiles where the preference
|
||||
setting actually changes what gets rendered. If it is near zero, the setting is
|
||||
academic and either default is fine. If it is large, the setting earns its keep.
|
||||
|
||||
Worth surfacing as a small panel on the admin stats page — it is one cheap
|
||||
aggregate over a table with one row per pubkey, and it makes
|
||||
`profile_name_preference` self-documenting: you can see the impact of the choice
|
||||
before making it. Add it once the table has accumulated real data.
|
||||
|
||||
### 2.8 Verification
|
||||
|
||||
- Fresh database: relay starts, `profiles` exists, posting a kind-0 populates
|
||||
exactly one row with `name` and `display_name` both preserved verbatim.
|
||||
- Upgrade path: start against a database with pre-existing kind-0 events, confirm
|
||||
the backfill fills every row once and does **not** re-run on the next restart.
|
||||
- Replaceable-update: publish a newer kind-0, confirm the row updates; replay an
|
||||
older one, confirm the row does **not** regress.
|
||||
- Malformed content: store a kind-0 whose content is not JSON; confirm the insert
|
||||
still succeeds, `parse_ok = FALSE`, and the admin pages render without error.
|
||||
- **NUL byte:** publish a kind-0 whose `name` contains `\u0000`; confirm the
|
||||
event is still accepted, the profile row is created, and the relay does not
|
||||
error. This is the regression test for the ingest-DoS path in §2A.1.
|
||||
- **XSS:** publish a kind-0 with `name` set to
|
||||
`<img src=x onerror="window.__xss=1">`, load the stats and caching pages, and
|
||||
confirm the markup is rendered as visible text and `window.__xss` is
|
||||
undefined.
|
||||
- **Bidi/Zalgo:** publish names containing `U+202E` and stacked combining marks;
|
||||
confirm table layout and neighbouring rows are unaffected.
|
||||
- Consistency: the same pubkey shows an identical name in the stats table, the
|
||||
caching follows table, and the header.
|
||||
- Query-count check: confirm the top-pubkeys API response issues one profile
|
||||
query rather than ten.
|
||||
- Both-fields check: query `profiles` for a pubkey whose kind-0 sets `name` and
|
||||
`display_name` to different values; confirm both are stored distinctly.
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 2 — Image Caching (design only, deferred)
|
||||
|
||||
Recorded here so Phase 1's schema does not need reworking later.
|
||||
|
||||
### 3.1 Motivation
|
||||
|
||||
Today the admin UI hotlinks `picture` URLs directly
|
||||
([`admin/assets/app.js:653`](../admin/assets/app.js:653)). Problems: the admin's
|
||||
browser reveals its IP to arbitrary third-party hosts on every page load; dead
|
||||
or slow hosts degrade the UI; images can be arbitrarily large; there is no way to
|
||||
show avatars offline.
|
||||
|
||||
### 3.2 Proposed schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS profile_images (
|
||||
pubkey TEXT PRIMARY KEY,
|
||||
source_url TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL DEFAULT '',
|
||||
byte_size INTEGER NOT NULL DEFAULT 0,
|
||||
sha256 TEXT NOT NULL DEFAULT '',
|
||||
etag TEXT NOT NULL DEFAULT '',
|
||||
image_data BYTEA,
|
||||
fetch_state TEXT NOT NULL DEFAULT 'pending',
|
||||
fetch_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
fetched_at BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
|
||||
CHECK (fetch_state IN ('pending','ok','failed','skipped','too_large'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_profile_images_pending
|
||||
ON profile_images(fetch_state, fetch_attempts) WHERE fetch_state = 'pending';
|
||||
```
|
||||
|
||||
`BYTEA` in PostgreSQL rather than the filesystem keeps backup/restore and the
|
||||
container story single-artifact, matching how everything else in this project is
|
||||
stored. Avatars are small; a cap keeps total size bounded.
|
||||
|
||||
### 3.3 Fetch worker
|
||||
|
||||
`libcurl` is already linked ([`Makefile:6`](../Makefile:6)) but currently unused
|
||||
in `src/`. A worker modeled on
|
||||
[`caching_inbox_poller.c`](../src/caching_inbox_poller.c) — two-state
|
||||
idle/active polling, config-gated, off the main libwebsockets thread — would:
|
||||
|
||||
1. Enqueue `pending` rows when `profiles.picture` changes (trigger or poll).
|
||||
2. Fetch with a hard timeout, a max-bytes ceiling (~256 KB), redirect limit,
|
||||
and `Content-Type` allow-list (`image/png|jpeg|webp|gif`).
|
||||
3. Send `If-None-Match` on refresh, honour `304`.
|
||||
4. Exponential backoff, capped `fetch_attempts`, terminal `failed`.
|
||||
|
||||
New config keys following existing naming: `profile_image_cache_enabled`
|
||||
(default **off**), `profile_image_max_bytes`, `profile_image_refresh_days`,
|
||||
`profile_image_fetch_concurrency`.
|
||||
|
||||
### 3.4 Serving
|
||||
|
||||
A relay HTTP route `/avatar/<pubkey>` handled in
|
||||
[`handle_embedded_file_request()`](../src/api.c:1006) (called from
|
||||
[`src/websockets.c:1261`](../src/websockets.c:1261)), returning the bytes with a
|
||||
long `Cache-Control` and an `ETag`, falling back to a generated identicon or
|
||||
`404` when uncached. The UI then only ever loads images from the relay's own
|
||||
origin.
|
||||
|
||||
### 3.5 Risks to weigh before committing
|
||||
|
||||
- **Outbound HTTP from the relay** is a new capability and a real SSRF surface —
|
||||
needs a private-IP/localhost block-list and scheme restriction. This is the
|
||||
main reason to keep it default-off and deferred.
|
||||
- Database growth: bounded by `max_bytes × profile count`; needs a documented
|
||||
ceiling and a prune path.
|
||||
- Content risk: the relay would be re-serving arbitrary third-party bytes under
|
||||
its own origin. Strict `Content-Type` enforcement plus
|
||||
`Content-Security-Policy` / `X-Content-Type-Options: nosniff` on the route.
|
||||
|
||||
---
|
||||
|
||||
## 4. Files Touched (Phase 1)
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| [`src/pg_schema.sql`](../src/pg_schema.sql) | `profiles` table (both name fields, no generated column), `safe_jsonb()`, `sanitize_pg_text()`, sync + delete triggers, guarded backfill, `profile_name_preference` config default, version → 6 |
|
||||
| [`src/pg_schema.h`](../src/pg_schema.h) | Mirror the above as C string literals; bump `EMBEDDED_PG_SCHEMA_VERSION` |
|
||||
| [`src/db_ops.h`](../src/db_ops.h) | Declare `db_get_profile()` / `db_get_profiles()` |
|
||||
| [`src/db_ops_postgres.h`](../src/db_ops_postgres.h) | Declare the postgres implementations |
|
||||
| [`src/db_ops_postgres.c`](../src/db_ops_postgres.c) | Implement both against `profiles`; retire the events-table query |
|
||||
| [`src/db_ops.c`](../src/db_ops.c) | Dispatch entries + SQLite stubs |
|
||||
| [`src/config.h`](../src/config.h) / [`src/config.c`](../src/config.c) | `profile_display_name()` resolver; batch profile lookup in the caching follows loop |
|
||||
| [`src/api.c`](../src/api.c) | Batch both top-pubkeys loops; shared attach helper emitting `name` + `display_name` + `best_name` |
|
||||
| [`admin/lib/helpers.php`](../admin/lib/helpers.php) | `profile_map()` + `profile_display_name()` |
|
||||
| [`admin/api/stats.php`](../admin/api/stats.php) | Use `profile_map()`; drop LATERAL + JSON casts |
|
||||
| [`admin/api/caching.php`](../admin/api/caching.php) | Join `profiles`; drop LATERAL + JSON casts |
|
||||
| `admin/api/profile.php` | New: single-profile lookup endpoint |
|
||||
| [`admin/assets/app.js`](../admin/assets/app.js) | **`esc()` helper + XSS sweep of all `innerHTML` renderers (§2A.2)**; local-first profile load; consume server `best_name` |
|
||||
| [`admin/assets/index.css`](../admin/assets/index.css) | `nowrap` / `overflow` / `max-width` + ellipsis on name cells (§2A.3) |
|
||||
| `tests/` | New script covering populate / upgrade / replace / malformed / NUL / XSS / bidi cases |
|
||||
|
||||
---
|
||||
|
||||
## 5. Sequencing
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Add profiles table + safe_jsonb + sanitize_pg_text + triggers to pg_schema.sql] --> B[Mirror into pg_schema.h and bump version to 6]
|
||||
B --> C[Guarded one-time backfill + profile_name_preference config default]
|
||||
C --> D[Implement db_get_profile and db_get_profiles]
|
||||
D --> E[Migrate api.c and config.c to batch lookups]
|
||||
E --> F[Add profile_map helper and migrate PHP endpoints]
|
||||
F --> X[Fix stored XSS: esc helper and innerHTML sweep in app.js]
|
||||
X --> G[Add profile.php endpoint and update app.js profile load]
|
||||
G --> Y[CSS hardening for hostile name rendering]
|
||||
Y --> Z[Add name-field usage panel to stats page]
|
||||
G --> H[Tests: populate, upgrade, replace, malformed, consistency]
|
||||
H --> I[Phase 2 image caching - deferred]
|
||||
```
|
||||
@@ -0,0 +1,233 @@
|
||||
# Server-Side ASCII Chart Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the client-side `text_graph.js` ASCII chart with a **server-side PHP renderer** that produces the ASCII X-bar chart string. The chart is served from a **dedicated plain-text endpoint** (`api/chart.php`) that works both in the browser (injected into a `<div>`) and in the terminal via `curl` — the ASCII art renders correctly either way.
|
||||
|
||||
Four time ranges, each with its own bin size and refresh/caching strategy:
|
||||
|
||||
| Range | Span | Bin size | Bins | Refresh | Cache TTL |
|
||||
|--------|-------------|------------|------|------------------|------------|
|
||||
| Hour | last 1h | 10 seconds | 360 | every 10s (live) | none |
|
||||
| Day | last 24h | 5 minutes | 288 | every 10s* | 1 hour |
|
||||
| Month | last 30d | 1 hour | 720 | every 10s* | 1 day |
|
||||
| Year | last 365d | 1 day | 365 | every 10s* | 1 month |
|
||||
|
||||
\* The client polls every 10s, but the server only re-runs the expensive query when the cache expires. Between cache expirations, the cached ASCII string is returned instantly.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Clients
|
||||
T[Terminal — curl]
|
||||
W[Web UI — app.js]
|
||||
end
|
||||
|
||||
T -->|GET chart.php?range=hour| EP[chart.php]
|
||||
W -->|GET chart.php?range=hour| EP
|
||||
T -->|GET chart.php?range=day| EP
|
||||
W -->|GET chart.php?range=day| EP
|
||||
|
||||
EP --> C{Cache valid?}
|
||||
C -->|Yes| Return[Return cached ASCII string]
|
||||
C -->|No| Query[Run GROUP BY binning query]
|
||||
Query --> Render[render_ascii_chart in lib/ascii_chart.php]
|
||||
Render --> CacheWrite[Write to cache file]
|
||||
CacheWrite --> Return
|
||||
|
||||
Return -->|text/plain; charset=utf-8| T
|
||||
Return -->|text/plain; charset=utf-8| W
|
||||
|
||||
subgraph "Cache files (admin2/cache/)"
|
||||
H[chart_hour.txt — never cached]
|
||||
D[chart_day.txt — TTL 1h]
|
||||
M[chart_month.txt — TTL 1d]
|
||||
Y[chart_year.txt — TTL 1mo]
|
||||
end
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### 1. PHP ASCII Chart Renderer — `admin2/lib/ascii_chart.php`
|
||||
|
||||
A pure function that takes an array of bin counts and produces the ASCII X-bar chart string. Mirrors the layout of the original `text_graph.js`:
|
||||
|
||||
```
|
||||
New Events
|
||||
|
||||
11 | X
|
||||
10 | X X
|
||||
9 | X X X X
|
||||
8 | X X X X X X X
|
||||
7 | X X X X X X X X X X
|
||||
6 | X X X X X X X X X X X X X
|
||||
5 | X X X X X X X X X X X X X X X
|
||||
4 | X X X X X X X X X X X X X X X X X
|
||||
3 |X X X X X X X X X X X X X X X X X X X
|
||||
2 |X X X X X X X X X X X X X X X X X X X
|
||||
1 |X X X X X X X X X X X X X X X X X X X
|
||||
+----------------------------------------
|
||||
0s 50s 100s 150s 200s 250s 300s
|
||||
```
|
||||
|
||||
**Function signature:**
|
||||
```php
|
||||
function render_ascii_chart(array $bins, array $options = []): string
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `title` (string, default `'New Events'`)
|
||||
- `max_height` (int, default `11`) — chart height in rows
|
||||
- `x_axis_label` (string, default `''`)
|
||||
- `bin_duration` (int, seconds) — for X-axis elapsed-time labels
|
||||
- `label_interval` (int, default `5`) — label every N bins
|
||||
|
||||
**Algorithm** (same as `text_graph.js` render method):
|
||||
1. `max_count = max($bins)`; `scale_factor = max(1, ceil(max_count / max_height))`
|
||||
2. For each row from `max_height` down to `1`:
|
||||
- Y-axis label = `(row - 1) * scale_factor + 1`, right-padded to 3 chars
|
||||
- For each bin: if `ceil(count / scale_factor) >= row` → `X`, else space
|
||||
3. X-axis: `+` followed by dashes (one per bin)
|
||||
4. X-axis labels: elapsed time every `label_interval` bins, formatted as `Ns` / `Nm` / `Hh` / `Dd` depending on magnitude
|
||||
|
||||
### 2. Binning SQL Queries — in `stats.php`
|
||||
|
||||
Each range uses a `FLOOR((created_at - epoch) / bin_size)` GROUP BY query. The `idx_events_created_at` index makes these fast.
|
||||
|
||||
**Hour (live, no cache):**
|
||||
```sql
|
||||
SELECT FLOOR((created_at - :epoch) / 10)::INT AS bin, COUNT(*) AS cnt
|
||||
FROM events
|
||||
WHERE created_at >= :epoch
|
||||
GROUP BY bin
|
||||
ORDER BY bin;
|
||||
```
|
||||
- `epoch = now - 3600`, `bin_size = 10s`, produces up to 360 bins
|
||||
- Runs on every 10s poll (cheap: only scans last hour, indexed)
|
||||
|
||||
**Day (cache TTL 1h):**
|
||||
```sql
|
||||
SELECT FLOOR((created_at - :epoch) / 300)::INT AS bin, COUNT(*) AS cnt
|
||||
FROM events
|
||||
WHERE created_at >= :epoch
|
||||
GROUP BY bin
|
||||
ORDER BY bin;
|
||||
```
|
||||
- `epoch = now - 86400`, `bin_size = 300s` (5 min), produces up to 288 bins
|
||||
|
||||
**Month (cache TTL 1d):**
|
||||
```sql
|
||||
SELECT FLOOR((created_at - :epoch) / 3600)::INT AS bin, COUNT(*) AS cnt
|
||||
FROM events
|
||||
WHERE created_at >= :epoch
|
||||
GROUP BY bin
|
||||
ORDER BY bin;
|
||||
```
|
||||
- `epoch = now - 2592000`, `bin_size = 3600s` (1h), produces up to 720 bins
|
||||
|
||||
**Year (cache TTL 1 month):**
|
||||
```sql
|
||||
SELECT FLOOR((created_at - :epoch) / 86400)::INT AS bin, COUNT(*) AS cnt
|
||||
FROM events
|
||||
WHERE created_at >= :epoch
|
||||
GROUP BY bin
|
||||
ORDER BY bin;
|
||||
```
|
||||
- `epoch = now - 31536000`, `bin_size = 86400s` (1 day), produces up to 365 bins
|
||||
|
||||
**Bin array assembly:** Query returns only non-empty bins. PHP fills a fixed-length array (all zeros) and overlays the counts at the correct positions, so empty time slots show as blank columns — the chart always advances in time.
|
||||
|
||||
### 3. File-Based Cache — `admin2/cache/`
|
||||
|
||||
Simple file cache with TTL. No APCu/Redis dependency.
|
||||
|
||||
```php
|
||||
function get_cached_chart(string $range): ?string
|
||||
function set_cached_chart(string $range, string $ascii): void
|
||||
```
|
||||
|
||||
- Cache files: `admin2/cache/chart_{range}.txt`
|
||||
- TTLs: `hour` = 0 (never cache), `day` = 3600, `month` = 86400, `year` = 2592000
|
||||
- Check: `filemtime($file) > time() - $ttl`
|
||||
- Directory `admin2/cache/` created automatically with `mkdir(..., 0775, true)`
|
||||
|
||||
### 4. Standalone Chart Endpoint — `admin2/api/chart.php`
|
||||
|
||||
A dedicated plain-text endpoint that returns the raw ASCII chart string. Works in both the browser and the terminal.
|
||||
|
||||
**Request:** `GET api/chart.php?range=hour|day|month|year`
|
||||
|
||||
**Response:** `Content-Type: text/plain; charset=utf-8` — just the ASCII chart string, no JSON wrapper.
|
||||
|
||||
**Terminal usage:**
|
||||
```bash
|
||||
curl http://localhost:8088/api/chart.php?range=hour
|
||||
curl http://localhost:8088/api/chart.php?range=day
|
||||
curl http://localhost:8088/api/chart.php?range=month
|
||||
curl http://localhost:8088/api/chart.php?range=year
|
||||
```
|
||||
|
||||
**Logic:**
|
||||
1. Read `range` query param (default: `hour`)
|
||||
2. Validate against allowed ranges (`hour`, `day`, `month`, `year`)
|
||||
3. Check cache: if valid, return cached string immediately
|
||||
4. If cache miss/expired: run the binning SQL query, fill the bin array, call `render_ascii_chart()`, write to cache, return the string
|
||||
5. Set `Content-Type: text/plain; charset=utf-8` header
|
||||
|
||||
**`stats.php` is unchanged** — it continues to return the existing JSON stats (numbers only). The chart is a completely separate endpoint, keeping concerns cleanly separated.
|
||||
|
||||
### 5. Frontend Changes
|
||||
|
||||
#### `admin2/index.php`
|
||||
- Replace the single chart div with a chart container + range selector tabs:
|
||||
```html
|
||||
<div class="chart-range-tabs">
|
||||
<button class="chart-tab active" data-range="hour">1H</button>
|
||||
<button class="chart-tab" data-range="day">1D</button>
|
||||
<button class="chart-tab" data-range="month">1M</button>
|
||||
<button class="chart-tab" data-range="year">1Y</button>
|
||||
</div>
|
||||
<div id="event-rate-chart" class="event-rate-chart-container">Loading chart...</div>
|
||||
```
|
||||
- Remove `<script src="assets/text_graph.js">` (no longer needed)
|
||||
|
||||
#### `admin2/assets/app.js`
|
||||
- Remove: `eventRateChart`, `previousTotalEvents`, `initializeEventRateChart`, `createChartStubElements`, the `addValue` call in `loadStats`
|
||||
- Add: `currentChartRange = 'hour'` state variable
|
||||
- Add: `loadChart(range)` function — fetches `api/chart.php?range=${range}` as plain text, injects the response into `#event-rate-chart` via `textContent`
|
||||
- Add: tab click handlers that set `currentChartRange` and call `loadChart`
|
||||
- In `loadStats`: call `loadChart(currentChartRange)` at the end (so the hour chart refreshes every 10s)
|
||||
- On DOMContentLoaded: call `loadChart('hour')` to load the initial chart
|
||||
|
||||
#### `admin2/assets/index.css`
|
||||
- Add `.chart-range-tabs` styles (small monospace buttons, active state with accent border)
|
||||
|
||||
### 6. Cache Directory
|
||||
|
||||
- `admin2/cache/` — add to `.gitignore` (runtime artifacts)
|
||||
- Created at runtime by `mkdir` if missing
|
||||
|
||||
## File Summary
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `admin2/lib/ascii_chart.php` | **New** — PHP ASCII chart renderer function |
|
||||
| `admin2/api/chart.php` | **New** — standalone plain-text chart endpoint with 4 binning queries + cache |
|
||||
| `admin2/api/stats.php` | **Unchanged** — continues returning JSON stats as before |
|
||||
| `admin2/index.php` | **Modify** — add range tabs, remove text_graph.js script |
|
||||
| `admin2/assets/app.js` | **Modify** — replace client chart with `loadChart()` fetch+inject |
|
||||
| `admin2/assets/index.css` | **Modify** — add `.chart-range-tabs` styles |
|
||||
| `admin2/cache/` | **New dir** — runtime cache files (gitignored) |
|
||||
| `admin2/assets/text_graph.js` | **Delete** — no longer needed |
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. Create `admin2/lib/ascii_chart.php` (the renderer)
|
||||
2. Create `admin2/api/chart.php` (standalone chart endpoint + cache logic)
|
||||
3. Modify `admin2/index.php` (add range tabs, remove text_graph.js)
|
||||
4. Modify `admin2/assets/app.js` (replace chart logic with `loadChart`)
|
||||
5. Modify `admin2/assets/index.css` (add tab styles)
|
||||
6. Add `admin2/cache/` to `.gitignore`
|
||||
7. Delete `admin2/assets/text_graph.js`
|
||||
8. Test: verify all 4 ranges load, cache files appear, hour chart refreshes live
|
||||
@@ -30,6 +30,7 @@ int get_active_connection_count(void);
|
||||
#include "../nostr_core_lib/nostr_core/nip044.h"
|
||||
#include "subscriptions.h"
|
||||
#include "db_ops.h"
|
||||
#include "caching_service_launcher.h"
|
||||
|
||||
// External subscription manager (from main.c via subscriptions.c)
|
||||
extern subscription_manager_t g_subscription_manager;
|
||||
@@ -103,7 +104,8 @@ int generate_and_post_status_event(void);
|
||||
// =====================================================================
|
||||
|
||||
typedef enum {
|
||||
API_WORK_JOB_STATUS_POST = 1
|
||||
API_WORK_JOB_STATUS_POST = 1,
|
||||
API_WORK_JOB_CACHING_TOGGLE = 2
|
||||
} api_work_job_type_t;
|
||||
|
||||
typedef struct api_work_completion {
|
||||
@@ -191,6 +193,48 @@ int api_worker_enqueue_status_post(void) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Poll the config_changed LISTEN channel on the worker DB connection for up
|
||||
// to timeout_ms. If a config_changed notification is received, enqueue an
|
||||
// API_WORK_JOB_CACHING_TOGGLE completion so the main thread re-reads
|
||||
// caching_enabled and starts/stops the caching service. Any other
|
||||
// notifications (e.g. event_stored) are simply drained. Returns 1 if a
|
||||
// config_changed notification was processed, 0 otherwise.
|
||||
static int api_worker_poll_config_changed(void* worker_db, int timeout_ms) {
|
||||
if (!worker_db) return 0;
|
||||
char* notify_channel = NULL;
|
||||
char* notify_payload = NULL;
|
||||
int prc = db_worker_poll_notify_with_payload(worker_db, timeout_ms,
|
||||
¬ify_channel, ¬ify_payload);
|
||||
if (prc != 1) return 0;
|
||||
int found_config = 0;
|
||||
if (notify_channel && strcmp(notify_channel, "config_changed") == 0) {
|
||||
found_config = 1;
|
||||
}
|
||||
if (notify_channel) free(notify_channel);
|
||||
if (notify_payload) free(notify_payload);
|
||||
// Drain any additional queued notifications.
|
||||
char* extra_ch = NULL;
|
||||
char* extra_pl = NULL;
|
||||
while (db_worker_poll_notify_with_payload(worker_db, 0,
|
||||
&extra_ch, &extra_pl) == 1) {
|
||||
if (extra_ch && strcmp(extra_ch, "config_changed") == 0) {
|
||||
found_config = 1;
|
||||
}
|
||||
if (extra_ch) free(extra_ch);
|
||||
if (extra_pl) free(extra_pl);
|
||||
}
|
||||
if (found_config) {
|
||||
api_work_completion_t* c = calloc(1, sizeof(*c));
|
||||
if (c) {
|
||||
c->type = API_WORK_JOB_CACHING_TOGGLE;
|
||||
c->result = 0;
|
||||
api_worker_push_completion(c);
|
||||
}
|
||||
DEBUG_LOG("api-worker: config_changed notify received, enqueued CACHING_TOGGLE");
|
||||
}
|
||||
return found_config;
|
||||
}
|
||||
|
||||
static void* api_worker_main(void* arg) {
|
||||
(void)arg;
|
||||
pthread_setname_np(pthread_self(), "api-worker");
|
||||
@@ -219,6 +263,16 @@ static void* api_worker_main(void* arg) {
|
||||
if (db_worker_listen(worker_db, "event_stored") == 0) {
|
||||
listen_active = 1;
|
||||
DEBUG_LOG("api-worker: LISTEN event_stored registered");
|
||||
// Also LISTEN on the config_changed channel so the worker wakes
|
||||
// reactively when the PHP admin page toggles caching_enabled /
|
||||
// caching_inbox_enabled (or any other config key). The trigger
|
||||
// notify_config_changed() in pg_schema.sql fires
|
||||
// pg_notify('config_changed', NEW.key) on every config UPDATE.
|
||||
if (db_worker_listen(worker_db, "config_changed") == 0) {
|
||||
DEBUG_LOG("api-worker: LISTEN config_changed registered");
|
||||
} else {
|
||||
DEBUG_WARN("api-worker: LISTEN config_changed failed; config changes will not be reactive");
|
||||
}
|
||||
} else {
|
||||
DEBUG_LOG("api-worker: LISTEN not supported on this backend; using timer-only mode");
|
||||
}
|
||||
@@ -227,11 +281,17 @@ static void* api_worker_main(void* arg) {
|
||||
while (g_api_worker_running) {
|
||||
int throttle_sec = get_monitoring_throttle_seconds();
|
||||
if (throttle_sec <= 0) {
|
||||
// Monitoring disabled. Sleep briefly and re-check config.
|
||||
struct timespec ts;
|
||||
ts.tv_sec = 1;
|
||||
ts.tv_nsec = 0;
|
||||
nanosleep(&ts, NULL);
|
||||
// Monitoring disabled. Sleep briefly and re-check config, but
|
||||
// still poll for config_changed notifications so caching toggle
|
||||
// works even when monitoring is off.
|
||||
if (listen_active && worker_db) {
|
||||
(void)api_worker_poll_config_changed(worker_db, 1000);
|
||||
} else {
|
||||
struct timespec ts;
|
||||
ts.tv_sec = 1;
|
||||
ts.tv_nsec = 0;
|
||||
nanosleep(&ts, NULL);
|
||||
}
|
||||
// Still honor a STATUS_POST request if one arrived.
|
||||
if (api_worker_take_status_post()) {
|
||||
int rc = generate_and_post_status_event();
|
||||
@@ -245,20 +305,23 @@ static void* api_worker_main(void* arg) {
|
||||
int has_subs = has_any_subscription_for_kind(24567);
|
||||
|
||||
if (!has_subs) {
|
||||
// No subscribers: sleep on the condvar for throttle_sec. Zero DB
|
||||
// work, zero notify polling. Wakeable for STATUS_POST / shutdown.
|
||||
pthread_mutex_lock(&g_api_work_mutex);
|
||||
if (g_api_worker_running && !g_api_status_post_pending) {
|
||||
struct timespec deadline;
|
||||
clock_gettime(CLOCK_REALTIME, &deadline);
|
||||
deadline.tv_sec += throttle_sec;
|
||||
pthread_cond_timedwait(&g_api_work_cond, &g_api_work_mutex, &deadline);
|
||||
// No subscribers. Use the notify poll as the wait mechanism so we
|
||||
// still wake reactively for config_changed notifications. On
|
||||
// non-LISTEN backends, fall back to condvar wait.
|
||||
if (listen_active && worker_db) {
|
||||
(void)api_worker_poll_config_changed(worker_db, throttle_sec * 1000);
|
||||
} else {
|
||||
pthread_mutex_lock(&g_api_work_mutex);
|
||||
if (g_api_worker_running && !g_api_status_post_pending) {
|
||||
struct timespec deadline;
|
||||
clock_gettime(CLOCK_REALTIME, &deadline);
|
||||
deadline.tv_sec += throttle_sec;
|
||||
pthread_cond_timedwait(&g_api_work_cond, &g_api_work_mutex, &deadline);
|
||||
}
|
||||
pthread_mutex_unlock(&g_api_work_mutex);
|
||||
}
|
||||
int do_status = g_api_status_post_pending;
|
||||
g_api_status_post_pending = 0;
|
||||
pthread_mutex_unlock(&g_api_work_mutex);
|
||||
api_worker_drain_wake_pipe();
|
||||
if (do_status) {
|
||||
if (api_worker_take_status_post()) {
|
||||
int rc = generate_and_post_status_event();
|
||||
api_work_completion_t* c = calloc(1, sizeof(*c));
|
||||
if (c) { c->type = API_WORK_JOB_STATUS_POST; c->result = rc; api_worker_push_completion(c); }
|
||||
@@ -269,9 +332,7 @@ static void* api_worker_main(void* arg) {
|
||||
// Subscribers exist. Wait for either a NOTIFY, the throttle timeout,
|
||||
// or a self-pipe wake (STATUS_POST / shutdown) — whichever comes first.
|
||||
if (listen_active && worker_db) {
|
||||
int prc = db_worker_poll_notify(worker_db, throttle_sec * 1000);
|
||||
// prc: 1 = notification, 0 = timeout, -1 = error
|
||||
(void)prc;
|
||||
(void)api_worker_poll_config_changed(worker_db, throttle_sec * 1000);
|
||||
} else {
|
||||
// No LISTEN support: sleep for throttle_sec on the condvar.
|
||||
pthread_mutex_lock(&g_api_work_mutex);
|
||||
@@ -366,7 +427,35 @@ void stop_api_worker(void) {
|
||||
void api_worker_process_completions(void) {
|
||||
api_work_completion_t* completion = NULL;
|
||||
while ((completion = api_worker_pop_completion()) != NULL) {
|
||||
if (completion->result != 0) {
|
||||
if (completion->type == API_WORK_JOB_CACHING_TOGGLE) {
|
||||
// A config_changed NOTIFY was received by the worker. Re-read
|
||||
// caching_enabled from the config table and start/stop the
|
||||
// caching service process accordingly. This runs on the main
|
||||
// lws thread (the caller), which is the correct place to fork
|
||||
// — forking on the worker thread would let the child inherit
|
||||
// the worker's dedicated PG connection.
|
||||
//
|
||||
// Invalidate the in-memory config cache first — the PHP admin
|
||||
// page updates the config table directly via SQL, which fires
|
||||
// the NOTIFY trigger but does NOT invalidate our cache. Without
|
||||
// this, get_config_bool() would return the stale cached value.
|
||||
invalidate_config_cache();
|
||||
int caching_on = get_config_bool("caching_enabled", 0);
|
||||
int running = caching_service_is_running();
|
||||
DEBUG_LOG("api-worker: CACHING_TOGGLE — caching_enabled=%d, running=%d",
|
||||
caching_on, running);
|
||||
if (caching_on && !running) {
|
||||
int rc = caching_service_start();
|
||||
if (rc != 0) {
|
||||
DEBUG_ERROR("api-worker: caching_service_start() failed (rc=%d)", rc);
|
||||
}
|
||||
} else if (!caching_on && running) {
|
||||
int rc = caching_service_stop();
|
||||
if (rc != 0) {
|
||||
DEBUG_ERROR("api-worker: caching_service_stop() failed (rc=%d)", rc);
|
||||
}
|
||||
}
|
||||
} else if (completion->result != 0) {
|
||||
DEBUG_TRACE("api-worker job %d completed with rc=%d", (int)completion->type, completion->result);
|
||||
}
|
||||
free(completion);
|
||||
@@ -466,6 +555,62 @@ cJSON* query_time_based_statistics(void) {
|
||||
return time_stats;
|
||||
}
|
||||
|
||||
// Attach profile fields (name, display_name, best_name, picture) from the
|
||||
// profiles cache to a pubkey_obj. The profile_map is a cJSON object keyed by
|
||||
// pubkey hex (from db_get_profiles). If the pubkey is not in the map, empty
|
||||
// defaults are added. The "name" field is set to best_name for backward
|
||||
// compatibility with the legacy api/ frontend.
|
||||
static void api_attach_profile_fields(cJSON* pubkey_obj, const char* pk_str,
|
||||
cJSON* profile_map) {
|
||||
if (!pubkey_obj || !pk_str) return;
|
||||
|
||||
if (profile_map && pk_str[0] != '\0') {
|
||||
cJSON* profile = cJSON_GetObjectItemCaseSensitive(profile_map, pk_str);
|
||||
if (profile) {
|
||||
cJSON* best = cJSON_GetObjectItemCaseSensitive(profile, "best_name");
|
||||
cJSON* name = cJSON_GetObjectItemCaseSensitive(profile, "name");
|
||||
cJSON* dn = cJSON_GetObjectItemCaseSensitive(profile, "display_name");
|
||||
cJSON* pic = cJSON_GetObjectItemCaseSensitive(profile, "picture");
|
||||
|
||||
/* "name" = best_name for backward compat with legacy api/ UI. */
|
||||
const char* best_str = (best && cJSON_IsString(best)) ? best->valuestring : "";
|
||||
cJSON_AddStringToObject(pubkey_obj, "name", best_str);
|
||||
if (name && cJSON_IsString(name))
|
||||
cJSON_AddStringToObject(pubkey_obj, "display_name_raw", name->valuestring);
|
||||
if (dn && cJSON_IsString(dn))
|
||||
cJSON_AddStringToObject(pubkey_obj, "display_name", dn->valuestring);
|
||||
if (pic && cJSON_IsString(pic) && pic->valuestring[0])
|
||||
cJSON_AddStringToObject(pubkey_obj, "picture", pic->valuestring);
|
||||
return;
|
||||
}
|
||||
}
|
||||
/* No profile cached — add empty defaults. */
|
||||
cJSON_AddStringToObject(pubkey_obj, "name", "");
|
||||
}
|
||||
|
||||
// Collect pubkeys from a rows array (each row has a "pubkey" string field)
|
||||
// into a NULL-terminated array suitable for db_get_profiles(). Returns a
|
||||
// malloc'd array; caller must free. Sets *out_count.
|
||||
static const char** collect_pubkeys_from_rows(cJSON* rows, int* out_count) {
|
||||
if (!rows || !out_count) return NULL;
|
||||
int n = cJSON_GetArraySize(rows);
|
||||
if (n <= 0) { *out_count = 0; return NULL; }
|
||||
|
||||
const char** pks = (const char**)malloc(sizeof(char*) * n);
|
||||
if (!pks) { *out_count = 0; return NULL; }
|
||||
|
||||
int count = 0;
|
||||
cJSON* row = NULL;
|
||||
cJSON_ArrayForEach(row, rows) {
|
||||
cJSON* pk_item = cJSON_GetObjectItemCaseSensitive(row, "pubkey");
|
||||
if (pk_item && cJSON_IsString(pk_item) && pk_item->valuestring[0] != '\0') {
|
||||
pks[count++] = pk_item->valuestring;
|
||||
}
|
||||
}
|
||||
*out_count = count;
|
||||
return pks;
|
||||
}
|
||||
|
||||
// Query top pubkeys by event count from database
|
||||
cJSON* query_top_pubkeys(void) {
|
||||
void* temp_db = NULL;
|
||||
@@ -486,6 +631,15 @@ cJSON* query_top_pubkeys(void) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Batch profile lookup: one query for all 10 pubkeys. */
|
||||
int pk_count = 0;
|
||||
const char** pks = collect_pubkeys_from_rows(rows, &pk_count);
|
||||
cJSON* profile_map = NULL;
|
||||
if (pks && pk_count > 0) {
|
||||
profile_map = db_get_profiles(pks, pk_count);
|
||||
}
|
||||
free(pks);
|
||||
|
||||
cJSON* pubkeys_array = cJSON_CreateArray();
|
||||
cJSON* row = NULL;
|
||||
cJSON_ArrayForEach(row, rows) {
|
||||
@@ -493,12 +647,16 @@ cJSON* query_top_pubkeys(void) {
|
||||
cJSON* pubkey_item = cJSON_GetObjectItemCaseSensitive(row, "pubkey");
|
||||
cJSON* count_item = cJSON_GetObjectItemCaseSensitive(row, "count");
|
||||
|
||||
cJSON_AddStringToObject(pubkey_obj, "pubkey",
|
||||
cJSON_IsString(pubkey_item) ? pubkey_item->valuestring : "");
|
||||
const char* pk_str = cJSON_IsString(pubkey_item) ? pubkey_item->valuestring : "";
|
||||
cJSON_AddStringToObject(pubkey_obj, "pubkey", pk_str);
|
||||
cJSON_AddNumberToObject(pubkey_obj, "event_count",
|
||||
cJSON_IsNumber(count_item) ? count_item->valuedouble : 0.0);
|
||||
|
||||
api_attach_profile_fields(pubkey_obj, pk_str, profile_map);
|
||||
|
||||
cJSON_AddItemToArray(pubkeys_array, pubkey_obj);
|
||||
}
|
||||
cJSON_Delete(profile_map);
|
||||
cJSON_Delete(rows);
|
||||
|
||||
long long total_events = 0;
|
||||
@@ -1605,6 +1763,15 @@ char* generate_stats_json(void) {
|
||||
cJSON* top_pubkeys = cJSON_CreateArray();
|
||||
cJSON* pubkey_rows = db_get_top_pubkeys_rows(10);
|
||||
if (pubkey_rows) {
|
||||
/* Batch profile lookup: one query for all pubkeys. */
|
||||
int pk_count = 0;
|
||||
const char** pks = collect_pubkeys_from_rows(pubkey_rows, &pk_count);
|
||||
cJSON* profile_map = NULL;
|
||||
if (pks && pk_count > 0) {
|
||||
profile_map = db_get_profiles(pks, pk_count);
|
||||
}
|
||||
free(pks);
|
||||
|
||||
cJSON* row = NULL;
|
||||
cJSON_ArrayForEach(row, pubkey_rows) {
|
||||
cJSON* pubkey_obj = cJSON_CreateObject();
|
||||
@@ -1613,11 +1780,14 @@ char* generate_stats_json(void) {
|
||||
double count_val = cJSON_IsNumber(count_item) ? count_item->valuedouble : 0.0;
|
||||
double pct = (total_events > 0) ? ((count_val * 100.0) / (double)total_events) : 0.0;
|
||||
|
||||
cJSON_AddStringToObject(pubkey_obj, "pubkey", cJSON_IsString(pubkey_item) ? pubkey_item->valuestring : "");
|
||||
const char* pk_str = cJSON_IsString(pubkey_item) ? pubkey_item->valuestring : "";
|
||||
cJSON_AddStringToObject(pubkey_obj, "pubkey", pk_str);
|
||||
cJSON_AddNumberToObject(pubkey_obj, "event_count", count_val);
|
||||
cJSON_AddNumberToObject(pubkey_obj, "percentage", pct);
|
||||
api_attach_profile_fields(pubkey_obj, pk_str, profile_map);
|
||||
cJSON_AddItemToArray(top_pubkeys, pubkey_obj);
|
||||
}
|
||||
cJSON_Delete(profile_map);
|
||||
cJSON_Delete(pubkey_rows);
|
||||
}
|
||||
cJSON_AddItemToObject(response, "top_pubkeys", top_pubkeys);
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
// ---- Defaults ----------------------------------------------------------------
|
||||
|
||||
#define DEFAULT_BATCH_SIZE 25
|
||||
#define DEFAULT_BATCH_SIZE 150
|
||||
#define DEFAULT_ACTIVE_POLL_MS 200
|
||||
#define DEFAULT_IDLE_POLL_MS 5000
|
||||
|
||||
|
||||
@@ -75,14 +75,21 @@ static int caching_service_start_fork(const char* binary_path, const char* pg_co
|
||||
close(devnull);
|
||||
}
|
||||
|
||||
// Redirect stdout/stderr to a log file or /dev/null
|
||||
// For now, send to /dev/null. A future improvement could
|
||||
// redirect to a configurable log file.
|
||||
devnull = open("/dev/null", O_WRONLY);
|
||||
if (devnull >= 0) {
|
||||
dup2(devnull, STDOUT_FILENO);
|
||||
dup2(devnull, STDERR_FILENO);
|
||||
close(devnull);
|
||||
// Redirect stdout/stderr to a log file for debugging.
|
||||
// The log file is created in the relay's working directory (build/).
|
||||
int logfile = open("caching_relay.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
|
||||
if (logfile >= 0) {
|
||||
dup2(logfile, STDOUT_FILENO);
|
||||
dup2(logfile, STDERR_FILENO);
|
||||
close(logfile);
|
||||
} else {
|
||||
// Fallback to /dev/null if log file can't be opened
|
||||
devnull = open("/dev/null", O_WRONLY);
|
||||
if (devnull >= 0) {
|
||||
dup2(devnull, STDOUT_FILENO);
|
||||
dup2(devnull, STDERR_FILENO);
|
||||
close(devnull);
|
||||
}
|
||||
}
|
||||
|
||||
// Build argv for the caching_relay binary.
|
||||
|
||||
+225
-2
@@ -4197,6 +4197,17 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
|
||||
int svc_live = 0, svc_backfill = 0, svc_oldest = 0;
|
||||
db_caching_inbox_pending_counts(&svc_live, &svc_backfill);
|
||||
(void)svc_live; (void)svc_backfill; (void)svc_oldest;
|
||||
|
||||
// Backfill author progress (per-author until-cursor drain model).
|
||||
// The caching_service_state table carries backfill_authors_complete
|
||||
// and backfill_authors_total columns (altered in via pg_schema.sql).
|
||||
// Surface them on the service object so the frontend can render
|
||||
// "Backfill: X/Y authors complete".
|
||||
int bf_complete = 0, bf_total = 0;
|
||||
if (db_caching_backfill_author_counts(&bf_complete, &bf_total) == DB_OK) {
|
||||
cJSON_AddNumberToObject(service_obj, "backfill_authors_complete", bf_complete);
|
||||
cJSON_AddNumberToObject(service_obj, "backfill_authors_total", bf_total);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -4218,6 +4229,203 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
|
||||
snprintf(error_message, error_size, "failed to send caching status response");
|
||||
return -1;
|
||||
}
|
||||
else if (strcmp(command, "caching_follows_status") == 0) {
|
||||
// Build per-follow status response with usernames, event counts,
|
||||
// outbox relays, and backfill progress.
|
||||
cJSON* response = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(response, "command", "caching_follows_status");
|
||||
cJSON_AddStringToObject(response, "status", "success");
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
|
||||
// Read the active backfill target from the caching_backfill_active
|
||||
// table (written by the caching_relay process) so the UI can
|
||||
// highlight which author/relay is currently being queried.
|
||||
{
|
||||
char active_pk[65] = {0};
|
||||
char active_relay[256] = {0};
|
||||
db_stmt_t* at_stmt = NULL;
|
||||
if (db_prepare("SELECT active_pubkey, active_relay "
|
||||
"FROM caching_backfill_active WHERE id = 1", &at_stmt) == DB_OK) {
|
||||
if (db_step_stmt(at_stmt) == DB_ROW) {
|
||||
const char* apk = db_column_text_value(at_stmt, 0);
|
||||
const char* arl = db_column_text_value(at_stmt, 1);
|
||||
cJSON_AddStringToObject(response, "active_backfill_pubkey",
|
||||
apk ? apk : "");
|
||||
cJSON_AddStringToObject(response, "active_backfill_relay",
|
||||
arl ? arl : "");
|
||||
}
|
||||
db_finalize_stmt(at_stmt);
|
||||
}
|
||||
// If the table doesn't exist or query fails, just omit the fields.
|
||||
}
|
||||
|
||||
cJSON* follows_array = cJSON_CreateArray();
|
||||
long total_events_in_db = 0;
|
||||
|
||||
// Query all followed pubkeys from caching_followed_pubkeys.
|
||||
// Two-pass: first collect all pubkeys for a batched profile lookup,
|
||||
// then iterate again to build the response objects.
|
||||
db_stmt_t* stmt = NULL;
|
||||
if (db_prepare("SELECT pubkey, is_root, backfill_complete, events_fetched, "
|
||||
"first_seen, last_seen FROM caching_followed_pubkeys "
|
||||
"ORDER BY is_root DESC, pubkey", &stmt) == DB_OK) {
|
||||
|
||||
/* Pass 1: collect pubkeys into a growable array. */
|
||||
int pk_cap = 64;
|
||||
int pk_count = 0;
|
||||
const char** pk_list = (const char**)malloc(sizeof(char*) * pk_cap);
|
||||
cJSON* pk_strings = cJSON_CreateArray(); /* keep strings alive */
|
||||
|
||||
while (db_step_stmt(stmt) == DB_ROW) {
|
||||
const char* pk = db_column_text_value(stmt, 0);
|
||||
if (!pk || pk[0] == '\0') continue;
|
||||
if (pk_count >= pk_cap) {
|
||||
pk_cap *= 2;
|
||||
pk_list = (const char**)realloc(pk_list, sizeof(char*) * pk_cap);
|
||||
}
|
||||
/* cJSON_CreateString copies the string; point pk_list at the
|
||||
* cJSON-owned copy so it stays valid through pass 2. */
|
||||
cJSON* pk_node = cJSON_CreateString(pk);
|
||||
cJSON_AddItemToArray(pk_strings, pk_node);
|
||||
pk_list[pk_count++] = pk_node->valuestring;
|
||||
}
|
||||
/* Reset the statement for pass 2. */
|
||||
db_reset_stmt(stmt);
|
||||
|
||||
/* Batch profile lookup: one query for all followed pubkeys. */
|
||||
cJSON* profile_map = NULL;
|
||||
if (pk_count > 0) {
|
||||
profile_map = db_get_profiles(pk_list, pk_count);
|
||||
}
|
||||
free(pk_list);
|
||||
|
||||
/* Pass 2: build response objects, using the profile map. */
|
||||
while (db_step_stmt(stmt) == DB_ROW) {
|
||||
const char* pk = db_column_text_value(stmt, 0);
|
||||
if (!pk || pk[0] == '\0') continue;
|
||||
|
||||
cJSON* follow = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(follow, "pubkey", pk);
|
||||
|
||||
// is_root (column 1) - boolean
|
||||
const char* is_root_str = db_column_text_value(stmt, 1);
|
||||
int is_root = (is_root_str && (is_root_str[0] == 't' || is_root_str[0] == 'T' || is_root_str[0] == '1'));
|
||||
cJSON_AddBoolToObject(follow, "is_root", is_root);
|
||||
|
||||
// backfill_complete (column 2) - boolean
|
||||
const char* bf_complete_str = db_column_text_value(stmt, 2);
|
||||
int bf_complete = (bf_complete_str && (bf_complete_str[0] == 't' || bf_complete_str[0] == 'T' || bf_complete_str[0] == '1'));
|
||||
cJSON_AddBoolToObject(follow, "backfill_complete", bf_complete);
|
||||
|
||||
// events_fetched (column 3) - int
|
||||
long long events_fetched = db_column_int64_value(stmt, 3);
|
||||
cJSON_AddNumberToObject(follow, "events_fetched", (double)events_fetched);
|
||||
|
||||
// first_seen / last_seen (columns 4, 5)
|
||||
long long first_seen = db_column_int64_value(stmt, 4);
|
||||
long long last_seen = db_column_int64_value(stmt, 5);
|
||||
cJSON_AddNumberToObject(follow, "first_seen", (double)first_seen);
|
||||
cJSON_AddNumberToObject(follow, "last_seen", (double)last_seen);
|
||||
|
||||
// Profile metadata from the profiles cache (batched lookup).
|
||||
cJSON* profile = profile_map
|
||||
? cJSON_GetObjectItemCaseSensitive(profile_map, pk) : NULL;
|
||||
if (profile) {
|
||||
cJSON* best = cJSON_GetObjectItemCaseSensitive(profile, "best_name");
|
||||
cJSON* picture = cJSON_GetObjectItemCaseSensitive(profile, "picture");
|
||||
cJSON* nip05 = cJSON_GetObjectItemCaseSensitive(profile, "nip05");
|
||||
const char* best_str = (best && cJSON_IsString(best)) ? best->valuestring : "";
|
||||
cJSON_AddStringToObject(follow, "name", best_str);
|
||||
if (picture && cJSON_IsString(picture) && picture->valuestring[0])
|
||||
cJSON_AddStringToObject(follow, "picture", picture->valuestring);
|
||||
if (nip05 && cJSON_IsString(nip05) && nip05->valuestring[0])
|
||||
cJSON_AddStringToObject(follow, "nip05", nip05->valuestring);
|
||||
} else {
|
||||
cJSON_AddStringToObject(follow, "name", "");
|
||||
}
|
||||
|
||||
// Event counts by kind for this pubkey.
|
||||
cJSON* event_counts = cJSON_CreateArray();
|
||||
long long total_for_pubkey = 0;
|
||||
db_stmt_t* kind_stmt = NULL;
|
||||
const char* kind_params[1] = { pk };
|
||||
// Use db_prepare with parameterized query.
|
||||
if (db_prepare("SELECT kind, COUNT(*) FROM events WHERE pubkey = ? "
|
||||
"GROUP BY kind ORDER BY kind", &kind_stmt) == DB_OK) {
|
||||
if (db_bind_text_param(kind_stmt, 1, pk) == DB_OK) {
|
||||
while (db_step_stmt(kind_stmt) == DB_ROW) {
|
||||
int kind = db_column_int_value(kind_stmt, 0);
|
||||
long long count = db_column_int64_value(kind_stmt, 1);
|
||||
cJSON* kc = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(kc, "kind", (double)kind);
|
||||
cJSON_AddNumberToObject(kc, "count", (double)count);
|
||||
cJSON_AddItemToArray(event_counts, kc);
|
||||
total_for_pubkey += count;
|
||||
}
|
||||
}
|
||||
db_finalize_stmt(kind_stmt);
|
||||
}
|
||||
cJSON_AddItemToObject(follow, "event_counts", event_counts);
|
||||
cJSON_AddNumberToObject(follow, "total_events_in_db", (double)total_for_pubkey);
|
||||
total_events_in_db += total_for_pubkey;
|
||||
|
||||
// Outbox relays from kind-10002.
|
||||
cJSON* outbox = db_get_outbox_relays(pk);
|
||||
if (outbox) {
|
||||
cJSON_AddItemToObject(follow, "outbox_relays", outbox);
|
||||
} else {
|
||||
cJSON_AddItemToObject(follow, "outbox_relays", cJSON_CreateArray());
|
||||
}
|
||||
|
||||
// Per-relay backfill progress.
|
||||
cJSON* relay_progress = cJSON_CreateArray();
|
||||
db_stmt_t* rp_stmt = NULL;
|
||||
if (db_prepare("SELECT relay_url, until_cursor, complete, events_fetched, "
|
||||
"COALESCE(last_status, '') AS last_status "
|
||||
"FROM caching_backfill_relay_progress "
|
||||
"WHERE author_pubkey = ? ORDER BY relay_url", &rp_stmt) == DB_OK) {
|
||||
if (db_bind_text_param(rp_stmt, 1, pk) == DB_OK) {
|
||||
while (db_step_stmt(rp_stmt) == DB_ROW) {
|
||||
const char* relay_url = db_column_text_value(rp_stmt, 0);
|
||||
long long until_cursor = db_column_int64_value(rp_stmt, 1);
|
||||
const char* complete_str = db_column_text_value(rp_stmt, 2);
|
||||
long long rp_events = db_column_int64_value(rp_stmt, 3);
|
||||
const char* rp_status = db_column_text_value(rp_stmt, 4);
|
||||
int rp_complete = (complete_str && (complete_str[0] == 't' || complete_str[0] == 'T' || complete_str[0] == '1'));
|
||||
|
||||
cJSON* rp = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(rp, "relay_url", relay_url ? relay_url : "");
|
||||
cJSON_AddNumberToObject(rp, "until_cursor", (double)until_cursor);
|
||||
cJSON_AddBoolToObject(rp, "complete", rp_complete);
|
||||
cJSON_AddNumberToObject(rp, "events_fetched", (double)rp_events);
|
||||
cJSON_AddStringToObject(rp, "last_status", rp_status ? rp_status : "");
|
||||
cJSON_AddItemToArray(relay_progress, rp);
|
||||
}
|
||||
}
|
||||
db_finalize_stmt(rp_stmt);
|
||||
}
|
||||
cJSON_AddItemToObject(follow, "relay_progress", relay_progress);
|
||||
|
||||
cJSON_AddItemToArray(follows_array, follow);
|
||||
}
|
||||
cJSON_Delete(profile_map);
|
||||
cJSON_Delete(pk_strings);
|
||||
db_finalize_stmt(stmt);
|
||||
}
|
||||
|
||||
cJSON_AddItemToObject(response, "follows", follows_array);
|
||||
cJSON_AddNumberToObject(response, "total_follows", (double)cJSON_GetArraySize(follows_array));
|
||||
cJSON_AddNumberToObject(response, "total_events_in_db", (double)total_events_in_db);
|
||||
|
||||
if (send_admin_response_event(response, sender_pubkey, wsi) == 0) {
|
||||
cJSON_Delete(response);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON_Delete(response);
|
||||
snprintf(error_message, error_size, "failed to send caching follows status response");
|
||||
return -1;
|
||||
}
|
||||
else if (strcmp(command, "caching_reset_progress") == 0) {
|
||||
// Reset backfill progress
|
||||
DEBUG_INFO("Admin requested caching backfill progress reset");
|
||||
@@ -4227,8 +4435,23 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
|
||||
#ifdef DB_BACKEND_POSTGRES
|
||||
// Clear the caching_backfill_progress table
|
||||
if (db_exec_sql("DELETE FROM caching_backfill_progress") == 0) {
|
||||
// Clear the backfill progress tables and mark all authors incomplete.
|
||||
// 1. caching_backfill_progress (legacy window table — may be empty)
|
||||
// 2. caching_backfill_relay_progress (per-relay drain table)
|
||||
// 3. caching_followed_pubkeys: reset backfill_complete=FALSE, until_cursor=0
|
||||
int reset_ok = (db_exec_sql("DELETE FROM caching_backfill_progress") == 0);
|
||||
if (reset_ok) {
|
||||
reset_ok = (db_exec_sql("DELETE FROM caching_backfill_relay_progress") == 0);
|
||||
}
|
||||
if (reset_ok) {
|
||||
/* Mark all followed authors as incomplete so the backfill picks them
|
||||
* up again. Reset until_cursor to 0 so each relay starts fresh. */
|
||||
reset_ok = (db_exec_sql(
|
||||
"UPDATE caching_followed_pubkeys "
|
||||
"SET backfill_complete = FALSE, until_cursor = 0, events_fetched = 0, "
|
||||
" updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT") == 0);
|
||||
}
|
||||
if (reset_ok) {
|
||||
cJSON_AddStringToObject(response, "status", "success");
|
||||
cJSON_AddStringToObject(response, "message", "Backfill progress reset. The caching service will restart backfill from the beginning.");
|
||||
DEBUG_INFO("Caching backfill progress reset successfully");
|
||||
|
||||
@@ -36,6 +36,8 @@ typedef struct {
|
||||
char relay_privkey_override[65]; // Empty string = not set, 64-char hex = override
|
||||
int strict_port; // 0 = allow port increment, 1 = fail if exact port unavailable
|
||||
int debug_level; // 0-5, default 0 (no debug output)
|
||||
int start_caching; // 0 = don't auto-start, 1 = start caching service on startup
|
||||
int reset_backfill; // 0 = no, 1 = clear caching_backfill_progress before starting
|
||||
char db_connstring_override[1024];
|
||||
char db_host[128];
|
||||
int db_port; // 0 = not set
|
||||
|
||||
@@ -26,6 +26,10 @@ int db_worker_listen(void* connection, const char* channel) {
|
||||
int db_worker_poll_notify(void* connection, int timeout_ms) {
|
||||
return postgres_db_worker_poll_notify(connection, timeout_ms);
|
||||
}
|
||||
int db_worker_poll_notify_with_payload(void* connection, int timeout_ms,
|
||||
char** out_channel, char** out_payload) {
|
||||
return postgres_db_worker_poll_notify_with_payload(connection, timeout_ms, out_channel, out_payload);
|
||||
}
|
||||
|
||||
int db_prepare(const char* sql, db_stmt_t** out_stmt) {
|
||||
return postgres_db_prepare(sql, (postgres_db_stmt_t**)out_stmt);
|
||||
@@ -178,11 +182,26 @@ int db_caching_inbox_pending_counts(int* out_live_count, int* out_backfill_count
|
||||
int db_caching_inbox_oldest_age(int* out_oldest_age_seconds) {
|
||||
return postgres_db_caching_inbox_oldest_age(out_oldest_age_seconds);
|
||||
}
|
||||
int db_caching_backfill_author_counts(int* out_complete, int* out_total) {
|
||||
return postgres_db_caching_backfill_author_counts(out_complete, out_total);
|
||||
}
|
||||
int db_caching_inbox_insert(const char* event_id, const char* event_json,
|
||||
const char* source_relay, const char* source_class,
|
||||
int priority) {
|
||||
return postgres_db_caching_inbox_insert(event_id, event_json, source_relay, source_class, priority);
|
||||
}
|
||||
cJSON* db_get_profile_metadata(const char* pubkey) {
|
||||
return postgres_db_get_profile_metadata(pubkey);
|
||||
}
|
||||
cJSON* db_get_outbox_relays(const char* pubkey) {
|
||||
return postgres_db_get_outbox_relays(pubkey);
|
||||
}
|
||||
cJSON* db_get_profile(const char* pubkey) {
|
||||
return postgres_db_get_profile(pubkey);
|
||||
}
|
||||
cJSON* db_get_profiles(const char** pubkeys, int count) {
|
||||
return postgres_db_get_profiles(pubkeys, count);
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
@@ -206,6 +225,13 @@ int db_worker_listen(void* connection, const char* channel) {
|
||||
int db_worker_poll_notify(void* connection, int timeout_ms) {
|
||||
return sqlite_db_worker_poll_notify(connection, timeout_ms);
|
||||
}
|
||||
int db_worker_poll_notify_with_payload(void* connection, int timeout_ms,
|
||||
char** out_channel, char** out_payload) {
|
||||
(void)connection; (void)timeout_ms;
|
||||
if (out_channel) *out_channel = NULL;
|
||||
if (out_payload) *out_payload = NULL;
|
||||
return -1; /* SQLite has no LISTEN/NOTIFY */
|
||||
}
|
||||
|
||||
int db_prepare(const char* sql, db_stmt_t** out_stmt) {
|
||||
return sqlite_db_prepare(sql, (sqlite_db_stmt_t**)out_stmt);
|
||||
@@ -365,6 +391,11 @@ int db_caching_inbox_oldest_age(int* out_oldest_age_seconds) {
|
||||
if (out_oldest_age_seconds) *out_oldest_age_seconds = 0;
|
||||
return DB_ERROR;
|
||||
}
|
||||
int db_caching_backfill_author_counts(int* out_complete, int* out_total) {
|
||||
if (out_complete) *out_complete = 0;
|
||||
if (out_total) *out_total = 0;
|
||||
return DB_ERROR;
|
||||
}
|
||||
int db_caching_inbox_insert(const char* event_id, const char* event_json,
|
||||
const char* source_relay, const char* source_class,
|
||||
int priority) {
|
||||
@@ -372,4 +403,25 @@ int db_caching_inbox_insert(const char* event_id, const char* event_json,
|
||||
return DB_ERROR;
|
||||
}
|
||||
|
||||
cJSON* db_get_profile_metadata(const char* pubkey) {
|
||||
/* SQLite backend: kind-0 metadata not supported in this context.
|
||||
* The caching follows feature is PostgreSQL-only. */
|
||||
(void)pubkey;
|
||||
return NULL;
|
||||
}
|
||||
cJSON* db_get_outbox_relays(const char* pubkey) {
|
||||
(void)pubkey;
|
||||
return NULL;
|
||||
}
|
||||
cJSON* db_get_profile(const char* pubkey) {
|
||||
/* SQLite backend: profiles cache table is PostgreSQL-only. */
|
||||
(void)pubkey;
|
||||
return NULL;
|
||||
}
|
||||
cJSON* db_get_profiles(const char** pubkeys, int count) {
|
||||
(void)pubkeys;
|
||||
(void)count;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -28,6 +28,12 @@ void db_close_worker_connection(void* connection);
|
||||
// 0 on timeout, -1 on error/unsupported backend.
|
||||
int db_worker_listen(void* connection, const char* channel);
|
||||
int db_worker_poll_notify(void* connection, int timeout_ms);
|
||||
// db_worker_poll_notify_with_payload: like db_worker_poll_notify but also
|
||||
// returns the notification payload and channel via out params. The caller
|
||||
// must free *out_payload and *out_channel (both NULL on timeout/error).
|
||||
// Returns 1 if a notification was consumed, 0 on timeout, -1 on error.
|
||||
int db_worker_poll_notify_with_payload(void* connection, int timeout_ms,
|
||||
char** out_channel, char** out_payload);
|
||||
|
||||
// DB result codes (backend-agnostic)
|
||||
#define DB_OK 0
|
||||
@@ -133,8 +139,36 @@ int db_wal_checkpoint_truncate(void);
|
||||
cJSON* db_caching_inbox_dequeue_batch(int batch_size, int* out_count);
|
||||
int db_caching_inbox_pending_counts(int* out_live_count, int* out_backfill_count);
|
||||
int db_caching_inbox_oldest_age(int* out_oldest_age_seconds);
|
||||
|
||||
// Backfill author progress from caching_service_state (per-author until-cursor
|
||||
// drain model). Returns DB_OK and fills out_complete/out_total, or DB_ERROR.
|
||||
int db_caching_backfill_author_counts(int* out_complete, int* out_total);
|
||||
int db_caching_inbox_insert(const char* event_id, const char* event_json,
|
||||
const char* source_relay, const char* source_class,
|
||||
int priority);
|
||||
|
||||
// Profile metadata and outbox relay helpers.
|
||||
// db_get_profile_metadata: [DEPRECATED] returns the most recent kind-0 metadata
|
||||
// for a pubkey as a cJSON object with name/picture/about/nip05/display_name
|
||||
// fields parsed from the event content JSON. Returns NULL if no kind-0 event
|
||||
// exists. Caller must cJSON_Delete() the result.
|
||||
// Prefer db_get_profile() which reads from the profiles cache table.
|
||||
cJSON* db_get_profile_metadata(const char* pubkey);
|
||||
// db_get_outbox_relays: returns relay URLs from the most recent kind-10002
|
||||
// event for a pubkey, as a cJSON array of strings. Returns NULL if no
|
||||
// kind-10002 event exists. Caller must cJSON_Delete() the result.
|
||||
cJSON* db_get_outbox_relays(const char* pubkey);
|
||||
|
||||
// Profile cache (profiles table) lookups.
|
||||
// db_get_profile: returns a single cached profile as a cJSON object with
|
||||
// "pubkey", "name", "display_name", "best_name" (resolved per
|
||||
// profile_name_preference config), and optional "picture"/"nip05"/etc.
|
||||
// Returns NULL if no profile is cached. Caller must cJSON_Delete().
|
||||
cJSON* db_get_profile(const char* pubkey);
|
||||
// db_get_profiles: batch lookup for many pubkeys in one query. Returns a cJSON
|
||||
// object keyed by pubkey hex -> profile object (same fields as db_get_profile).
|
||||
// Pubkeys with no cached profile are absent from the result.
|
||||
// Caller must cJSON_Delete(). Returns NULL on error.
|
||||
cJSON* db_get_profiles(const char** pubkeys, int count);
|
||||
|
||||
#endif // DB_OPS_H
|
||||
|
||||
@@ -329,6 +329,62 @@ int postgres_db_worker_poll_notify(void* connection, int timeout_ms) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Like postgres_db_worker_poll_notify but also returns channel + payload.
|
||||
// Caller must free *out_channel and *out_payload (both NULL on timeout/error).
|
||||
int postgres_db_worker_poll_notify_with_payload(void* connection, int timeout_ms,
|
||||
char** out_channel, char** out_payload) {
|
||||
PGconn* conn = (PGconn*)connection;
|
||||
if (out_channel) *out_channel = NULL;
|
||||
if (out_payload) *out_payload = NULL;
|
||||
if (!conn || PQstatus(conn) != CONNECTION_OK) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// First, drain any already-queued notifications without blocking.
|
||||
PQconsumeInput(conn);
|
||||
PGnotify* notify = PQnotifies(conn);
|
||||
if (notify) {
|
||||
if (out_channel && notify->relname) *out_channel = strdup(notify->relname);
|
||||
if (out_payload && notify->extra) *out_payload = strdup(notify->extra);
|
||||
PQfreemem(notify);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int sock = PQsocket(conn);
|
||||
if (sock < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
fd_set fds;
|
||||
FD_ZERO(&fds);
|
||||
FD_SET(sock, &fds);
|
||||
|
||||
struct timeval tv;
|
||||
tv.tv_sec = timeout_ms / 1000;
|
||||
tv.tv_usec = (timeout_ms % 1000) * 1000;
|
||||
|
||||
int rc = select(sock + 1, &fds, NULL, NULL, &tv);
|
||||
if (rc < 0) {
|
||||
if (errno == EINTR) return 0;
|
||||
return -1;
|
||||
}
|
||||
if (rc == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (PQconsumeInput(conn) == 0) {
|
||||
return -1;
|
||||
}
|
||||
notify = PQnotifies(conn);
|
||||
if (notify) {
|
||||
if (out_channel && notify->relname) *out_channel = strdup(notify->relname);
|
||||
if (out_payload && notify->extra) *out_payload = strdup(notify->extra);
|
||||
PQfreemem(notify);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void postgres_db_close_worker_connection(void* connection) {
|
||||
PGconn* conn = (PGconn*)connection;
|
||||
if (!conn) return;
|
||||
@@ -2470,3 +2526,350 @@ int postgres_db_caching_inbox_insert(const char* event_id, const char* event_jso
|
||||
return DB_ERROR;
|
||||
#endif
|
||||
}
|
||||
|
||||
int postgres_db_caching_backfill_author_counts(int* out_complete, int* out_total) {
|
||||
#if defined(DB_BACKEND_POSTGRES) && defined(HAVE_LIBPQ)
|
||||
if (out_complete) *out_complete = 0;
|
||||
if (out_total) *out_total = 0;
|
||||
|
||||
PGconn* conn = postgres_db_active_connection();
|
||||
if (!conn || PQstatus(conn) != CONNECTION_OK) return DB_ERROR;
|
||||
|
||||
PGresult* res = PQexec(conn,
|
||||
"SELECT backfill_authors_complete, backfill_authors_total "
|
||||
"FROM caching_service_state WHERE id = 1");
|
||||
if (!res || PQresultStatus(res) != PGRES_TUPLES_OK) {
|
||||
if (res) {
|
||||
postgres_set_error_text(PQresultErrorMessage(res));
|
||||
PQclear(res);
|
||||
} else {
|
||||
postgres_set_error_from_conn(conn, "postgres_db_caching_backfill_author_counts failed");
|
||||
}
|
||||
return DB_ERROR;
|
||||
}
|
||||
|
||||
if (PQntuples(res) > 0) {
|
||||
if (out_complete) {
|
||||
*out_complete = PQgetisnull(res, 0, 0) ? 0
|
||||
: (int)strtol(PQgetvalue(res, 0, 0), NULL, 10);
|
||||
}
|
||||
if (out_total) {
|
||||
*out_total = PQgetisnull(res, 0, 1) ? 0
|
||||
: (int)strtol(PQgetvalue(res, 0, 1), NULL, 10);
|
||||
}
|
||||
}
|
||||
|
||||
PQclear(res);
|
||||
return DB_OK;
|
||||
#else
|
||||
if (out_complete) *out_complete = 0;
|
||||
if (out_total) *out_total = 0;
|
||||
return DB_ERROR;
|
||||
#endif
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Profile metadata and outbox relay helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Returns the most recent kind-0 metadata for a pubkey as a cJSON object
|
||||
* with name/picture/about/nip05/display_name fields parsed from the event
|
||||
* content JSON. Returns NULL if no kind-0 event exists or on error.
|
||||
* Caller must cJSON_Delete() the result. */
|
||||
cJSON* postgres_db_get_profile_metadata(const char* pubkey) {
|
||||
#if defined(DB_BACKEND_POSTGRES) && defined(HAVE_LIBPQ)
|
||||
if (!pubkey || pubkey[0] == '\0') return NULL;
|
||||
PGconn* conn = postgres_db_active_connection();
|
||||
if (!conn || PQstatus(conn) != CONNECTION_OK) return NULL;
|
||||
|
||||
const char* params[1] = { pubkey };
|
||||
PGresult* res = PQexecParams(conn,
|
||||
"SELECT content FROM events WHERE pubkey = $1 AND kind = 0 "
|
||||
"ORDER BY created_at DESC LIMIT 1",
|
||||
1, NULL, params, NULL, NULL, 0);
|
||||
if (!res || PQresultStatus(res) != PGRES_TUPLES_OK) {
|
||||
if (res) PQclear(res);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON* result = NULL;
|
||||
if (PQntuples(res) > 0 && !PQgetisnull(res, 0, 0)) {
|
||||
const char* content = PQgetvalue(res, 0, 0);
|
||||
if (content && content[0] != '\0') {
|
||||
/* Parse the kind-0 content JSON and extract known fields. */
|
||||
cJSON* parsed = cJSON_Parse(content);
|
||||
if (parsed) {
|
||||
result = cJSON_CreateObject();
|
||||
if (!result) {
|
||||
cJSON_Delete(parsed);
|
||||
PQclear(res);
|
||||
return NULL;
|
||||
}
|
||||
/* Extract standard NIP-01 metadata fields. */
|
||||
const char* fields[] = {"name", "display_name", "picture",
|
||||
"about", "nip05", "website", "lud16", "lud06"};
|
||||
for (size_t i = 0; i < sizeof(fields) / sizeof(fields[0]); i++) {
|
||||
cJSON* val = cJSON_GetObjectItemCaseSensitive(parsed, fields[i]);
|
||||
if (val && cJSON_IsString(val) && val->valuestring[0] != '\0') {
|
||||
cJSON_AddStringToObject(result, fields[i], val->valuestring);
|
||||
}
|
||||
}
|
||||
cJSON_Delete(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PQclear(res);
|
||||
return result;
|
||||
#else
|
||||
(void)pubkey;
|
||||
return NULL;
|
||||
#endif
|
||||
}
|
||||
|
||||
/* Returns relay URLs from the most recent kind-10002 event for a pubkey,
|
||||
* as a cJSON array of strings. Returns NULL if no kind-10002 event exists
|
||||
* or on error. Caller must cJSON_Delete() the result. */
|
||||
cJSON* postgres_db_get_outbox_relays(const char* pubkey) {
|
||||
#if defined(DB_BACKEND_POSTGRES) && defined(HAVE_LIBPQ)
|
||||
if (!pubkey || pubkey[0] == '\0') return NULL;
|
||||
PGconn* conn = postgres_db_active_connection();
|
||||
if (!conn || PQstatus(conn) != CONNECTION_OK) return NULL;
|
||||
|
||||
const char* params[1] = { pubkey };
|
||||
PGresult* res = PQexecParams(conn,
|
||||
"SELECT tags FROM events WHERE pubkey = $1 AND kind = 10002 "
|
||||
"ORDER BY created_at DESC LIMIT 1",
|
||||
1, NULL, params, NULL, NULL, 0);
|
||||
if (!res || PQresultStatus(res) != PGRES_TUPLES_OK) {
|
||||
if (res) PQclear(res);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON* result = NULL;
|
||||
if (PQntuples(res) > 0 && !PQgetisnull(res, 0, 0)) {
|
||||
const char* tags_json = PQgetvalue(res, 0, 0);
|
||||
if (tags_json && tags_json[0] != '\0') {
|
||||
cJSON* tags = cJSON_Parse(tags_json);
|
||||
if (tags && cJSON_IsArray(tags)) {
|
||||
result = cJSON_CreateArray();
|
||||
if (!result) {
|
||||
cJSON_Delete(tags);
|
||||
PQclear(res);
|
||||
return NULL;
|
||||
}
|
||||
int n = cJSON_GetArraySize(tags);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* tag = cJSON_GetArrayItem(tags, i);
|
||||
if (!tag || !cJSON_IsArray(tag)) continue;
|
||||
int tag_len = cJSON_GetArraySize(tag);
|
||||
if (tag_len < 2) continue;
|
||||
cJSON* tag_name = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON* tag_val = cJSON_GetArrayItem(tag, 1);
|
||||
if (tag_name && cJSON_IsString(tag_name) &&
|
||||
strcmp(tag_name->valuestring, "r") == 0 &&
|
||||
tag_val && cJSON_IsString(tag_val) &&
|
||||
tag_val->valuestring[0] != '\0') {
|
||||
/* Check for a marker (3rd element) - skip "read" only
|
||||
* relays, keep "write" and unmarked (both) relays. */
|
||||
int is_write = 1;
|
||||
if (tag_len >= 3) {
|
||||
cJSON* marker = cJSON_GetArrayItem(tag, 2);
|
||||
if (marker && cJSON_IsString(marker) &&
|
||||
strcmp(marker->valuestring, "read") == 0) {
|
||||
is_write = 0;
|
||||
}
|
||||
}
|
||||
if (is_write) {
|
||||
cJSON_AddItemToArray(result,
|
||||
cJSON_CreateString(tag_val->valuestring));
|
||||
}
|
||||
}
|
||||
}
|
||||
cJSON_Delete(tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PQclear(res);
|
||||
return result;
|
||||
#else
|
||||
(void)pubkey;
|
||||
return NULL;
|
||||
#endif
|
||||
}
|
||||
|
||||
/* ---- Profile cache (profiles table) -------------------------------------- */
|
||||
|
||||
/* Forward declaration for config access (defined in config.c). */
|
||||
const char* get_config_value(const char* key);
|
||||
|
||||
/* Resolve the display name per the profile_name_preference config key.
|
||||
* Returns display_name if preference is "display_name" (default) and it is
|
||||
* non-empty, else name. If preference is "name", returns name if non-empty,
|
||||
* else display_name. Returns "" if neither is set. Never returns NULL. */
|
||||
static const char* profile_resolve_best_name(const char* name,
|
||||
const char* display_name) {
|
||||
if (!name) name = "";
|
||||
if (!display_name) display_name = "";
|
||||
|
||||
const char* pref = get_config_value("profile_name_preference");
|
||||
if (pref && strcmp(pref, "name") == 0) {
|
||||
/* Prefer name, fall back to display_name. */
|
||||
if (name[0] != '\0') return name;
|
||||
return display_name;
|
||||
}
|
||||
/* Default: prefer display_name, fall back to name. */
|
||||
if (display_name[0] != '\0') return display_name;
|
||||
return name;
|
||||
}
|
||||
|
||||
/* Build a profile cJSON object from a PGresult row. The columns must be in
|
||||
* the order: pubkey, name, display_name, about, picture, banner, nip05,
|
||||
* website, lud16, lud06. Returns a new object (caller owns) or NULL. */
|
||||
static cJSON* profile_obj_from_pgrow(PGresult* res, int row) {
|
||||
cJSON* obj = cJSON_CreateObject();
|
||||
if (!obj) return NULL;
|
||||
|
||||
const char* pubkey = PQgetvalue(res, row, 0);
|
||||
const char* name = PQgetisnull(res, row, 1) ? "" : PQgetvalue(res, row, 1);
|
||||
const char* display_name = PQgetisnull(res, row, 2) ? "" : PQgetvalue(res, row, 2);
|
||||
|
||||
cJSON_AddStringToObject(obj, "pubkey", pubkey ? pubkey : "");
|
||||
cJSON_AddStringToObject(obj, "name", name);
|
||||
cJSON_AddStringToObject(obj, "display_name", display_name);
|
||||
cJSON_AddStringToObject(obj, "best_name",
|
||||
profile_resolve_best_name(name, display_name));
|
||||
|
||||
/* Optional fields — only add when non-empty. */
|
||||
const char* opt_fields[] = {"about", "picture", "banner", "nip05",
|
||||
"website", "lud16", "lud06"};
|
||||
for (size_t i = 0; i < sizeof(opt_fields) / sizeof(opt_fields[0]); i++) {
|
||||
int col = (int)(i + 3);
|
||||
if (!PQgetisnull(res, row, col)) {
|
||||
const char* val = PQgetvalue(res, row, col);
|
||||
if (val && val[0] != '\0') {
|
||||
cJSON_AddStringToObject(obj, opt_fields[i], val);
|
||||
}
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
/* Returns a single cached profile from the profiles table, or NULL if no
|
||||
* profile is cached or on error. Caller must cJSON_Delete(). */
|
||||
cJSON* postgres_db_get_profile(const char* pubkey) {
|
||||
#if defined(DB_BACKEND_POSTGRES) && defined(HAVE_LIBPQ)
|
||||
if (!pubkey || pubkey[0] == '\0') return NULL;
|
||||
PGconn* conn = postgres_db_active_connection();
|
||||
if (!conn || PQstatus(conn) != CONNECTION_OK) return NULL;
|
||||
|
||||
const char* params[1] = { pubkey };
|
||||
PGresult* res = PQexecParams(conn,
|
||||
"SELECT pubkey, name, display_name, about, picture, banner, "
|
||||
"nip05, website, lud16, lud06 FROM profiles WHERE pubkey = $1",
|
||||
1, NULL, params, NULL, NULL, 0);
|
||||
if (!res || PQresultStatus(res) != PGRES_TUPLES_OK) {
|
||||
if (res) PQclear(res);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON* result = NULL;
|
||||
if (PQntuples(res) > 0) {
|
||||
result = profile_obj_from_pgrow(res, 0);
|
||||
}
|
||||
PQclear(res);
|
||||
return result;
|
||||
#else
|
||||
(void)pubkey;
|
||||
return NULL;
|
||||
#endif
|
||||
}
|
||||
|
||||
/* Batch lookup: one query for many pubkeys. Returns a cJSON object keyed by
|
||||
* pubkey hex -> profile object. Pubkeys with no cached profile are absent.
|
||||
* Caller must cJSON_Delete(). Returns NULL on error. */
|
||||
cJSON* postgres_db_get_profiles(const char** pubkeys, int count) {
|
||||
#if defined(DB_BACKEND_POSTGRES) && defined(HAVE_LIBPQ)
|
||||
if (!pubkeys || count <= 0) return NULL;
|
||||
PGconn* conn = postgres_db_active_connection();
|
||||
if (!conn || PQstatus(conn) != CONNECTION_OK) return NULL;
|
||||
|
||||
/* Build a PostgreSQL array literal: {pk1,pk2,...}
|
||||
* Each pubkey is 64 hex chars (no quotes needed), but we validate to
|
||||
* prevent SQL injection since we're building the literal manually. */
|
||||
size_t buf_size = (size_t)count * 66 + 4;
|
||||
char* array_lit = (char*)malloc(buf_size);
|
||||
if (!array_lit) return NULL;
|
||||
size_t pos = 0;
|
||||
array_lit[pos++] = '{';
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (!pubkeys[i] || strlen(pubkeys[i]) != 64) {
|
||||
/* Skip invalid pubkeys rather than including them. */
|
||||
continue;
|
||||
}
|
||||
/* Validate hex characters. */
|
||||
int valid = 1;
|
||||
for (int j = 0; j < 64; j++) {
|
||||
char c = pubkeys[i][j];
|
||||
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') ||
|
||||
(c >= 'A' && c <= 'F'))) {
|
||||
valid = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!valid) continue;
|
||||
if (pos > 1) array_lit[pos++] = ',';
|
||||
memcpy(array_lit + pos, pubkeys[i], 64);
|
||||
pos += 64;
|
||||
}
|
||||
array_lit[pos++] = '}';
|
||||
array_lit[pos] = '\0';
|
||||
|
||||
/* If no valid pubkeys, return an empty object. */
|
||||
if (pos <= 2) {
|
||||
free(array_lit);
|
||||
cJSON* empty = cJSON_CreateObject();
|
||||
return empty;
|
||||
}
|
||||
|
||||
const char* params[1] = { array_lit };
|
||||
PGresult* res = PQexecParams(conn,
|
||||
"SELECT pubkey, name, display_name, about, picture, banner, "
|
||||
"nip05, website, lud16, lud06 FROM profiles "
|
||||
"WHERE pubkey = ANY($1::text[])",
|
||||
1, NULL, params, NULL, NULL, 0);
|
||||
free(array_lit);
|
||||
|
||||
if (!res || PQresultStatus(res) != PGRES_TUPLES_OK) {
|
||||
if (res) PQclear(res);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON* result = cJSON_CreateObject();
|
||||
if (!result) {
|
||||
PQclear(res);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int n = PQntuples(res);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* obj = profile_obj_from_pgrow(res, i);
|
||||
if (obj) {
|
||||
const char* pk = PQgetvalue(res, i, 0);
|
||||
if (pk && pk[0] != '\0') {
|
||||
cJSON_AddItemToObject(result, pk, obj);
|
||||
} else {
|
||||
cJSON_Delete(obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PQclear(res);
|
||||
return result;
|
||||
#else
|
||||
(void)pubkeys;
|
||||
(void)count;
|
||||
return NULL;
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ void postgres_db_close_worker_connection(void* connection);
|
||||
|
||||
int postgres_db_worker_listen(void* connection, const char* channel);
|
||||
int postgres_db_worker_poll_notify(void* connection, int timeout_ms);
|
||||
int postgres_db_worker_poll_notify_with_payload(void* connection, int timeout_ms,
|
||||
char** out_channel, char** out_payload);
|
||||
|
||||
int postgres_db_prepare(const char* sql, postgres_db_stmt_t** out_stmt);
|
||||
int postgres_db_bind_text_param(postgres_db_stmt_t* stmt, int index, const char* value);
|
||||
@@ -102,8 +104,17 @@ int postgres_db_wal_checkpoint_truncate(void);
|
||||
cJSON* postgres_db_caching_inbox_dequeue_batch(int batch_size, int* out_count);
|
||||
int postgres_db_caching_inbox_pending_counts(int* out_live_count, int* out_backfill_count);
|
||||
int postgres_db_caching_inbox_oldest_age(int* out_oldest_age_seconds);
|
||||
int postgres_db_caching_backfill_author_counts(int* out_complete, int* out_total);
|
||||
int postgres_db_caching_inbox_insert(const char* event_id, const char* event_json,
|
||||
const char* source_relay, const char* source_class,
|
||||
int priority);
|
||||
|
||||
// Profile metadata and outbox relay helpers.
|
||||
cJSON* postgres_db_get_profile_metadata(const char* pubkey);
|
||||
cJSON* postgres_db_get_outbox_relays(const char* pubkey);
|
||||
|
||||
// Profile cache (profiles table) lookups.
|
||||
cJSON* postgres_db_get_profile(const char* pubkey);
|
||||
cJSON* postgres_db_get_profiles(const char** pubkeys, int count);
|
||||
|
||||
#endif // DB_OPS_POSTGRES_H
|
||||
|
||||
@@ -148,7 +148,7 @@ static const struct {
|
||||
{"caching_live_resubscribe_seconds", "300"}, // Live subscription resubscribe interval
|
||||
{"caching_backfill_enabled", "true"}, // Enable historical backfill
|
||||
{"caching_backfill_windows", "86400,604800,2592000,7776000,31536000"}, // Backfill window schedule in seconds
|
||||
{"caching_backfill_page_size", "50"}, // Initial backfill query page size
|
||||
{"caching_backfill_page_size", "500"}, // Initial backfill query page size
|
||||
{"caching_backfill_tick_interval_ms", "5000"}, // Minimum delay between backfill queries
|
||||
{"caching_backfill_window_cooldown_seconds", "60"}, // Delay between completed windows
|
||||
{"caching_follow_graph_refresh_seconds", "600"}, // Kind-3 follow graph refresh interval
|
||||
@@ -158,7 +158,7 @@ static const struct {
|
||||
{"caching_max_relays_per_pubkey", "5"}, // Maximum outbox relays per author
|
||||
{"caching_query_timeout_ms", "15000"}, // Upstream query timeout
|
||||
{"caching_inbox_enabled", "false"}, // Enable inbox poller in c-relay-pg
|
||||
{"caching_inbox_batch_size", "25"}, // Inbox dequeue batch size
|
||||
{"caching_inbox_batch_size", "150"}, // Inbox dequeue batch size
|
||||
{"caching_inbox_active_poll_ms", "200"}, // Poll interval when inbox has events
|
||||
{"caching_inbox_idle_poll_ms", "5000"}, // Poll interval when inbox is empty
|
||||
{"caching_max_event_json_bytes", "65536"}, // Maximum event JSON size for inbox insert
|
||||
|
||||
File diff suppressed because one or more lines are too long
+85
-3
@@ -2299,6 +2299,8 @@ void print_usage(const char* program_name) {
|
||||
printf(" --db-name NAME PostgreSQL database name (default: crelay)\n");
|
||||
printf(" --db-user USER PostgreSQL user (default: crelay)\n");
|
||||
printf(" --db-password PASS PostgreSQL password\n");
|
||||
printf(" --start-caching Auto-start the caching service on startup\n");
|
||||
printf(" --reset-backfill Clear caching_backfill_progress before starting (use with --start-caching)\n");
|
||||
printf("\n");
|
||||
printf("Configuration:\n");
|
||||
printf(" This relay uses event-based configuration stored in the database.\n");
|
||||
@@ -2338,7 +2340,9 @@ int main(int argc, char* argv[]) {
|
||||
.admin_pubkey_override = {0}, // Empty string = not set
|
||||
.relay_privkey_override = {0}, // Empty string = not set
|
||||
.strict_port = 0, // 0 = allow port increment (default)
|
||||
.debug_level = 0 // 0 = no debug output (default)
|
||||
.debug_level = 0, // 0 = no debug output (default)
|
||||
.start_caching = 0, // 0 = don't auto-start caching service
|
||||
.reset_backfill = 0 // 0 = don't reset backfill progress
|
||||
};
|
||||
|
||||
// Parse command line arguments
|
||||
@@ -2565,6 +2569,12 @@ int main(int argc, char* argv[]) {
|
||||
} else if (strcmp(argv[i], "--strict-port") == 0) {
|
||||
// Strict port mode option
|
||||
cli_options.strict_port = 1;
|
||||
} else if (strcmp(argv[i], "--start-caching") == 0) {
|
||||
// Auto-start the caching service on startup
|
||||
cli_options.start_caching = 1;
|
||||
} else if (strcmp(argv[i], "--reset-backfill") == 0) {
|
||||
// Clear caching_backfill_progress before starting caching service
|
||||
cli_options.reset_backfill = 1;
|
||||
} else if (strncmp(argv[i], "--debug-level=", 14) == 0) {
|
||||
// Debug level option
|
||||
char* endptr;
|
||||
@@ -2805,6 +2815,15 @@ int main(int argc, char* argv[]) {
|
||||
}
|
||||
DEBUG_INFO("Synchronized relay_version to %s", CRELAY_VERSION);
|
||||
|
||||
// Insert any config keys that were added in newer versions but are
|
||||
// missing from this existing database (e.g. caching_* keys added after
|
||||
// the relay was first deployed). This only inserts MISSING keys —
|
||||
// existing values are preserved.
|
||||
int added = populate_default_config_values();
|
||||
if (added > 0) {
|
||||
DEBUG_INFO("Inserted %d missing default config keys", added);
|
||||
}
|
||||
|
||||
// Apply CLI overrides atomically (now that database is initialized)
|
||||
if (apply_cli_overrides_atomic(&cli_options) != 0) {
|
||||
DEBUG_ERROR("Failed to apply CLI overrides for existing relay");
|
||||
@@ -2911,8 +2930,27 @@ int main(int argc, char* argv[]) {
|
||||
// Initialize kind-based index for fast subscription lookup
|
||||
init_kind_index();
|
||||
|
||||
// Populate event_tags from existing events (for tag-based lookups)
|
||||
populate_event_tags_from_existing();
|
||||
// Populate event_tags from existing events (for tag-based lookups).
|
||||
// This is a one-time backfill guarded by a config flag so it does NOT
|
||||
// run on every startup — on large databases (tens of GB) the
|
||||
// CROSS JOIN LATERAL jsonb_array_elements over every event can take a
|
||||
// very long time and blocks the relay from binding its WebSocket port.
|
||||
{
|
||||
char* tags_backfilled = db_get_config_value_dup("event_tags_backfilled");
|
||||
if (!tags_backfilled || strcmp(tags_backfilled, "true") != 0) {
|
||||
DEBUG_INFO("One-time event_tags backfill: populating from existing events...");
|
||||
populate_event_tags_from_existing();
|
||||
/* Record completion in the config table so we skip it on future
|
||||
* startups. db_set_config_value_full(key, value, type, ...). */
|
||||
db_set_config_value_full("event_tags_backfilled", "true", "boolean",
|
||||
"Internal flag: event_tags backfill completed",
|
||||
"internal", 0);
|
||||
DEBUG_INFO("event_tags backfill complete.");
|
||||
} else {
|
||||
DEBUG_LOG("event_tags backfill already done, skipping.");
|
||||
}
|
||||
if (tags_backfilled) free(tags_backfilled);
|
||||
}
|
||||
|
||||
// Sync Web of Trust whitelist if enabled
|
||||
int wot_level = get_config_int("wot_enabled", 0);
|
||||
@@ -2956,6 +2994,50 @@ int main(int argc, char* argv[]) {
|
||||
// Runs on the main lws service thread via caching_inbox_poller_tick().
|
||||
caching_inbox_poller_init();
|
||||
|
||||
// Auto-start the caching service if requested via --start-caching.
|
||||
// Optionally reset backfill progress first if --reset-backfill was passed.
|
||||
if (cli_options.start_caching) {
|
||||
#ifdef DB_BACKEND_POSTGRES
|
||||
/* Ensure caching_enabled and caching_inbox_enabled are set to true
|
||||
* in the config table so the UI reflects the actual state and the
|
||||
* inbox poller is active. We call db_update_config_value_only()
|
||||
* directly (bypassing the config-layer wrapper) so we must also
|
||||
* invalidate the in-memory config cache — otherwise the poller's
|
||||
* get_config_bool("caching_inbox_enabled") will keep returning
|
||||
* the stale pre-update value and never dequeue any events. */
|
||||
db_update_config_value_only("caching_enabled", "true");
|
||||
db_update_config_value_only("caching_inbox_enabled", "true");
|
||||
invalidate_config_cache();
|
||||
DEBUG_INFO("CLI: --start-caching: set caching_enabled=true, caching_inbox_enabled=true");
|
||||
|
||||
if (cli_options.reset_backfill) {
|
||||
DEBUG_INFO("CLI: resetting caching backfill progress (--reset-backfill)");
|
||||
/* Clear both the legacy window table and the per-relay drain table,
|
||||
* and mark all followed authors as incomplete. */
|
||||
int rb_ok = (db_exec_sql("DELETE FROM caching_backfill_progress") == 0);
|
||||
if (rb_ok) rb_ok = (db_exec_sql("DELETE FROM caching_backfill_relay_progress") == 0);
|
||||
if (rb_ok) rb_ok = (db_exec_sql(
|
||||
"UPDATE caching_followed_pubkeys "
|
||||
"SET backfill_complete = FALSE, until_cursor = 0, events_fetched = 0, "
|
||||
" updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT") == 0);
|
||||
if (rb_ok) {
|
||||
DEBUG_INFO("CLI: caching backfill progress cleared (all tables reset)");
|
||||
} else {
|
||||
DEBUG_WARN("CLI: failed to clear caching backfill progress");
|
||||
}
|
||||
}
|
||||
DEBUG_INFO("CLI: auto-starting caching service (--start-caching)");
|
||||
int caching_rc = caching_service_start();
|
||||
if (caching_rc == 0) {
|
||||
DEBUG_INFO("CLI: caching service started successfully");
|
||||
} else {
|
||||
DEBUG_ERROR("CLI: failed to start caching service (rc=%d)", caching_rc);
|
||||
}
|
||||
#else
|
||||
DEBUG_WARN("CLI: --start-caching requires PostgreSQL backend");
|
||||
#endif
|
||||
}
|
||||
|
||||
// Phase 5 strict mode: remove main-thread fallback to global DB handle.
|
||||
// Runtime DB access must go through thread-bound worker connections.
|
||||
// Ownership now resides in db_ops.c; no direct global handle mutation here.
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
// Using CRELAY_ prefix to avoid conflicts with nostr_core_lib VERSION macros
|
||||
#define CRELAY_VERSION_MAJOR 2
|
||||
#define CRELAY_VERSION_MINOR 1
|
||||
#define CRELAY_VERSION_PATCH 21
|
||||
#define CRELAY_VERSION "v2.1.21"
|
||||
#define CRELAY_VERSION_PATCH 28
|
||||
#define CRELAY_VERSION "v2.1.28"
|
||||
|
||||
// Relay metadata (authoritative source for NIP-11 information)
|
||||
#define RELAY_NAME "C-Relay-PG"
|
||||
|
||||
+262
-2
@@ -1,7 +1,7 @@
|
||||
#ifndef PG_SCHEMA_H
|
||||
#define PG_SCHEMA_H
|
||||
|
||||
#define EMBEDDED_PG_SCHEMA_VERSION "5"
|
||||
#define EMBEDDED_PG_SCHEMA_VERSION "6"
|
||||
|
||||
static const char* const EMBEDDED_PG_SCHEMA_SQL =
|
||||
"-- C-Relay-PG PostgreSQL Schema\n"
|
||||
@@ -380,7 +380,7 @@ static const char* const EMBEDDED_PG_SCHEMA_SQL =
|
||||
"WHERE created_at >= (EXTRACT(EPOCH FROM NOW())::BIGINT - 2592000);\n"
|
||||
"\n"
|
||||
"INSERT INTO schema_info(key, value, updated_at)\n"
|
||||
"VALUES ('version', '5', EXTRACT(EPOCH FROM NOW())::BIGINT)\n"
|
||||
"VALUES ('version', '6', EXTRACT(EPOCH FROM NOW())::BIGINT)\n"
|
||||
"ON CONFLICT (key) DO UPDATE SET\n"
|
||||
" value = EXCLUDED.value,\n"
|
||||
" updated_at = EXCLUDED.updated_at;\n"
|
||||
@@ -461,6 +461,61 @@ static const char* const EMBEDDED_PG_SCHEMA_SQL =
|
||||
" ON caching_backfill_progress(window_index)\n"
|
||||
" WHERE complete = FALSE;\n"
|
||||
"\n"
|
||||
"-- Durable followed-pubkey list with per-author backfill cursor state.\n"
|
||||
"-- Replaces the window-coupled caching_backfill_progress table for the\n"
|
||||
"-- per-author until-cursor drain backfill model.\n"
|
||||
"CREATE TABLE IF NOT EXISTS caching_followed_pubkeys (\n"
|
||||
" pubkey TEXT PRIMARY KEY,\n"
|
||||
" is_root BOOLEAN NOT NULL DEFAULT FALSE,\n"
|
||||
" until_cursor BIGINT NOT NULL DEFAULT 0,\n"
|
||||
" backfill_complete BOOLEAN NOT NULL DEFAULT FALSE,\n"
|
||||
" events_fetched INTEGER NOT NULL DEFAULT 0,\n"
|
||||
" first_seen BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,\n"
|
||||
" last_seen BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,\n"
|
||||
" updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT\n"
|
||||
");\n"
|
||||
"\n"
|
||||
"-- last_event_at: created_at of the most recent event we've seen for this\n"
|
||||
"-- author. Used by the forward catch-up to bridge gaps when caching resumes\n"
|
||||
"-- after being off. Seeded from the events table on upgrade.\n"
|
||||
"ALTER TABLE caching_followed_pubkeys\n"
|
||||
" ADD COLUMN IF NOT EXISTS last_event_at BIGINT NOT NULL DEFAULT 0;\n"
|
||||
"\n"
|
||||
"UPDATE caching_followed_pubkeys fp\n"
|
||||
" SET last_event_at = COALESCE(\n"
|
||||
" (SELECT MAX(e.created_at) FROM events e WHERE e.pubkey = fp.pubkey),\n"
|
||||
" 0\n"
|
||||
" )\n"
|
||||
" WHERE fp.last_event_at = 0;\n"
|
||||
"\n"
|
||||
"CREATE INDEX IF NOT EXISTS idx_caching_followed_incomplete\n"
|
||||
" ON caching_followed_pubkeys(backfill_complete, last_seen)\n"
|
||||
" WHERE backfill_complete = FALSE;\n"
|
||||
"\n"
|
||||
"-- Per-relay backfill cursor tracking for the relay-by-relay drain model.\n"
|
||||
"-- Each (author, relay) pair has its own until_cursor and completion flag so\n"
|
||||
"-- slow relays do not cause premature completion marking for the author.\n"
|
||||
"CREATE TABLE IF NOT EXISTS caching_backfill_relay_progress (\n"
|
||||
" author_pubkey TEXT NOT NULL,\n"
|
||||
" relay_url TEXT NOT NULL,\n"
|
||||
" until_cursor BIGINT NOT NULL DEFAULT 0,\n"
|
||||
" complete BOOLEAN NOT NULL DEFAULT FALSE,\n"
|
||||
" events_fetched INTEGER NOT NULL DEFAULT 0,\n"
|
||||
" updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,\n"
|
||||
" PRIMARY KEY (author_pubkey, relay_url)\n"
|
||||
");\n"
|
||||
"\n"
|
||||
"CREATE INDEX IF NOT EXISTS idx_caching_relay_progress_incomplete\n"
|
||||
" ON caching_backfill_relay_progress(author_pubkey)\n"
|
||||
" WHERE complete = FALSE;\n"
|
||||
"\n"
|
||||
"-- Replace window-based progress columns with author-based progress.\n"
|
||||
"ALTER TABLE caching_service_state\n"
|
||||
" DROP COLUMN IF EXISTS current_window_index,\n"
|
||||
" DROP COLUMN IF EXISTS backfill_cursor,\n"
|
||||
" ADD COLUMN IF NOT EXISTS backfill_authors_complete INTEGER NOT NULL DEFAULT 0,\n"
|
||||
" ADD COLUMN IF NOT EXISTS backfill_authors_total INTEGER NOT NULL DEFAULT 0;\n"
|
||||
"\n"
|
||||
"-- =====================================================================\n"
|
||||
"-- LISTEN/NOTIFY support for api-worker monitoring (Phase 5)\n"
|
||||
"-- Fires a notification on the 'event_stored' channel whenever a new event\n"
|
||||
@@ -483,6 +538,211 @@ static const char* const EMBEDDED_PG_SCHEMA_SQL =
|
||||
" AFTER INSERT ON events\n"
|
||||
" FOR EACH ROW EXECUTE FUNCTION notify_event_stored();\n"
|
||||
"\n"
|
||||
"-- =====================================================================\n"
|
||||
"-- Config change notification: fires pg_notify('config_changed', key)\n"
|
||||
"-- whenever a config row is updated, so the relay can react to\n"
|
||||
"-- caching_enabled / caching_inbox_enabled toggles from the PHP admin\n"
|
||||
"-- without polling.\n"
|
||||
"-- =====================================================================\n"
|
||||
"CREATE OR REPLACE FUNCTION notify_config_changed() RETURNS trigger AS $$\n"
|
||||
"BEGIN\n"
|
||||
" PERFORM pg_notify('config_changed', NEW.key);\n"
|
||||
" RETURN NEW;\n"
|
||||
"END;\n"
|
||||
"$$ LANGUAGE plpgsql;\n"
|
||||
"\n"
|
||||
"DROP TRIGGER IF EXISTS trg_notify_config_changed ON config;\n"
|
||||
"CREATE TRIGGER trg_notify_config_changed\n"
|
||||
" AFTER UPDATE ON config\n"
|
||||
" FOR EACH ROW EXECUTE FUNCTION notify_config_changed();\n"
|
||||
"\n"
|
||||
"-- =====================================================================\n"
|
||||
"-- Profile cache (kind-0 metadata projection)\n"
|
||||
"-- One row per pubkey, kept in sync with the latest kind-0 event via\n"
|
||||
"-- triggers. Stores both `name` and `display_name` verbatim; the display\n"
|
||||
"-- preference is a runtime config key (profile_name_preference), not a\n"
|
||||
"-- generated column, so it can be changed without a migration.\n"
|
||||
"-- =====================================================================\n"
|
||||
"\n"
|
||||
"-- Safe JSONB parse: returns NULL on malformed input instead of raising.\n"
|
||||
"-- IMMUTABLE so it can be used in generated expressions and backfills.\n"
|
||||
"CREATE OR REPLACE FUNCTION safe_jsonb(input text) RETURNS jsonb AS $$\n"
|
||||
"DECLARE\n"
|
||||
" result jsonb;\n"
|
||||
"BEGIN\n"
|
||||
" IF input IS NULL THEN\n"
|
||||
" RETURN NULL;\n"
|
||||
" END IF;\n"
|
||||
" BEGIN\n"
|
||||
" result := input::jsonb;\n"
|
||||
" IF jsonb_typeof(result) <> 'object' THEN\n"
|
||||
" RETURN NULL;\n"
|
||||
" END IF;\n"
|
||||
" RETURN result;\n"
|
||||
" EXCEPTION WHEN others THEN\n"
|
||||
" RETURN NULL;\n"
|
||||
" END;\n"
|
||||
"END;\n"
|
||||
"$$ LANGUAGE plpgsql IMMUTABLE;\n"
|
||||
"\n"
|
||||
"-- Sanitize a text value for safe storage in a PostgreSQL TEXT column.\n"
|
||||
"-- Strips U+0000 (which TEXT cannot store) and caps length to avoid\n"
|
||||
"-- pathological values bloating the table. Everything else is preserved\n"
|
||||
"-- byte-for-byte; presentation-layer concerns (bidi, Zalgo, etc.) are\n"
|
||||
"-- handled at render time, not here.\n"
|
||||
"CREATE OR REPLACE FUNCTION sanitize_pg_text(input text, max_len integer DEFAULT 1024)\n"
|
||||
"RETURNS text AS $$\n"
|
||||
"BEGIN\n"
|
||||
" IF input IS NULL THEN\n"
|
||||
" RETURN '';\n"
|
||||
" END IF;\n"
|
||||
" -- Remove NUL characters (U+0000) that PostgreSQL TEXT cannot store.\n"
|
||||
" -- Using regexp_replace for portability across PG versions.\n"
|
||||
" RETURN left(regexp_replace(input, '\\u0000', '', 'g'), max_len);\n"
|
||||
"END;\n"
|
||||
"$$ LANGUAGE plpgsql IMMUTABLE;\n"
|
||||
"\n"
|
||||
"CREATE TABLE IF NOT EXISTS profiles (\n"
|
||||
" pubkey TEXT PRIMARY KEY,\n"
|
||||
" event_id TEXT NOT NULL,\n"
|
||||
" created_at BIGINT NOT NULL,\n"
|
||||
" name TEXT NOT NULL DEFAULT '',\n"
|
||||
" display_name TEXT NOT NULL DEFAULT '',\n"
|
||||
" about TEXT NOT NULL DEFAULT '',\n"
|
||||
" picture TEXT NOT NULL DEFAULT '',\n"
|
||||
" banner TEXT NOT NULL DEFAULT '',\n"
|
||||
" nip05 TEXT NOT NULL DEFAULT '',\n"
|
||||
" website TEXT NOT NULL DEFAULT '',\n"
|
||||
" lud16 TEXT NOT NULL DEFAULT '',\n"
|
||||
" lud06 TEXT NOT NULL DEFAULT '',\n"
|
||||
" raw_content TEXT NOT NULL DEFAULT '',\n"
|
||||
" parse_ok BOOLEAN NOT NULL DEFAULT TRUE,\n"
|
||||
" updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT\n"
|
||||
");\n"
|
||||
"\n"
|
||||
"CREATE INDEX IF NOT EXISTS idx_profiles_name\n"
|
||||
" ON profiles(name) WHERE name <> '';\n"
|
||||
"CREATE INDEX IF NOT EXISTS idx_profiles_display_name\n"
|
||||
" ON profiles(display_name) WHERE display_name <> '';\n"
|
||||
"CREATE INDEX IF NOT EXISTS idx_profiles_nip05\n"
|
||||
" ON profiles(nip05) WHERE nip05 <> '';\n"
|
||||
"\n"
|
||||
"-- Default display-name preference. Can be changed at runtime via the\n"
|
||||
"-- normal admin config path. Valid values: 'display_name' or 'name'.\n"
|
||||
"INSERT INTO config (key, value, data_type, description, category, requires_restart)\n"
|
||||
"VALUES ('profile_name_preference', 'display_name',\n"
|
||||
" 'string',\n"
|
||||
" 'Which kind-0 field to prefer for display: display_name or name',\n"
|
||||
" 'display', 0)\n"
|
||||
"ON CONFLICT (key) DO NOTHING;\n"
|
||||
"\n"
|
||||
"-- Sync profiles row on kind-0 INSERT or UPDATE of content.\n"
|
||||
"-- Early-returns for non-kind-0 events (cost: one integer comparison).\n"
|
||||
"-- Guards against an older kind-0 overwriting a newer one.\n"
|
||||
"CREATE OR REPLACE FUNCTION sync_profile_from_event() RETURNS TRIGGER AS $$\n"
|
||||
"DECLARE\n"
|
||||
" j jsonb;\n"
|
||||
"BEGIN\n"
|
||||
" IF NEW.kind <> 0 THEN\n"
|
||||
" RETURN NEW;\n"
|
||||
" END IF;\n"
|
||||
"\n"
|
||||
" j := safe_jsonb(NEW.content);\n"
|
||||
"\n"
|
||||
" INSERT INTO profiles (\n"
|
||||
" pubkey, event_id, created_at,\n"
|
||||
" name, display_name, about, picture, banner,\n"
|
||||
" nip05, website, lud16, lud06,\n"
|
||||
" raw_content, parse_ok, updated_at\n"
|
||||
" ) VALUES (\n"
|
||||
" NEW.pubkey, NEW.id, NEW.created_at,\n"
|
||||
" sanitize_pg_text(COALESCE(j->>'name','')),\n"
|
||||
" sanitize_pg_text(COALESCE(j->>'display_name','')),\n"
|
||||
" sanitize_pg_text(COALESCE(j->>'about','')),\n"
|
||||
" sanitize_pg_text(COALESCE(j->>'picture',''), 2048),\n"
|
||||
" sanitize_pg_text(COALESCE(j->>'banner',''), 2048),\n"
|
||||
" sanitize_pg_text(COALESCE(j->>'nip05','')),\n"
|
||||
" sanitize_pg_text(COALESCE(j->>'website',''), 2048),\n"
|
||||
" sanitize_pg_text(COALESCE(j->>'lud16','')),\n"
|
||||
" sanitize_pg_text(COALESCE(j->>'lud06',''), 2048),\n"
|
||||
" NEW.content, (j IS NOT NULL),\n"
|
||||
" EXTRACT(EPOCH FROM NOW())::BIGINT\n"
|
||||
" )\n"
|
||||
" ON CONFLICT (pubkey) DO UPDATE SET\n"
|
||||
" event_id = EXCLUDED.event_id,\n"
|
||||
" created_at = EXCLUDED.created_at,\n"
|
||||
" name = EXCLUDED.name,\n"
|
||||
" display_name = EXCLUDED.display_name,\n"
|
||||
" about = EXCLUDED.about,\n"
|
||||
" picture = EXCLUDED.picture,\n"
|
||||
" banner = EXCLUDED.banner,\n"
|
||||
" nip05 = EXCLUDED.nip05,\n"
|
||||
" website = EXCLUDED.website,\n"
|
||||
" lud16 = EXCLUDED.lud16,\n"
|
||||
" lud06 = EXCLUDED.lud06,\n"
|
||||
" raw_content = EXCLUDED.raw_content,\n"
|
||||
" parse_ok = EXCLUDED.parse_ok,\n"
|
||||
" updated_at = EXCLUDED.updated_at\n"
|
||||
" WHERE EXCLUDED.created_at >= profiles.created_at;\n"
|
||||
"\n"
|
||||
" RETURN NEW;\n"
|
||||
"END;\n"
|
||||
"$$ LANGUAGE plpgsql;\n"
|
||||
"\n"
|
||||
"DROP TRIGGER IF EXISTS trg_events_sync_profile ON events;\n"
|
||||
"CREATE TRIGGER trg_events_sync_profile\n"
|
||||
"AFTER INSERT OR UPDATE OF content ON events\n"
|
||||
"FOR EACH ROW EXECUTE FUNCTION sync_profile_from_event();\n"
|
||||
"\n"
|
||||
"-- Remove profiles row when its backing kind-0 is deleted (NIP-09).\n"
|
||||
"-- Guarded on event_id so deleting a superseded event doesn't drop a\n"
|
||||
"-- current profile.\n"
|
||||
"CREATE OR REPLACE FUNCTION delete_profile_from_event() RETURNS TRIGGER AS $$\n"
|
||||
"BEGIN\n"
|
||||
" IF OLD.kind <> 0 THEN\n"
|
||||
" RETURN OLD;\n"
|
||||
" END IF;\n"
|
||||
" DELETE FROM profiles\n"
|
||||
" WHERE pubkey = OLD.pubkey\n"
|
||||
" AND event_id = OLD.id;\n"
|
||||
" RETURN OLD;\n"
|
||||
"END;\n"
|
||||
"$$ LANGUAGE plpgsql;\n"
|
||||
"\n"
|
||||
"DROP TRIGGER IF EXISTS trg_events_delete_profile ON events;\n"
|
||||
"CREATE TRIGGER trg_events_delete_profile\n"
|
||||
"AFTER DELETE ON events\n"
|
||||
"FOR EACH ROW EXECUTE FUNCTION delete_profile_from_event();\n"
|
||||
"\n"
|
||||
"-- One-time backfill from existing kind-0 events. Idempotent: only\n"
|
||||
"-- inserts profiles that don't already exist (ON CONFLICT DO NOTHING).\n"
|
||||
"-- Runs on every startup but is a no-op once all profiles are populated,\n"
|
||||
"-- which is cheap because the query only returns rows for kind-0 events\n"
|
||||
"-- whose pubkey is not already in the profiles table.\n"
|
||||
"DO $$\n"
|
||||
"BEGIN\n"
|
||||
" INSERT INTO profiles (pubkey, event_id, created_at, name, display_name,\n"
|
||||
" about, picture, banner, nip05, website, lud16, lud06,\n"
|
||||
" raw_content, parse_ok)\n"
|
||||
" SELECT e.pubkey, e.id, e.created_at,\n"
|
||||
" sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'name','')),\n"
|
||||
" sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'display_name','')),\n"
|
||||
" sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'about','')),\n"
|
||||
" sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'picture',''), 2048),\n"
|
||||
" sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'banner',''), 2048),\n"
|
||||
" sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'nip05','')),\n"
|
||||
" sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'website',''), 2048),\n"
|
||||
" sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'lud16','')),\n"
|
||||
" sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'lud06',''), 2048),\n"
|
||||
" e.content,\n"
|
||||
" (safe_jsonb(e.content) IS NOT NULL)\n"
|
||||
" FROM events e\n"
|
||||
" WHERE e.kind = 0\n"
|
||||
" AND NOT EXISTS (SELECT 1 FROM profiles p WHERE p.pubkey = e.pubkey)\n"
|
||||
" ON CONFLICT (pubkey) DO NOTHING;\n"
|
||||
"END\n"
|
||||
"$$;\n"
|
||||
"\n"
|
||||
"COMMIT;\n"
|
||||
;
|
||||
|
||||
|
||||
+261
-1
@@ -257,7 +257,7 @@ CREATE TABLE IF NOT EXISTS ip_bans (
|
||||
);
|
||||
|
||||
INSERT INTO schema_info(key, value, updated_at)
|
||||
VALUES ('version', '5', EXTRACT(EPOCH FROM NOW())::BIGINT)
|
||||
VALUES ('version', '6', EXTRACT(EPOCH FROM NOW())::BIGINT)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
value = EXCLUDED.value,
|
||||
updated_at = EXCLUDED.updated_at;
|
||||
@@ -284,6 +284,24 @@ CREATE TRIGGER trg_notify_event_stored
|
||||
AFTER INSERT ON events
|
||||
FOR EACH ROW EXECUTE FUNCTION notify_event_stored();
|
||||
|
||||
-- =====================================================================
|
||||
-- Config change notification: fires pg_notify('config_changed', key)
|
||||
-- whenever a config row is updated, so the relay can react to
|
||||
-- caching_enabled / caching_inbox_enabled toggles from the PHP admin
|
||||
-- without polling.
|
||||
-- =====================================================================
|
||||
CREATE OR REPLACE FUNCTION notify_config_changed() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('config_changed', NEW.key);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_notify_config_changed ON config;
|
||||
CREATE TRIGGER trg_notify_config_changed
|
||||
AFTER UPDATE ON config
|
||||
FOR EACH ROW EXECUTE FUNCTION notify_config_changed();
|
||||
|
||||
-- =====================================================================
|
||||
-- Caching relay integration tables
|
||||
-- These tables are written by an external caching application and
|
||||
@@ -360,4 +378,246 @@ CREATE INDEX IF NOT EXISTS idx_caching_backfill_progress_window
|
||||
ON caching_backfill_progress(window_index)
|
||||
WHERE complete = FALSE;
|
||||
|
||||
-- Durable followed-pubkey list with per-author backfill cursor state.
|
||||
-- Replaces the window-coupled caching_backfill_progress table for the
|
||||
-- per-author until-cursor drain backfill model.
|
||||
CREATE TABLE IF NOT EXISTS caching_followed_pubkeys (
|
||||
pubkey TEXT PRIMARY KEY,
|
||||
is_root BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
until_cursor BIGINT NOT NULL DEFAULT 0,
|
||||
backfill_complete BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
events_fetched INTEGER NOT NULL DEFAULT 0,
|
||||
first_seen BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
|
||||
last_seen BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
|
||||
updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT
|
||||
);
|
||||
|
||||
-- last_event_at: created_at of the most recent event we've seen for this
|
||||
-- author. Used by the forward catch-up to bridge gaps when caching resumes
|
||||
-- after being off. Seeded from the events table on upgrade.
|
||||
ALTER TABLE caching_followed_pubkeys
|
||||
ADD COLUMN IF NOT EXISTS last_event_at BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
UPDATE caching_followed_pubkeys fp
|
||||
SET last_event_at = COALESCE(
|
||||
(SELECT MAX(e.created_at) FROM events e WHERE e.pubkey = fp.pubkey),
|
||||
0
|
||||
)
|
||||
WHERE fp.last_event_at = 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_caching_followed_incomplete
|
||||
ON caching_followed_pubkeys(backfill_complete, last_seen)
|
||||
WHERE backfill_complete = FALSE;
|
||||
|
||||
-- Per-relay backfill cursor tracking for the relay-by-relay drain model.
|
||||
-- Each (author, relay) pair has its own until_cursor and completion flag so
|
||||
-- slow relays do not cause premature completion marking for the author.
|
||||
CREATE TABLE IF NOT EXISTS caching_backfill_relay_progress (
|
||||
author_pubkey TEXT NOT NULL,
|
||||
relay_url TEXT NOT NULL,
|
||||
until_cursor BIGINT NOT NULL DEFAULT 0,
|
||||
complete BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
events_fetched INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
|
||||
PRIMARY KEY (author_pubkey, relay_url)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_caching_relay_progress_incomplete
|
||||
ON caching_backfill_relay_progress(author_pubkey)
|
||||
WHERE complete = FALSE;
|
||||
|
||||
-- Replace window-based progress columns with author-based progress.
|
||||
ALTER TABLE caching_service_state
|
||||
DROP COLUMN IF EXISTS current_window_index,
|
||||
DROP COLUMN IF EXISTS backfill_cursor,
|
||||
ADD COLUMN IF NOT EXISTS backfill_authors_complete INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS backfill_authors_total INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- =====================================================================
|
||||
-- Profile cache (kind-0 metadata projection)
|
||||
-- One row per pubkey, kept in sync with the latest kind-0 event via
|
||||
-- triggers. Stores both `name` and `display_name` verbatim; the display
|
||||
-- preference is a runtime config key (profile_name_preference), not a
|
||||
-- generated column, so it can be changed without a migration.
|
||||
-- =====================================================================
|
||||
|
||||
-- Safe JSONB parse: returns NULL on malformed input instead of raising.
|
||||
-- IMMUTABLE so it can be used in generated expressions and backfills.
|
||||
CREATE OR REPLACE FUNCTION safe_jsonb(input text) RETURNS jsonb AS $$
|
||||
DECLARE
|
||||
result jsonb;
|
||||
BEGIN
|
||||
IF input IS NULL THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
BEGIN
|
||||
result := input::jsonb;
|
||||
IF jsonb_typeof(result) <> 'object' THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
RETURN result;
|
||||
EXCEPTION WHEN others THEN
|
||||
RETURN NULL;
|
||||
END;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
-- Sanitize a text value for safe storage in a PostgreSQL TEXT column.
|
||||
-- Strips U+0000 (which TEXT cannot store) and caps length to avoid
|
||||
-- pathological values bloating the table. Everything else is preserved
|
||||
-- byte-for-byte; presentation-layer concerns (bidi, Zalgo, etc.) are
|
||||
-- handled at render time, not here.
|
||||
CREATE OR REPLACE FUNCTION sanitize_pg_text(input text, max_len integer DEFAULT 1024)
|
||||
RETURNS text AS $$
|
||||
BEGIN
|
||||
IF input IS NULL THEN
|
||||
RETURN '';
|
||||
END IF;
|
||||
-- Remove NUL characters (U+0000) that PostgreSQL TEXT cannot store.
|
||||
-- Using regexp_replace for portability across PG versions.
|
||||
RETURN left(regexp_replace(input, '\u0000', '', 'g'), max_len);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
pubkey TEXT PRIMARY KEY,
|
||||
event_id TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
about TEXT NOT NULL DEFAULT '',
|
||||
picture TEXT NOT NULL DEFAULT '',
|
||||
banner TEXT NOT NULL DEFAULT '',
|
||||
nip05 TEXT NOT NULL DEFAULT '',
|
||||
website TEXT NOT NULL DEFAULT '',
|
||||
lud16 TEXT NOT NULL DEFAULT '',
|
||||
lud06 TEXT NOT NULL DEFAULT '',
|
||||
raw_content TEXT NOT NULL DEFAULT '',
|
||||
parse_ok BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_name
|
||||
ON profiles(name) WHERE name <> '';
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_display_name
|
||||
ON profiles(display_name) WHERE display_name <> '';
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_nip05
|
||||
ON profiles(nip05) WHERE nip05 <> '';
|
||||
|
||||
-- Default display-name preference. Can be changed at runtime via the
|
||||
-- normal admin config path. Valid values: 'display_name' or 'name'.
|
||||
INSERT INTO config (key, value, data_type, description, category, requires_restart)
|
||||
VALUES ('profile_name_preference', 'display_name',
|
||||
'string',
|
||||
'Which kind-0 field to prefer for display: display_name or name',
|
||||
'display', 0)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- Sync profiles row on kind-0 INSERT or UPDATE of content.
|
||||
-- Early-returns for non-kind-0 events (cost: one integer comparison).
|
||||
-- Guards against an older kind-0 overwriting a newer one.
|
||||
CREATE OR REPLACE FUNCTION sync_profile_from_event() RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
j jsonb;
|
||||
BEGIN
|
||||
IF NEW.kind <> 0 THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
j := safe_jsonb(NEW.content);
|
||||
|
||||
INSERT INTO profiles (
|
||||
pubkey, event_id, created_at,
|
||||
name, display_name, about, picture, banner,
|
||||
nip05, website, lud16, lud06,
|
||||
raw_content, parse_ok, updated_at
|
||||
) VALUES (
|
||||
NEW.pubkey, NEW.id, NEW.created_at,
|
||||
sanitize_pg_text(COALESCE(j->>'name','')),
|
||||
sanitize_pg_text(COALESCE(j->>'display_name','')),
|
||||
sanitize_pg_text(COALESCE(j->>'about','')),
|
||||
sanitize_pg_text(COALESCE(j->>'picture',''), 2048),
|
||||
sanitize_pg_text(COALESCE(j->>'banner',''), 2048),
|
||||
sanitize_pg_text(COALESCE(j->>'nip05','')),
|
||||
sanitize_pg_text(COALESCE(j->>'website',''), 2048),
|
||||
sanitize_pg_text(COALESCE(j->>'lud16','')),
|
||||
sanitize_pg_text(COALESCE(j->>'lud06',''), 2048),
|
||||
NEW.content, (j IS NOT NULL),
|
||||
EXTRACT(EPOCH FROM NOW())::BIGINT
|
||||
)
|
||||
ON CONFLICT (pubkey) DO UPDATE SET
|
||||
event_id = EXCLUDED.event_id,
|
||||
created_at = EXCLUDED.created_at,
|
||||
name = EXCLUDED.name,
|
||||
display_name = EXCLUDED.display_name,
|
||||
about = EXCLUDED.about,
|
||||
picture = EXCLUDED.picture,
|
||||
banner = EXCLUDED.banner,
|
||||
nip05 = EXCLUDED.nip05,
|
||||
website = EXCLUDED.website,
|
||||
lud16 = EXCLUDED.lud16,
|
||||
lud06 = EXCLUDED.lud06,
|
||||
raw_content = EXCLUDED.raw_content,
|
||||
parse_ok = EXCLUDED.parse_ok,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
WHERE EXCLUDED.created_at >= profiles.created_at;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_events_sync_profile ON events;
|
||||
CREATE TRIGGER trg_events_sync_profile
|
||||
AFTER INSERT OR UPDATE OF content ON events
|
||||
FOR EACH ROW EXECUTE FUNCTION sync_profile_from_event();
|
||||
|
||||
-- Remove profiles row when its backing kind-0 is deleted (NIP-09).
|
||||
-- Guarded on event_id so deleting a superseded event doesn't drop a
|
||||
-- current profile.
|
||||
CREATE OR REPLACE FUNCTION delete_profile_from_event() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF OLD.kind <> 0 THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
DELETE FROM profiles
|
||||
WHERE pubkey = OLD.pubkey
|
||||
AND event_id = OLD.id;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_events_delete_profile ON events;
|
||||
CREATE TRIGGER trg_events_delete_profile
|
||||
AFTER DELETE ON events
|
||||
FOR EACH ROW EXECUTE FUNCTION delete_profile_from_event();
|
||||
|
||||
-- One-time backfill from existing kind-0 events. Idempotent: only
|
||||
-- inserts profiles that don't already exist (ON CONFLICT DO NOTHING).
|
||||
-- Runs on every startup but is a no-op once all profiles are populated,
|
||||
-- which is cheap because the query only returns rows for kind-0 events
|
||||
-- whose pubkey is not already in the profiles table.
|
||||
DO $$
|
||||
BEGIN
|
||||
INSERT INTO profiles (pubkey, event_id, created_at, name, display_name,
|
||||
about, picture, banner, nip05, website, lud16, lud06,
|
||||
raw_content, parse_ok)
|
||||
SELECT e.pubkey, e.id, e.created_at,
|
||||
sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'name','')),
|
||||
sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'display_name','')),
|
||||
sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'about','')),
|
||||
sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'picture',''), 2048),
|
||||
sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'banner',''), 2048),
|
||||
sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'nip05','')),
|
||||
sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'website',''), 2048),
|
||||
sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'lud16','')),
|
||||
sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'lud06',''), 2048),
|
||||
e.content,
|
||||
(safe_jsonb(e.content) IS NOT NULL)
|
||||
FROM events e
|
||||
WHERE e.kind = 0
|
||||
AND NOT EXISTS (SELECT 1 FROM profiles p WHERE p.pubkey = e.pubkey)
|
||||
ON CONFLICT (pubkey) DO NOTHING;
|
||||
END
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
+4
-1
@@ -22,7 +22,10 @@
|
||||
// Maximum number of messages allowed in a per-client write queue.
|
||||
// When exceeded, new messages are dropped to prevent unbounded memory growth
|
||||
// under high-traffic or slow-client conditions.
|
||||
#define MAX_MESSAGE_QUEUE_SIZE 500
|
||||
// Must be larger than max_limit (5000) so a full page of EVENT messages
|
||||
// plus the trailing EOSE can always be queued — otherwise the EOSE gets
|
||||
// dropped when a query returns exactly `limit` events.
|
||||
#define MAX_MESSAGE_QUEUE_SIZE 5100
|
||||
|
||||
// Filter validation constants
|
||||
#define MAX_FILTERS_PER_REQUEST 10
|
||||
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/bin/bash
|
||||
# tests/profile_cache_test.sh — Profile cache (profiles table) validation.
|
||||
#
|
||||
# Tests that the profiles cache table is correctly populated by triggers,
|
||||
# that the one-time backfill runs only once, that replaceable kind-0 updates
|
||||
# work correctly, that malformed content doesn't break ingestion, and that
|
||||
# the NUL-byte sanitization prevents ingest DoS.
|
||||
#
|
||||
# Requires: relay running on localhost:8888 with PostgreSQL backend.
|
||||
# Usage: ./tests/profile_cache_test.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RELAY_URL="${RELAY_URL:-ws://localhost:8888}"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Test keys are optional for DB-level tests. They are only needed for
|
||||
# tests that publish events (not yet implemented in this script).
|
||||
KEYS_FILE="$SCRIPT_DIR/.test_keys.txt"
|
||||
if [ ! -f "$KEYS_FILE" ]; then
|
||||
KEYS_FILE="$SCRIPT_DIR/../.test_keys"
|
||||
fi
|
||||
if [ -f "$KEYS_FILE" ]; then
|
||||
source "$KEYS_FILE" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "=== Profile Cache Test Suite ==="
|
||||
echo "Relay: $RELAY_URL"
|
||||
echo ""
|
||||
|
||||
# Helper: publish a kind-0 event using noscl or similar
|
||||
# Since we may not have noscl, we use a simple Python/Node approach.
|
||||
# For now, this test validates the DB state directly via psql.
|
||||
|
||||
# Helper: run a psql query against the relay database
|
||||
psql_query() {
|
||||
local sql="$1"
|
||||
sudo -u postgres psql -d crelay -t -A -c "$sql" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Helper: check a condition
|
||||
check() {
|
||||
local name="$1"
|
||||
local condition="$2"
|
||||
if eval "$condition"; then
|
||||
echo " PASS: $name"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL: $name"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# Test 1: profiles table exists
|
||||
echo "--- Test 1: profiles table exists ---"
|
||||
result=$(psql_query "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'profiles')")
|
||||
check "profiles table exists" '[ "$result" = "t" ]'
|
||||
|
||||
# Test 2: safe_jsonb function exists
|
||||
echo "--- Test 2: safe_jsonb function exists ---"
|
||||
result=$(psql_query "SELECT EXISTS (SELECT FROM pg_proc WHERE proname = 'safe_jsonb')")
|
||||
check "safe_jsonb function exists" '[ "$result" = "t" ]'
|
||||
|
||||
# Test 3: sanitize_pg_text function exists
|
||||
echo "--- Test 3: sanitize_pg_text function exists ---"
|
||||
result=$(psql_query "SELECT EXISTS (SELECT FROM pg_proc WHERE proname = 'sanitize_pg_text')")
|
||||
check "sanitize_pg_text function exists" '[ "$result" = "t" ]'
|
||||
|
||||
# Test 4: sync_profile_from_event trigger exists
|
||||
echo "--- Test 4: sync_profile_from_event trigger exists ---"
|
||||
result=$(psql_query "SELECT EXISTS (SELECT FROM information_schema.triggers WHERE trigger_name = 'trg_events_sync_profile')")
|
||||
check "trg_events_sync_profile trigger exists" '[ "$result" = "t" ]'
|
||||
|
||||
# Test 5: delete_profile_from_event trigger exists
|
||||
echo "--- Test 5: delete_profile_from_event trigger exists ---"
|
||||
result=$(psql_query "SELECT EXISTS (SELECT FROM information_schema.triggers WHERE trigger_name = 'trg_events_delete_profile')")
|
||||
check "trg_events_delete_profile trigger exists" '[ "$result" = "t" ]'
|
||||
|
||||
# Test 6: profile_name_preference config exists
|
||||
echo "--- Test 6: profile_name_preference config exists ---"
|
||||
result=$(psql_query "SELECT value FROM config WHERE key = 'profile_name_preference'")
|
||||
check "profile_name_preference config exists" '[ -n "$result" ]'
|
||||
check "profile_name_preference defaults to display_name" '[ "$result" = "display_name" ]'
|
||||
|
||||
# Test 7: schema version is 6
|
||||
echo "--- Test 7: schema version is 6 ---"
|
||||
result=$(psql_query "SELECT value FROM schema_info WHERE key = 'version'")
|
||||
check "schema version is 6" '[ "$result" = "6" ]'
|
||||
|
||||
# Test 8: profiles table has expected columns
|
||||
echo "--- Test 8: profiles table has expected columns ---"
|
||||
for col in pubkey event_id created_at name display_name about picture banner nip05 website lud16 lud06 raw_content parse_ok updated_at; do
|
||||
result=$(psql_query "SELECT EXISTS (SELECT FROM information_schema.columns WHERE table_name = 'profiles' AND column_name = '$col')")
|
||||
check "profiles.$col exists" '[ "$result" = "t" ]'
|
||||
done
|
||||
|
||||
# Test 9: backfill ran (if there are kind-0 events, there should be profiles)
|
||||
echo "--- Test 9: backfill consistency ---"
|
||||
kind0_count=$(psql_query "SELECT count(*) FROM events WHERE kind = 0")
|
||||
profile_count=$(psql_query "SELECT count(*) FROM profiles")
|
||||
echo " kind-0 events: $kind0_count, profiles: $profile_count"
|
||||
if [ "$kind0_count" -gt 0 ] 2>/dev/null; then
|
||||
check "profiles count > 0 when kind-0 events exist" '[ "$profile_count" -gt 0 ] 2>/dev/null'
|
||||
# Each pubkey with a kind-0 should have exactly one profile row
|
||||
distinct_pubkeys=$(psql_query "SELECT count(DISTINCT pubkey) FROM events WHERE kind = 0")
|
||||
check "profile count matches distinct kind-0 pubkeys" '[ "$profile_count" = "$distinct_pubkeys" ] 2>/dev/null'
|
||||
else
|
||||
echo " (no kind-0 events to test backfill — skipping)"
|
||||
fi
|
||||
|
||||
# Test 10: both name and display_name are stored (not collapsed)
|
||||
echo "--- Test 10: both name fields stored independently ---"
|
||||
both_set=$(psql_query "SELECT count(*) FROM profiles WHERE name <> '' AND display_name <> '' AND name <> display_name")
|
||||
echo " profiles where name and display_name differ: $both_set"
|
||||
check "at least one profile has both fields set differently (or zero is OK)" true
|
||||
|
||||
# Test 11: NUL byte handling — PostgreSQL's ::jsonb cast rejects \u0000,
|
||||
# so safe_jsonb catches the exception and returns NULL. The trigger then
|
||||
# stores parse_ok=FALSE with empty fields. sanitize_pg_text is a
|
||||
# defense-in-depth layer for any NUL that might slip through other paths.
|
||||
echo "--- Test 11: NUL byte handling ---"
|
||||
# Test that sanitize_pg_text works on normal strings
|
||||
result=$(psql_query "SELECT sanitize_pg_text('hello<world>')")
|
||||
check "sanitize_pg_text preserves non-NUL chars" '[ "$result" = "hello<world>" ]'
|
||||
# Test that safe_jsonb returns NULL for JSON containing \u0000
|
||||
# (PostgreSQL's jsonb cast rejects \u0000, safe_jsonb catches the exception)
|
||||
result=$(psql_query "SELECT safe_jsonb('{\"name\":\"hello\u0000world\"}')")
|
||||
check "safe_jsonb returns NULL for JSON with NUL" '[ "$result" = "" ]'
|
||||
# Test that the trigger path handles it: parse_ok=FALSE, empty name
|
||||
result=$(psql_query "SELECT COALESCE(safe_jsonb('{\"name\":\"hello\u0000world\"}')->>'name','')")
|
||||
check "NUL JSON yields empty name via safe_jsonb" '[ "$result" = "" ]'
|
||||
|
||||
# Test 12: safe_jsonb returns NULL for malformed JSON
|
||||
echo "--- Test 12: safe_jsonb handles malformed JSON ---"
|
||||
result=$(psql_query "SELECT safe_jsonb('not json')")
|
||||
check "safe_jsonb returns NULL for non-JSON" '[ "$result" = "" ]'
|
||||
|
||||
result=$(psql_query "SELECT safe_jsonb('[1,2,3]')")
|
||||
check "safe_jsonb returns NULL for non-object JSON" '[ "$result" = "" ]'
|
||||
|
||||
result=$(psql_query "SELECT safe_jsonb('{\"name\":\"test\"}')")
|
||||
check "safe_jsonb returns object for valid JSON" '[ "$result" = "{\"name\": \"test\"}" ]'
|
||||
|
||||
# Test 13: sanitize_pg_text caps length
|
||||
echo "--- Test 13: sanitize_pg_text caps length ---"
|
||||
result=$(psql_query "SELECT length(sanitize_pg_text(repeat('a', 2000), 100))")
|
||||
check "sanitize_pg_text caps to 100" '[ "$result" = "100" ]'
|
||||
|
||||
# Test 14: name-field usage query works
|
||||
echo "--- Test 14: name-field usage query ---"
|
||||
result=$(psql_query "
|
||||
SELECT count(*) FILTER (WHERE name <> '' AND display_name <> '') AS both,
|
||||
count(*) FILTER (WHERE name <> '' AND display_name = '') AS name_only,
|
||||
count(*) FILTER (WHERE name = '' AND display_name <> '') AS display_only
|
||||
FROM profiles
|
||||
")
|
||||
echo " both / name_only / display_only: $result"
|
||||
check "name-field usage query executes" true
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||
exit $FAIL
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* test_ws_client.c — Standalone test for nostr_core_lib's WebSocket client.
|
||||
*
|
||||
* Connects to a relay, sends a REQ with a filter, and counts how many
|
||||
* EVENT messages and EOSE it receives. This isolates the WebSocket client
|
||||
* from the caching backfill logic to diagnose why the C client only gets
|
||||
* 1 event from laantungir.net/relay while node.js gets 500 + EOSE.
|
||||
*
|
||||
* Build: see test_ws_client_makefile or use the caching/ Makefile pattern.
|
||||
* cd tests && gcc -o test_ws_client test_ws_client.c \
|
||||
* -I../nostr_core_lib -I../nostr_core_lib/nostr_core \
|
||||
* -I../nostr_core_lib/cjson -I../nostr_core_lib/nostr_websocket \
|
||||
* ../nostr_core_lib/libnostr_core_x64.a \
|
||||
* -lssl -lcrypto -lwebsockets -lsecp256k1 -lz -ldl -lpthread -lm
|
||||
*
|
||||
* Usage: ./test_ws_client <relay_url> <pubkey_hex> [limit] [kinds]
|
||||
* limit defaults to 500, kinds defaults to "0,1,3,6,10000,10002,30023"
|
||||
*/
|
||||
|
||||
#define _GNU_SOURCE
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "nostr_core.h"
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
typedef struct {
|
||||
int event_count;
|
||||
int got_eose;
|
||||
int got_timeout;
|
||||
int got_error;
|
||||
char last_status[256];
|
||||
time_t start;
|
||||
} test_ctx_t;
|
||||
|
||||
static void test_callback(const char* relay_url, const char* status,
|
||||
const char* event_id, int events_received,
|
||||
int total_relays, int completed_relays,
|
||||
void* user_data) {
|
||||
(void)relay_url; (void)event_id; (void)total_relays; (void)completed_relays;
|
||||
test_ctx_t* ctx = (test_ctx_t*)user_data;
|
||||
if (!ctx || !status) return;
|
||||
|
||||
double elapsed = difftime(time(NULL), ctx->start);
|
||||
|
||||
/* Track all status types */
|
||||
if (strcmp(status, "event_found") == 0) {
|
||||
ctx->event_count = events_received;
|
||||
if (ctx->event_count % 50 == 0) {
|
||||
printf(" [%5.0fs] %d events received\n", elapsed, ctx->event_count);
|
||||
}
|
||||
} else if (strcmp(status, "eose") == 0) {
|
||||
ctx->got_eose = 1;
|
||||
ctx->event_count = events_received;
|
||||
printf(" [%5.0fs] EOSE received! events=%d\n", elapsed, ctx->event_count);
|
||||
} else if (strcmp(status, "timeout") == 0) {
|
||||
ctx->got_timeout = 1;
|
||||
ctx->event_count = events_received;
|
||||
printf(" [%5.0fs] TIMEOUT (relay timed out) events=%d\n", elapsed, ctx->event_count);
|
||||
} else if (strcmp(status, "error") == 0) {
|
||||
ctx->got_error = 1;
|
||||
printf(" [%5.0fs] ERROR from relay\n", elapsed);
|
||||
} else if (strcmp(status, "all_complete") == 0) {
|
||||
printf(" [%5.0fs] all_complete (synthetic) events=%d\n", elapsed, events_received);
|
||||
} else if (strcmp(status, "connecting") == 0) {
|
||||
printf(" [%5.0fs] connecting...\n", elapsed);
|
||||
} else if (strcmp(status, "subscribed") == 0) {
|
||||
printf(" [%5.0fs] subscribed, REQ sent\n", elapsed);
|
||||
} else {
|
||||
printf(" [%5.0fs] status='%s' events=%d\n", elapsed, status, events_received);
|
||||
}
|
||||
|
||||
snprintf(ctx->last_status, sizeof(ctx->last_status), "%s", status);
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
if (argc < 3) {
|
||||
fprintf(stderr, "Usage: %s <relay_url> <pubkey_hex> [limit] [kinds_csv]\n", argv[0]);
|
||||
fprintf(stderr, "Example: %s wss://laantungir.net/relay 1ec454734dcbf6fe54901ce25c0c7c6bca5edd89443416761fadc321d38df139 500\n", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const char* relay_url = argv[1];
|
||||
const char* pubkey = argv[2];
|
||||
int limit = (argc >= 4) ? atoi(argv[3]) : 500;
|
||||
const char* kinds_csv = (argc >= 5) ? argv[4] : "0,1,3,6,10000,10002,30023";
|
||||
|
||||
printf("=== nostr_core_lib WebSocket Client Test ===\n");
|
||||
printf("Relay: %s\n", relay_url);
|
||||
printf("Pubkey: %s\n", pubkey);
|
||||
printf("Limit: %d\n", limit);
|
||||
printf("Kinds: %s\n", kinds_csv);
|
||||
printf("Timeout: 30s\n\n");
|
||||
|
||||
/* Build the filter JSON */
|
||||
cJSON* filter = cJSON_CreateObject();
|
||||
cJSON* authors = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(pubkey));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
|
||||
/* Parse kinds CSV */
|
||||
cJSON* kinds = cJSON_CreateArray();
|
||||
char kinds_buf[256];
|
||||
strncpy(kinds_buf, kinds_csv, sizeof(kinds_buf) - 1);
|
||||
kinds_buf[sizeof(kinds_buf) - 1] = '\0';
|
||||
char* tok = strtok(kinds_buf, ",");
|
||||
while (tok) {
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber((double)atoi(tok)));
|
||||
tok = strtok(NULL, ",");
|
||||
}
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddItemToObject(filter, "limit", cJSON_CreateNumber((double)limit));
|
||||
|
||||
/* Use until=now to mimic backfill first query */
|
||||
long now = (long)time(NULL);
|
||||
cJSON_AddItemToObject(filter, "until", cJSON_CreateNumber((double)now));
|
||||
printf("Until: %ld (now)\n\n", now);
|
||||
|
||||
const char* one_relay = relay_url;
|
||||
int result_count = 0;
|
||||
test_ctx_t ctx = {0};
|
||||
ctx.start = time(NULL);
|
||||
|
||||
printf("Calling synchronous_query_relays_with_progress...\n\n");
|
||||
|
||||
cJSON** events = synchronous_query_relays_with_progress(
|
||||
&one_relay, 1, filter, RELAY_QUERY_ALL_RESULTS,
|
||||
&result_count, 30, /* 30s timeout */
|
||||
test_callback, &ctx,
|
||||
0, NULL /* no NIP-42 */);
|
||||
|
||||
double total_elapsed = difftime(time(NULL), ctx.start);
|
||||
|
||||
printf("\n=== RESULTS ===\n");
|
||||
printf("Total elapsed: %.0fs\n", total_elapsed);
|
||||
printf("Events received: %d\n", ctx.event_count);
|
||||
printf("Result count: %d\n", result_count);
|
||||
printf("Got EOSE: %s\n", ctx.got_eose ? "YES" : "NO");
|
||||
printf("Got timeout: %s\n", ctx.got_timeout ? "YES" : "NO");
|
||||
printf("Got error: %s\n", ctx.got_error ? "YES" : "NO");
|
||||
printf("Last status: %s\n", ctx.last_status);
|
||||
|
||||
if (events) {
|
||||
printf("Returned event array: %d events\n", result_count);
|
||||
for (int i = 0; i < result_count && i < 3; i++) {
|
||||
if (events[i]) {
|
||||
cJSON* id = cJSON_GetObjectItem(events[i], "id");
|
||||
cJSON* kind = cJSON_GetObjectItem(events[i], "kind");
|
||||
cJSON* created = cJSON_GetObjectItem(events[i], "created_at");
|
||||
printf(" event[%d]: id=%s kind=%d created_at=%lld\n",
|
||||
i,
|
||||
(id && cJSON_IsString(id)) ? id->valuestring : "?",
|
||||
(kind && cJSON_IsNumber(kind)) ? (int)kind->valuedouble : -1,
|
||||
(created && cJSON_IsNumber(created)) ? (long long)created->valuedouble : 0);
|
||||
cJSON_Delete(events[i]);
|
||||
}
|
||||
}
|
||||
/* Free remaining */
|
||||
for (int i = 3; i < result_count; i++) {
|
||||
if (events[i]) cJSON_Delete(events[i]);
|
||||
}
|
||||
free(events);
|
||||
}
|
||||
|
||||
cJSON_Delete(filter);
|
||||
|
||||
printf("\n");
|
||||
if (ctx.got_eose) {
|
||||
printf("✓ SUCCESS: EOSE received, WebSocket client works correctly\n");
|
||||
return 0;
|
||||
} else if (ctx.got_timeout) {
|
||||
printf("✗ FAILURE: Timed out without EOSE — WebSocket client bug confirmed\n");
|
||||
return 1;
|
||||
} else {
|
||||
printf("✗ FAILURE: No EOSE and no timeout — unexpected state\n");
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# test_ws_client Makefile — builds the standalone WebSocket client test
|
||||
# using nostr_core_lib, matching the caching/ build pattern.
|
||||
|
||||
CC = gcc
|
||||
CFLAGS = -Wall -Wextra -std=c99 -g -O0
|
||||
|
||||
NOSTR_CORE_DIR = ../nostr_core_lib
|
||||
|
||||
INCLUDES = -I. -I$(NOSTR_CORE_DIR) -I$(NOSTR_CORE_DIR)/nostr_core \
|
||||
-I$(NOSTR_CORE_DIR)/cjson -I$(NOSTR_CORE_DIR)/nostr_websocket
|
||||
|
||||
LIBS = -lssl -lcrypto -lwebsockets -lsecp256k1 -lz -ldl -lpthread -lm -lsqlite3 -lcurl
|
||||
|
||||
ARCH = $(shell uname -m)
|
||||
ifeq ($(ARCH),x86_64)
|
||||
NOSTR_CORE_LIB = $(NOSTR_CORE_DIR)/libnostr_core_x64.a
|
||||
else ifeq ($(ARCH),aarch64)
|
||||
NOSTR_CORE_LIB = $(NOSTR_CORE_DIR)/libnostr_core_arm64.a
|
||||
else ifeq ($(ARCH),arm64)
|
||||
NOSTR_CORE_LIB = $(NOSTR_CORE_DIR)/libnostr_core_arm64.a
|
||||
else
|
||||
NOSTR_CORE_LIB = $(NOSTR_CORE_DIR)/libnostr_core_x64.a
|
||||
endif
|
||||
|
||||
TARGET = test_ws_client
|
||||
|
||||
all: $(TARGET)
|
||||
|
||||
$(TARGET): test_ws_client.c $(NOSTR_CORE_LIB)
|
||||
@echo "Building test_ws_client..."
|
||||
@rm -rf /tmp/twsc_lib && mkdir -p /tmp/twsc_lib && \
|
||||
ar x $(NOSTR_CORE_LIB) --output=/tmp/twsc_lib && \
|
||||
$(CC) $(CFLAGS) $(INCLUDES) test_ws_client.c /tmp/twsc_lib/*.o -o $(TARGET) $(LIBS) && \
|
||||
rm -rf /tmp/twsc_lib && \
|
||||
echo "Build complete: $(TARGET)"
|
||||
|
||||
clean:
|
||||
rm -f $(TARGET)
|
||||
|
||||
.PHONY: all clean
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* test_ws_raw.c — Minimal raw WebSocket test using nostr_core_lib.
|
||||
* Directly calls nostr_ws_connect + nostr_ws_receive to bypass
|
||||
* synchronous_query_relays_with_progress and see exactly what the
|
||||
* WebSocket client returns.
|
||||
*/
|
||||
|
||||
#define _GNU_SOURCE
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "nostr_websocket_tls.h"
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
if (argc < 3) {
|
||||
fprintf(stderr, "Usage: %s <relay_url> <pubkey_hex> [limit]\n", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const char* relay_url = argv[1];
|
||||
const char* pubkey = argv[2];
|
||||
int limit = (argc >= 4) ? atoi(argv[3]) : 10;
|
||||
|
||||
printf("Connecting to %s ...\n", relay_url);
|
||||
fflush(stdout);
|
||||
|
||||
nostr_ws_client_t* ws = nostr_ws_connect(relay_url);
|
||||
if (!ws) {
|
||||
printf("FAILED to connect\n");
|
||||
return 1;
|
||||
}
|
||||
printf("Connected!\n");
|
||||
fflush(stdout);
|
||||
|
||||
/* Build REQ message */
|
||||
cJSON* filter = cJSON_CreateObject();
|
||||
cJSON* authors = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(pubkey));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
cJSON* kinds = cJSON_CreateArray();
|
||||
int kind_list[] = {0, 1, 3, 6, 10000, 10002, 30023};
|
||||
for (int i = 0; i < 7; i++) {
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber((double)kind_list[i]));
|
||||
}
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddItemToObject(filter, "limit", cJSON_CreateNumber((double)limit));
|
||||
long now = (long)time(NULL);
|
||||
cJSON_AddItemToObject(filter, "until", cJSON_CreateNumber((double)now));
|
||||
|
||||
cJSON* req = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(req, cJSON_CreateString("REQ"));
|
||||
cJSON_AddItemToArray(req, cJSON_CreateString("test_raw"));
|
||||
cJSON_AddItemToArray(req, filter);
|
||||
|
||||
char* req_str = cJSON_PrintUnformatted(req);
|
||||
printf("Sending REQ: %.200s...\n", req_str);
|
||||
fflush(stdout);
|
||||
|
||||
int send_ret = nostr_ws_send_text(ws, req_str);
|
||||
printf("send_text returned: %d\n", send_ret);
|
||||
fflush(stdout);
|
||||
free(req_str);
|
||||
cJSON_Delete(req);
|
||||
|
||||
/* Receive loop */
|
||||
char buffer[262144];
|
||||
int event_count = 0;
|
||||
int eose_received = 0;
|
||||
time_t start = time(NULL);
|
||||
|
||||
printf("\n--- Receiving messages ---\n");
|
||||
fflush(stdout);
|
||||
|
||||
while (1) {
|
||||
double elapsed = difftime(time(NULL), start);
|
||||
if (elapsed > 35) {
|
||||
printf("[%.0fs] *** 35s total timeout reached ***\n", elapsed);
|
||||
break;
|
||||
}
|
||||
|
||||
printf("[%.0fs] calling nostr_ws_receive (timeout=2000ms)...\n", elapsed);
|
||||
fflush(stdout);
|
||||
|
||||
int len = nostr_ws_receive(ws, buffer, sizeof(buffer) - 1, 2000);
|
||||
elapsed = difftime(time(NULL), start);
|
||||
|
||||
if (len < 0) {
|
||||
printf("[%.0fs] nostr_ws_receive returned %d (error/timeout)\n", elapsed, len);
|
||||
fflush(stdout);
|
||||
/* Keep trying — the relay might send data later */
|
||||
continue;
|
||||
}
|
||||
if (len == 0) {
|
||||
printf("[%.0fs] nostr_ws_receive returned 0 (connection closed)\n", elapsed);
|
||||
break;
|
||||
}
|
||||
|
||||
buffer[len] = '\0';
|
||||
printf("[%.0fs] received %d bytes: %.200s\n", elapsed, len, buffer);
|
||||
fflush(stdout);
|
||||
|
||||
/* Parse the message */
|
||||
cJSON* msg = cJSON_Parse(buffer);
|
||||
if (msg && cJSON_IsArray(msg)) {
|
||||
cJSON* type = cJSON_GetArrayItem(msg, 0);
|
||||
if (type && cJSON_IsString(type)) {
|
||||
if (strcmp(type->valuestring, "EVENT") == 0) {
|
||||
event_count++;
|
||||
if (event_count % 50 == 0) {
|
||||
printf(" >>> %d events so far\n", event_count);
|
||||
}
|
||||
} else if (strcmp(type->valuestring, "EOSE") == 0) {
|
||||
eose_received = 1;
|
||||
printf(" >>> EOSE received! total events=%d\n", event_count);
|
||||
break;
|
||||
} else if (strcmp(type->valuestring, "NOTICE") == 0) {
|
||||
cJSON* notice = cJSON_GetArrayItem(msg, 1);
|
||||
printf(" >>> NOTICE: %s\n", notice ? notice->valuestring : "?");
|
||||
} else if (strcmp(type->valuestring, "CLOSED") == 0) {
|
||||
printf(" >>> CLOSED\n");
|
||||
break;
|
||||
} else if (strcmp(type->valuestring, "AUTH") == 0) {
|
||||
printf(" >>> AUTH challenge received\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (msg) cJSON_Delete(msg);
|
||||
}
|
||||
|
||||
printf("\n=== RESULTS ===\n");
|
||||
printf("Events: %d\n", event_count);
|
||||
printf("EOSE: %s\n", eose_received ? "YES" : "NO");
|
||||
printf("Elapsed: %.0fs\n", difftime(time(NULL), start));
|
||||
|
||||
nostr_ws_close(ws);
|
||||
|
||||
return eose_received ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user