| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425 |
- <?php
- declare(strict_types=1);
- // Shared helpers for the backup server. Included by upload.php and manage.php.
- $backupServerConfigFile = __DIR__ . "/config.php";
- if (is_file($backupServerConfigFile)) {
- require_once $backupServerConfigFile;
- }
- if (!defined("BACKUP_SERVER_RETENTION")) {
- define("BACKUP_SERVER_RETENTION", 30);
- }
- if (!defined("BACKUP_SERVER_BACKUP_DIR")) {
- define("BACKUP_SERVER_BACKUP_DIR", __DIR__ . "/backups/");
- }
- if (!defined("BACKUP_SERVER_INDEX_FILE")) {
- define("BACKUP_SERVER_INDEX_FILE", rtrim((string) BACKUP_SERVER_BACKUP_DIR, "/\\") . "/index.json");
- }
- if (!defined("BACKUP_SERVER_SETTINGS_FILE")) {
- define("BACKUP_SERVER_SETTINGS_FILE", rtrim((string) BACKUP_SERVER_BACKUP_DIR, "/\\") . "/settings.json");
- }
- if (!defined("BACKUP_SERVER_S3_ENABLED")) {
- define("BACKUP_SERVER_S3_ENABLED", false);
- }
- if (!defined("BACKUP_SERVER_S3_ENDPOINT")) {
- define("BACKUP_SERVER_S3_ENDPOINT", "");
- }
- if (!defined("BACKUP_SERVER_S3_REGION")) {
- define("BACKUP_SERVER_S3_REGION", "");
- }
- if (!defined("BACKUP_SERVER_S3_BUCKET")) {
- define("BACKUP_SERVER_S3_BUCKET", "");
- }
- if (!defined("BACKUP_SERVER_S3_PREFIX")) {
- define("BACKUP_SERVER_S3_PREFIX", "");
- }
- if (!defined("BACKUP_SERVER_S3_ACCESS_KEY")) {
- define("BACKUP_SERVER_S3_ACCESS_KEY", "");
- }
- if (!defined("BACKUP_SERVER_S3_SECRET_KEY")) {
- define("BACKUP_SERVER_S3_SECRET_KEY", "");
- }
- if (!defined("BACKUP_SERVER_S3_PATH_STYLE")) {
- define("BACKUP_SERVER_S3_PATH_STYLE", false);
- }
- if (!defined("BACKUP_SERVER_S3_TIMEOUT")) {
- define("BACKUP_SERVER_S3_TIMEOUT", 120);
- }
- if (!defined("BACKUP_SERVER_S3_RETENTION")) {
- define("BACKUP_SERVER_S3_RETENTION", 365);
- }
- if (!defined("BACKUP_SERVER_LOG_FILE")) {
- define("BACKUP_SERVER_LOG_FILE", rtrim((string) BACKUP_SERVER_BACKUP_DIR, "/\\") . "/s3.log");
- }
- require_once __DIR__ . "/s3.php";
- function backupServerEnsureDirectory(string $dir): void
- {
- if (!is_dir($dir) && !mkdir($dir, 02775, true) && !is_dir($dir)) {
- throw new RuntimeException("Directory cannot be created: " . $dir);
- }
- @chmod($dir, 02775);
- }
- function backupServerReadJsonFile(string $file): array
- {
- if (!is_file($file)) {
- return [];
- }
- $decoded = json_decode((string) file_get_contents($file), true);
- if (!is_array($decoded)) {
- throw new RuntimeException("JSON file is invalid: " . basename($file));
- }
- return $decoded;
- }
- function backupServerWriteJsonFile(string $file, array $data): void
- {
- backupServerEnsureDirectory(dirname($file));
- $json = json_encode(
- $data,
- JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
- );
- if ($json === false) {
- throw new RuntimeException("JSON cannot be encoded.");
- }
- $tmpFile = $file . ".tmp";
- if (file_put_contents($tmpFile, $json . PHP_EOL, LOCK_EX) === false) {
- throw new RuntimeException("JSON cannot be written.");
- }
- @chmod($tmpFile, 0664);
- if (!rename($tmpFile, $file)) {
- @unlink($tmpFile);
- throw new RuntimeException("JSON cannot be saved.");
- }
- @chmod($file, 0664);
- }
- function backupServerReadIndex(): array
- {
- $index = backupServerReadJsonFile((string) BACKUP_SERVER_INDEX_FILE);
- $backups = isset($index["backups"]) && is_array($index["backups"])
- ? $index["backups"]
- : [];
- return ["backups" => 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);
- }
- }
|