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 : []; } }