Files
api/services/nginx/app/classes/backup_store.php
T

1641 lines
63 KiB
PHP

<?php
namespace classes;
require_once WD . '/classes/backup_schema_bootstrap.php';
require_once WD . '/modules/backups/backups_c.php';
use backups\backups_c;
use Exception;
use interfaces\minio_backups_i;
use RuntimeException;
use Throwable;
use traits\minio_t;
class backup_store implements minio_backups_i
{
use minio_t;
public const BACKUP_SCHEMA_VERSION = 2;
public const backup_s3_prefix = 'backups/';
public const metadata_s3_prefix = 'metadata/';
public const local_backup_path = '/tmp/backups/';
private const BACKUP_BUCKET = 'backups';
private const APP_DATA_BUCKETS = ['pdfs', 'uploads', 'attachments', 'invoices', 'truckwashdev'];
public backups_c $config;
private string $lock_owner;
/**
* @throws Exception
*/
public function __construct()
{
backup_schema_bootstrap::ensureTables();
$this->config = new backups_c();
$this->lock_owner = gethostname() . ':' . getmypid() . ':' . bin2hex(random_bytes(4));
self::setBucket(self::BACKUP_BUCKET);
self::validateBucket();
}
public function validateBucket(): void
{
if ($this->shouldUseLocalBackupStorage()) {
$this->localTestBucketDirectory(self::BACKUP_BUCKET);
return;
}
if (!self::getS3Client()->doesBucketExist(self::getBucket())) {
throw new Exception('The bucket does not exist in the S3 service! Missing: ' . self::getBucket());
}
}
public function backup_exists(string $backup_uuid): bool
{
$record = $this->backupRecord($backup_uuid);
if ($record !== null) {
return !in_array((string)$record['status'], ['failed', 'pruned'], true);
}
return $this->objectExists(self::BACKUP_BUCKET, self::backup_s3_prefix . 'backup_' . $backup_uuid . '.zip');
}
public function getBackupDownloadUrl(string $backup_uuid): string
{
$record = $this->backupRecord($backup_uuid);
if ($record !== null && (string)($record['manifest_key'] ?? '') !== '') {
return self::getPresignedUrl((string)$record['manifest_key']);
}
return self::getPresignedUrl(self::backup_s3_prefix . 'backup_' . $backup_uuid . '.zip');
}
public function download(string $backup_uuid): string
{
$path = self::local_backup_path . 'backup_' . $backup_uuid . '.zip';
$this->ensureLocalDirectory(dirname($path));
$body = $this->getObjectBody(self::BACKUP_BUCKET, self::backup_s3_prefix . 'backup_' . $backup_uuid . '.zip');
file_put_contents($path, $body);
return $path;
}
/**
* Legacy synchronous entrypoint. New HTTP routes should use enqueueCreateBackup().
*
* @throws Exception
*/
public function createBackup($backup_name = null, $backup_description = null): string
{
$queued = $this->enqueueCreateBackup($backup_name, $backup_description, null, 'cron');
$job = $this->runJobById((int)$queued['job_id']);
if (!in_array((string)$job['status'], ['succeeded', 'created_unverified'], true)) {
throw new RuntimeException((string)($job['error_message'] ?? 'Backup failed.'));
}
return (string)$queued['backup_uuid'];
}
public function enqueueCreateBackup($backup_name = null, $backup_description = null, ?int $actor_user_id = null, string $source = 'manual'): array
{
if (!$this->configBool('enabled', true)) {
throw new RuntimeException('Backup system is disabled in backup configuration.');
}
$backup_uuid = self::uuid();
$now = date('Y-m-d H:i:s');
$backup_name = trim((string)($backup_name ?: 'Backup ' . $now));
$backup_description = trim((string)($backup_description ?: 'Backup created on ' . $now));
$storage_prefix = self::backup_s3_prefix . $backup_uuid . '/';
$this->query(
"INSERT INTO backup_records
(backup_uuid, name, description, source, status, schema_version, storage_bucket, storage_prefix, requested_by_user_id)
VALUES ("
. $this->sql($backup_uuid) . ', '
. $this->sql($backup_name) . ', '
. $this->nullableSql($backup_description) . ', '
. $this->sql($source) . ", 'queued', "
. self::BACKUP_SCHEMA_VERSION . ', '
. $this->sql(self::BACKUP_BUCKET) . ', '
. $this->sql($storage_prefix) . ', '
. ($actor_user_id === null ? 'NULL' : (string)$actor_user_id)
. ")"
);
$job_id = $this->createJob('create', $backup_uuid, [
'name' => $backup_name,
'description' => $backup_description,
'source' => $source,
], $actor_user_id);
return [
'job_id' => $job_id,
'backup_uuid' => $backup_uuid,
'status' => 'queued',
];
}
public function enqueueVerifyBackup(string $backup_uuid, ?int $actor_user_id = null): array
{
$this->requireBackupRecord($backup_uuid);
$job_id = $this->createJob('verify', $backup_uuid, [], $actor_user_id);
return [
'job_id' => $job_id,
'backup_uuid' => $backup_uuid,
'status' => 'queued',
];
}
public function previewRestore(string $backup_uuid, ?int $actor_user_id = null): array
{
$record = $this->requireBackupRecord($backup_uuid);
if ((string)$record['status'] !== 'available') {
throw new RuntimeException('Only verified available backups can be restored.');
}
$manifest = $this->loadManifest($record);
$checks = $this->restorePreflightChecks($record, $manifest);
$expires_at = date('c', time() + 900);
$confirmation_phrase = 'RESTORE ' . $backup_uuid . ' TO PRODUCTION';
$preview = [
'backup_uuid' => $backup_uuid,
'backup_name' => $record['name'],
'target_environment' => 'production',
'expires_at' => $expires_at,
'confirmation_phrase' => $confirmation_phrase,
'checks' => $checks,
'destructive_impact' => [
'database' => 'The current production database will be replaced from the backup dump after a pre-restore backup is created.',
'object_data' => 'App-owned object buckets will be reconciled to the backup manifest. Extra objects are moved to restore quarantine.',
],
];
$job_id = $this->createJob('restore_preview', $backup_uuid, ['expires_at' => $expires_at], $actor_user_id, 'succeeded', 100, 'Restore preview ready.', $preview);
return $preview + ['preview_id' => $job_id];
}
public function enqueueRestore(string $backup_uuid, int $preview_job_id, string $confirmation_phrase, string $reason, ?int $actor_user_id = null, array $request_context = []): array
{
if (!$this->configBool('restore_enabled', false)) {
throw new RuntimeException('Direct production restore is disabled in backup configuration.');
}
$record = $this->requireBackupRecord($backup_uuid);
if ((string)$record['status'] !== 'available') {
throw new RuntimeException('Only verified available backups can be restored.');
}
$preview = $this->getJob($preview_job_id);
if ($preview === null || (string)$preview['job_type'] !== 'restore_preview' || (string)$preview['backup_uuid'] !== $backup_uuid) {
throw new RuntimeException('Restore preview is missing or does not match this backup.');
}
$result = $this->decodeJson($preview['result_json'] ?? null);
if (strtotime((string)($result['expires_at'] ?? '')) < time()) {
throw new RuntimeException('Restore preview has expired.');
}
$expected = 'RESTORE ' . $backup_uuid . ' TO PRODUCTION';
if (!hash_equals($expected, trim($confirmation_phrase))) {
throw new RuntimeException('Restore confirmation phrase did not match.');
}
$reason = trim($reason);
if ($reason === '') {
throw new RuntimeException('Restore reason is required.');
}
$job_id = $this->createJob('restore', $backup_uuid, [
'preview_job_id' => $preview_job_id,
'reason' => $reason,
'request_context' => $request_context,
], $actor_user_id);
$this->query(
"INSERT INTO backup_restore_audit
(restore_job_id, preview_job_id, backup_uuid, actor_user_id, target_environment, confirmation_fingerprint, reason, ip_address, user_agent, status)
VALUES ("
. (string)$job_id . ', '
. (string)$preview_job_id . ', '
. $this->sql($backup_uuid) . ', '
. ($actor_user_id === null ? 'NULL' : (string)$actor_user_id) . ", 'production', "
. $this->sql(hash('sha256', $confirmation_phrase)) . ', '
. $this->nullableSql($reason) . ', '
. $this->nullableSql((string)($request_context['ip_address'] ?? '')) . ', '
. $this->nullableSql(substr((string)($request_context['user_agent'] ?? ''), 0, 255)) . ", 'queued')"
);
return [
'job_id' => $job_id,
'backup_uuid' => $backup_uuid,
'status' => 'queued',
];
}
public function enqueueRetentionPrune(?int $actor_user_id = null): array
{
$job_id = $this->createJob('prune', null, [], $actor_user_id);
return [
'job_id' => $job_id,
'status' => 'queued',
];
}
public function processPendingJobs(int $limit = 5): array
{
$limit = max(1, min(25, $limit));
$jobs = $this->fetchAll(
"SELECT * FROM backup_jobs WHERE status = 'queued' ORDER BY id ASC LIMIT $limit"
);
$processed = [];
foreach ($jobs as $job) {
$processed[] = $this->runJobById((int)$job['id']);
}
return [
'processed' => $processed,
'count' => count($processed),
];
}
public function runJobById(int $job_id): array
{
$job = $this->getJob($job_id);
if ($job === null) {
throw new RuntimeException('Backup job not found.');
}
if ((string)$job['status'] !== 'queued') {
return $job + ['result' => $this->decodeJson($job['result_json'] ?? null)];
}
$this->query(
"UPDATE backup_jobs
SET status = 'running',
started_at = " . $this->sql(date('Y-m-d H:i:s')) . ",
locked_at = " . $this->sql(date('Y-m-d H:i:s')) . ",
lock_owner = " . $this->sql($this->lock_owner) . "
WHERE id = " . (string)$job_id . " AND status = 'queued'"
);
if ($this->affectedRows() !== 1) {
return $this->getJob($job_id) ?? $job;
}
$status = 'succeeded';
$message = 'Backup job completed.';
$result = [];
$error = null;
try {
$type = (string)$job['job_type'];
$payload = $this->decodeJson($job['payload_json'] ?? null);
if ($type === 'create') {
$result = $this->performCreateBackup((string)$job['backup_uuid'], $payload);
$status = (string)($result['status'] ?? 'succeeded');
$message = (string)($result['message'] ?? 'Backup created.');
} elseif ($type === 'verify') {
$result = $this->performVerification((string)$job['backup_uuid'], true);
$status = (string)($result['job_status'] ?? 'succeeded');
$message = (string)($result['message'] ?? 'Backup verification completed.');
} elseif ($type === 'restore') {
$result = $this->performRestore((string)$job['backup_uuid'], $payload, (int)($job['actor_user_id'] ?? 0) ?: null, $job_id);
$message = 'Restore completed.';
} elseif ($type === 'prune') {
$result = $this->pruneRetention();
$message = 'Backup retention pruned.';
} else {
throw new RuntimeException('Unsupported backup job type: ' . $type);
}
} catch (Throwable $throwable) {
$status = 'failed';
$error = $throwable->getMessage();
$message = $error;
}
$this->completeJob($job_id, $status, $message, $result, $error);
return $this->getJob($job_id) + ['result' => $result];
}
public function getJob(int $job_id): ?array
{
$job = $this->fetchOne("SELECT * FROM backup_jobs WHERE id = " . (string)$job_id);
if ($job === null) {
return null;
}
$job['payload'] = $this->decodeJson($job['payload_json'] ?? null);
$job['result'] = $this->decodeJson($job['result_json'] ?? null);
return $job;
}
public function listJobs(?string $backup_uuid = null, int $limit = 50): array
{
$limit = max(1, min(200, $limit));
$where = $backup_uuid !== null && $backup_uuid !== ''
? "WHERE backup_uuid = " . $this->sql($backup_uuid)
: '';
return [
'jobs' => $this->fetchAll("SELECT * FROM backup_jobs $where ORDER BY id DESC LIMIT $limit"),
];
}
public function generateBackupFile(): string
{
$uuid = $this->createBackup();
return self::local_backup_path . 'backup_' . $uuid . '.zip';
}
public function cleanupBackupFile(string $backup_uuid): void
{
$path = self::local_backup_path . 'backup_' . $backup_uuid;
if (is_file($path . '.zip')) {
@unlink($path . '.zip');
}
if (is_file($path . '.json')) {
@unlink($path . '.json');
}
if (is_dir($path)) {
$this->removeDirectory($path);
}
}
public function backupDatabase(string $backup_uuid): string
{
$component = $this->createDatabaseComponent($backup_uuid);
return (string)$component['storage_key'];
}
public function backupServerFiles(string $backup_uuid): string
{
throw new RuntimeException('Full server filesystem backups are intentionally not supported. Restore application source from deployment artifacts.');
}
public function backupEnvironmentVariables(string $backup_uuid): string
{
$key = self::backup_s3_prefix . $backup_uuid . '/runtime-manifest.json';
$manifest = [
'generated_at' => date('c'),
'required_config_keys' => $this->sanitizedRuntimeConfigKeys(),
];
$this->putObjectBody(self::BACKUP_BUCKET, $key, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return $key;
}
public function createBackupMetadata(string $backup_uuid, string $backup_name, string $backup_description): void
{
$record = $this->backupRecord($backup_uuid);
$metadata = [
'backup_uuid' => $backup_uuid,
'backup_name' => $backup_name,
'backup_description' => $backup_description,
'backup_date' => date('Y-m-d H:i:s'),
'status' => $record['status'] ?? 'legacy',
'hash' => $record['manifest_sha256'] ?? null,
];
$this->putObjectBody(
self::BACKUP_BUCKET,
self::metadata_s3_prefix . 'backup_' . $backup_uuid . '.json',
json_encode($metadata, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
);
}
public function listBackups(?int $limit = 50, int $offset = 0): array
{
$limit = max(1, min(200, (int)$limit));
$offset = max(0, $offset);
$total = (int)($this->fetchOne("SELECT COUNT(*) AS total FROM backup_records")['total'] ?? 0);
$records = $this->fetchAll(
"SELECT * FROM backup_records ORDER BY created_at DESC LIMIT $limit OFFSET $offset"
);
foreach ($records as &$record) {
$record['components'] = $this->componentsForBackup((string)$record['backup_uuid']);
}
return [
'items' => $records,
'legacy_items' => $offset === 0 ? $this->legacyBackups() : [],
'total' => $total,
'limit' => $limit,
'offset' => $offset,
'has_more' => ($offset + count($records)) < $total,
];
}
public function getBackupMetadata(string $backup_uuid): array
{
$record = $this->backupRecord($backup_uuid);
if ($record !== null) {
return $record + ['components' => $this->componentsForBackup($backup_uuid)];
}
$metadata = $this->getObjectBody(self::BACKUP_BUCKET, self::metadata_s3_prefix . 'backup_' . $backup_uuid . '.json');
return json_decode($metadata, true) ?: [];
}
public function restoreAudit(int $limit = 50): array
{
$limit = max(1, min(200, $limit));
return [
'items' => $this->fetchAll("SELECT * FROM backup_restore_audit ORDER BY id DESC LIMIT $limit"),
];
}
public function latestVerifiedBackup(): ?array
{
return $this->fetchOne(
"SELECT * FROM backup_records WHERE status = 'available' AND verified_at IS NOT NULL ORDER BY verified_at DESC LIMIT 1"
);
}
public function healthSummary(): array
{
$latest = $this->latestVerifiedBackup();
$encryption = $this->encryptionStatus();
$latestAgeSeconds = null;
if ($latest !== null && strtotime((string)$latest['verified_at']) !== false) {
$latestAgeSeconds = time() - strtotime((string)$latest['verified_at']);
}
$fresh = $latestAgeSeconds !== null && $latestAgeSeconds <= 4500;
return [
'latest_verified_backup' => $latest,
'latest_verified_age_seconds' => $latestAgeSeconds,
'fresh' => $fresh,
'encryption' => $encryption,
'verification_required' => $this->configBool('verification_required', true),
'restore_enabled' => $this->configBool('restore_enabled', false),
];
}
private function performCreateBackup(string $backup_uuid, array $payload): array
{
$record = $this->requireBackupRecord($backup_uuid);
$started_at = date('Y-m-d H:i:s');
$this->updateJobProgressForBackup($backup_uuid, 5, 'Preparing backup.');
$this->query(
"UPDATE backup_records
SET status = 'creating', started_at = " . $this->sql($started_at) . ", last_error = NULL
WHERE backup_uuid = " . $this->sql($backup_uuid)
);
$components = [];
$this->clearComponents($backup_uuid);
$this->ensureLocalDirectory(self::local_backup_path . $backup_uuid);
$components[] = $this->createDatabaseComponent($backup_uuid);
$this->updateJobProgressForBackup($backup_uuid, 45, 'Database component created.');
if ($this->configBool('app_data_enabled', true)) {
foreach (self::APP_DATA_BUCKETS as $bucket) {
$components[] = $this->createObjectBucketComponent($backup_uuid, $bucket);
}
}
$this->updateJobProgressForBackup($backup_uuid, 80, 'Object components created.');
$manifest = [
'schema_version' => self::BACKUP_SCHEMA_VERSION,
'backup_uuid' => $backup_uuid,
'name' => $record['name'],
'description' => $record['description'],
'source' => $record['source'],
'created_at' => date('c'),
'app_identifier' => $this->appIdentifier(),
'storage_bucket' => self::BACKUP_BUCKET,
'storage_prefix' => self::backup_s3_prefix . $backup_uuid . '/',
'encryption_key_id' => $this->currentEncryptionKey()['id'],
'required_runtime_config_keys' => $this->sanitizedRuntimeConfigKeys(),
'components' => $components,
];
$manifest_json = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($manifest_json === false) {
throw new RuntimeException('Could not encode backup manifest.');
}
$manifest_key = self::backup_s3_prefix . $backup_uuid . '/manifest.json';
$this->putObjectBody(self::BACKUP_BUCKET, $manifest_key, $manifest_json, 'application/json');
$manifest_sha256 = hash('sha256', $manifest_json);
$component_count = count(array_filter($components, static fn(array $component): bool => ($component['status'] ?? '') === 'stored'));
$object_count = array_sum(array_map(static fn(array $component): int => (int)($component['object_count'] ?? 0), $components));
$total_bytes = array_sum(array_map(static fn(array $component): int => (int)($component['byte_size'] ?? 0), $components));
$completed_at = date('Y-m-d H:i:s');
$this->query(
"UPDATE backup_records
SET status = 'created_unverified',
completed_at = " . $this->sql($completed_at) . ",
manifest_key = " . $this->sql($manifest_key) . ",
manifest_sha256 = " . $this->sql($manifest_sha256) . ",
encryption_key_id = " . $this->sql($manifest['encryption_key_id']) . ",
component_count = " . (string)$component_count . ",
object_count = " . (string)$object_count . ",
total_bytes = " . (string)$total_bytes . "
WHERE backup_uuid = " . $this->sql($backup_uuid)
);
$this->createBackupMetadata($backup_uuid, (string)$record['name'], (string)($record['description'] ?? ''));
if ($this->configBool('verification_required', true)) {
$verification = $this->performVerification($backup_uuid, true);
return [
'backup_uuid' => $backup_uuid,
'status' => (string)($verification['job_status'] ?? 'created_unverified'),
'message' => (string)($verification['message'] ?? 'Backup created but verification did not complete.'),
'verification' => $verification,
];
}
$this->query(
"UPDATE backup_records
SET status = 'available', verified_at = " . $this->sql(date('Y-m-d H:i:s')) . "
WHERE backup_uuid = " . $this->sql($backup_uuid)
);
return [
'backup_uuid' => $backup_uuid,
'status' => 'succeeded',
'message' => 'Backup created and marked available without scratch verification.',
];
}
private function createDatabaseComponent(string $backup_uuid): array
{
global $db;
if (!$db instanceof db) {
throw new RuntimeException('Database connection is not available.');
}
$dir = self::local_backup_path . $backup_uuid;
$this->ensureLocalDirectory($dir);
$dump_path = $dir . '/database.sql';
if (!$db->backupDatabase($dump_path)) {
throw new RuntimeException('Failed to dump the database.');
}
$dump = file_get_contents($dump_path);
if ($dump === false) {
throw new RuntimeException('Could not read database dump.');
}
$compressed = gzencode($dump, 6);
if ($compressed === false) {
throw new RuntimeException('Could not compress database dump.');
}
$storage_key = self::backup_s3_prefix . $backup_uuid . '/components/database.sql.gz.enc';
$aad = ['backup_uuid' => $backup_uuid, 'component' => 'database', 'storage_key' => $storage_key];
$encrypted = $this->encryptString($compressed, $aad);
$this->putObjectBody(self::BACKUP_BUCKET, $storage_key, $encrypted['ciphertext']);
@unlink($dump_path);
@unlink($dump_path . '.error.log');
$component = [
'component_type' => 'database',
'logical_name' => $db->getDatabase(),
'source_bucket' => null,
'source_prefix' => null,
'storage_key' => $storage_key,
'object_count' => 1,
'byte_size' => strlen($compressed),
'content_sha256' => hash('sha256', $compressed),
'encrypted_sha256' => hash('sha256', $encrypted['ciphertext']),
'encryption' => $encrypted['metadata'],
'aad' => $aad,
'status' => 'stored',
];
$this->insertComponent($backup_uuid, $component);
return $component;
}
private function createObjectBucketComponent(string $backup_uuid, string $bucket): array
{
$objects = [];
$status = 'stored';
$error = null;
try {
if (!$this->bucketExists($bucket)) {
$status = 'skipped';
$error = 'Bucket is not available.';
} else {
foreach ($this->listObjectsInBucket($bucket) as $object) {
$source_key = (string)$object['Key'];
if ($source_key === '' || str_ends_with($source_key, '/')) {
continue;
}
$body = $this->getObjectBody($bucket, $source_key);
$storage_key = self::backup_s3_prefix . $backup_uuid . '/objects/' . $bucket . '/' . $this->encodeObjectKey($source_key) . '.enc';
$aad = ['backup_uuid' => $backup_uuid, 'component' => 'object', 'bucket' => $bucket, 'source_key' => $source_key, 'storage_key' => $storage_key];
$encrypted = $this->encryptString($body, $aad);
$this->putObjectBody(self::BACKUP_BUCKET, $storage_key, $encrypted['ciphertext']);
$objects[] = [
'source_key' => $source_key,
'size' => strlen($body),
'content_sha256' => hash('sha256', $body),
'storage_key' => $storage_key,
'encrypted_sha256' => hash('sha256', $encrypted['ciphertext']),
'encryption' => $encrypted['metadata'],
'aad' => $aad,
];
}
}
} catch (Throwable $throwable) {
$status = 'failed';
$error = $throwable->getMessage();
}
$manifest_key = self::backup_s3_prefix . $backup_uuid . '/components/' . $bucket . '.manifest.json';
$bucket_manifest = json_encode([
'bucket' => $bucket,
'created_at' => date('c'),
'objects' => $objects,
'status' => $status,
'error' => $error,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$this->putObjectBody(self::BACKUP_BUCKET, $manifest_key, $bucket_manifest ?: '{}', 'application/json');
$component = [
'component_type' => 'object_bucket',
'logical_name' => $bucket,
'source_bucket' => $bucket,
'source_prefix' => '',
'storage_key' => null,
'manifest_key' => $manifest_key,
'object_count' => count($objects),
'byte_size' => array_sum(array_map(static fn(array $object): int => (int)$object['size'], $objects)),
'content_sha256' => $bucket_manifest !== false ? hash('sha256', $bucket_manifest) : null,
'encrypted_sha256' => null,
'encryption' => null,
'objects' => $objects,
'status' => $status,
'error' => $error,
];
$this->insertComponent($backup_uuid, $component);
return $component;
}
private function performVerification(string $backup_uuid, bool $require_scratch_db): array
{
$record = $this->requireBackupRecord($backup_uuid);
$manifest = $this->loadManifest($record);
$failures = [];
if (hash('sha256', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) !== (string)$record['manifest_sha256']) {
$manifest_body = $this->getObjectBody(self::BACKUP_BUCKET, (string)$record['manifest_key']);
if (hash('sha256', $manifest_body) !== (string)$record['manifest_sha256']) {
$failures[] = 'Manifest hash mismatch.';
}
}
foreach (($manifest['components'] ?? []) as $component) {
if (($component['status'] ?? '') === 'skipped') {
continue;
}
if (($component['status'] ?? '') !== 'stored') {
$failures[] = 'Component failed: ' . (string)($component['logical_name'] ?? 'unknown');
continue;
}
if (($component['component_type'] ?? '') === 'database') {
$this->verifyDatabaseComponent($component, $require_scratch_db, $failures);
}
if (($component['component_type'] ?? '') === 'object_bucket') {
$this->verifyObjectBucketComponent($component, $failures);
}
}
if ($failures !== []) {
$error = implode(' ', $failures);
$this->query(
"UPDATE backup_records
SET status = 'created_unverified', last_error = " . $this->sql($error) . "
WHERE backup_uuid = " . $this->sql($backup_uuid)
);
return [
'backup_uuid' => $backup_uuid,
'job_status' => 'created_unverified',
'message' => $error,
'failures' => $failures,
];
}
$this->query(
"UPDATE backup_records
SET status = 'available', verified_at = " . $this->sql(date('Y-m-d H:i:s')) . ", last_error = NULL
WHERE backup_uuid = " . $this->sql($backup_uuid)
);
return [
'backup_uuid' => $backup_uuid,
'job_status' => 'succeeded',
'message' => 'Backup manifest, encrypted components, and verification target were validated.',
];
}
private function verifyDatabaseComponent(array $component, bool $require_scratch_db, array &$failures): void
{
$storage_key = (string)($component['storage_key'] ?? '');
if ($storage_key === '' || !$this->objectExists(self::BACKUP_BUCKET, $storage_key)) {
$failures[] = 'Database component object is missing.';
return;
}
$ciphertext = $this->getObjectBody(self::BACKUP_BUCKET, $storage_key);
if (hash('sha256', $ciphertext) !== (string)($component['encrypted_sha256'] ?? '')) {
$failures[] = 'Database component encrypted hash mismatch.';
return;
}
$compressed = $this->decryptString($ciphertext, $component['encryption'] ?? [], $component['aad'] ?? []);
if (hash('sha256', $compressed) !== (string)($component['content_sha256'] ?? '')) {
$failures[] = 'Database component plaintext hash mismatch.';
return;
}
$sql = gzdecode($compressed);
if ($sql === false || trim($sql) === '') {
$failures[] = 'Database dump could not be decompressed.';
return;
}
$verifyConfig = $this->verificationDatabaseConfig();
if ($verifyConfig === null) {
if ($require_scratch_db) {
$failures[] = 'Backup verification database is not configured.';
}
return;
}
$this->importSqlIntoDatabase($sql, $verifyConfig, 'verify');
}
private function verifyObjectBucketComponent(array $component, array &$failures): void
{
foreach (($component['objects'] ?? []) as $object) {
$storage_key = (string)($object['storage_key'] ?? '');
if ($storage_key === '' || !$this->objectExists(self::BACKUP_BUCKET, $storage_key)) {
$failures[] = 'Object backup is missing for ' . (string)($component['logical_name'] ?? 'bucket') . '/' . (string)($object['source_key'] ?? 'unknown');
continue;
}
$ciphertext = $this->getObjectBody(self::BACKUP_BUCKET, $storage_key);
if (hash('sha256', $ciphertext) !== (string)($object['encrypted_sha256'] ?? '')) {
$failures[] = 'Object backup encrypted hash mismatch for ' . (string)($object['source_key'] ?? 'unknown');
}
}
}
private function performRestore(string $backup_uuid, array $payload, ?int $actor_user_id, int $job_id): array
{
$record = $this->requireBackupRecord($backup_uuid);
$manifest = $this->loadManifest($record);
$owner = 'backup-restore:' . $job_id;
$pre_restore_uuid = null;
$this->query(
"UPDATE backup_restore_audit
SET status = 'running', started_at = " . $this->sql(date('Y-m-d H:i:s')) . "
WHERE restore_job_id = " . (string)$job_id
);
application_write_freeze::freeze('Backup restore in progress.', $owner, 7200);
try {
$pre = $this->enqueueCreateBackup('Pre-restore backup ' . date('Y-m-d H:i:s'), 'Automatic backup before restoring ' . $backup_uuid, $actor_user_id, 'pre_restore');
$pre_restore_uuid = (string)$pre['backup_uuid'];
$preJob = $this->runJobById((int)$pre['job_id']);
if ((string)($preJob['status'] ?? '') !== 'succeeded') {
throw new RuntimeException('Pre-restore backup did not complete as a verified backup.');
}
foreach (($manifest['components'] ?? []) as $component) {
if (($component['component_type'] ?? '') === 'database') {
$this->restoreDatabaseComponent($component);
}
}
foreach (($manifest['components'] ?? []) as $component) {
if (($component['component_type'] ?? '') === 'object_bucket') {
$this->restoreObjectBucketComponent($backup_uuid, $component);
}
}
$this->query(
"UPDATE backup_restore_audit
SET status = 'succeeded',
pre_restore_backup_uuid = " . $this->sql($pre_restore_uuid) . ",
completed_at = " . $this->sql(date('Y-m-d H:i:s')) . "
WHERE restore_job_id = " . (string)$job_id
);
} catch (Throwable $throwable) {
$this->query(
"UPDATE backup_restore_audit
SET status = 'failed',
pre_restore_backup_uuid = " . $this->nullableSql($pre_restore_uuid) . ",
completed_at = " . $this->sql(date('Y-m-d H:i:s')) . ",
error_message = " . $this->sql($throwable->getMessage()) . "
WHERE restore_job_id = " . (string)$job_id
);
throw $throwable;
} finally {
application_write_freeze::unfreeze($owner);
}
return [
'backup_uuid' => $backup_uuid,
'pre_restore_backup_uuid' => $pre_restore_uuid,
'status' => 'succeeded',
];
}
private function restoreDatabaseComponent(array $component): void
{
global $db;
if (!$db instanceof db) {
throw new RuntimeException('Production database connection is not available.');
}
$ciphertext = $this->getObjectBody(self::BACKUP_BUCKET, (string)$component['storage_key']);
$compressed = $this->decryptString($ciphertext, $component['encryption'] ?? [], $component['aad'] ?? []);
$sql = gzdecode($compressed);
if ($sql === false || trim($sql) === '') {
throw new RuntimeException('Restore database dump could not be decompressed.');
}
$this->importSqlIntoDatabase($sql, [
'host' => $db->getHost(),
'port' => $db->getPort(),
'user' => $db->getUsername(),
'password' => $db->getPassword(),
'database' => $db->getDatabase(),
], 'restore');
}
private function restoreObjectBucketComponent(string $backup_uuid, array $component): void
{
$bucket = (string)($component['source_bucket'] ?? '');
if ($bucket === '') {
return;
}
if (!$this->bucketExists($bucket)) {
throw new RuntimeException('Restore target bucket is missing: ' . $bucket);
}
$sourceKeys = [];
foreach (($component['objects'] ?? []) as $object) {
$source_key = (string)($object['source_key'] ?? '');
if ($source_key === '') {
continue;
}
$sourceKeys[$source_key] = true;
$ciphertext = $this->getObjectBody(self::BACKUP_BUCKET, (string)$object['storage_key']);
$body = $this->decryptString($ciphertext, $object['encryption'] ?? [], $object['aad'] ?? []);
$this->putObjectBody($bucket, $source_key, $body);
}
foreach ($this->listObjectsInBucket($bucket) as $existing) {
$key = (string)$existing['Key'];
if ($key === '' || isset($sourceKeys[$key]) || str_starts_with($key, 'restore-quarantine/')) {
continue;
}
$quarantineKey = 'restore-quarantine/' . $backup_uuid . '/' . $key;
$body = $this->getObjectBody($bucket, $key);
$this->putObjectBody($bucket, $quarantineKey, $body);
$this->deleteObject($bucket, $key);
}
}
public function pruneRetention(): array
{
$records = $this->fetchAll(
"SELECT * FROM backup_records WHERE status IN ('available', 'created_unverified') ORDER BY completed_at DESC, created_at DESC"
);
$keep = $this->retentionKeepSet($records);
$pruned = [];
foreach ($records as $record) {
$uuid = (string)$record['backup_uuid'];
if (isset($keep[$uuid])) {
continue;
}
$this->deletePrefix(self::BACKUP_BUCKET, (string)$record['storage_prefix']);
$this->query(
"UPDATE backup_records
SET status = 'pruned', expires_at = " . $this->sql(date('Y-m-d H:i:s')) . "
WHERE backup_uuid = " . $this->sql($uuid)
);
$pruned[] = $uuid;
}
return [
'pruned' => $pruned,
'kept' => array_keys($keep),
];
}
private function retentionKeepSet(array $records): array
{
$keep = [];
$newestVerifiedKept = false;
$recentCutoff = time() - ($this->configInt('retention_recent_hours', 48) * 3600);
$dailyCutoff = time() - ($this->configInt('retention_daily_days', 30) * 86400);
$weeklyCutoff = time() - ($this->configInt('retention_weekly_weeks', 8) * 7 * 86400);
$monthlyCutoff = strtotime('-' . $this->configInt('retention_monthly_months', 3) . ' months');
$daily = [];
$weekly = [];
$monthly = [];
foreach ($records as $record) {
$uuid = (string)$record['backup_uuid'];
$created = strtotime((string)($record['completed_at'] ?: $record['created_at']));
if ($created === false) {
continue;
}
if (!$newestVerifiedKept && (string)$record['status'] === 'available') {
$keep[$uuid] = true;
$newestVerifiedKept = true;
continue;
}
if ($created >= $recentCutoff) {
$keep[$uuid] = true;
continue;
}
if ($created >= $dailyCutoff) {
$day = date('Y-m-d', $created);
if (!isset($daily[$day])) {
$daily[$day] = true;
$keep[$uuid] = true;
}
continue;
}
if ($created >= $weeklyCutoff) {
$week = date('o-W', $created);
if (!isset($weekly[$week])) {
$weekly[$week] = true;
$keep[$uuid] = true;
}
continue;
}
if ($monthlyCutoff !== false && $created >= $monthlyCutoff) {
$month = date('Y-m', $created);
if (!isset($monthly[$month])) {
$monthly[$month] = true;
$keep[$uuid] = true;
}
}
}
return $keep;
}
private function restorePreflightChecks(array $record, array $manifest): array
{
$checks = [];
$checks[] = [
'key' => 'status',
'status' => (string)$record['status'] === 'available' ? 'ok' : 'failed',
'message' => 'Backup must be verified and available.',
];
$checks[] = [
'key' => 'encryption_key',
'status' => $this->encryptionStatus()['available'] ? 'ok' : 'failed',
'message' => 'Backup encryption key must be available.',
];
foreach (($manifest['components'] ?? []) as $component) {
if (($component['status'] ?? '') !== 'stored') {
continue;
}
if (($component['storage_key'] ?? '') !== '') {
$checks[] = [
'key' => 'component:' . (string)$component['logical_name'],
'status' => $this->objectExists(self::BACKUP_BUCKET, (string)$component['storage_key']) ? 'ok' : 'failed',
'message' => 'Component object exists: ' . (string)$component['logical_name'],
];
}
if (($component['manifest_key'] ?? '') !== '') {
$checks[] = [
'key' => 'component_manifest:' . (string)$component['logical_name'],
'status' => $this->objectExists(self::BACKUP_BUCKET, (string)$component['manifest_key']) ? 'ok' : 'failed',
'message' => 'Component manifest exists: ' . (string)$component['logical_name'],
];
}
}
return $checks;
}
private function createJob(string $type, ?string $backup_uuid, array $payload, ?int $actor_user_id, string $status = 'queued', int $progress = 0, ?string $message = null, array $result = []): int
{
$this->query(
"INSERT INTO backup_jobs
(job_type, backup_uuid, status, progress_percent, progress_message, payload_json, result_json, actor_user_id, started_at, completed_at)
VALUES ("
. $this->sql($type) . ', '
. $this->nullableSql($backup_uuid) . ', '
. $this->sql($status) . ', '
. (string)$progress . ', '
. $this->nullableSql($message) . ', '
. $this->nullableSql(json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) . ', '
. $this->nullableSql($result === [] ? null : json_encode($result, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) . ', '
. ($actor_user_id === null ? 'NULL' : (string)$actor_user_id) . ', '
. ($status === 'queued' ? 'NULL, NULL' : $this->sql(date('Y-m-d H:i:s')) . ', ' . $this->sql(date('Y-m-d H:i:s')))
. ")"
);
return $this->insertId();
}
private function completeJob(int $job_id, string $status, string $message, array $result, ?string $error): void
{
$this->query(
"UPDATE backup_jobs
SET status = " . $this->sql($status) . ",
progress_percent = 100,
progress_message = " . $this->sql(substr($message, 0, 255)) . ",
result_json = " . $this->nullableSql(json_encode($result, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) . ",
error_message = " . $this->nullableSql($error) . ",
completed_at = " . $this->sql(date('Y-m-d H:i:s')) . ",
lock_owner = NULL,
locked_at = NULL
WHERE id = " . (string)$job_id
);
}
private function updateJobProgressForBackup(string $backup_uuid, int $percent, string $message): void
{
$this->query(
"UPDATE backup_jobs
SET progress_percent = " . max(0, min(99, $percent)) . ",
progress_message = " . $this->sql(substr($message, 0, 255)) . "
WHERE backup_uuid = " . $this->sql($backup_uuid) . " AND status = 'running'"
);
}
private function insertComponent(string $backup_uuid, array $component): void
{
$this->query(
"INSERT INTO backup_components
(backup_uuid, component_type, logical_name, source_bucket, source_prefix, storage_key, manifest_key, object_count, byte_size, content_sha256, encrypted_sha256, encryption_key_id, status, error_message)
VALUES ("
. $this->sql($backup_uuid) . ', '
. $this->sql((string)$component['component_type']) . ', '
. $this->sql((string)$component['logical_name']) . ', '
. $this->nullableSql($component['source_bucket'] ?? null) . ', '
. $this->nullableSql($component['source_prefix'] ?? null) . ', '
. $this->nullableSql($component['storage_key'] ?? null) . ', '
. $this->nullableSql($component['manifest_key'] ?? null) . ', '
. (string)(int)($component['object_count'] ?? 0) . ', '
. (string)(int)($component['byte_size'] ?? 0) . ', '
. $this->nullableSql($component['content_sha256'] ?? null) . ', '
. $this->nullableSql($component['encrypted_sha256'] ?? null) . ', '
. $this->nullableSql($component['encryption']['key_id'] ?? null) . ', '
. $this->sql((string)($component['status'] ?? 'stored')) . ', '
. $this->nullableSql($component['error'] ?? null)
. ")"
);
}
private function clearComponents(string $backup_uuid): void
{
$this->query("DELETE FROM backup_components WHERE backup_uuid = " . $this->sql($backup_uuid));
}
private function componentsForBackup(string $backup_uuid): array
{
return $this->fetchAll(
"SELECT * FROM backup_components WHERE backup_uuid = " . $this->sql($backup_uuid) . " ORDER BY id ASC"
);
}
private function requireBackupRecord(string $backup_uuid): array
{
$record = $this->backupRecord($backup_uuid);
if ($record === null) {
throw new RuntimeException('Backup not found.');
}
return $record;
}
private function backupRecord(string $backup_uuid): ?array
{
return $this->fetchOne(
"SELECT * FROM backup_records WHERE backup_uuid = " . $this->sql($backup_uuid)
);
}
private function loadManifest(array $record): array
{
$key = (string)($record['manifest_key'] ?? '');
if ($key === '') {
throw new RuntimeException('Backup manifest is missing.');
}
$body = $this->getObjectBody(self::BACKUP_BUCKET, $key);
if ((string)($record['manifest_sha256'] ?? '') !== '' && hash('sha256', $body) !== (string)$record['manifest_sha256']) {
throw new RuntimeException('Backup manifest hash mismatch.');
}
$manifest = json_decode($body, true);
if (!is_array($manifest)) {
throw new RuntimeException('Backup manifest is invalid JSON.');
}
return $manifest;
}
private function encryptString(string $plaintext, array $aad): array
{
$key = $this->currentEncryptionKey();
$nonce = random_bytes(12);
$tag = '';
$aadJson = json_encode($aad, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
$ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key['key'], OPENSSL_RAW_DATA, $nonce, $tag, $aadJson);
if ($ciphertext === false) {
throw new RuntimeException('Could not encrypt backup component.');
}
return [
'ciphertext' => $ciphertext,
'metadata' => [
'algorithm' => 'AES-256-GCM',
'key_id' => $key['id'],
'nonce' => base64_encode($nonce),
'tag' => base64_encode($tag),
],
];
}
private function decryptString(string $ciphertext, array $metadata, array $aad): string
{
$key = $this->currentEncryptionKey();
$nonce = base64_decode((string)($metadata['nonce'] ?? ''), true);
$tag = base64_decode((string)($metadata['tag'] ?? ''), true);
if ($nonce === false || $tag === false) {
throw new RuntimeException('Backup encryption metadata is invalid.');
}
if (isset($metadata['key_id']) && (string)$metadata['key_id'] !== $key['id']) {
throw new RuntimeException('Configured backup encryption key id does not match artifact key id.');
}
$aadJson = json_encode($aad, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
$plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key['key'], OPENSSL_RAW_DATA, $nonce, $tag, $aadJson);
if ($plaintext === false) {
throw new RuntimeException('Could not decrypt backup component.');
}
return $plaintext;
}
private function currentEncryptionKey(): array
{
$raw = $this->readEnv('BACKUP_ENCRYPTION_KEY_V1');
if ($raw === '') {
throw new RuntimeException('BACKUP_ENCRYPTION_KEY_V1 is not configured.');
}
$key = base64_decode($raw, true);
if ($key === false || strlen($key) !== 32) {
$hex = ctype_xdigit($raw) ? hex2bin($raw) : false;
$key = is_string($hex) && strlen($hex) === 32 ? $hex : hash('sha256', $raw, true);
}
if (strlen($key) !== 32) {
throw new RuntimeException('Backup encryption key must resolve to 32 bytes.');
}
$id = $this->readEnv('BACKUP_ENCRYPTION_KEY_ID');
if ($id === '') {
$id = 'env-v1-' . substr(hash('sha256', $key), 0, 12);
}
return ['id' => $id, 'key' => $key];
}
private function encryptionStatus(): array
{
try {
$key = $this->currentEncryptionKey();
return ['available' => true, 'key_id' => $key['id']];
} catch (Throwable $throwable) {
return ['available' => false, 'error' => $throwable->getMessage()];
}
}
private function verificationDatabaseConfig(): ?array
{
$host = $this->readEnv('BACKUP_VERIFY_DB_HOST');
$user = $this->readEnv('BACKUP_VERIFY_DB_USER');
$database = $this->readEnv('BACKUP_VERIFY_DB_DATABASE');
if ($host === '' || $user === '' || $database === '') {
return null;
}
return [
'host' => $host,
'user' => $user,
'password' => $this->readEnv('BACKUP_VERIFY_DB_PASSWORD'),
'database' => $database,
'port' => (int)($this->readEnv('BACKUP_VERIFY_DB_PORT') ?: '3306'),
];
}
private function importSqlIntoDatabase(string $sql, array $config, string $label): void
{
$dir = self::local_backup_path . 'restore-' . bin2hex(random_bytes(6));
$this->ensureLocalDirectory($dir);
$path = $dir . '/database.sql';
file_put_contents($path, $sql);
$host = escapeshellarg((string)$config['host']);
$user = escapeshellarg((string)$config['user']);
$database = escapeshellarg((string)$config['database']);
$port = (int)($config['port'] ?? 3306);
$command = "mysql -h $host -P $port -u $user $database";
$environment = array_merge(getenv() ?: [], $_ENV);
$environment['MYSQL_PWD'] = (string)($config['password'] ?? '');
$descriptors = [
0 => ['file', $path, 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$process = proc_open($command, $descriptors, $pipes, null, $environment);
if (!is_resource($process)) {
$this->removeDirectory($dir);
throw new RuntimeException('Could not start mysql import for backup ' . $label . '.');
}
$stdout = stream_get_contents($pipes[1]);
fclose($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[2]);
$return = proc_close($process);
$this->removeDirectory($dir);
if ($return !== 0) {
throw new RuntimeException('Database import for backup ' . $label . ' failed: ' . trim((string)($stderr ?: $stdout)));
}
}
private function putObjectBody(string $bucket, string $key, string $body, string $content_type = 'application/octet-stream'): void
{
if ($this->shouldUseLocalBackupStorage()) {
$path = $this->localTestObjectPath($bucket, $key);
$this->ensureLocalDirectory(dirname($path));
file_put_contents($path, $body);
return;
}
self::getS3Client()->putObject([
'Bucket' => $bucket,
'Key' => $key,
'Body' => $body,
'ContentType' => $content_type,
]);
}
private function getObjectBody(string $bucket, string $key): string
{
if ($this->shouldUseLocalBackupStorage()) {
$path = $this->localTestObjectPath($bucket, $key);
if (!is_file($path)) {
throw new RuntimeException('Object does not exist: ' . $bucket . '/' . $key);
}
$body = file_get_contents($path);
if ($body === false) {
throw new RuntimeException('Could not read object: ' . $bucket . '/' . $key);
}
return $body;
}
$result = self::getS3Client()->getObject([
'Bucket' => $bucket,
'Key' => $key,
]);
return (string)$result['Body'];
}
private function objectExists(string $bucket, string $key): bool
{
if ($this->shouldUseLocalBackupStorage()) {
return is_file($this->localTestObjectPath($bucket, $key));
}
return self::getS3Client()->doesObjectExist($bucket, $key);
}
private function bucketExists(string $bucket): bool
{
if ($this->shouldUseLocalBackupStorage()) {
return is_dir($this->localTestBucketDirectory($bucket));
}
return self::getS3Client()->doesBucketExist($bucket);
}
private function listObjectsInBucket(string $bucket, string $prefix = ''): array
{
if ($this->shouldUseLocalBackupStorage()) {
$dir = $this->localTestBucketDirectory($bucket);
if (!is_dir($dir)) {
return [];
}
$objects = [];
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $fileInfo) {
if (!$fileInfo->isFile()) {
continue;
}
$relative = str_replace('\\', '/', substr($fileInfo->getPathname(), strlen($dir) + 1));
if ($prefix !== '' && !str_starts_with($relative, $prefix)) {
continue;
}
$objects[] = ['Key' => $relative, 'Size' => $fileInfo->getSize()];
}
return $objects;
}
$objects = [];
$continuation = null;
do {
$params = ['Bucket' => $bucket, 'Prefix' => $prefix];
if ($continuation !== null) {
$params['ContinuationToken'] = $continuation;
}
$result = self::getS3Client()->listObjectsV2($params);
foreach (($result['Contents'] ?? []) as $object) {
$objects[] = $object;
}
$continuation = !empty($result['IsTruncated']) ? (string)($result['NextContinuationToken'] ?? '') : null;
} while ($continuation !== null && $continuation !== '');
return $objects;
}
private function deleteObject(string $bucket, string $key): void
{
if ($this->shouldUseLocalBackupStorage()) {
$path = $this->localTestObjectPath($bucket, $key);
if (is_file($path)) {
@unlink($path);
}
return;
}
self::getS3Client()->deleteObject(['Bucket' => $bucket, 'Key' => $key]);
}
private function deletePrefix(string $bucket, string $prefix): void
{
foreach ($this->listObjectsInBucket($bucket, $prefix) as $object) {
$this->deleteObject($bucket, (string)$object['Key']);
}
}
private function shouldUseLocalBackupStorage(): bool
{
return getenv('RUN_API_TESTS') === '1'
&& (trim((string)$this->getEndpoint()) === ''
|| trim((string)$this->getAccessKey()) === ''
|| trim((string)$this->getSecretKey()) === '');
}
private function localTestBucketDirectory(string $bucket): string
{
$bucket = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $bucket) ?: 'default';
$directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR)
. DIRECTORY_SEPARATOR
. 'truckwash-test-object-store'
. DIRECTORY_SEPARATOR
. $bucket;
if (!is_dir($directory)) {
mkdir($directory, 0777, true);
}
return $directory;
}
private function localTestObjectPath(string $bucket, string $key): string
{
$normalizedKey = str_replace('\\', '/', $key);
$normalizedKey = preg_replace('#(^|/)\\.\\.(?=/|$)#', '', $normalizedKey) ?? $normalizedKey;
$normalizedKey = ltrim($normalizedKey, '/');
if ($normalizedKey === '') {
$normalizedKey = 'object';
}
return $this->localTestBucketDirectory($bucket) . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $normalizedKey);
}
private function legacyBackups(): array
{
$legacy = [];
try {
foreach ($this->listObjectsInBucket(self::BACKUP_BUCKET, self::metadata_s3_prefix) as $object) {
$key = (string)$object['Key'];
if (!str_ends_with($key, '.json')) {
continue;
}
$uuid = str_replace([self::metadata_s3_prefix . 'backup_', '.json'], '', $key);
if ($this->backupRecord($uuid) !== null) {
continue;
}
$metadata = json_decode($this->getObjectBody(self::BACKUP_BUCKET, $key), true);
if (is_array($metadata)) {
$legacy[] = $metadata + ['backup_uuid' => $uuid, 'status' => 'legacy_unverified'];
}
}
} catch (Throwable) {
return [];
}
return $legacy;
}
private function appIdentifier(): array
{
return [
'host' => gethostname(),
'wd' => defined('WD') ? WD : null,
'git_sha' => $this->readEnv('GIT_COMMIT_SHA') ?: $this->readEnv('SOURCE_COMMIT'),
];
}
private function sanitizedRuntimeConfigKeys(): array
{
$keys = array_keys($_ENV + $_SERVER);
$required = array_values(array_filter($keys, static function (string $key): bool {
return str_starts_with($key, 'CONFIG_DB_')
|| str_starts_with($key, 'MINIO_')
|| str_starts_with($key, 'BACKUP_');
}));
sort($required, SORT_STRING);
return $required;
}
private function configBool(string $variable, bool $default): bool
{
$config = $this->configMap();
if (!array_key_exists($variable, $config)) {
return $default;
}
$value = $config[$variable];
if (is_bool($value)) {
return $value;
}
if (is_int($value) || is_float($value)) {
return (int)$value !== 0;
}
if (is_string($value)) {
$normalized = strtolower(trim($value));
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
return true;
}
if (in_array($normalized, ['0', 'false', 'no', 'off', ''], true)) {
return false;
}
}
return $default;
}
private function configInt(string $variable, int $default): int
{
$config = $this->configMap();
if (!array_key_exists($variable, $config) || !is_numeric($config[$variable])) {
return $default;
}
return max(0, (int)$config[$variable]);
}
private function configMap(): array
{
$map = [];
foreach ($this->config->getConfig() as $row) {
$map[(string)$row['variable']] = $row['value'];
}
return $map;
}
private function encodeObjectKey(string $key): string
{
return rtrim(strtr(base64_encode($key), '+/', '-_'), '=');
}
private function readEnv(string $key): string
{
$value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key);
if ($value === false || $value === null) {
return '';
}
return trim((string)$value);
}
private static function uuid(): string
{
return bin2hex(random_bytes(16));
}
private function ensureLocalDirectory(string $path): void
{
if (!is_dir($path) && !mkdir($path, 0770, true) && !is_dir($path)) {
throw new RuntimeException('Could not create directory: ' . $path);
}
}
private function removeDirectory(string $path): void
{
if (!is_dir($path)) {
return;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $fileInfo) {
if ($fileInfo->isDir()) {
@rmdir($fileInfo->getPathname());
} else {
@unlink($fileInfo->getPathname());
}
}
@rmdir($path);
}
private function query(string $sql): void
{
$this->db()->query($sql);
}
private function fetchOne(string $sql): ?array
{
$result = $this->db()->query($sql);
if (!is_object($result)) {
return null;
}
$row = $result->fetch_assoc();
return is_array($row) ? $row : null;
}
private function fetchAll(string $sql): array
{
$result = $this->db()->query($sql);
if (!is_object($result)) {
return [];
}
return $result->fetch_all(MYSQLI_ASSOC);
}
private function affectedRows(): int
{
return (int)$this->db()->conn()->affected_rows;
}
private function insertId(): int
{
return (int)$this->db()->insert_id();
}
private function db(): db
{
global $db;
if (!$db instanceof db) {
throw new RuntimeException('Database connection is not available.');
}
return $db;
}
private function sql(string|int|float|bool|null $value): string
{
return "'" . $this->db()->escape_string((string)$value) . "'";
}
private function nullableSql(mixed $value): string
{
if ($value === null || $value === '') {
return 'NULL';
}
return $this->sql((string)$value);
}
private function decodeJson(mixed $value): array
{
if (!is_string($value) || trim($value) === '') {
return [];
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
}