248 lines
7.8 KiB
PHP
248 lines
7.8 KiB
PHP
<?php
|
|
/**
|
|
* C-Relay-PG Admin — Shared helper functions.
|
|
*/
|
|
|
|
require_once __DIR__ . '/db.php';
|
|
|
|
/** HTML-escape a string. */
|
|
function e(?string $s): string {
|
|
return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
|
|
}
|
|
|
|
/** Format a Unix timestamp as a human-readable relative time. */
|
|
function time_ago(int $ts): string {
|
|
if ($ts <= 0) return 'never';
|
|
$diff = time() - $ts;
|
|
if ($diff < 60) return $diff . 's ago';
|
|
if ($diff < 3600) return floor($diff / 60) . 'm ago';
|
|
if ($diff < 86400) return floor($diff / 3600) . 'h ago';
|
|
return floor($diff / 86400) . 'd ago';
|
|
}
|
|
|
|
/** Format a Unix timestamp as a date/time string. */
|
|
function fmt_date(int $ts): string {
|
|
if ($ts <= 0) return '-';
|
|
return date('Y-m-d H:i:s', $ts);
|
|
}
|
|
|
|
/**
|
|
* Convert a 64-char hex pubkey to npub (bech32).
|
|
* Uses the standard bech32 encoding (NIP-19).
|
|
*/
|
|
function hex_to_npub(string $hex): string {
|
|
if (!preg_match('/^[0-9a-fA-F]{64}$/', $hex)) return $hex;
|
|
$hex = strtolower($hex);
|
|
|
|
// Bech32 constants
|
|
$charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
|
$data = [];
|
|
for ($i = 0; $i < strlen($hex); $i += 2) {
|
|
$data[] = intval(substr($hex, $i, 2), 16);
|
|
}
|
|
// Convert 8-bit groups to 5-bit groups
|
|
$conv = [];
|
|
$buffer = 0;
|
|
$bits = 0;
|
|
foreach ($data as $byte) {
|
|
$buffer = ($buffer << 8) | $byte;
|
|
$bits += 8;
|
|
while ($bits >= 5) {
|
|
$conv[] = ($buffer >> ($bits - 5)) & 31;
|
|
$bits -= 5;
|
|
}
|
|
}
|
|
if ($bits > 0) {
|
|
$conv[] = ($buffer << (5 - $bits)) & 31;
|
|
}
|
|
|
|
// Compute checksum — HRP values are positions of each char in the charset.
|
|
$values = array_merge(
|
|
array_map(function($c) {
|
|
return strpos('qpzry9x8gf2tvdw0s3jn54khce6mua7l', $c);
|
|
}, str_split('npub')),
|
|
$conv
|
|
);
|
|
$gen = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
|
$chk = 1;
|
|
foreach ($values as $v) {
|
|
$top = $chk >> 25;
|
|
$chk = (($chk & 0x1ffffff) << 5) ^ $v;
|
|
for ($i = 0; $i < 5; $i++) {
|
|
if (($top >> $i) & 1) $chk ^= $gen[$i];
|
|
}
|
|
}
|
|
$chk ^= 1;
|
|
$checksum = [];
|
|
for ($i = 0; $i < 6; $i++) {
|
|
$checksum[] = ($chk >> (5 * (5 - $i))) & 31;
|
|
}
|
|
|
|
$result = 'npub1';
|
|
foreach (array_merge($conv, $checksum) as $v) {
|
|
$result .= $charset[$v];
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
/** Truncate a string to $len chars with ellipsis. */
|
|
function trunc(string $s, int $len = 16): string {
|
|
if (strlen($s) <= $len) return $s;
|
|
return substr($s, 0, $len) . '…';
|
|
}
|
|
|
|
/** Build pagination links HTML. */
|
|
function pagination(int $page, int $per_page, int $total, string $base_url): string {
|
|
$total_pages = max(1, ceil($total / $per_page));
|
|
if ($total_pages <= 1) return '';
|
|
|
|
$html = '<div class="pagination">';
|
|
if ($page > 1) {
|
|
$html .= '<a href="' . e($base_url) . '?page=' . ($page - 1) . '">← Prev</a>';
|
|
}
|
|
$html .= '<span>Page ' . $page . ' of ' . $total_pages . ' (' . $total . ' total)</span>';
|
|
if ($page < $total_pages) {
|
|
$html .= '<a href="' . e($base_url) . '?page=' . ($page + 1) . '">Next →</a>';
|
|
}
|
|
$html .= '</div>';
|
|
return $html;
|
|
}
|
|
|
|
/** Get the current page number from the query string. */
|
|
function current_page(): int {
|
|
$p = intval($_GET['page'] ?? 1);
|
|
return max(1, $p);
|
|
}
|
|
|
|
/** JSON response helper for AJAX endpoints. */
|
|
function json_response($data): void {
|
|
header('Content-Type: application/json');
|
|
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
/** Get a query parameter with a default. */
|
|
function query_param(string $key, $default = null) {
|
|
return $_GET[$key] ?? $default;
|
|
}
|
|
|
|
/**
|
|
* Batch-resolve profiles from the profiles cache table.
|
|
* Returns [pubkey_hex => ['name'=>..., 'display_name'=>...,
|
|
* 'best_name'=>..., 'picture'=>..., 'nip05'=>...]].
|
|
* Pubkeys with no cached profile are absent from the result.
|
|
*/
|
|
function profile_map(array $pubkeys): array {
|
|
if (empty($pubkeys)) return [];
|
|
$pdo = db();
|
|
$map = [];
|
|
try {
|
|
// Build parameterized IN clause: WHERE pubkey = ANY(?)
|
|
// PDO PostgreSQL supports passing an array as a string literal.
|
|
$placeholders = implode(',', array_fill(0, count($pubkeys), '?'));
|
|
$stmt = $pdo->prepare(
|
|
"SELECT pubkey, name, display_name, picture, nip05 FROM profiles "
|
|
. "WHERE pubkey IN ($placeholders)"
|
|
);
|
|
$stmt->execute(array_values($pubkeys));
|
|
$rows = $stmt->fetchAll();
|
|
foreach ($rows as $r) {
|
|
$map[$r['pubkey']] = [
|
|
'name' => $r['name'] ?? '',
|
|
'display_name' => $r['display_name'] ?? '',
|
|
'best_name' => profile_display_name($r),
|
|
'picture' => $r['picture'] ?? '',
|
|
'nip05' => $r['nip05'] ?? '',
|
|
];
|
|
}
|
|
} catch (PDOException $e) {}
|
|
return $map;
|
|
}
|
|
|
|
/**
|
|
* Apply the profile_name_preference config key to resolve the display name.
|
|
* Never returns null. Returns '' if neither field is set.
|
|
*/
|
|
function profile_display_name(array $profile): string {
|
|
$name = $profile['name'] ?? '';
|
|
$display_name = $profile['display_name'] ?? '';
|
|
static $pref = null;
|
|
if ($pref === null) {
|
|
try {
|
|
$pdo = db();
|
|
$pref = $pdo->query("SELECT value FROM config WHERE key = 'profile_name_preference'")->fetchColumn() ?: 'display_name';
|
|
} catch (PDOException $e) {
|
|
$pref = 'display_name';
|
|
}
|
|
}
|
|
if ($pref === 'name') {
|
|
return $name !== '' ? $name : $display_name;
|
|
}
|
|
// Default: prefer display_name, fall back to name.
|
|
return $display_name !== '' ? $display_name : $name;
|
|
}
|
|
|
|
/**
|
|
* Render the shared HTML header with side navigation.
|
|
* Pass the active page name to highlight the current nav item.
|
|
*/
|
|
function admin_header(string $active = '', string $title = 'C-Relay-PG Admin'): void {
|
|
$pages = [
|
|
'dashboard' => ['index.php', 'Dashboard'],
|
|
'follows' => ['follows.php', 'Follows'],
|
|
'relays' => ['relays.php', 'Relays'],
|
|
'config' => ['config-edit.php', 'Config'],
|
|
'inbox' => ['inbox.php', 'Inbox'],
|
|
];
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title><?= e($title) ?></title>
|
|
<link rel="stylesheet" href="assets/style.css">
|
|
</head>
|
|
<body>
|
|
<!-- Side Navigation -->
|
|
<nav class="side-nav" id="side-nav">
|
|
<ul class="nav-menu">
|
|
<?php foreach ($pages as $key => [$url, $label]): ?>
|
|
<li><a class="nav-item<?= $active === $key ? ' active' : '' ?>" href="<?= e($url) ?>"><?= e($label) ?></a></li>
|
|
<?php endforeach; ?>
|
|
</ul>
|
|
</nav>
|
|
<div class="side-nav-overlay" id="side-nav-overlay" onclick="closeNav()"></div>
|
|
|
|
<!-- Header -->
|
|
<div class="main-header">
|
|
<div class="header-content">
|
|
<button class="menu-btn" onclick="openNav()">☰</button>
|
|
<div class="header-title" onclick="location.href='index.php'">
|
|
<span class="relay-letter">R</span><span class="relay-letter">E</span><span class="relay-letter">L</span><span class="relay-letter">A</span><span class="relay-letter">Y</span>
|
|
</div>
|
|
<div style="width:40px"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function openNav() {
|
|
document.getElementById('side-nav').classList.add('open');
|
|
document.getElementById('side-nav-overlay').classList.add('show');
|
|
}
|
|
function closeNav() {
|
|
document.getElementById('side-nav').classList.remove('open');
|
|
document.getElementById('side-nav-overlay').classList.remove('show');
|
|
}
|
|
</script>
|
|
<?php
|
|
}
|
|
|
|
/** Render the shared HTML footer. */
|
|
function admin_footer(): void {
|
|
?>
|
|
</body>
|
|
</html>
|
|
<?php
|
|
}
|