Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php
T
Jonathan Miller e62dba8f40
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 7s
Universal: PR Check / Branch Policy (pull_request) Failing after 3s
Universal: PR Check / Secret Scan (pull_request) Successful in 9s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Generic: Repo Health / Access control (pull_request) Successful in 3s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 16s
Universal: Build & Release / Promote to RC (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: Extension CI / Lint & Validate (pull_request) Failing after 53s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 31s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 8m41s
feat: standalone restore script — separate file that scans for ZIPs (#107)
New MokoRestore mode: 'standalone' generates restore.php as a separate
file that scans its directory for ZIP backup archives and lets the user
choose which one to restore. Unlike 'wrapped' mode which bundles
restore.php inside the backup ZIP, standalone mode keeps both files
separate — ideal for remote servers where you SCP the backup.

Changes:
- MokoRestore::generateStandalone() — writes restore.php with ZIP scanner
- Profile form: include_mokorestore now a dropdown (none/wrapped/standalone)
- BackupEngine: standalone mode writes restore.php + uploads to remote
- Restore script uses safe DOM methods (no innerHTML with user data)

Closes #107
2026-06-23 11:20:23 -05:00

711 lines
23 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;
use Joomla\CMS\Factory;
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
use Joomla\Event\Event;
class BackupEngine
{
private string $backupDir;
private array $log = [];
/**
* Run a backup using the specified profile.
*
* @param int $profileId Profile ID to use
* @param string $description Human-readable description
* @param string $origin Origin: backend, cli, api, scheduled
*
* @return array{success: bool, message: string, record_id?: int}
*/
public function run(int $profileId, string $description, string $origin = 'backend'): array
{
// Run pre-flight checks before creating any backup record
$preflight = new PreflightCheck();
$preflightResult = $preflight->run($profileId);
if (!$preflightResult['pass']) {
return [
'success' => false,
'message' => 'Pre-flight failed: ' . implode('; ', $preflightResult['errors']),
'warnings' => $preflightResult['warnings'],
];
}
// Override PHP limits for long-running backup operations
$this->overridePhpLimits();
$db = Factory::getDbo();
// Load profile
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . $profileId);
$db->setQuery($query);
$profile = $db->loadObject();
if (!$profile) {
return ['success' => false, 'message' => 'Profile not found: ' . $profileId, 'warnings' => []];
}
// Log any preflight warnings
foreach ($preflightResult['warnings'] as $warning) {
$this->log('PREFLIGHT WARNING: ' . $warning);
}
// Read settings directly from profile columns
$excludeDirs = BackupDirectory::parseNewlineList($profile->exclude_dirs ?? '');
$excludeFiles = BackupDirectory::parseNewlineList($profile->exclude_files ?? '');
$excludeTables = BackupDirectory::parseNewlineList($profile->exclude_tables ?? '');
// Resolve placeholders in directory and filename
$resolver = new PlaceholderResolver($profile);
$configuredDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
$this->backupDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
if (!BackupDirectory::ensureReady($this->backupDir)) {
return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0, 'warnings' => $preflightResult['warnings']];
}
// Create backup record
$now = date('Y-m-d H:i:s');
$tag = $resolver->getTag();
$archiveFormat = $profile->archive_format ?? 'zip';
$archiveName = '';
$archiver = $this->createArchiver($archiveFormat);
$archiveExt = $archiver->getExtension();
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
$archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt;
if (empty($description)) {
$description = $profile->title . ' — ' . $now;
}
$record = (object) [
'profile_id' => $profileId,
'description' => $description,
'status' => 'running',
'origin' => $origin,
'backup_type' => $profile->backup_type,
'archivename' => $archiveName,
'absolute_path' => $this->backupDir . '/' . $archiveName,
'total_size' => 0,
'db_size' => 0,
'files_count' => 0,
'tables_count' => 0,
'multipart' => 0,
'tag' => $tag,
'backupstart' => $now,
'backupend' => '0000-00-00 00:00:00',
'filesexist' => 0,
'remote_filename' => '',
'log' => '',
];
$db->insertObject('#__mokosuitebackup_records', $record, 'id');
$recordId = $record->id;
try {
$this->log('Backup started: ' . $description);
$archivePath = $this->backupDir . '/' . $archiveName;
// Create archive
$archiver->open($archivePath);
$dbSize = 0;
$filesCount = 0;
$tablesCount = 0;
// Step 1: Database dump (unless files-only)
// Streams to a temp file to avoid loading the entire dump into RAM
$sqlTempFile = '';
if ($profile->backup_type !== 'files') {
$this->log('Starting database dump...');
$sqlTempFile = $this->backupDir . '/.database-' . $tag . '.sql';
$dumper = new DatabaseDumper($excludeTables);
$dbSize = $dumper->dumpToFile($sqlTempFile);
$archiver->addFile($sqlTempFile, 'database.sql');
$tablesCount = $dumper->getTablesCount();
$this->log('Database dump complete: ' . $tablesCount . ' tables, ' . number_format($dbSize) . ' bytes');
}
// Step 2: Files (unless database-only)
$manifest = [];
if ($profile->backup_type !== 'database') {
$this->log('Starting file scan...');
$scanner = new FileScanner(JPATH_ROOT, $excludeDirs, $excludeFiles);
$allFiles = $scanner->scan();
// Differential: only include changed files
if ($profile->backup_type === 'differential') {
$baseManifest = $this->loadBaseManifest($db, $profileId);
if (empty($baseManifest)) {
$this->log('No base full backup found — running full backup instead');
$filesToBackup = $allFiles;
} else {
$filesToBackup = DifferentialScanner::getChangedFiles($allFiles, $baseManifest, JPATH_ROOT);
$this->log('Differential: ' . count($filesToBackup) . ' changed files out of ' . count($allFiles) . ' total');
}
} else {
$filesToBackup = $allFiles;
}
$filesCount = count($filesToBackup);
$this->log('Backing up ' . $filesCount . ' files');
$skippedFiles = 0;
foreach ($filesToBackup as $relativePath) {
$fullPath = JPATH_ROOT . '/' . $relativePath;
if (!is_file($fullPath) || !is_readable($fullPath)) {
$skippedFiles++;
continue;
}
// Store configuration.php as .bak with credentials stripped.
// The restore process rebuilds a fresh configuration.php
// from user input + non-sensitive values from the .bak.
if ($relativePath === 'configuration.php') {
$sanitized = self::sanitizeConfiguration($fullPath);
$archiver->addFromString('configuration.php.bak', $sanitized);
$this->log('configuration.php saved as .bak (credentials stripped)');
} else {
$archiver->addFile($fullPath, $relativePath);
}
}
if ($skippedFiles > 0) {
$this->log('WARNING: ' . $skippedFiles . ' files skipped (not readable or missing)');
}
$this->log('Files added to archive');
// Build manifest for full/differential backups (used by future differentials)
if ($profile->backup_type === 'full' || ($profile->backup_type === 'differential' && empty($baseManifest))) {
$manifest = DifferentialScanner::buildManifest($allFiles, JPATH_ROOT);
$this->log('File manifest built: ' . count($manifest) . ' entries');
}
}
$archiver->close();
// Clean up temp SQL file (no longer needed after archive is closed)
if (!empty($sqlTempFile) && is_file($sqlTempFile)) {
@unlink($sqlTempFile);
}
// Step 1.5: Apply AES-256 encryption (if configured)
$encryptionPassword = $profile->encryption_password ?? '';
if (!empty($encryptionPassword)) {
if ($archiveFormat !== 'zip') {
$this->log('WARNING: AES-256 encryption only supported for ZIP archives — skipping encryption');
} else {
$this->log('Encrypting archive with AES-256...');
$this->encryptArchive($archivePath, $encryptionPassword);
$this->log('Archive encrypted');
}
}
// Record archive size and compute checksum (after encryption)
$totalSize = file_exists($archivePath) ? filesize($archivePath) : 0;
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
$checksum = is_file($archivePath) ? hash_file('sha256', $archivePath) : '';
$this->log('Archive created: ' . $sizeHuman);
$this->log('SHA-256: ' . ($checksum ?: 'N/A'));
// Verify archive integrity
$this->log('Verifying archive integrity...');
$this->verifyArchive($archivePath, $profile->backup_type);
$this->log('Archive integrity verified');
// Step 2.5: MokoRestore script (if enabled)
$mokoRestoreMode = $profile->include_mokorestore ?? '0';
$restoreScriptPath = '';
if ($mokoRestoreMode === '1') {
// Wrapped mode: backup ZIP inside an outer ZIP with restore.php
$this->log('Wrapping with MokoRestore script...');
$mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName);
$mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName;
MokoRestore::wrap($archivePath, $mokoRestorePath);
if (is_file($archivePath) && !unlink($archivePath)) {
$this->log('WARNING: Could not remove pre-wrap archive');
}
rename($mokoRestorePath, $archivePath);
$totalSize = filesize($archivePath);
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
$checksum = hash_file('sha256', $archivePath);
$this->log('MokoRestore archive created: ' . $sizeHuman);
$this->log('SHA-256 (wrapped): ' . $checksum);
} elseif ($mokoRestoreMode === 'standalone') {
// Standalone mode: restore.php as a separate file next to the backup ZIP
$this->log('Generating standalone restore.php...');
$restoreScriptPath = $this->backupDir . '/restore.php';
MokoRestore::generateStandalone($restoreScriptPath);
$this->log('Standalone restore.php generated (' . number_format(filesize($restoreScriptPath)) . ' bytes)');
}
$remoteFilename = '';
$uploadFailed = false;
// Step 3: Remote upload (if configured)
// Wrapped in its own try-catch so a remote failure does not mark
// the entire backup as failed — the local archive is preserved.
$remoteStorage = $profile->remote_storage ?? 'none';
if ($remoteStorage !== 'none') {
try {
$this->log('Starting remote upload (' . $remoteStorage . ')...');
$uploader = $this->createUploader($remoteStorage, $profile);
$uploadResult = $uploader->upload($archivePath, $archiveName);
if ($uploadResult['success']) {
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
$this->log('Remote upload complete: ' . $uploadResult['message']);
// Upload standalone restore.php alongside the backup if in standalone mode
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
$this->log('Uploading standalone restore.php...');
$restoreUpload = $uploader->upload($restoreScriptPath, 'restore.php');
if ($restoreUpload['success']) {
$this->log('Standalone restore.php uploaded');
} else {
$this->log('WARNING: restore.php upload failed: ' . $restoreUpload['message']);
}
}
// Delete local copy if configured
if (empty($profile->remote_keep_local) && is_file($archivePath)) {
@unlink($archivePath);
$this->log('Local copy removed (remote_keep_local = off)');
}
} else {
$uploadFailed = true;
$this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
$this->log('Local backup is preserved.');
}
} catch (\Throwable $e) {
$uploadFailed = true;
$this->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
$this->log('Local backup is preserved.');
}
}
// Write log file alongside the archive
$logContent = implode("\n", $this->log);
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath);
if (@file_put_contents($logPath, $logContent) === false) {
error_log('MokoSuiteBackup: Could not write log file: ' . $logPath);
}
// Final record update (includes fields needed by NotificationSender)
$update = (object) [
'id' => $recordId,
'status' => 'complete',
'description' => $description,
'backup_type' => $profile->backup_type,
'archivename' => $archiveName,
'origin' => $origin,
'backupstart' => $now,
'total_size' => $totalSize,
'db_size' => $dbSize,
'files_count' => $filesCount,
'tables_count' => $tablesCount,
'backupend' => date('Y-m-d H:i:s'),
'filesexist' => is_file($archivePath) ? 1 : 0,
'remote_filename' => $remoteFilename,
'checksum' => $checksum,
'manifest' => !empty($manifest) ? json_encode($manifest) : '',
'log' => $logContent,
];
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
// Send success notification (backup completed, even if upload failed)
NotificationSender::send($profile, $update, true, implode("\n", $this->log));
// If remote upload failed, also send a failure notification for the upload
if ($uploadFailed) {
NotificationSender::send($profile, $update, false, "Remote upload failed — see backup log for details.\n\n" . implode("\n", $this->log));
}
// Dispatch event for actionlog and other listeners
$this->dispatchAfterRun(true, $recordId, $description, $profileId, $origin);
return [
'success' => true,
'message' => 'Backup complete: ' . $archiveName . ' (' . $sizeHuman . ')',
'record_id' => $recordId,
'warnings' => $preflightResult['warnings'],
];
} catch (\Throwable $e) {
$this->log('FATAL: ' . $e->getMessage());
// Clean up temp SQL file on failure
if (!empty($sqlTempFile) && is_file($sqlTempFile)) {
@unlink($sqlTempFile);
}
// If encryption was intended and failed, remove the plaintext archive
if (!empty($encryptionPassword) && !empty($archivePath) && is_file($archivePath)) {
@unlink($archivePath);
$this->log('Plaintext archive removed after encryption failure');
}
$update = (object) [
'id' => $recordId,
'status' => 'fail',
'description' => $description ?: '',
'backup_type' => $profile->backup_type ?? 'full',
'origin' => $origin,
'archivename' => $archiveName,
'backupstart' => $now ?? date('Y-m-d H:i:s'),
'backupend' => date('Y-m-d H:i:s'),
'total_size' => 0,
'files_count' => 0,
'tables_count' => 0,
'remote_filename' => '',
'log' => implode("\n", $this->log),
];
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
// Send failure notification
NotificationSender::send($profile, $update, false, implode("\n", $this->log));
// Dispatch event for actionlog and other listeners
$this->dispatchAfterRun(false, $recordId, $description, $profileId, $origin);
return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId, 'warnings' => $preflightResult['warnings'] ?? []];
}
}
/**
* Override PHP execution limits for backup operations.
* Attempts multiple methods since some hosts restrict ini_set.
*/
private function overridePhpLimits(): void
{
// Remove execution time limit (0 = unlimited)
@set_time_limit(0);
@ini_set('max_execution_time', '0');
// Increase memory limit for large sites
$currentMemory = $this->parseBytes(ini_get('memory_limit'));
if ($currentMemory > 0 && $currentMemory < 512 * 1024 * 1024) {
@ini_set('memory_limit', '512M');
}
// Disable output buffering to prevent memory buildup
while (@ob_end_clean()) {
// flush all output buffers
}
// Prevent browser/proxy timeout by disabling compression
@ini_set('zlib.output_compression', 'Off');
// Ignore user abort so backup completes even if browser closes
@ignore_user_abort(true);
}
/**
* Parse a PHP ini byte value (e.g. "128M") into bytes.
*/
private function parseBytes(string $value): int
{
$value = trim($value);
if ($value === '-1' || $value === '') {
return -1;
}
$unit = strtolower(substr($value, -1));
$num = (int) $value;
return match ($unit) {
'g' => $num * 1024 * 1024 * 1024,
'm' => $num * 1024 * 1024,
'k' => $num * 1024,
default => $num,
};
}
/**
* Create the appropriate archiver based on the archive format.
*/
private function createArchiver(string $format): ArchiverInterface
{
return match ($format) {
'zip' => new ZipArchiver(),
'tar.gz' => new TarGzArchiver(),
default => throw new \InvalidArgumentException('Unknown archive format: ' . $format),
};
}
/**
* Create the appropriate remote uploader based on the storage type.
*/
private function createUploader(string $type, object $profile): RemoteUploaderInterface
{
return match ($type) {
'ftp' => new FtpUploader($profile),
'sftp' => new SftpUploader($profile),
'google_drive' => new GoogleDriveUploader($profile),
's3' => new S3Uploader($profile),
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
};
}
/**
* Load the file manifest from the most recent full backup for this profile.
* Used by differential backups to determine which files changed.
*/
private function loadBaseManifest(object $db, int $profileId): array
{
$query = $db->getQuery(true)
->select($db->quoteName('manifest'))
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('profile_id') . ' = ' . $profileId)
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
->where($db->quoteName('manifest') . ' != ' . $db->quote(''))
->where($db->quoteName('backup_type') . ' = ' . $db->quote('full'))
->order($db->quoteName('backupstart') . ' DESC');
$db->setQuery($query, 0, 1);
$manifestJson = $db->loadResult();
if (empty($manifestJson)) {
return [];
}
return json_decode($manifestJson, true) ?: [];
}
/**
* Encrypt a ZIP archive using AES-256.
*
* Uses ZipArchive::setEncryptionName() (PHP 7.2+) which produces
* WinZip-compatible AES-256 encrypted archives. Falls back to
* re-creating the archive with per-file encryption if needed.
*/
private function encryptArchive(string $archivePath, string $password): void
{
if (!defined('ZipArchive::EM_AES_256')) {
throw new \RuntimeException(
'AES-256 ZIP encryption requires PHP 7.2+ compiled with libzip 1.2.0+. '
. 'Your PHP installation does not support ZipArchive::EM_AES_256.'
);
}
$zip = new \ZipArchive();
if ($zip->open($archivePath) !== true) {
throw new \RuntimeException('Cannot open archive for encryption');
}
$zip->setPassword($password);
$numFiles = $zip->numFiles;
for ($i = 0; $i < $numFiles; $i++) {
$name = $zip->getNameIndex($i);
if ($name === false) {
$this->log('WARNING: Could not read file at index ' . $i . ' during encryption — file may remain unencrypted');
continue;
}
$zip->setEncryptionName($name, \ZipArchive::EM_AES_256);
}
$zip->close();
}
/**
* Verify that a backup archive can be opened and contains expected entries.
*
* @param string $archivePath Absolute path to the archive file
* @param string $backupType Backup type: full, database, files, differential
*
* @throws \RuntimeException If the archive fails verification
*/
private function verifyArchive(string $archivePath, string $backupType): void
{
if (!is_file($archivePath)) {
throw new \RuntimeException('Archive file does not exist: ' . $archivePath);
}
$extension = strtolower(pathinfo($archivePath, PATHINFO_EXTENSION));
// Detect tar.gz (pathinfo only returns 'gz')
if ($extension === 'gz' && str_ends_with(strtolower($archivePath), '.tar.gz')) {
$this->verifyTarGzArchive($archivePath);
return;
}
// ZIP verification
$zip = new \ZipArchive();
if ($zip->open($archivePath, \ZipArchive::RDONLY) !== true) {
throw new \RuntimeException('Archive integrity check failed: cannot open ZIP file');
}
if ($zip->numFiles < 1) {
$zip->close();
throw new \RuntimeException('Archive integrity check failed: archive contains no files');
}
// Verify database.sql exists when backup includes database
if ($backupType !== 'files') {
if ($zip->locateName('database.sql') === false) {
$zip->close();
throw new \RuntimeException('Archive integrity check failed: database.sql missing from archive');
}
}
// Spot-check: verify the first entry is readable
$firstName = $zip->getNameIndex(0);
if ($firstName === false) {
$zip->close();
throw new \RuntimeException('Archive integrity check failed: cannot read first entry');
}
$zip->close();
}
/**
* Verify a tar.gz archive can be opened and iterated.
*
* @param string $archivePath Absolute path to the .tar.gz file
*
* @throws \RuntimeException If the archive fails verification
*/
private function verifyTarGzArchive(string $archivePath): void
{
try {
$phar = new \PharData($archivePath);
$count = 0;
foreach ($phar as $entry) {
// Spot-check: verify at least the first entry is accessible
$entry->getFilename();
$count++;
break;
}
if ($count === 0) {
throw new \RuntimeException('Archive integrity check failed: tar.gz archive contains no entries');
}
} catch (\RuntimeException $e) {
throw $e;
} catch (\Throwable $e) {
throw new \RuntimeException('Archive integrity check failed: ' . $e->getMessage());
}
}
/**
* Dispatch the onMokoSuiteBackupAfterRun event so plugins (actionlog, etc.) can react.
*/
private function dispatchAfterRun(bool $success, int $recordId, string $description, int $profileId, string $origin): void
{
try {
$app = Factory::getApplication();
$event = new Event('onMokoSuiteBackupAfterRun', [
'success' => $success,
'record_id' => $recordId,
'description' => $description,
'profile_id' => $profileId,
'origin' => $origin,
]);
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterRun', $event);
} catch (\Throwable $e) {
// Never let a listener failure break the backup result, but log it
error_log('MokoSuiteBackup: onAfterRun listener error: ' . $e->getMessage());
}
}
/**
* Sanitize configuration.php by replacing sensitive field values with
* [SANITIZED:fieldname] placeholders. Non-sensitive fields (sitename,
* debug, cache, SEF, etc.) are preserved as-is.
*
* @param string $path Absolute path to configuration.php
*
* @return string Sanitized file contents
*/
public static function sanitizeConfiguration(string $path): string
{
$content = file_get_contents($path);
if ($content === false) {
error_log('MokoSuiteBackup: sanitizeConfiguration() failed to read: ' . $path);
return '';
}
// Fields whose values must be replaced with placeholders.
// Grouped by category for maintainability.
$sensitiveFields = [
// Database
'host', 'user', 'password', 'db',
// Security
'secret',
// SMTP
'smtpuser', 'smtppass', 'smtphost',
// Proxy
'proxy_user', 'proxy_pass',
// Redis
'redis_server_auth', 'session_redis_server_auth',
// Database TLS
'dbsslkey', 'dbsslcert', 'dbsslca',
];
foreach ($sensitiveFields as $field) {
// Match: public $field = 'value'; (single-quoted)
$content = preg_replace(
'/^(\s*public\s+\$' . preg_quote($field, '/') . '\s*=\s*\').*?(\';)/m',
'$1[SANITIZED:' . $field . ']$2',
$content
);
// Match: public $field = "value"; (double-quoted)
$content = preg_replace(
'/^(\s*public\s+\$' . preg_quote($field, '/') . '\s*=\s*").*?("\s*;)/m',
'$1[SANITIZED:' . $field . ']$2',
$content
);
}
return $content;
}
private function log(string $message): void
{
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
}
}