899a33bc58
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 10s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 3s
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 4s
Universal: PR Check / Validate PR (pull_request) Failing after 3s
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 13s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 4m50s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
#119: Manual purge — toolbar button opens modal with date picker, AJAX count preview, confirmation before bulk delete. #105: CPanel admin dashboard module (mod_mokosuitebackup_cpanel) — backup status, quick action buttons per profile, next scheduled, stats, and quick links. Registered in package manifest. #122: 7z archive format via system 7za/7z CLI binary with optional password encryption. New SevenZipArchiver engine class. #98: SFTP remote file browser — custom SftpPathField with "Browse Remote" button, modal directory listing via SSH ls, click to navigate, double-click to select. Also: CHANGELOG updated, wiki Home updated, #121 verified (encryption field already visible in Archive Settings tab). Closes #119, closes #105, closes #122, closes #98, closes #121
261 lines
7.1 KiB
PHP
261 lines
7.1 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
|
|
*/
|
|
|
|
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
/**
|
|
* 7z archiver using the 7za/7z CLI binary.
|
|
*
|
|
* Requires p7zip-full (Linux) or 7-Zip (Windows) to be installed on the server.
|
|
* Supports native AES-256 encryption via the -p flag.
|
|
*/
|
|
class SevenZipArchiver implements ArchiverInterface
|
|
{
|
|
/** @var string Absolute path to the target archive */
|
|
private string $archivePath = '';
|
|
|
|
/** @var string[] Absolute paths of files to add */
|
|
private array $filePaths = [];
|
|
|
|
/** @var string[] Corresponding local names inside the archive */
|
|
private array $localNames = [];
|
|
|
|
/** @var string[] Temp files created by addFromString() that must be cleaned up */
|
|
private array $tempFiles = [];
|
|
|
|
/** @var string Optional encryption password */
|
|
private string $encryptionPassword = '';
|
|
|
|
/**
|
|
* Set the encryption password for the archive.
|
|
*
|
|
* @param string $password Password for AES-256 encryption
|
|
*/
|
|
public function setEncryptionPassword(string $password): void
|
|
{
|
|
$this->encryptionPassword = $password;
|
|
}
|
|
|
|
public function open(string $path): void
|
|
{
|
|
$this->archivePath = $path;
|
|
$this->filePaths = [];
|
|
$this->localNames = [];
|
|
$this->tempFiles = [];
|
|
|
|
// Remove existing archive to avoid appending to stale data
|
|
if (is_file($path)) {
|
|
@unlink($path);
|
|
}
|
|
}
|
|
|
|
public function addFromString(string $localName, string $contents): void
|
|
{
|
|
// Write to a temp file so 7z can read it from disk
|
|
$tempDir = \dirname($this->archivePath);
|
|
$tempFile = $tempDir . '/.7z-tmp-' . md5($localName . microtime(true)) . '-' . basename($localName);
|
|
|
|
if (file_put_contents($tempFile, $contents) === false) {
|
|
throw new \RuntimeException('SevenZipArchiver: cannot write temp file: ' . $tempFile);
|
|
}
|
|
|
|
$this->tempFiles[] = $tempFile;
|
|
$this->filePaths[] = $tempFile;
|
|
$this->localNames[] = $localName;
|
|
}
|
|
|
|
public function addFile(string $filePath, string $localName): void
|
|
{
|
|
$this->filePaths[] = $filePath;
|
|
$this->localNames[] = $localName;
|
|
}
|
|
|
|
public function close(): void
|
|
{
|
|
try {
|
|
$this->buildArchive();
|
|
} finally {
|
|
// Always clean up temp files
|
|
foreach ($this->tempFiles as $tempFile) {
|
|
if (is_file($tempFile)) {
|
|
@unlink($tempFile);
|
|
}
|
|
}
|
|
|
|
$this->tempFiles = [];
|
|
}
|
|
}
|
|
|
|
public function getExtension(): string
|
|
{
|
|
return '7z';
|
|
}
|
|
|
|
/**
|
|
* Build the 7z archive using the CLI binary.
|
|
*
|
|
* Writes a list file mapping local names to absolute paths, then invokes
|
|
* 7za/7z to create the archive. Uses stdin rename pairs for correct
|
|
* internal paths.
|
|
*/
|
|
private function buildArchive(): void
|
|
{
|
|
$binary = $this->findBinary();
|
|
|
|
if ($binary === null) {
|
|
throw new \RuntimeException(
|
|
'SevenZipArchiver: 7z/7za binary not found. '
|
|
. 'Install p7zip-full (Linux) or 7-Zip (Windows).'
|
|
);
|
|
}
|
|
|
|
if (empty($this->filePaths)) {
|
|
throw new \RuntimeException('SevenZipArchiver: no files to archive');
|
|
}
|
|
|
|
// Strategy: create a temporary staging directory with the correct
|
|
// directory structure, symlink or copy files, then archive the
|
|
// staging directory. This gives us correct internal paths.
|
|
$stagingDir = \dirname($this->archivePath) . '/.7z-staging-' . md5($this->archivePath . microtime(true));
|
|
|
|
if (!mkdir($stagingDir, 0755, true)) {
|
|
throw new \RuntimeException('SevenZipArchiver: cannot create staging directory: ' . $stagingDir);
|
|
}
|
|
|
|
try {
|
|
// Create the directory structure and link/copy files
|
|
foreach ($this->filePaths as $i => $sourcePath) {
|
|
$localName = $this->localNames[$i];
|
|
$targetPath = $stagingDir . '/' . $localName;
|
|
$targetDir = \dirname($targetPath);
|
|
|
|
if (!is_dir($targetDir) && !mkdir($targetDir, 0755, true)) {
|
|
throw new \RuntimeException('SevenZipArchiver: cannot create directory: ' . $targetDir);
|
|
}
|
|
|
|
// Use symlink where possible (faster, no disk usage), fall back to copy
|
|
if (@symlink($sourcePath, $targetPath) === false) {
|
|
if (!copy($sourcePath, $targetPath)) {
|
|
throw new \RuntimeException('SevenZipArchiver: cannot copy file: ' . $sourcePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build command
|
|
$cmd = escapeshellarg($binary)
|
|
. ' a'
|
|
. ' -t7z'
|
|
. ' -mx=5'
|
|
. ' -mhe=on'
|
|
. ' ' . escapeshellarg($this->archivePath)
|
|
. ' ' . escapeshellarg($stagingDir . '/*');
|
|
|
|
// Add encryption if password is set
|
|
if ($this->encryptionPassword !== '') {
|
|
$cmd .= ' -p' . escapeshellarg($this->encryptionPassword);
|
|
}
|
|
|
|
// Suppress interactive prompts
|
|
$cmd .= ' -y';
|
|
|
|
// Redirect stderr to stdout for capture
|
|
$cmd .= ' 2>&1';
|
|
|
|
$output = [];
|
|
$exitCode = 0;
|
|
exec($cmd, $output, $exitCode);
|
|
|
|
if ($exitCode !== 0) {
|
|
$outputStr = implode("\n", $output);
|
|
throw new \RuntimeException(
|
|
'SevenZipArchiver: 7z exited with code ' . $exitCode . ': ' . $outputStr
|
|
);
|
|
}
|
|
|
|
if (!is_file($this->archivePath)) {
|
|
throw new \RuntimeException('SevenZipArchiver: archive was not created: ' . $this->archivePath);
|
|
}
|
|
|
|
// The archive contains paths relative to the staging dir.
|
|
// We need to verify that the internal structure doesn't include
|
|
// the staging dir name as a prefix. If 7z was given staging/*,
|
|
// the paths inside should be correct (relative to staging).
|
|
} finally {
|
|
// Remove staging directory
|
|
$this->removeDirectory($stagingDir);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Locate the 7z or 7za binary.
|
|
*
|
|
* @return string|null Absolute path to binary, or null if not found
|
|
*/
|
|
private function findBinary(): ?string
|
|
{
|
|
// Check common binary names
|
|
$candidates = PHP_OS_FAMILY === 'Windows'
|
|
? ['7z', '7za', 'C:\\Program Files\\7-Zip\\7z.exe', 'C:\\Program Files (x86)\\7-Zip\\7z.exe']
|
|
: ['7za', '7z', '/usr/bin/7za', '/usr/bin/7z', '/usr/local/bin/7za', '/usr/local/bin/7z'];
|
|
|
|
foreach ($candidates as $candidate) {
|
|
// If it's an absolute path, check file existence
|
|
if (str_contains($candidate, DIRECTORY_SEPARATOR) || str_contains($candidate, '/')) {
|
|
if (is_file($candidate) && is_executable($candidate)) {
|
|
return $candidate;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
// Use 'which' / 'where' to find in PATH
|
|
$whichCmd = PHP_OS_FAMILY === 'Windows'
|
|
? 'where ' . escapeshellarg($candidate) . ' 2>NUL'
|
|
: 'which ' . escapeshellarg($candidate) . ' 2>/dev/null';
|
|
|
|
$result = trim((string) shell_exec($whichCmd));
|
|
|
|
if ($result !== '' && is_executable($result)) {
|
|
return $result;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Recursively remove a directory and its contents.
|
|
*/
|
|
private function removeDirectory(string $dir): void
|
|
{
|
|
if (!is_dir($dir)) {
|
|
return;
|
|
}
|
|
|
|
$items = new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
|
|
\RecursiveIteratorIterator::CHILD_FIRST
|
|
);
|
|
|
|
foreach ($items as $item) {
|
|
if ($item->isDir()) {
|
|
@rmdir($item->getPathname());
|
|
} else {
|
|
// Remove symlinks and files
|
|
@unlink($item->getPathname());
|
|
}
|
|
}
|
|
|
|
@rmdir($dir);
|
|
}
|
|
}
|