upload.php 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. <?php
  2. declare(strict_types=1);
  3. $baseDir = __DIR__;
  4. $configFile = $baseDir . "/config.php";
  5. if (is_file($configFile)) {
  6. require_once $configFile;
  7. }
  8. if (!defined("BACKUP_SERVER_RETENTION")) {
  9. define("BACKUP_SERVER_RETENTION", 30);
  10. }
  11. if (!defined("BACKUP_SERVER_BACKUP_DIR")) {
  12. define("BACKUP_SERVER_BACKUP_DIR", $baseDir . "/backups/");
  13. }
  14. if (!defined("BACKUP_SERVER_INDEX_FILE")) {
  15. define("BACKUP_SERVER_INDEX_FILE", rtrim((string) BACKUP_SERVER_BACKUP_DIR, "/\\") . "/index.json");
  16. }
  17. if (!defined("BACKUP_SERVER_SETTINGS_FILE")) {
  18. define("BACKUP_SERVER_SETTINGS_FILE", rtrim((string) BACKUP_SERVER_BACKUP_DIR, "/\\") . "/settings.json");
  19. }
  20. header("Content-Type: application/json; charset=utf-8");
  21. header("Cache-Control: no-store");
  22. header("X-Content-Type-Options: nosniff");
  23. function backupUploadRespond(int $status, array $payload): void
  24. {
  25. http_response_code($status);
  26. echo json_encode(
  27. $payload,
  28. JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
  29. );
  30. exit;
  31. }
  32. function backupUploadEnsureDirectory(string $dir): void
  33. {
  34. if (!is_dir($dir) && !mkdir($dir, 02775, true) && !is_dir($dir)) {
  35. throw new RuntimeException("Directory cannot be created.");
  36. }
  37. @chmod($dir, 02775);
  38. }
  39. function backupUploadNormalizeInstance(string $instance): string
  40. {
  41. $instance = trim($instance);
  42. if (
  43. $instance === "" ||
  44. strlen($instance) > 120 ||
  45. preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*$/', $instance) !== 1
  46. ) {
  47. throw new RuntimeException("Invalid instance identifier.");
  48. }
  49. return $instance;
  50. }
  51. function backupUploadReadJsonFile(string $file): array
  52. {
  53. if (!is_file($file)) {
  54. return [];
  55. }
  56. $decoded = json_decode((string) file_get_contents($file), true);
  57. return is_array($decoded) ? $decoded : [];
  58. }
  59. function backupUploadWriteJsonFile(string $file, array $data): void
  60. {
  61. backupUploadEnsureDirectory(dirname($file));
  62. $json = json_encode(
  63. $data,
  64. JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
  65. );
  66. if ($json === false) {
  67. throw new RuntimeException("JSON cannot be encoded.");
  68. }
  69. $tmpFile = $file . ".tmp";
  70. if (file_put_contents($tmpFile, $json . PHP_EOL, LOCK_EX) === false) {
  71. throw new RuntimeException("JSON cannot be written.");
  72. }
  73. @chmod($tmpFile, 0664);
  74. if (!rename($tmpFile, $file)) {
  75. @unlink($tmpFile);
  76. throw new RuntimeException("JSON cannot be saved.");
  77. }
  78. @chmod($file, 0664);
  79. }
  80. function backupUploadReadIndex(): array
  81. {
  82. $index = backupUploadReadJsonFile((string) BACKUP_SERVER_INDEX_FILE);
  83. $backups = isset($index["backups"]) && is_array($index["backups"])
  84. ? $index["backups"]
  85. : [];
  86. return ["backups" => array_values($backups)];
  87. }
  88. function backupUploadWriteIndex(array $backups): void
  89. {
  90. backupUploadWriteJsonFile((string) BACKUP_SERVER_INDEX_FILE, [
  91. "backups" => array_values($backups),
  92. ]);
  93. }
  94. function backupUploadGetRetention(): int
  95. {
  96. $settings = backupUploadReadJsonFile((string) BACKUP_SERVER_SETTINGS_FILE);
  97. if (isset($settings["retention"])) {
  98. return max(1, (int) $settings["retention"]);
  99. }
  100. return max(1, (int) BACKUP_SERVER_RETENTION);
  101. }
  102. function backupUploadGetAllowedInstances(): array
  103. {
  104. $settings = backupUploadReadJsonFile((string) BACKUP_SERVER_SETTINGS_FILE);
  105. $instances =
  106. isset($settings["instances"]) && is_array($settings["instances"])
  107. ? $settings["instances"]
  108. : [];
  109. $allowed = [];
  110. foreach ($instances as $instance) {
  111. $instance = (string) $instance;
  112. if (
  113. $instance !== "" &&
  114. preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*$/', $instance) === 1
  115. ) {
  116. $allowed[$instance] = true;
  117. }
  118. }
  119. return $allowed;
  120. }
  121. function backupUploadInstanceIsAllowed(string $instance): bool
  122. {
  123. $allowed = backupUploadGetAllowedInstances();
  124. return isset($allowed[$instance]);
  125. }
  126. function backupUploadInstanceDir(string $instance): string
  127. {
  128. return rtrim((string) BACKUP_SERVER_BACKUP_DIR, "/\\") . DIRECTORY_SEPARATOR . $instance;
  129. }
  130. function backupUploadIsZipFile(string $path): bool
  131. {
  132. $handle = fopen($path, "rb");
  133. if ($handle === false) {
  134. return false;
  135. }
  136. $signature = fread($handle, 4);
  137. fclose($handle);
  138. return $signature === "PK\x03\x04" ||
  139. $signature === "PK\x05\x06" ||
  140. $signature === "PK\x07\x08";
  141. }
  142. function backupUploadChooseFilename(string $clientFilename, string $instanceDir): string
  143. {
  144. $clientFilename = trim($clientFilename);
  145. if ($clientFilename === "") {
  146. $filename = "backup-" . gmdate("Ymd-His") . ".zip";
  147. } elseif (
  148. basename($clientFilename) !== $clientFilename ||
  149. preg_match('/^backup-\d{8}-\d{6}(?:-\d+)?\.zip$/', $clientFilename) !== 1
  150. ) {
  151. throw new RuntimeException("Invalid backup filename.");
  152. } else {
  153. $filename = $clientFilename;
  154. }
  155. $base = substr($filename, 0, -4);
  156. $counter = 2;
  157. while (is_file($instanceDir . DIRECTORY_SEPARATOR . $filename)) {
  158. $filename = $base . "-" . $counter . ".zip";
  159. $counter++;
  160. }
  161. return $filename;
  162. }
  163. function backupUploadApplyRetention(string $instance): void
  164. {
  165. $index = backupUploadReadIndex();
  166. $retention = backupUploadGetRetention();
  167. $instanceBackups = [];
  168. $otherBackups = [];
  169. foreach ($index["backups"] as $backup) {
  170. if (!is_array($backup)) {
  171. continue;
  172. }
  173. if (($backup["instance"] ?? "") === $instance) {
  174. $instanceBackups[] = $backup;
  175. } else {
  176. $otherBackups[] = $backup;
  177. }
  178. }
  179. usort($instanceBackups, function ($left, $right) {
  180. return strcmp((string) ($right["uploaded_at"] ?? ""), (string) ($left["uploaded_at"] ?? ""));
  181. });
  182. $keep = array_slice($instanceBackups, 0, $retention);
  183. $remove = array_slice($instanceBackups, $retention);
  184. $instanceDir = backupUploadInstanceDir($instance);
  185. foreach ($remove as $backup) {
  186. $filename = basename((string) ($backup["filename"] ?? ""));
  187. if ($filename !== "" && is_file($instanceDir . DIRECTORY_SEPARATOR . $filename)) {
  188. @unlink($instanceDir . DIRECTORY_SEPARATOR . $filename);
  189. }
  190. }
  191. backupUploadWriteIndex(array_merge($otherBackups, $keep));
  192. }
  193. if ($_SERVER["REQUEST_METHOD"] !== "POST") {
  194. backupUploadRespond(405, ["success" => false, "error" => "POST required."]);
  195. }
  196. try {
  197. $instance = backupUploadNormalizeInstance((string) ($_POST["instance"] ?? ""));
  198. if (!backupUploadInstanceIsAllowed($instance)) {
  199. throw new RuntimeException("Instance is not allowed.");
  200. }
  201. $file = $_FILES["backup"] ?? null;
  202. if (!is_array($file)) {
  203. throw new RuntimeException("Backup file is missing.");
  204. }
  205. if (($file["error"] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
  206. throw new RuntimeException("Upload failed with error code " . (string) ($file["error"] ?? "unknown") . ".");
  207. }
  208. $tmpName = (string) ($file["tmp_name"] ?? "");
  209. if ($tmpName === "" || !is_uploaded_file($tmpName)) {
  210. throw new RuntimeException("Upload is invalid.");
  211. }
  212. if (!backupUploadIsZipFile($tmpName)) {
  213. throw new RuntimeException("Uploaded file must be a ZIP file.");
  214. }
  215. $instanceDir = backupUploadInstanceDir($instance);
  216. backupUploadEnsureDirectory($instanceDir);
  217. $requestedFilename = (string) ($_POST["filename"] ?? "");
  218. $clientFilename = $requestedFilename !== "" ? $requestedFilename : (string) ($file["name"] ?? "");
  219. $filename = backupUploadChooseFilename($requestedFilename, $instanceDir);
  220. $targetPath = $instanceDir . DIRECTORY_SEPARATOR . $filename;
  221. if (!move_uploaded_file($tmpName, $targetPath)) {
  222. throw new RuntimeException("Uploaded backup cannot be stored.");
  223. }
  224. @chmod($targetPath, 0664);
  225. $size = filesize($targetPath);
  226. $sha256 = strtolower(hash_file("sha256", $targetPath) ?: "");
  227. if ($size === false || $size <= 0 || !preg_match('/^[a-f0-9]{64}$/', $sha256)) {
  228. @unlink($targetPath);
  229. throw new RuntimeException("Stored backup could not be verified.");
  230. }
  231. $postedSha256 = strtolower(trim((string) ($_POST["sha256"] ?? "")));
  232. if ($postedSha256 !== "" && (!preg_match('/^[a-f0-9]{64}$/', $postedSha256) || $postedSha256 !== $sha256)) {
  233. @unlink($targetPath);
  234. throw new RuntimeException("Backup checksum mismatch.");
  235. }
  236. $index = backupUploadReadIndex();
  237. $record = [
  238. "instance" => $instance,
  239. "filename" => $filename,
  240. "client_filename" => basename($clientFilename),
  241. "size" => $size,
  242. "sha256" => $sha256,
  243. "uploaded_at" => date(DATE_ATOM),
  244. "source_ip" => $_SERVER["REMOTE_ADDR"] ?? "unknown",
  245. ];
  246. $index["backups"][] = $record;
  247. backupUploadWriteIndex($index["backups"]);
  248. backupUploadApplyRetention($instance);
  249. backupUploadRespond(200, [
  250. "success" => true,
  251. "instance" => $instance,
  252. "filename" => $filename,
  253. "size" => $size,
  254. "sha256" => $sha256,
  255. "retention" => backupUploadGetRetention(),
  256. ]);
  257. } catch (Throwable $exception) {
  258. backupUploadRespond(400, [
  259. "success" => false,
  260. "error" => $exception->getMessage(),
  261. ]);
  262. }