Unraid already enforces CSRF on every POST via auto_prepend, but its injector is jQuery-only — the plugin's native fetch() calls carried no token and were being terminated before the endpoint ran, silently, because csrf_terminate exits with an empty body that r.json() swallows.
129 lines
6.8 KiB
PHP
129 lines
6.8 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// Arr data endpoint. Serves the arrs tab: local Sonarr/Radarr/Lidarr statistics gathered
|
|
// live, partner statistics served from the background cache, plus an on-demand refresh of
|
|
// one partner's cache.
|
|
//
|
|
// OPERATIONAL MODEL
|
|
// Two modes on one URL. Without parameters it answers from a 300s cache and only builds a
|
|
// fresh payload on a miss. With ?action=refresh_remote&host=hostN it shells out to
|
|
// Tools/remote_arr_cache_writer.sh for that one host, returns the newly written node, and
|
|
// busts the main cache so the next ordinary poll picks the change up.
|
|
//
|
|
// DESIGN PRINCIPLES
|
|
// The refresh branch runs before the cache read, and exits.
|
|
// It is a distinct operation, not a cache-control flag, so it never falls through into
|
|
// the normal load path and cannot return a stale payload labelled as refreshed.
|
|
//
|
|
// Cache first for the ordinary case.
|
|
// vv_arrs_all() contacts every configured arr instance. At the tab's poll rate that is
|
|
// far too expensive to repeat, so the 300s cache is the default path and the live build
|
|
// is the exception.
|
|
//
|
|
// include/arrs.php is required only on a cache miss.
|
|
// A cache hit answers without loading the library at all, which is the difference
|
|
// between a poll that costs a file read and one that costs an autoload.
|
|
//
|
|
// OPERATIONAL SAFEGUARDS
|
|
// The host parameter is matched against a pattern, never used as a path.
|
|
// ^host\d+$ is enforced before the value goes anywhere. It reaches the script only as
|
|
// an escapeshellarg'd --host= value and the cache filename it composes, so neither a
|
|
// shell metacharacter nor a traversal sequence can survive the check.
|
|
//
|
|
// The refresh is externally time-boxed.
|
|
// set_time_limit() does not count time spent inside exec() on Linux, so PHP's own limit
|
|
// cannot end a hung SSH call — the child is wrapped in `timeout` instead. Exit 124 is
|
|
// reported as a timeout rather than a generic failure, so the UI can distinguish an
|
|
// unreachable partner from a broken script.
|
|
//
|
|
// A missing script is reported, not executed.
|
|
// The file_exists() check runs before exec(), so a partial deploy returns a named error
|
|
// rather than a shell "command not found" surfacing as an empty refresh.
|
|
//
|
|
// The cache is busted after the write, not before.
|
|
// @unlink() of arrs.json follows the script run, so a failed refresh leaves the previous
|
|
// good payload in place instead of forcing every subsequent poll onto the live path.
|
|
//
|
|
// Read-only with respect to the arrs themselves. Statistics come from the databases the
|
|
// scripts write; nothing here triggers a scan, cleanup, or import.
|
|
//
|
|
// REQUEST
|
|
// GET cached (300s) full payload
|
|
// POST action=refresh_remote host=host<n> re-run the partner cache writer for one host
|
|
// (POST, so Unraid's CSRF guard applies)
|
|
//
|
|
// RESPONSE
|
|
// normal vv_arrs_all() verbatim — local node live, remote nodes cached with cache_age
|
|
// refresh {"ok":bool,"node":object|null,"output":string}
|
|
// errors {"ok":false,"error":string} — invalid host, missing script, or timeout
|
|
//
|
|
// DEPENDS ON
|
|
// include/config.php vv_cache_read(), VV_CACHE_DIR
|
|
// include/arrs.php vv_arrs_all() (loaded only on a cache miss)
|
|
// Tools/remote_arr_cache_writer.sh (refresh branch only)
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
header('Content-Type: application/json');
|
|
require_once dirname(__DIR__) . '/include/config.php';
|
|
|
|
// Bound on the partner refresh child process. set_time_limit() does not cover exec() time
|
|
// on Linux, so this is enforced by `timeout`, not by PHP.
|
|
define('VV_ARRS_REFRESH_TIMEOUT', 120);
|
|
|
|
// ── Manual remote refresh — runs remote_arr_cache_writer for one host ─────────
|
|
// POST only. The refresh executes a script, and Unraid's CSRF prepend validates POSTs while
|
|
// ignoring GETs entirely — so reaching this over GET would mean running it with no token
|
|
// check. A GET naming the action is refused rather than falling through to the cached read,
|
|
// so a stale caller fails visibly instead of silently appearing to succeed.
|
|
if (trim($_GET['action'] ?? '') === 'refresh_remote') {
|
|
http_response_code(405);
|
|
echo json_encode(['ok' => false, 'error' => 'POST only']); exit;
|
|
}
|
|
$_action = ($_SERVER['REQUEST_METHOD'] === 'POST') ? trim($_POST['action'] ?? '') : '';
|
|
if ($_action === 'refresh_remote') {
|
|
$host = strtolower(trim($_POST['host'] ?? ''));
|
|
if (!preg_match('/^host\d+$/', $host)) {
|
|
echo json_encode(['ok' => false, 'error' => 'Invalid host']); exit;
|
|
}
|
|
$script = dirname(__DIR__) . '/Tools/remote_arr_cache_writer.sh';
|
|
if (!file_exists($script)) {
|
|
echo json_encode(['ok' => false, 'error' => 'remote_arr_cache_writer.sh not found']); exit;
|
|
}
|
|
set_time_limit(VV_ARRS_REFRESH_TIMEOUT + 30);
|
|
$out = []; $exit = 0;
|
|
exec('timeout ' . VV_ARRS_REFRESH_TIMEOUT . ' bash ' . escapeshellarg($script)
|
|
. ' --host=' . escapeshellarg(strtoupper($host)) . ' 2>&1', $out, $exit);
|
|
|
|
if ($exit === 124) {
|
|
echo json_encode([
|
|
'ok' => false,
|
|
'error' => 'Refresh timed out after ' . VV_ARRS_REFRESH_TIMEOUT . 's',
|
|
'output' => implode("\n", $out),
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
$cacheFile = VV_CACHE_DIR . '/arrs_remote_' . $host . '.json';
|
|
$node = null;
|
|
if (file_exists($cacheFile)) {
|
|
$node = json_decode(file_get_contents($cacheFile), true) ?: null;
|
|
if ($node) {
|
|
$node['cached'] = true;
|
|
$node['cache_age'] = time() - (int)filemtime($cacheFile);
|
|
}
|
|
}
|
|
// Bust the main arrs cache so next poll gets fresh data
|
|
@unlink(VV_CACHE_DIR . '/arrs.json');
|
|
echo json_encode(['ok' => $exit === 0, 'node' => $node, 'output' => implode("\n", $out)]);
|
|
exit;
|
|
}
|
|
unset($_action);
|
|
|
|
// ── Normal data load ──────────────────────────────────────────────────────────
|
|
$_vv_cached = vv_cache_read('arrs', 300);
|
|
if ($_vv_cached !== null) { echo json_encode($_vv_cached); exit; }
|
|
unset($_vv_cached);
|
|
|
|
require_once dirname(__DIR__) . '/include/arrs.php';
|
|
echo json_encode(vv_arrs_all());
|