array_values($backups)]; } function backupServerWriteIndex(array $backups): void { backupServerWriteJsonFile((string) BACKUP_SERVER_INDEX_FILE, [ "backups" => array_values($backups), ]); } function backupServerValidateInstance(string $instance): string { $instance = trim($instance); if ( $instance === "" || strlen($instance) > 120 || preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*$/', $instance) !== 1 ) { throw new RuntimeException("Invalid instance identifier."); } return $instance; } function backupServerInstanceDir(string $instance): string { return rtrim((string) BACKUP_SERVER_BACKUP_DIR, "/\\") . DIRECTORY_SEPARATOR . $instance; } function backupServerBackupPath(string $instance, string $filename): string { return backupServerInstanceDir($instance) . DIRECTORY_SEPARATOR . $filename; } function backupServerGetSettings(): array { $settings = backupServerReadJsonFile((string) BACKUP_SERVER_SETTINGS_FILE); $retention = isset($settings["retention"]) ? max(1, (int) $settings["retention"]) : max(1, (int) BACKUP_SERVER_RETENTION); $s3Retention = isset($settings["s3_retention"]) ? max(1, (int) $settings["s3_retention"]) : max(1, (int) BACKUP_SERVER_S3_RETENTION); $instances = isset($settings["instances"]) && is_array($settings["instances"]) ? $settings["instances"] : []; $allowedInstances = []; foreach ($instances as $instance) { try { $allowedInstances[] = backupServerValidateInstance((string) $instance); } catch (Throwable $exception) { continue; } } $allowedInstances = array_values(array_unique($allowedInstances)); sort($allowedInstances); return [ "retention" => $retention, "s3_retention" => $s3Retention, "instances" => $allowedInstances, ]; } function backupServerWriteSettings(array $settings): void { $instances = isset($settings["instances"]) && is_array($settings["instances"]) ? $settings["instances"] : backupServerGetSettings()["instances"]; $allowedInstances = []; foreach ($instances as $instance) { $allowedInstances[] = backupServerValidateInstance((string) $instance); } $allowedInstances = array_values(array_unique($allowedInstances)); sort($allowedInstances); backupServerWriteJsonFile((string) BACKUP_SERVER_SETTINGS_FILE, [ "retention" => max(1, (int) ($settings["retention"] ?? BACKUP_SERVER_RETENTION)), "s3_retention" => max(1, (int) ($settings["s3_retention"] ?? BACKUP_SERVER_S3_RETENTION)), "instances" => $allowedInstances, ]); } function backupServerLog(string $message, array $context = []): void { $line = date(DATE_ATOM) . " " . $message; $encoded = @json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if (is_string($encoded) && $encoded !== "[]") { $line .= " " . $encoded; } @file_put_contents((string) BACKUP_SERVER_LOG_FILE, $line . PHP_EOL, FILE_APPEND | LOCK_EX); } function backupServerUpdateIndexRecord(string $instance, string $filename, callable $update): void { $index = backupServerReadIndex(); foreach ($index["backups"] as $position => $backup) { if ( is_array($backup) && ($backup["instance"] ?? "") === $instance && ($backup["filename"] ?? "") === $filename ) { $index["backups"][$position] = $update($backup); } } backupServerWriteIndex($index["backups"]); } function backupServerIndexInstances(): array { $instances = []; foreach (backupServerReadIndex()["backups"] as $backup) { if (is_array($backup)) { $instance = (string) ($backup["instance"] ?? ""); if ($instance !== "") { $instances[$instance] = true; } } } return array_keys($instances); } // Uploads every local backup of the instance that is not yet confirmed in S3, // oldest first. Serves both the immediate upload after receiving a backup and // the opportunistic retry of earlier failures. Stops at the first failure // because the endpoint is then most likely unreachable. function backupServerSyncInstanceS3(string $instance): array { $result = ["uploaded" => 0, "pending" => 0, "error" => null]; if (!backupS3Enabled()) { return $result; } $pending = []; foreach (backupServerReadIndex()["backups"] as $backup) { if (!is_array($backup) || ($backup["instance"] ?? "") !== $instance) { continue; } if (!empty($backup["s3_uploaded_at"])) { continue; } $filename = basename((string) ($backup["filename"] ?? "")); if ($filename === "" || !is_file(backupServerBackupPath($instance, $filename))) { continue; } $backup["filename"] = $filename; $pending[] = $backup; } usort($pending, function ($left, $right) { return strcmp((string) ($left["uploaded_at"] ?? ""), (string) ($right["uploaded_at"] ?? "")); }); foreach ($pending as $position => $backup) { $filename = (string) $backup["filename"]; $key = (string) ($backup["s3_key"] ?? ""); if ($key === "") { $key = backupS3ObjectKey($instance, $filename); } try { backupS3PutFile(backupServerBackupPath($instance, $filename), $key); } catch (Throwable $exception) { $result["pending"] = count($pending) - $position; $result["error"] = $exception->getMessage(); backupServerUpdateIndexRecord($instance, $filename, function (array $record) use ($key, $exception) { $record["s3_key"] = $key; $record["s3_last_error"] = $exception->getMessage(); $record["s3_last_attempt_at"] = date(DATE_ATOM); return $record; }); backupServerLog("S3 upload failed", [ "instance" => $instance, "filename" => $filename, "key" => $key, "error" => $exception->getMessage(), ]); return $result; } backupServerUpdateIndexRecord($instance, $filename, function (array $record) use ($key) { $record["s3_key"] = $key; $record["s3_uploaded_at"] = date(DATE_ATOM); unset($record["s3_last_error"], $record["s3_last_attempt_at"], $record["s3_expired"]); return $record; }); $result["uploaded"]++; } return $result; } // Applies both retention tiers for one instance. S3 keeps the newest // s3_retention archived backups; local keeps the newest retention copies but // never deletes a file whose S3 upload is still pending. function backupServerApplyRetention(string $instance): void { $index = backupServerReadIndex(); $settings = backupServerGetSettings(); $s3Enabled = backupS3Enabled(); $instanceBackups = []; $otherBackups = []; foreach ($index["backups"] as $backup) { if (!is_array($backup)) { continue; } if (($backup["instance"] ?? "") === $instance) { $instanceBackups[] = $backup; } else { $otherBackups[] = $backup; } } usort($instanceBackups, function ($left, $right) { return strcmp((string) ($right["uploaded_at"] ?? ""), (string) ($left["uploaded_at"] ?? "")); }); if ($s3Enabled) { $archivedSeen = 0; foreach ($instanceBackups as $position => $backup) { if (empty($backup["s3_uploaded_at"])) { continue; } $archivedSeen++; if ($archivedSeen <= $settings["s3_retention"]) { continue; } $filename = basename((string) ($backup["filename"] ?? "")); $key = (string) ($backup["s3_key"] ?? ""); if ($key === "" && $filename !== "") { $key = backupS3ObjectKey($instance, $filename); } try { if ($key !== "") { backupS3DeleteObject($key); } } catch (Throwable $exception) { backupServerLog("S3 retention delete failed", [ "instance" => $instance, "filename" => $filename, "key" => $key, "error" => $exception->getMessage(), ]); continue; } unset($backup["s3_uploaded_at"], $backup["s3_key"]); $backup["s3_expired"] = true; $instanceBackups[$position] = $backup; } } $localSeen = 0; $kept = []; foreach ($instanceBackups as $backup) { $filename = basename((string) ($backup["filename"] ?? "")); $path = $filename !== "" ? backupServerBackupPath($instance, $filename) : ""; $localExists = $path !== "" && is_file($path); $inS3 = !empty($backup["s3_uploaded_at"]); if (!$localExists) { if ($inS3) { $kept[] = $backup; } // Present in neither store: drop the orphaned record. continue; } $localSeen++; if ($localSeen <= $settings["retention"]) { $kept[] = $backup; continue; } if ($inS3) { @unlink($path); $backup["local_deleted_at"] = date(DATE_ATOM); $kept[] = $backup; continue; } if ($s3Enabled && empty($backup["s3_expired"])) { // The only copy lives locally until the S3 upload succeeds. $kept[] = $backup; continue; } // S3 disabled (legacy behavior) or the backup already aged out of S3. @unlink($path); } backupServerWriteIndex(array_merge($otherBackups, $kept)); } function backupServerApplyRetentionAll(): void { foreach (backupServerIndexInstances() as $instance) { backupServerApplyRetention($instance); } }