Files
webgui/emhttp/plugins/dynamix/nchan/file_manager
T
mgutt 69a21de7a4 file_manager: persist move state and handle restarts
- Initialize  outside the main loop to ensure correct state handling for move/cleanup phases
- If file_manager restarts and PID is loaded from file and move operation is still active, enable  to cleanup empty directories afterwards
- Clarify comment for INI job parameters
2025-10-24 13:26:10 +02:00

375 lines
14 KiB
PHP
Executable File

#!/usr/bin/php -q
<?PHP
/* Copyright 2005-2025, Lime Technology
* Copyright 2012-2025, Bergware International.
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*/
?>
<?
$docroot = '/usr/local/emhttp';
$active = '/var/tmp/file.manager.active';
$pid_file = '/var/tmp/file.manager.pid';
$status = '/var/tmp/file.manager.status';
$error = '/var/tmp/file.manager.error';
$empty_dir = '/var/tmp/file.manager.empty_dir/'; // trailing slash is required for rsync
$null = '/dev/null';
$timer = time();
require_once "$docroot/webGui/include/Wrappers.php";
require_once "$docroot/webGui/include/publish.php";
extract(parse_plugin_cfg('dynamix', true));
// add translations
$_SERVER['REQUEST_URI'] = '';
$login_locale = $display['locale'] ?? '';
require_once "$docroot/webGui/include/Translations.php";
// remember current language
$locale_init = $locale;
function pool_only(&$disks) {
return array_filter($disks, function($disk){return $disk['type'] == 'Cache' && !empty($disk['uuid']);});
}
function pools_filter(&$disks) {
return array_keys(pool_only($disks));
}
function delete_file(...$file) {
array_map('unlink', array_filter($file, 'file_exists'));
}
function pgrep($pid) {
$pid = is_array($pid) ? $pid[0] : $pid;
return $pid && file_exists("/proc/$pid") ? $pid : false;
}
function isdir($name) {
return mb_substr($name, -1) == '/';
}
function truepath($name) {
$bits = array_filter(explode('/', $name), 'mb_strlen');
$path = [];
foreach ($bits as $bit) {
if ($bit == '.') continue;
if ($bit == '..') array_pop($path); else $path[] = $bit;
}
return '/'.implode('/', $path);
}
function validname($name, $real=true) {
$path = $real ? realpath(dirname($name)) : truepath(dirname($name));
$root = explode('/', $path)[1] ?? '';
return in_array($root, ['mnt','boot']) ? $path.'/'.basename($name).(mb_substr($name,-1) == '/' ? '/' : '') : '';
}
function update_translation($locale) {
global $docroot,$language;
$language = [];
if ($locale) {
$text = "$docroot/languages/$locale/translations.txt";
if (file_exists($text)) {
$store = "$docroot/languages/$locale/translations.dot";
if (!file_exists($store)) file_put_contents($store, serialize(parse_lang_file($text)));
$language = unserialize(file_get_contents($store));
}
}
}
function cat($file) {
global $null;
$cat = $set = [];
$rows = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if (count($rows) > 0) {
natcasesort($rows);
$user = array_filter($rows, function($path){return preg_match('/^\/mnt\/user0?\//', $path);});
if (count($user) > 0) {
for ($n=0; $n < count($user); $n+=100) {
$name = array_slice($user, $n, 100);
$set[] = exec("getfattr --no-dereference --absolute-names --only-values -n system.LOCATIONS ".quoted($name)." 2>$null");
}
$disks = parse_ini_file('state/disks.ini', true);
$tag = implode('|',array_merge(['disk'],pools_filter($disks)));
$set = explode(';',str_replace(',;',',',preg_replace("/($tag)/", ';$1', implode($set))));
}
foreach (array_diff($rows, $user) as $row) {
[$none, $root, $main] = explode('/',$row,4);
$cat[] = ($root == 'mnt' ? $main : ($root == 'boot' ? 'flash' : '---'))."\0".$row;
}
$i = 0;
foreach ($user as $row) $cat[] = $set[++$i]."\0".$row;
}
return "#cat#\n".implode("\n",$cat)."\n";
}
function escape($name) {return escapeshellarg(validname($name));}
function quoted($name) {return is_array($name) ? implode(' ',array_map('escape', $name)) : escape($name);}
function source($name) {return is_array($name) ? implode(' ',array_map('escapeshellarg', $name)) : escapeshellarg($name);}
// Escape rsync filter meta characters in a single path component
function rsync_escape_component($s) {
// escape: *, ?, [, ], \
return preg_replace('/([*?\[\]\\\\])/', '\\\\$1', $s);
}
function quoted_rsync_include($paths) {
// note: this function is never called with invalid names because of "if (!$valid_source_path)"
$result = [];
foreach ($paths as $path) {
$valid_path = validname($path);
$base = rsync_escape_component(basename($valid_path));
if (is_dir($valid_path) && !is_link($valid_path)) {
$result[] = "--include=" . escapeshellarg("/{$base}/***");
} else {
$result[] = "--include=" . escapeshellarg("/{$base}");
}
}
return implode(' ', $result);
}
// create empty directory for optimized move if not exists
if (!file_exists($empty_dir)) {
mkdir($empty_dir);
}
// initialize $move state: null = not a move operation (yet), true = rsync phase, false = cleanup phase
$move = null;
while (true) {
unset($action, $source, $target, $H, $sparse, $exist, $zfs);
// read job parameters from ini file: $action, $title, $source, $target, $H, $sparse, $exist, $zfs (set by emhttp/plugins/dynamix/include/Control.php)
if (file_exists($active)) extract(parse_ini_file($active));
// read PID from file (file_manager may have been restarted)
if (!$pid && file_exists($pid_file)) {
$pid = trim(file_get_contents($pid_file));
}
$reply = [];
if (isset($action)) {
// check for language changes
extract(parse_plugin_cfg('dynamix', true));
if ($display['locale'] != $locale_init) {
$locale_init = $display['locale'];
update_translation($locale_init);
}
$source = explode("\r", $source);
switch ($action) {
case 0: // create folder
if (!empty($pid)) {
$reply['status'] = '<i class="fa fa-circle-o-notch fa-spin dfm"></i>'._('Creating').'...';
} else {
$dir = $source[0].'/'.$target;
exec("mkdir -pm0777 ".quoted($dir)." 1>$null 2>$error & echo $!", $pid);
exec("chown -Rfv nobody:users ".quoted($dir));
}
break;
case 1: // delete folder
case 6: // delete file
if (!empty($pid)) {
$reply['status'] = '<i class="fa fa-circle-o-notch fa-spin dfm"></i>'._('Removing').'... '.exec("tail -1 $status");
} else {
exec("find ".quoted($source)." -name \"*\" -print -delete 1>$status 2>$null & echo \$!", $pid);
}
break;
case 2: // rename folder
case 7: // rename file
if (!empty($pid)) {
$reply['status'] = '<i class="fa fa-circle-o-notch fa-spin dfm"></i>'._('Renaming').'...';
} else {
$path = dirname($source[0]);
exec("mv -f ".quoted($source)." ".quoted("$path/$target")." 1>$null 2>$error & echo \$!", $pid);
}
break;
case 3: // copy folder
case 8: // copy file
if (!empty($pid)) {
$reply['status'] = '<i class="fa fa-circle-o-notch fa-spin dfm"></i>'._('Copying').'... '.shell_exec("tail -2 $status | awk -F\"\\r\" '{gsub(/^ +/,\"\",\$NF);print \$NF}'");
} else {
$target = validname($target, false);
if ($target) {
$mkpath = isdir($target) ? '--mkpath' : '';
exec("rsync -ahPIX$H $sparse $exist $mkpath --out-format=%f --info=flist0,misc0,stats0,name1,progress2 ".quoted($source)." ".escapeshellarg($target)." 1>$status 2>$error & echo \$!", $pid);
} else {
$reply['error'] = 'Invalid target name';
}
}
break;
case 4: // move folder (rsync)
case 5: // move folder (mv) - this case needs to be removed from Browse.page
case 9: // move file (rsync)
case 10: // move file (mv) - this case needs to be removed from Browse.page
if (!empty($pid)) {
// Set move state for resume: true=rsync phase, false=cleanup phase
if ($move === null) $move = true;
$reply['status'] = '<i class="fa fa-circle-o-notch fa-spin dfm"></i>'._('Moving').'... '.($move===false ? exec("tail -1 $status") : shell_exec("tail -2 $status | awk -F\"\\r\" '{gsub(/^ +/,\"\",\$NF);print \$NF}'"));
} else {
$target = validname($target, false);
if ($target) {
$move = true;
$mkpath = isdir($target) ? '--mkpath' : '';
// determine if we can use rsync with rename(2)
// requirements:
// 1. all sources and target must be on the same filesystem (device)
// 2. all sources must have the same parent directory
// 3. either no --ignore-existing flag OR no conflicts with existing files on target
$use_rsync_rename = false;
$last_dirname = '';
$target_device_id = [];
// for filesystem check, find first existing parent directory
$target_for_stat = $target;
while (!file_exists($target_for_stat) && $target_for_stat != '/') {
$target_for_stat = dirname($target_for_stat);
}
exec("stat -c %d -- ".escapeshellarg($target_for_stat)." 2>/dev/null", $target_device_id);
// check all source paths
if (!empty($target_device_id)) {
$use_rsync_rename = true; // assume we can use it, then check for disqualifying conditions
foreach ($source as $source_path) {
// source path must be valid
$valid_source_path = validname($source_path);
if (!$valid_source_path) {
$use_rsync_rename = false;
break;
}
// filesystem (device) of source and target must be equal
$source_device_id = [];
exec("stat -c %d -- ".escapeshellarg($valid_source_path)." 2>/dev/null", $source_device_id);
if (empty($source_device_id) || $source_device_id[0] != $target_device_id[0]) {
$use_rsync_rename = false;
break;
}
// parent directory of all source paths must be equal (not sure if this is really required, but keeping for now)
if (!empty($last_dirname) && $last_dirname != dirname($valid_source_path) ) {
$use_rsync_rename = false;
break;
}
$last_dirname = dirname($valid_source_path);
// target must be a directory
if (!is_dir(rtrim($target,'/'))) {
$use_rsync_rename = false;
break;
}
// selected source files and directories must not exist on target when "Overwrite existing files" is not set
if (!empty($exist)) { // would add "--ignore-existing" to rsync
$target_item = rtrim($target, '/') . '/' . basename($valid_source_path);
if (file_exists($target_item)) {
$use_rsync_rename = false;
break;
}
}
}
}
// use rsync rename
// let rsync act like "mv" by syncing an empty directory against the source location, which moves the files to --backup-dir
// notes:
// - existing files are overwritten in --backup-dir (like not using --ignore-existing)
// - missing directories are created in --backup-dir (like using --mkpath)
if ($use_rsync_rename) {
$parent_dir = dirname(validname($source[0]));
$cmd = "rsync -r --out-format=%f --info=flist0,misc0,stats0,name1,progress2 --delete --backup --backup-dir=".escapeshellarg($target)." ".quoted_rsync_include($source)." --exclude='*' ".escapeshellarg($empty_dir)." ".escapeshellarg($parent_dir)." 1>$status 2>$error & echo \$!";
exec($cmd, $pid);
// use rsync copy-delete
} else {
$cmd = "rsync -ahPIX$H $sparse $exist $mkpath --out-format=%f --info=flist0,misc0,stats0,name1,progress2 --remove-source-files ".quoted($source)." ".escapeshellarg($target)." 1>$status 2>$error & echo \$!";
exec($cmd, $pid);
}
} else {
$reply['error'] = 'Invalid target name';
}
}
break;
case 11: // change owner
if (!empty($pid)) {
$reply['status'] = '<i class="fa fa-circle-o-notch fa-spin dfm"></i>'._('Updating').'... '.exec("tail -2 $status | grep -Pom1 \"^.+ of '\\K[^']+\"");
} else {
exec("chown -Rfv $target ".quoted($source)." 1>$status 2>$error & echo \$!", $pid);
}
break;
case 12: // change permission
if (!empty($pid)) {
$reply['status'] = '<i class="fa fa-circle-o-notch fa-spin dfm"></i>'._('Updating').'... '.exec("tail -2 $status | grep -Pom1 \"^.+ of '\\K[^']+\"");
} else {
exec("chmod -Rfv $target ".quoted($source)." 1>$status 2>$error & echo \$!", $pid);
}
break;
case 15: // search
if (!empty($pid)) {
$reply['status'] = '<i class="fa fa-circle-o-notch fa-spin dfm"></i>'._('Searching').'... '.exec("wc -l $status | grep -Pom1 '^[0-9]+'");
} else {
exec("find ".source($source)." -iname ".escapeshellarg($target)." 1>$status 2>$null & echo \$!", $pid);
}
break;
case 99: // kill running background process
if (!empty($pid)) exec("kill $pid");
delete_file($active, $pid_file, $status, $error);
unset($pid, $move);
break;
default:
continue 2;
}
$pid = pgrep($pid??0);
// Store PID to survive file_manager restarts
if ($pid !== false) {
file_put_contents($pid_file, $pid);
}
if ($pid === false) {
if (!empty($move)) {
exec("find ".quoted($source)." -type d -empty -print -delete 1>$status 2>$null & echo \$!", $pid);
$move = false;
$pid = pgrep($pid);
} else {
if ($action != 15) {
$reply['status'] = _('Done');
$reply['done'] = 1;
} else {
$reply['status'] = cat($status);
$reply['done'] = 2;
}
if ($zfs) {
$pools = explode("\r",$zfs);
foreach ($pools as $pool) {
unset($datasets);
exec("zfs list -Ho name|grep '^$pool'",$datasets);
foreach ($datasets as $dataset) if (exec("ls --indicator-style=none /mnt/$dataset|wc -l")==0) exec("zfs destroy $dataset 2>/dev/null");
}
}
if (file_exists($error)) $reply['error'] = str_replace("\n","<br>", trim(file_get_contents($error)));
delete_file($active, $pid_file, $status, $error);
unset($pid, $move);
}
}
}
if (time() - $timer) {
// update every second
publish('filemonitor', file_exists($active) ? 1 : 0);
$timer = time();
}
publish('filemanager', json_encode($reply));
usleep(250000);
}
?>