288baf41d3
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 13s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 30s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
CI version_bump was creating duplicate <version> lines in all sub-extension manifests. Also AjaxController still referenced the old `config` column and removed `keep_local` column on the remotes table. Claude-Session: https://claude.ai/code/session_01MbEjBtsSjPuTWhqqrMS2wG
1401 lines
38 KiB
PHP
1401 lines
38 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @package MokoSuiteBackup
|
|
* @subpackage com_mokosuitebackup
|
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
* @license GNU General Public License version 3 or later; see LICENSE
|
|
*
|
|
* AJAX controller for step-based backups.
|
|
* Handles init and step requests from the admin UI JavaScript.
|
|
*/
|
|
|
|
namespace Joomla\Component\MokoSuiteBackup\Administrator\Controller;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\CMS\MVC\Controller\BaseController;
|
|
use Joomla\CMS\Session\Session;
|
|
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\PlaceholderResolver;
|
|
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedBackupEngine;
|
|
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedRestoreEngine;
|
|
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
|
|
|
class AjaxController extends BaseController
|
|
{
|
|
/**
|
|
* Initialize a new stepped backup.
|
|
* POST: task=ajax.init&profile_id=1&description=...
|
|
*/
|
|
public function init(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$profileId = $this->input->getInt('profile_id', 1);
|
|
$description = $this->input->getString('description', '');
|
|
|
|
$engine = new SteppedBackupEngine();
|
|
$result = $engine->init($profileId, $description, 'backend');
|
|
|
|
$this->sendJson($result);
|
|
}
|
|
|
|
/**
|
|
* Run the next step of a backup session.
|
|
* POST: task=ajax.step&session_id=mb_...
|
|
*/
|
|
public function step(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$sessionId = $this->input->getString('session_id', '');
|
|
|
|
if (empty($sessionId)) {
|
|
$this->sendJson(['error' => true, 'message' => 'Missing session_id']);
|
|
|
|
return;
|
|
}
|
|
|
|
$engine = new SteppedBackupEngine();
|
|
$result = $engine->runStep($sessionId);
|
|
|
|
$this->sendJson($result);
|
|
}
|
|
|
|
/**
|
|
* Browse server directories for the folder picker field.
|
|
* POST: task=ajax.browseDir&path=/some/path
|
|
*/
|
|
public function browseDir(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$requestPath = $this->input->getString('path', JPATH_ROOT);
|
|
|
|
// Resolve placeholders and relative paths before permission check
|
|
$resolved = BackupDirectory::resolve($requestPath);
|
|
$path = realpath($resolved) ?: $resolved;
|
|
|
|
// Security: restrict browsing to site root and current user's home
|
|
$jRoot = realpath(JPATH_ROOT);
|
|
$homeDir = BackupDirectory::getHomeDirectory();
|
|
$allowed = false;
|
|
|
|
if ($jRoot !== false && strpos($path, $jRoot) === 0) {
|
|
$allowed = true;
|
|
} elseif ($homeDir !== '' && strpos($path, $homeDir) === 0) {
|
|
$allowed = true;
|
|
}
|
|
|
|
if (!$allowed) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied: path outside allowed directories']);
|
|
return;
|
|
}
|
|
|
|
if (!is_dir($path)) {
|
|
$this->sendJson(['error' => true, 'message' => 'Directory not found: ' . $path]);
|
|
|
|
return;
|
|
}
|
|
|
|
// Security: only allow browsing within JPATH_ROOT or parent directories
|
|
// that could contain a backup folder (e.g., /home/user/backups)
|
|
$dirs = [];
|
|
$handle = @opendir($path);
|
|
$warning = null;
|
|
|
|
if ($handle) {
|
|
while (($entry = readdir($handle)) !== false) {
|
|
if ($entry === '.' || $entry === '..') {
|
|
continue;
|
|
}
|
|
|
|
$fullPath = $path . '/' . $entry;
|
|
|
|
if (is_dir($fullPath) && $entry[0] !== '.') {
|
|
$dirs[] = [
|
|
'name' => $entry,
|
|
'path' => $fullPath,
|
|
];
|
|
}
|
|
}
|
|
|
|
closedir($handle);
|
|
} else {
|
|
$warning = 'Cannot read directory contents (check permissions)';
|
|
}
|
|
|
|
usort($dirs, fn($a, $b) => strcasecmp($a['name'], $b['name']));
|
|
|
|
$parent = dirname($path);
|
|
|
|
// Ensure parent is still within allowed boundaries
|
|
$parentAllowed = false;
|
|
|
|
if ($parent !== $path) {
|
|
if ($jRoot !== false && strpos($parent, $jRoot) === 0) {
|
|
$parentAllowed = true;
|
|
} elseif ($homeDir !== '' && (strpos($parent, $homeDir) === 0 || $parent === \dirname($homeDir))) {
|
|
$parentAllowed = true;
|
|
}
|
|
}
|
|
|
|
$response = [
|
|
'error' => false,
|
|
'current' => $path,
|
|
'parent' => $parentAllowed ? $parent : null,
|
|
'dirs' => $dirs,
|
|
];
|
|
|
|
if ($warning !== null) {
|
|
$response['warning'] = $warning;
|
|
}
|
|
|
|
$this->sendJson($response);
|
|
}
|
|
|
|
/**
|
|
* Load and return the log file contents for a backup record.
|
|
* POST: task=ajax.viewLog&id=123
|
|
*/
|
|
public function viewLog(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$id = $this->input->getInt('id', 0);
|
|
|
|
if (!$id) {
|
|
$this->sendJson(['error' => true, 'message' => 'Missing record ID']);
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$db = \Joomla\CMS\Factory::getDbo();
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName(['absolute_path', 'log']))
|
|
->from($db->quoteName('#__mokosuitebackup_records'))
|
|
->where($db->quoteName('id') . ' = ' . (int) $id);
|
|
$db->setQuery($query);
|
|
$record = $db->loadObject();
|
|
} catch (\Exception $e) {
|
|
error_log('MokoSuiteBackup: viewLog() DB error for record ' . $id . ': ' . $e->getMessage());
|
|
$this->sendJson(['error' => true, 'message' => 'Failed to load backup record'], 500);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$record) {
|
|
$this->sendJson(['error' => true, 'message' => 'Record not found'], 404);
|
|
|
|
return;
|
|
}
|
|
|
|
// Try to load log from file alongside the archive
|
|
$logPath = BackupDirectory::logPathFromArchive($record->absolute_path);
|
|
$logContent = '';
|
|
$source = 'none';
|
|
|
|
if (is_file($logPath)) {
|
|
$content = file_get_contents($logPath);
|
|
|
|
if ($content !== false) {
|
|
$logContent = $content;
|
|
$source = 'file';
|
|
} else {
|
|
$source = 'file (read error)';
|
|
}
|
|
} elseif (!empty($record->log)) {
|
|
$logContent = $record->log;
|
|
$source = 'database';
|
|
}
|
|
|
|
$this->sendJson([
|
|
'error' => false,
|
|
'log' => $logContent ?: '(no log available)',
|
|
'source' => $source,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Check directory existence, writability and permissions.
|
|
* POST: task=ajax.checkDir&path=/some/path
|
|
*/
|
|
public function checkDir(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$rawPath = trim($this->input->getString('path', ''));
|
|
|
|
if ($rawPath === '') {
|
|
$this->sendJson(['error' => true, 'message' => 'No path provided']);
|
|
|
|
return;
|
|
}
|
|
|
|
/* Resolve all placeholders — both directory ([HOME], [DEFAULT_DIR])
|
|
and name-level ([SITE_NAME], [HOST], [PROFILE_ID], etc.) */
|
|
$profileId = $this->input->getInt('profile_id', 0);
|
|
|
|
if ($profileId > 0) {
|
|
$db = Factory::getDbo();
|
|
$query = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
|
->where($db->quoteName('id') . ' = ' . $profileId);
|
|
$db->setQuery($query);
|
|
$profile = $db->loadObject();
|
|
}
|
|
|
|
if (empty($profile)) {
|
|
/* No profile context — create a minimal dummy for PlaceholderResolver */
|
|
$profile = (object) [
|
|
'id' => 1,
|
|
'title' => 'default',
|
|
'backup_type' => 'full',
|
|
];
|
|
}
|
|
|
|
$resolver = new PlaceholderResolver($profile);
|
|
$withNamePlaceholders = $resolver->resolve($rawPath);
|
|
$resolved = BackupDirectory::resolve($withNamePlaceholders);
|
|
|
|
if (BackupDirectory::hasPlaceholders($resolved)) {
|
|
$this->sendJson([
|
|
'error' => false,
|
|
'exists' => null,
|
|
'writable' => null,
|
|
'resolved' => $resolved,
|
|
'placeholder' => true,
|
|
]);
|
|
|
|
return;
|
|
}
|
|
|
|
$exists = is_dir($resolved);
|
|
$writable = $exists && is_writable($resolved);
|
|
|
|
$this->sendJson([
|
|
'error' => false,
|
|
'exists' => $exists,
|
|
'writable' => $writable,
|
|
'resolved' => $resolved,
|
|
'placeholder' => false,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Initialize a new stepped restore.
|
|
* POST: task=ajax.restoreInit&id=123&restore_files=1&restore_db=1&preserve_config=1&encryption_password=
|
|
*/
|
|
public function restoreInit(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.restore', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$recordId = $this->input->getInt('id', 0);
|
|
$restoreFiles = (bool) $this->input->getInt('restore_files', 1);
|
|
$restoreDb = (bool) $this->input->getInt('restore_db', 1);
|
|
$preserveConfig = (bool) $this->input->getInt('preserve_config', 1);
|
|
$password = $this->input->getString('encryption_password', '');
|
|
|
|
if (!$recordId) {
|
|
$this->sendJson(['error' => true, 'message' => 'Missing record ID']);
|
|
|
|
return;
|
|
}
|
|
|
|
$engine = new SteppedRestoreEngine();
|
|
$result = $engine->init($recordId, $restoreFiles, $restoreDb, $preserveConfig, $password);
|
|
|
|
$this->sendJson($result);
|
|
}
|
|
|
|
/**
|
|
* Run the next step of a restore session.
|
|
* POST: task=ajax.restoreStep&session_id=mb_...
|
|
*/
|
|
public function restoreStep(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.restore', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$sessionId = $this->input->getString('session_id', '');
|
|
|
|
if (empty($sessionId)) {
|
|
$this->sendJson(['error' => true, 'message' => 'Missing session_id']);
|
|
|
|
return;
|
|
}
|
|
|
|
$engine = new SteppedRestoreEngine();
|
|
$result = $engine->runStep($sessionId);
|
|
|
|
$this->sendJson($result);
|
|
}
|
|
|
|
/**
|
|
* Browse archive contents without extracting.
|
|
* POST: task=ajax.browseArchive&id=123
|
|
*/
|
|
public function browseArchive(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.browse', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$id = $this->input->getInt('id', 0);
|
|
|
|
if (!$id) {
|
|
$this->sendJson(['error' => true, 'message' => 'Missing record ID']);
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$db = \Joomla\CMS\Factory::getDbo();
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName(['absolute_path', 'status', 'filesexist']))
|
|
->from($db->quoteName('#__mokosuitebackup_records'))
|
|
->where($db->quoteName('id') . ' = ' . (int) $id);
|
|
$db->setQuery($query);
|
|
$record = $db->loadObject();
|
|
} catch (\Exception $e) {
|
|
error_log('MokoSuiteBackup: browseArchive() DB error for record ' . $id . ': ' . $e->getMessage());
|
|
$this->sendJson(['error' => true, 'message' => 'Failed to load backup record'], 500);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$record) {
|
|
$this->sendJson(['error' => true, 'message' => 'Record not found'], 404);
|
|
|
|
return;
|
|
}
|
|
|
|
if ($record->status !== 'complete' || !$record->filesexist) {
|
|
$this->sendJson(['error' => true, 'message' => 'Archive not available']);
|
|
|
|
return;
|
|
}
|
|
|
|
$archivePath = $record->absolute_path;
|
|
|
|
if (!is_file($archivePath)) {
|
|
$this->sendJson(['error' => true, 'message' => 'Archive file not found on disk']);
|
|
|
|
return;
|
|
}
|
|
|
|
$maxEntries = 500;
|
|
|
|
try {
|
|
$files = [];
|
|
$totalFiles = 0;
|
|
$totalSize = 0;
|
|
$truncated = false;
|
|
|
|
$lower = strtolower($archivePath);
|
|
|
|
if (substr($lower, -4) === '.zip') {
|
|
$files = $this->browseZipArchive($archivePath, $maxEntries, $totalFiles, $totalSize, $truncated);
|
|
} elseif (substr($lower, -7) === '.tar.gz' || substr($lower, -4) === '.tgz') {
|
|
$files = $this->browseTarArchive($archivePath, $maxEntries, $totalFiles, $totalSize, $truncated);
|
|
} else {
|
|
$this->sendJson(['error' => true, 'message' => 'Unsupported archive format']);
|
|
|
|
return;
|
|
}
|
|
} catch (\Exception $e) {
|
|
error_log('MokoSuiteBackup: browseArchive() error for record ' . $id . ': ' . $e->getMessage());
|
|
$this->sendJson(['error' => true, 'message' => 'Failed to read archive: ' . $e->getMessage()]);
|
|
|
|
return;
|
|
}
|
|
|
|
$this->sendJson([
|
|
'error' => false,
|
|
'files' => $files,
|
|
'total_files' => $totalFiles,
|
|
'total_size' => $totalSize,
|
|
'truncated' => $truncated,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Browse a ZIP archive and return file entries.
|
|
*
|
|
* @param string $path Absolute path to the ZIP file
|
|
* @param int $maxEntries Maximum entries to return
|
|
* @param int &$totalFiles Total number of files (by reference)
|
|
* @param int &$totalSize Total uncompressed size (by reference)
|
|
* @param bool &$truncated Whether results were truncated (by reference)
|
|
*
|
|
* @return array List of file entry arrays
|
|
*/
|
|
private function browseZipArchive(string $path, int $maxEntries, int &$totalFiles, int &$totalSize, bool &$truncated): array
|
|
{
|
|
$zip = new \ZipArchive();
|
|
|
|
if ($zip->open($path, \ZipArchive::RDONLY) !== true) {
|
|
throw new \RuntimeException('Cannot open ZIP archive');
|
|
}
|
|
|
|
$files = [];
|
|
$totalFiles = $zip->numFiles;
|
|
|
|
for ($i = 0; $i < $totalFiles; $i++) {
|
|
$stat = $zip->statIndex($i);
|
|
|
|
if ($stat === false) {
|
|
continue;
|
|
}
|
|
|
|
$totalSize += $stat['size'];
|
|
|
|
if (\count($files) < $maxEntries) {
|
|
$files[] = [
|
|
'name' => $stat['name'],
|
|
'size' => $stat['size'],
|
|
'compressed_size' => $stat['comp_size'],
|
|
];
|
|
}
|
|
}
|
|
|
|
$truncated = $totalFiles > $maxEntries;
|
|
$zip->close();
|
|
|
|
return $files;
|
|
}
|
|
|
|
/**
|
|
* Browse a tar.gz archive and return file entries.
|
|
*
|
|
* @param string $path Absolute path to the tar.gz file
|
|
* @param int $maxEntries Maximum entries to return
|
|
* @param int &$totalFiles Total number of files (by reference)
|
|
* @param int &$totalSize Total uncompressed size (by reference)
|
|
* @param bool &$truncated Whether results were truncated (by reference)
|
|
*
|
|
* @return array List of file entry arrays
|
|
*/
|
|
private function browseTarArchive(string $path, int $maxEntries, int &$totalFiles, int &$totalSize, bool &$truncated): array
|
|
{
|
|
$phar = new \PharData($path);
|
|
$files = [];
|
|
|
|
foreach (new \RecursiveIteratorIterator($phar) as $entry) {
|
|
$totalFiles++;
|
|
$entrySize = $entry->getSize();
|
|
$totalSize += $entrySize;
|
|
|
|
if (\count($files) < $maxEntries) {
|
|
// Strip the phar:// prefix and archive path to get relative name
|
|
$fullPath = str_replace('\\', '/', $entry->getPathname());
|
|
$relativeName = preg_replace('#^phar://.+?\.tar\.gz/#i', '', $fullPath)
|
|
?: preg_replace('#^phar://.+?\.tgz/#i', '', $fullPath)
|
|
?: $fullPath;
|
|
|
|
$files[] = [
|
|
'name' => $relativeName,
|
|
'size' => $entrySize,
|
|
'compressed_size' => $entrySize,
|
|
];
|
|
}
|
|
}
|
|
|
|
$truncated = $totalFiles > $maxEntries;
|
|
|
|
return $files;
|
|
}
|
|
|
|
/**
|
|
* Browse articles inside a snapshot — returns JSON list for the browse modal.
|
|
* POST: task=ajax.browseSnapshot&id=123
|
|
*/
|
|
public function browseSnapshot(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$id = $this->input->getInt('id', 0);
|
|
|
|
if (!$id) {
|
|
$this->sendJson(['error' => true, 'message' => 'Missing snapshot ID']);
|
|
|
|
return;
|
|
}
|
|
|
|
$db = \Joomla\CMS\Factory::getDbo();
|
|
$query = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
|
->where($db->quoteName('id') . ' = ' . (int) $id);
|
|
$db->setQuery($query);
|
|
$record = $db->loadObject();
|
|
|
|
if (!$record) {
|
|
$this->sendJson(['error' => true, 'message' => 'Snapshot not found'], 404);
|
|
|
|
return;
|
|
}
|
|
|
|
if ($record->status !== 'complete') {
|
|
$this->sendJson(['error' => true, 'message' => 'Cannot browse a failed snapshot']);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!is_file($record->data_file) || !is_readable($record->data_file)) {
|
|
$this->sendJson(['error' => true, 'message' => 'Snapshot data file not found']);
|
|
|
|
return;
|
|
}
|
|
|
|
$json = file_get_contents($record->data_file);
|
|
|
|
if ($json === false) {
|
|
$this->sendJson(['error' => true, 'message' => 'Cannot read snapshot file']);
|
|
|
|
return;
|
|
}
|
|
|
|
$data = json_decode($json, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid snapshot data']);
|
|
|
|
return;
|
|
}
|
|
|
|
$tables = $data['tables'] ?? [];
|
|
|
|
// Articles
|
|
$articles = [];
|
|
|
|
if (!empty($tables['#__content'])) {
|
|
foreach ($tables['#__content'] as $row) {
|
|
$articles[] = [
|
|
'id' => (int) ($row['id'] ?? 0),
|
|
'title' => $row['title'] ?? '',
|
|
'catid' => (int) ($row['catid'] ?? 0),
|
|
'state' => (int) ($row['state'] ?? 0),
|
|
'created' => $row['created'] ?? '',
|
|
];
|
|
}
|
|
}
|
|
|
|
// Categories
|
|
$categories = [];
|
|
|
|
if (!empty($tables['#__categories'])) {
|
|
foreach ($tables['#__categories'] as $row) {
|
|
$categories[] = [
|
|
'id' => (int) ($row['id'] ?? 0),
|
|
'title' => $row['title'] ?? '',
|
|
'extension' => $row['extension'] ?? '',
|
|
'published' => (int) ($row['published'] ?? 0),
|
|
'level' => (int) ($row['level'] ?? 0),
|
|
];
|
|
}
|
|
}
|
|
|
|
// Modules
|
|
$modules = [];
|
|
|
|
if (!empty($tables['#__modules'])) {
|
|
foreach ($tables['#__modules'] as $row) {
|
|
$modules[] = [
|
|
'id' => (int) ($row['id'] ?? 0),
|
|
'title' => $row['title'] ?? '',
|
|
'module' => $row['module'] ?? '',
|
|
'position' => $row['position'] ?? '',
|
|
'published' => (int) ($row['published'] ?? 0),
|
|
];
|
|
}
|
|
}
|
|
|
|
$this->sendJson([
|
|
'error' => false,
|
|
'articles' => $articles,
|
|
'categories' => $categories,
|
|
'modules' => $modules,
|
|
'total_articles' => \count($articles),
|
|
'total_categories' => \count($categories),
|
|
'total_modules' => \count($modules),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Count backup records that would be purged before a given date.
|
|
* POST: task=ajax.countPurge&date=2025-01-01
|
|
*/
|
|
public function countPurge(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.purge', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$date = $this->input->getString('date', '');
|
|
|
|
if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid date format. Expected YYYY-MM-DD.']);
|
|
|
|
return;
|
|
}
|
|
|
|
$cutoff = $date . ' 00:00:00';
|
|
|
|
try {
|
|
$db = \Joomla\CMS\Factory::getDbo();
|
|
$query = $db->getQuery(true)
|
|
->select('COUNT(*)')
|
|
->from($db->quoteName('#__mokosuitebackup_records'))
|
|
->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
|
|
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
|
|
$db->setQuery($query);
|
|
$count = (int) $db->loadResult();
|
|
} catch (\Exception $e) {
|
|
error_log('MokoSuiteBackup: countPurge() DB error: ' . $e->getMessage());
|
|
$this->sendJson(['error' => true, 'message' => 'Database error'], 500);
|
|
|
|
return;
|
|
}
|
|
|
|
$this->sendJson([
|
|
'error' => false,
|
|
'count' => $count,
|
|
'date' => $date,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Compare two backup records side-by-side.
|
|
* POST: task=ajax.compareBackups&id1=123&id2=456
|
|
*/
|
|
public function compareBackups(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.compare', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$id1 = $this->input->getInt('id1', 0);
|
|
$id2 = $this->input->getInt('id2', 0);
|
|
|
|
if (!$id1 || !$id2) {
|
|
$this->sendJson(['error' => true, 'message' => 'Two backup record IDs are required']);
|
|
|
|
return;
|
|
}
|
|
|
|
if ($id1 === $id2) {
|
|
$this->sendJson(['error' => true, 'message' => 'Please select two different backup records']);
|
|
|
|
return;
|
|
}
|
|
|
|
$fields = [
|
|
'r.id', 'r.description', 'r.status', 'r.backup_type',
|
|
'r.total_size', 'r.db_size', 'r.files_count', 'r.tables_count',
|
|
'r.backupstart', 'r.backupend',
|
|
];
|
|
|
|
try {
|
|
$db = \Joomla\CMS\Factory::getDbo();
|
|
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName($fields))
|
|
->select($db->quoteName('p.title', 'profile_title'))
|
|
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
|
|
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p')
|
|
. ' ON ' . $db->quoteName('p.id') . ' = ' . $db->quoteName('r.profile_id'))
|
|
->where($db->quoteName('r.id') . ' IN (' . (int) $id1 . ', ' . (int) $id2 . ')');
|
|
|
|
$db->setQuery($query);
|
|
$rows = $db->loadObjectList('id');
|
|
} catch (\Exception $e) {
|
|
error_log('MokoSuiteBackup: compareBackups() DB error: ' . $e->getMessage());
|
|
$this->sendJson(['error' => true, 'message' => 'Failed to load backup records'], 500);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!isset($rows[$id1])) {
|
|
$this->sendJson(['error' => true, 'message' => 'Backup record #' . $id1 . ' not found'], 404);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!isset($rows[$id2])) {
|
|
$this->sendJson(['error' => true, 'message' => 'Backup record #' . $id2 . ' not found'], 404);
|
|
|
|
return;
|
|
}
|
|
|
|
$b1 = $rows[$id1];
|
|
$b2 = $rows[$id2];
|
|
|
|
// Calculate durations in seconds
|
|
$duration1 = 0;
|
|
$duration2 = 0;
|
|
|
|
if ($b1->backupstart !== '0000-00-00 00:00:00' && $b1->backupend !== '0000-00-00 00:00:00') {
|
|
$duration1 = strtotime($b1->backupend) - strtotime($b1->backupstart);
|
|
}
|
|
|
|
if ($b2->backupstart !== '0000-00-00 00:00:00' && $b2->backupend !== '0000-00-00 00:00:00') {
|
|
$duration2 = strtotime($b2->backupend) - strtotime($b2->backupstart);
|
|
}
|
|
|
|
$formatRecord = function ($row) {
|
|
return [
|
|
'id' => (int) $row->id,
|
|
'description' => $row->description,
|
|
'status' => $row->status,
|
|
'backup_type' => $row->backup_type,
|
|
'total_size' => (int) $row->total_size,
|
|
'db_size' => (int) $row->db_size,
|
|
'files_count' => (int) $row->files_count,
|
|
'tables_count' => (int) $row->tables_count,
|
|
'backupstart' => $row->backupstart,
|
|
'backupend' => $row->backupend,
|
|
'profile_title' => $row->profile_title ?? '',
|
|
];
|
|
};
|
|
|
|
$this->sendJson([
|
|
'error' => false,
|
|
'backup1' => $formatRecord($b1),
|
|
'backup2' => $formatRecord($b2),
|
|
'delta' => [
|
|
'size_diff' => (int) $b2->total_size - (int) $b1->total_size,
|
|
'files_diff' => (int) $b2->files_count - (int) $b1->files_count,
|
|
'tables_diff' => (int) $b2->tables_count - (int) $b1->tables_count,
|
|
'duration_diff_seconds' => $duration2 - $duration1,
|
|
],
|
|
]);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Remote Destinations CRUD
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* List remote destinations for a profile.
|
|
* POST: task=ajax.listRemotes&profile_id=1
|
|
*/
|
|
public function listRemotes(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$profileId = $this->input->getInt('profile_id', 0);
|
|
|
|
if (!$profileId) {
|
|
$this->sendJson(['error' => true, 'message' => 'Missing profile_id']);
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$db = Factory::getDbo();
|
|
$query = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
|
->where($db->quoteName('profile_id') . ' = ' . $profileId)
|
|
->order($db->quoteName('ordering') . ' ASC, ' . $db->quoteName('id') . ' ASC');
|
|
$db->setQuery($query);
|
|
$rows = $db->loadObjectList();
|
|
} catch (\Exception $e) {
|
|
$this->sendJson(['error' => true, 'message' => 'Database error'], 500);
|
|
|
|
return;
|
|
}
|
|
|
|
// Decode JSON params and mask secrets
|
|
$items = [];
|
|
|
|
foreach ($rows as $row) {
|
|
$config = json_decode($row->params, true) ?: [];
|
|
|
|
// Mask sensitive fields so they never leave the server in list views
|
|
$masked = $this->maskSecrets($config, $row->type);
|
|
|
|
$items[] = [
|
|
'id' => (int) $row->id,
|
|
'profile_id' => (int) $row->profile_id,
|
|
'title' => $row->title,
|
|
'type' => $row->type,
|
|
'enabled' => (int) $row->enabled,
|
|
'params' => $masked,
|
|
'ordering' => (int) $row->ordering,
|
|
];
|
|
}
|
|
|
|
$this->sendJson(['error' => false, 'items' => $items]);
|
|
}
|
|
|
|
/**
|
|
* Save (create or update) a remote destination.
|
|
* POST: task=ajax.saveRemote (JSON body or form fields)
|
|
*/
|
|
public function saveRemote(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$id = $this->input->getInt('remote_id', 0);
|
|
$profileId = $this->input->getInt('profile_id', 0);
|
|
$title = trim($this->input->getString('remote_title', ''));
|
|
$type = $this->input->getCmd('remote_type', 'sftp');
|
|
$enabled = $this->input->getInt('remote_enabled', 1);
|
|
$configRaw = $this->input->getString('remote_config', '{}');
|
|
|
|
if (!$profileId) {
|
|
$this->sendJson(['error' => true, 'message' => 'Missing profile_id']);
|
|
|
|
return;
|
|
}
|
|
|
|
if (empty($title)) {
|
|
$this->sendJson(['error' => true, 'message' => 'Title is required']);
|
|
|
|
return;
|
|
}
|
|
|
|
$config = json_decode($configRaw, true);
|
|
|
|
if (!is_array($config)) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid config JSON']);
|
|
|
|
return;
|
|
}
|
|
|
|
// If editing, merge secrets that were masked with __KEEP_EXISTING__
|
|
if ($id) {
|
|
$config = $this->mergeExistingSecrets($id, $config, $type);
|
|
}
|
|
|
|
$db = Factory::getDbo();
|
|
|
|
try {
|
|
$table = new \Joomla\Component\MokoSuiteBackup\Administrator\Table\RemoteTable($db);
|
|
|
|
if ($id) {
|
|
$table->load($id);
|
|
|
|
// Verify ownership
|
|
if ((int) $table->profile_id !== $profileId) {
|
|
$this->sendJson(['error' => true, 'message' => 'Remote does not belong to this profile'], 403);
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
$table->profile_id = $profileId;
|
|
$table->title = $title;
|
|
$table->type = $type;
|
|
$table->enabled = $enabled ? 1 : 0;
|
|
$table->params = json_encode($config);
|
|
if (!$table->check() || !$table->store()) {
|
|
$this->sendJson(['error' => true, 'message' => $table->getError() ?: 'Save failed']);
|
|
|
|
return;
|
|
}
|
|
|
|
$this->sendJson(['error' => false, 'id' => (int) $table->id, 'message' => 'Saved']);
|
|
} catch (\Exception $e) {
|
|
$this->sendJson(['error' => true, 'message' => 'Database error: ' . $e->getMessage()], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a remote destination.
|
|
* POST: task=ajax.deleteRemote&remote_id=1&profile_id=1
|
|
*/
|
|
public function deleteRemote(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$id = $this->input->getInt('remote_id', 0);
|
|
$profileId = $this->input->getInt('profile_id', 0);
|
|
|
|
if (!$id || !$profileId) {
|
|
$this->sendJson(['error' => true, 'message' => 'Missing remote_id or profile_id']);
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$db = Factory::getDbo();
|
|
$query = $db->getQuery(true)
|
|
->delete($db->quoteName('#__mokosuitebackup_remotes'))
|
|
->where($db->quoteName('id') . ' = ' . $id)
|
|
->where($db->quoteName('profile_id') . ' = ' . $profileId);
|
|
$db->setQuery($query);
|
|
$db->execute();
|
|
|
|
$this->sendJson(['error' => false, 'message' => 'Deleted']);
|
|
} catch (\Exception $e) {
|
|
$this->sendJson(['error' => true, 'message' => 'Database error'], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle enabled/disabled for a remote destination.
|
|
* POST: task=ajax.toggleRemote&remote_id=1&profile_id=1
|
|
*/
|
|
public function toggleRemote(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$id = $this->input->getInt('remote_id', 0);
|
|
$profileId = $this->input->getInt('profile_id', 0);
|
|
|
|
if (!$id || !$profileId) {
|
|
$this->sendJson(['error' => true, 'message' => 'Missing remote_id or profile_id']);
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$db = Factory::getDbo();
|
|
|
|
// Load current state
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName('enabled'))
|
|
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
|
->where($db->quoteName('id') . ' = ' . $id)
|
|
->where($db->quoteName('profile_id') . ' = ' . $profileId);
|
|
$db->setQuery($query);
|
|
$current = $db->loadResult();
|
|
|
|
if ($current === null) {
|
|
$this->sendJson(['error' => true, 'message' => 'Remote not found'], 404);
|
|
|
|
return;
|
|
}
|
|
|
|
$newState = $current ? 0 : 1;
|
|
|
|
$update = $db->getQuery(true)
|
|
->update($db->quoteName('#__mokosuitebackup_remotes'))
|
|
->set($db->quoteName('enabled') . ' = ' . $newState)
|
|
->set($db->quoteName('modified') . ' = ' . $db->quote(date('Y-m-d H:i:s')))
|
|
->where($db->quoteName('id') . ' = ' . $id)
|
|
->where($db->quoteName('profile_id') . ' = ' . $profileId);
|
|
$db->setQuery($update);
|
|
$db->execute();
|
|
|
|
$this->sendJson(['error' => false, 'enabled' => $newState]);
|
|
} catch (\Exception $e) {
|
|
$this->sendJson(['error' => true, 'message' => 'Database error'], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mask sensitive values in a remote config array for display.
|
|
*/
|
|
private function maskSecrets(array $config, string $type): array
|
|
{
|
|
$secrets = [
|
|
'sftp' => ['password', 'passphrase', 'key_data'],
|
|
's3' => ['secret_key'],
|
|
'google_drive' => ['client_secret', 'refresh_token'],
|
|
];
|
|
|
|
$fields = $secrets[$type] ?? [];
|
|
|
|
foreach ($fields as $field) {
|
|
if (!empty($config[$field])) {
|
|
$config[$field] = '********';
|
|
}
|
|
}
|
|
|
|
return $config;
|
|
}
|
|
|
|
/**
|
|
* When updating a remote, merge back secrets that were masked in the form.
|
|
*/
|
|
private function mergeExistingSecrets(int $id, array $config, string $type): array
|
|
{
|
|
$secrets = [
|
|
'sftp' => ['password', 'passphrase', 'key_data'],
|
|
's3' => ['secret_key'],
|
|
'google_drive' => ['client_secret', 'refresh_token'],
|
|
];
|
|
|
|
$fields = $secrets[$type] ?? [];
|
|
$needsMerge = false;
|
|
|
|
foreach ($fields as $field) {
|
|
if (isset($config[$field]) && ($config[$field] === '********' || $config[$field] === '__KEEP_EXISTING__')) {
|
|
$needsMerge = true;
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$needsMerge) {
|
|
return $config;
|
|
}
|
|
|
|
// Load existing config from DB
|
|
try {
|
|
$db = Factory::getDbo();
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName('params'))
|
|
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
|
->where($db->quoteName('id') . ' = ' . $id);
|
|
$db->setQuery($query);
|
|
$existing = json_decode($db->loadResult() ?: '{}', true) ?: [];
|
|
} catch (\Exception $e) {
|
|
return $config;
|
|
}
|
|
|
|
foreach ($fields as $field) {
|
|
if (isset($config[$field]) && ($config[$field] === '********' || $config[$field] === '__KEEP_EXISTING__')) {
|
|
$config[$field] = $existing[$field] ?? '';
|
|
}
|
|
}
|
|
|
|
return $config;
|
|
}
|
|
|
|
/**
|
|
* Browse directories on a remote SFTP server for the path picker.
|
|
* POST: task=ajax.browseSftpDir&profile_id=1&path=/some/path
|
|
*/
|
|
public function browseSftpDir(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$profileId = $this->input->getInt('profile_id', 0);
|
|
|
|
if (!$profileId) {
|
|
$this->sendJson(['error' => true, 'message' => 'Missing profile_id']);
|
|
|
|
return;
|
|
}
|
|
|
|
/* Load the profile to get SFTP credentials */
|
|
try {
|
|
$db = Factory::getDbo();
|
|
$query = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
|
->where($db->quoteName('id') . ' = ' . $profileId);
|
|
$db->setQuery($query);
|
|
$profile = $db->loadObject();
|
|
} catch (\Exception $e) {
|
|
$this->sendJson(['error' => true, 'message' => 'Failed to load profile'], 500);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$profile) {
|
|
$this->sendJson(['error' => true, 'message' => 'Profile not found'], 404);
|
|
|
|
return;
|
|
}
|
|
|
|
$host = $profile->sftp_host ?? '';
|
|
$port = (int) ($profile->sftp_port ?? 22);
|
|
$username = $profile->sftp_username ?? '';
|
|
$keyData = $profile->sftp_key_data ?? '';
|
|
$password = $profile->sftp_password ?? '';
|
|
|
|
if (empty($host) || empty($username)) {
|
|
$this->sendJson(['error' => true, 'message' => 'SFTP host and username must be configured and saved before browsing']);
|
|
|
|
return;
|
|
}
|
|
|
|
if (empty($keyData) && empty($password)) {
|
|
$this->sendJson(['error' => true, 'message' => 'SFTP credentials (key or password) must be configured and saved before browsing']);
|
|
|
|
return;
|
|
}
|
|
|
|
$requestPath = $this->input->getString('path', '/');
|
|
|
|
/* Sanitize: must start with / and not contain shell meta-characters */
|
|
$requestPath = '/' . ltrim($requestPath, '/');
|
|
|
|
if (preg_match('/[;&|`$<>]/', $requestPath)) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid path characters']);
|
|
|
|
return;
|
|
}
|
|
|
|
$keyFile = null;
|
|
|
|
try {
|
|
/* Write temp key if using key auth (same pattern as SftpUploader) */
|
|
if (!empty($keyData)) {
|
|
$keyContent = base64_decode($keyData, true);
|
|
|
|
if ($keyContent === false) {
|
|
$keyContent = $keyData;
|
|
}
|
|
|
|
$keyFile = sys_get_temp_dir() . '/mokobackup-sftp-browse-' . bin2hex(random_bytes(8)) . '.key';
|
|
|
|
if (file_put_contents($keyFile, $keyContent) === false) {
|
|
throw new \RuntimeException('Cannot write temporary SSH key file');
|
|
}
|
|
|
|
chmod($keyFile, 0600);
|
|
}
|
|
|
|
/* Build SSH command to list directories */
|
|
$escapedPath = escapeshellarg($requestPath);
|
|
$remoteCmd = 'ls -1pa ' . $escapedPath . ' 2>/dev/null | grep "/$"';
|
|
|
|
$parts = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10'];
|
|
|
|
if ($port !== 22) {
|
|
$parts[] = '-p';
|
|
$parts[] = (string) $port;
|
|
}
|
|
|
|
if ($keyFile !== null) {
|
|
$parts[] = '-i';
|
|
$parts[] = escapeshellarg($keyFile);
|
|
}
|
|
|
|
$parts[] = escapeshellarg($username . '@' . $host);
|
|
$parts[] = escapeshellarg($remoteCmd);
|
|
|
|
$cmd = implode(' ', $parts);
|
|
|
|
$output = [];
|
|
$exitCode = 0;
|
|
exec($cmd . ' 2>&1', $output, $exitCode);
|
|
|
|
/* exitCode 1 from grep means no matches (empty dir), which is OK */
|
|
if ($exitCode !== 0 && $exitCode !== 1) {
|
|
throw new \RuntimeException('SSH command failed (exit ' . $exitCode . '): ' . implode(' ', $output));
|
|
}
|
|
|
|
/* Parse output: each line is a directory name ending with / */
|
|
$dirs = [];
|
|
|
|
foreach ($output as $line) {
|
|
$line = trim($line);
|
|
|
|
if ($line === '' || $line === './' || $line === '../') {
|
|
continue;
|
|
}
|
|
|
|
$dirName = rtrim($line, '/');
|
|
|
|
if ($dirName === '' || $dirName === '.' || $dirName === '..') {
|
|
continue;
|
|
}
|
|
|
|
$fullPath = rtrim($requestPath, '/') . '/' . $dirName;
|
|
|
|
$dirs[] = [
|
|
'name' => $dirName,
|
|
'path' => $fullPath,
|
|
];
|
|
}
|
|
|
|
usort($dirs, fn($a, $b) => strcasecmp($a['name'], $b['name']));
|
|
|
|
/* Parent path */
|
|
$parent = null;
|
|
|
|
if ($requestPath !== '/') {
|
|
$parent = \dirname($requestPath);
|
|
|
|
if ($parent === '') {
|
|
$parent = '/';
|
|
}
|
|
}
|
|
|
|
$this->sendJson([
|
|
'error' => false,
|
|
'current' => $requestPath,
|
|
'parent' => $parent,
|
|
'dirs' => $dirs,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
$this->sendJson(['error' => true, 'message' => 'SFTP browse failed: ' . $e->getMessage()]);
|
|
} finally {
|
|
if ($keyFile !== null && is_file($keyFile)) {
|
|
unlink($keyFile);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a JSON response and close the application.
|
|
*/
|
|
private function sendJson(array $data, int $status = 200): void
|
|
{
|
|
$app = $this->app;
|
|
$app->setHeader('status', $status);
|
|
$app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
$app->setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
$app->sendHeaders();
|
|
|
|
echo json_encode($data);
|
|
|
|
$app->close();
|
|
}
|
|
}
|