diff --git a/Plugin/unraid/README-unraid.md b/Plugin/unraid/README-unraid.md index 05b8747..edae6b1 100644 --- a/Plugin/unraid/README-unraid.md +++ b/Plugin/unraid/README-unraid.md @@ -159,6 +159,33 @@ is already covered by a different platform mechanism: dynamix's `BodyInlineJS.ph hidden `csrf_token` input to every form on the page, which is what makes `VaraverkSettings.page` work without any of this. +### Never send a POST as multipart/form-data + +**`FormData` is banned in this plugin.** A `multipart/form-data` POST to the plugin API *hangs +and never completes* on this host — confirmed 2026-08-02. + +The failure is completely silent, which is what makes it dangerous: + +- The browser sends it correctly, valid token and all (verified in the Network tab). +- The Network row shows **no status code at all** — not 403, not 500. It never completes. +- Server side there is nothing: no CSRF termination in syslog, no fatal in `/var/log/phplog`, + and no output from a log statement that is the literal first line of the endpoint. + +Use `URLSearchParams`. `fetch` sets `application/x-www-form-urlencoded` for it automatically, +and it has the same `append()` / `set()` API, so it is a drop-in for any payload of strings: + +```js +const fd = new URLSearchParams(); // NOT new FormData() +fd.append('action', 'save'); +fetch(url, { method: 'POST', body: fd }); +``` + +The correlation across the plugin was exact: every page using `URLSearchParams` worked, every +page using `FormData` hung. All 21 call sites were converted in one pass. Suspected to date from +the Unraid 7.3.1→7.3.2 upgrade; root cause in nginx/php-fpm was never identified, only the +workaround. If a POST ever hangs with no status code and leaves no server-side trace whatsoever, +**suspect the encoding first** — not CSRF, not auth, not the endpoint. + ### api/webhook.php is dead code It receives arr download events — but an arr has no WebGUI session and no CSRF token, so it is diff --git a/Plugin/unraid/pages/arrs.php b/Plugin/unraid/pages/arrs.php index 8acbba0..c1e468b 100644 --- a/Plugin/unraid/pages/arrs.php +++ b/Plugin/unraid/pages/arrs.php @@ -435,7 +435,7 @@ setInterval(vvArrsLoad, 60000); function vvArrsRefreshRemote(host) { const btn = document.getElementById('vv-arr-rfsh-' + host); if (btn) { btn.disabled = true; btn.textContent = '↻…'; } - const fd = new FormData(); + const fd = new URLSearchParams(); fd.append('action', 'refresh_remote'); fd.append('host', host); fetch('/plugins/varaverk/api/arrs.php', { method: 'POST', body: fd }) @@ -452,7 +452,7 @@ function vvArrsRefreshRemote(host) { function vvArrToggle(track, key, file) { const on = !track.classList.contains('on'); track.classList.toggle('on', on); - const fd = new FormData(); + const fd = new URLSearchParams(); fd.append('id', 'arrs'); fd.append('changes', JSON.stringify([{ file, key, value: on ? 'true' : 'false', type: 'scalar' }])); fetch('/plugins/varaverk/api/confform.php', { method: 'POST', body: fd }) @@ -468,7 +468,7 @@ function vvArrSaveAge() { const fb = document.getElementById('vv-arr-age-fb'); if (!val) return; btn.disabled = true; btn.textContent = 'Saving…'; fb.textContent = ''; - const fd = new FormData(); + const fd = new URLSearchParams(); fd.append('id', 'arrs'); fd.append('changes', JSON.stringify([{ file: 'master.conf', key: 'ARR_IMPORT_RECOVERY_AGE', value: val, type: 'scalar' diff --git a/Plugin/unraid/pages/auth.php b/Plugin/unraid/pages/auth.php index 8489fc3..817f9eb 100644 --- a/Plugin/unraid/pages/auth.php +++ b/Plugin/unraid/pages/auth.php @@ -297,7 +297,7 @@ function _get(action, cb) { } function _post(params, cb) { - const fd = new FormData(); + const fd = new URLSearchParams(); for (const [k, v] of Object.entries(params)) fd.append(k, v); fetch(API, { method: 'POST', body: fd }) .then(r => r.json()).then(cb) diff --git a/Plugin/unraid/pages/docker.php b/Plugin/unraid/pages/docker.php index 3e19f30..9acc220 100644 --- a/Plugin/unraid/pages/docker.php +++ b/Plugin/unraid/pages/docker.php @@ -143,7 +143,7 @@ let _popTarget = null; // ── API ─────────────────────────────────────────────────────────────────────── function _api(params, cb) { - const fd = new FormData(); + const fd = new URLSearchParams(); for (const [k,v] of Object.entries(params)) fd.append(k, v); fetch('/plugins/varaverk/api/docker.php', {method:'POST', body:fd}) .then(r => r.json()).then(cb) @@ -310,7 +310,7 @@ function _render(data) { let _activeJobs = {}; // { ctrName: intervalId } function _actApi(params, cb) { - const fd = new FormData(); + const fd = new URLSearchParams(); for (const [k, v] of Object.entries(params)) fd.append(k, v); fetch('/plugins/varaverk/api/docker_action.php', {method: 'POST', body: fd}) .then(r => r.json()).then(cb) diff --git a/Plugin/unraid/pages/fallback.php b/Plugin/unraid/pages/fallback.php index 9768727..cf89c2d 100644 --- a/Plugin/unraid/pages/fallback.php +++ b/Plugin/unraid/pages/fallback.php @@ -398,7 +398,7 @@ function vvFbLoad() { window.vvFbToggle = function(track, key) { const on = !track.classList.contains('on'); track.classList.toggle('on', on); - const fd = new FormData(); + const fd = new URLSearchParams(); fd.append('id', 'fallback'); fd.append('changes', JSON.stringify([{ file: 'master.conf', key, value: on ? 'true' : 'false', type: 'scalar' }])); fetch('/plugins/varaverk/api/confform.php', { method: 'POST', body: fd }) @@ -423,7 +423,7 @@ window.vvFbSaveSettings = function() { } btn.disabled = true; btn.textContent = 'Saving…'; fb.textContent = ''; - const fd = new FormData(); + const fd = new URLSearchParams(); fd.append('id', 'fallback'); fd.append('changes', JSON.stringify([ { file: 'master.conf', key: 'FALLBACK_CHECK_INTERVAL', value: String(interval), type: 'scalar' }, diff --git a/Plugin/unraid/pages/monitor.php b/Plugin/unraid/pages/monitor.php index 8e77234..c48f576 100644 --- a/Plugin/unraid/pages/monitor.php +++ b/Plugin/unraid/pages/monitor.php @@ -612,7 +612,7 @@ function vvWdRsyncToggle(el) { el.style.color = on ? '#4caf50' : '#333'; el.style.background = on ? '#0f1a0f' : '#111'; el.style.borderColor = on ? '#1a3a1a' : '#222'; - const fd = new FormData(); + const fd = new URLSearchParams(); fd.append('name', flag); fd.append('enabled', on ? '1' : '0'); fetch('/plugins/varaverk/api/flag_toggle.php', { method: 'POST', body: fd }) @@ -2229,7 +2229,7 @@ function vvDockerAction(action, name, webui) { encodeURIComponent('/boot/config/plugins/dockerMan/templates-user/my-' + name + '.xml') + '&update=true'; return; } - const fd = new FormData(); + const fd = new URLSearchParams(); fd.set('action', action); fd.set('name', name); fetch('/plugins/varaverk/api/docker_action.php', { method: 'POST', body: fd }) diff --git a/Plugin/unraid/pages/rsync.php b/Plugin/unraid/pages/rsync.php index 4fea997..a4d88e2 100644 --- a/Plugin/unraid/pages/rsync.php +++ b/Plugin/unraid/pages/rsync.php @@ -1004,7 +1004,7 @@ function vvRyWinSave(key) { if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; } if (fb) fb.textContent = ''; - const fd = new FormData(); + const fd = new URLSearchParams(); fd.append('action', 'save'); fd.append('win_key', key); fd.append('scripts', JSON.stringify(state.scripts)); @@ -1247,7 +1247,7 @@ function _vvRyLoadProfiles(key) { function vvRyToggle(trackEl, name) { const on = !trackEl.classList.contains('on'); trackEl.classList.toggle('on', on); - const fd = new FormData(); + const fd = new URLSearchParams(); fd.append('name', name); fd.append('enabled', on ? '1' : '0'); fetch('/plugins/varaverk/api/flag_toggle.php', { method: 'POST', body: fd }) @@ -1269,7 +1269,7 @@ function vvRySaveDefaults() { btn.disabled = true; btn.textContent = 'Saving…'; fb.textContent = ''; - const fd = new FormData(); + const fd = new URLSearchParams(); fd.append('id', 'rsync'); fd.append('changes', JSON.stringify(changes)); fetch('/plugins/varaverk/api/confform.php', { method: 'POST', body: fd }) @@ -1441,7 +1441,7 @@ function vvRpSave() { const btn = document.querySelector('#vv-rp-card .vv-ry-save-btn'); btn.disabled = true; btn.textContent = 'Saving…'; fb.textContent = ''; - const fd = new FormData(); + const fd = new URLSearchParams(); fd.append('action', 'save'); fd.append('name', name); fd.append('rsync_opts', document.getElementById('vv-rp-rsync_opts')?.value?.trim() || ''); @@ -1478,7 +1478,7 @@ function vvRpDelete() { const del = document.getElementById('vv-rp-del-btn'); del.disabled = true; fb.textContent = ''; - const fd = new FormData(); + const fd = new URLSearchParams(); fd.append('action', 'delete'); fd.append('name', name); @@ -1521,7 +1521,7 @@ function vvMsStop() { if (!token) return; const btn = document.getElementById('vv-ms-stop-btn'); btn.textContent = '⟳ Stopping…'; btn.disabled = true; - const fd = new FormData(); + const fd = new URLSearchParams(); fd.append('action', 'stop'); fd.append('token', token); fetch('/plugins/varaverk/api/manual_sync.php', { method: 'POST', body: fd }) @@ -1768,7 +1768,7 @@ function vvMsRun() { badge.style.color = '#4a9eff'; badge.style.border = '1px solid #1a3a5a'; - const fd = new FormData(); + const fd = new URLSearchParams(); fd.append('action', 'run'); fd.append('local', local); fd.append('host', slot); diff --git a/Plugin/unraid/pages/settings.php b/Plugin/unraid/pages/settings.php index 3d1f47f..07a49c1 100644 --- a/Plugin/unraid/pages/settings.php +++ b/Plugin/unraid/pages/settings.php @@ -286,7 +286,7 @@ function vvStorMigrate() { if (fb) fb.textContent = ''; if (out) { out.textContent = 'Starting migration…\n'; out.style.display = ''; } - const fd = new FormData(); + const fd = new URLSearchParams(); fd.append('action', 'migrate'); fd.append('to', to); @@ -323,7 +323,7 @@ function vvNtfToggle(track, key) { track.classList.toggle('on', on); const file = (key === 'NOTIFY_UNRAID' || key === 'ENABLE_LOGGING') ? 'master.conf' : '.conf'; - const fd = new FormData(); + const fd = new URLSearchParams(); fd.append('id', 'settings'); fd.append('changes', JSON.stringify([{ file, key, value: on ? 'true' : 'false', type: 'scalar' }])); fetch('/plugins/varaverk/api/confform.php', { method: 'POST', body: fd }) @@ -337,7 +337,7 @@ function vvNtfSaveWebhook() { const fb = document.getElementById('vv-ntf-fb'); const btn = document.getElementById('vv-ntf-save'); btn.disabled = true; btn.textContent = 'Saving…'; fb.textContent = ''; - const fd = new FormData(); + const fd = new URLSearchParams(); fd.append('id', 'settings'); fd.append('changes', JSON.stringify([{ file: '.conf', @@ -400,7 +400,7 @@ function vvApiRenew() { btn.disabled = true; btn.textContent = 'Renewing…'; fb.textContent = ''; out.textContent = ''; out.style.display = ''; - const fd = new FormData(); + const fd = new URLSearchParams(); fd.append('action', 'setup_apikeys'); fetch('/plugins/varaverk/api/storage.php', { method: 'POST', body: fd }) .then(r => r.json())