Public web-only release: SvxPortal front-end + auth backend, install templates and INSTALL.md
Stripped from the internal repo: per-box deploy trees, Go services (nodelist/astmeta/proxy/svxdr), deploy/sync scripts and all secrets. Config templates for nginx, PHP-FPM, Redis and tmpfiles live in install/ with CHANGEME placeholders. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
// acl.php — shared PHP helper for every web (dmr-web, svx-web).
|
||||
// Canonical copy: callmgr2/www-mumien/auth/lib/acl.php; other repos carry a COPY.
|
||||
// Admin-on-admin checks. Needs acct_admin_level() and json_out() from the site.
|
||||
|
||||
// An admin may change another account only when their own level is strictly
|
||||
// higher (and may always change themselves).
|
||||
function require_privilege(string $acting_uuid, int $acting_level, string $target_uuid): void {
|
||||
if ($target_uuid === $acting_uuid) return;
|
||||
$target_level = acct_admin_level($target_uuid);
|
||||
if ($target_level >= $acting_level) {
|
||||
json_out(['ok' => false, 'error' => 'insufficient_privilege'], 403);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
// html.php — shared PHP helper for every web (dmr-web, svx-web, svxlink-web).
|
||||
// Canonical copy: callmgr2/www-mumien/auth/lib/html.php; other repos carry a COPY.
|
||||
// HTML escaping for server-rendered pages.
|
||||
|
||||
// Escape for HTML text and attribute context (quotes included, invalid UTF-8 replaced).
|
||||
function h(?string $s): string {
|
||||
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
// http.php — shared PHP helper for every web (dmr-web, svx-web).
|
||||
// Canonical copy: callmgr2/www-mumien/auth/lib/http.php; other repos carry a COPY.
|
||||
// JSON request/response, CSRF check, Redis-backed rate limiting.
|
||||
// Loaded by auth/lib.php; needs cfg() from the page's own lib.php.
|
||||
|
||||
function json_in(): array {
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === false || $raw === '') return [];
|
||||
$j = json_decode($raw, true);
|
||||
return is_array($j) ? $j : [];
|
||||
}
|
||||
|
||||
function json_out(array $obj, int $code = 200): void {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
// V5-14F63: prevent browser heuristic caching — every endpoint returns
|
||||
// live data; stale-from-cache breaks SPA pages that re-fetch on nav.
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
echo json_encode($obj, JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
function csrf_token(): string {
|
||||
start_session();
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
return $_SESSION['csrf_token'];
|
||||
}
|
||||
|
||||
function csrf_check(): void {
|
||||
start_session();
|
||||
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
$expected = $_SESSION['csrf_token'] ?? '';
|
||||
session_write_close(); // release the lock immediately after reading
|
||||
if ($token === '' || $expected === '' || !hash_equals($expected, $token)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid_csrf_token'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Rate-limit key namespace per site: a site may define rate_limit_key_prefix()
|
||||
// (svx-web uses its own rkey() namespace); the default is callmgr's.
|
||||
function rate_limit_key_prefix_(): string {
|
||||
return function_exists('rate_limit_key_prefix') ? rate_limit_key_prefix() : '/callmgr/ratelimit/';
|
||||
}
|
||||
|
||||
function rate_limit(string $key, int $max_attempts, int $window_seconds): void {
|
||||
$r = redis();
|
||||
$rk = rate_limit_key_prefix_() . $key;
|
||||
$count = (int)$r->get($rk);
|
||||
if ($count >= $max_attempts) {
|
||||
$ttl = $r->ttl($rk);
|
||||
json_out(['ok' => false, 'error' => "Too many attempts. Try again in {$ttl} seconds."], 429);
|
||||
}
|
||||
$r->incr($rk);
|
||||
if ($count === 0) {
|
||||
$r->expire($rk, $window_seconds);
|
||||
}
|
||||
}
|
||||
|
||||
function rate_limit_clear(string $key): void {
|
||||
redis()->del(rate_limit_key_prefix_() . $key);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
// redis.php — shared PHP helper for every web (dmr-web, svx-web).
|
||||
// Canonical copy: callmgr2/www-mumien/auth/lib/redis.php; other repos carry a COPY.
|
||||
// Redis connections (main + aux) and the batched SCAN+MGET read — the only way to read many keys.
|
||||
// Loaded by auth/lib.php; needs cfg() from the page's own lib.php.
|
||||
|
||||
// One connection from a config block {unix_socket|host,port, password, db}.
|
||||
// $read_timeout > 0 keeps a blocking SUBSCRIBE alive (lastheard relay: 86400 s).
|
||||
function redis_connect(array $c, float $read_timeout = 0.0): Redis {
|
||||
$r = new Redis();
|
||||
if (!empty($c['unix_socket'])) {
|
||||
$r->connect($c['unix_socket'], 0, 0.0, null, 0, $read_timeout);
|
||||
} else {
|
||||
$r->connect($c['host'], (int)$c['port'], 0.0, null, 0, $read_timeout);
|
||||
}
|
||||
if (!empty($c['password'])) $r->auth($c['password']);
|
||||
if (isset($c['db'])) $r->select((int)$c['db']);
|
||||
return $r;
|
||||
}
|
||||
|
||||
function redis(): Redis {
|
||||
static $r = null;
|
||||
if ($r instanceof Redis) return $r;
|
||||
|
||||
$c = cfg()['redis'];
|
||||
$r = new Redis();
|
||||
if (!empty($c['unix_socket'])) {
|
||||
$r->connect($c['unix_socket']);
|
||||
} else {
|
||||
$r->connect($c['host'], (int)$c['port']);
|
||||
}
|
||||
if (!empty($c['password'])) $r->auth($c['password']);
|
||||
if (isset($c['db'])) $r->select((int)$c['db']);
|
||||
return $r;
|
||||
}
|
||||
|
||||
// V5-14F64: aux redis instance — daemon's APRS inbox/outbox lives here
|
||||
// (same place stats/events go). Falls back to main redis if no aux config
|
||||
// is present (older installs without redis-aux).
|
||||
function redis_aux(): Redis {
|
||||
static $r = null;
|
||||
if ($r instanceof Redis) return $r;
|
||||
|
||||
$c = cfg()['redis_aux'] ?? null;
|
||||
if (!$c) return redis(); // fallback: main socket
|
||||
|
||||
$r = new Redis();
|
||||
if (!empty($c['unix_socket'])) {
|
||||
$r->connect($c['unix_socket']);
|
||||
} else {
|
||||
$r->connect($c['host'], (int)$c['port']);
|
||||
}
|
||||
if (!empty($c['password'])) $r->auth($c['password']);
|
||||
if (isset($c['db'])) $r->select((int)$c['db']);
|
||||
return $r;
|
||||
}
|
||||
|
||||
// Every key matching $pattern in ONE SCAN sweep (the only way to enumerate
|
||||
// keys: no KEYS, no per-page loops copied around). Returns [] on error.
|
||||
function redis_scan_keys(Redis $r, string $pattern, int $count = 500): array {
|
||||
$keys = [];
|
||||
$it = null;
|
||||
do {
|
||||
$batch = $r->scan($it, $pattern, $count);
|
||||
if (is_array($batch)) {
|
||||
foreach ($batch as $k) $keys[] = $k;
|
||||
}
|
||||
} while ($it > 0);
|
||||
return $keys;
|
||||
}
|
||||
|
||||
// Batch-read every key under $scan_pattern: ONE SCAN sweep + one MGET, keyed
|
||||
// by the part after $base_prefix. With ~700k keys in the instance this is the
|
||||
// difference between 2 round-trips and one per key.
|
||||
function redis_scan_mget(Redis $r, string $base_prefix, string $scan_pattern): array {
|
||||
$keys = redis_scan_keys($r, $scan_pattern, 200);
|
||||
$out = [];
|
||||
if (!empty($keys)) {
|
||||
$vals = $r->mGet($keys);
|
||||
foreach ($keys as $i => $k) {
|
||||
$rel = substr($k, strlen($base_prefix));
|
||||
$v = $vals[$i] ?? false;
|
||||
$out[$rel] = is_string($v) ? $v : '';
|
||||
}
|
||||
}
|
||||
ksort($out);
|
||||
return $out;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
// shell.php — shared PHP helper for every web (dmr-web, svx-web, svxlink-web).
|
||||
// Canonical copy: callmgr2/www-mumien/auth/lib/shell.php; other repos carry a COPY.
|
||||
|
||||
// Shell-argument quoting for the few pages that run local helpers.
|
||||
function sh_arg($v): string { return escapeshellarg((string)$v); }
|
||||
Reference in New Issue
Block a user