update-server.php 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. <?php
  2. declare(strict_types=1);
  3. function updateServerJsonResponse(int $status, array $payload): void
  4. {
  5. http_response_code($status);
  6. header("Content-Type: application/json; charset=utf-8");
  7. echo json_encode(
  8. $payload,
  9. JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
  10. );
  11. exit;
  12. }
  13. function updateServerTextResponse(int $status, string $message): void
  14. {
  15. http_response_code($status);
  16. header("Content-Type: text/plain; charset=utf-8");
  17. echo $message;
  18. exit;
  19. }
  20. function updateServerVersionIsValid(string $version): bool
  21. {
  22. return preg_match('/^v\d+\.\d+\.\d+$/', $version) === 1;
  23. }
  24. function updateServerEnsureDirectory(string $dir): void
  25. {
  26. if (!is_dir($dir) && !mkdir($dir, 02775, true) && !is_dir($dir)) {
  27. throw new RuntimeException("Directory cannot be created: " . $dir);
  28. }
  29. @chmod($dir, 02775);
  30. }
  31. function updateServerReadManifest(string $manifestFile, bool $allowMissing = false): array
  32. {
  33. if (!is_file($manifestFile)) {
  34. if ($allowMissing) {
  35. return ["latest" => "", "releases" => []];
  36. }
  37. throw new RuntimeException("Manifest file is missing.");
  38. }
  39. $decoded = json_decode((string) file_get_contents($manifestFile), true);
  40. if (!is_array($decoded)) {
  41. throw new RuntimeException("Manifest file is not valid JSON.");
  42. }
  43. return [
  44. "latest" => trim((string) ($decoded["latest"] ?? "")),
  45. "releases" => isset($decoded["releases"]) && is_array($decoded["releases"])
  46. ? $decoded["releases"]
  47. : [],
  48. ];
  49. }
  50. function updateServerWriteManifest(string $manifestFile, array $manifest): void
  51. {
  52. $json = json_encode(
  53. $manifest,
  54. JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
  55. );
  56. if ($json === false) {
  57. throw new RuntimeException("Manifest cannot be encoded.");
  58. }
  59. $tmpFile = $manifestFile . ".tmp";
  60. if (file_put_contents($tmpFile, $json . PHP_EOL, LOCK_EX) === false) {
  61. throw new RuntimeException("Manifest cannot be written.");
  62. }
  63. @chmod($tmpFile, 0664);
  64. if (!rename($tmpFile, $manifestFile)) {
  65. @unlink($tmpFile);
  66. throw new RuntimeException("Manifest cannot be saved.");
  67. }
  68. @chmod($manifestFile, 0664);
  69. }
  70. function updateServerLatestRelease(array $manifest): array
  71. {
  72. $latest = trim((string) ($manifest["latest"] ?? ""));
  73. $releases = isset($manifest["releases"]) && is_array($manifest["releases"])
  74. ? $manifest["releases"]
  75. : [];
  76. if ($latest === "" || !updateServerVersionIsValid($latest)) {
  77. throw new RuntimeException("No valid latest release is configured.");
  78. }
  79. if (!isset($releases[$latest]) || !is_array($releases[$latest])) {
  80. throw new RuntimeException("Latest release entry is missing.");
  81. }
  82. return updateServerNormalizeRelease($latest, $releases[$latest]);
  83. }
  84. function updateServerReleaseByVersion(array $manifest, string $version): array
  85. {
  86. if (!updateServerVersionIsValid($version)) {
  87. throw new InvalidArgumentException("Invalid version.");
  88. }
  89. $releases = isset($manifest["releases"]) && is_array($manifest["releases"])
  90. ? $manifest["releases"]
  91. : [];
  92. if (!isset($releases[$version]) || !is_array($releases[$version])) {
  93. throw new RuntimeException("Release is not configured.");
  94. }
  95. return updateServerNormalizeRelease($version, $releases[$version]);
  96. }
  97. function updateServerNormalizeRelease(string $expectedVersion, array $release): array
  98. {
  99. $version = trim((string) ($release["version"] ?? $expectedVersion));
  100. $package = trim((string) ($release["package"] ?? ""));
  101. $sha256 = strtolower(trim((string) ($release["sha256"] ?? "")));
  102. $publishedAt = trim((string) ($release["published_at"] ?? ""));
  103. $size = isset($release["size"]) ? (int) $release["size"] : 0;
  104. if ($version !== $expectedVersion || !updateServerVersionIsValid($version)) {
  105. throw new RuntimeException("Release version is invalid.");
  106. }
  107. if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
  108. throw new RuntimeException("Release checksum is invalid.");
  109. }
  110. if ($package === "" || str_contains($package, "\0") || str_starts_with($package, "/")) {
  111. throw new RuntimeException("Release package path is invalid.");
  112. }
  113. return [
  114. "version" => $version,
  115. "package" => $package,
  116. "sha256" => $sha256,
  117. "size" => $size,
  118. "published_at" => $publishedAt,
  119. ];
  120. }
  121. function updateServerPackagePath(string $baseDir, string $package): string
  122. {
  123. $packagePath = realpath($baseDir . "/" . $package);
  124. $packagesDir = realpath($baseDir . "/packages");
  125. if (
  126. $packagePath === false ||
  127. $packagesDir === false ||
  128. !str_starts_with($packagePath, $packagesDir . DIRECTORY_SEPARATOR) ||
  129. !is_file($packagePath)
  130. ) {
  131. throw new RuntimeException("Release package is missing.");
  132. }
  133. return $packagePath;
  134. }
  135. function updateServerPackageUrl(string $version): string
  136. {
  137. $scheme = "http";
  138. if (
  139. (!empty($_SERVER["HTTPS"]) && $_SERVER["HTTPS"] !== "off") ||
  140. (isset($_SERVER["SERVER_PORT"]) && (int) $_SERVER["SERVER_PORT"] === 443) ||
  141. strtolower((string) ($_SERVER["HTTP_X_FORWARDED_PROTO"] ?? "")) === "https"
  142. ) {
  143. $scheme = "https";
  144. }
  145. $host = $_SERVER["HTTP_HOST"] ?? "localhost";
  146. $scriptDir = rtrim(str_replace("\\", "/", dirname($_SERVER["SCRIPT_NAME"] ?? "")), "/");
  147. return $scheme .
  148. "://" .
  149. $host .
  150. ($scriptDir === "" ? "" : $scriptDir) .
  151. "/package.php?version=" .
  152. rawurlencode($version);
  153. }
  154. function updateServerPackageFileName(string $version): string
  155. {
  156. $prefix = defined("UPDATE_SERVER_PACKAGE_PREFIX")
  157. ? (string) UPDATE_SERVER_PACKAGE_PREFIX
  158. : "release";
  159. $prefix = trim((string) preg_replace('/[^A-Za-z0-9._-]+/', "-", $prefix), ".-_");
  160. if ($prefix === "") {
  161. $prefix = "release";
  162. }
  163. return $prefix . "-" . $version . ".zip";
  164. }
  165. function updateServerUploadedFileIsZip(array $file): bool
  166. {
  167. $name = strtolower((string) ($file["name"] ?? ""));
  168. $tmpName = (string) ($file["tmp_name"] ?? "");
  169. if (!str_ends_with($name, ".zip") || !is_uploaded_file($tmpName)) {
  170. return false;
  171. }
  172. $handle = fopen($tmpName, "rb");
  173. if ($handle === false) {
  174. return false;
  175. }
  176. $signature = fread($handle, 4);
  177. fclose($handle);
  178. return $signature === "PK\x03\x04" || $signature === "PK\x05\x06" || $signature === "PK\x07\x08";
  179. }
  180. function updateServerPublishUpload(
  181. string $version,
  182. array $file,
  183. string $manifestFile,
  184. string $packagesDir,
  185. ): void {
  186. if (!updateServerVersionIsValid($version)) {
  187. throw new RuntimeException("Version must use the format vX.Y.Z.");
  188. }
  189. if (($file["error"] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
  190. throw new RuntimeException("Upload failed with error code " . (string) ($file["error"] ?? "unknown") . ".");
  191. }
  192. if (!updateServerUploadedFileIsZip($file)) {
  193. throw new RuntimeException("Uploaded file must be a ZIP package.");
  194. }
  195. updateServerEnsureDirectory($packagesDir);
  196. $fileName = updateServerPackageFileName($version);
  197. $targetPath = $packagesDir . DIRECTORY_SEPARATOR . $fileName;
  198. if (!move_uploaded_file((string) $file["tmp_name"], $targetPath)) {
  199. throw new RuntimeException("Uploaded package cannot be stored.");
  200. }
  201. @chmod($targetPath, 0664);
  202. $sha256 = strtolower(hash_file("sha256", $targetPath) ?: "");
  203. $size = filesize($targetPath);
  204. if (!preg_match('/^[a-f0-9]{64}$/', $sha256) || $size === false || $size <= 0) {
  205. @unlink($targetPath);
  206. throw new RuntimeException("Stored package could not be verified.");
  207. }
  208. $manifest = updateServerReadManifest($manifestFile, true);
  209. $manifest["latest"] = $version;
  210. $manifest["releases"][$version] = [
  211. "version" => $version,
  212. "package" => "packages/" . $fileName,
  213. "sha256" => $sha256,
  214. "size" => $size,
  215. "published_at" => date(DATE_ATOM),
  216. ];
  217. ksort($manifest["releases"]);
  218. updateServerWriteManifest($manifestFile, $manifest);
  219. }
  220. function updateServerSetLatest(string $version, string $manifestFile): void
  221. {
  222. if (!updateServerVersionIsValid($version)) {
  223. throw new RuntimeException("Invalid release version.");
  224. }
  225. $manifest = updateServerReadManifest($manifestFile, true);
  226. if (!isset($manifest["releases"][$version])) {
  227. throw new RuntimeException("Release is not present in the manifest.");
  228. }
  229. $manifest["latest"] = $version;
  230. updateServerWriteManifest($manifestFile, $manifest);
  231. }
  232. function updateServerDeleteRelease(string $version, string $manifestFile, string $baseDir): void
  233. {
  234. if (!updateServerVersionIsValid($version)) {
  235. throw new RuntimeException("Invalid release version.");
  236. }
  237. $manifest = updateServerReadManifest($manifestFile, true);
  238. if (!isset($manifest["releases"][$version])) {
  239. throw new RuntimeException("Release is not present in the manifest.");
  240. }
  241. $package = trim((string) ($manifest["releases"][$version]["package"] ?? ""));
  242. unset($manifest["releases"][$version]);
  243. if ($manifest["latest"] === $version) {
  244. $manifest["latest"] = "";
  245. }
  246. updateServerWriteManifest($manifestFile, $manifest);
  247. if ($package === "" || str_contains($package, "\0") || str_starts_with($package, "/")) {
  248. return;
  249. }
  250. try {
  251. $packagePath = updateServerPackagePath($baseDir, $package);
  252. unlink($packagePath);
  253. } catch (Throwable $exception) {
  254. return;
  255. }
  256. }