Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Engine/SevenZipArchiver.php
T
Jonathan Miller 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
feat: purge, CPanel module, 7z format, SFTP browser (#119, #105, #122, #98)
#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
2026-06-23 13:05:42 -05:00

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);
}
}