Compare commits

..
4 Commits
60 changed files with 31680 additions and 1365 deletions
+1 -1
View File
@@ -11,4 +11,4 @@ copy_executable_local.sh
nostr_login_lite/
style_guide/
nostr-tools
.test_keys
.test_keysadmin/cache/
+2
View File
@@ -0,0 +1,2 @@
ADMIN_PUBKEY='8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e'
SERVER_PRIVKEY='1111111111111111111111111111111111111111111111111111111111111111'
-133
View File
@@ -1,133 +0,0 @@
# C-Relay-PG PHP Admin Page
A traditional PHP admin interface for the caching service that connects
directly to PostgreSQL, bypassing the NIP-44 64KB encryption limit of the
Nostr admin API. Handles thousands of followed pubkeys via server-side
pagination.
## Quick Start
### 1. Install PHP + PHP-FPM + PostgreSQL extension
```bash
# Debian/Ubuntu
sudo apt install php-fpm php-pgsql
# RHEL/Fedora/Alma
sudo dnf install php-fpm php-pgsql
```
### 2. Copy admin files to the server
```bash
sudo mkdir -p /opt/c-relay-pg/admin
sudo cp -r admin/* /opt/c-relay-pg/admin/
```
### 3. Configure database credentials
Edit `/opt/c-relay-pg/admin/lib/config.php` and set the PostgreSQL
connection parameters to match your relay's `--db-*` flags:
```php
return [
'db_host' => 'localhost',
'db_port' => '5432',
'db_name' => 'crelay',
'db_user' => 'crelay',
'db_password' => 'crelay',
// ...
];
```
### 4. Set up HTTP Basic Auth
```bash
sudo htpasswd -c /opt/c-relay-pg/admin/.htpasswd admin
# Enter a password when prompted
```
### 5. Add nginx location block
Add this to your existing nginx server block (the one that proxies to
the relay on port 8888):
```nginx
# PHP admin page for caching service
location /admin/ {
alias /opt/c-relay-pg/admin/;
index index.php;
auth_basic "Relay Admin";
auth_basic_user_file /opt/c-relay-pg/admin/.htpasswd;
location ~ \.php$ {
fastcgi_pass unix:/run/php/php-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;
}
}
```
> **Note:** The `fastcgi_pass` socket path varies by distro:
> - Debian/Ubuntu: `unix:/run/php/php8.2-fpm.sock`
> - RHEL/Fedora: `unix:/run/php-fpm/www.sock`
>
> Check with: `ls /run/php/` or `ls /run/php-fpm/`
### 6. Reload nginx
```bash
sudo nginx -t && sudo systemctl reload nginx
```
### 7. Visit the admin page
```
https://relay.yourdomain.com/admin/
```
Enter the HTTP Basic Auth credentials you set in step 4.
## Pages
| Page | URL | Description |
|------|-----|-------------|
| Dashboard | `/admin/` | Service state, backfill progress, error summary, active target |
| Follows | `/admin/follows.php` | Paginated followed-pubkey table with names, event counts, relay status |
| Relays | `/admin/relays.php` | Per (author, relay) backfill progress with descriptive error statuses |
| Config | `/admin/config-edit.php` | Read/edit caching config values; bumps config generation on save |
| Inbox | `/admin/inbox.php` | Queue depth monitor for `caching_event_inbox` |
## Security
- **HTTP Basic Auth** protects all pages (nginx `auth_basic`)
- **`lib/` directory denied** via nginx (contains DB credentials)
- **PDO prepared statements** everywhere (no SQL injection)
- **HTTPS** via existing nginx SSL config
- **No CORS needed** — same origin as the relay
## How It Works
The PHP pages query PostgreSQL directly using PDO with the same
`crelay` database user the relay uses. The dashboard auto-refreshes
every 10 seconds via AJAX polling of `api/status.php`. Paginated tables
(follows, relays) use server-side pagination with `LIMIT`/`OFFSET`.
The config editor writes to the `config` table and bumps
`caching_config_generation`, which the caching service detects and
hot-reloads — no relay restart needed.
## Upgrading to Real-Time (Future)
If you later need true push updates (sub-second), you can add a small
Node.js WebSocket server alongside the PHP pages that uses PostgreSQL
`LISTEN/NOTIFY` to push updates to connected browsers. The PHP pages
and JSON endpoints are reusable — the WebSocket server would just
replace the polling `fetch()` calls in the JavaScript.
+37
View File
@@ -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]);
+123
View File
@@ -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]);
+120
View File
@@ -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);
}
+20
View File
@@ -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]);
+16
View File
@@ -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]);
+32
View File
@@ -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]);
+35
View File
@@ -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]);
+38
View File
@@ -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']);
}
+24
View File
@@ -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()]);
}
+154
View File
@@ -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';
}
-69
View File
@@ -1,69 +0,0 @@
<?php
/**
* C-Relay-PG Admin — JSON status endpoint for dashboard polling.
*
* Returns all dashboard stats in a single JSON object.
* Called by index.php's JavaScript every 10 seconds.
*/
require_once __DIR__ . '/../lib/helpers.php';
$pdo = db();
// Service state
$state = $pdo->query("SELECT * FROM caching_service_state WHERE id = 1")->fetch();
// Active backfill target (table may not exist yet on fresh start)
$active = ['active_pubkey' => '', 'active_relay' => ''];
try {
$active = $pdo->query("SELECT active_pubkey, active_relay FROM caching_backfill_active WHERE id = 1")->fetch() ?: $active;
} catch (PDOException $e) { /* table doesn't exist yet */ }
// Error relay summary (consecutive_errors column may not exist yet on fresh start)
$error_stats = ['auto_completed' => 0, 'error_count' => 0, 'timeout_count' => 0];
try {
$error_stats = $pdo->query("
SELECT
COUNT(*) FILTER (WHERE consecutive_errors >= 3) AS auto_completed,
COUNT(*) FILTER (WHERE last_status LIKE 'error%') AS error_count,
COUNT(*) FILTER (WHERE last_status = 'timeout') AS timeout_count
FROM caching_backfill_relay_progress
")->fetch() ?: $error_stats;
} catch (PDOException $e) { /* column/table doesn't exist yet */ }
// Inbox stats
$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();
// Event count
$events = $pdo->query("SELECT COUNT(*) AS total FROM events")->fetch();
// Heartbeat age
$hb_age = intval($state['heartbeat_at'] ?? 0);
$hb_ago = $hb_age > 0 ? time() - $hb_age : 0;
json_response([
'service_state' => $state['service_state'] ?? 'unknown',
'heartbeat_ago' => $hb_ago > 0 ? $hb_ago . 's ago' : 'never',
'config_generation' => intval($state['config_generation'] ?? 0),
'followed_count' => intval($state['followed_author_count'] ?? 0),
'selected_relays' => intval($state['selected_relay_count'] ?? 0),
'connected_relays' => intval($state['connected_relay_count'] ?? 0),
'bf_complete' => intval($state['backfill_authors_complete'] ?? 0),
'bf_total' => intval($state['backfill_authors_total'] ?? 0),
'events_fetched' => intval($state['events_fetched'] ?? 0),
'inbox_inserts' => intval($state['inbox_inserts'] ?? 0),
'db_events' => intval($events['total'] ?? 0),
'inbox_pending' => intval($inbox['pending'] ?? 0),
'inbox_live' => intval($inbox['live'] ?? 0),
'inbox_backfill' => intval($inbox['backfill'] ?? 0),
'error_count' => intval($error_stats['error_count'] ?? 0),
'timeout_count' => intval($error_stats['timeout_count'] ?? 0),
'auto_completed' => intval($error_stats['auto_completed'] ?? 0),
'active_pubkey' => $active['active_pubkey'] ?? '',
'active_relay' => $active['active_relay'] ?? '',
]);
+11
View File
@@ -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]);
+956
View File
@@ -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
-419
View File
@@ -1,419 +0,0 @@
/*
* C-Relay-PG Admin — PHP caching service admin pages.
* Styled to match the original api/index.css Nostr admin interface.
* Courier New monospace, black/white/red color scheme, dark mode by default.
*/
:root {
/* Core Variables — matches api/index.css light mode (default) */
--primary-color: #000000;
--secondary-color: #ffffff;
--accent-color: #ff0000;
--muted-color: #dddddd;
--border-color: var(--muted-color);
--font-family: "Courier New", Courier, monospace;
--border-radius: 5px;
--border-width: 1px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-family);
background-color: var(--secondary-color);
color: var(--primary-color);
padding: 0;
max-width: 1200px;
margin: 0 auto;
}
a {
color: var(--primary-color);
text-decoration: none;
}
a:hover { color: var(--accent-color); }
/* ================================
SIDE NAVIGATION (matches original)
================================ */
.side-nav {
position: fixed;
top: 0;
left: -300px;
width: 280px;
height: 100vh;
background: var(--secondary-color);
border-right: var(--border-width) solid var(--border-color);
z-index: 1000;
transition: left 0.3s ease;
overflow-y: auto;
padding-top: 80px;
}
.side-nav.open { left: 0; }
.side-nav-overlay {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
display: none;
}
.side-nav-overlay.show { display: block; }
.nav-menu { list-style: none; padding: 0; margin: 0; }
.nav-menu li { border-bottom: var(--border-width) solid var(--muted-color); }
.nav-menu li:last-child { border-bottom: none; }
.nav-item {
display: block;
padding: 15px 20px;
color: var(--primary-color);
text-decoration: none;
font-family: var(--font-family);
font-size: 16px;
font-weight: bold;
transition: all 0.2s ease;
cursor: pointer;
border: 2px solid var(--secondary-color);
background: none;
width: 100%;
text-align: left;
}
.nav-item:hover {
background: var(--muted-color);
color: var(--accent-color);
}
.nav-item.active {
text-decoration: underline;
padding-left: 16px;
}
.nav-footer {
position: absolute;
bottom: 20px; left: 0; right: 0;
padding: 0 20px;
}
.nav-footer-btn {
display: block;
width: 100%;
padding: 12px 20px;
margin-bottom: 8px;
color: var(--primary-color);
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: var(--font-family);
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s ease;
background: none;
}
.nav-footer-btn:hover {
background: var(--muted-color);
border-color: var(--accent-color);
}
/* ================================
HEADER (matches original)
================================ */
.main-header {
background-color: var(--secondary-color);
padding: 15px 20px;
z-index: 100;
max-width: 1200px;
margin: 0 auto;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
}
.header-title {
margin: 0;
font-size: 24px;
font-weight: bolder;
color: var(--primary-color);
cursor: pointer;
transition: all 0.2s ease;
}
.header-title:hover { opacity: 0.8; }
.header-title .relay-letter { display: inline-block; }
.menu-btn {
background: none;
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
color: var(--primary-color);
font-family: var(--font-family);
font-size: 20px;
padding: 5px 12px;
cursor: pointer;
width: auto;
margin: 0;
}
.menu-btn:hover { border-color: var(--accent-color); }
/* ================================
SECTIONS (matches original)
================================ */
.section {
background: var(--secondary-color);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
padding: 20px;
margin-bottom: 20px;
margin-left: 5px;
margin-right: 5px;
}
.section-header {
display: flex;
justify-content: center;
align-items: center;
padding-bottom: 15px;
font-size: 16px;
font-weight: normal;
font-family: var(--font-family);
color: var(--primary-color);
}
/* ================================
TABLES (matches .config-table)
================================ */
.config-table {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin: 10px 0;
overflow: hidden;
}
.config-table th,
.config-table td {
border: 0.1px solid var(--muted-color);
padding: 4px 8px;
text-align: left;
font-family: var(--font-family);
font-size: 10px;
}
.config-table th {
font-weight: bold;
height: 24px;
line-height: 24px;
}
.config-table tbody tr:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.config-table-container {
overflow-x: auto;
max-width: 100%;
}
/* ================================
INPUTS / BUTTONS (matches original)
================================ */
input, textarea, select {
width: 100%;
padding: 8px;
background: var(--secondary-color);
color: var(--primary-color);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
font-family: var(--font-family);
font-size: 14px;
box-sizing: border-box;
transition: all 0.2s ease;
}
input:focus, textarea:focus, select:focus {
border-color: var(--accent-color);
outline: none;
}
button {
width: 100%;
padding: 8px;
background: var(--secondary-color);
color: var(--primary-color);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
font-family: var(--font-family);
font-size: 14px;
cursor: pointer;
margin: 5px 0;
font-weight: bold;
transition: all 0.2s ease;
}
button:hover { border-color: var(--accent-color); }
button:active {
background: var(--accent-color);
color: var(--secondary-color);
}
/* ================================
STAT CARDS (dashboard)
================================ */
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
margin-bottom: 20px;
}
.card {
background: var(--secondary-color);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
padding: 12px;
}
.card .label {
color: var(--muted-color);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.card .value {
font-size: 20px;
font-weight: bold;
margin-top: 4px;
}
.card .sub {
color: var(--muted-color);
font-size: 10px;
margin-top: 4px;
}
/* ================================
STATUS BADGES
================================ */
.badge {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: bold;
border: var(--border-width) solid var(--border-color);
}
.badge-success { border-color: #4caf50; color: #4caf50; }
.badge-error { border-color: var(--accent-color); color: var(--accent-color); }
.badge-warning { border-color: #ff9800; color: #ff9800; }
.badge-muted { border-color: var(--muted-color); color: var(--muted-color); }
/* ================================
STATUS INDICATORS
================================ */
.status-working { color: var(--accent-color); }
.status-complete { color: #4caf50; }
.status-error { color: var(--accent-color); }
/* ================================
PAGINATION
================================ */
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
gap: 16px;
}
.pagination a {
padding: 6px 14px;
background: var(--secondary-color);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
color: var(--primary-color);
font-size: 14px;
width: auto;
}
.pagination a:hover {
border-color: var(--accent-color);
color: var(--accent-color);
text-decoration: none;
}
.pagination span { color: var(--muted-color); font-size: 12px; }
/* ================================
FILTERS BAR
================================ */
.filters {
display: flex;
gap: 10px;
margin-bottom: 16px;
flex-wrap: wrap;
align-items: center;
}
.filters select, .filters input {
width: auto;
min-width: 120px;
}
.filters input[type="text"] { min-width: 250px; }
.filters button {
width: auto;
margin: 0;
min-width: 80px;
}
/* ================================
CONFIG EDITOR FORM
================================ */
.config-form .row {
display: grid;
grid-template-columns: 250px 1fr;
gap: 12px;
padding: 12px 0;
border-bottom: 0.1px solid var(--muted-color);
align-items: start;
}
.config-form .row label {
font-weight: bold;
color: var(--primary-color);
font-size: 12px;
}
.config-form .row .hint {
color: var(--muted-color);
font-size: 10px;
margin-top: 4px;
}
.config-form button {
width: auto;
margin-top: 16px;
padding: 10px 24px;
}
/* ================================
MISC
================================ */
.pubkey {
font-family: var(--font-family);
font-size: 10px;
color: var(--muted-color);
}
.npub-link {
font-family: var(--font-family);
font-size: 10px;
color: var(--primary-color);
text-decoration: none;
}
.npub-link:hover { color: var(--accent-color); }
h2 {
font-weight: normal;
text-align: center;
font-size: 16px;
font-family: var(--font-family);
color: var(--primary-color);
margin-bottom: 16px;
}
/* ================================
RESPONSIVE
================================ */
@media (max-width: 768px) {
.cards { grid-template-columns: 1fr 1fr; }
.config-form .row { grid-template-columns: 1fr; }
.filters { flex-direction: column; align-items: stretch; }
.filters select, .filters input, .filters button { width: 100%; }
}
+15
View File
@@ -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
+15
View File
@@ -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
+15
View File
@@ -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
-99
View File
@@ -1,99 +0,0 @@
<?php
/**
* C-Relay-PG Admin — Caching Config Editor.
* On save, bumps caching_config_generation so the caching service hot-reloads.
*/
require_once __DIR__ . '/lib/helpers.php';
$pdo = db();
$message = '';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$fields = [
'caching_root_npubs', 'caching_bootstrap_relays', 'caching_kinds',
'caching_admin_kinds', 'caching_live_enabled', 'caching_backfill_enabled',
'caching_backfill_page_size', 'caching_backfill_tick_interval_ms',
'caching_follow_graph_refresh_seconds', 'caching_relay_discovery_refresh_seconds',
'caching_max_followed_pubkeys', 'caching_max_upstream_relays',
'caching_max_relays_per_pubkey', 'caching_query_timeout_ms',
'caching_live_resubscribe_seconds',
];
try {
$pdo->beginTransaction();
foreach ($fields as $key) {
$val = $_POST[$key] ?? null;
if ($val === null) continue;
$pdo->prepare("UPDATE config SET value = ? WHERE key = ?")->execute([$val, $key]);
}
$gen = $pdo->query("SELECT value FROM config WHERE key = 'caching_config_generation'")->fetchColumn();
$new_gen = intval($gen) + 1;
$pdo->prepare("UPDATE config SET value = ? WHERE key = 'caching_config_generation'")->execute([$new_gen]);
$pdo->commit();
$message = "Configuration saved. Config generation bumped to $new_gen. The caching service will reload automatically.";
} catch (Exception $e) {
$pdo->rollBack();
$error = 'Failed to save: ' . $e->getMessage();
}
}
$config_keys = [
'caching_root_npubs' => 'Root npubs (comma-separated npub... values)',
'caching_bootstrap_relays' => 'Bootstrap relays (comma-separated wss://... URLs)',
'caching_kinds' => 'Kinds to cache (comma-separated integers)',
'caching_admin_kinds' => 'Admin kinds (* for all, or comma-separated)',
'caching_live_enabled' => 'Live subscription enabled (true/false)',
'caching_backfill_enabled' => 'Backfill enabled (true/false)',
'caching_backfill_page_size' => 'Backfill page size (events per tick)',
'caching_backfill_tick_interval_ms' => 'Backfill tick interval (ms)',
'caching_follow_graph_refresh_seconds' => 'Follow graph refresh interval (seconds)',
'caching_relay_discovery_refresh_seconds' => 'Relay discovery refresh (seconds)',
'caching_max_followed_pubkeys' => 'Max followed pubkeys',
'caching_max_upstream_relays' => 'Max upstream relays',
'caching_max_relays_per_pubkey' => 'Max relays per pubkey',
'caching_query_timeout_ms' => 'Query timeout (ms)',
'caching_live_resubscribe_seconds' => 'Live resubscribe interval (seconds)',
];
$values = [];
foreach (array_keys($config_keys) as $key) {
$stmt = $pdo->prepare("SELECT value FROM config WHERE key = ?");
$stmt->execute([$key]);
$values[$key] = $stmt->fetchColumn() ?: '';
}
admin_header('config', 'C-Relay-PG Admin — Config');
?>
<div class="section">
<div class="section-header">CACHING CONFIGURATION</div>
<?php if ($message): ?>
<p class="status-complete" style="padding:10px;border:1px solid #4caf50;border-radius:5px;margin-bottom:16px">✓ <?= e($message) ?></p>
<?php endif; ?>
<?php if ($error): ?>
<p class="status-error" style="padding:10px;border:1px solid var(--accent-color);border-radius:5px;margin-bottom:16px">✗ <?= e($error) ?></p>
<?php endif; ?>
<form class="config-form" method="post">
<?php foreach ($config_keys as $key => $label): ?>
<div class="row">
<div>
<label for="<?= e($key) ?>"><?= e($label) ?></label>
<div class="hint"><?= e($key) ?></div>
</div>
<div>
<input type="text" id="<?= e($key) ?>" name="<?= e($key) ?>" value="<?= e($values[$key]) ?>">
</div>
</div>
<?php endforeach; ?>
<button type="submit">Save & Apply (bumps config generation)</button>
</form>
<p style="margin-top:16px;color:var(--muted-color);font-size:10px">
Saving bumps <code>caching_config_generation</code> so the caching service
detects the change and hot-reloads. No relay restart needed.
</p>
</div>
<?php admin_footer(); ?>
-153
View File
@@ -1,153 +0,0 @@
<?php
/**
* C-Relay-PG Admin — Followed Pubkeys (paginated).
* Replaces the NIP-44-encrypted caching_follows_status admin command.
*/
require_once __DIR__ . '/lib/helpers.php';
$pdo = db();
$page = current_page();
$per = cfg('follows_per_page', 50);
$offset = ($page - 1) * $per;
$filter = query_param('filter', 'all');
$search = query_param('search', '');
$sort = query_param('sort', 'events');
$where = [];
$params = [];
if ($filter === 'incomplete') {
$where[] = 'fp.backfill_complete = false';
} elseif ($filter === 'root') {
$where[] = 'fp.is_root = true';
} elseif ($filter === 'error') {
$where[] = "EXISTS (SELECT 1 FROM caching_backfill_relay_progress rp WHERE rp.author_pubkey = fp.pubkey AND rp.last_status LIKE 'error%')";
}
if ($search) {
$where[] = '(fp.pubkey ILIKE ? OR e.content::json->>\'name\' ILIKE ? OR e.content::json->>\'display_name\' ILIKE ?)';
$params[] = "%$search%"; $params[] = "%$search%"; $params[] = "%$search%";
}
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sort_map = [
'events' => 'fp.events_fetched DESC',
'name' => "COALESCE(e.content::json->>'display_name', e.content::json->>'name') ASC NULLS LAST",
'recent' => 'fp.last_seen DESC',
'complete' => 'fp.backfill_complete ASC, fp.events_fetched DESC',
];
$sort_sql = $sort_map[$sort] ?? $sort_map['events'];
$stmt = $pdo->prepare("SELECT COUNT(*) 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 $where_sql");
$stmt->execute($params);
$total = intval($stmt->fetchColumn());
$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
$where_sql
ORDER BY $sort_sql
LIMIT $per OFFSET $offset
";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$follows = $stmt->fetchAll();
admin_header('follows', 'C-Relay-PG Admin — Follows');
?>
<div class="section">
<div class="section-header">FOLLOWED PUBKEYS (<?= number_format($total) ?> total)</div>
<form class="filters" method="get">
<select name="filter" onchange="this.form.submit()">
<option value="all" <?= $filter==='all'?'selected':'' ?>>All</option>
<option value="incomplete" <?= $filter==='incomplete'?'selected':'' ?>>Incomplete only</option>
<option value="root" <?= $filter==='root'?'selected':'' ?>>Root only</option>
<option value="error" <?= $filter==='error'?'selected':'' ?>>Has error relays</option>
</select>
<select name="sort" onchange="this.form.submit()">
<option value="events" <?= $sort==='events'?'selected':'' ?>>Sort: Events fetched</option>
<option value="name" <?= $sort==='name'?'selected':'' ?>>Sort: Name</option>
<option value="recent" <?= $sort==='recent'?'selected':'' ?>>Sort: Recently seen</option>
<option value="complete" <?= $sort==='complete'?'selected':'' ?>>Sort: Backfill status</option>
</select>
<input type="text" name="search" value="<?= e($search) ?>" placeholder="Search name or pubkey…">
<button type="submit">Search</button>
<?php if ($filter !== 'all' || $search): ?>
<a href="follows.php" style="font-size:12px">Clear</a>
<?php endif; ?>
</form>
<div class="config-table-container">
<table class="config-table">
<thead>
<tr>
<th>Name</th>
<th>npub</th>
<th>Root?</th>
<th>Events in DB</th>
<th>Backfill</th>
<th>Last Seen</th>
<th>Relays</th>
</tr>
</thead>
<tbody>
<?php foreach ($follows as $f):
$npub = hex_to_npub($f['pubkey']);
$name = $f['display_name'] ?: $f['name'];
$rc = ['total' => 0, 'incomplete' => 0, 'errors' => 0];
try {
$relay_count = $pdo->prepare("SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE complete = false) AS incomplete, COUNT(*) FILTER (WHERE last_status LIKE 'error%') AS errors FROM caching_backfill_relay_progress WHERE author_pubkey = ?");
$relay_count->execute([$f['pubkey']]);
$rc = $relay_count->fetch() ?: $rc;
} catch (PDOException $ex) {}
?>
<tr>
<td>
<?php if ($name): ?>
<strong><?= e($name) ?></strong>
<?php if ($f['nip05']): ?>
<br><span style="color:var(--muted-color)">✓ <?= e($f['nip05']) ?></span>
<?php endif; ?>
<?php else: ?>
<span style="color:var(--muted-color);font-style:italic">unknown</span>
<?php endif; ?>
</td>
<td><a href="https://njump.me/<?= e($npub) ?>" target="_blank" class="npub-link"><?= e(trunc($npub, 22)) ?></a></td>
<td><?= $f['is_root'] ? '<span class="badge badge-warning">root</span>' : '' ?></td>
<td><?= number_format(intval($f['total_events'])) ?></td>
<td><?= $f['backfill_complete']
? '<span class="badge badge-success">✓ complete</span>'
: '<span class="badge badge-warning">in progress</span>' ?></td>
<td><?= time_ago(intval($f['last_seen'])) ?></td>
<td>
<?php if (intval($rc['total']) > 0): ?>
<?= intval($rc['total']) ?> relays (<?= intval($rc['incomplete']) ?> incomplete
<?php if (intval($rc['errors']) > 0): ?>
, <span class="status-error"><?= intval($rc['errors']) ?> errors</span>
<?php endif; ?>)
<br><a href="relays.php?pubkey=<?= e($f['pubkey']) ?>" style="font-size:10px">View relay details →</a>
<?php else: ?>
<span style="color:var(--muted-color)">—</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($follows)): ?>
<tr><td colspan="7" style="text-align:center;color:var(--muted-color);padding:20px">No followed pubkeys found.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?= pagination($page, $per, $total, 'follows.php' . ($filter !== 'all' ? '?filter=' . e($filter) : '') . ($search ? '&search=' . e($search) : '') . ($sort !== 'events' ? '&sort=' . e($sort) : '')) ?>
</div>
<?php admin_footer(); ?>
-111
View File
@@ -1,111 +0,0 @@
<?php
/**
* C-Relay-PG Admin — Inbox Queue Monitor.
*/
require_once __DIR__ . '/lib/helpers.php';
$pdo = db();
$stats = $pdo->query("
SELECT
COUNT(*) AS pending,
COUNT(*) FILTER (WHERE source_class = 'live') AS live,
COUNT(*) FILTER (WHERE source_class = 'backfill') AS backfill,
COUNT(*) FILTER (WHERE source_class = 'discovery') AS discovery,
COUNT(*) FILTER (WHERE priority = 0) AS high_priority,
COALESCE(EXTRACT(EPOCH FROM NOW())::BIGINT - MIN(received_at), 0) AS oldest_age
FROM caching_event_inbox
")->fetch() ?: ['pending' => 0, 'live' => 0, 'backfill' => 0, 'discovery' => 0, 'high_priority' => 0, 'oldest_age' => 0];
$recent = $pdo->query("
SELECT event_id, event_json->>'pubkey' AS pubkey, event_json->>'kind' AS kind,
source_relay, source_class, priority, received_at
FROM caching_event_inbox
ORDER BY received_at DESC
LIMIT 20
")->fetchAll();
admin_header('inbox', 'C-Relay-PG Admin — Inbox');
?>
<div class="section">
<div class="section-header">INBOX QUEUE MONITOR</div>
<div class="cards">
<div class="card">
<div class="label">Pending Events</div>
<div class="value" id="inbox-pending"><?= number_format(intval($stats['pending'])) ?></div>
<div class="sub" id="inbox-detail"><?= intval($stats['live']) ?> live, <?= intval($stats['backfill']) ?> backfill, <?= intval($stats['discovery']) ?> discovery</div>
</div>
<div class="card">
<div class="label">High Priority</div>
<div class="value"><?= number_format(intval($stats['high_priority'])) ?></div>
<div class="sub">priority = 0 (live events)</div>
</div>
<div class="card">
<div class="label">Oldest Pending</div>
<div class="value" id="inbox-oldest"><?= intval($stats['oldest_age']) ?>s</div>
<div class="sub">age of oldest event in queue</div>
</div>
</div>
<?php if (intval($stats['oldest_age']) > 300): ?>
<p class="status-error" style="padding:10px;border:1px solid var(--accent-color);border-radius:5px;margin-bottom:16px">
⚠ Oldest pending event is <?= intval($stats['oldest_age']) ?>s old — the inbox poller may not be keeping up.
Check that <code>caching_inbox_enabled = true</code> in the config table and the relay is running.
</p>
<?php endif; ?>
</div>
<div class="section">
<div class="section-header">RECENT EVENTS IN QUEUE (last 20)</div>
<div class="config-table-container">
<table class="config-table">
<thead>
<tr>
<th>Event ID</th>
<th>Pubkey</th>
<th>Kind</th>
<th>Source Relay</th>
<th>Class</th>
<th>Priority</th>
<th>Received</th>
</tr>
</thead>
<tbody>
<?php foreach ($recent as $r): ?>
<tr>
<td class="pubkey"><?= e(trunc($r['event_id'], 16)) ?></td>
<td class="pubkey"><?= e(trunc($r['pubkey'], 16)) ?></td>
<td><?= e($r['kind']) ?></td>
<td><?= e($r['source_relay'] ?? '—') ?></td>
<td><span class="badge badge-muted"><?= e($r['source_class']) ?></span></td>
<td><?= intval($r['priority']) ?></td>
<td><?= time_ago(intval($r['received_at'])) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($recent)): ?>
<tr><td colspan="7" style="text-align:center;color:var(--muted-color);padding:20px">Inbox is empty — all events have been dequeued.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<script>
async function refreshInbox() {
try {
const res = await fetch('api/status.php');
if (!res.ok) return;
const d = await res.json();
console.log('[inbox] refreshed at', new Date().toLocaleTimeString(), '— pending:', d.inbox_pending);
const p = document.getElementById('inbox-pending');
const det = document.getElementById('inbox-detail');
if (p) p.textContent = (d.inbox_pending ?? 0).toLocaleString();
if (det) det.textContent = (d.inbox_live ?? 0) + ' live, ' + (d.inbox_backfill ?? 0) + ' backfill';
} catch (e) {}
}
setInterval(refreshInbox, <?= cfg('refresh_ms', 10000) ?>);
</script>
<?php admin_footer(); ?>
+419 -148
View File
@@ -1,158 +1,429 @@
<?php
/**
* C-Relay-PG Admin — Dashboard.
* Auto-refreshes every 10 seconds via AJAX polling of api/status.php.
* admin2Full 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();
// Service state
$state = $pdo->query("SELECT * FROM caching_service_state WHERE id = 1")->fetch() ?: [];
// Active backfill target (table may not exist yet on fresh start)
$active = ['active_pubkey' => '', 'active_relay' => ''];
// Fetch relay info for the header (from config table)
$relay_name = '';
$relay_desc = '';
$relay_pubkey = '';
$relay_version = '';
try {
$active = $pdo->query("SELECT active_pubkey, active_relay FROM caching_backfill_active WHERE id = 1")->fetch() ?: $active;
$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) {}
// Error relay summary
$error_stats = ['auto_completed' => 0, 'error_count' => 0, 'timeout_count' => 0, 'incomplete' => 0, 'total' => 0];
try {
$error_stats = $pdo->query("
SELECT
COUNT(*) FILTER (WHERE consecutive_errors >= 3) AS auto_completed,
COUNT(*) FILTER (WHERE last_status LIKE 'error%') AS error_count,
COUNT(*) FILTER (WHERE last_status = 'timeout') AS timeout_count,
COUNT(*) FILTER (WHERE complete = false) AS incomplete,
COUNT(*) AS total
FROM caching_backfill_relay_progress
")->fetch() ?: $error_stats;
} catch (PDOException $e) {}
// Inbox stats
$inbox = $pdo->query("
SELECT
COUNT(*) AS pending,
COUNT(*) FILTER (WHERE source_class = 'live') AS live,
COUNT(*) FILTER (WHERE source_class = 'backfill') AS backfill,
COALESCE(EXTRACT(EPOCH FROM NOW())::BIGINT - MIN(received_at), 0) AS oldest_age
FROM caching_event_inbox
")->fetch() ?: ['pending' => 0, 'live' => 0, 'backfill' => 0, 'oldest_age' => 0];
// Event counts
$event_stats = $pdo->query("SELECT COUNT(*) AS total, COUNT(DISTINCT pubkey) AS authors FROM events")->fetch() ?: ['total' => 0, 'authors' => 0];
$kind_stats = $pdo->query("SELECT kind, COUNT(*) AS cnt FROM events GROUP BY kind ORDER BY cnt DESC LIMIT 10")->fetchAll();
admin_header('dashboard', 'C-Relay-PG Admin — Dashboard');
?>
<div class="section">
<div class="section-header">CACHING SERVICE STATUS</div>
<div class="cards" id="dashboard-cards">
<div class="card">
<div class="label">Service State</div>
<div class="value" id="card-state"><?= e($state['service_state'] ?? 'unknown') ?></div>
<div class="sub" id="card-heartbeat">Heartbeat: <?= time_ago(intval($state['heartbeat_at'] ?? 0)) ?></div>
</div>
<div class="card">
<div class="label">Followed Authors</div>
<div class="value" id="card-follows"><?= intval($state['followed_author_count'] ?? 0) ?></div>
<div class="sub" id="card-relays"><?= intval($state['selected_relay_count'] ?? 0) ?> relays, <?= intval($state['connected_relay_count'] ?? 0) ?> connected</div>
</div>
<div class="card">
<div class="label">Backfill Progress</div>
<div class="value" id="card-backfill"><?= intval($state['backfill_authors_complete'] ?? 0) ?> / <?= intval($state['backfill_authors_total'] ?? 0) ?></div>
<div class="sub" id="card-backfill-pct"><?= ($state['backfill_authors_total'] ?? 0) > 0 ? round(intval($state['backfill_authors_complete'] ?? 0) / intval($state['backfill_authors_total']) * 100) : 0 ?>% complete</div>
</div>
<div class="card">
<div class="label">Events Fetched</div>
<div class="value" id="card-events"><?= number_format(intval($state['events_fetched'] ?? 0)) ?></div>
<div class="sub" id="card-inserts"><?= number_format(intval($state['inbox_inserts'] ?? 0)) ?> inbox inserts</div>
</div>
<div class="card">
<div class="label">Events in DB</div>
<div class="value" id="card-db-events"><?= number_format(intval($event_stats['total'] ?? 0)) ?></div>
<div class="sub"><?= number_format(intval($event_stats['authors'] ?? 0)) ?> authors</div>
</div>
<div class="card">
<div class="label">Inbox Pending</div>
<div class="value" id="card-inbox"><?= number_format(intval($inbox['pending'] ?? 0)) ?></div>
<div class="sub" id="card-inbox-detail"><?= intval($inbox['live'] ?? 0) ?> live, <?= intval($inbox['backfill'] ?? 0) ?> backfill</div>
</div>
<div class="card">
<div class="label">Error Relays</div>
<div class="value status-error" id="card-errors"><?= intval($error_stats['error_count'] ?? 0) ?></div>
<div class="sub" id="card-errors-detail"><?= intval($error_stats['auto_completed'] ?? 0) ?> auto-completed</div>
</div>
<div class="card">
<div class="label">Config Generation</div>
<div class="value" id="card-gen"><?= intval($state['config_generation'] ?? 0) ?></div>
<div class="sub">Updated <?= time_ago(intval($state['updated_at'] ?? 0)) ?></div>
</div>
</div>
</div>
<div class="section">
<div class="section-header">ACTIVE BACKFILL TARGET</div>
<div id="active-target" style="text-align:center;padding:10px">
<?php if (!empty($active['active_pubkey'])): ?>
<p class="status-working">⚡ Working on: <span class="pubkey"><?= e(trunc($active['active_pubkey'], 16)) ?></span> @ <?= e($active['active_relay'] ?? '') ?></p>
<?php else: ?>
<p class="status-complete">✓ Caching complete — no active backfill target</p>
<?php endif; ?>
</div>
</div>
<div class="section">
<div class="section-header">TOP EVENT KINDS</div>
<div class="config-table-container">
<table class="config-table">
<thead><tr><th>Kind</th><th>Count</th></tr></thead>
<tbody id="kind-table">
<?php foreach ($kind_stats as $ks): ?>
<tr><td><?= intval($ks['kind']) ?></td><td><?= number_format(intval($ks['cnt'])) ?></td></tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<script>
const REFRESH_MS = <?= cfg('refresh_ms', 10000) ?>;
async function refreshDashboard() {
console.log('[dashboard] polling api/status.php at', new Date().toLocaleTimeString());
try {
const res = await fetch('api/status.php');
if (!res.ok) { console.warn('[dashboard] status.php returned', res.status); return; }
const d = await res.json();
console.log('[dashboard] refreshed at', new Date().toLocaleTimeString(), '— state:', d.service_state, 'follows:', d.followed_count, 'events:', d.db_events);
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
set('card-state', d.service_state || 'unknown');
set('card-heartbeat', 'Heartbeat: ' + (d.heartbeat_ago || 'never'));
set('card-follows', d.followed_count ?? 0);
set('card-relays', (d.selected_relays ?? 0) + ' relays, ' + (d.connected_relays ?? 0) + ' connected');
set('card-backfill', (d.bf_complete ?? 0) + ' / ' + (d.bf_total ?? 0));
set('card-backfill-pct', (d.bf_total > 0 ? Math.round(d.bf_complete / d.bf_total * 100) : 0) + '% complete');
set('card-events', (d.events_fetched ?? 0).toLocaleString());
set('card-inserts', (d.inbox_inserts ?? 0).toLocaleString() + ' inbox inserts');
set('card-db-events', (d.db_events ?? 0).toLocaleString());
set('card-inbox', (d.inbox_pending ?? 0).toLocaleString());
set('card-inbox-detail', (d.inbox_live ?? 0) + ' live, ' + (d.inbox_backfill ?? 0) + ' backfill');
set('card-errors', d.error_count ?? 0);
set('card-errors-detail', (d.auto_completed ?? 0) + ' auto-completed');
set('card-gen', d.config_generation ?? 0);
const atEl = document.getElementById('active-target');
if (d.active_pubkey && d.active_relay) {
atEl.innerHTML = '<p class="status-working">⚡ Working on: <span class="pubkey">' + d.active_pubkey.substring(0,16) + '…</span> @ ' + d.active_relay + '</p>';
} else if (d.active_pubkey) {
atEl.innerHTML = '<p class="status-working">⚡ Working on: <span class="pubkey">' + d.active_pubkey.substring(0,16) + '…</span></p>';
} else {
atEl.innerHTML = '<p class="status-complete">✓ Caching complete — no active backfill target</p>';
}
} catch (e) { console.error('[dashboard] refresh error:', 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);
}
setInterval(refreshDashboard, REFRESH_MS);
console.log('[dashboard] auto-refresh started, interval:', REFRESH_MS, 'ms');
</script>
<?php admin_footer(); ?>
// 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>
+147
View File
@@ -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;
}
+7 -3
View File
@@ -12,14 +12,18 @@ 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;password=%s',
'pgsql:host=%s;port=%d;dbname=%s;user=%s',
$cfg['db_host'],
$cfg['db_port'],
$cfg['db_name'],
$cfg['db_user'],
$cfg['db_password']
$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,
+56
View File
@@ -126,6 +126,62 @@ 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.
+8754
View File
File diff suppressed because it is too large Load Diff
-132
View File
@@ -1,132 +0,0 @@
<?php
/**
* C-Relay-PG Admin — Per-Relay Backfill Progress.
*/
require_once __DIR__ . '/lib/helpers.php';
$pdo = db();
$page = current_page();
$per = cfg('relays_per_page', 50);
$offset = ($page - 1) * $per;
$filter = query_param('filter', 'all');
$pubkey = query_param('pubkey', '');
$where = [];
$params = [];
if ($filter === 'incomplete') {
$where[] = 'rp.complete = false';
} elseif ($filter === 'error') {
$where[] = "rp.last_status LIKE 'error%'";
} elseif ($filter === 'complete') {
$where[] = 'rp.complete = true';
}
if ($pubkey) {
$where[] = 'rp.author_pubkey = ?';
$params[] = $pubkey;
}
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$total = 0;
$relays = [];
try {
$stmt = $pdo->prepare("SELECT COUNT(*) FROM caching_backfill_relay_progress rp $where_sql");
$stmt->execute($params);
$total = intval($stmt->fetchColumn());
$sql = "
SELECT rp.author_pubkey, rp.relay_url, rp.until_cursor, rp.complete,
rp.events_fetched,
COALESCE(rp.consecutive_errors, 0) AS consecutive_errors,
rp.last_status, rp.updated_at,
e.content::json->>'name' AS name,
e.content::json->>'display_name' AS display_name
FROM caching_backfill_relay_progress rp
LEFT JOIN LATERAL (SELECT content FROM events WHERE pubkey = rp.author_pubkey AND kind = 0 ORDER BY created_at DESC LIMIT 1) e ON true
$where_sql
ORDER BY rp.complete ASC, COALESCE(rp.consecutive_errors, 0) DESC, rp.updated_at DESC
LIMIT $per OFFSET $offset
";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$relays = $stmt->fetchAll();
} catch (PDOException $ex) {}
function status_badge(string $status): string {
if ($status === 'eose') return '<span class="badge badge-success">eose</span>';
if ($status === 'timeout') return '<span class="badge badge-warning">timeout</span>';
if (str_starts_with($status, 'error')) return '<span class="badge badge-error">' . e($status) . '</span>';
if (str_starts_with($status, 'NOTICE')) return '<span class="badge badge-warning">' . e($status) . '</span>';
if (str_starts_with($status, 'CLOSED')) return '<span class="badge badge-warning">' . e($status) . '</span>';
if ($status === '') return '<span class="badge badge-muted">—</span>';
return '<span class="badge badge-muted">' . e($status) . '</span>';
}
admin_header('relays', 'C-Relay-PG Admin — Relay Progress');
?>
<div class="section">
<div class="section-header">PER-RELAY BACKFILL PROGRESS (<?= number_format($total) ?> total)</div>
<form class="filters" method="get">
<select name="filter" onchange="this.form.submit()">
<option value="all" <?= $filter==='all'?'selected':'' ?>>All</option>
<option value="incomplete" <?= $filter==='incomplete'?'selected':'' ?>>Incomplete only</option>
<option value="error" <?= $filter==='error'?'selected':'' ?>>Errors only</option>
<option value="complete" <?= $filter==='complete'?'selected':'' ?>>Complete only</option>
</select>
<?php if ($pubkey): ?>
<input type="hidden" name="pubkey" value="<?= e($pubkey) ?>">
<span style="font-size:10px;color:var(--muted-color)">Filtered by: <span class="pubkey"><?= e(trunc($pubkey, 16)) ?></span></span>
<a href="relays.php?filter=<?= e($filter) ?>" style="font-size:10px">Clear</a>
<?php endif; ?>
</form>
<div class="config-table-container">
<table class="config-table">
<thead>
<tr>
<th>Author</th>
<th>Relay URL</th>
<th>Complete</th>
<th>Events</th>
<th>Errors</th>
<th>Last Status</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
<?php foreach ($relays as $r):
$name = $r['display_name'] ?: $r['name'];
$npub = hex_to_npub($r['author_pubkey']);
?>
<tr <?= intval($r['consecutive_errors']) >= 3 ? 'style="background:rgba(255,0,0,0.05)"' : '' ?>>
<td>
<?php if ($name): ?>
<strong><?= e($name) ?></strong>
<?php else: ?>
<span style="color:var(--muted-color);font-style:italic">unknown</span>
<?php endif; ?>
<br><a href="follows.php?search=<?= e($r['author_pubkey']) ?>" class="npub-link"><?= e(trunc($npub, 20)) ?></a>
</td>
<td><?= e($r['relay_url']) ?></td>
<td><?= $r['complete'] ? '<span class="badge badge-success">✓</span>' : '<span class="badge badge-warning">…</span>' ?></td>
<td><?= number_format(intval($r['events_fetched'])) ?></td>
<td><?= intval($r['consecutive_errors']) > 0
? '<span class="status-error">' . intval($r['consecutive_errors']) . '</span>'
: '0' ?></td>
<td><?= status_badge($r['last_status']) ?></td>
<td><?= time_ago(intval($r['updated_at'])) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($relays)): ?>
<tr><td colspan="7" style="text-align:center;color:var(--muted-color);padding:20px">No relay progress rows found.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?= pagination($page, $per, $total, 'relays.php' . ($filter !== 'all' ? '?filter=' . e($filter) : '') . ($pubkey ? '&pubkey=' . e($pubkey) : '')) ?>
</div>
<?php admin_footer(); ?>
Executable
+27
View File
@@ -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
+4 -1
View File
@@ -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
+1 -1
View File
@@ -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
+26 -1
View File
@@ -134,6 +134,24 @@ static int find_oldest_created_at(cJSON **events, int count, long *out_oldest) {
return found;
}
/* 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) {
@@ -370,9 +388,11 @@ int cr_backfill_tick(cr_backfill_t *bf, cr_config_t *cfg,
continue;
}
/* Find oldest timestamp, then publish every event. */
/* 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);
for (int k = 0; k < ev_count; k++) {
cr_sink_publish(sink, events[k]);
@@ -381,6 +401,11 @@ int cr_backfill_tick(cr_backfill_t *bf, cr_config_t *cfg,
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);
+25 -6
View File
@@ -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;
}
+200
View File
@@ -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;
}
+30
View File
@@ -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 */
+10
View File
@@ -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>
@@ -25,6 +26,15 @@ static void live_on_event(cJSON *event, const char *relay_url, void *user_data)
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) {
+10
View File
@@ -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"
@@ -367,6 +368,15 @@ int main(int argc, char **argv) {
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) {
+86
View File
@@ -1100,3 +1100,89 @@ int pg_inbox_get_active_target(char *out_pubkey, int pk_len,
PQclear(res);
return 0;
}
/* ------------------------------------------------------------------ */
/* Forward catch-up support (last_event_at tracking) */
/* ------------------------------------------------------------------ */
cJSON* pg_inbox_get_authors_for_catchup(void) {
if (!g_pg) {
DEBUG_ERROR("pg_inbox: get_authors_for_catchup: not initialized");
return NULL;
}
const char *sql =
"SELECT pubkey, last_event_at "
" FROM caching_followed_pubkeys "
" WHERE last_event_at > 0 "
" ORDER BY last_event_at ASC";
PGresult *res = PQexec(g_pg, sql);
if (!res) {
DEBUG_ERROR("pg_inbox: get_authors_for_catchup: NULL result");
return NULL;
}
ExecStatusType st = PQresultStatus(res);
if (st != PGRES_TUPLES_OK) {
DEBUG_ERROR("pg_inbox: get_authors_for_catchup failed: %s",
PQresultErrorMessage(res));
PQclear(res);
return NULL;
}
cJSON *arr = cJSON_CreateArray();
if (!arr) {
PQclear(res);
return NULL;
}
int n = PQntuples(res);
for (int i = 0; i < n; i++) {
const char *pk = PQgetvalue(res, i, 0);
long last_event_at = strtol(PQgetvalue(res, i, 1), NULL, 10);
if (!pk || pk[0] == '\0') continue;
cJSON *obj = cJSON_CreateObject();
cJSON_AddStringToObject(obj, "pubkey", pk);
cJSON_AddNumberToObject(obj, "last_event_at", (double)last_event_at);
cJSON_AddItemToArray(arr, obj);
}
PQclear(res);
return arr;
}
int pg_inbox_update_last_event_at(const char *pk, long event_created_at) {
if (!g_pg) {
DEBUG_ERROR("pg_inbox: update_last_event_at: not initialized");
return -1;
}
if (!pk || pk[0] == '\0' || event_created_at <= 0) return -1;
char ea_buf[32];
snprintf(ea_buf, sizeof(ea_buf), "%ld", event_created_at);
const char *vals[2] = {pk, ea_buf};
int lens[2] = {(int)strlen(pk), (int)strlen(ea_buf)};
int fmts[2] = {0, 0};
const char *sql =
"UPDATE caching_followed_pubkeys "
" SET last_event_at = GREATEST(last_event_at, $2::BIGINT) "
" WHERE pubkey = $1";
PGresult *res = PQexecParams(g_pg, sql, 2, NULL, vals, lens, fmts, 0);
if (!res) {
DEBUG_ERROR("pg_inbox: update_last_event_at: NULL result");
return -1;
}
ExecStatusType st = PQresultStatus(res);
if (st != PGRES_COMMAND_OK) {
DEBUG_ERROR("pg_inbox: update_last_event_at failed: %s",
PQresultErrorMessage(res));
PQclear(res);
return -1;
}
PQclear(res);
return 0;
}
+13
View File
@@ -149,4 +149,17 @@ int pg_inbox_set_active_target(const char *pubkey, const char *relay_url);
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 */
+68
View File
@@ -47,6 +47,16 @@ 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'
set -euo pipefail
@@ -102,6 +112,46 @@ 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
@@ -130,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."
+206
View File
@@ -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
+306
View File
@@ -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]
+708
View File
@@ -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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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]
```
+233
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
3125854
2356597
+189 -63
View File
@@ -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,
&notify_channel, &notify_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) {
@@ -498,31 +652,11 @@ cJSON* query_top_pubkeys(void) {
cJSON_AddNumberToObject(pubkey_obj, "event_count",
cJSON_IsNumber(count_item) ? count_item->valuedouble : 0.0);
// Enrich with profile name from kind-0 metadata.
if (pk_str[0] != '\0') {
cJSON* metadata = db_get_profile_metadata(pk_str);
if (metadata) {
cJSON* name = cJSON_GetObjectItemCaseSensitive(metadata, "name");
cJSON* display_name = cJSON_GetObjectItemCaseSensitive(metadata, "display_name");
const char* display = NULL;
if (display_name && cJSON_IsString(display_name) && display_name->valuestring[0])
display = display_name->valuestring;
else if (name && cJSON_IsString(name) && name->valuestring[0])
display = name->valuestring;
cJSON_AddStringToObject(pubkey_obj, "name", display ? display : "");
cJSON* picture = cJSON_GetObjectItemCaseSensitive(metadata, "picture");
if (picture && cJSON_IsString(picture) && picture->valuestring[0])
cJSON_AddStringToObject(pubkey_obj, "picture", picture->valuestring);
cJSON_Delete(metadata);
} else {
cJSON_AddStringToObject(pubkey_obj, "name", "");
}
} else {
cJSON_AddStringToObject(pubkey_obj, "name", "");
}
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;
@@ -1629,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();
@@ -1641,27 +1784,10 @@ char* generate_stats_json(void) {
cJSON_AddStringToObject(pubkey_obj, "pubkey", pk_str);
cJSON_AddNumberToObject(pubkey_obj, "event_count", count_val);
cJSON_AddNumberToObject(pubkey_obj, "percentage", pct);
// Enrich with profile name from kind-0 metadata.
if (pk_str[0] != '\0') {
cJSON* metadata = db_get_profile_metadata(pk_str);
if (metadata) {
cJSON* name = cJSON_GetObjectItemCaseSensitive(metadata, "name");
cJSON* display_name = cJSON_GetObjectItemCaseSensitive(metadata, "display_name");
const char* display = NULL;
if (display_name && cJSON_IsString(display_name) && display_name->valuestring[0])
display = display_name->valuestring;
else if (name && cJSON_IsString(name) && name->valuestring[0])
display = name->valuestring;
cJSON_AddStringToObject(pubkey_obj, "name", display ? display : "");
cJSON_Delete(metadata);
} else {
cJSON_AddStringToObject(pubkey_obj, "name", "");
}
} else {
cJSON_AddStringToObject(pubkey_obj, "name", "");
}
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);
+44 -15
View File
@@ -4263,10 +4263,43 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
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;
@@ -4294,25 +4327,19 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
cJSON_AddNumberToObject(follow, "first_seen", (double)first_seen);
cJSON_AddNumberToObject(follow, "last_seen", (double)last_seen);
// Profile metadata (kind-0) for username.
cJSON* metadata = db_get_profile_metadata(pk);
if (metadata) {
cJSON* name = cJSON_GetObjectItemCaseSensitive(metadata, "name");
cJSON* display_name = cJSON_GetObjectItemCaseSensitive(metadata, "display_name");
cJSON* picture = cJSON_GetObjectItemCaseSensitive(metadata, "picture");
cJSON* nip05 = cJSON_GetObjectItemCaseSensitive(metadata, "nip05");
// Prefer display_name, fall back to name.
const char* display = NULL;
if (display_name && cJSON_IsString(display_name) && display_name->valuestring[0])
display = display_name->valuestring;
else if (name && cJSON_IsString(name) && name->valuestring[0])
display = name->valuestring;
cJSON_AddStringToObject(follow, "name", display ? display : "");
// 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);
cJSON_Delete(metadata);
} else {
cJSON_AddStringToObject(follow, "name", "");
}
@@ -4381,6 +4408,8 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
cJSON_AddItemToArray(follows_array, follow);
}
cJSON_Delete(profile_map);
cJSON_Delete(pk_strings);
db_finalize_stmt(stmt);
}
+27
View File
@@ -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);
@@ -192,6 +196,12 @@ cJSON* db_get_profile_metadata(const char* 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
@@ -215,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);
@@ -396,5 +413,15 @@ 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
+23 -4
View File
@@ -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
@@ -142,14 +148,27 @@ int db_caching_inbox_insert(const char* event_id, const char* event_json,
int priority);
// Profile metadata and outbox relay helpers.
// db_get_profile_metadata: 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.
// 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
+231
View File
@@ -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;
@@ -2642,3 +2698,178 @@ cJSON* postgres_db_get_outbox_relays(const char* 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
}
+6
View File
@@ -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);
@@ -111,4 +113,8 @@ int postgres_db_caching_inbox_insert(const char* event_id, const char* event_jso
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
+9
View File
@@ -2815,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");
+2 -2
View File
@@ -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 23
#define CRELAY_VERSION "v2.1.23"
#define CRELAY_VERSION_PATCH 27
#define CRELAY_VERSION "v2.1.27"
// Relay metadata (authoritative source for NIP-11 information)
#define RELAY_NAME "C-Relay-PG"
+220 -2
View File
@@ -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"
@@ -475,6 +475,19 @@ static const char* const EMBEDDED_PG_SCHEMA_SQL =
" 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"
@@ -525,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"
;
+219 -1
View File
@@ -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
@@ -374,6 +392,19 @@ CREATE TABLE IF NOT EXISTS caching_followed_pubkeys (
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;
@@ -402,4 +433,191 @@ ALTER TABLE caching_service_state
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;
+164
View File
@@ -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