Files
Varaverk/Plugin/unraid/Varaverk.page
T
Gmer4Lfe e4423200cc Report failures where the operator can still see them
The 55 alert() calls carried the same suppression as the confirms, and go wrong in the worse
direction: a silenced confirm makes a button do nothing, while a silenced alert lets the action
run and says nothing about it failing. vvAlert returns a promise nobody has to await, so these
converted by rename with no caller becoming async. The icon is inferred from the message rather
than asked of fifty call sites, and an explicit type still wins.
2026-08-09 21:47:23 -04:00

280 lines
15 KiB
Plaintext

Menu="Tasks:95"
Title="Varaverk"
Icon="varaverk.png"
---
<?php
$plugin = 'varaverk';
$docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
$pluginDir = "$docroot/plugins/$plugin";
require_once "$pluginDir/include/config.php";
?>
<script>
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// CSRF token propagation — must run before any page JS.
//
// Unraid enforces CSRF centrally: /etc/php.ini sets auto_prepend_file to webGui's
// local_prepend.php, which terminates *every* POST that does not carry a valid token, before a
// single line of endpoint code runs. It accepts the token as a `csrf_token` POST field or as an
// X-CSRF-Token header.
//
// The webGUI's own injector is jQuery-only ($.ajaxPrefilter in HeadInlineJS.php). Varaverk's
// pages use native fetch(), which that prefilter does not touch — so without this shim every
// mutating request in the plugin is silently killed by the platform. Silently, because
// csrf_terminate() exits with no body: the fetch resolves, r.json() throws on the empty
// response, and the page's own .catch() swallows it.
//
// Setting the header rather than appending a body field is deliberate. It works identically for
// FormData, URLSearchParams and raw JSON bodies, so no call site has to know about it and a new
// endpoint cannot forget to include it. The header is only ever attached to same-origin
// Varaverk URLs; a cross-origin page cannot set a custom header without a preflight it will
// fail, which is precisely what makes this a CSRF defence rather than a formality.
//
// Inline, and above the tab content, because pages/*.php carry their own inline fetch calls and
// some fire on load — an external script could not be guaranteed to install first.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
(function () {
if (window.__vvCsrfInstalled) return;
window.__vvCsrfInstalled = true;
var nativeFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
var url = (typeof input === 'string') ? input : (input && input.url) || '';
if (url.indexOf('/plugins/varaverk/') !== -1 &&
typeof csrf_token !== 'undefined' && csrf_token) {
init = init || {};
var headers = new Headers(init.headers || (typeof input === 'object' && input.headers) || {});
if (!headers.has('X-CSRF-Token')) headers.set('X-CSRF-Token', csrf_token);
init = Object.assign({}, init, { headers: headers });
}
return nativeFetch(input, init);
};
})();
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// Escaping helpers — shared, because every page builds HTML strings and assigns them to innerHTML.
//
// Defined here rather than per page for the reason the CSRF shim is: only one pages/*.php is ever
// included per request, so a helper defined inside one page does not exist for any other. That is
// not a hypothetical — these lived in pages/scheduler.php, and pages/monitor.php rendered media
// titles, partner hostnames and docker folder names straight into innerHTML with no escaping
// available to it at all.
//
// Not in js/varaverk.js, which would otherwise be the obvious home: that file is loaded by a
// src-tag *below* the tab include, so it is not defined yet while a page's inline script is
// running. The shared formatters there survive only because every caller is inside a fetch
// callback. An escaping helper must be callable from the first synchronous line of a page.
//
// Never write a literal script tag inside this block, not even in a comment. The tab body is
// injected into the page by the webGUI rather than parsed as part of the document, and the
// opening sequence ends this element early wherever it appears — the whole file then renders as
// visible text and every tab loads dead. That is not theoretical: the comment above said it with
// real angle brackets and took the monitor tab down on 2026-08-07.
//
// String() rather than assuming a string: these are fed payload fields that are frequently
// numbers, and sometimes null or undefined. A helper that throws on a number is a helper call
// sites will skip.
//
// Two functions because the contexts differ, and using the wrong one is silent:
// vvEscHtml text between tags. Leaves " alone — harmless there.
// vvEscAttr text inside an attribute. Escapes " as well, because a quote inside a
// double-quoted attribute ends it early and destroys the rest of the handler.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
function vvEscHtml(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function vvEscAttr(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/"/g,'&quot;')
.replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// A URL about to be put in href/src or handed to window.open. Anything that is not plainly http,
// https or a site-relative path becomes empty — javascript: is the one that matters, and an
// allowlist is the only way to say that without chasing encodings. include/docs.php applies the
// same rule to markdown links; container WebUI values, which come from template XML, had no such
// check before reaching window.open().
function vvSafeUrl(u) {
const s = String(u ?? '').trim();
if (s === '') return '';
if (/^https?:\/\//i.test(s)) return s;
if (/^\/(?!\/)/.test(s)) return s;
return '';
}
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// DIALOGS — never the browser's own
//
// confirm(), alert() and prompt() all carry a checkbox inside the dialog reading "prevent this
// page from creating additional dialogs". The moment it is ticked, every later call from that
// document returns false and draws nothing. Guards written as `if (!confirm(x)) return;` then
// decline silently and the button reads as dead — and because the webGUI swaps tabs by AJAX
// without tearing down the document, the suppression follows the operator across tabs and
// survives until a full reload. That happened on 2026-08-09: dismissing several repair findings
// in a row produced the checkbox, and the AI tab's buttons went dead until history was cleared.
//
// swal is Unraid's own dialog, defined in webGui/javascript/dynamix.js and loaded on every page
// by DefaultPageLayout — core calls it around 370 times and uses confirm() exactly never. So
// this is not a new dependency, and these dialogs look like the rest of the machine.
//
// THE TRAP, and why the callback below is not an arrow function:
// On cancel, SweetAlert 1.x only invokes the callback if the callback's own source declares a
// parameter — it literally does String(fn).replace(/\s/g,'') and checks that it starts with
// "function(" and that the next character is not ")". An arrow function stringifies as "ok=>…"
// and fails that test, so cancel would never resolve this promise and the awaiting caller
// would hang forever with no dialog on screen. Which is the exact bug this file is replacing,
// wearing a different hat. Classic function expression, one named parameter, deliberately.
function vvConfirm(text, opts) {
const o = opts || {};
return new Promise(function (resolve) {
// Without swal there is nothing better than the browser's own dialog. It carries the bug
// described above, but the alternative is a guard that answers neither yes nor no.
if (typeof swal !== 'function') { resolve(window.confirm(text)); return; }
swal({
title: o.title || 'Are you sure?',
text: String(text ?? ''),
type: o.type || 'warning',
showCancelButton: true,
confirmButtonText: o.confirmText || 'Yes',
cancelButtonText: o.cancelText || 'Cancel',
confirmButtonColor: o.danger === false ? '#3085d6' : '#d9534f',
closeOnConfirm: true,
}, function (ok) { resolve(ok !== false); });
});
}
// Telling the operator something, with no question attached. Returns a promise so a caller can
// sequence on it, but nothing has to await it — which is what let the fifty-odd alert() sites
// become this by rename alone, with no function above them turning async.
//
// The icon is read off the message when the caller does not say. Almost every one of these
// reports a failure — "Save failed: …", "Error: …" — and asking fifty call sites to each classify
// themselves would mean fifty chances to disagree about what counts as an error. The caller can
// still pass type explicitly and that always wins.
function vvAlertType(text) {
const s = String(text ?? '');
if (/\b(fail|failed|error|denied|invalid|refused|cannot|could not|unable)\b/i.test(s)) return 'error';
if (/\b(warn|warning|already|must be)\b/i.test(s)) return 'warning';
return 'info';
}
function vvAlert(text, opts) {
const o = opts || {};
return new Promise(function (resolve) {
if (typeof swal !== 'function') { window.alert(text); resolve(true); return; }
swal({
title: o.title || '',
text: String(text ?? ''),
type: o.type || vvAlertType(text),
confirmButtonText: o.confirmText || 'OK',
}, function (ok) { resolve(true); });
});
}
// Asking for a value. Resolves to the string, or null when cancelled — prompt()'s own contract,
// so call sites keep reading the same way. The empty string is a real answer and is not null.
function vvPrompt(text, def, opts) {
const o = opts || {};
return new Promise(function (resolve) {
if (typeof swal !== 'function') { resolve(window.prompt(text, def || '')); return; }
swal({
title: o.title || '',
text: String(text ?? ''),
type: 'input',
inputValue: def || '',
inputPlaceholder: o.placeholder || '',
showCancelButton: true,
confirmButtonText: o.confirmText || 'OK',
cancelButtonText: o.cancelText || 'Cancel',
closeOnConfirm: true,
}, function (val) { resolve(val === false ? null : String(val)); });
});
}
</script>
<?php
// First-run check — show setup wizard if HOST1 is blank OR local host.conf is missing
$_master = vv_read_conf_raw('master.conf');
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
$_host1_blank = empty(trim($_h1m[1] ?? ''));
$_my_hostid = vv_detect_host();
$_conf_missing = $_my_hostid !== 'unknown'
&& !file_exists(CONF_DIR . '/' . $_my_hostid . '.conf');
if ($_host1_blank || $_conf_missing) {
include "$pluginDir/pages/setup.php";
return;
}
unset($_master, $_h1m, $_host1_blank, $_my_hostid, $_conf_missing);
// Determine active tab
$tab = $_GET['tab'] ?? 'monitor';
$validTabs = ['monitor', 'scheduler', 'docker', 'watchdog', 'partnership', 'fallback', 'arrs', 'rsync', 'auth', 'settings'];
// The AI tab exists only on HOST1, and only while AI_ENABLED is true. Appended to $validTabs
// rather than filtered out of it, so the check below rejects ?tab=ai server-side as well —
// omitting the link is presentation, not access control, and api/ai.php refuses every action
// on the same two conditions independently.
//
// The host half is not a preference: HOST1 owns the GPU, the Ollama process and the index, and
// include/ai.php only ever reads the *local* {HOST}_OLLAMA_URL — there is no Tailscale resolver
// in the PHP layer the way there is in the shell. On any other host the tab could only render
// and then fail its own health check.
$_vv_ai = vv_ai_ui_on();
if ($_vv_ai) $validTabs[] = 'ai';
if (!in_array($tab, $validTabs)) $tab = 'monitor';
$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'docker' => 'Docker', 'watchdog' => 'Watchdog', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'arrs' => 'Arrs', 'rsync' => 'Rsync', 'auth' => 'Auth Stack', 'settings' => 'Settings', 'ai' => 'AI'];
// Cache stamp for the stylesheet and script below. Both are served straight off the plugin
// directory at a path that never changes, so a browser holding an old copy keeps using it after
// a git pull. This page's own markup is generated fresh every request, so the two fall out of
// step: markup from the new commit rendering against a stylesheet from the old one. That is not
// a theoretical failure — it broke the Monitor AI row on 2026-08-09, when placement moved from
// inline spans into the stylesheet and only half of that arrived in the browser.
//
// mtime rather than a hand-bumped version: it changes on exactly the event that matters, a pull
// rewriting the file, and cannot be forgotten. Falling back to time() when the stat fails errs
// toward re-fetching rather than toward the stale copy that caused the problem.
$_vv_asset_rev = [];
foreach (['css/varaverk.css', 'js/varaverk.js'] as $_vv_a) {
$_vv_asset_rev[$_vv_a] = @filemtime(__DIR__ . '/' . $_vv_a) ?: time();
}
?>
<link rel="stylesheet" href="/plugins/<?=$plugin?>/css/varaverk.css?v=<?=$_vv_asset_rev['css/varaverk.css']?>">
<div id="varaverk-wrap" class="unapi">
<!-- Tab bar -->
<div id="vv-tabs">
<?php foreach ($validTabs as $t): ?>
<a href="?tab=<?=$t?>" class="localURL vv-tab<?= $t === $tab ? ' active' : '' ?>">
<?= $tabLabels[$t] ?? ucfirst($t) ?>
</a>
<?php endforeach; ?>
<div style="margin-left:auto;display:flex;align-items:center;gap:2px;">
<a href="https://github.com/FailedProxy/Varaverk" target="_blank"
style="padding:0 10px;font-size:10px;color:#333;text-decoration:none;
display:flex;align-items:center;letter-spacing:.03em;"
title="GitHub — source, issues, changelog">
⎋ GitHub
</a>
<button id="vv-expand-btn" type="button" onclick="vvToggleExpand()" title="Expand">⤢</button>
</div>
</div>
<!-- Tab content -->
<div id="vv-content">
<?php
$page = "$pluginDir/pages/$tab.php";
if (file_exists($page)) include $page;
else echo "<p>Page not found: $tab</p>";
?>
</div>
</div>
<script src="/plugins/<?=$plugin?>/js/varaverk.js?v=<?=$_vv_asset_rev['js/varaverk.js']?>"></script>