]> ### 2026.09.27 - Built from source. [filename => diskPath]. */ private static array $dirCache = []; /** * Resolve every entry directly inside a fused directory to its physical * disk path in one pass: scan each disk's copy of the directory once * (readdir), instead of probing every disk with file_exists() for each * file individually. On Unraid this also avoids waking a spun-down disk * once per file that isn't even on it. * * First disk found wins, matching the previous first-hit semantics of * toDisk(). Result is cached per directory for the life of the request, * since callers (e.g. Browse.php's per-entry lookup, or a recursive * Toggle.php walk) commonly resolve many files from the same directory. */ public static function resolveDir(string $fusedDir, string $mnt = '/mnt'): array { $fusedDir = self::clean($fusedDir); if (isset(self::$dirCache[$mnt][$fusedDir])) return self::$dirCache[$mnt][$fusedDir]; $map = []; if (preg_match('#^/mnt/user0?/(.*)$#', $fusedDir, $m)) { $rel = $m[1]; foreach (self::diskRoots($mnt) as $root) { $cand = $rel === '' ? $root : $root . '/' . $rel; if (!is_dir($cand)) continue; $dh = @opendir($cand); if ($dh === false) continue; while (($name = readdir($dh)) !== false) { if ($name === '.' || $name === '..') continue; if (!isset($map[$name])) $map[$name] = $cand . '/' . $name; } closedir($dh); } } return self::$dirCache[$mnt][$fusedDir] = $map; } /** * Convert a fused path to the physical disk path that actually holds it, * via the directory-level cache above. Returns false if not found. * * If the path isn't a /mnt/user(0) path it's assumed to already be physical * and is returned unchanged. */ public static function toDisk(string $fused, string $mnt = '/mnt') { $fused = self::clean($fused); if (!preg_match('#^/mnt/user0?/(.+)$#', $fused)) { return $fused; // not a user-share path; treat as already physical } $map = self::resolveDir(dirname($fused), $mnt); return $map[basename($fused)] ?? false; } /** * Normalise a path: force leading slash, collapse '.' and '..', strip * duplicate slashes. Does NOT require the path to exist (unlike realpath), * so it is safe to run on user input before touching the filesystem. */ public static function clean(string $path): string { $parts = []; foreach (explode('/', $path) as $seg) { if ($seg === '' || $seg === '.') continue; if ($seg === '..') { array_pop($parts); continue; } $parts[] = $seg; } return '/' . implode('/', $parts); } /** * Guard against directory traversal: confirm $path resolves to somewhere * at or below $base (both fused paths). Returns the cleaned path, or false * if it escapes the sandbox. */ public static function within(string $path, string $base) { $path = self::clean($path); $base = self::clean($base); if ($path === $base) return $path; if (strpos($path, rtrim($base, '/') . '/') === 0) return $path; return false; } /** Max paths per lsattr/chattr subprocess call, well under a typical ARG_MAX. */ const BATCH_SIZE = 200; /** * Read the immutable flag of a physical path via lsattr. * Returns true (locked), false (unlocked), or null (couldn't determine, * e.g. filesystem doesn't support attributes). */ public static function isImmutable(string $diskPath) { return self::isImmutableBatch([$diskPath])[$diskPath] ?? null; } /** * Read the immutable flag of many physical paths at once: a handful of * `lsattr` subprocesses (chunked to stay under ARG_MAX) instead of one * per path, which matters once a directory has hundreds of files. * * Returns [diskPath => true|false|null], null meaning the filesystem * couldn't report attributes for that path (lsattr omits it from output * rather than failing the whole batch). */ public static function isImmutableBatch(array $diskPaths): array { $status = []; foreach (array_unique($diskPaths) as $p) $status[$p] = null; if (!$status) return $status; foreach (array_chunk(array_keys($status), self::BATCH_SIZE) as $chunk) { $args = implode(' ', array_map('escapeshellarg', $chunk)); $out = []; $rc = 0; exec("lsattr -d -- $args 2>/dev/null", $out, $rc); foreach ($out as $line) { $parsed = self::parseLsattrLine($line); if ($parsed === null) continue; [$path, $immutable] = $parsed; $status[$path] = $immutable; } } return $status; } /** * Parse one line of `lsattr -d` output, e.g. * "----i---------e----- /mnt/disk1/share/file.jpg". * Returns [diskPath, immutable] or null if the line doesn't match the * expected shape (kept separate from isImmutableBatch so it's testable * without actually shelling out to lsattr). */ public static function parseLsattrLine(string $line): ?array { if (!preg_match('/^(\S+)\s+(.*)$/', $line, $m)) return null; return [$m[2], strpos($m[1], 'i') !== false]; } } ]]> $cfg['BASEDIR'] ?? '/mnt/user/media/video', ]; } // CSRF is validated globally by Unraid's webGUI before any plugin endpoint // runs, and the validated token is then stripped from $_POST. Endpoints // therefore do NOT re-check it (doing so fails, since the token is already // gone). The browser must still SEND csrf_token so the request passes // Unraid's gate in the first place. function filelock_json($data): void { header('Content-Type: application/json'); echo json_encode($data); exit; } ]]> 'Path is outside the base directory']); if (is_link($path)) filelock_json(['error' => 'Symlinks are not followed']); if (!is_dir($path)) filelock_json(['error' => 'Not a directory: ' . $path]); $dh = @opendir($path); if ($dh === false) filelock_json(['error' => 'Cannot open directory']); $entries = []; $disks = []; // fused file path => disk path, resolved after the listing loop while (($name = readdir($dh)) !== false) { if ($name === '.' || $name === '..') continue; $full = rtrim($path, '/') . '/' . $name; // Symlinks aren't listed at all: a symlink under the base directory could // point outside it, and both navigating into it and locking through it // (chattr follows a symlink to its target) would escape the sandbox. if (is_link($full)) continue; $isDir = is_dir($full); $entries[] = ['name' => $name, 'path' => $full, 'dir' => $isDir, 'locked' => null]; if (!$isDir) { $disk = PathResolver::toDisk($full); if ($disk !== false) $disks[$full] = $disk; } } closedir($dh); // One batched lsattr call for every file's status instead of one per file. $status = PathResolver::isImmutableBatch(array_values($disks)); foreach ($entries as &$e) { if (isset($disks[$e['path']])) $e['locked'] = $status[$disks[$e['path']]] ?? null; } unset($e); // Folders first, then files, each alphabetical (case-insensitive). usort($entries, function ($a, $b) { if ($a['dir'] !== $b['dir']) return $a['dir'] ? -1 : 1; return strcasecmp($a['name'], $b['name']); }); filelock_json([ 'base' => $base, 'path' => $path, 'parent' => ($path === PathResolver::clean($base)) ? null : dirname($path), 'entries' => $entries, ]); ]]> 'Bad request']); } $flag = $action === 'lock' ? '+i' : '-i'; $results = []; $jobs = []; // fused file path => disk path, resolved files waiting for chattr /** Resolve a single fused file path to its disk path, or record why it can't be done. */ $prepareFile = function (string $fused) use (&$results, &$jobs, $base) { $safe = PathResolver::within($fused, $base); if ($safe === false) { $results[] = ['path' => $fused, 'ok' => false, 'msg' => 'outside base directory']; return; } $disk = PathResolver::toDisk($safe); if ($disk === false || !is_file($disk)) { $results[] = ['path' => $fused, 'ok' => false, 'msg' => 'file not found on any disk']; return; } $jobs[$fused] = $disk; }; foreach ($paths as $p) { $safe = PathResolver::within($p, $base); if ($safe === false) { $results[] = ['path' => $p, 'ok' => false, 'msg' => 'outside base directory']; continue; } // chattr opens its target through a symlink, so locking a symlink would // actually flag whatever it points at — possibly outside the sandbox. if (is_link($safe)) { $results[] = ['path' => $p, 'ok' => false, 'msg' => 'symlinks are not followed']; continue; } if (is_dir($safe)) { // Recurse into the directory and flag every regular file. The filter // excludes symlinks from both the walk and recursion, so a symlinked // subdirectory can't be used to walk outside the sandbox and a // symlinked file can't be used to chattr its target. $dirIter = new RecursiveDirectoryIterator($safe, FilesystemIterator::SKIP_DOTS); $filter = new RecursiveCallbackFilterIterator($dirIter, fn($cur) => !$cur->isLink()); $it = new RecursiveIteratorIterator($filter); foreach ($it as $f) { if ($f->isFile()) $prepareFile($f->getPathname()); } } else { $prepareFile($safe); } } // Apply the flag in batches -- a handful of chattr subprocesses instead of // one per file. If a batch as a whole fails (e.g. one bad path in it), fall // back to running that batch's members individually so one bad file doesn't // mark the rest as failed too. foreach (array_chunk($jobs, PathResolver::BATCH_SIZE, true) as $chunk) { $args = implode(' ', array_map('escapeshellarg', $chunk)); $out = []; $rc = 0; exec("chattr $flag -- $args 2>&1", $out, $rc); if ($rc === 0) { foreach ($chunk as $fused => $disk) { $results[] = ['path' => $fused, 'ok' => true, 'msg' => '']; } continue; } foreach ($chunk as $fused => $disk) { $out2 = []; $rc2 = 0; exec('chattr ' . $flag . ' -- ' . escapeshellarg($disk) . ' 2>&1', $out2, $rc2); $results[] = ['path' => $fused, 'ok' => ($rc2 === 0), 'msg' => $rc2 === 0 ? '' : implode(' ', $out2)]; } } filelock_json(['action' => $action, 'results' => $results]); ]]> 'Bad request']); } $results = []; $jobs = []; // fused file path => disk path, resolved files waiting for unlock+delete /** Resolve a single fused file path to its disk path, or record why it can't be done. */ $prepareFile = function (string $fused) use (&$results, &$jobs, $base) { $safe = PathResolver::within($fused, $base); if ($safe === false) { $results[] = ['path' => $fused, 'ok' => false, 'msg' => 'outside base directory']; return; } $disk = PathResolver::toDisk($safe); if ($disk === false || !is_file($disk)) { $results[] = ['path' => $fused, 'ok' => false, 'msg' => 'file not found on any disk']; return; } $jobs[$fused] = $disk; }; foreach ($paths as $p) { $safe = PathResolver::within($p, $base); if ($safe === false) { $results[] = ['path' => $p, 'ok' => false, 'msg' => 'outside base directory']; continue; } // Same symlink guard as Toggle.php: chattr/unlink would act on whatever // the symlink points at, possibly outside the sandbox. if (is_link($safe)) { $results[] = ['path' => $p, 'ok' => false, 'msg' => 'symlinks are not followed']; continue; } if (is_dir($safe)) { $dirIter = new RecursiveDirectoryIterator($safe, FilesystemIterator::SKIP_DOTS); $filter = new RecursiveCallbackFilterIterator($dirIter, fn($cur) => !$cur->isLink()); $it = new RecursiveIteratorIterator($filter); foreach ($it as $f) { if ($f->isFile()) $prepareFile($f->getPathname()); } } else { $prepareFile($safe); } } // Unlock every job first, in batches (same pattern as Toggle.php): an // immutable file can't be unlinked, and batching keeps this fast when // deleting many files at once. If a batch as a whole fails (e.g. one bad // path in it), fall back to unlocking that batch's members individually so // one bad file doesn't leave the rest immutable and un-deletable. foreach (array_chunk($jobs, PathResolver::BATCH_SIZE, true) as $chunk) { $args = implode(' ', array_map('escapeshellarg', $chunk)); $out = []; $rc = 0; exec("chattr -i -- $args 2>&1", $out, $rc); if ($rc !== 0) { foreach ($chunk as $disk) { $out2 = []; $rc2 = 0; exec('chattr -i -- ' . escapeshellarg($disk) . ' 2>&1', $out2, $rc2); } } } // Delete. unlink()'s own success/failure is the source of truth here — no // need to branch on the chattr result above, since a file that was never // immutable (or lives on a filesystem chattr doesn't support) still deletes // normally, and one that's still stuck immutable will simply fail here. foreach ($jobs as $fused => $disk) { if (@unlink($disk)) { $results[] = ['path' => $fused, 'ok' => true, 'msg' => '']; } else { $results[] = ['path' => $fused, 'ok' => false, 'msg' => 'delete failed']; } } filelock_json(['results' => $results]); ]]> 'Empty search query']); $path = $_POST['path'] ?? $base; $path = PathResolver::within($path, $base); if ($path === false) filelock_json(['error' => 'Path is outside the base directory']); if (is_link($path)) filelock_json(['error' => 'Symlinks are not followed']); if (!is_dir($path)) filelock_json(['error' => 'Not a directory: ' . $path]); $results = []; $scanned = 0; $truncated = false; // Same symlink guard as Browse/Toggle: a symlinked subdirectory could walk // outside the sandbox, so it's excluded from both the walk and recursion. $dirIter = new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS); $filter = new RecursiveCallbackFilterIterator($dirIter, fn($cur) => !$cur->isLink()); $it = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::SELF_FIRST); foreach ($it as $f) { if (++$scanned > FILELOCK_SEARCH_MAX_SCAN) { $truncated = true; break; } if (stripos($f->getFilename(), $q) === false) continue; $results[] = ['name' => $f->getFilename(), 'path' => $f->getPathname(), 'dir' => $f->isDir(), 'locked' => null]; if (count($results) >= FILELOCK_SEARCH_MAX_RESULTS) { $truncated = true; break; } } // Batch-resolve lock status for every matched file in one/few lsattr calls, // same as Browse.php, rather than one subprocess per match. $disks = []; foreach ($results as $r) { if ($r['dir']) continue; $disk = PathResolver::toDisk($r['path']); if ($disk !== false) $disks[$r['path']] = $disk; } $status = PathResolver::isImmutableBatch(array_values($disks)); foreach ($results as &$r) { if (isset($disks[$r['path']])) $r['locked'] = $status[$disks[$r['path']]] ?? null; } unset($r); usort($results, function ($a, $b) { if ($a['dir'] !== $b['dir']) return $a['dir'] ? -1 : 1; return strcasecmp($a['name'], $b['name']); }); filelock_json([ 'base' => $base, 'path' => $path, 'query' => $q, 'results' => $results, 'truncated' => $truncated, ]); ]]> false, 'msg' => 'Base directory must be under /mnt']); } if (!is_dir($base)) { filelock_json(['ok' => false, 'msg' => 'Directory does not exist: ' . $base]); } $dir = dirname(FILELOCK_CFG); if (!is_dir($dir)) @mkdir($dir, 0755, true); $ok = @file_put_contents(FILELOCK_CFG, "BASEDIR=\"$base\"\n"); filelock_json($ok !== false ? ['ok' => true, 'base' => $base] : ['ok' => false, 'msg' => 'Could not write config to flash']); ]]>
Name Status
Settings
The browser is sandboxed to this directory and below.
]]>
r.json()); } function setMsg(text, kind) { status.textContent = text || ''; status.className = 'fl-msg' + (kind ? ' fl-msg-' + kind : ''); } function refreshButtons() { const n = selected.size; lockBtn.disabled = unlockBtn.disabled = deleteBtn.disabled = (n === 0); lockBtn.textContent = n ? `Lock (${n})` : 'Lock'; unlockBtn.textContent = n ? `Unlock (${n})` : 'Unlock'; deleteBtn.textContent = n ? `Unlock & Delete (${n})` : 'Unlock & Delete'; } /* --- rendering ------------------------------------------------------- */ function renderCrumbs() { crumbs.innerHTML = ''; const rel = current.startsWith(BASE) ? current.slice(BASE.length) : current; const segs = rel.split('/').filter(Boolean); let acc = BASE.replace(/\/$/, ''); addCrumb(BASE.replace(/\/+$/, '') || '/', BASE); segs.forEach(seg => { acc += '/' + seg; crumbs.appendChild(document.createTextNode(' / ')); addCrumb(seg, acc); }); } function addCrumb(label, path) { const a = document.createElement('a'); a.textContent = label; a.href = '#'; a.onclick = e => { e.preventDefault(); load(path); }; crumbs.appendChild(a); } /** Build one table row for either a normal directory listing or a search * result. `opts.showPath` adds a small parent-folder line under the name, * since search results (unlike a folder listing) span multiple folders. */ function buildRow(en, opts) { opts = opts || {}; const tr = document.createElement('tr'); tr.className = en.dir ? 'fl-row fl-dir' : 'fl-row fl-file'; // checkbox const tdC = tr.insertCell(); tdC.className = 'fl-c-check'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.onchange = () => { cb.checked ? selected.add(en.path) : selected.delete(en.path); tr.classList.toggle('fl-selected', cb.checked); refreshButtons(); }; tdC.appendChild(cb); // name (folders navigate on click) const tdN = tr.insertCell(); tdN.className = 'fl-c-name'; const icon = document.createElement('span'); icon.className = 'fl-icon'; icon.textContent = en.dir ? '\u{1F4C1}' : '\u{1F4C4}'; tdN.appendChild(icon); if (en.dir) { const a = document.createElement('a'); a.textContent = en.name; a.href = '#'; a.onclick = e => { e.preventDefault(); load(en.path); }; tdN.appendChild(a); } else { const span = document.createElement('span'); span.textContent = en.name; span.className = 'fl-fname'; span.style.cursor = 'pointer'; span.onclick = () => { cb.checked = !cb.checked; cb.onchange(); }; tdN.appendChild(span); } if (opts.showPath) { const parentPath = en.path.slice(0, en.path.length - en.name.length - 1); const rel = parentPath.startsWith(BASE) ? (parentPath.slice(BASE.length) || '/') : parentPath; const hint = document.createElement('div'); hint.className = 'fl-path-hint'; hint.textContent = rel; hint.title = parentPath; hint.onclick = () => load(parentPath); tdN.appendChild(hint); } // status const tdS = tr.insertCell(); tdS.className = 'fl-c-status'; if (en.dir) { tdS.innerHTML = 'folder'; } else if (en.locked === true) { tdS.innerHTML = 'locked'; tr.classList.add('fl-is-locked'); } else if (en.locked === false) { tdS.innerHTML = 'unlocked'; tr.classList.add('fl-is-unlocked'); } else { tdS.innerHTML = 'n/a'; } return tr; } function render(data) { if (data.error) { setMsg(data.error, 'err'); return; } current = data.path; parent = data.parent; selected.clear(); allBox.checked = false; upBtn.disabled = (parent === null); renderCrumbs(); refreshButtons(); list.innerHTML = ''; if (!data.entries.length) { const tr = list.insertRow(); tr.innerHTML = 'Empty directory'; return; } data.entries.forEach(en => list.appendChild(buildRow(en))); } function renderSearch(data) { if (data.error) { setMsg(data.error, 'err'); return; } selected.clear(); allBox.checked = false; refreshButtons(); list.innerHTML = ''; if (!data.results.length) { const tr = list.insertRow(); tr.innerHTML = 'No matches'; } else { data.results.forEach(en => list.appendChild(buildRow(en, { showPath: true }))); } setMsg( data.truncated ? `Showing first ${data.results.length} match(es) — search stopped early on a very large tree.` : `${data.results.length} match(es) for “${data.query}”.` ); } /* --- actions --------------------------------------------------------- */ function load(path) { if (searchTimer) { clearTimeout(searchTimer); searchTimer = null; } searchActive = false; searchClearBtn.hidden = true; searchInput.value = ''; setMsg(''); post('Browse.php', { path }).then(render) .catch(() => setMsg('Failed to load directory', 'err')); } function runSearch() { const q = searchInput.value.trim(); if (!q) { load(current); return; } lastQuery = q; searchActive = true; searchClearBtn.hidden = false; setMsg('Searching…'); post('Search.php', { q, path: current }).then(renderSearch) .catch(() => setMsg('Search failed', 'err')); } /** Re-run whatever's currently on screen (a search or a folder listing) * after a lock/unlock, so selections and status badges reflect reality. */ function refresh() { if (searchActive && lastQuery) { post('Search.php', { q: lastQuery, path: current }).then(renderSearch); } else { load(current); } } function apply(action) { if (!selected.size) return; const paths = JSON.stringify([...selected]); lockBtn.disabled = unlockBtn.disabled = deleteBtn.disabled = true; setMsg('Working…'); post('Toggle.php', { action, paths }).then(res => { if (res.error) { setMsg(res.error, 'err'); return; } const fail = res.results.filter(r => !r.ok); const ok = res.results.length - fail.length; if (fail.length) { setMsg(`${ok} succeeded, ${fail.length} failed (e.g. ${fail[0].msg})`, 'err'); } else { setMsg(`${action === 'lock' ? 'Locked' : 'Unlocked'} ${ok} file(s).`, 'ok'); } refresh(); }).catch(() => setMsg('Operation failed', 'err')); } /** Unlock and permanently delete every selected file. Destructive and * irreversible, so it's gated behind an explicit confirm() prompt -- * this is a UX safety net against fat-fingering the button, not an * access control: anyone who can reach this page could already delete * these files locking/unlocking them manually. */ function doDelete() { if (!selected.size) return; const n = selected.size; const ok = confirm( `Permanently delete ${n} item(s)?\n\n` + `This removes the immutable flag first, then deletes the file(s) ` + `-- selected folders are deleted recursively. This cannot be undone.` ); if (!ok) return; const paths = JSON.stringify([...selected]); lockBtn.disabled = unlockBtn.disabled = deleteBtn.disabled = true; setMsg('Deleting…'); post('Delete.php', { paths }).then(res => { if (res.error) { setMsg(res.error, 'err'); return; } const fail = res.results.filter(r => !r.ok); const done = res.results.length - fail.length; if (fail.length) { setMsg(`${done} deleted, ${fail.length} failed (e.g. ${fail[0].msg})`, 'err'); } else { setMsg(`Deleted ${done} file(s).`, 'ok'); } refresh(); }).catch(() => setMsg('Delete failed', 'err')); } /* --- wire up --------------------------------------------------------- */ upBtn.onclick = () => { if (parent !== null) load(parent); }; lockBtn.onclick = () => apply('lock'); unlockBtn.onclick = () => apply('unlock'); deleteBtn.onclick = doDelete; allBox.onchange = () => { list.querySelectorAll('input[type=checkbox]').forEach(cb => { if (cb.checked !== allBox.checked) { cb.checked = allBox.checked; cb.onchange(); } }); }; searchInput.addEventListener('input', () => { if (searchTimer) clearTimeout(searchTimer); if (!searchInput.value.trim()) { load(current); return; } searchTimer = setTimeout(runSearch, 300); }); searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); if (searchTimer) clearTimeout(searchTimer); runSearch(); } }); searchClearBtn.onclick = () => load(current); document.getElementById('fl-save').onclick = () => { const base = document.getElementById('fl-base').value.trim(); post('Settings.php', { base }).then(res => { if (res.ok) { setMsg('Base directory saved. Reloading…', 'ok'); setTimeout(() => location.reload(), 600); } else { setMsg(res.msg || 'Could not save', 'err'); } }); }; load(current); })(); ]]> mkdir -p /boot/config/plugins/file.lock [ -f /boot/config/plugins/file.lock/file.lock.cfg ] || echo 'BASEDIR="/mnt/user/media/video"' > /boot/config/plugins/file.lock/file.lock.cfg chmod -R 755 /usr/local/emhttp/plugins/file.lock echo "" echo "File Lock 2026.09.27 installed. Open it under Settings in the webGUI." echo "" rm -rf /usr/local/emhttp/plugins/file.lock rm -f /boot/config/plugins/file.lock.plg