updater.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. <?php
  2. declare(strict_types=1);
  3. class SimpleUpdater
  4. {
  5. private string $manifestUrl;
  6. private string $workDir;
  7. private string $backupDir;
  8. private string $currentVersion;
  9. private string $appRoot;
  10. private string $userAgent;
  11. /** @var string[] */
  12. private array $preservePaths;
  13. /** @var string[] */
  14. private array $requiredPackagePaths;
  15. /**
  16. * @param array{
  17. * manifest_url?: string,
  18. * work_dir?: string,
  19. * backup_dir?: string,
  20. * current_version?: string,
  21. * app_root?: string,
  22. * user_agent?: string,
  23. * preserve_paths?: string[],
  24. * required_package_paths?: string[]
  25. * } $config
  26. */
  27. public function __construct(array $config)
  28. {
  29. $this->manifestUrl = trim((string) ($config["manifest_url"] ?? ""));
  30. $this->workDir = rtrim((string) ($config["work_dir"] ?? ""), "/\\");
  31. $this->backupDir = rtrim((string) ($config["backup_dir"] ?? ""), "/\\");
  32. $this->currentVersion = trim((string) ($config["current_version"] ?? ""));
  33. $this->appRoot = rtrim((string) ($config["app_root"] ?? ""), "/\\");
  34. $this->userAgent = trim((string) ($config["user_agent"] ?? "Simple-PHP-Updater"));
  35. if ($this->userAgent === "") {
  36. $this->userAgent = "Simple-PHP-Updater";
  37. }
  38. $this->preservePaths = $this->normalizePathList($config["preserve_paths"] ?? null);
  39. if ($this->preservePaths === []) {
  40. $this->preservePaths = ["config.php", "data/", ".git/"];
  41. }
  42. $this->requiredPackagePaths = $this->normalizePathList($config["required_package_paths"] ?? null);
  43. if ($this->requiredPackagePaths === []) {
  44. $this->requiredPackagePaths = ["index.php", "admin/", "includes/"];
  45. }
  46. if ($this->workDir === "") {
  47. throw new InvalidArgumentException("Updater work directory is not configured.");
  48. }
  49. if ($this->backupDir === "") {
  50. throw new InvalidArgumentException("Updater backup directory is not configured.");
  51. }
  52. if ($this->currentVersion === "") {
  53. throw new InvalidArgumentException("Current application version is not configured.");
  54. }
  55. if ($this->appRoot === "" || !is_dir($this->appRoot)) {
  56. throw new InvalidArgumentException("Application root cannot be resolved.");
  57. }
  58. }
  59. public function getManifestUrl(): string
  60. {
  61. return $this->manifestUrl;
  62. }
  63. public function getCurrentVersion(): string
  64. {
  65. return $this->currentVersion;
  66. }
  67. public function fetchManifest(): array
  68. {
  69. if ($this->manifestUrl === "") {
  70. throw new RuntimeException("UPDATE_MANIFEST_URL is not configured.");
  71. }
  72. $body = $this->httpGet($this->manifestUrl, 15);
  73. $manifest = json_decode($body, true);
  74. if (!is_array($manifest)) {
  75. throw new RuntimeException("Manifest response is not valid JSON.");
  76. }
  77. $version = trim((string) ($manifest["version"] ?? $manifest["latest"] ?? ""));
  78. $packageUrl = trim((string) ($manifest["package_url"] ?? ""));
  79. $sha256 = strtolower(trim((string) ($manifest["sha256"] ?? "")));
  80. $size = isset($manifest["size"]) ? (int) $manifest["size"] : 0;
  81. $publishedAt = trim((string) ($manifest["published_at"] ?? ""));
  82. if (!self::isVersion($version)) {
  83. throw new RuntimeException("Manifest version is invalid.");
  84. }
  85. if (!filter_var($packageUrl, FILTER_VALIDATE_URL)) {
  86. throw new RuntimeException("Manifest package URL is invalid.");
  87. }
  88. if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
  89. throw new RuntimeException("Manifest checksum is invalid.");
  90. }
  91. return [
  92. "version" => $version,
  93. "package_url" => $packageUrl,
  94. "sha256" => $sha256,
  95. "size" => $size,
  96. "published_at" => $publishedAt,
  97. ];
  98. }
  99. public function updateAvailable(array $manifest): bool
  100. {
  101. return version_compare(
  102. self::versionToCompare((string) ($manifest["version"] ?? "")),
  103. self::versionToCompare($this->currentVersion),
  104. ">",
  105. );
  106. }
  107. public function deploy(array $manifest): array
  108. {
  109. $version = (string) ($manifest["version"] ?? "");
  110. if (!self::isVersion($version)) {
  111. throw new RuntimeException("Manifest version is invalid.");
  112. }
  113. $runId = date("Ymd-His");
  114. $workDir = $this->workDir . DIRECTORY_SEPARATOR . $runId;
  115. $stageDir = $workDir . DIRECTORY_SEPARATOR . "stage";
  116. $zipFile = $workDir . DIRECTORY_SEPARATOR . "package.zip";
  117. $backupDir = $this->backupDir .
  118. DIRECTORY_SEPARATOR .
  119. $runId .
  120. "-" .
  121. $version;
  122. try {
  123. self::ensureDirectory($workDir);
  124. $this->downloadPackage($manifest, $zipFile);
  125. $this->extractPackage($zipFile, $stageDir);
  126. $result = $this->copyWithBackup($stageDir, $backupDir);
  127. } finally {
  128. self::removeDirectory($workDir);
  129. }
  130. $removedBackups = $this->cleanupOldBackups($backupDir);
  131. return [
  132. "backup_dir" => $backupDir,
  133. "copied" => $result["copied"],
  134. "backed_up" => $result["backed_up"],
  135. "removed_backups" => $removedBackups,
  136. "skipped" => $result["skipped"],
  137. ];
  138. }
  139. public static function versionToCompare(string $version): string
  140. {
  141. return ltrim(trim($version), "vV");
  142. }
  143. public static function isVersion(string $version): bool
  144. {
  145. return preg_match('/^v\d+\.\d+\.\d+$/', $version) === 1;
  146. }
  147. public static function ensureDirectory(string $dir): void
  148. {
  149. if (!is_dir($dir) && !mkdir($dir, 02775, true) && !is_dir($dir)) {
  150. throw new RuntimeException("Directory cannot be created: " . $dir);
  151. }
  152. @chmod($dir, 02775);
  153. }
  154. public static function removeDirectory(string $dir): void
  155. {
  156. if (!is_dir($dir)) {
  157. return;
  158. }
  159. $items = new RecursiveIteratorIterator(
  160. new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
  161. RecursiveIteratorIterator::CHILD_FIRST,
  162. );
  163. foreach ($items as $item) {
  164. if ($item->isDir()) {
  165. rmdir($item->getPathname());
  166. } else {
  167. unlink($item->getPathname());
  168. }
  169. }
  170. rmdir($dir);
  171. }
  172. private function httpGet(string $url, int $timeout): string
  173. {
  174. if (!filter_var($url, FILTER_VALIDATE_URL)) {
  175. throw new RuntimeException("Invalid URL: " . $url);
  176. }
  177. $context = stream_context_create([
  178. "http" => [
  179. "method" => "GET",
  180. "timeout" => $timeout,
  181. "ignore_errors" => true,
  182. "header" => "User-Agent: " . $this->userAgent . "\r\n",
  183. ],
  184. ]);
  185. $body = @file_get_contents($url, false, $context);
  186. $status = 0;
  187. $responseHeaders = function_exists("http_get_last_response_headers")
  188. ? http_get_last_response_headers()
  189. : [];
  190. if (is_array($responseHeaders)) {
  191. foreach ($responseHeaders as $header) {
  192. if (preg_match('/^HTTP\/\S+\s+(\d+)/', $header, $matches)) {
  193. $status = (int) $matches[1];
  194. }
  195. }
  196. }
  197. if ($body === false || ($status >= 400 && $status < 600)) {
  198. throw new RuntimeException(
  199. "HTTP request failed" . ($status > 0 ? " with status " . $status : "") . ".",
  200. );
  201. }
  202. return $body;
  203. }
  204. private function downloadPackage(array $manifest, string $targetFile): void
  205. {
  206. self::ensureDirectory(dirname($targetFile));
  207. $data = $this->httpGet((string) $manifest["package_url"], 120);
  208. if ($data === "") {
  209. throw new RuntimeException("Downloaded package is empty.");
  210. }
  211. if (file_put_contents($targetFile, $data, LOCK_EX) === false) {
  212. throw new RuntimeException("Downloaded package cannot be written.");
  213. }
  214. if ((int) $manifest["size"] > 0 && filesize($targetFile) !== (int) $manifest["size"]) {
  215. unlink($targetFile);
  216. throw new RuntimeException("Downloaded package size mismatch.");
  217. }
  218. $actualHash = strtolower(hash_file("sha256", $targetFile) ?: "");
  219. if ($actualHash !== (string) $manifest["sha256"]) {
  220. unlink($targetFile);
  221. throw new RuntimeException("Package checksum mismatch.");
  222. }
  223. }
  224. private function extractPackage(string $zipFile, string $stageDir): void
  225. {
  226. if (!class_exists("ZipArchive")) {
  227. throw new RuntimeException("PHP ZipArchive extension is not available.");
  228. }
  229. self::removeDirectory($stageDir);
  230. self::ensureDirectory($stageDir);
  231. $zip = new ZipArchive();
  232. if ($zip->open($zipFile) !== true) {
  233. throw new RuntimeException("Downloaded package is not a readable ZIP file.");
  234. }
  235. $hasRequiredPath = false;
  236. for ($i = 0; $i < $zip->numFiles; $i++) {
  237. $name = (string) $zip->getNameIndex($i);
  238. if (!$this->validateZipEntry($name)) {
  239. $zip->close();
  240. throw new RuntimeException("ZIP contains an unsafe path: " . $name);
  241. }
  242. if ($this->matchesAnyConfiguredPath($name, $this->requiredPackagePaths)) {
  243. $hasRequiredPath = true;
  244. }
  245. }
  246. if (!$hasRequiredPath) {
  247. $zip->close();
  248. throw new RuntimeException("ZIP does not look like an app-root release package.");
  249. }
  250. if (!$zip->extractTo($stageDir)) {
  251. $zip->close();
  252. throw new RuntimeException("ZIP package cannot be extracted.");
  253. }
  254. $zip->close();
  255. }
  256. private function validateZipEntry(string $entry): bool
  257. {
  258. $entry = str_replace("\\", "/", $entry);
  259. $normalized = trim($entry, "/");
  260. if (
  261. $normalized === "" ||
  262. str_contains($entry, "\0") ||
  263. str_starts_with($entry, "/") ||
  264. preg_match('/^[A-Za-z]:\//', $entry)
  265. ) {
  266. return false;
  267. }
  268. foreach (explode("/", $normalized) as $segment) {
  269. if ($segment === "" || $segment === "." || $segment === "..") {
  270. return false;
  271. }
  272. }
  273. return true;
  274. }
  275. private function copyWithBackup(string $stageDir, string $backupDir): array
  276. {
  277. self::ensureDirectory($backupDir);
  278. $copied = 0;
  279. $backedUp = 0;
  280. $skipped = 0;
  281. $items = new RecursiveIteratorIterator(
  282. new RecursiveDirectoryIterator($stageDir, FilesystemIterator::SKIP_DOTS),
  283. RecursiveIteratorIterator::SELF_FIRST,
  284. );
  285. foreach ($items as $item) {
  286. $relativePath = $this->relativePath($item->getPathname(), $stageDir);
  287. if ($this->shouldPreservePath($relativePath)) {
  288. $skipped++;
  289. continue;
  290. }
  291. $targetPath = $this->appRoot . DIRECTORY_SEPARATOR . $relativePath;
  292. if ($item->isDir()) {
  293. self::ensureDirectory($targetPath);
  294. continue;
  295. }
  296. self::ensureDirectory(dirname($targetPath));
  297. if (file_exists($targetPath)) {
  298. $backupPath = $backupDir . DIRECTORY_SEPARATOR . $relativePath;
  299. self::ensureDirectory(dirname($backupPath));
  300. if (!copy($targetPath, $backupPath)) {
  301. throw new RuntimeException("Cannot back up file: " . $relativePath);
  302. }
  303. $backedUp++;
  304. }
  305. if (!copy($item->getPathname(), $targetPath)) {
  306. throw new RuntimeException("Cannot deploy file: " . $relativePath);
  307. }
  308. @chmod($targetPath, fileperms($item->getPathname()) & 0777);
  309. $copied++;
  310. }
  311. return [
  312. "copied" => $copied,
  313. "backed_up" => $backedUp,
  314. "skipped" => $skipped,
  315. ];
  316. }
  317. private function cleanupOldBackups(string $keepBackupDir): int
  318. {
  319. if (!is_dir($this->backupDir)) {
  320. return 0;
  321. }
  322. $keepRealPath = realpath($keepBackupDir);
  323. $backupRootRealPath = realpath($this->backupDir);
  324. if ($keepRealPath === false || $backupRootRealPath === false) {
  325. return 0;
  326. }
  327. $removed = 0;
  328. $items = new DirectoryIterator($backupRootRealPath);
  329. foreach ($items as $item) {
  330. if ($item->isDot() || !$item->isDir()) {
  331. continue;
  332. }
  333. $path = $item->getPathname();
  334. if (realpath($path) === $keepRealPath) {
  335. continue;
  336. }
  337. self::removeDirectory($path);
  338. if (is_dir($path)) {
  339. throw new RuntimeException("Old backup directory could not be removed: " . $path);
  340. }
  341. $removed++;
  342. }
  343. return $removed;
  344. }
  345. private function shouldPreservePath(string $relativePath): bool
  346. {
  347. return $this->matchesAnyConfiguredPath($relativePath, $this->preservePaths);
  348. }
  349. private function matchesAnyConfiguredPath(string $path, array $configuredPaths): bool
  350. {
  351. $path = trim(str_replace("\\", "/", $path), "/");
  352. if ($path === "") {
  353. return true;
  354. }
  355. foreach ($configuredPaths as $configuredPath) {
  356. $configuredPath = str_replace("\\", "/", $configuredPath);
  357. $isDirectoryMatch = str_ends_with($configuredPath, "/");
  358. $configuredPath = trim($configuredPath, "/");
  359. if ($configuredPath === "") {
  360. continue;
  361. }
  362. if ($path === $configuredPath) {
  363. return true;
  364. }
  365. if (($isDirectoryMatch || !str_contains($configuredPath, ".")) &&
  366. str_starts_with($path, $configuredPath . "/")
  367. ) {
  368. return true;
  369. }
  370. }
  371. return false;
  372. }
  373. private function relativePath(string $path, string $baseDir): string
  374. {
  375. return ltrim(str_replace("\\", "/", substr($path, strlen($baseDir))), "/");
  376. }
  377. /**
  378. * @param mixed $paths
  379. * @return string[]
  380. */
  381. private function normalizePathList($paths): array
  382. {
  383. if (!is_array($paths)) {
  384. return [];
  385. }
  386. $normalized = [];
  387. foreach ($paths as $path) {
  388. $path = trim(str_replace("\\", "/", (string) $path));
  389. if ($path !== "") {
  390. $normalized[] = $path;
  391. }
  392. }
  393. return $normalized;
  394. }
  395. }