Files
Gmer4Lfe c34224effa Carry the CSRF token on fetch requests and put mutations behind POST
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.
2026-08-02 10:28:53 -04:00

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());