Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ec5ed9bd4 | ||
|
|
bd5150cd09 | ||
|
|
2e1d731de4 | ||
|
|
75d983b2df |
+1
-1
@@ -11,4 +11,4 @@ copy_executable_local.sh
|
||||
nostr_login_lite/
|
||||
style_guide/
|
||||
nostr-tools
|
||||
.test_keys
|
||||
.test_keysadmin/cache/
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ADMIN_PUBKEY='8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e'
|
||||
SERVER_PRIVKEY='1111111111111111111111111111111111111111111111111111111111111111'
|
||||
-133
@@ -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.
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
/** admin2/api/auth.php — Auth rules + WoT status. */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$action = $input['action'] ?? '';
|
||||
try {
|
||||
if ($action === 'add') {
|
||||
$pdo->prepare("INSERT INTO auth_rules (rule_type, pattern_type, pattern_value) VALUES (?, 'pubkey', ?)")->execute([$input['rule_type'], $input['pattern_value']]);
|
||||
json_response(['message' => 'Rule added']);
|
||||
} elseif ($action === 'remove') {
|
||||
$pdo->prepare("DELETE FROM auth_rules WHERE id = ?")->execute([intval($input['id'])]);
|
||||
json_response(['message' => 'Rule removed']);
|
||||
} elseif ($action === 'set_wot_level') {
|
||||
$pdo->prepare("UPDATE config SET value = ? WHERE key = 'wot_level'")->execute([strval($input['level'])]);
|
||||
json_response(['message' => 'WoT level set']);
|
||||
} elseif ($action === 'sync_wot') {
|
||||
json_response(['message' => 'WoT sync not available via PHP (requires relay)']);
|
||||
}
|
||||
} catch (PDOException $e) { json_response(['error' => $e->getMessage()]); }
|
||||
}
|
||||
|
||||
// GET: WoT status
|
||||
if (isset($_GET['action']) && $_GET['action'] === 'wot') {
|
||||
$level = $pdo->query("SELECT value FROM config WHERE key = 'wot_level'")->fetchColumn() ?: '0';
|
||||
$wl_count = 0;
|
||||
try { $wl_count = intval($pdo->query("SELECT count(*) FROM auth_rules WHERE rule_type = 'whitelist'")->fetchColumn()); } catch (PDOException $e) {}
|
||||
$descriptions = ['0' => 'Level 0: Open relay — anyone can read and write', '1' => 'Level 1: Write only — WoT users can write', '2' => 'Level 2: Full — WoT users only'];
|
||||
json_response(['kind3_status' => 'N/A (PHP admin)', 'whitelist_count' => $wl_count, 'level_description' => $descriptions[$level] ?? $descriptions['0']]);
|
||||
}
|
||||
|
||||
// GET: auth rules
|
||||
$rules = [];
|
||||
try { $rules = $pdo->query("SELECT id, rule_type, pattern_type, pattern_value FROM auth_rules ORDER BY id")->fetchAll(); } catch (PDOException $e) {}
|
||||
json_response(['rules' => $rules]);
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
/** admin2/api/caching.php — Caching service status + follows + reset actions. */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
// --- POST actions: reset backfill (all or per-user) ---
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$action = $input['action'] ?? '';
|
||||
$pubkey = $input['pubkey'] ?? '';
|
||||
|
||||
if ($action === 'reset_all') {
|
||||
// Reset backfill progress for ALL followed authors, then bump config
|
||||
// generation so the running caching service hot-reloads and re-drains.
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
$pdo->exec("UPDATE caching_followed_pubkeys
|
||||
SET until_cursor = 0, backfill_complete = FALSE,
|
||||
events_fetched = 0,
|
||||
updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT");
|
||||
// Also reset per-relay progress rows
|
||||
$pdo->exec("UPDATE caching_backfill_relay_progress
|
||||
SET complete = FALSE, events_fetched = 0,
|
||||
until_cursor = 0, last_status = 'reset',
|
||||
consecutive_errors = 0,
|
||||
updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT");
|
||||
// Bump config generation to trigger hot-reload
|
||||
$pdo->exec("UPDATE config SET value = (COALESCE(value::int, 0) + 1)::text
|
||||
WHERE key = 'caching_config_generation'");
|
||||
$pdo->commit();
|
||||
json_response(['ok' => true, 'message' => 'All backfill progress reset. Caching service will re-drain.']);
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
json_response(['ok' => false, 'error' => $e->getMessage()], 500);
|
||||
}
|
||||
} elseif ($action === 'reset_user' && $pubkey) {
|
||||
// Reset backfill progress for a single followed author.
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
$stmt = $pdo->prepare("UPDATE caching_followed_pubkeys
|
||||
SET until_cursor = 0, backfill_complete = FALSE,
|
||||
events_fetched = 0,
|
||||
updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT
|
||||
WHERE pubkey = ?");
|
||||
$stmt->execute([$pubkey]);
|
||||
// Also reset per-relay progress for this author
|
||||
$stmt2 = $pdo->prepare("UPDATE caching_backfill_relay_progress
|
||||
SET complete = FALSE, events_fetched = 0,
|
||||
until_cursor = 0, last_status = 'reset',
|
||||
consecutive_errors = 0,
|
||||
updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT
|
||||
WHERE author_pubkey = ?");
|
||||
$stmt2->execute([$pubkey]);
|
||||
// Bump config generation to trigger hot-reload
|
||||
$pdo->exec("UPDATE config SET value = (COALESCE(value::int, 0) + 1)::text
|
||||
WHERE key = 'caching_config_generation'");
|
||||
$pdo->commit();
|
||||
json_response(['ok' => true, 'message' => "Backfill reset for pubkey. Caching service will re-fetch."]);
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
json_response(['ok' => false, 'error' => $e->getMessage()], 500);
|
||||
}
|
||||
} else {
|
||||
json_response(['ok' => false, 'error' => 'Unknown action'], 400);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// Caching config values (for toggle controls)
|
||||
$config = [];
|
||||
try {
|
||||
$cfg_rows = $pdo->query("SELECT key, value FROM config WHERE key IN ('caching_enabled','caching_inbox_enabled','caching_live_enabled','caching_backfill_enabled')")->fetchAll();
|
||||
foreach ($cfg_rows as $r) { $config[$r['key']] = $r['value']; }
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Service state
|
||||
$state = [];
|
||||
try { $state = $pdo->query("SELECT * FROM caching_service_state WHERE id = 1")->fetch() ?: []; } catch (PDOException $e) {}
|
||||
|
||||
// Active target
|
||||
$active = ['pubkey' => '', 'relay' => ''];
|
||||
try { $active = $pdo->query("SELECT active_pubkey AS pubkey, active_relay AS relay FROM caching_backfill_active WHERE id = 1")->fetch() ?: $active; } catch (PDOException $e) {}
|
||||
|
||||
// Inbox
|
||||
$inbox = ['pending' => 0, 'live' => 0, 'backfill' => 0];
|
||||
try {
|
||||
$inbox = $pdo->query("SELECT COUNT(*) AS pending, COUNT(*) FILTER (WHERE source_class='live') AS live, COUNT(*) FILTER (WHERE source_class='backfill') AS backfill FROM caching_event_inbox")->fetch() ?: $inbox;
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Follows (paginated, with names from profiles cache + per-relay progress)
|
||||
$follows = [];
|
||||
try {
|
||||
$follows = $pdo->query("
|
||||
SELECT fp.pubkey, fp.is_root, fp.backfill_complete, fp.events_fetched,
|
||||
fp.last_event_at,
|
||||
p.name, p.display_name,
|
||||
(SELECT count(*) FROM events WHERE pubkey = fp.pubkey) AS total_events,
|
||||
(SELECT count(*) FROM caching_backfill_relay_progress WHERE author_pubkey = fp.pubkey) AS relay_count,
|
||||
(SELECT count(*) FROM caching_backfill_relay_progress WHERE author_pubkey = fp.pubkey AND complete = false) AS relay_incomplete
|
||||
FROM caching_followed_pubkeys fp
|
||||
LEFT JOIN profiles p ON p.pubkey = fp.pubkey
|
||||
ORDER BY fp.is_root DESC, fp.events_fetched DESC LIMIT 1000
|
||||
")->fetchAll();
|
||||
// Fetch per-relay progress for all followed pubkeys in one query
|
||||
$relayProgress = [];
|
||||
try {
|
||||
$rpRows = $pdo->query("
|
||||
SELECT author_pubkey, relay_url, complete, events_fetched, until_cursor, last_status, updated_at
|
||||
FROM caching_backfill_relay_progress
|
||||
ORDER BY author_pubkey, events_fetched DESC
|
||||
")->fetchAll();
|
||||
foreach ($rpRows as $rp) {
|
||||
$relayProgress[$rp['author_pubkey']][] = $rp;
|
||||
}
|
||||
} catch (PDOException $e) {}
|
||||
foreach ($follows as &$f) {
|
||||
$f['name'] = profile_display_name($f);
|
||||
$f['npub'] = function_exists('hex_to_npub') ? hex_to_npub($f['pubkey']) : substr($f['pubkey'], 0, 20);
|
||||
$f['relays'] = $relayProgress[$f['pubkey']] ?? [];
|
||||
}
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
json_response(['state' => $state, 'active' => $active, 'inbox' => $inbox, 'follows' => $follows, 'config' => $config]);
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
/**
|
||||
* admin2/api/chart.php — Standalone ASCII chart endpoint.
|
||||
*
|
||||
* Returns a plain-text ASCII X-bar chart of event counts over time.
|
||||
* Works in both the browser (injected into a <div>) and the terminal:
|
||||
*
|
||||
* curl http://localhost:8088/api/chart.php?range=hour
|
||||
* curl http://localhost:8088/api/chart.php?range=day
|
||||
* curl http://localhost:8088/api/chart.php?range=month
|
||||
* curl http://localhost:8088/api/chart.php?range=year
|
||||
*
|
||||
* Caching: the hour chart is never cached (live). Day/month/year are
|
||||
* cached to file with TTLs to avoid expensive queries on every request.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
require_once __DIR__ . '/../lib/ascii_chart.php';
|
||||
|
||||
// --- Range configuration ---
|
||||
$ranges = [
|
||||
'hour' => ['span' => 3600, 'bin' => 45, 'bins' => 80, 'ttl' => 0, 'title' => 'New Events — Last Hour'],
|
||||
'day' => ['span' => 86400, 'bin' => 1080, 'bins' => 80, 'ttl' => 3600, 'title' => 'New Events — Last 24 Hours'],
|
||||
'month' => ['span' => 2592000, 'bin' => 32400, 'bins' => 80, 'ttl' => 86400, 'title' => 'New Events — Last 30 Days'],
|
||||
'year' => ['span' => 31536000, 'bin' => 394200, 'bins' => 80, 'ttl' => 2592000, 'title' => 'New Events — Last Year'],
|
||||
];
|
||||
|
||||
$range = $_GET['range'] ?? 'hour';
|
||||
if (!isset($ranges[$range])) {
|
||||
http_response_code(400);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo "Invalid range. Use: hour, day, month, or year.\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
$cfg = $ranges[$range];
|
||||
|
||||
// --- Set content type ---
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
|
||||
// --- Check cache ---
|
||||
if ($cfg['ttl'] > 0) {
|
||||
$cached = get_cached_chart($range, $cfg['ttl']);
|
||||
if ($cached !== null) {
|
||||
echo $cached;
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Run binning query ---
|
||||
$pdo = db();
|
||||
$now = time();
|
||||
$epoch = $now - $cfg['span'];
|
||||
$bin_size = $cfg['bin'];
|
||||
$num_bins = $cfg['bins'];
|
||||
|
||||
try {
|
||||
// Calculate the bin offset so the rightmost bin aligns with "now"
|
||||
// bin index 0 = oldest, bin index (num_bins-1) = newest
|
||||
$base_bin = (int)floor($epoch / $bin_size);
|
||||
|
||||
$sql = "SELECT FLOOR(created_at / {$bin_size})::BIGINT - {$base_bin} AS bin, COUNT(*) AS cnt
|
||||
FROM events
|
||||
WHERE created_at >= {$epoch}
|
||||
GROUP BY bin
|
||||
ORDER BY bin";
|
||||
$rows = $pdo->query($sql)->fetchAll();
|
||||
} catch (PDOException $e) {
|
||||
echo "Chart query failed: " . $e->getMessage() . "\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- Build bin array (zero-filled, fixed length) ---
|
||||
// Index 0 = oldest, index N-1 = newest (natural time order from SQL)
|
||||
$bins = build_bin_array($rows, $num_bins);
|
||||
|
||||
// Reverse so newest bin is at index 0 (leftmost) and oldest at right.
|
||||
// This matches the original text_graph.js display: new data enters
|
||||
// from the left and proceeds rightward as it ages.
|
||||
$bins = array_reverse($bins);
|
||||
|
||||
// --- Render ASCII chart ---
|
||||
$ascii = render_ascii_chart($bins, [
|
||||
'title' => $cfg['title'],
|
||||
'max_height' => 11,
|
||||
'bin_duration' => $bin_size,
|
||||
'label_interval' => max(1, (int)floor($num_bins / 15)), // ~15 labels across
|
||||
]);
|
||||
|
||||
// --- Write to cache (if TTL > 0) ---
|
||||
if ($cfg['ttl'] > 0) {
|
||||
set_cached_chart($range, $ascii);
|
||||
}
|
||||
|
||||
echo $ascii;
|
||||
|
||||
|
||||
// ================================
|
||||
// CACHE HELPERS
|
||||
// ================================
|
||||
|
||||
function get_cache_dir(): string {
|
||||
return __DIR__ . '/../cache';
|
||||
}
|
||||
|
||||
function get_cached_chart(string $range, int $ttl): ?string {
|
||||
$file = get_cache_dir() . "/chart_{$range}.txt";
|
||||
if (!file_exists($file)) return null;
|
||||
if (filemtime($file) < (time() - $ttl)) return null;
|
||||
$content = @file_get_contents($file);
|
||||
return ($content !== false) ? $content : null;
|
||||
}
|
||||
|
||||
function set_cached_chart(string $range, string $ascii): void {
|
||||
$dir = get_cache_dir();
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0775, true);
|
||||
}
|
||||
@file_put_contents($dir . "/chart_{$range}.txt", $ascii);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
/** admin2/api/config.php — Config table read/edit (all keys). */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$key = $input['key'] ?? '';
|
||||
$value = $input['value'] ?? '';
|
||||
if (!$key) json_response(['error' => 'Missing key']);
|
||||
try {
|
||||
$pdo->prepare("UPDATE config SET value = ? WHERE key = ?")->execute([$value, $key]);
|
||||
json_response(['message' => "Updated $key"]);
|
||||
} catch (PDOException $e) {
|
||||
json_response(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
$rows = $pdo->query("SELECT key, value FROM config ORDER BY key")->fetchAll();
|
||||
json_response(['config' => $rows]);
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
/** admin2/api/dm.php — Direct messages (kind 4/14/15). */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
$limit = min(200, intval($_GET['limit'] ?? 50));
|
||||
$rows = $pdo->prepare("SELECT id, pubkey, kind, created_at, content FROM events WHERE kind IN (4, 14, 15) ORDER BY created_at DESC LIMIT $limit");
|
||||
$rows->execute();
|
||||
$messages = $rows->fetchAll();
|
||||
|
||||
foreach ($messages as &$m) {
|
||||
$m['created_at'] = date('Y-m-d H:i:s', intval($m['created_at']));
|
||||
$m['content'] = substr($m['content'] ?? '', 0, 300);
|
||||
}
|
||||
|
||||
json_response(['messages' => $messages]);
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
/** admin2/api/events.php — Recent relay events (paginated). */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
$limit = min(200, intval($_GET['limit'] ?? 50));
|
||||
$offset = intval($_GET['offset'] ?? 0);
|
||||
$kind = $_GET['kind'] ?? '';
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($kind !== '') { $where[] = 'kind = ?'; $params[] = intval($kind); }
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$rows = $pdo->prepare("SELECT e.id, e.pubkey, e.kind, e.created_at, e.content,
|
||||
p.name, p.display_name
|
||||
FROM events e
|
||||
LEFT JOIN profiles p ON p.pubkey = e.pubkey
|
||||
$where_sql
|
||||
ORDER BY e.created_at DESC LIMIT $limit OFFSET $offset");
|
||||
$rows->execute($params);
|
||||
$events = $rows->fetchAll();
|
||||
|
||||
foreach ($events as &$e) {
|
||||
$e['created_at'] = date('Y-m-d H:i:s', intval($e['created_at']));
|
||||
$e['content'] = substr($e['content'] ?? '', 0, 200);
|
||||
// Resolve display name from profiles cache; fall back to truncated pubkey.
|
||||
$best = profile_display_name($e);
|
||||
$e['display_name'] = $best !== '' ? $best : substr($e['pubkey'] ?? '', 0, 16) . '…';
|
||||
}
|
||||
|
||||
json_response(['events' => $events]);
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
/** admin2/api/ipbans.php — IP ban management. */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$action = $input['action'] ?? '';
|
||||
try {
|
||||
if ($action === 'add') {
|
||||
$until = time() + intval($input['duration'] ?? 86400);
|
||||
$pdo->prepare("INSERT INTO ip_bans (ip, banned_until, ban_count) VALUES (?, ?, 1) ON CONFLICT (ip) DO UPDATE SET banned_until = ?, ban_count = ip_bans.ban_count + 1")->execute([$input['ip'], $until, $until]);
|
||||
json_response(['message' => 'IP banned']);
|
||||
} elseif ($action === 'remove') {
|
||||
$pdo->prepare("DELETE FROM ip_bans WHERE ip = ?")->execute([$input['ip']]);
|
||||
json_response(['message' => 'Ban removed']);
|
||||
}
|
||||
} catch (PDOException $e) { json_response(['error' => $e->getMessage()]); }
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$total = 0; $active = 0; $issued = 0;
|
||||
$bans = [];
|
||||
try {
|
||||
$total = intval($pdo->query("SELECT count(*) FROM ip_bans")->fetchColumn());
|
||||
$active = intval($pdo->query("SELECT count(*) FROM ip_bans WHERE banned_until > $now")->fetchColumn());
|
||||
$issued = intval($pdo->query("SELECT COALESCE(sum(ban_count),0) FROM ip_bans")->fetchColumn());
|
||||
$bans = $pdo->query("SELECT ip, banned_until, ban_count, failures, authed_successfully, connection_attempts FROM ip_bans ORDER BY banned_until DESC LIMIT 100")->fetchAll();
|
||||
foreach ($bans as &$b) {
|
||||
$b['status'] = intval($b['banned_until']) > $now ? 'banned' : 'expired';
|
||||
$b['banned_until'] = date('Y-m-d H:i:s', intval($b['banned_until']));
|
||||
}
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
json_response(['total' => $total, 'active' => $active, 'issued' => $issued, 'bans' => $bans]);
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/api/profile.php — Single-profile lookup from the profiles cache.
|
||||
* Returns cached kind-0 metadata for a given pubkey.
|
||||
*
|
||||
* Usage: profile.php?pubkey=<64-char-hex>
|
||||
* Returns: { "pubkey": "...", "name": "...", "display_name": "...",
|
||||
* "best_name": "...", "picture": "...", "nip05": "...",
|
||||
* "about": "...", "website": "...", "lud16": "..." }
|
||||
* or { "error": "not found" } with HTTP 404 if no profile is cached.
|
||||
*/
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
|
||||
$pubkey = trim($_GET['pubkey'] ?? '');
|
||||
if (!preg_match('/^[0-9a-fA-F]{64}$/', $pubkey)) {
|
||||
http_response_code(400);
|
||||
json_response(['error' => 'Invalid pubkey']);
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
try {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT pubkey, name, display_name, about, picture, banner,
|
||||
nip05, website, lud16, lud06
|
||||
FROM profiles WHERE pubkey = ?"
|
||||
);
|
||||
$stmt->execute([$pubkey]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) {
|
||||
http_response_code(404);
|
||||
json_response(['error' => 'not found']);
|
||||
}
|
||||
$row['best_name'] = profile_display_name($row);
|
||||
json_response($row);
|
||||
} catch (PDOException $e) {
|
||||
http_response_code(500);
|
||||
json_response(['error' => 'Database error']);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
/** admin2/api/query.php — Direct SQL query (SELECT only, admin only). */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
json_response(['error' => 'POST only']);
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$sql = trim($input['sql'] ?? '');
|
||||
|
||||
if (!$sql) json_response(['error' => 'No query provided']);
|
||||
if (!preg_match('/^\s*SELECT/i', $sql)) json_response(['error' => 'Only SELECT queries are allowed']);
|
||||
|
||||
$start = microtime(true);
|
||||
try {
|
||||
$stmt = $pdo->query($sql);
|
||||
$rows = $stmt->fetchAll();
|
||||
$time_ms = round((microtime(true) - $start) * 1000, 1);
|
||||
json_response(['rows' => $rows, 'row_count' => count($rows), 'time_ms' => $time_ms]);
|
||||
} catch (PDOException $e) {
|
||||
json_response(['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
/**
|
||||
* admin2/api/stats.php — Statistics page data.
|
||||
* Returns DB size, event counts, kind distribution, top pubkeys,
|
||||
* time-based stats, and event-rate delta (for the chart).
|
||||
*/
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
|
||||
$pdo = db();
|
||||
|
||||
// Database size
|
||||
$db_size = '-';
|
||||
try {
|
||||
$bytes = $pdo->query("SELECT pg_database_size(current_database())")->fetchColumn();
|
||||
$db_size = format_bytes(intval($bytes));
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Total events
|
||||
$total_events = intval($pdo->query("SELECT COUNT(*) FROM events")->fetchColumn());
|
||||
|
||||
// Event rate: events with first_seen in last 10 seconds
|
||||
$events_delta = 0;
|
||||
try {
|
||||
$events_delta = intval($pdo->query("SELECT COUNT(*) FROM events WHERE first_seen >= EXTRACT(EPOCH FROM NOW())::BIGINT - 10")->fetchColumn());
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Process info (from pg_stat_activity)
|
||||
$process_id = '-';
|
||||
$ws_connections = '-';
|
||||
try {
|
||||
$pid = $pdo->query("SELECT pg_backend_pid()")->fetchColumn();
|
||||
$process_id = strval($pid);
|
||||
$ws_connections = intval($pdo->query("SELECT count(*) FROM pg_stat_activity WHERE state = 'active' AND pid != pg_backend_pid()")->fetchColumn());
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Active subscriptions
|
||||
$active_subscriptions = 0;
|
||||
try {
|
||||
$active_subscriptions = intval($pdo->query("SELECT count(*) FROM subscriptions WHERE active = true")->fetchColumn());
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Memory/CPU (from /proc on Linux)
|
||||
$memory_usage = '-';
|
||||
$cpu_usage = '-';
|
||||
$cpu_core = '-';
|
||||
if (is_readable('/proc/meminfo')) {
|
||||
$mem = parse_ini_file('/proc/meminfo');
|
||||
$total = intval($mem['MemTotal'] ?? 0) * 1024;
|
||||
$avail = intval($mem['MemAvailable'] ?? 0) * 1024;
|
||||
if ($total > 0) $memory_usage = format_bytes($total - $avail) . ' / ' . format_bytes($total);
|
||||
}
|
||||
if (is_readable('/proc/cpuinfo')) {
|
||||
$cores = intval(shell_exec('nproc 2>/dev/null') ?: 1);
|
||||
$cpu_core = strval($cores) . ' cores';
|
||||
}
|
||||
$cpu_usage = @file_get_contents('/proc/loadavg');
|
||||
if ($cpu_usage !== false) $cpu_usage = trim(explode(' ', $cpu_usage)[0] ?? '-');
|
||||
|
||||
// Oldest / newest event
|
||||
$oldest_event = '-';
|
||||
$newest_event = '-';
|
||||
try {
|
||||
$oldest = $pdo->query("SELECT to_timestamp(MIN(created_at)) FROM events")->fetchColumn();
|
||||
$newest = $pdo->query("SELECT to_timestamp(MAX(created_at)) FROM events")->fetchColumn();
|
||||
if ($oldest) $oldest_event = substr($oldest, 0, 19);
|
||||
if ($newest) $newest_event = substr($newest, 0, 19);
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Time-based stats
|
||||
$now = time();
|
||||
$events_24h = 0; $events_7d = 0; $events_30d = 0;
|
||||
try {
|
||||
$events_24h = intval($pdo->query("SELECT COUNT(*) FROM events WHERE created_at >= $now - 86400")->fetchColumn());
|
||||
$events_7d = intval($pdo->query("SELECT COUNT(*) FROM events WHERE created_at >= $now - 604800")->fetchColumn());
|
||||
$events_30d = intval($pdo->query("SELECT COUNT(*) FROM events WHERE created_at >= $now - 2592000")->fetchColumn());
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Kind distribution
|
||||
$kinds = [];
|
||||
try {
|
||||
$rows = $pdo->query("SELECT kind, COUNT(*) AS cnt FROM events GROUP BY kind ORDER BY cnt DESC LIMIT 20")->fetchAll();
|
||||
foreach ($rows as $r) {
|
||||
$kinds[] = ['kind' => intval($r['kind']), 'count' => intval($r['cnt']), 'pct' => $total_events > 0 ? round(intval($r['cnt']) / $total_events * 100, 1) : 0];
|
||||
}
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Top pubkeys (with names from profiles cache)
|
||||
$top_pubkeys = [];
|
||||
try {
|
||||
$rows = $pdo->query("
|
||||
SELECT pubkey, COUNT(*) AS cnt
|
||||
FROM events
|
||||
GROUP BY pubkey
|
||||
ORDER BY cnt DESC LIMIT 20
|
||||
")->fetchAll();
|
||||
// Batch-resolve profile names from the profiles cache table.
|
||||
$pubkeys = array_column($rows, 'pubkey');
|
||||
$pmap = profile_map($pubkeys);
|
||||
foreach ($rows as $r) {
|
||||
$pk = $r['pubkey'];
|
||||
$prof = $pmap[$pk] ?? null;
|
||||
$top_pubkeys[] = [
|
||||
'pubkey' => $pk,
|
||||
'name' => $prof ? $prof['best_name'] : '',
|
||||
'count' => intval($r['cnt']),
|
||||
'pct' => $total_events > 0 ? round(intval($r['cnt']) / $total_events * 100, 1) : 0,
|
||||
];
|
||||
}
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Name-field usage stats (from profiles cache)
|
||||
$name_field_usage = ['both' => 0, 'name_only' => 0, 'display_only' => 0, 'neither' => 0, 'both_differ' => 0, 'total' => 0];
|
||||
try {
|
||||
$row = $pdo->query("
|
||||
SELECT count(*) FILTER (WHERE name <> '' AND display_name <> '') AS both,
|
||||
count(*) FILTER (WHERE name <> '' AND display_name = '') AS name_only,
|
||||
count(*) FILTER (WHERE name = '' AND display_name <> '') AS display_only,
|
||||
count(*) FILTER (WHERE name = '' AND display_name = '') AS neither,
|
||||
count(*) FILTER (WHERE name <> '' AND display_name <> '' AND name <> display_name) AS both_differ,
|
||||
count(*) AS total
|
||||
FROM profiles
|
||||
")->fetch();
|
||||
if ($row) {
|
||||
$name_field_usage = array_map('intval', $row);
|
||||
}
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
json_response([
|
||||
'db_size' => $db_size,
|
||||
'total_events' => $total_events,
|
||||
'events_delta' => $events_delta,
|
||||
'process_id' => $process_id,
|
||||
'ws_connections' => $ws_connections,
|
||||
'active_subscriptions' => $active_subscriptions,
|
||||
'memory_usage' => $memory_usage,
|
||||
'cpu_usage' => $cpu_usage,
|
||||
'cpu_core' => $cpu_core,
|
||||
'oldest_event' => $oldest_event,
|
||||
'newest_event' => $newest_event,
|
||||
'events_24h' => $events_24h,
|
||||
'events_7d' => $events_7d,
|
||||
'events_30d' => $events_30d,
|
||||
'kinds' => $kinds,
|
||||
'top_pubkeys' => $top_pubkeys,
|
||||
'name_field_usage' => $name_field_usage,
|
||||
]);
|
||||
|
||||
/** Format bytes as human-readable. */
|
||||
function format_bytes(int $bytes): string {
|
||||
if ($bytes < 1024) return $bytes . ' B';
|
||||
if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB';
|
||||
if ($bytes < 1073741824) return round($bytes / 1048576, 1) . ' MB';
|
||||
return round($bytes / 1073741824, 2) . ' GB';
|
||||
}
|
||||
@@ -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'] ?? '',
|
||||
]);
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
/** admin2/api/subscriptions.php — Active subscription details. */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
$subs = [];
|
||||
try {
|
||||
$subs = $pdo->query("SELECT id, sub_id, pubkey, filters, created_at, events_sent FROM subscriptions ORDER BY created_at DESC LIMIT 100")->fetchAll();
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
json_response(['subscriptions' => $subs]);
|
||||
@@ -0,0 +1,956 @@
|
||||
/*
|
||||
* admin2 — Full PHP admin for C-Relay-PG.
|
||||
* Replaces the 7457-line api/index.js with a lightweight AJAX-based
|
||||
* controller that fetches data from PHP API endpoints (api/*.php)
|
||||
* instead of Nostr WebSocket admin commands.
|
||||
*/
|
||||
|
||||
const REFRESH_MS = 10000;
|
||||
let currentPage = 'statistics';
|
||||
let statsInterval = null;
|
||||
|
||||
// HTML-escape helper for safe interpolation into innerHTML.
|
||||
// Prevents stored XSS from user-controlled data (profile names, event
|
||||
// content, config values, etc.) being parsed as HTML by the browser.
|
||||
const esc = (s) => {
|
||||
const str = String(s ?? '');
|
||||
return str.replace(/[&<>"']/g, (ch) => {
|
||||
return '&#' + ch.charCodeAt(0) + ';';
|
||||
});
|
||||
};
|
||||
|
||||
// Auth state
|
||||
let nlLite = null;
|
||||
let userPubkey = null;
|
||||
let isLoggedIn = false;
|
||||
let relayAnimationTimer = null;
|
||||
|
||||
// Server-rendered ASCII chart state
|
||||
let currentChartRange = 'hour';
|
||||
let chartLoadTimer = null;
|
||||
|
||||
// ================================
|
||||
// NAVIGATION
|
||||
// ================================
|
||||
|
||||
function toggleNav() {
|
||||
document.getElementById('side-nav').classList.toggle('open');
|
||||
document.getElementById('side-nav-overlay').classList.toggle('show');
|
||||
}
|
||||
|
||||
document.getElementById('side-nav-overlay')?.addEventListener('click', toggleNav);
|
||||
|
||||
document.querySelectorAll('.nav-item').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const page = btn.getAttribute('data-page');
|
||||
switchPage(page);
|
||||
document.getElementById('side-nav').classList.remove('open');
|
||||
document.getElementById('side-nav-overlay').classList.remove('show');
|
||||
});
|
||||
});
|
||||
|
||||
function switchPage(pageName) {
|
||||
currentPage = pageName;
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
if (item.getAttribute('data-page') === pageName) item.classList.add('active');
|
||||
});
|
||||
|
||||
const sections = [
|
||||
'databaseStatisticsSection', 'subscriptionDetailsSection', 'div_config',
|
||||
'authRulesSection', 'wotSection', 'ipBansSection', 'relayEventsSection',
|
||||
'cachingSection', 'nip17DMSection', 'sqlQuerySection'
|
||||
];
|
||||
sections.forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; });
|
||||
|
||||
const pageMap = {
|
||||
'statistics': 'databaseStatisticsSection',
|
||||
'subscriptions': 'subscriptionDetailsSection',
|
||||
'configuration': 'div_config',
|
||||
'ip-bans': 'ipBansSection',
|
||||
'relay-events': 'relayEventsSection',
|
||||
'caching': 'cachingSection',
|
||||
'dm': 'nip17DMSection',
|
||||
'database': 'sqlQuerySection'
|
||||
};
|
||||
|
||||
if (pageName === 'authorization') {
|
||||
document.getElementById('authRulesSection').style.display = 'block';
|
||||
document.getElementById('wotSection').style.display = 'block';
|
||||
loadAuthRules();
|
||||
loadWotStatus();
|
||||
} else {
|
||||
const target = pageMap[pageName];
|
||||
if (target) document.getElementById(target).style.display = 'block';
|
||||
}
|
||||
|
||||
// Load data for the page
|
||||
const loaders = {
|
||||
'statistics': loadStats,
|
||||
'subscriptions': loadSubscriptions,
|
||||
'configuration': loadConfig,
|
||||
'ip-bans': loadIpBans,
|
||||
'relay-events': loadEvents,
|
||||
'caching': loadCaching,
|
||||
'dm': loadDMs,
|
||||
};
|
||||
if (loaders[pageName]) loaders[pageName]();
|
||||
|
||||
// Start/stop auto-refresh for statistics
|
||||
if (pageName === 'statistics') {
|
||||
if (!statsInterval) {
|
||||
loadStats();
|
||||
statsInterval = setInterval(loadStats, REFRESH_MS);
|
||||
console.log('[admin2] auto-refresh started, interval:', REFRESH_MS, 'ms');
|
||||
}
|
||||
} else {
|
||||
if (statsInterval) { clearInterval(statsInterval); statsInterval = null; }
|
||||
}
|
||||
}
|
||||
|
||||
// ================================
|
||||
// STATISTICS
|
||||
// ================================
|
||||
|
||||
async function loadStats() {
|
||||
console.log('[admin2] loading stats at', new Date().toLocaleTimeString());
|
||||
// Fire the RELAY letter animation on every refresh — visual indicator the page is updating
|
||||
startRelayAnimation();
|
||||
try {
|
||||
const res = await fetch('api/stats.php');
|
||||
if (!res.ok) { console.warn('[admin2] stats.php returned', res.status); return; }
|
||||
const d = await res.json();
|
||||
console.log('[admin2] stats loaded — events:', d.total_events, 'rate:', d.events_delta, '/10s');
|
||||
|
||||
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
|
||||
set('db-size', d.db_size || '-');
|
||||
set('total-events', (d.total_events ?? 0).toLocaleString());
|
||||
set('process-id', d.process_id || '-');
|
||||
set('websocket-connections', d.ws_connections ?? '-');
|
||||
set('active-subscriptions', d.active_subscriptions ?? '-');
|
||||
set('memory-usage', d.memory_usage || '-');
|
||||
set('cpu-usage', d.cpu_usage || '-');
|
||||
set('cpu-core', d.cpu_core || '-');
|
||||
set('oldest-event', d.oldest_event || '-');
|
||||
set('newest-event', d.newest_event || '-');
|
||||
set('events-24h', (d.events_24h ?? 0).toLocaleString());
|
||||
set('events-7d', (d.events_7d ?? 0).toLocaleString());
|
||||
set('events-30d', (d.events_30d ?? 0).toLocaleString());
|
||||
|
||||
// Kind distribution
|
||||
if (d.kinds) {
|
||||
const tbody = document.getElementById('stats-kinds-table-body');
|
||||
tbody.innerHTML = d.kinds.map(k =>
|
||||
`<tr><td>${k.kind}</td><td>${k.count.toLocaleString()}</td><td>${k.pct}%</td></tr>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Top pubkeys
|
||||
if (d.top_pubkeys) {
|
||||
const tbody = document.getElementById('stats-pubkeys-table-body');
|
||||
tbody.innerHTML = d.top_pubkeys.map((p, i) =>
|
||||
`<tr><td>${i+1}</td><td><bdi>${esc(p.name) || '<i>unknown</i>'}</bdi></td><td class="pubkey">${esc(p.pubkey.substring(0,16))}…</td><td>${p.count.toLocaleString()}</td><td>${p.pct}%</td></tr>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Name-field usage stats (from profiles cache)
|
||||
if (d.name_field_usage && d.name_field_usage.total > 0) {
|
||||
const u = d.name_field_usage;
|
||||
const el = document.getElementById('name-field-usage');
|
||||
if (el) {
|
||||
el.innerHTML = `
|
||||
<div class="name-usage-stats">
|
||||
<span class="name-usage-label">Profile name fields (${u.total} profiles):</span>
|
||||
<span class="name-usage-item">Both: ${u.both}</span>
|
||||
<span class="name-usage-item">name only: ${u.name_only}</span>
|
||||
<span class="name-usage-item">display_name only: ${u.display_only}</span>
|
||||
<span class="name-usage-item name-usage-differ">Differ: ${u.both_differ}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the chart on every stats poll (server handles caching for non-hour ranges)
|
||||
loadChart(currentChartRange);
|
||||
} catch (e) { console.error('[admin2] stats error:', e); }
|
||||
}
|
||||
|
||||
// ================================
|
||||
// EVENT RATE CHART (server-rendered ASCII via chart.php)
|
||||
// ================================
|
||||
|
||||
// Fetch the ASCII chart from the server and inject it into the div.
|
||||
// The chart is rendered server-side as plain text — works in both
|
||||
// browser and terminal (curl http://localhost:8088/api/chart.php?range=hour)
|
||||
async function loadChart(range) {
|
||||
const el = document.getElementById('event-rate-chart');
|
||||
if (!el) return;
|
||||
try {
|
||||
const res = await fetch('api/chart.php?range=' + encodeURIComponent(range));
|
||||
if (!res.ok) { el.textContent = 'Chart load failed (HTTP ' + res.status + ')'; return; }
|
||||
const text = await res.text();
|
||||
el.textContent = text;
|
||||
// Newest data is at the left (index 0) — scroll to start so it's visible
|
||||
el.scrollLeft = 0;
|
||||
} catch (e) {
|
||||
console.error('[admin2] chart load error:', e);
|
||||
el.textContent = 'Chart load error: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Chart range tab click handlers
|
||||
document.querySelectorAll('.chart-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const range = tab.getAttribute('data-range');
|
||||
if (!range || range === currentChartRange) return;
|
||||
currentChartRange = range;
|
||||
document.querySelectorAll('.chart-tab').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
loadChart(range);
|
||||
});
|
||||
});
|
||||
|
||||
// ================================
|
||||
// SUBSCRIPTIONS
|
||||
// ================================
|
||||
|
||||
async function loadSubscriptions() {
|
||||
try {
|
||||
const res = await fetch('api/subscriptions.php');
|
||||
const d = await res.json();
|
||||
const tbody = document.getElementById('subscription-details-table-body');
|
||||
if (!d.subscriptions || d.subscriptions.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;font-style:italic">No subscriptions active</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = d.subscriptions.map(s =>
|
||||
`<tr><td>${esc(s.sub_id) || '-'}</td><td class="pubkey">${esc((s.pubkey||'-').substring(0,16))}</td><td>${esc(s.filters) || '-'}</td><td>${esc(s.created) || '-'}</td><td>${s.events_sent ?? 0}</td></tr>`
|
||||
).join('');
|
||||
} catch (e) { console.error('[admin2] subscriptions error:', e); }
|
||||
}
|
||||
|
||||
// ================================
|
||||
// CONFIGURATION
|
||||
// ================================
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const res = await fetch('api/config.php');
|
||||
const d = await res.json();
|
||||
const tbody = document.getElementById('config-table-body');
|
||||
if (!d.config || d.config.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;font-style:italic">No config entries</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = d.config.map(c =>
|
||||
`<tr><td>${esc(c.key)}</td><td>${esc(c.value)}</td><td><button onclick="editConfig('${esc(c.key)}')">EDIT</button></td></tr>`
|
||||
).join('');
|
||||
} catch (e) { console.error('[admin2] config error:', e); }
|
||||
}
|
||||
|
||||
function editConfig(key) {
|
||||
const newVal = prompt('Enter new value for ' + key + ':');
|
||||
if (newVal === null) return;
|
||||
fetch('api/config.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({key, value: newVal})
|
||||
}).then(r => r.json()).then(d => {
|
||||
alert(d.message || 'Saved');
|
||||
loadConfig();
|
||||
}).catch(e => alert('Error: ' + e));
|
||||
}
|
||||
|
||||
// ================================
|
||||
// AUTH RULES
|
||||
// ================================
|
||||
|
||||
async function loadAuthRules() {
|
||||
try {
|
||||
const res = await fetch('api/auth.php');
|
||||
const d = await res.json();
|
||||
const tbody = document.getElementById('authRulesTableBody');
|
||||
if (!d.rules || d.rules.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;font-style:italic">No auth rules</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = d.rules.map(r =>
|
||||
`<tr><td>${esc(r.rule_type)}</td><td>${esc(r.pattern_type)}</td><td>${esc(r.pattern_value)}</td><td>${esc(r.status) || 'active'}</td><td><button onclick="removeAuthRule(${r.id})">REMOVE</button></td></tr>`
|
||||
).join('');
|
||||
} catch (e) { console.error('[admin2] auth error:', e); }
|
||||
}
|
||||
|
||||
async function addAuthRule(type) {
|
||||
const pk = document.getElementById('authRulePubkey').value.trim();
|
||||
if (!pk) { alert('Enter a pubkey first'); return; }
|
||||
const res = await fetch('api/auth.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'add', rule_type: type, pattern_value: pk})
|
||||
});
|
||||
const d = await res.json();
|
||||
alert(d.message || 'Done');
|
||||
loadAuthRules();
|
||||
}
|
||||
|
||||
async function removeAuthRule(id) {
|
||||
const res = await fetch('api/auth.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'remove', id})
|
||||
});
|
||||
const d = await res.json();
|
||||
alert(d.message || 'Done');
|
||||
loadAuthRules();
|
||||
}
|
||||
|
||||
// ================================
|
||||
// WEB OF TRUST
|
||||
// ================================
|
||||
|
||||
async function loadWotStatus() {
|
||||
try {
|
||||
const res = await fetch('api/auth.php?action=wot');
|
||||
const d = await res.json();
|
||||
const ind = document.getElementById('wotKind3Indicator');
|
||||
if (ind) ind.textContent = d.kind3_status || 'Unknown';
|
||||
const cnt = document.getElementById('wotWhitelistCount');
|
||||
if (cnt) cnt.textContent = d.whitelist_count ?? '—';
|
||||
const desc = document.getElementById('wotLevelDescription');
|
||||
if (desc) desc.textContent = d.level_description || '';
|
||||
} catch (e) { console.error('[admin2] wot error:', e); }
|
||||
}
|
||||
|
||||
async function setWotLevel(level) {
|
||||
const res = await fetch('api/auth.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'set_wot_level', level})
|
||||
});
|
||||
const d = await res.json();
|
||||
alert(d.message || 'Done');
|
||||
loadWotStatus();
|
||||
}
|
||||
|
||||
async function syncWot() {
|
||||
const res = await fetch('api/auth.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'sync_wot'})
|
||||
});
|
||||
const d = await res.json();
|
||||
alert(d.message || 'Done');
|
||||
loadWotStatus();
|
||||
}
|
||||
|
||||
// ================================
|
||||
// IP BANS
|
||||
// ================================
|
||||
|
||||
async function loadIpBans() {
|
||||
try {
|
||||
const res = await fetch('api/ipbans.php');
|
||||
const d = await res.json();
|
||||
document.getElementById('ip-bans-total').textContent = d.total ?? '-';
|
||||
document.getElementById('ip-bans-active').textContent = d.active ?? '-';
|
||||
document.getElementById('ip-bans-issued').textContent = d.issued ?? '-';
|
||||
const tbody = document.getElementById('ip-bans-tbody');
|
||||
if (!d.bans || d.bans.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center">No IP bans</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = d.bans.map(b =>
|
||||
`<tr><td>${esc(b.ip)}</td><td>${esc(b.status)}</td><td>${esc(b.banned_until)}</td><td>${b.failures ?? 0}</td><td><button onclick="removeBan('${esc(b.ip)}')">REMOVE</button></td></tr>`
|
||||
).join('');
|
||||
} catch (e) { console.error('[admin2] ipbans error:', e); }
|
||||
}
|
||||
|
||||
async function addBan() {
|
||||
const ip = document.getElementById('ban-ip-input').value.trim();
|
||||
const duration = document.getElementById('ban-duration-select').value;
|
||||
if (!ip) { alert('Enter an IP address'); return; }
|
||||
const res = await fetch('api/ipbans.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'add', ip, duration: parseInt(duration)})
|
||||
});
|
||||
const d = await res.json();
|
||||
document.getElementById('add-ban-status').textContent = d.message || 'Done';
|
||||
loadIpBans();
|
||||
}
|
||||
|
||||
async function removeBan(ip) {
|
||||
const res = await fetch('api/ipbans.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'remove', ip})
|
||||
});
|
||||
const d = await res.json();
|
||||
alert(d.message || 'Done');
|
||||
loadIpBans();
|
||||
}
|
||||
|
||||
// ================================
|
||||
// RELAY EVENTS
|
||||
// ================================
|
||||
|
||||
async function loadEvents() {
|
||||
try {
|
||||
const res = await fetch('api/events.php?limit=50');
|
||||
const d = await res.json();
|
||||
const tbody = document.getElementById('live-relay-events-table-body');
|
||||
if (!d.events || d.events.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center">No events</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = d.events.map(e =>
|
||||
`<tr><td>${esc(e.created_at)}</td><td>${e.kind}</td><td><bdi>${esc(e.display_name || (e.pubkey||'').substring(0,16) + '…')}</bdi></td><td class="pubkey">${esc((e.id||'').substring(0,16))}…</td><td>${esc((e.content||'').substring(0,80))}</td></tr>`
|
||||
).join('');
|
||||
} catch (err) { console.error('[admin2] events error:', err); }
|
||||
}
|
||||
|
||||
// ================================
|
||||
// CACHING
|
||||
// ================================
|
||||
|
||||
async function loadCaching() {
|
||||
try {
|
||||
const res = await fetch('api/caching.php');
|
||||
const d = await res.json();
|
||||
// Config toggle state
|
||||
if (d.config) {
|
||||
const enabled = d.config.caching_enabled === 'true';
|
||||
const inboxEnabled = d.config.caching_inbox_enabled === 'true';
|
||||
const enLabel = document.getElementById('caching-enabled-label');
|
||||
const enBtn = document.getElementById('caching-toggle-btn');
|
||||
const inLabel = document.getElementById('caching-inbox-enabled-label');
|
||||
const inBtn = document.getElementById('caching-inbox-toggle-btn');
|
||||
if (enLabel) enLabel.textContent = 'Caching: ' + (enabled ? 'ON' : 'OFF');
|
||||
if (enBtn) enBtn.textContent = enabled ? 'Turn OFF' : 'Turn ON';
|
||||
if (inLabel) inLabel.textContent = 'Inbox: ' + (inboxEnabled ? 'ON' : 'OFF');
|
||||
if (inBtn) inBtn.textContent = inboxEnabled ? 'Turn OFF' : 'Turn ON';
|
||||
}
|
||||
// Service status
|
||||
const ssEl = document.getElementById('caching-service-status');
|
||||
if (ssEl && d.state) {
|
||||
const s = d.state;
|
||||
const hb = s.heartbeat_at ? new Date(s.heartbeat_at * 1000).toLocaleTimeString() : '—';
|
||||
ssEl.innerHTML = `<p>State: <strong>${esc(s.service_state)}</strong> | Follows: ${s.followed_author_count ?? 0} | Connected relays: ${s.connected_relay_count ?? 0}/${s.selected_relay_count ?? 0} | Backfill: ${s.backfill_authors_complete ?? 0}/${s.backfill_authors_total ?? 0} | Events fetched: ${s.events_fetched ?? 0} | Inbox inserts: ${s.inbox_inserts ?? 0} | Heartbeat: ${hb}</p>`;
|
||||
}
|
||||
// Inbox status
|
||||
const isEl = document.getElementById('caching-inbox-status');
|
||||
if (isEl && d.inbox) {
|
||||
isEl.innerHTML = `<p>Pending: ${d.inbox.pending} | Live: ${d.inbox.live} | Backfill: ${d.inbox.backfill}</p>`;
|
||||
}
|
||||
// Follows table
|
||||
const tbody = document.getElementById('caching-follows-table-body');
|
||||
if (tbody && d.follows) {
|
||||
if (d.follows.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;font-style:italic">No followed pubkeys</td></tr>';
|
||||
} else {
|
||||
const activePk = d.active && d.active.pubkey ? d.active.pubkey : '';
|
||||
tbody.innerHTML = d.follows.map((f, i) => {
|
||||
const npub = f.npub || f.pubkey.substring(0, 20);
|
||||
const isActive = activePk && f.pubkey === activePk;
|
||||
const relaySummary = f.relay_incomplete > 0
|
||||
? `<span class="status-working">${f.relay_count - f.relay_incomplete}/${f.relay_count} done</span>`
|
||||
: `${f.relay_count ?? 0} relays`;
|
||||
// Per-relay detail row (hidden by default, toggle via click)
|
||||
let relayDetail = '';
|
||||
if (f.relays && f.relays.length > 0) {
|
||||
relayDetail = '<div class="relay-progress-list">' +
|
||||
f.relays.map(r => {
|
||||
const statusIcon = r.complete ? '✓' : '⏳';
|
||||
const statusBadge = r.complete
|
||||
? `<span class="relay-status relay-done">${esc(r.last_status || 'eose')}</span>`
|
||||
: `<span class="relay-status relay-pending">${esc(r.last_status || 'pending')}</span>`;
|
||||
const relayHost = r.relay_url.replace(/^wss?:\/\//, '').replace(/\/relay$/, '');
|
||||
return `<div class="relay-progress-row"><span class="relay-icon">${statusIcon}</span><span class="relay-url" title="${esc(r.relay_url)}">${esc(relayHost)}</span><span class="relay-events">${r.events_fetched} evts</span>${statusBadge}</div>`;
|
||||
}).join('') +
|
||||
'</div>';
|
||||
}
|
||||
const refreshBtn = `<button type="button" class="refresh-user-btn" onclick="event.stopPropagation(); refreshCachingUser('${esc(f.pubkey)}', '${esc(f.name || '')}')">↻ Refresh this user</button>`;
|
||||
const rowClass = isActive ? 'follows-row follows-row-active' : 'follows-row';
|
||||
const activeIcon = isActive ? '⚡ ' : '';
|
||||
return `<tr class="${rowClass}" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? '' : 'none'"><td><bdi>${activeIcon}${esc(f.name) || '<i>unknown</i>'}</bdi></td><td class="npub-link">${esc(npub)}…</td><td>${f.is_root ? '✓' : ''}</td><td>${f.total_events ?? 0}</td><td>${f.backfill_complete ? '✓' : '…'}</td><td>${relaySummary}</td></tr><tr class="follows-detail" style="display:none"><td colspan="6"><div class="follows-detail-content">${relayDetail || '<i>No relay progress data</i>'}<div class="follows-detail-actions">${refreshBtn}</div></div></td></tr>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
// Active target
|
||||
const fsEl = document.getElementById('caching-follows-status');
|
||||
if (fsEl) {
|
||||
if (d.active && d.active.pubkey && d.active.relay) {
|
||||
fsEl.innerHTML = `<span class="status-working">⚡ Backfilling: ${d.active.pubkey.substring(0,16)}… @ ${esc(d.active.relay)}</span>`;
|
||||
} else if (d.state && d.state.service_state === 'running') {
|
||||
const incomplete = d.state.backfill_authors_complete < d.state.backfill_authors_total;
|
||||
fsEl.innerHTML = incomplete
|
||||
? `<span class="status-working">⚡ Backfill in progress…</span>`
|
||||
: `<span class="status-complete">✓ Backfill complete — listening for live events</span>`;
|
||||
} else {
|
||||
fsEl.innerHTML = `<span class="status-complete">✓ Caching complete</span>`;
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error('[admin2] caching error:', e); }
|
||||
}
|
||||
|
||||
// Toggle caching_enabled config via the config API.
|
||||
async function toggleCachingEnabled() {
|
||||
try {
|
||||
const res = await fetch('api/caching.php');
|
||||
const d = await res.json();
|
||||
const current = d.config?.caching_enabled === 'true';
|
||||
const newVal = current ? 'false' : 'true';
|
||||
await fetch('api/config.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({key: 'caching_enabled', value: newVal})
|
||||
});
|
||||
loadCaching();
|
||||
} catch (e) { console.error('[admin2] toggle caching error:', e); }
|
||||
}
|
||||
|
||||
// Toggle caching_inbox_enabled config via the config API.
|
||||
async function toggleCachingInboxEnabled() {
|
||||
try {
|
||||
const res = await fetch('api/caching.php');
|
||||
const d = await res.json();
|
||||
const current = d.config?.caching_inbox_enabled === 'true';
|
||||
const newVal = current ? 'false' : 'true';
|
||||
await fetch('api/config.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({key: 'caching_inbox_enabled', value: newVal})
|
||||
});
|
||||
loadCaching();
|
||||
} catch (e) { console.error('[admin2] toggle inbox error:', e); }
|
||||
}
|
||||
|
||||
// Re-run all caching: resets backfill progress for all followed authors and
|
||||
// bumps caching_config_generation so the running service hot-reloads and
|
||||
// re-drains from the beginning.
|
||||
async function rerunAllCaching() {
|
||||
if (!confirm('Reset backfill progress for ALL followed authors? The caching service will re-download everything from scratch.')) return;
|
||||
const btn = document.getElementById('caching-rerun-all-btn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Resetting…'; }
|
||||
try {
|
||||
const res = await fetch('api/caching.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'reset_all'})
|
||||
});
|
||||
const d = await res.json();
|
||||
if (d.ok) {
|
||||
if (btn) { btn.textContent = '✓ Reset — re-draining'; }
|
||||
setTimeout(() => { if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Caching'; } loadCaching(); }, 2000);
|
||||
} else {
|
||||
alert('Reset failed: ' + (d.error || 'unknown error'));
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Caching'; }
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[admin2] rerunAllCaching error:', e);
|
||||
alert('Reset failed: ' + e.message);
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Caching'; }
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh a single user: resets backfill progress for one followed author
|
||||
// and bumps caching_config_generation so the service re-fetches that author.
|
||||
async function refreshCachingUser(pubkey, name) {
|
||||
if (!confirm('Reset backfill progress for ' + (name || pubkey.substring(0, 16) + '…') + '? The caching service will re-download this user\'s events from scratch.')) return;
|
||||
try {
|
||||
const res = await fetch('api/caching.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'reset_user', pubkey: pubkey})
|
||||
});
|
||||
const d = await res.json();
|
||||
if (d.ok) {
|
||||
loadCaching();
|
||||
} else {
|
||||
alert('Refresh failed: ' + (d.error || 'unknown error'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[admin2] refreshCachingUser error:', e);
|
||||
alert('Refresh failed: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================
|
||||
// DMs
|
||||
// ================================
|
||||
|
||||
async function loadDMs() {
|
||||
try {
|
||||
const res = await fetch('api/dm.php?limit=50');
|
||||
const d = await res.json();
|
||||
const el = document.getElementById('dm-inbox');
|
||||
if (!d.messages || d.messages.length === 0) {
|
||||
el.innerHTML = '<div class="log-entry">No messages found.</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = d.messages.map(m =>
|
||||
`<div class="log-entry"><strong>kind ${m.kind}</strong> from <span class="pubkey">${esc((m.pubkey||'').substring(0,16))}…</span> at ${esc(m.created_at)}:<br>${esc((m.content||'').substring(0,200))}</div>`
|
||||
).join('');
|
||||
} catch (e) { console.error('[admin2] dm error:', e); }
|
||||
}
|
||||
|
||||
// ================================
|
||||
// SQL QUERY
|
||||
// ================================
|
||||
|
||||
async function executeQuery() {
|
||||
const sql = document.getElementById('sql-input').value.trim();
|
||||
if (!sql) { alert('Enter a SQL query'); return; }
|
||||
if (!sql.toUpperCase().startsWith('SELECT')) { alert('Only SELECT queries are allowed'); return; }
|
||||
try {
|
||||
const res = await fetch('api/query.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({sql})
|
||||
});
|
||||
const d = await res.json();
|
||||
const info = document.getElementById('query-info');
|
||||
const tableDiv = document.getElementById('query-table');
|
||||
if (d.error) {
|
||||
info.textContent = 'Error: ' + d.error;
|
||||
tableDiv.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
info.textContent = `${d.row_count} rows in ${d.time_ms}ms`;
|
||||
if (d.rows && d.rows.length > 0) {
|
||||
const cols = Object.keys(d.rows[0]);
|
||||
let html = '<table class="config-table"><thead><tr>';
|
||||
cols.forEach(c => html += `<th>${c}</th>`);
|
||||
html += '</tr></thead><tbody>';
|
||||
d.rows.forEach(r => {
|
||||
html += '<tr>';
|
||||
cols.forEach(c => html += `<td>${r[c] ?? ''}</td>`);
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
tableDiv.innerHTML = html;
|
||||
} else {
|
||||
tableDiv.innerHTML = '<p>No rows returned.</p>';
|
||||
}
|
||||
} catch (e) { console.error('[admin2] query error:', e); }
|
||||
}
|
||||
|
||||
// ================================
|
||||
// DARK MODE
|
||||
// ================================
|
||||
|
||||
document.getElementById('nav-dark-mode-btn')?.addEventListener('click', () => {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('admin2-dark-mode', document.body.classList.contains('dark-mode'));
|
||||
const btn = document.getElementById('nav-dark-mode-btn');
|
||||
if (btn) btn.textContent = document.body.classList.contains('dark-mode') ? 'LIGHT MODE' : 'DARK MODE';
|
||||
});
|
||||
|
||||
if (localStorage.getItem('admin2-dark-mode') === 'true') {
|
||||
document.body.classList.add('dark-mode');
|
||||
const btn = document.getElementById('nav-dark-mode-btn');
|
||||
if (btn) btn.textContent = 'LIGHT MODE';
|
||||
}
|
||||
|
||||
// ================================
|
||||
// RELAY LETTER ANIMATION
|
||||
// ================================
|
||||
|
||||
// Animate the RELAY letters with an underline sweep. Fires on every stats
|
||||
// refresh so the user gets a visual cue that the page is updating.
|
||||
function startRelayAnimation() {
|
||||
const letters = document.querySelectorAll('.relay-letter');
|
||||
if (letters.length === 0) return;
|
||||
|
||||
// Cancel any in-flight animation so rapid refreshes don't overlap
|
||||
if (relayAnimationTimer) { clearTimeout(relayAnimationTimer); relayAnimationTimer = null; }
|
||||
|
||||
let currentIndex = 0;
|
||||
letters.forEach(l => l.classList.remove('underlined'));
|
||||
|
||||
function animateLetter() {
|
||||
letters.forEach(letter => letter.classList.remove('underlined'));
|
||||
if (letters[currentIndex]) {
|
||||
letters[currentIndex].classList.add('underlined');
|
||||
}
|
||||
currentIndex++;
|
||||
if (currentIndex > letters.length) {
|
||||
// Sweep complete — clear underlines and pause before next refresh
|
||||
letters.forEach(letter => letter.classList.remove('underlined'));
|
||||
relayAnimationTimer = null;
|
||||
return;
|
||||
}
|
||||
relayAnimationTimer = setTimeout(animateLetter, 100);
|
||||
}
|
||||
animateLetter();
|
||||
}
|
||||
|
||||
// ================================
|
||||
// RELAY PUBKEY COPY-TO-CLIPBOARD
|
||||
// ================================
|
||||
|
||||
document.getElementById('relay-pubkey-container')?.addEventListener('click', async () => {
|
||||
const el = document.getElementById('relay-pubkey');
|
||||
if (!el || !el.textContent.trim()) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(el.textContent.replace(/\s+/g, ''));
|
||||
const container = document.getElementById('relay-pubkey-container');
|
||||
container.classList.add('copied');
|
||||
setTimeout(() => container.classList.remove('copied'), 500);
|
||||
} catch (e) { console.warn('[admin2] clipboard copy failed:', e); }
|
||||
});
|
||||
|
||||
// ================================
|
||||
// AUTH — nostr_login_lite modal
|
||||
// ================================
|
||||
|
||||
const loginModal = document.getElementById('login-modal');
|
||||
const loginModalContainer = document.getElementById('login-modal-container');
|
||||
const profileArea = document.getElementById('profile-area');
|
||||
const headerUserName = document.getElementById('header-user-name');
|
||||
const headerUserImage = document.getElementById('header-user-image');
|
||||
const logoutDropdown = document.getElementById('logout-dropdown');
|
||||
|
||||
function showLoginModal() {
|
||||
if (loginModal && loginModalContainer) {
|
||||
if (window.NOSTR_LOGIN_LITE && typeof window.NOSTR_LOGIN_LITE.embed === 'function') {
|
||||
// Clear previous embed before re-embedding
|
||||
loginModalContainer.innerHTML = '';
|
||||
window.NOSTR_LOGIN_LITE.embed('#login-modal-container', { seamless: true });
|
||||
}
|
||||
loginModal.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function hideLoginModal() {
|
||||
if (loginModal) loginModal.style.display = 'none';
|
||||
}
|
||||
|
||||
function showProfileInHeader() {
|
||||
if (profileArea) profileArea.style.display = 'flex';
|
||||
}
|
||||
|
||||
function hideProfileFromHeader() {
|
||||
if (profileArea) profileArea.style.display = 'none';
|
||||
}
|
||||
|
||||
// Toggle logout dropdown when clicking the profile area
|
||||
profileArea?.addEventListener('click', (e) => {
|
||||
// Only toggle if the click wasn't on the logout button itself
|
||||
if (e.target.closest('.logout-btn')) return;
|
||||
if (logoutDropdown) {
|
||||
logoutDropdown.style.display = (logoutDropdown.style.display === 'none' || !logoutDropdown.style.display) ? 'block' : 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Hide logout dropdown when clicking elsewhere
|
||||
document.addEventListener('click', (e) => {
|
||||
if (logoutDropdown && logoutDropdown.style.display === 'block' && !e.target.closest('#profile-area')) {
|
||||
logoutDropdown.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Update header profile display from logged-in user's pubkey.
|
||||
// Sets a placeholder from the npub immediately, then fetches the kind 0
|
||||
// profile event from public relays to populate the name + picture.
|
||||
function updateProfileDisplay(pubkey) {
|
||||
if (!pubkey) return;
|
||||
let npub = '';
|
||||
try {
|
||||
if (pubkey.length === 64 && /^[0-9a-fA-F]+$/.test(pubkey)) {
|
||||
npub = window.NostrTools.nip19.npubEncode(pubkey);
|
||||
}
|
||||
} catch (err) { console.warn('[admin2] npub encode failed:', err); }
|
||||
|
||||
// Placeholder until the profile fetch resolves
|
||||
if (headerUserName) {
|
||||
headerUserName.textContent = npub ? npub.substring(0, 16) + '…' : pubkey.substring(0, 16) + '…';
|
||||
}
|
||||
if (headerUserImage) headerUserImage.style.display = 'none';
|
||||
|
||||
// Fetch kind 0 profile from public relays (same approach as original api page)
|
||||
loadUserProfile(pubkey, npub);
|
||||
}
|
||||
|
||||
// Apply profile data to the header name + profile picture.
|
||||
// Uses best_name from the server (resolved per profile_name_preference).
|
||||
function applyProfileToHeader(name, picture) {
|
||||
if (headerUserName) headerUserName.textContent = name || 'Anonymous User';
|
||||
if (headerUserImage && picture && typeof picture === 'string' &&
|
||||
(picture.startsWith('http://') || picture.startsWith('https://'))) {
|
||||
headerUserImage.src = picture;
|
||||
headerUserImage.style.display = 'block';
|
||||
headerUserImage.onerror = function() { this.style.display = 'none'; };
|
||||
} else if (headerUserImage) {
|
||||
headerUserImage.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the user's profile. Tries the local profiles cache first
|
||||
// (admin/api/profile.php), then falls back to public relays if the
|
||||
// relay has no cached kind-0 for this pubkey.
|
||||
async function loadUserProfile(pubkey, npub) {
|
||||
if (!pubkey) return;
|
||||
|
||||
// Try local profiles cache first.
|
||||
try {
|
||||
const res = await fetch('api/profile.php?pubkey=' + encodeURIComponent(pubkey));
|
||||
if (res.ok) {
|
||||
const profile = await res.json();
|
||||
if (profile && profile.best_name !== undefined) {
|
||||
applyProfileToHeader(profile.best_name, profile.picture);
|
||||
console.log('[admin2] profile loaded from local cache for', profile.best_name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Local endpoint not available — fall through to public relays.
|
||||
}
|
||||
|
||||
// Fall back to public relays.
|
||||
if (!window.NostrTools || !window.NostrTools.SimplePool) {
|
||||
if (headerUserName) headerUserName.textContent = npub ? npub.substring(0, 16) + '…' : 'Anonymous User';
|
||||
return;
|
||||
}
|
||||
const relays = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relay.snort.social'
|
||||
];
|
||||
try {
|
||||
const pool = new window.NostrTools.SimplePool();
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Profile query timeout')), 5000)
|
||||
);
|
||||
const queryPromise = pool.querySync(relays, {
|
||||
kinds: [0],
|
||||
authors: [pubkey],
|
||||
limit: 1
|
||||
});
|
||||
const events = await Promise.race([queryPromise, timeoutPromise]);
|
||||
try { await pool.close(relays); } catch (e) {}
|
||||
|
||||
if (events && events.length > 0) {
|
||||
const profile = JSON.parse(events[0].content);
|
||||
// Use best_name resolution: display_name first, then name.
|
||||
const name = profile.display_name || profile.name || profile.displayName || 'Anonymous User';
|
||||
const picture = profile.picture || profile.image || null;
|
||||
applyProfileToHeader(name, picture);
|
||||
console.log('[admin2] profile loaded from public relays for', name);
|
||||
} else {
|
||||
if (headerUserName) headerUserName.textContent = 'Anonymous User';
|
||||
console.log('[admin2] no profile event found for', pubkey);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[admin2] profile load failed:', err.message);
|
||||
if (headerUserName) headerUserName.textContent = npub ? npub.substring(0, 16) + '…' : 'Error loading profile';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize nostr_login_lite and show modal if not already authenticated
|
||||
async function initializeAuth() {
|
||||
if (!window.NOSTR_LOGIN_LITE) {
|
||||
console.warn('[admin2] NOSTR_LOGIN_LITE not loaded — skipping auth modal');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await window.NOSTR_LOGIN_LITE.init({
|
||||
theme: 'default',
|
||||
methods: {
|
||||
extension: true,
|
||||
local: true,
|
||||
seedphrase: true,
|
||||
readonly: true,
|
||||
connect: true,
|
||||
remote: true,
|
||||
otp: false
|
||||
},
|
||||
floatingTab: { enabled: false }
|
||||
});
|
||||
nlLite = window.NOSTR_LOGIN_LITE;
|
||||
console.log('[admin2] nostr_login_lite initialized');
|
||||
|
||||
// Check for existing auth state
|
||||
let alreadyLoggedIn = false;
|
||||
try {
|
||||
const stored = localStorage.getItem('nostr_login_lite_auth');
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed && parsed.pubkey) {
|
||||
userPubkey = parsed.pubkey;
|
||||
isLoggedIn = true;
|
||||
alreadyLoggedIn = true;
|
||||
showProfileInHeader();
|
||||
updateProfileDisplay(userPubkey);
|
||||
hideLoginModal();
|
||||
console.log('[admin2] existing auth restored for', userPubkey);
|
||||
}
|
||||
}
|
||||
} catch (e) { /* no stored auth */ }
|
||||
|
||||
if (!alreadyLoggedIn) {
|
||||
console.log('[admin2] no existing auth — showing login modal');
|
||||
showLoginModal();
|
||||
}
|
||||
|
||||
// Listen for auth events
|
||||
window.addEventListener('nlMethodSelected', (event) => {
|
||||
const { pubkey, method, error } = event.detail || {};
|
||||
if (method && pubkey) {
|
||||
userPubkey = pubkey;
|
||||
isLoggedIn = true;
|
||||
console.log('[admin2] login success via', method, pubkey);
|
||||
hideLoginModal();
|
||||
showProfileInHeader();
|
||||
updateProfileDisplay(pubkey);
|
||||
} else if (error) {
|
||||
console.warn('[admin2] auth error:', error);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('nlLogout', () => {
|
||||
console.log('[admin2] logout event received');
|
||||
userPubkey = null;
|
||||
isLoggedIn = false;
|
||||
hideProfileFromHeader();
|
||||
if (logoutDropdown) logoutDropdown.style.display = 'none';
|
||||
showLoginModal();
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('[admin2] nostr_login_lite init failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Logout function — clears auth state and re-shows login modal
|
||||
async function logout() {
|
||||
console.log('[admin2] logging out...');
|
||||
try {
|
||||
if (nlLite && typeof nlLite.logout === 'function') {
|
||||
await nlLite.logout();
|
||||
}
|
||||
} catch (e) { console.warn('[admin2] nlLite.logout error:', e); }
|
||||
userPubkey = null;
|
||||
isLoggedIn = false;
|
||||
hideProfileFromHeader();
|
||||
if (logoutDropdown) logoutDropdown.style.display = 'none';
|
||||
showLoginModal();
|
||||
console.log('[admin2] logged out');
|
||||
}
|
||||
|
||||
// ================================
|
||||
// INIT
|
||||
// ================================
|
||||
|
||||
switchPage('statistics');
|
||||
|
||||
// Start the RELAY animation immediately on page load
|
||||
startRelayAnimation();
|
||||
|
||||
// Initialize auth + load initial chart on DOM ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(initializeAuth, 100);
|
||||
// Load the default (hour) chart immediately
|
||||
loadChart(currentChartRange);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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%; }
|
||||
}
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
New Events — Last 24 Hours
|
||||
|
||||
11 | X
|
||||
10 | XX
|
||||
9 | XX
|
||||
8 | XX
|
||||
7 | XX X X
|
||||
6 | XX X X
|
||||
5 | XX X X
|
||||
4 | X XX X XX
|
||||
3 | X XXX X XX
|
||||
2 | X X X XXX X XX
|
||||
1 | X XX X X X X X X X X X X X X XX X X XXXX X X X X X XX
|
||||
+--------------------------------------------------------------------------------
|
||||
0s 1h 3h 4h 6h 7h 9h 10h 12h 13h 15h 16h 18h 19h 21h 22h
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
New Events — Last 30 Days
|
||||
|
||||
81 | X
|
||||
73 | XX X
|
||||
65 | XX X
|
||||
57 | XX X X X
|
||||
49 | X X XX XX X X X
|
||||
41 | X X X XX X X X XXX XXX XXX X X X X
|
||||
33 | X X X XXXX XX X X X XX XXX XXXXXXXX XX X X X X X XX
|
||||
25 |X XX XX XX XXXX XX XXX X XX XXXXXXXXXXXXXXXXXXXX XX XXX X XX XX XXXXX
|
||||
17 |X XXXXX XX XXXXXXXXXXXXXXX X XX XXXXXXXXXXXXXXXXXXXXXXXX XXXXXXXXX XX XXXXXXXX
|
||||
9 |XXX XXXXXXXX XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX XXXXXXXXXXXXXXXXXXXXX
|
||||
1 |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
+--------------------------------------------------------------------------------
|
||||
0s 1d 3d 5d 7d 9d 11d 13d 15d 16d 18d 20d 22d 24d 26d 28d
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
New Events — Last Year
|
||||
|
||||
531 | X XX
|
||||
478 | X X X XXX X
|
||||
425 | X X XX X XX XXX X
|
||||
372 | XXX XX X XX XXX X
|
||||
319 |XXXXXXXXXXX X X X XXXXXXX X
|
||||
266 |XXXXXXXXXXXX X XX XXX XXXXXXXXX X
|
||||
213 |XXXXXXXXXXXXXX XXX XXXXXXXXXXXXX XX X
|
||||
160 |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX X X XXXX X
|
||||
107 |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX XX XX XX X X X XX XXX X XXXXXXX X
|
||||
54 |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX XXXXXXXXXXXXXXXXXXXXX XXXXXXXXXXX
|
||||
1 |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
+--------------------------------------------------------------------------------
|
||||
0s 22d 45d 68d 91d 114d 136d 159d 182d 205d 228d 250d 273d 296d 319d 342d
|
||||
@@ -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(); ?>
|
||||
@@ -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
@@ -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
@@ -1,158 +1,429 @@
|
||||
<?php
|
||||
/**
|
||||
* C-Relay-PG Admin — Dashboard.
|
||||
* Auto-refreshes every 10 seconds via AJAX polling of api/status.php.
|
||||
* admin2 — Full PHP admin for C-Relay-PG.
|
||||
* Recreates the original api/ admin interface with a PHP/PDO backend
|
||||
* instead of Nostr WebSocket admin commands.
|
||||
*/
|
||||
require_once __DIR__ . '/lib/helpers.php';
|
||||
|
||||
$pdo = db();
|
||||
|
||||
// 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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <ctype.h>
|
||||
|
||||
int cr_follow_is_root(const cr_config_t *cfg, const char *hex) {
|
||||
if (!cfg->root_hex_ready) return 0;
|
||||
@@ -16,17 +17,35 @@ int cr_follow_is_root(const cr_config_t *cfg, const char *hex) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Check if a string is a 64-character hex pubkey (no npub prefix). */
|
||||
static int is_hex_pubkey(const char *s) {
|
||||
if (!s || strlen(s) != 64) return 0;
|
||||
for (int i = 0; i < 64; i++) {
|
||||
if (!isxdigit((unsigned char)s[i])) return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
int cr_follow_decode_roots(cr_config_t *cfg) {
|
||||
for (int i = 0; i < cfg->root_npub_count; i++) {
|
||||
unsigned char pubkey[32];
|
||||
if (nostr_decode_npub(cfg->root_npubs[i], pubkey) != NOSTR_SUCCESS) {
|
||||
DEBUG_ERROR("follow: failed to decode npub '%s'", cfg->root_npubs[i]);
|
||||
return -1;
|
||||
const char *input = cfg->root_npubs[i];
|
||||
if (is_hex_pubkey(input)) {
|
||||
/* Already a 64-char hex pubkey — copy directly. */
|
||||
strncpy(cfg->root_hex[i], input, 64);
|
||||
cfg->root_hex[i][64] = '\0';
|
||||
} else {
|
||||
/* Assume npub (bech32) format — decode to hex. */
|
||||
unsigned char pubkey[32];
|
||||
if (nostr_decode_npub(input, pubkey) != NOSTR_SUCCESS) {
|
||||
DEBUG_ERROR("follow: failed to decode root pubkey '%s' "
|
||||
"(not a valid npub or 64-char hex)", input);
|
||||
return -1;
|
||||
}
|
||||
nostr_bytes_to_hex(pubkey, 32, cfg->root_hex[i]);
|
||||
}
|
||||
nostr_bytes_to_hex(pubkey, 32, cfg->root_hex[i]);
|
||||
}
|
||||
cfg->root_hex_ready = 1;
|
||||
DEBUG_INFO("follow: decoded %d root npubs", cfg->root_npub_count);
|
||||
DEBUG_INFO("follow: decoded %d root pubkeys", cfg->root_npub_count);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* caching_relay - forward catch-up implementation.
|
||||
*
|
||||
* See forward_catchup.h for design.
|
||||
*/
|
||||
#include "forward_catchup.h"
|
||||
#include "pg_inbox.h"
|
||||
#include "config.h"
|
||||
#include "follow_graph.h"
|
||||
#include "relay_sink.h"
|
||||
#include "debug.h"
|
||||
|
||||
#include <cjson/cJSON.h>
|
||||
#include <time.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/* Find the newest (maximum) created_at among an array of event JSON objects.
|
||||
* Returns 1 and sets *out_newest, or 0 if no events / no valid created_at. */
|
||||
static int find_newest_created_at(cJSON **events, int count, long *out_newest) {
|
||||
long newest = 0;
|
||||
int found = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
cJSON *ca = cJSON_GetObjectItem(events[i], "created_at");
|
||||
if (!ca || !cJSON_IsNumber(ca)) continue;
|
||||
long ts = (long)ca->valuedouble;
|
||||
if (!found || ts > newest) {
|
||||
newest = ts;
|
||||
found = 1;
|
||||
}
|
||||
}
|
||||
if (found && out_newest) *out_newest = newest;
|
||||
return found;
|
||||
}
|
||||
|
||||
/* Build the kinds array for a given pubkey (admin vs regular).
|
||||
* Returns NULL if no kinds filter should be applied (admin_all_kinds). */
|
||||
static cJSON *build_kinds(cr_config_t *cfg, const char *pk) {
|
||||
int is_admin = cr_follow_is_root(cfg, pk);
|
||||
if (is_admin && cfg->admin_all_kinds) {
|
||||
return NULL;
|
||||
}
|
||||
cJSON *kinds = cJSON_CreateArray();
|
||||
if (is_admin && cfg->admin_kind_count > 0) {
|
||||
for (int i = 0; i < cfg->admin_kind_count; i++)
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->admin_kinds[i]));
|
||||
} else {
|
||||
for (int i = 0; i < cfg->kind_count; i++)
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->kinds[i]));
|
||||
}
|
||||
return kinds;
|
||||
}
|
||||
|
||||
/* Get relay URLs for an author from the relay progress table.
|
||||
* Returns a cJSON array of relay_url strings. Caller must cJSON_Delete().
|
||||
* Returns NULL on error or if no relays found. */
|
||||
static cJSON *get_author_relays(const char *pk) {
|
||||
/* Use pg_inbox_get_incomplete_relays for incomplete authors.
|
||||
* For completed authors, we need all relays — query directly. */
|
||||
cJSON *incomplete = pg_inbox_get_incomplete_relays(pk);
|
||||
if (incomplete && cJSON_GetArraySize(incomplete) > 0) {
|
||||
/* Extract just the relay_url strings. */
|
||||
cJSON *urls = cJSON_CreateArray();
|
||||
int n = cJSON_GetArraySize(incomplete);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON *entry = cJSON_GetArrayItem(incomplete, i);
|
||||
cJSON *url = cJSON_GetObjectItemCaseSensitive(entry, "relay_url");
|
||||
if (url && cJSON_IsString(url) && url->valuestring[0]) {
|
||||
cJSON_AddItemToArray(urls, cJSON_CreateString(url->valuestring));
|
||||
}
|
||||
}
|
||||
cJSON_Delete(incomplete);
|
||||
if (cJSON_GetArraySize(urls) > 0) return urls;
|
||||
cJSON_Delete(urls);
|
||||
}
|
||||
if (incomplete) cJSON_Delete(incomplete);
|
||||
|
||||
/* For completed authors, fall back to the upstream pool's connected
|
||||
* relays. The caller will use the pool directly. */
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int cr_forward_catchup(cr_config_t *cfg,
|
||||
nostr_relay_pool_t *upstream,
|
||||
cr_sink_t *sink) {
|
||||
if (!cfg || !upstream || !sink) return -1;
|
||||
|
||||
cJSON *authors = pg_inbox_get_authors_for_catchup();
|
||||
if (!authors) {
|
||||
DEBUG_INFO("forward_catchup: no authors with last_event_at > 0");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int author_count = cJSON_GetArraySize(authors);
|
||||
if (author_count == 0) {
|
||||
cJSON_Delete(authors);
|
||||
DEBUG_INFO("forward_catchup: no authors need catch-up");
|
||||
return 0;
|
||||
}
|
||||
|
||||
DEBUG_INFO("forward_catchup: checking %d authors for missed events", author_count);
|
||||
|
||||
long now = (long)time(NULL);
|
||||
int page_size = cfg->backfill.events_per_tick;
|
||||
if (page_size < 1) page_size = 500;
|
||||
|
||||
cr_sink_set_source_class(sink, CR_SINK_CLASS_BACKFILL);
|
||||
|
||||
int total_events_published = 0;
|
||||
int authors_caught_up = 0;
|
||||
|
||||
for (int i = 0; i < author_count; i++) {
|
||||
cJSON *entry = cJSON_GetArrayItem(authors, i);
|
||||
if (!entry) continue;
|
||||
|
||||
cJSON *pk_node = cJSON_GetObjectItemCaseSensitive(entry, "pubkey");
|
||||
cJSON *lea_node = cJSON_GetObjectItemCaseSensitive(entry, "last_event_at");
|
||||
if (!pk_node || !cJSON_IsString(pk_node) || !lea_node || !cJSON_IsNumber(lea_node))
|
||||
continue;
|
||||
|
||||
const char *pk = pk_node->valuestring;
|
||||
long last_event_at = (long)lea_node->valuedouble;
|
||||
|
||||
if (last_event_at <= 0 || last_event_at >= now) continue;
|
||||
|
||||
/* Build filter: since = last_event_at + 1, until = now. */
|
||||
cJSON *filter = cJSON_CreateObject();
|
||||
cJSON *authors_arr = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(authors_arr, cJSON_CreateString(pk));
|
||||
cJSON_AddItemToObject(filter, "authors", authors_arr);
|
||||
cJSON *kinds = build_kinds(cfg, pk);
|
||||
if (kinds) cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddItemToObject(filter, "since", cJSON_CreateNumber((double)(last_event_at + 1)));
|
||||
cJSON_AddItemToObject(filter, "until", cJSON_CreateNumber((double)now));
|
||||
cJSON_AddItemToObject(filter, "limit", cJSON_CreateNumber((double)page_size));
|
||||
|
||||
/* Try to get author-specific relays first; fall back to upstream pool. */
|
||||
cJSON *author_relays = get_author_relays(pk);
|
||||
|
||||
int ev_count = 0;
|
||||
cJSON **events = NULL;
|
||||
|
||||
if (author_relays && cJSON_GetArraySize(author_relays) > 0) {
|
||||
/* Query using the author's outbox relays. */
|
||||
int n_relays = cJSON_GetArraySize(author_relays);
|
||||
const char **urls = malloc(sizeof(char *) * n_relays);
|
||||
for (int r = 0; r < n_relays; r++) {
|
||||
cJSON *u = cJSON_GetArrayItem(author_relays, r);
|
||||
urls[r] = cJSON_IsString(u) ? u->valuestring : "";
|
||||
}
|
||||
events = synchronous_query_relays_with_progress(
|
||||
urls, n_relays, filter, RELAY_QUERY_ALL_RESULTS,
|
||||
&ev_count, 30, NULL, NULL, 0, NULL);
|
||||
free(urls);
|
||||
} else {
|
||||
/* Fall back: query all connected upstream relays.
|
||||
* Use the pool's relay list. We pass NULL for urls to let
|
||||
* the pool use all connected relays. */
|
||||
events = synchronous_query_relays_with_progress(
|
||||
NULL, 0, filter, RELAY_QUERY_ALL_RESULTS,
|
||||
&ev_count, 30, NULL, NULL, 0, NULL);
|
||||
}
|
||||
cJSON_Delete(author_relays);
|
||||
cJSON_Delete(filter);
|
||||
|
||||
if (events && ev_count > 0) {
|
||||
/* Publish all events. */
|
||||
long newest = 0;
|
||||
find_newest_created_at(events, ev_count, &newest);
|
||||
|
||||
for (int k = 0; k < ev_count; k++) {
|
||||
cr_sink_publish(sink, events[k]);
|
||||
cJSON_Delete(events[k]);
|
||||
}
|
||||
free(events);
|
||||
total_events_published += ev_count;
|
||||
authors_caught_up++;
|
||||
|
||||
/* Update last_event_at to the newest event we saw. */
|
||||
if (newest > 0) {
|
||||
pg_inbox_update_last_event_at(pk, newest);
|
||||
}
|
||||
|
||||
DEBUG_LOG("forward_catchup: %s -> %d events (since=%ld, until=%ld)",
|
||||
pk, ev_count, last_event_at + 1, now);
|
||||
} else {
|
||||
/* No events in the gap — update last_event_at to now so we
|
||||
* don't re-query the same empty window next time. */
|
||||
pg_inbox_update_last_event_at(pk, now);
|
||||
if (events) free(events);
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(authors);
|
||||
|
||||
DEBUG_INFO("forward_catchup: %d authors checked, %d had events, %d total events published",
|
||||
author_count, authors_caught_up, total_events_published);
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* caching_relay - forward catch-up for gap bridging.
|
||||
*
|
||||
* When the caching service starts (or caching is re-enabled after being
|
||||
* off), events posted by followed authors during the downtime are missed
|
||||
* by the backward-drain backfill (which walks toward the beginning of
|
||||
* time) and by the live subscriber (which starts at `since = now`).
|
||||
*
|
||||
* The forward catch-up queries `since = last_event_at + 1, until = now`
|
||||
* for each followed author to bridge that gap. `last_event_at` is the
|
||||
* `created_at` of the most recent event we've ever seen for the author,
|
||||
* maintained by the backfill and live subscriber.
|
||||
*/
|
||||
#ifndef CACHING_RELAY_FORWARD_CATCHUP_H
|
||||
#define CACHING_RELAY_FORWARD_CATCHUP_H
|
||||
|
||||
#include "config.h"
|
||||
#include "relay_sink.h"
|
||||
#include "../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
/* Run forward catch-up for all followed authors with last_event_at > 0.
|
||||
* For each author, queries events from last_event_at + 1 to now on one
|
||||
* outbox relay and publishes them to the sink.
|
||||
*
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int cr_forward_catchup(cr_config_t *cfg,
|
||||
nostr_relay_pool_t *upstream,
|
||||
cr_sink_t *sink);
|
||||
|
||||
#endif /* CACHING_RELAY_FORWARD_CATCHUP_H */
|
||||
@@ -4,6 +4,7 @@
|
||||
#define _GNU_SOURCE
|
||||
#include "live_subscriber.h"
|
||||
#include "follow_graph.h"
|
||||
#include "pg_inbox.h"
|
||||
#include "debug.h"
|
||||
|
||||
#include <string.h>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
# admin2: Full PHP Admin for C-Relay-PG
|
||||
|
||||
## Goal
|
||||
|
||||
Recreate the entire c-relay-pg admin interface as a PHP application in a new
|
||||
`admin2/` directory, starting from the existing `api/` HTML/CSS/JS files and
|
||||
replacing the Nostr admin command backend with direct PostgreSQL queries via
|
||||
PHP PDO. This covers ALL admin sections, not just caching.
|
||||
|
||||
## Architecture
|
||||
|
||||
Same as `admin/` (PHP + nginx + PDO + 10s polling), but with the full
|
||||
section set from the original `api/index.html`:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Browser -->|HTTPS /admin2/| Nginx
|
||||
Nginx -->|*.php| PHP_FPM
|
||||
Nginx -->|/relay/| Relay[C-Relay-PG]
|
||||
PHP_FPM -->|PDO| PostgreSQL
|
||||
Relay -->|libpq| PostgreSQL
|
||||
```
|
||||
|
||||
## Sections (9 pages, matching original nav)
|
||||
|
||||
| # | Nav Label | Section ID | Data Source | PHP API Endpoint |
|
||||
|---|-----------|------------|-------------|------------------|
|
||||
| 1 | Statistics | databaseStatisticsSection | `pg_database_size()`, `events` count, `pg_stat_activity`, process stats | `api/stats.php` |
|
||||
| 2 | Subscriptions | subscriptionDetailsSection | `subscriptions` + `subscription_metrics` tables | `api/subscriptions.php` |
|
||||
| 3 | Configuration | div_config | `config` table (read/edit all keys) | `api/config.php` |
|
||||
| 4 | Authorization | authRulesSection + wotSection | `auth_rules` table | `api/auth.php` |
|
||||
| 5 | IP BAN | ipBansSection | `ip_bans` table | `api/ipbans.php` |
|
||||
| 6 | Relay Events | relayEventsSection | `events` table (recent events, kind filter) | `api/events.php` |
|
||||
| 7 | Caching | cachingSection | `caching_*` tables (already built in `admin/`) | `api/caching.php` |
|
||||
| 8 | DM | nip17DMSection | `events` table (kind 4/14/15 DMs) | `api/dm.php` |
|
||||
| 9 | Database Query | sqlQuerySection | Direct SQL execution (admin only) | `api/query.php` |
|
||||
|
||||
## Graph on Statistics Page
|
||||
|
||||
The original has an event-rate chart. For the PHP version:
|
||||
- **X-axis:** one tick = 10 seconds (the refresh interval)
|
||||
- **Y-axis:** events per interval
|
||||
- **Data source:** `api/stats.php` returns `events_delta` (count of new events since last poll)
|
||||
- **JS:** maintains a rolling array of the last 60 data points (10 minutes of history), appends a new point on each 10s refresh, renders as a simple canvas/SVG line chart
|
||||
- **No external chart library** — use a lightweight inline canvas renderer to keep it dependency-free
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
admin2/
|
||||
├── index.php ← Single-page app (all 9 sections in one page, show/hide via JS)
|
||||
├── assets/
|
||||
│ ├── index.css ← Copied from api/index.css (unchanged)
|
||||
│ └── app.js ← Adapted from api/index.js (replace Nostr commands with fetch() to PHP APIs)
|
||||
├── lib/
|
||||
│ ├── config.php ← DB connection config (same as admin/)
|
||||
│ ├── db.php ← PDO helper (same as admin/)
|
||||
│ └── helpers.php ← Shared helpers (same as admin/)
|
||||
├── api/
|
||||
│ ├── stats.php ← Statistics: DB size, event counts, process info, event-rate delta
|
||||
│ ├── subscriptions.php ← Subscription details + metrics
|
||||
│ ├── config.php ← Config table read/edit (all keys, not just caching)
|
||||
│ ├── auth.php ← Auth rules + WoT status
|
||||
│ ├── ipbans.php ← IP ban list (read/add/remove)
|
||||
│ ├── events.php ← Recent relay events (paginated, kind filter)
|
||||
│ ├── caching.php ← Caching status (reuses admin/api/status.php logic)
|
||||
│ ├── dm.php ← DM list (kind 4/14/15 events)
|
||||
│ └── query.php ← Direct SQL query execution (admin only, read-only by default)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1 — Scaffold admin2/ directory
|
||||
- Create `admin2/` with `lib/` (copy from `admin/`), `assets/`, `api/`
|
||||
- Copy `api/index.css` → `admin2/assets/index.css` (unchanged)
|
||||
- Copy `api/index.html` → `admin2/index.php` (will be adapted)
|
||||
|
||||
### Step 2 — Adapt index.php from api/index.html
|
||||
- Remove Nostr login/auth (NIP-07 extension, kind-23456 admin commands)
|
||||
- Remove WebSocket connection logic (replaced by PHP AJAX polling)
|
||||
- Keep all 9 section HTML structures exactly as-is
|
||||
- Keep the side-nav, header, section show/hide JS
|
||||
- Replace `sendAdminCommand()` calls with `fetch('api/*.php')` calls
|
||||
- Add 10s auto-refresh via `setInterval` for statistics page
|
||||
- Add event-rate chart canvas renderer
|
||||
|
||||
### Step 3 — Adapt app.js from api/index.js
|
||||
- Keep: `switchPage()`, nav handling, section show/hide, config table rendering
|
||||
- Remove: WebSocket connection, NIP-07 login, NIP-44 encryption, kind-23456 event sending
|
||||
- Replace: each `sendAdminCommand(['system_command', '...'])` with `fetch('api/....php')`
|
||||
- Add: `refreshStats()` function that polls `api/stats.php` every 10s
|
||||
- Add: event-rate chart rendering on canvas (rolling 60-point window)
|
||||
|
||||
### Step 4 — Build PHP API endpoints (9 files)
|
||||
Each endpoint returns JSON, queried directly from PostgreSQL via PDO:
|
||||
|
||||
**api/stats.php** — Statistics page data:
|
||||
```sql
|
||||
SELECT pg_database_size(current_database()) AS db_size;
|
||||
SELECT COUNT(*) FROM events;
|
||||
SELECT COUNT(*) FROM pg_stat_activity WHERE state = 'active';
|
||||
-- Event rate: count events with first_seen in last 10s
|
||||
SELECT COUNT(*) FROM events WHERE first_seen >= EXTRACT(EPOCH FROM NOW())::BIGINT - 10;
|
||||
-- Kind distribution
|
||||
SELECT kind, COUNT(*) FROM events GROUP BY kind ORDER BY count DESC;
|
||||
```
|
||||
|
||||
**api/config.php** — Configuration (all config keys, not just caching):
|
||||
```sql
|
||||
SELECT key, value FROM config ORDER BY key;
|
||||
-- POST: UPDATE config SET value = ? WHERE key = ?
|
||||
```
|
||||
|
||||
**api/subscriptions.php** — Subscription details:
|
||||
```sql
|
||||
SELECT * FROM subscriptions ORDER BY created_at DESC LIMIT 100;
|
||||
SELECT * FROM subscription_metrics ORDER BY id DESC LIMIT 100;
|
||||
```
|
||||
|
||||
**api/auth.php** — Auth rules:
|
||||
```sql
|
||||
SELECT * FROM auth_rules ORDER BY id;
|
||||
```
|
||||
|
||||
**api/ipbans.php** — IP bans:
|
||||
```sql
|
||||
SELECT * FROM ip_bans ORDER BY banned_at DESC;
|
||||
-- POST: INSERT/DELETE
|
||||
```
|
||||
|
||||
**api/events.php** — Recent relay events (paginated):
|
||||
```sql
|
||||
SELECT id, pubkey, kind, created_at, content, tags
|
||||
FROM events ORDER BY created_at DESC LIMIT 50 OFFSET ?;
|
||||
-- Filter by kind, pubkey
|
||||
```
|
||||
|
||||
**api/caching.php** — Caching status (reuse from admin/):
|
||||
```sql
|
||||
SELECT * FROM caching_service_state;
|
||||
SELECT * FROM caching_followed_pubkeys LIMIT 50 OFFSET ?;
|
||||
SELECT * FROM caching_backfill_relay_progress LIMIT 50 OFFSET ?;
|
||||
```
|
||||
|
||||
**api/dm.php** — Direct messages:
|
||||
```sql
|
||||
SELECT id, pubkey, kind, created_at, content
|
||||
FROM events WHERE kind IN (4, 14, 15) ORDER BY created_at DESC LIMIT 50;
|
||||
```
|
||||
|
||||
**api/query.php** — SQL query (admin only):
|
||||
```sql
|
||||
-- Execute arbitrary SELECT, return JSON
|
||||
-- Read-only by default; write mode behind a flag
|
||||
```
|
||||
|
||||
### Step 5 — Event-rate chart
|
||||
- Canvas element in the statistics section
|
||||
- JS maintains `eventRateHistory = []` (max 60 points)
|
||||
- On each 10s refresh, push new event count, shift if > 60
|
||||
- Render as line chart on canvas (no external library)
|
||||
- X-axis labels: time (mm:ss), one tick per 10s
|
||||
- Y-axis labels: event count per interval
|
||||
|
||||
### Step 6 — nginx config
|
||||
Add `/admin2/` location block (same pattern as `/admin/`):
|
||||
```nginx
|
||||
location ^~ /admin2/ {
|
||||
alias /opt/c-relay-pg/admin2/;
|
||||
index index.php;
|
||||
auth_basic "Relay Admin";
|
||||
auth_basic_user_file /opt/c-relay-pg/admin/.htpasswd;
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $request_filename;
|
||||
}
|
||||
location ^~ /admin2/lib/ { deny all; }
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7 — Deploy and test
|
||||
- Copy `admin2/` to server
|
||||
- Add nginx location block, reload
|
||||
- Test all 9 sections
|
||||
- Verify 10s auto-refresh on statistics page
|
||||
- Verify event-rate chart updates
|
||||
|
||||
## Key Differences from Original api/
|
||||
|
||||
| Aspect | Original (api/) | New (admin2/) |
|
||||
|--------|-----------------|---------------|
|
||||
| Auth | NIP-07 Nostr extension login | HTTP Basic Auth (nginx) |
|
||||
| Data fetch | WebSocket + kind-23456 encrypted admin commands | PHP AJAX `fetch()` to `api/*.php` |
|
||||
| Encryption | NIP-44 (64KB limit) | None (direct DB, no limit) |
|
||||
| Real-time | WebSocket push | 10s polling |
|
||||
| Backend | C relay binary (embedded JS) | PHP-FPM + PostgreSQL PDO |
|
||||
| Sections | 9 (same) | 9 (same HTML, different data source) |
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- NIP-07 Nostr login (use HTTP Basic Auth)
|
||||
- WebSocket push (use 10s polling)
|
||||
- Write operations that require relay-level logic (event publishing, NIP-42 auth) — those stay on the Nostr admin API
|
||||
- The original `api/` page remains unchanged and functional
|
||||
@@ -0,0 +1,306 @@
|
||||
# Forward Catch-Up Plan: Bridging the Gap When Caching Resumes
|
||||
|
||||
## Problem
|
||||
|
||||
When caching is turned off (or the caching service stops), events posted by
|
||||
followed authors during the downtime are missed. The current backfill walks
|
||||
**backward** from `until_cursor` toward the beginning of time — it does not
|
||||
cover events **newer** than the cursor. The live subscriber uses
|
||||
`since = time(NULL)`, so it only catches events from the moment it connects.
|
||||
Events in the gap between "last event we know about" and "caching resumed"
|
||||
are lost.
|
||||
|
||||
## Schema Change: `last_event_at` column
|
||||
|
||||
Add a `last_event_at BIGINT NOT NULL DEFAULT 0` column to
|
||||
`caching_followed_pubkeys`. This records the `created_at` of the most
|
||||
recent event we've ever seen for this author — not a proxy like
|
||||
`updated_at` (which records when we last *touched* the row, which could
|
||||
be a zero-event progress write or a follow-graph refresh).
|
||||
|
||||
`since = last_event_at + 1, until = now` is exact: it catches every
|
||||
event the relay doesn't yet have, with no assumptions about when the
|
||||
last event occurred relative to when we last checked.
|
||||
|
||||
### Population
|
||||
|
||||
- **Backfill**: when publishing a page of events, compute
|
||||
`max(created_at)` across the page (we already compute
|
||||
`min(created_at)` for the cursor advance — add a parallel max).
|
||||
Update `last_event_at = GREATEST(last_event_at, page_max_created_at)`.
|
||||
- **Live subscriber**: when an event is received and published, update
|
||||
`last_event_at = GREATEST(last_event_at, event.created_at)`.
|
||||
- **One-time seed from `events` table**: on schema upgrade, set
|
||||
`last_event_at = COALESCE((SELECT MAX(created_at) FROM events WHERE
|
||||
pubkey = caching_followed_pubkeys.pubkey), 0)` for all existing rows.
|
||||
This seeds the column from the relay's own data.
|
||||
|
||||
### Migration
|
||||
|
||||
Added to `src/pg_schema.sql` and `src/pg_schema.h`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE caching_followed_pubkeys
|
||||
ADD COLUMN IF NOT EXISTS last_event_at BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
-- One-time seed from the events table.
|
||||
UPDATE caching_followed_pubkeys fp
|
||||
SET last_event_at = COALESCE(
|
||||
(SELECT MAX(e.created_at) FROM events e WHERE e.pubkey = fp.pubkey),
|
||||
0
|
||||
)
|
||||
WHERE fp.last_event_at = 0;
|
||||
```
|
||||
|
||||
No schema version bump needed — `ALTER TABLE ADD COLUMN IF NOT EXISTS`
|
||||
is idempotent and the seed `UPDATE` is guarded by `WHERE last_event_at = 0`.
|
||||
|
||||
## Current Architecture
|
||||
|
||||
### Tables
|
||||
|
||||
**`caching_followed_pubkeys`** (per-author state):
|
||||
| Column | Purpose |
|
||||
|---|---|
|
||||
| `pubkey` | PK |
|
||||
| `until_cursor` | Unix timestamp; backfill queries `until = this`, walks backward |
|
||||
| `backfill_complete` | TRUE when drained to the beginning of time |
|
||||
| `events_fetched` | Cumulative count |
|
||||
| `last_seen` | Updated on follow-graph refresh |
|
||||
| `updated_at` | Updated on every backfill progress write and completion mark |
|
||||
| `last_event_at` | **NEW**: `created_at` of the most recent event we've seen for this author |
|
||||
|
||||
**`caching_backfill_relay_progress`** (per-author-per-relay state):
|
||||
| Column | Purpose |
|
||||
|---|---|
|
||||
| `author_pubkey, relay_url` | Composite PK |
|
||||
| `until_cursor` | Per-relay backward-walk cursor |
|
||||
| `complete` | TRUE when this relay is drained for this author |
|
||||
| `updated_at` | Updated on every relay progress write |
|
||||
|
||||
### Backfill flow ([`caching/src/backfill.c`](../caching/src/backfill.c:256))
|
||||
|
||||
1. Pick next incomplete author (round-robin)
|
||||
2. For each incomplete relay for that author:
|
||||
- Query `authors=[pk], until=until_cursor, limit=page_size`
|
||||
- Publish events to the relay via the sink
|
||||
- Advance `until_cursor` to `oldest_event_created_at - 1`
|
||||
- If < page_size events + EOSE: mark relay `complete = TRUE`
|
||||
3. If all relays complete: mark author `backfill_complete = TRUE`
|
||||
|
||||
### Restart behavior
|
||||
|
||||
- **Normal restart** (no `--restart`): cursor and completion state preserved.
|
||||
Backfill resumes the backward walk from stored cursors. **Gap not covered.**
|
||||
- **`--restart`**: all cursors reset to 0, all completion flags cleared. Full
|
||||
re-drain from `now` backward. **Re-fetches everything** but covers the gap
|
||||
incidentally (since it starts from `now`).
|
||||
|
||||
### Live subscriber ([`caching/src/live_subscriber.c`](../caching/src/live_subscriber.c:86))
|
||||
|
||||
Uses `since = time(NULL)` — only catches events from the moment it connects.
|
||||
No gap-bridging.
|
||||
|
||||
## The Gap
|
||||
|
||||
```
|
||||
Time ──────────────────────────────────────────────────────►
|
||||
│ │ │
|
||||
last_event_at caching now
|
||||
(most recent event turned (caching
|
||||
we know about) off resumed)
|
||||
│
|
||||
└── events posted here are missed
|
||||
```
|
||||
|
||||
For **completed authors** (`backfill_complete = TRUE`): the backward walk is
|
||||
done. `last_event_at` tells us the most recent event we have. Events posted
|
||||
after `last_event_at` are in the gap.
|
||||
|
||||
For **incomplete authors**: the backward walk is still in progress. The cursor
|
||||
is somewhere in the past, walking backward. Events newer than the cursor are
|
||||
not fetched by backfill. The live subscriber covers events from `now` forward.
|
||||
The gap is between `last_event_at` and `now`.
|
||||
|
||||
## Solution: Forward Catch-Up Phase
|
||||
|
||||
Add a **forward catch-up** phase that runs once when the caching service
|
||||
starts (or when backfill is re-enabled), before the normal backward-drain
|
||||
backfill loop begins.
|
||||
|
||||
### Logic
|
||||
|
||||
For **every** followed author (both complete and incomplete):
|
||||
1. Read `last_event_at` from `caching_followed_pubkeys`.
|
||||
2. If `last_event_at = 0`, skip (no events known yet — the backward drain
|
||||
will handle it).
|
||||
3. Query one or two outbox relays:
|
||||
`authors=[pk], since=last_event_at + 1, until=now, limit=page_size`
|
||||
4. Publish all returned events to the relay via the sink.
|
||||
5. Update `last_event_at` to the max `created_at` seen (or `now` if no
|
||||
events were returned, to avoid re-querying the same empty window next
|
||||
time).
|
||||
|
||||
This is safe for both complete and incomplete authors:
|
||||
- **Complete authors**: the backward drain is done, so the forward catch-up
|
||||
is the only thing needed.
|
||||
- **Incomplete authors**: the backward drain walks *below* `until_cursor`,
|
||||
so events above `until_cursor` up to `last_event_at` were already fetched
|
||||
during the initial drain (when `until_cursor` started at `now`). The
|
||||
forward catch-up fills from `last_event_at + 1` to `now` — the gap that
|
||||
formed while caching was off.
|
||||
|
||||
### When to run
|
||||
|
||||
- **On caching service startup** (not `--restart`, which does a full reset).
|
||||
- The caching service process starts when `caching_enabled` is turned on,
|
||||
so this covers the "caching was off, now it's on" case.
|
||||
|
||||
### Implementation
|
||||
|
||||
#### 1. Schema: add `last_event_at` column
|
||||
|
||||
In `src/pg_schema.sql` and `src/pg_schema.h`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE caching_followed_pubkeys
|
||||
ADD COLUMN IF NOT EXISTS last_event_at BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
UPDATE caching_followed_pubkeys fp
|
||||
SET last_event_at = COALESCE(
|
||||
(SELECT MAX(e.created_at) FROM events e WHERE e.pubkey = fp.pubkey),
|
||||
0
|
||||
)
|
||||
WHERE fp.last_event_at = 0;
|
||||
```
|
||||
|
||||
#### 2. New function: `pg_inbox_get_authors_for_catchup()`
|
||||
|
||||
In `caching/src/pg_inbox.c`:
|
||||
|
||||
```c
|
||||
/* Returns a cJSON array of {pubkey, last_event_at} objects for all
|
||||
* followed authors where last_event_at > 0. Caller must cJSON_Delete().
|
||||
* Returns NULL on error. */
|
||||
cJSON* pg_inbox_get_authors_for_catchup(void);
|
||||
```
|
||||
|
||||
SQL:
|
||||
```sql
|
||||
SELECT pubkey, last_event_at
|
||||
FROM caching_followed_pubkeys
|
||||
WHERE last_event_at > 0
|
||||
ORDER BY last_event_at ASC
|
||||
```
|
||||
|
||||
#### 3. New function: `pg_inbox_update_last_event_at()`
|
||||
|
||||
```c
|
||||
/* Update last_event_at for a pubkey to the max of current and new value. */
|
||||
int pg_inbox_update_last_event_at(const char *pk, long event_created_at);
|
||||
```
|
||||
|
||||
SQL:
|
||||
```sql
|
||||
UPDATE caching_followed_pubkeys
|
||||
SET last_event_at = GREATEST(last_event_at, $2::BIGINT)
|
||||
WHERE pubkey = $1
|
||||
```
|
||||
|
||||
#### 4. New function: `cr_forward_catchup()`
|
||||
|
||||
In a new file `caching/src/forward_catchup.c`:
|
||||
|
||||
```c
|
||||
/* Run forward catch-up for all followed authors.
|
||||
* For each author with last_event_at > 0, query events from
|
||||
* last_event_at + 1 to now and publish them to the sink.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int cr_forward_catchup(cr_config_t *cfg,
|
||||
nostr_relay_pool_t *upstream,
|
||||
cr_sink_t *sink);
|
||||
```
|
||||
|
||||
Flow:
|
||||
1. Call `pg_inbox_get_authors_for_catchup()` to get the list.
|
||||
2. For each author:
|
||||
a. Get the author's outbox relays from `caching_backfill_relay_progress`
|
||||
(any relay, since we just need one good source).
|
||||
b. Query `authors=[pk], since=last_event_at + 1, until=now,
|
||||
limit=page_size` on one relay.
|
||||
c. Publish all returned events to the sink.
|
||||
d. If events were returned, update `last_event_at` to the max
|
||||
`created_at` in the batch. If no events, update `last_event_at`
|
||||
to `now` (so we don't re-query the same empty window).
|
||||
3. Log: "forward catch-up: N authors checked, M events published".
|
||||
|
||||
#### 5. Update backfill to maintain `last_event_at`
|
||||
|
||||
In `caching/src/backfill.c`, in the page-publishing loop (around line 375):
|
||||
- Add a `find_newest_created_at()` helper (parallel to the existing
|
||||
`find_oldest_created_at()`).
|
||||
- After publishing a page, call
|
||||
`pg_inbox_update_last_event_at(pk, newest_created_at)`.
|
||||
|
||||
#### 6. Update live subscriber to maintain `last_event_at`
|
||||
|
||||
In `caching/src/live_subscriber.c`, in the event-received callback:
|
||||
- Extract `created_at` from the event.
|
||||
- Call `pg_inbox_update_last_event_at(pubkey, created_at)`.
|
||||
|
||||
#### 7. Call from `main.c`
|
||||
|
||||
In `caching/src/main.c`, after relay discovery and followed-set sync,
|
||||
before the main loop:
|
||||
|
||||
```c
|
||||
/* Forward catch-up: bridge the gap for all followed authors. */
|
||||
if (cfg->backfill.enabled && pg_conn && !restart) {
|
||||
DEBUG_INFO("forward catch-up: checking for missed events");
|
||||
cr_forward_catchup(&cfg, upstream, &sink);
|
||||
}
|
||||
```
|
||||
|
||||
This runs once at startup. It's not throttled — it's a one-time pass.
|
||||
|
||||
### Edge cases
|
||||
|
||||
- **`--restart` flag**: full reset already starts from `now`, so forward
|
||||
catch-up is skipped. The `last_event_at` seed from the `events` table
|
||||
will set it to the most recent known event, and the backward drain from
|
||||
`now` will cover everything.
|
||||
- **`last_event_at = 0`**: author has no known events. Skip — the backward
|
||||
drain handles it.
|
||||
- **Very large gap** (caching off for months): the forward catch-up query
|
||||
may return many events. Use `limit = page_size` and paginate if needed.
|
||||
The relay's own dedup (unique index on event ID) handles duplicates.
|
||||
- **Relay doesn't support `since`**: most Nostr relays support `since`/
|
||||
`until` (NIP-01). If ignored, the relay returns all events — dedup
|
||||
handles it.
|
||||
|
||||
### Files to change
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/pg_schema.sql` | `ALTER TABLE` add `last_event_at` + seed from `events` |
|
||||
| `src/pg_schema.h` | Mirror the above |
|
||||
| `caching/src/forward_catchup.c` | New: `cr_forward_catchup()` |
|
||||
| `caching/src/forward_catchup.h` | New: declaration |
|
||||
| `caching/src/pg_inbox.c` | New: `pg_inbox_get_authors_for_catchup()`, `pg_inbox_update_last_event_at()` |
|
||||
| `caching/src/pg_inbox.h` | New: declarations |
|
||||
| `caching/src/backfill.c` | Add `find_newest_created_at()`, call `pg_inbox_update_last_event_at()` after each page |
|
||||
| `caching/src/live_subscriber.c` | Call `pg_inbox_update_last_event_at()` on event receipt |
|
||||
| `caching/src/main.c` | Call `cr_forward_catchup()` at startup |
|
||||
| `caching/Makefile` | Add `forward_catchup.c` to sources |
|
||||
|
||||
### Sequencing
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Caching service starts] --> B{Is --restart?}
|
||||
B -- Yes --> C[Reset all progress, full re-drain from now]
|
||||
B -- No --> D[Forward catch-up: all authors with last_event_at > 0]
|
||||
D --> E[Normal backward-drain backfill loop]
|
||||
E --> F[Live subscriber: since = now, ongoing]
|
||||
F --> G[Live subscriber updates last_event_at on each event]
|
||||
E --> H[Backfill updates last_event_at on each page]
|
||||
@@ -0,0 +1,708 @@
|
||||
# Profile (kind-0) Cache Plan
|
||||
|
||||
Make username/profile resolution a first-class c-relay-pg feature backed by a
|
||||
dedicated `profiles` table, replacing the three independent ad-hoc
|
||||
implementations that exist today. Phase 1 (name/metadata cache) is scoped for
|
||||
implementation now; Phase 2 (image caching) is designed here but deferred.
|
||||
|
||||
## 0. Scope Boundary
|
||||
|
||||
**In scope:** `src/` (schema + C data layer) and `admin/` (the PHP admin).
|
||||
|
||||
**Out of scope:** the top-level [`api/`](../api/index.js) directory — the legacy
|
||||
embedded JS admin compiled into the binary via
|
||||
[`src/embedded_web_content.h`](../src/embedded_web_content.h) and served by
|
||||
[`handle_embedded_file_request()`](../src/api.c:1006). It is being replaced by
|
||||
`admin/` and is deliberately left untouched.
|
||||
|
||||
Two consequences worth being explicit about:
|
||||
|
||||
1. **Do not "fix" the duplicated logic in `api/index.js`.** It carries its own
|
||||
copies of the profile-name preference ([`api/index.js:962`](../api/index.js:962))
|
||||
and profile-picture handling ([`api/index.js:986`](../api/index.js:986)), plus
|
||||
the same class of unescaped-`innerHTML` issue described in §2A.2. These are
|
||||
knowingly left as-is because the whole tree is slated for removal. Note this
|
||||
means the XSS exposure persists for as long as the embedded UI remains
|
||||
reachable on the relay's HTTP port — a reason to prioritize retiring `api/`,
|
||||
tracked separately from this plan.
|
||||
2. **The C-side changes still matter to both.** [`src/api.c`](../src/api.c) serves
|
||||
the JSON that the embedded UI consumes, so the batching work in §2.5 benefits
|
||||
`api/` incidentally. The C API must therefore stay backward-compatible: keep
|
||||
emitting the existing `name` field (now the resolved value) alongside the new
|
||||
`display_name` / `best_name` fields, so the legacy frontend keeps working
|
||||
unchanged until it is deleted.
|
||||
|
||||
---
|
||||
|
||||
## 1. Current State
|
||||
|
||||
Profile display-name resolution was introduced alongside the caching service and
|
||||
never generalized. There are **three separate implementations**, none cached:
|
||||
|
||||
### 1.1 C backend — per-pubkey query inside a loop
|
||||
|
||||
[`postgres_db_get_profile_metadata()`](../src/db_ops_postgres.c:2523) runs:
|
||||
|
||||
```sql
|
||||
SELECT content FROM events WHERE pubkey = $1 AND kind = 0
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
```
|
||||
|
||||
then `cJSON_Parse`es `content` and copies out eight known fields
|
||||
(`name`, `display_name`, `picture`, `about`, `nip05`, `website`, `lud16`, `lud06`).
|
||||
|
||||
Dispatched through [`db_get_profile_metadata()`](../src/db_ops.c:189); the SQLite
|
||||
backend is a `NULL` stub ([`src/db_ops.c:389`](../src/db_ops.c:389)).
|
||||
|
||||
Called from three places, **always inside a row loop** — a classic N+1:
|
||||
|
||||
| Call site | Loop over | Queries per response |
|
||||
|---|---|---|
|
||||
| [`query_top_pubkeys()`](../src/api.c:503) | top 10 pubkeys | 10 |
|
||||
| [`src/api.c:1646`](../src/api.c:1646) | top pubkeys (2nd copy) | 10 |
|
||||
| caching follows status [`src/config.c:4298`](../src/config.c:4298) | every followed pubkey | 1 per follow (unbounded) |
|
||||
|
||||
The `config.c` loop additionally issues a per-pubkey kind-count query
|
||||
([`src/config.c:4326`](../src/config.c:4326)), an outbox lookup
|
||||
([`src/config.c:4346`](../src/config.c:4346)) and a relay-progress query
|
||||
([`src/config.c:4356`](../src/config.c:4356)) — so a relay following 500 authors
|
||||
performs ~2000 queries to render one admin panel.
|
||||
|
||||
The `display_name || name` preference logic is **duplicated verbatim** at all
|
||||
three call sites.
|
||||
|
||||
### 1.2 PHP admin — repeated LATERAL joins
|
||||
|
||||
[`admin/api/stats.php:90`](../admin/api/stats.php:90) and
|
||||
[`admin/api/caching.php:23`](../admin/api/caching.php:23) each hand-roll:
|
||||
|
||||
```sql
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT content FROM events WHERE pubkey = e.pubkey AND kind = 0
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) p ON true
|
||||
... p.content::json->>'name', p.content::json->>'display_name'
|
||||
```
|
||||
|
||||
with the same `$display_name ?: $name` fallback repeated in PHP. `content::json`
|
||||
re-parses the JSON text on every single admin page poll. The cast is also
|
||||
fragile: a malformed kind-0 `content` raises a PostgreSQL error that aborts the
|
||||
whole query (the `try/catch` then silently returns an empty result set).
|
||||
|
||||
### 1.3 Browser JS — fetches from public relays
|
||||
|
||||
[`loadUserProfile()`](../admin/assets/app.js:626) opens WebSocket connections to
|
||||
**third-party public relays** to fetch the logged-in admin's own kind-0, even
|
||||
though the relay's own database very likely has it. A third variant of the
|
||||
name-preference logic lives at [`admin/assets/app.js:650`](../admin/assets/app.js:650)
|
||||
(`profile.name || profile.display_name || profile.displayName`) — note this one
|
||||
prefers `name` over `display_name`, the **opposite** of the C and PHP versions,
|
||||
so the same user can render under two different names in one UI.
|
||||
|
||||
Profile images are hotlinked straight to whatever URL the kind-0 contains
|
||||
([`admin/assets/app.js:653`](../admin/assets/app.js:653)), which leaks the admin's
|
||||
IP to arbitrary hosts and breaks silently on dead links.
|
||||
|
||||
### 1.4 Conclusion
|
||||
|
||||
A cache table is clearly warranted:
|
||||
|
||||
- Kind 0 is **replaceable** — the unique index
|
||||
[`uq_events_replaceable_pubkey_kind`](../src/pg_schema.sql:82) guarantees at
|
||||
most one kind-0 row per pubkey. A `profiles` table is therefore a strict 1:1
|
||||
projection of existing data and can be rebuilt from scratch at any time. No
|
||||
risk of divergence-by-design.
|
||||
- Profiles change rarely but are read constantly.
|
||||
- Parsing JSON at write time (once per profile update) instead of read time
|
||||
(every page poll × every row) is a large, cheap win.
|
||||
- One canonical name-preference rule fixes the inconsistency across the three
|
||||
layers.
|
||||
|
||||
---
|
||||
|
||||
## 2. Phase 1 — `profiles` Table
|
||||
|
||||
### 2.1 Schema
|
||||
|
||||
Added to [`src/pg_schema.sql`](../src/pg_schema.sql) before the `COMMIT;` at
|
||||
[line 405](../src/pg_schema.sql:405), and mirrored into
|
||||
[`src/pg_schema.h`](../src/pg_schema.h) as escaped C string literals.
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
pubkey TEXT PRIMARY KEY,
|
||||
event_id TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
about TEXT NOT NULL DEFAULT '',
|
||||
picture TEXT NOT NULL DEFAULT '',
|
||||
banner TEXT NOT NULL DEFAULT '',
|
||||
nip05 TEXT NOT NULL DEFAULT '',
|
||||
website TEXT NOT NULL DEFAULT '',
|
||||
lud16 TEXT NOT NULL DEFAULT '',
|
||||
lud06 TEXT NOT NULL DEFAULT '',
|
||||
raw_content TEXT NOT NULL DEFAULT '',
|
||||
parse_ok BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_name ON profiles(name)
|
||||
WHERE name <> '';
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_display_name ON profiles(display_name)
|
||||
WHERE display_name <> '';
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_nip05 ON profiles(nip05)
|
||||
WHERE nip05 <> '';
|
||||
```
|
||||
|
||||
Notes:
|
||||
- **`name` and `display_name` are both stored verbatim, always.** Storing both
|
||||
is free (they are short strings on a table with one row per pubkey), and it
|
||||
means the question "which field do Nostr clients actually populate?" can be
|
||||
answered from real data later rather than guessed at now — see
|
||||
[§2.9](#29-which-field-do-people-actually-use). Neither field is ever
|
||||
discarded, overwritten by the other, or collapsed into a single value at write
|
||||
time.
|
||||
- There is deliberately **no generated `best_name` column.** An earlier draft
|
||||
had one; it was wrong. A `STORED` generated column freezes the preference rule
|
||||
into the schema, so changing which field is displayed would require a schema
|
||||
migration and a full-table rewrite. Display preference is a presentation
|
||||
decision and belongs at read time.
|
||||
- `raw_content` keeps the original JSON so non-standard fields (including
|
||||
`displayName`, the camelCase variant some clients emit — see
|
||||
[`admin/assets/app.js:650`](../admin/assets/app.js:650)) remain reachable
|
||||
without re-querying `events`.
|
||||
- `parse_ok = FALSE` records "we saw a kind-0 but its content was not valid
|
||||
JSON" — distinct from "no profile at all" (row absent). This makes the
|
||||
malformed-JSON case explicit instead of an aborted query.
|
||||
- Empty-string defaults rather than `NULL` keep the C accessors branch-free.
|
||||
|
||||
### 2.1.1 Display preference as configuration
|
||||
|
||||
The preference rule lives in the existing `config` table
|
||||
([`src/pg_schema.sql:192`](../src/pg_schema.sql:192)) so it can be changed at
|
||||
runtime through the normal admin config path, with no migration:
|
||||
|
||||
```sql
|
||||
INSERT INTO config (key, value, data_type, description, category, requires_restart)
|
||||
VALUES ('profile_name_preference', 'display_name',
|
||||
'string', 'Which kind-0 field to prefer for display: display_name or name',
|
||||
'display', 0)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
```
|
||||
|
||||
Valid values: `display_name` (prefer `display_name`, fall back to `name`) or
|
||||
`name` (the reverse). Default `display_name`, matching the current C and PHP
|
||||
behaviour so nothing visibly changes on upgrade.
|
||||
|
||||
Each layer gets **one** resolver that reads this key — replacing the four
|
||||
scattered inline copies with one function per layer, while keeping the choice
|
||||
adjustable:
|
||||
|
||||
```c
|
||||
// Applies profile_name_preference; falls back to the other field when the
|
||||
// preferred one is empty. Returns "" when neither is set (never NULL).
|
||||
const char* profile_display_name(const cJSON* profile);
|
||||
```
|
||||
|
||||
Every profile object returned to a UI carries `name`, `display_name`, **and** the
|
||||
resolved `best_name`, so a consumer can render the resolved label while still
|
||||
having both raw values available.
|
||||
|
||||
### 2.2 Population — PostgreSQL trigger
|
||||
|
||||
A trigger keeps the table correct regardless of which process writes the event
|
||||
(relay ingest, the caching inbox poller, or a manual `psql` insert), so no
|
||||
writer can bypass it.
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION sync_profile_from_event() RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
j JSONB;
|
||||
BEGIN
|
||||
IF NEW.kind <> 0 THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
BEGIN
|
||||
j := NEW.content::jsonb;
|
||||
IF jsonb_typeof(j) <> 'object' THEN
|
||||
j := NULL;
|
||||
END IF;
|
||||
EXCEPTION WHEN others THEN
|
||||
j := NULL;
|
||||
END;
|
||||
|
||||
INSERT INTO profiles (
|
||||
pubkey, event_id, created_at,
|
||||
name, display_name, about, picture, banner,
|
||||
nip05, website, lud16, lud06,
|
||||
raw_content, parse_ok, updated_at
|
||||
) VALUES (
|
||||
NEW.pubkey, NEW.id, NEW.created_at,
|
||||
COALESCE(j->>'name',''),
|
||||
COALESCE(j->>'display_name',''),
|
||||
COALESCE(j->>'about',''),
|
||||
COALESCE(j->>'picture',''),
|
||||
COALESCE(j->>'banner',''),
|
||||
COALESCE(j->>'nip05',''),
|
||||
COALESCE(j->>'website',''),
|
||||
COALESCE(j->>'lud16',''),
|
||||
COALESCE(j->>'lud06',''),
|
||||
NEW.content, (j IS NOT NULL),
|
||||
EXTRACT(EPOCH FROM NOW())::BIGINT
|
||||
)
|
||||
ON CONFLICT (pubkey) DO UPDATE SET
|
||||
event_id = EXCLUDED.event_id,
|
||||
created_at = EXCLUDED.created_at,
|
||||
name = EXCLUDED.name,
|
||||
display_name = EXCLUDED.display_name,
|
||||
about = EXCLUDED.about,
|
||||
picture = EXCLUDED.picture,
|
||||
banner = EXCLUDED.banner,
|
||||
nip05 = EXCLUDED.nip05,
|
||||
website = EXCLUDED.website,
|
||||
lud16 = EXCLUDED.lud16,
|
||||
lud06 = EXCLUDED.lud06,
|
||||
raw_content = EXCLUDED.raw_content,
|
||||
parse_ok = EXCLUDED.parse_ok,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
-- Never let an older kind-0 overwrite a newer one.
|
||||
WHERE EXCLUDED.created_at >= profiles.created_at;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_events_sync_profile ON events;
|
||||
CREATE TRIGGER trg_events_sync_profile
|
||||
AFTER INSERT OR UPDATE OF content ON events
|
||||
FOR EACH ROW EXECUTE FUNCTION sync_profile_from_event();
|
||||
```
|
||||
|
||||
The `NEW.kind <> 0` early return means the cost for the 99.9% of events that are
|
||||
not profiles is one integer comparison — negligible next to the two triggers
|
||||
already firing on every insert
|
||||
([`trg_events_set_derived_fields`](../src/pg_schema.sql:160),
|
||||
[`trg_events_sync_event_tags`](../src/pg_schema.sql:166),
|
||||
[`trg_notify_event_stored`](../src/pg_schema.sql:283)).
|
||||
|
||||
**Deletion:** add a companion `AFTER DELETE` trigger removing the `profiles` row
|
||||
when its backing kind-0 is deleted (NIP-09 via [`src/nip009.c`](../src/nip009.c)),
|
||||
guarded on `OLD.kind = 0 AND profiles.event_id = OLD.id` so a delete of a
|
||||
superseded event does not drop a current profile.
|
||||
|
||||
### 2.3 One-time backfill
|
||||
|
||||
Existing databases already hold kind-0 events. Following the established
|
||||
guarded-migration pattern used for `d_tag_value`
|
||||
([`src/pg_schema.sql:57`](../src/pg_schema.sql:57)), the backfill runs only on
|
||||
the version transition, not on every boot:
|
||||
|
||||
```sql
|
||||
DO $$
|
||||
BEGIN
|
||||
IF COALESCE((SELECT value FROM schema_info WHERE key = 'version'), '0') < '6' THEN
|
||||
INSERT INTO profiles (pubkey, event_id, created_at, name, display_name,
|
||||
about, picture, banner, nip05, website, lud16, lud06,
|
||||
raw_content, parse_ok)
|
||||
SELECT e.pubkey, e.id, e.created_at,
|
||||
COALESCE(c.j->>'name',''), COALESCE(c.j->>'display_name',''),
|
||||
COALESCE(c.j->>'about',''), COALESCE(c.j->>'picture',''),
|
||||
COALESCE(c.j->>'banner',''), COALESCE(c.j->>'nip05',''),
|
||||
COALESCE(c.j->>'website',''), COALESCE(c.j->>'lud16',''),
|
||||
COALESCE(c.j->>'lud06',''),
|
||||
e.content, (c.j IS NOT NULL)
|
||||
FROM events e
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT CASE WHEN e.content ~ '^\s*\{' THEN
|
||||
(SELECT x FROM jsonb(e.content::jsonb) AS x)
|
||||
END AS j
|
||||
) c ON true
|
||||
WHERE e.kind = 0
|
||||
ON CONFLICT (pubkey) DO NOTHING;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
```
|
||||
|
||||
The `::jsonb` cast can still raise on malformed content. Implementation should
|
||||
use a small `PL/pgSQL` loop with a per-row `EXCEPTION` block, or a
|
||||
`safe_jsonb(text)` helper function marked `IMMUTABLE` that returns `NULL` on
|
||||
parse failure — cleaner and reusable by the trigger too. Prefer the
|
||||
`safe_jsonb()` helper and use it in both the trigger and the backfill.
|
||||
|
||||
Bump `EMBEDDED_PG_SCHEMA_VERSION` from `"5"` to `"6"` in
|
||||
[`src/pg_schema.h:4`](../src/pg_schema.h:4) and the `schema_info` insert at
|
||||
[`src/pg_schema.sql:260`](../src/pg_schema.sql:260).
|
||||
|
||||
`postgres_db_apply_schema()` ([`src/db_ops_postgres.c:138`](../src/db_ops_postgres.c:138))
|
||||
runs the whole embedded script at startup, so existing deployments upgrade
|
||||
automatically. There is no generator script for `pg_schema.h` — it is a
|
||||
hand-maintained mirror, so **both files must be edited and kept identical**.
|
||||
|
||||
### 2.4 C API
|
||||
|
||||
Replace the single-row helper with a batch-capable pair in
|
||||
[`src/db_ops.h`](../src/db_ops.h:144):
|
||||
|
||||
```c
|
||||
// Single profile from the profiles cache. NULL if no profile is cached.
|
||||
// Result object always contains "name", "display_name" (each possibly "")
|
||||
// and the resolved "best_name". Caller must cJSON_Delete().
|
||||
cJSON* db_get_profile(const char* pubkey);
|
||||
|
||||
// Batch lookup: one query for many pubkeys. Returns an object keyed by
|
||||
// pubkey hex -> profile object. Pubkeys with no cached profile are absent.
|
||||
// Caller must cJSON_Delete().
|
||||
cJSON* db_get_profiles(const char** pubkeys, int count);
|
||||
```
|
||||
|
||||
`db_get_profiles()` issues a single `WHERE pubkey = ANY($1::text[])` query,
|
||||
collapsing the N+1 loops into one round trip.
|
||||
|
||||
`db_get_profile_metadata()` is retained as a deprecated thin wrapper over
|
||||
`db_get_profile()` so nothing breaks mid-refactor, then removed once all call
|
||||
sites are migrated.
|
||||
|
||||
SQLite stubs in [`src/db_ops.c:389`](../src/db_ops.c:389) continue returning
|
||||
`NULL` — the `profiles` table is PostgreSQL-only, consistent with how the
|
||||
caching tables are handled.
|
||||
|
||||
### 2.5 C call-site migration
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| [`src/api.c:502`](../src/api.c:502) | Collect the 10 pubkeys, one `db_get_profiles()` call, then attach `name`/`display_name`/`best_name`/`picture` from the map. Replace the inline preference logic with `profile_display_name()`. |
|
||||
| [`src/api.c:1645`](../src/api.c:1645) | Same. Consider factoring the two near-identical blocks into one shared `api_attach_profile_fields()` helper. |
|
||||
| [`src/config.c:4297`](../src/config.c:4297) | Batch all followed pubkeys up front (they are already fully enumerated by the outer query) and look them up from the returned map inside the loop. |
|
||||
|
||||
The `config.c` loop's other per-row queries (kind counts, relay progress) are
|
||||
out of scope here but are noted as the next optimization target — they can
|
||||
become two `GROUP BY` queries executed once.
|
||||
|
||||
### 2.6 PHP migration
|
||||
|
||||
Add one helper to [`admin/lib/helpers.php`](../admin/lib/helpers.php):
|
||||
|
||||
```php
|
||||
/**
|
||||
* Batch-resolve profiles from the cache.
|
||||
* Returns [pubkey_hex => ['name'=>..., 'display_name'=>...,
|
||||
* 'best_name'=>..., 'picture'=>..., 'nip05'=>...]].
|
||||
*/
|
||||
function profile_map(array $pubkeys): array
|
||||
|
||||
/** Applies the profile_name_preference config key. Never returns null. */
|
||||
function profile_display_name(array $profile): string
|
||||
```
|
||||
|
||||
`profile_map()` is a single parameterized `WHERE pubkey = ANY(...)` query against
|
||||
`profiles`, returning both raw name fields plus the resolved label. Then:
|
||||
|
||||
- [`admin/api/stats.php:90`](../admin/api/stats.php:90) — drop the
|
||||
`LEFT JOIN LATERAL` and the `content::json` casts; the top-pubkeys query
|
||||
becomes a plain `GROUP BY e.pubkey`, and names come from `profile_map()`.
|
||||
This also removes the `GROUP BY e.pubkey, p.content` grouping-by-a-JSON-blob
|
||||
wart.
|
||||
- [`admin/api/caching.php:23`](../admin/api/caching.php:23) — same; or simply
|
||||
`LEFT JOIN profiles p ON p.pubkey = fp.pubkey` and select `p.name,
|
||||
p.display_name`, which is a cheap indexed join now that no subquery or parsing
|
||||
is involved.
|
||||
- Replace both copies of the `$display_name ?: $name` fallback with
|
||||
`profile_display_name()`.
|
||||
|
||||
### 2.7 JS migration
|
||||
|
||||
- Add a read-only admin endpoint (`admin/api/profile.php?pubkey=...`) returning
|
||||
the cached profile.
|
||||
- [`loadUserProfile()`](../admin/assets/app.js:626) tries that endpoint first and
|
||||
only falls back to public relays if the relay has no cached kind-0 for the
|
||||
logged-in admin (a real possibility for a fresh relay), then keeps the existing
|
||||
render path.
|
||||
- Consume the server-provided `best_name` instead of re-deriving a preference in
|
||||
the browser, so all three layers finally agree and the JS copy at
|
||||
[`admin/assets/app.js:650`](../admin/assets/app.js:650) — which currently
|
||||
prefers `name`, the opposite of C and PHP — stops disagreeing. Keep
|
||||
`displayName` (camelCase) handling only in the public-relay fallback path,
|
||||
where raw client JSON is parsed directly.
|
||||
|
||||
---
|
||||
|
||||
## 2A. Hostile Characters in Names
|
||||
|
||||
Nostr names are attacker-controlled free-form UTF-8. The guiding principle:
|
||||
|
||||
> **Store bytes verbatim. Neutralize at the point of rendering.**
|
||||
|
||||
Sanitizing at write time would be wrong — it is lossy, irreversible, and the
|
||||
"correct" transformation differs per output context (HTML body vs. attribute vs.
|
||||
JSON vs. CSV vs. terminal log). A name mangled on the way into the cache can
|
||||
never be recovered, and the cache would no longer faithfully mirror the kind-0
|
||||
event. So the cache table stores exactly what the user published.
|
||||
|
||||
**But "it's a frontend issue" is only ~90% true.** There is one true storage-layer
|
||||
concern, and one place where the current frontend is actively unsafe.
|
||||
|
||||
### 2A.1 Storage-layer concern: NUL bytes (must handle at write time)
|
||||
|
||||
PostgreSQL `TEXT` **cannot** store `U+0000`. A kind-0 containing `\u0000` in its
|
||||
JSON string makes `->>` yield a value that PostgreSQL refuses to store, raising
|
||||
`ERROR: unsupported Unicode escape sequence` — which would abort the trigger and
|
||||
therefore **reject the whole event insert**. That turns a cosmetic nuisance into
|
||||
a denial-of-service on event ingestion.
|
||||
|
||||
This is not a presentation problem and must be handled in the trigger:
|
||||
|
||||
```sql
|
||||
-- Strip NUL only; everything else is preserved byte-for-byte.
|
||||
replace(COALESCE(j->>'name',''), E'\\u0000', '')
|
||||
```
|
||||
|
||||
Implement as a small `sanitize_pg_text(text)` helper used for every extracted
|
||||
string column. It removes **only** characters PostgreSQL structurally cannot
|
||||
store — not "weird" characters generally. Invalid UTF-8 byte sequences are
|
||||
already rejected earlier by `cJSON` parsing and by the `safe_jsonb()` helper
|
||||
(the row lands with `parse_ok = FALSE`), so no additional handling is needed.
|
||||
|
||||
A defensive `byte_size` guard is also worth adding: cap stored name fields at a
|
||||
sane length (e.g. 1 KB) so a megabyte-long "name" cannot bloat the table or the
|
||||
admin JSON payloads. Truncation is recorded in `raw_content`, which keeps the
|
||||
full original.
|
||||
|
||||
### 2A.2 Live vulnerability: stored XSS in the admin UI
|
||||
|
||||
This must be fixed as part of this work, because the whole point of the change is
|
||||
to route more user-controlled names into more admin pages.
|
||||
|
||||
[`admin/assets/app.js:142`](../admin/assets/app.js:142) interpolates the name
|
||||
directly into `innerHTML`:
|
||||
|
||||
```js
|
||||
tbody.innerHTML = d.top_pubkeys.map((p, i) =>
|
||||
`<tr><td>${i+1}</td><td>${p.name || '<i>unknown</i>'}</td>...`
|
||||
```
|
||||
|
||||
and [`admin/assets/app.js:411`](../admin/assets/app.js:411) does the same for the
|
||||
caching-follows table. A user who sets their kind-0 `name` to
|
||||
`<img src=x onerror="...">` achieves **script execution in the relay
|
||||
administrator's authenticated browser session** merely by posting enough events
|
||||
to appear in the top-pubkeys list. No privileged access is required.
|
||||
|
||||
The codebase is already inconsistent about this: the header name at
|
||||
[`app.js:652`](../admin/assets/app.js:652) correctly uses `textContent` and is
|
||||
safe. The table renderers are not.
|
||||
|
||||
**Fix:** add an escaping helper and apply it to every interpolated
|
||||
user-controlled value in `innerHTML` template strings:
|
||||
|
||||
```js
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g,
|
||||
c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
```
|
||||
|
||||
Auditing the surrounding rows shows the same pattern applied to other
|
||||
user-controlled fields — event `content` ([`app.js:380`](../admin/assets/app.js:380)),
|
||||
DM content ([`app.js:441`](../admin/assets/app.js:441)), config values
|
||||
([`app.js:219`](../admin/assets/app.js:219)), and auth-rule `pattern_value`
|
||||
([`app.js:251`](../admin/assets/app.js:251)) — so the sweep should cover all of
|
||||
them, not just names. Preferring `textContent` / `createElement` over `innerHTML`
|
||||
in these renderers is the more durable fix where it is not too invasive.
|
||||
|
||||
Note the PHP side is already correct: [`e()`](../admin/lib/helpers.php:9) wraps
|
||||
`htmlspecialchars(..., ENT_QUOTES, 'UTF-8')` and is used for server-rendered
|
||||
output. The gap is purely in the JS-built tables.
|
||||
|
||||
### 2A.3 Presentation-layer nuisances (frontend, cosmetic)
|
||||
|
||||
These stay unsanitized in the database and are handled with CSS/formatting:
|
||||
|
||||
| Issue | Effect | Mitigation |
|
||||
|---|---|---|
|
||||
| Bidi overrides (`U+202E` RTL) | Reverses surrounding text, spoofs other names | Render names in a `<bdi>` element — purpose-built for exactly this, isolates bidi without altering the value |
|
||||
| Zalgo / stacked combining marks | Vertical overflow past row bounds | `overflow: hidden` + fixed line-height on the name cell |
|
||||
| Zero-width chars (`U+200B`, `U+FEFF`) | Invisible; two names look identical | Optional: reveal-on-hover indicator; do not strip |
|
||||
| Newlines / tabs | Break single-line table layout | CSS `white-space: nowrap` + `text-overflow: ellipsis` |
|
||||
| Very long names | Blow out column width | CSS `max-width` + ellipsis (value stays intact in a `title` tooltip) |
|
||||
| Emoji / astral-plane chars | None — legitimate usage | Nothing; ensure JS length math uses code points, not UTF-16 units, when truncating |
|
||||
|
||||
Truncation in JS deserves care: `substring()` on a UTF-16 string can split a
|
||||
surrogate pair and emit a replacement glyph. Use `Array.from(str).slice(0, n)` or
|
||||
CSS-based ellipsis (preferred — no string surgery at all).
|
||||
|
||||
### 2A.4 Terminal/log safety
|
||||
|
||||
Names flow into `DEBUG_*` output. ANSI escape sequences in a name can manipulate
|
||||
a maintainer's terminal. Log rendering should escape non-printable bytes, or
|
||||
simply avoid logging profile names at all — the pubkey is the useful identifier
|
||||
in logs anyway.
|
||||
|
||||
### 2.9 Which field do people actually use?
|
||||
|
||||
Storing both fields turns this into an empirical question rather than a guess.
|
||||
Once the table is populated, one aggregate query answers it against real data
|
||||
from your relay's own corpus:
|
||||
|
||||
```sql
|
||||
SELECT count(*) FILTER (WHERE name <> '' AND display_name <> '') AS both,
|
||||
count(*) FILTER (WHERE name <> '' AND display_name = '') AS name_only,
|
||||
count(*) FILTER (WHERE name = '' AND display_name <> '') AS display_only,
|
||||
count(*) FILTER (WHERE name = '' AND display_name = '') AS neither,
|
||||
count(*) FILTER (WHERE name <> '' AND display_name <> ''
|
||||
AND name <> display_name) AS both_differ,
|
||||
count(*) AS total
|
||||
FROM profiles;
|
||||
```
|
||||
|
||||
`both_differ` is the number that matters: it counts profiles where the preference
|
||||
setting actually changes what gets rendered. If it is near zero, the setting is
|
||||
academic and either default is fine. If it is large, the setting earns its keep.
|
||||
|
||||
Worth surfacing as a small panel on the admin stats page — it is one cheap
|
||||
aggregate over a table with one row per pubkey, and it makes
|
||||
`profile_name_preference` self-documenting: you can see the impact of the choice
|
||||
before making it. Add it once the table has accumulated real data.
|
||||
|
||||
### 2.8 Verification
|
||||
|
||||
- Fresh database: relay starts, `profiles` exists, posting a kind-0 populates
|
||||
exactly one row with `name` and `display_name` both preserved verbatim.
|
||||
- Upgrade path: start against a database with pre-existing kind-0 events, confirm
|
||||
the backfill fills every row once and does **not** re-run on the next restart.
|
||||
- Replaceable-update: publish a newer kind-0, confirm the row updates; replay an
|
||||
older one, confirm the row does **not** regress.
|
||||
- Malformed content: store a kind-0 whose content is not JSON; confirm the insert
|
||||
still succeeds, `parse_ok = FALSE`, and the admin pages render without error.
|
||||
- **NUL byte:** publish a kind-0 whose `name` contains `\u0000`; confirm the
|
||||
event is still accepted, the profile row is created, and the relay does not
|
||||
error. This is the regression test for the ingest-DoS path in §2A.1.
|
||||
- **XSS:** publish a kind-0 with `name` set to
|
||||
`<img src=x onerror="window.__xss=1">`, load the stats and caching pages, and
|
||||
confirm the markup is rendered as visible text and `window.__xss` is
|
||||
undefined.
|
||||
- **Bidi/Zalgo:** publish names containing `U+202E` and stacked combining marks;
|
||||
confirm table layout and neighbouring rows are unaffected.
|
||||
- Consistency: the same pubkey shows an identical name in the stats table, the
|
||||
caching follows table, and the header.
|
||||
- Query-count check: confirm the top-pubkeys API response issues one profile
|
||||
query rather than ten.
|
||||
- Both-fields check: query `profiles` for a pubkey whose kind-0 sets `name` and
|
||||
`display_name` to different values; confirm both are stored distinctly.
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 2 — Image Caching (design only, deferred)
|
||||
|
||||
Recorded here so Phase 1's schema does not need reworking later.
|
||||
|
||||
### 3.1 Motivation
|
||||
|
||||
Today the admin UI hotlinks `picture` URLs directly
|
||||
([`admin/assets/app.js:653`](../admin/assets/app.js:653)). Problems: the admin's
|
||||
browser reveals its IP to arbitrary third-party hosts on every page load; dead
|
||||
or slow hosts degrade the UI; images can be arbitrarily large; there is no way to
|
||||
show avatars offline.
|
||||
|
||||
### 3.2 Proposed schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS profile_images (
|
||||
pubkey TEXT PRIMARY KEY,
|
||||
source_url TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL DEFAULT '',
|
||||
byte_size INTEGER NOT NULL DEFAULT 0,
|
||||
sha256 TEXT NOT NULL DEFAULT '',
|
||||
etag TEXT NOT NULL DEFAULT '',
|
||||
image_data BYTEA,
|
||||
fetch_state TEXT NOT NULL DEFAULT 'pending',
|
||||
fetch_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
fetched_at BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
|
||||
CHECK (fetch_state IN ('pending','ok','failed','skipped','too_large'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_profile_images_pending
|
||||
ON profile_images(fetch_state, fetch_attempts) WHERE fetch_state = 'pending';
|
||||
```
|
||||
|
||||
`BYTEA` in PostgreSQL rather than the filesystem keeps backup/restore and the
|
||||
container story single-artifact, matching how everything else in this project is
|
||||
stored. Avatars are small; a cap keeps total size bounded.
|
||||
|
||||
### 3.3 Fetch worker
|
||||
|
||||
`libcurl` is already linked ([`Makefile:6`](../Makefile:6)) but currently unused
|
||||
in `src/`. A worker modeled on
|
||||
[`caching_inbox_poller.c`](../src/caching_inbox_poller.c) — two-state
|
||||
idle/active polling, config-gated, off the main libwebsockets thread — would:
|
||||
|
||||
1. Enqueue `pending` rows when `profiles.picture` changes (trigger or poll).
|
||||
2. Fetch with a hard timeout, a max-bytes ceiling (~256 KB), redirect limit,
|
||||
and `Content-Type` allow-list (`image/png|jpeg|webp|gif`).
|
||||
3. Send `If-None-Match` on refresh, honour `304`.
|
||||
4. Exponential backoff, capped `fetch_attempts`, terminal `failed`.
|
||||
|
||||
New config keys following existing naming: `profile_image_cache_enabled`
|
||||
(default **off**), `profile_image_max_bytes`, `profile_image_refresh_days`,
|
||||
`profile_image_fetch_concurrency`.
|
||||
|
||||
### 3.4 Serving
|
||||
|
||||
A relay HTTP route `/avatar/<pubkey>` handled in
|
||||
[`handle_embedded_file_request()`](../src/api.c:1006) (called from
|
||||
[`src/websockets.c:1261`](../src/websockets.c:1261)), returning the bytes with a
|
||||
long `Cache-Control` and an `ETag`, falling back to a generated identicon or
|
||||
`404` when uncached. The UI then only ever loads images from the relay's own
|
||||
origin.
|
||||
|
||||
### 3.5 Risks to weigh before committing
|
||||
|
||||
- **Outbound HTTP from the relay** is a new capability and a real SSRF surface —
|
||||
needs a private-IP/localhost block-list and scheme restriction. This is the
|
||||
main reason to keep it default-off and deferred.
|
||||
- Database growth: bounded by `max_bytes × profile count`; needs a documented
|
||||
ceiling and a prune path.
|
||||
- Content risk: the relay would be re-serving arbitrary third-party bytes under
|
||||
its own origin. Strict `Content-Type` enforcement plus
|
||||
`Content-Security-Policy` / `X-Content-Type-Options: nosniff` on the route.
|
||||
|
||||
---
|
||||
|
||||
## 4. Files Touched (Phase 1)
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| [`src/pg_schema.sql`](../src/pg_schema.sql) | `profiles` table (both name fields, no generated column), `safe_jsonb()`, `sanitize_pg_text()`, sync + delete triggers, guarded backfill, `profile_name_preference` config default, version → 6 |
|
||||
| [`src/pg_schema.h`](../src/pg_schema.h) | Mirror the above as C string literals; bump `EMBEDDED_PG_SCHEMA_VERSION` |
|
||||
| [`src/db_ops.h`](../src/db_ops.h) | Declare `db_get_profile()` / `db_get_profiles()` |
|
||||
| [`src/db_ops_postgres.h`](../src/db_ops_postgres.h) | Declare the postgres implementations |
|
||||
| [`src/db_ops_postgres.c`](../src/db_ops_postgres.c) | Implement both against `profiles`; retire the events-table query |
|
||||
| [`src/db_ops.c`](../src/db_ops.c) | Dispatch entries + SQLite stubs |
|
||||
| [`src/config.h`](../src/config.h) / [`src/config.c`](../src/config.c) | `profile_display_name()` resolver; batch profile lookup in the caching follows loop |
|
||||
| [`src/api.c`](../src/api.c) | Batch both top-pubkeys loops; shared attach helper emitting `name` + `display_name` + `best_name` |
|
||||
| [`admin/lib/helpers.php`](../admin/lib/helpers.php) | `profile_map()` + `profile_display_name()` |
|
||||
| [`admin/api/stats.php`](../admin/api/stats.php) | Use `profile_map()`; drop LATERAL + JSON casts |
|
||||
| [`admin/api/caching.php`](../admin/api/caching.php) | Join `profiles`; drop LATERAL + JSON casts |
|
||||
| `admin/api/profile.php` | New: single-profile lookup endpoint |
|
||||
| [`admin/assets/app.js`](../admin/assets/app.js) | **`esc()` helper + XSS sweep of all `innerHTML` renderers (§2A.2)**; local-first profile load; consume server `best_name` |
|
||||
| [`admin/assets/index.css`](../admin/assets/index.css) | `nowrap` / `overflow` / `max-width` + ellipsis on name cells (§2A.3) |
|
||||
| `tests/` | New script covering populate / upgrade / replace / malformed / NUL / XSS / bidi cases |
|
||||
|
||||
---
|
||||
|
||||
## 5. Sequencing
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Add profiles table + safe_jsonb + sanitize_pg_text + triggers to pg_schema.sql] --> B[Mirror into pg_schema.h and bump version to 6]
|
||||
B --> C[Guarded one-time backfill + profile_name_preference config default]
|
||||
C --> D[Implement db_get_profile and db_get_profiles]
|
||||
D --> E[Migrate api.c and config.c to batch lookups]
|
||||
E --> F[Add profile_map helper and migrate PHP endpoints]
|
||||
F --> X[Fix stored XSS: esc helper and innerHTML sweep in app.js]
|
||||
X --> G[Add profile.php endpoint and update app.js profile load]
|
||||
G --> Y[CSS hardening for hostile name rendering]
|
||||
Y --> Z[Add name-field usage panel to stats page]
|
||||
G --> H[Tests: populate, upgrade, replace, malformed, consistency]
|
||||
H --> I[Phase 2 image caching - deferred]
|
||||
```
|
||||
@@ -0,0 +1,233 @@
|
||||
# Server-Side ASCII Chart Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the client-side `text_graph.js` ASCII chart with a **server-side PHP renderer** that produces the ASCII X-bar chart string. The chart is served from a **dedicated plain-text endpoint** (`api/chart.php`) that works both in the browser (injected into a `<div>`) and in the terminal via `curl` — the ASCII art renders correctly either way.
|
||||
|
||||
Four time ranges, each with its own bin size and refresh/caching strategy:
|
||||
|
||||
| Range | Span | Bin size | Bins | Refresh | Cache TTL |
|
||||
|--------|-------------|------------|------|------------------|------------|
|
||||
| Hour | last 1h | 10 seconds | 360 | every 10s (live) | none |
|
||||
| Day | last 24h | 5 minutes | 288 | every 10s* | 1 hour |
|
||||
| Month | last 30d | 1 hour | 720 | every 10s* | 1 day |
|
||||
| Year | last 365d | 1 day | 365 | every 10s* | 1 month |
|
||||
|
||||
\* The client polls every 10s, but the server only re-runs the expensive query when the cache expires. Between cache expirations, the cached ASCII string is returned instantly.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Clients
|
||||
T[Terminal — curl]
|
||||
W[Web UI — app.js]
|
||||
end
|
||||
|
||||
T -->|GET chart.php?range=hour| EP[chart.php]
|
||||
W -->|GET chart.php?range=hour| EP
|
||||
T -->|GET chart.php?range=day| EP
|
||||
W -->|GET chart.php?range=day| EP
|
||||
|
||||
EP --> C{Cache valid?}
|
||||
C -->|Yes| Return[Return cached ASCII string]
|
||||
C -->|No| Query[Run GROUP BY binning query]
|
||||
Query --> Render[render_ascii_chart in lib/ascii_chart.php]
|
||||
Render --> CacheWrite[Write to cache file]
|
||||
CacheWrite --> Return
|
||||
|
||||
Return -->|text/plain; charset=utf-8| T
|
||||
Return -->|text/plain; charset=utf-8| W
|
||||
|
||||
subgraph "Cache files (admin2/cache/)"
|
||||
H[chart_hour.txt — never cached]
|
||||
D[chart_day.txt — TTL 1h]
|
||||
M[chart_month.txt — TTL 1d]
|
||||
Y[chart_year.txt — TTL 1mo]
|
||||
end
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### 1. PHP ASCII Chart Renderer — `admin2/lib/ascii_chart.php`
|
||||
|
||||
A pure function that takes an array of bin counts and produces the ASCII X-bar chart string. Mirrors the layout of the original `text_graph.js`:
|
||||
|
||||
```
|
||||
New Events
|
||||
|
||||
11 | X
|
||||
10 | X X
|
||||
9 | X X X X
|
||||
8 | X X X X X X X
|
||||
7 | X X X X X X X X X X
|
||||
6 | X X X X X X X X X X X X X
|
||||
5 | X X X X X X X X X X X X X X X
|
||||
4 | X X X X X X X X X X X X X X X X X
|
||||
3 |X X X X X X X X X X X X X X X X X X X
|
||||
2 |X X X X X X X X X X X X X X X X X X X
|
||||
1 |X X X X X X X X X X X X X X X X X X X
|
||||
+----------------------------------------
|
||||
0s 50s 100s 150s 200s 250s 300s
|
||||
```
|
||||
|
||||
**Function signature:**
|
||||
```php
|
||||
function render_ascii_chart(array $bins, array $options = []): string
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `title` (string, default `'New Events'`)
|
||||
- `max_height` (int, default `11`) — chart height in rows
|
||||
- `x_axis_label` (string, default `''`)
|
||||
- `bin_duration` (int, seconds) — for X-axis elapsed-time labels
|
||||
- `label_interval` (int, default `5`) — label every N bins
|
||||
|
||||
**Algorithm** (same as `text_graph.js` render method):
|
||||
1. `max_count = max($bins)`; `scale_factor = max(1, ceil(max_count / max_height))`
|
||||
2. For each row from `max_height` down to `1`:
|
||||
- Y-axis label = `(row - 1) * scale_factor + 1`, right-padded to 3 chars
|
||||
- For each bin: if `ceil(count / scale_factor) >= row` → `X`, else space
|
||||
3. X-axis: `+` followed by dashes (one per bin)
|
||||
4. X-axis labels: elapsed time every `label_interval` bins, formatted as `Ns` / `Nm` / `Hh` / `Dd` depending on magnitude
|
||||
|
||||
### 2. Binning SQL Queries — in `stats.php`
|
||||
|
||||
Each range uses a `FLOOR((created_at - epoch) / bin_size)` GROUP BY query. The `idx_events_created_at` index makes these fast.
|
||||
|
||||
**Hour (live, no cache):**
|
||||
```sql
|
||||
SELECT FLOOR((created_at - :epoch) / 10)::INT AS bin, COUNT(*) AS cnt
|
||||
FROM events
|
||||
WHERE created_at >= :epoch
|
||||
GROUP BY bin
|
||||
ORDER BY bin;
|
||||
```
|
||||
- `epoch = now - 3600`, `bin_size = 10s`, produces up to 360 bins
|
||||
- Runs on every 10s poll (cheap: only scans last hour, indexed)
|
||||
|
||||
**Day (cache TTL 1h):**
|
||||
```sql
|
||||
SELECT FLOOR((created_at - :epoch) / 300)::INT AS bin, COUNT(*) AS cnt
|
||||
FROM events
|
||||
WHERE created_at >= :epoch
|
||||
GROUP BY bin
|
||||
ORDER BY bin;
|
||||
```
|
||||
- `epoch = now - 86400`, `bin_size = 300s` (5 min), produces up to 288 bins
|
||||
|
||||
**Month (cache TTL 1d):**
|
||||
```sql
|
||||
SELECT FLOOR((created_at - :epoch) / 3600)::INT AS bin, COUNT(*) AS cnt
|
||||
FROM events
|
||||
WHERE created_at >= :epoch
|
||||
GROUP BY bin
|
||||
ORDER BY bin;
|
||||
```
|
||||
- `epoch = now - 2592000`, `bin_size = 3600s` (1h), produces up to 720 bins
|
||||
|
||||
**Year (cache TTL 1 month):**
|
||||
```sql
|
||||
SELECT FLOOR((created_at - :epoch) / 86400)::INT AS bin, COUNT(*) AS cnt
|
||||
FROM events
|
||||
WHERE created_at >= :epoch
|
||||
GROUP BY bin
|
||||
ORDER BY bin;
|
||||
```
|
||||
- `epoch = now - 31536000`, `bin_size = 86400s` (1 day), produces up to 365 bins
|
||||
|
||||
**Bin array assembly:** Query returns only non-empty bins. PHP fills a fixed-length array (all zeros) and overlays the counts at the correct positions, so empty time slots show as blank columns — the chart always advances in time.
|
||||
|
||||
### 3. File-Based Cache — `admin2/cache/`
|
||||
|
||||
Simple file cache with TTL. No APCu/Redis dependency.
|
||||
|
||||
```php
|
||||
function get_cached_chart(string $range): ?string
|
||||
function set_cached_chart(string $range, string $ascii): void
|
||||
```
|
||||
|
||||
- Cache files: `admin2/cache/chart_{range}.txt`
|
||||
- TTLs: `hour` = 0 (never cache), `day` = 3600, `month` = 86400, `year` = 2592000
|
||||
- Check: `filemtime($file) > time() - $ttl`
|
||||
- Directory `admin2/cache/` created automatically with `mkdir(..., 0775, true)`
|
||||
|
||||
### 4. Standalone Chart Endpoint — `admin2/api/chart.php`
|
||||
|
||||
A dedicated plain-text endpoint that returns the raw ASCII chart string. Works in both the browser and the terminal.
|
||||
|
||||
**Request:** `GET api/chart.php?range=hour|day|month|year`
|
||||
|
||||
**Response:** `Content-Type: text/plain; charset=utf-8` — just the ASCII chart string, no JSON wrapper.
|
||||
|
||||
**Terminal usage:**
|
||||
```bash
|
||||
curl http://localhost:8088/api/chart.php?range=hour
|
||||
curl http://localhost:8088/api/chart.php?range=day
|
||||
curl http://localhost:8088/api/chart.php?range=month
|
||||
curl http://localhost:8088/api/chart.php?range=year
|
||||
```
|
||||
|
||||
**Logic:**
|
||||
1. Read `range` query param (default: `hour`)
|
||||
2. Validate against allowed ranges (`hour`, `day`, `month`, `year`)
|
||||
3. Check cache: if valid, return cached string immediately
|
||||
4. If cache miss/expired: run the binning SQL query, fill the bin array, call `render_ascii_chart()`, write to cache, return the string
|
||||
5. Set `Content-Type: text/plain; charset=utf-8` header
|
||||
|
||||
**`stats.php` is unchanged** — it continues to return the existing JSON stats (numbers only). The chart is a completely separate endpoint, keeping concerns cleanly separated.
|
||||
|
||||
### 5. Frontend Changes
|
||||
|
||||
#### `admin2/index.php`
|
||||
- Replace the single chart div with a chart container + range selector tabs:
|
||||
```html
|
||||
<div class="chart-range-tabs">
|
||||
<button class="chart-tab active" data-range="hour">1H</button>
|
||||
<button class="chart-tab" data-range="day">1D</button>
|
||||
<button class="chart-tab" data-range="month">1M</button>
|
||||
<button class="chart-tab" data-range="year">1Y</button>
|
||||
</div>
|
||||
<div id="event-rate-chart" class="event-rate-chart-container">Loading chart...</div>
|
||||
```
|
||||
- Remove `<script src="assets/text_graph.js">` (no longer needed)
|
||||
|
||||
#### `admin2/assets/app.js`
|
||||
- Remove: `eventRateChart`, `previousTotalEvents`, `initializeEventRateChart`, `createChartStubElements`, the `addValue` call in `loadStats`
|
||||
- Add: `currentChartRange = 'hour'` state variable
|
||||
- Add: `loadChart(range)` function — fetches `api/chart.php?range=${range}` as plain text, injects the response into `#event-rate-chart` via `textContent`
|
||||
- Add: tab click handlers that set `currentChartRange` and call `loadChart`
|
||||
- In `loadStats`: call `loadChart(currentChartRange)` at the end (so the hour chart refreshes every 10s)
|
||||
- On DOMContentLoaded: call `loadChart('hour')` to load the initial chart
|
||||
|
||||
#### `admin2/assets/index.css`
|
||||
- Add `.chart-range-tabs` styles (small monospace buttons, active state with accent border)
|
||||
|
||||
### 6. Cache Directory
|
||||
|
||||
- `admin2/cache/` — add to `.gitignore` (runtime artifacts)
|
||||
- Created at runtime by `mkdir` if missing
|
||||
|
||||
## File Summary
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `admin2/lib/ascii_chart.php` | **New** — PHP ASCII chart renderer function |
|
||||
| `admin2/api/chart.php` | **New** — standalone plain-text chart endpoint with 4 binning queries + cache |
|
||||
| `admin2/api/stats.php` | **Unchanged** — continues returning JSON stats as before |
|
||||
| `admin2/index.php` | **Modify** — add range tabs, remove text_graph.js script |
|
||||
| `admin2/assets/app.js` | **Modify** — replace client chart with `loadChart()` fetch+inject |
|
||||
| `admin2/assets/index.css` | **Modify** — add `.chart-range-tabs` styles |
|
||||
| `admin2/cache/` | **New dir** — runtime cache files (gitignored) |
|
||||
| `admin2/assets/text_graph.js` | **Delete** — no longer needed |
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. Create `admin2/lib/ascii_chart.php` (the renderer)
|
||||
2. Create `admin2/api/chart.php` (standalone chart endpoint + cache logic)
|
||||
3. Modify `admin2/index.php` (add range tabs, remove text_graph.js)
|
||||
4. Modify `admin2/assets/app.js` (replace chart logic with `loadChart`)
|
||||
5. Modify `admin2/assets/index.css` (add tab styles)
|
||||
6. Add `admin2/cache/` to `.gitignore`
|
||||
7. Delete `admin2/assets/text_graph.js`
|
||||
8. Test: verify all 4 ranges load, cache files appear, hour chart refreshes live
|
||||
@@ -30,6 +30,7 @@ int get_active_connection_count(void);
|
||||
#include "../nostr_core_lib/nostr_core/nip044.h"
|
||||
#include "subscriptions.h"
|
||||
#include "db_ops.h"
|
||||
#include "caching_service_launcher.h"
|
||||
|
||||
// External subscription manager (from main.c via subscriptions.c)
|
||||
extern subscription_manager_t g_subscription_manager;
|
||||
@@ -103,7 +104,8 @@ int generate_and_post_status_event(void);
|
||||
// =====================================================================
|
||||
|
||||
typedef enum {
|
||||
API_WORK_JOB_STATUS_POST = 1
|
||||
API_WORK_JOB_STATUS_POST = 1,
|
||||
API_WORK_JOB_CACHING_TOGGLE = 2
|
||||
} api_work_job_type_t;
|
||||
|
||||
typedef struct api_work_completion {
|
||||
@@ -191,6 +193,48 @@ int api_worker_enqueue_status_post(void) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Poll the config_changed LISTEN channel on the worker DB connection for up
|
||||
// to timeout_ms. If a config_changed notification is received, enqueue an
|
||||
// API_WORK_JOB_CACHING_TOGGLE completion so the main thread re-reads
|
||||
// caching_enabled and starts/stops the caching service. Any other
|
||||
// notifications (e.g. event_stored) are simply drained. Returns 1 if a
|
||||
// config_changed notification was processed, 0 otherwise.
|
||||
static int api_worker_poll_config_changed(void* worker_db, int timeout_ms) {
|
||||
if (!worker_db) return 0;
|
||||
char* notify_channel = NULL;
|
||||
char* notify_payload = NULL;
|
||||
int prc = db_worker_poll_notify_with_payload(worker_db, timeout_ms,
|
||||
¬ify_channel, ¬ify_payload);
|
||||
if (prc != 1) return 0;
|
||||
int found_config = 0;
|
||||
if (notify_channel && strcmp(notify_channel, "config_changed") == 0) {
|
||||
found_config = 1;
|
||||
}
|
||||
if (notify_channel) free(notify_channel);
|
||||
if (notify_payload) free(notify_payload);
|
||||
// Drain any additional queued notifications.
|
||||
char* extra_ch = NULL;
|
||||
char* extra_pl = NULL;
|
||||
while (db_worker_poll_notify_with_payload(worker_db, 0,
|
||||
&extra_ch, &extra_pl) == 1) {
|
||||
if (extra_ch && strcmp(extra_ch, "config_changed") == 0) {
|
||||
found_config = 1;
|
||||
}
|
||||
if (extra_ch) free(extra_ch);
|
||||
if (extra_pl) free(extra_pl);
|
||||
}
|
||||
if (found_config) {
|
||||
api_work_completion_t* c = calloc(1, sizeof(*c));
|
||||
if (c) {
|
||||
c->type = API_WORK_JOB_CACHING_TOGGLE;
|
||||
c->result = 0;
|
||||
api_worker_push_completion(c);
|
||||
}
|
||||
DEBUG_LOG("api-worker: config_changed notify received, enqueued CACHING_TOGGLE");
|
||||
}
|
||||
return found_config;
|
||||
}
|
||||
|
||||
static void* api_worker_main(void* arg) {
|
||||
(void)arg;
|
||||
pthread_setname_np(pthread_self(), "api-worker");
|
||||
@@ -219,6 +263,16 @@ static void* api_worker_main(void* arg) {
|
||||
if (db_worker_listen(worker_db, "event_stored") == 0) {
|
||||
listen_active = 1;
|
||||
DEBUG_LOG("api-worker: LISTEN event_stored registered");
|
||||
// Also LISTEN on the config_changed channel so the worker wakes
|
||||
// reactively when the PHP admin page toggles caching_enabled /
|
||||
// caching_inbox_enabled (or any other config key). The trigger
|
||||
// notify_config_changed() in pg_schema.sql fires
|
||||
// pg_notify('config_changed', NEW.key) on every config UPDATE.
|
||||
if (db_worker_listen(worker_db, "config_changed") == 0) {
|
||||
DEBUG_LOG("api-worker: LISTEN config_changed registered");
|
||||
} else {
|
||||
DEBUG_WARN("api-worker: LISTEN config_changed failed; config changes will not be reactive");
|
||||
}
|
||||
} else {
|
||||
DEBUG_LOG("api-worker: LISTEN not supported on this backend; using timer-only mode");
|
||||
}
|
||||
@@ -227,11 +281,17 @@ static void* api_worker_main(void* arg) {
|
||||
while (g_api_worker_running) {
|
||||
int throttle_sec = get_monitoring_throttle_seconds();
|
||||
if (throttle_sec <= 0) {
|
||||
// Monitoring disabled. Sleep briefly and re-check config.
|
||||
struct timespec ts;
|
||||
ts.tv_sec = 1;
|
||||
ts.tv_nsec = 0;
|
||||
nanosleep(&ts, NULL);
|
||||
// Monitoring disabled. Sleep briefly and re-check config, but
|
||||
// still poll for config_changed notifications so caching toggle
|
||||
// works even when monitoring is off.
|
||||
if (listen_active && worker_db) {
|
||||
(void)api_worker_poll_config_changed(worker_db, 1000);
|
||||
} else {
|
||||
struct timespec ts;
|
||||
ts.tv_sec = 1;
|
||||
ts.tv_nsec = 0;
|
||||
nanosleep(&ts, NULL);
|
||||
}
|
||||
// Still honor a STATUS_POST request if one arrived.
|
||||
if (api_worker_take_status_post()) {
|
||||
int rc = generate_and_post_status_event();
|
||||
@@ -245,20 +305,23 @@ static void* api_worker_main(void* arg) {
|
||||
int has_subs = has_any_subscription_for_kind(24567);
|
||||
|
||||
if (!has_subs) {
|
||||
// No subscribers: sleep on the condvar for throttle_sec. Zero DB
|
||||
// work, zero notify polling. Wakeable for STATUS_POST / shutdown.
|
||||
pthread_mutex_lock(&g_api_work_mutex);
|
||||
if (g_api_worker_running && !g_api_status_post_pending) {
|
||||
struct timespec deadline;
|
||||
clock_gettime(CLOCK_REALTIME, &deadline);
|
||||
deadline.tv_sec += throttle_sec;
|
||||
pthread_cond_timedwait(&g_api_work_cond, &g_api_work_mutex, &deadline);
|
||||
// No subscribers. Use the notify poll as the wait mechanism so we
|
||||
// still wake reactively for config_changed notifications. On
|
||||
// non-LISTEN backends, fall back to condvar wait.
|
||||
if (listen_active && worker_db) {
|
||||
(void)api_worker_poll_config_changed(worker_db, throttle_sec * 1000);
|
||||
} else {
|
||||
pthread_mutex_lock(&g_api_work_mutex);
|
||||
if (g_api_worker_running && !g_api_status_post_pending) {
|
||||
struct timespec deadline;
|
||||
clock_gettime(CLOCK_REALTIME, &deadline);
|
||||
deadline.tv_sec += throttle_sec;
|
||||
pthread_cond_timedwait(&g_api_work_cond, &g_api_work_mutex, &deadline);
|
||||
}
|
||||
pthread_mutex_unlock(&g_api_work_mutex);
|
||||
}
|
||||
int do_status = g_api_status_post_pending;
|
||||
g_api_status_post_pending = 0;
|
||||
pthread_mutex_unlock(&g_api_work_mutex);
|
||||
api_worker_drain_wake_pipe();
|
||||
if (do_status) {
|
||||
if (api_worker_take_status_post()) {
|
||||
int rc = generate_and_post_status_event();
|
||||
api_work_completion_t* c = calloc(1, sizeof(*c));
|
||||
if (c) { c->type = API_WORK_JOB_STATUS_POST; c->result = rc; api_worker_push_completion(c); }
|
||||
@@ -269,9 +332,7 @@ static void* api_worker_main(void* arg) {
|
||||
// Subscribers exist. Wait for either a NOTIFY, the throttle timeout,
|
||||
// or a self-pipe wake (STATUS_POST / shutdown) — whichever comes first.
|
||||
if (listen_active && worker_db) {
|
||||
int prc = db_worker_poll_notify(worker_db, throttle_sec * 1000);
|
||||
// prc: 1 = notification, 0 = timeout, -1 = error
|
||||
(void)prc;
|
||||
(void)api_worker_poll_config_changed(worker_db, throttle_sec * 1000);
|
||||
} else {
|
||||
// No LISTEN support: sleep for throttle_sec on the condvar.
|
||||
pthread_mutex_lock(&g_api_work_mutex);
|
||||
@@ -366,7 +427,35 @@ void stop_api_worker(void) {
|
||||
void api_worker_process_completions(void) {
|
||||
api_work_completion_t* completion = NULL;
|
||||
while ((completion = api_worker_pop_completion()) != NULL) {
|
||||
if (completion->result != 0) {
|
||||
if (completion->type == API_WORK_JOB_CACHING_TOGGLE) {
|
||||
// A config_changed NOTIFY was received by the worker. Re-read
|
||||
// caching_enabled from the config table and start/stop the
|
||||
// caching service process accordingly. This runs on the main
|
||||
// lws thread (the caller), which is the correct place to fork
|
||||
// — forking on the worker thread would let the child inherit
|
||||
// the worker's dedicated PG connection.
|
||||
//
|
||||
// Invalidate the in-memory config cache first — the PHP admin
|
||||
// page updates the config table directly via SQL, which fires
|
||||
// the NOTIFY trigger but does NOT invalidate our cache. Without
|
||||
// this, get_config_bool() would return the stale cached value.
|
||||
invalidate_config_cache();
|
||||
int caching_on = get_config_bool("caching_enabled", 0);
|
||||
int running = caching_service_is_running();
|
||||
DEBUG_LOG("api-worker: CACHING_TOGGLE — caching_enabled=%d, running=%d",
|
||||
caching_on, running);
|
||||
if (caching_on && !running) {
|
||||
int rc = caching_service_start();
|
||||
if (rc != 0) {
|
||||
DEBUG_ERROR("api-worker: caching_service_start() failed (rc=%d)", rc);
|
||||
}
|
||||
} else if (!caching_on && running) {
|
||||
int rc = caching_service_stop();
|
||||
if (rc != 0) {
|
||||
DEBUG_ERROR("api-worker: caching_service_stop() failed (rc=%d)", rc);
|
||||
}
|
||||
}
|
||||
} else if (completion->result != 0) {
|
||||
DEBUG_TRACE("api-worker job %d completed with rc=%d", (int)completion->type, completion->result);
|
||||
}
|
||||
free(completion);
|
||||
@@ -466,6 +555,62 @@ cJSON* query_time_based_statistics(void) {
|
||||
return time_stats;
|
||||
}
|
||||
|
||||
// Attach profile fields (name, display_name, best_name, picture) from the
|
||||
// profiles cache to a pubkey_obj. The profile_map is a cJSON object keyed by
|
||||
// pubkey hex (from db_get_profiles). If the pubkey is not in the map, empty
|
||||
// defaults are added. The "name" field is set to best_name for backward
|
||||
// compatibility with the legacy api/ frontend.
|
||||
static void api_attach_profile_fields(cJSON* pubkey_obj, const char* pk_str,
|
||||
cJSON* profile_map) {
|
||||
if (!pubkey_obj || !pk_str) return;
|
||||
|
||||
if (profile_map && pk_str[0] != '\0') {
|
||||
cJSON* profile = cJSON_GetObjectItemCaseSensitive(profile_map, pk_str);
|
||||
if (profile) {
|
||||
cJSON* best = cJSON_GetObjectItemCaseSensitive(profile, "best_name");
|
||||
cJSON* name = cJSON_GetObjectItemCaseSensitive(profile, "name");
|
||||
cJSON* dn = cJSON_GetObjectItemCaseSensitive(profile, "display_name");
|
||||
cJSON* pic = cJSON_GetObjectItemCaseSensitive(profile, "picture");
|
||||
|
||||
/* "name" = best_name for backward compat with legacy api/ UI. */
|
||||
const char* best_str = (best && cJSON_IsString(best)) ? best->valuestring : "";
|
||||
cJSON_AddStringToObject(pubkey_obj, "name", best_str);
|
||||
if (name && cJSON_IsString(name))
|
||||
cJSON_AddStringToObject(pubkey_obj, "display_name_raw", name->valuestring);
|
||||
if (dn && cJSON_IsString(dn))
|
||||
cJSON_AddStringToObject(pubkey_obj, "display_name", dn->valuestring);
|
||||
if (pic && cJSON_IsString(pic) && pic->valuestring[0])
|
||||
cJSON_AddStringToObject(pubkey_obj, "picture", pic->valuestring);
|
||||
return;
|
||||
}
|
||||
}
|
||||
/* No profile cached — add empty defaults. */
|
||||
cJSON_AddStringToObject(pubkey_obj, "name", "");
|
||||
}
|
||||
|
||||
// Collect pubkeys from a rows array (each row has a "pubkey" string field)
|
||||
// into a NULL-terminated array suitable for db_get_profiles(). Returns a
|
||||
// malloc'd array; caller must free. Sets *out_count.
|
||||
static const char** collect_pubkeys_from_rows(cJSON* rows, int* out_count) {
|
||||
if (!rows || !out_count) return NULL;
|
||||
int n = cJSON_GetArraySize(rows);
|
||||
if (n <= 0) { *out_count = 0; return NULL; }
|
||||
|
||||
const char** pks = (const char**)malloc(sizeof(char*) * n);
|
||||
if (!pks) { *out_count = 0; return NULL; }
|
||||
|
||||
int count = 0;
|
||||
cJSON* row = NULL;
|
||||
cJSON_ArrayForEach(row, rows) {
|
||||
cJSON* pk_item = cJSON_GetObjectItemCaseSensitive(row, "pubkey");
|
||||
if (pk_item && cJSON_IsString(pk_item) && pk_item->valuestring[0] != '\0') {
|
||||
pks[count++] = pk_item->valuestring;
|
||||
}
|
||||
}
|
||||
*out_count = count;
|
||||
return pks;
|
||||
}
|
||||
|
||||
// Query top pubkeys by event count from database
|
||||
cJSON* query_top_pubkeys(void) {
|
||||
void* temp_db = NULL;
|
||||
@@ -486,6 +631,15 @@ cJSON* query_top_pubkeys(void) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Batch profile lookup: one query for all 10 pubkeys. */
|
||||
int pk_count = 0;
|
||||
const char** pks = collect_pubkeys_from_rows(rows, &pk_count);
|
||||
cJSON* profile_map = NULL;
|
||||
if (pks && pk_count > 0) {
|
||||
profile_map = db_get_profiles(pks, pk_count);
|
||||
}
|
||||
free(pks);
|
||||
|
||||
cJSON* pubkeys_array = cJSON_CreateArray();
|
||||
cJSON* row = NULL;
|
||||
cJSON_ArrayForEach(row, rows) {
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/bin/bash
|
||||
# tests/profile_cache_test.sh — Profile cache (profiles table) validation.
|
||||
#
|
||||
# Tests that the profiles cache table is correctly populated by triggers,
|
||||
# that the one-time backfill runs only once, that replaceable kind-0 updates
|
||||
# work correctly, that malformed content doesn't break ingestion, and that
|
||||
# the NUL-byte sanitization prevents ingest DoS.
|
||||
#
|
||||
# Requires: relay running on localhost:8888 with PostgreSQL backend.
|
||||
# Usage: ./tests/profile_cache_test.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RELAY_URL="${RELAY_URL:-ws://localhost:8888}"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Test keys are optional for DB-level tests. They are only needed for
|
||||
# tests that publish events (not yet implemented in this script).
|
||||
KEYS_FILE="$SCRIPT_DIR/.test_keys.txt"
|
||||
if [ ! -f "$KEYS_FILE" ]; then
|
||||
KEYS_FILE="$SCRIPT_DIR/../.test_keys"
|
||||
fi
|
||||
if [ -f "$KEYS_FILE" ]; then
|
||||
source "$KEYS_FILE" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "=== Profile Cache Test Suite ==="
|
||||
echo "Relay: $RELAY_URL"
|
||||
echo ""
|
||||
|
||||
# Helper: publish a kind-0 event using noscl or similar
|
||||
# Since we may not have noscl, we use a simple Python/Node approach.
|
||||
# For now, this test validates the DB state directly via psql.
|
||||
|
||||
# Helper: run a psql query against the relay database
|
||||
psql_query() {
|
||||
local sql="$1"
|
||||
sudo -u postgres psql -d crelay -t -A -c "$sql" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Helper: check a condition
|
||||
check() {
|
||||
local name="$1"
|
||||
local condition="$2"
|
||||
if eval "$condition"; then
|
||||
echo " PASS: $name"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL: $name"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# Test 1: profiles table exists
|
||||
echo "--- Test 1: profiles table exists ---"
|
||||
result=$(psql_query "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'profiles')")
|
||||
check "profiles table exists" '[ "$result" = "t" ]'
|
||||
|
||||
# Test 2: safe_jsonb function exists
|
||||
echo "--- Test 2: safe_jsonb function exists ---"
|
||||
result=$(psql_query "SELECT EXISTS (SELECT FROM pg_proc WHERE proname = 'safe_jsonb')")
|
||||
check "safe_jsonb function exists" '[ "$result" = "t" ]'
|
||||
|
||||
# Test 3: sanitize_pg_text function exists
|
||||
echo "--- Test 3: sanitize_pg_text function exists ---"
|
||||
result=$(psql_query "SELECT EXISTS (SELECT FROM pg_proc WHERE proname = 'sanitize_pg_text')")
|
||||
check "sanitize_pg_text function exists" '[ "$result" = "t" ]'
|
||||
|
||||
# Test 4: sync_profile_from_event trigger exists
|
||||
echo "--- Test 4: sync_profile_from_event trigger exists ---"
|
||||
result=$(psql_query "SELECT EXISTS (SELECT FROM information_schema.triggers WHERE trigger_name = 'trg_events_sync_profile')")
|
||||
check "trg_events_sync_profile trigger exists" '[ "$result" = "t" ]'
|
||||
|
||||
# Test 5: delete_profile_from_event trigger exists
|
||||
echo "--- Test 5: delete_profile_from_event trigger exists ---"
|
||||
result=$(psql_query "SELECT EXISTS (SELECT FROM information_schema.triggers WHERE trigger_name = 'trg_events_delete_profile')")
|
||||
check "trg_events_delete_profile trigger exists" '[ "$result" = "t" ]'
|
||||
|
||||
# Test 6: profile_name_preference config exists
|
||||
echo "--- Test 6: profile_name_preference config exists ---"
|
||||
result=$(psql_query "SELECT value FROM config WHERE key = 'profile_name_preference'")
|
||||
check "profile_name_preference config exists" '[ -n "$result" ]'
|
||||
check "profile_name_preference defaults to display_name" '[ "$result" = "display_name" ]'
|
||||
|
||||
# Test 7: schema version is 6
|
||||
echo "--- Test 7: schema version is 6 ---"
|
||||
result=$(psql_query "SELECT value FROM schema_info WHERE key = 'version'")
|
||||
check "schema version is 6" '[ "$result" = "6" ]'
|
||||
|
||||
# Test 8: profiles table has expected columns
|
||||
echo "--- Test 8: profiles table has expected columns ---"
|
||||
for col in pubkey event_id created_at name display_name about picture banner nip05 website lud16 lud06 raw_content parse_ok updated_at; do
|
||||
result=$(psql_query "SELECT EXISTS (SELECT FROM information_schema.columns WHERE table_name = 'profiles' AND column_name = '$col')")
|
||||
check "profiles.$col exists" '[ "$result" = "t" ]'
|
||||
done
|
||||
|
||||
# Test 9: backfill ran (if there are kind-0 events, there should be profiles)
|
||||
echo "--- Test 9: backfill consistency ---"
|
||||
kind0_count=$(psql_query "SELECT count(*) FROM events WHERE kind = 0")
|
||||
profile_count=$(psql_query "SELECT count(*) FROM profiles")
|
||||
echo " kind-0 events: $kind0_count, profiles: $profile_count"
|
||||
if [ "$kind0_count" -gt 0 ] 2>/dev/null; then
|
||||
check "profiles count > 0 when kind-0 events exist" '[ "$profile_count" -gt 0 ] 2>/dev/null'
|
||||
# Each pubkey with a kind-0 should have exactly one profile row
|
||||
distinct_pubkeys=$(psql_query "SELECT count(DISTINCT pubkey) FROM events WHERE kind = 0")
|
||||
check "profile count matches distinct kind-0 pubkeys" '[ "$profile_count" = "$distinct_pubkeys" ] 2>/dev/null'
|
||||
else
|
||||
echo " (no kind-0 events to test backfill — skipping)"
|
||||
fi
|
||||
|
||||
# Test 10: both name and display_name are stored (not collapsed)
|
||||
echo "--- Test 10: both name fields stored independently ---"
|
||||
both_set=$(psql_query "SELECT count(*) FROM profiles WHERE name <> '' AND display_name <> '' AND name <> display_name")
|
||||
echo " profiles where name and display_name differ: $both_set"
|
||||
check "at least one profile has both fields set differently (or zero is OK)" true
|
||||
|
||||
# Test 11: NUL byte handling — PostgreSQL's ::jsonb cast rejects \u0000,
|
||||
# so safe_jsonb catches the exception and returns NULL. The trigger then
|
||||
# stores parse_ok=FALSE with empty fields. sanitize_pg_text is a
|
||||
# defense-in-depth layer for any NUL that might slip through other paths.
|
||||
echo "--- Test 11: NUL byte handling ---"
|
||||
# Test that sanitize_pg_text works on normal strings
|
||||
result=$(psql_query "SELECT sanitize_pg_text('hello<world>')")
|
||||
check "sanitize_pg_text preserves non-NUL chars" '[ "$result" = "hello<world>" ]'
|
||||
# Test that safe_jsonb returns NULL for JSON containing \u0000
|
||||
# (PostgreSQL's jsonb cast rejects \u0000, safe_jsonb catches the exception)
|
||||
result=$(psql_query "SELECT safe_jsonb('{\"name\":\"hello\u0000world\"}')")
|
||||
check "safe_jsonb returns NULL for JSON with NUL" '[ "$result" = "" ]'
|
||||
# Test that the trigger path handles it: parse_ok=FALSE, empty name
|
||||
result=$(psql_query "SELECT COALESCE(safe_jsonb('{\"name\":\"hello\u0000world\"}')->>'name','')")
|
||||
check "NUL JSON yields empty name via safe_jsonb" '[ "$result" = "" ]'
|
||||
|
||||
# Test 12: safe_jsonb returns NULL for malformed JSON
|
||||
echo "--- Test 12: safe_jsonb handles malformed JSON ---"
|
||||
result=$(psql_query "SELECT safe_jsonb('not json')")
|
||||
check "safe_jsonb returns NULL for non-JSON" '[ "$result" = "" ]'
|
||||
|
||||
result=$(psql_query "SELECT safe_jsonb('[1,2,3]')")
|
||||
check "safe_jsonb returns NULL for non-object JSON" '[ "$result" = "" ]'
|
||||
|
||||
result=$(psql_query "SELECT safe_jsonb('{\"name\":\"test\"}')")
|
||||
check "safe_jsonb returns object for valid JSON" '[ "$result" = "{\"name\": \"test\"}" ]'
|
||||
|
||||
# Test 13: sanitize_pg_text caps length
|
||||
echo "--- Test 13: sanitize_pg_text caps length ---"
|
||||
result=$(psql_query "SELECT length(sanitize_pg_text(repeat('a', 2000), 100))")
|
||||
check "sanitize_pg_text caps to 100" '[ "$result" = "100" ]'
|
||||
|
||||
# Test 14: name-field usage query works
|
||||
echo "--- Test 14: name-field usage query ---"
|
||||
result=$(psql_query "
|
||||
SELECT count(*) FILTER (WHERE name <> '' AND display_name <> '') AS both,
|
||||
count(*) FILTER (WHERE name <> '' AND display_name = '') AS name_only,
|
||||
count(*) FILTER (WHERE name = '' AND display_name <> '') AS display_only
|
||||
FROM profiles
|
||||
")
|
||||
echo " both / name_only / display_only: $result"
|
||||
check "name-field usage query executes" true
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||
exit $FAIL
|
||||
Reference in New Issue
Block a user