WebOrbiton
v1.0.0

PriviMetrics 2

467 lines · 16.0 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. const PM_UPDATE_URL = 'https://weborbiton.eu/updater/';
  6. const PM_UPDATE_KEEP_BACKUPS = 5;
  7. const PM_UPDATE_BACKUP_MAX_AGE_DAYS = 30;
  8. const PM_UPDATE_TEXT_EXT = ['php', 'js', 'css', 'txt', 'svg', 'md', 'htaccess', 'json', 'html'];
  9. const PM_UPDATE_SKIP = ['changelog.txt', 'favicon.ico'];
  10. ​
  11. class PmUpdateException extends RuntimeException
  12. {
  13. }
  14. ​
  15. function pmUpdateCurrent(): array
  16. {
  17. $info = is_file(PM_ROOT . '/version.php') ? (require PM_ROOT . '/version.php') : [];
  18. return is_array($info) ? $info : [];
  19. }
  20. ​
  21. function pmUpdateHttp(string $url, ?string $saveTo = null, int $timeout = 20): string
  22. {
  23. if (function_exists('curl_init')) {
  24. $ch = curl_init($url);
  25. $fp = $saveTo !== null ? @fopen($saveTo, 'wb') : null;
  26. if ($fp === false) {
  27. curl_close($ch);
  28. throw new PmUpdateException('Cannot write the downloaded package to data/cache.');
  29. }
  30. curl_setopt_array($ch, [
  31. CURLOPT_RETURNTRANSFER => $saveTo === null,
  32. CURLOPT_FOLLOWLOCATION => true,
  33. CURLOPT_MAXREDIRS => 3,
  34. CURLOPT_TIMEOUT => $timeout,
  35. CURLOPT_CONNECTTIMEOUT => 10,
  36. CURLOPT_SSL_VERIFYPEER => true,
  37. CURLOPT_SSL_VERIFYHOST => 2,
  38. CURLOPT_USERAGENT => 'PriviMetrics-Updater/' . (pmUpdateCurrent()['version'] ?? '0'),
  39. ]);
  40. if ($fp) {
  41. curl_setopt($ch, CURLOPT_FILE, $fp);
  42. }
  43. $body = curl_exec($ch);
  44. $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
  45. $err = curl_error($ch);
  46. curl_close($ch);
  47. if ($fp) {
  48. fclose($fp);
  49. }
  50. if ($body === false) {
  51. throw new PmUpdateException('Connection to the update server failed: ' . $err);
  52. }
  53. if ($status !== 200) {
  54. throw new PmUpdateException('Update server answered with HTTP ' . $status . '.');
  55. }
  56. return $saveTo !== null ? '' : (string) $body;
  57. }
  58. ​
  59. $ctx = stream_context_create(['http' => ['timeout' => $timeout, 'user_agent' => 'PriviMetrics-Updater', 'ignore_errors' => true]]);
  60. $body = @file_get_contents($url, false, $ctx);
  61. $status = 0;
  62. foreach ($http_response_header ?? [] as $h) {
  63. if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) {
  64. $status = (int) $m[1];
  65. }
  66. }
  67. if ($body === false || $status !== 200) {
  68. throw new PmUpdateException('Could not reach the update server' . ($status ? ' (HTTP ' . $status . ')' : '') . '.');
  69. }
  70. if ($saveTo !== null) {
  71. if (file_put_contents($saveTo, $body) === false) {
  72. throw new PmUpdateException('Cannot write the downloaded package to data/cache.');
  73. }
  74. return '';
  75. }
  76. return $body;
  77. }
  78. ​
  79. function pmUpdateManifest(): array
  80. {
  81. $edited = (int) @filemtime(PM_ROOT . '/version.txt');
  82. $data = json_decode(pmUpdateHttp(PM_UPDATE_URL . '?v=' . $edited), true);
  83. if (!is_array($data)) {
  84. throw new PmUpdateException('Update server returned an invalid response.');
  85. }
  86. if (isset($data['error'])) {
  87. throw new PmUpdateException('Update server: ' . (string) $data['error']);
  88. }
  89. if (empty($data['version']) || !preg_match('/^[a-f0-9]{64}$/i', (string) ($data['sha256'] ?? ''))) {
  90. throw new PmUpdateException('Update manifest is incomplete.');
  91. }
  92. $data['version_raw'] = (string) $data['version'];
  93. if (!preg_match('/\d+(?:\.\d+)+(?:-[\w.]+)?/', $data['version_raw'], $m)) {
  94. throw new PmUpdateException('Could not read a version number from "' . $data['version_raw'] . '".');
  95. }
  96. $data['version'] = $m[0];
  97. $data['changelog'] = array_values(array_map('strval', (array) ($data['changelog'] ?? [])));
  98. $data['is_newer'] = version_compare($data['version'], (string) (pmUpdateCurrent()['version'] ?? '0'), '>');
  99. return $data;
  100. }
  101. ​
  102. function pmUpdateDownload(array $manifest): string
  103. {
  104. $dir = PM_DATA_DIR . '/cache';
  105. if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
  106. throw new PmUpdateException('Cannot create the cache directory.');
  107. }
  108. $path = $dir . '/update-' . bin2hex(random_bytes(4)) . '.zip';
  109. try {
  110. pmUpdateHttp(PM_UPDATE_URL . '?download=1', $path, 180);
  111. } catch (PmUpdateException $e) {
  112. @unlink($path);
  113. throw $e;
  114. }
  115. if (!hash_equals(strtolower((string) $manifest['sha256']), (string) hash_file('sha256', $path))) {
  116. @unlink($path);
  117. throw new PmUpdateException('Downloaded package failed the SHA-256 integrity check. Nothing was changed.');
  118. }
  119. return $path;
  120. }
  121. ​
  122. function pmUpdateOpenZip(string $path): array
  123. {
  124. if (!class_exists('ZipArchive')) {
  125. throw new PmUpdateException('The PHP zip extension is not enabled on this server.');
  126. }
  127. $zip = new ZipArchive();
  128. if ($zip->open($path) !== true) {
  129. throw new PmUpdateException('The update package could not be opened.');
  130. }
  131. ​
  132. $best = null;
  133. for ($i = 0; $i < $zip->numFiles; $i++) {
  134. $name = (string) $zip->getNameIndex($i);
  135. if (in_array(basename($name), ['version.php', 'version.txt'], true) && ($best === null || strlen($name) < strlen($best))) {
  136. $best = $name;
  137. }
  138. }
  139. $prefix = $best === null ? '' : substr($best, 0, -strlen(basename($best)));
  140. return [$zip, $prefix];
  141. }
  142. ​
  143. function pmUpdateIsProtected(string $rel): bool
  144. {
  145. $rel = strtolower($rel);
  146. $base = basename($rel);
  147. return str_starts_with($rel, 'data/')
  148. || str_starts_with($base, '.env')
  149. || str_starts_with($base, '.pm-env')
  150. || in_array($rel, PM_UPDATE_SKIP, true);
  151. }
  152. ​
  153. function pmUpdateIsSafePath(string $rel): bool
  154. {
  155. return $rel !== ''
  156. && !str_contains($rel, '..')
  157. && !str_contains($rel, '\\')
  158. && !str_contains($rel, "\0")
  159. && $rel[0] !== '/'
  160. && !preg_match('/^[A-Za-z]:/', $rel);
  161. }
  162. ​
  163. function pmUpdateIsText(string $rel): bool
  164. {
  165. return in_array(strtolower(pathinfo($rel, PATHINFO_EXTENSION)), PM_UPDATE_TEXT_EXT, true);
  166. }
  167. ​
  168. function pmUpdateNormalize(string $rel, string $content): string
  169. {
  170. return pmUpdateIsText($rel) ? str_replace("\r\n", "\n", $content) : $content;
  171. }
  172. ​
  173. function pmUpdateFunctions(string $code): array
  174. {
  175. $found = [];
  176. if (!preg_match_all('/^[ \t]*(?:(?:public|private|protected|static|final|abstract|async)\s+)*function\s+&?(\w+)\s*\(/m', $code, $m, PREG_OFFSET_CAPTURE)) {
  177. return $found;
  178. }
  179. $count = count($m[0]);
  180. for ($i = 0; $i < $count; $i++) {
  181. $start = $m[0][$i][1];
  182. $end = $i + 1 < $count ? $m[0][$i + 1][1] : strlen($code);
  183. $found[$m[1][$i][0]] = md5(preg_replace('/\s+/', ' ', substr($code, $start, $end - $start)));
  184. }
  185. return $found;
  186. }
  187. ​
  188. function pmUpdateDescribe(string $rel, string $old, string $new): array
  189. {
  190. $count = static function (string $text): array {
  191. $lines = array_filter(array_map('trim', explode("\n", $text)), static fn($l) => $l !== '');
  192. return array_count_values($lines);
  193. };
  194. $a = $count($old);
  195. $b = $count($new);
  196. $added = 0;
  197. $removed = 0;
  198. foreach ($b as $line => $n) {
  199. $added += max(0, $n - ($a[$line] ?? 0));
  200. }
  201. foreach ($a as $line => $n) {
  202. $removed += max(0, $n - ($b[$line] ?? 0));
  203. }
  204. ​
  205. $out = ['added_lines' => $added, 'removed_lines' => $removed, 'functions' => ['added' => [], 'changed' => [], 'removed' => []]];
  206. if (in_array(strtolower(pathinfo($rel, PATHINFO_EXTENSION)), ['php', 'js'], true)) {
  207. $fo = pmUpdateFunctions($old);
  208. $fn = pmUpdateFunctions($new);
  209. $out['functions']['added'] = array_values(array_diff(array_keys($fn), array_keys($fo)));
  210. $out['functions']['removed'] = array_values(array_diff(array_keys($fo), array_keys($fn)));
  211. foreach ($fn as $name => $hash) {
  212. if (isset($fo[$name]) && $fo[$name] !== $hash) {
  213. $out['functions']['changed'][] = $name;
  214. }
  215. }
  216. }
  217. return $out;
  218. }
  219. ​
  220. function pmUpdateLocalFiles(): array
  221. {
  222. $files = [];
  223. $root = str_replace('\\', '/', PM_ROOT) . '/';
  224. $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(PM_ROOT, FilesystemIterator::SKIP_DOTS));
  225. foreach ($it as $file) {
  226. if (!$file->isFile()) {
  227. continue;
  228. }
  229. $rel = substr(str_replace('\\', '/', $file->getPathname()), strlen($root));
  230. if (!pmUpdateIsProtected($rel)) {
  231. $files[] = $rel;
  232. }
  233. }
  234. return $files;
  235. }
  236. ​
  237. function pmUpdatePlan(ZipArchive $zip, string $prefix): array
  238. {
  239. $plan = ['added' => [], 'modified' => [], 'unchanged' => 0, 'not_in_release' => []];
  240. $inRelease = [];
  241. ​
  242. for ($i = 0; $i < $zip->numFiles; $i++) {
  243. $name = (string) $zip->getNameIndex($i);
  244. if (str_ends_with($name, '/') || ($prefix !== '' && !str_starts_with($name, $prefix))) {
  245. continue;
  246. }
  247. $rel = substr($name, strlen($prefix));
  248. if (!pmUpdateIsSafePath($rel) || pmUpdateIsProtected($rel)) {
  249. continue;
  250. }
  251. $inRelease[$rel] = true;
  252. $new = (string) $zip->getFromIndex($i);
  253. $local = PM_ROOT . '/' . $rel;
  254. ​
  255. if (!is_file($local)) {
  256. $plan['added'][] = ['path' => $rel, 'index' => $i, 'size' => strlen($new)];
  257. continue;
  258. }
  259. $old = (string) file_get_contents($local);
  260. if (pmUpdateNormalize($rel, $old) === pmUpdateNormalize($rel, $new)) {
  261. $plan['unchanged']++;
  262. continue;
  263. }
  264. $entry = ['path' => $rel, 'index' => $i, 'size' => strlen($new), 'old_size' => strlen($old)];
  265. if (pmUpdateIsText($rel)) {
  266. $entry += pmUpdateDescribe($rel, pmUpdateNormalize($rel, $old), pmUpdateNormalize($rel, $new));
  267. }
  268. $plan['modified'][] = $entry;
  269. }
  270. ​
  271. foreach (pmUpdateLocalFiles() as $rel) {
  272. if (!isset($inRelease[$rel])) {
  273. $plan['not_in_release'][] = $rel;
  274. }
  275. }
  276. return $plan;
  277. }
  278. ​
  279. function pmUpdateBackup(string $version): string
  280. {
  281. $dir = PM_DATA_DIR . '/backups';
  282. if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
  283. throw new PmUpdateException('Cannot create the backup directory (is data/ writable?).');
  284. }
  285. $path = $dir . '/backup-v' . preg_replace('/[^\w.\-]/', '_', $version) . '-' . date('Ymd-His') . '.zip';
  286. ​
  287. $zip = new ZipArchive();
  288. if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
  289. throw new PmUpdateException('Cannot create the backup archive.');
  290. }
  291. foreach (pmUpdateLocalFiles() as $rel) {
  292. $zip->addFile(PM_ROOT . '/' . $rel, $rel);
  293. }
  294. if (!$zip->close() || !is_file($path)) {
  295. throw new PmUpdateException('Writing the backup archive failed. Update aborted, nothing was changed.');
  296. }
  297. ​
  298. $older = [];
  299. foreach (glob($dir . '/backup-*.zip') ?: [] as $file) {
  300. if (realpath($file) !== realpath($path)) {
  301. $older[$file] = (int) @filemtime($file);
  302. }
  303. }
  304. arsort($older);
  305. $cutoff = time() - PM_UPDATE_BACKUP_MAX_AGE_DAYS * 86400;
  306. $kept = 1;
  307. foreach ($older as $file => $mtime) {
  308. if ($kept >= PM_UPDATE_KEEP_BACKUPS || $mtime < $cutoff) {
  309. @unlink($file);
  310. } else {
  311. $kept++;
  312. }
  313. }
  314. return $path;
  315. }
  316. ​
  317. function pmUpdateRestore(string $backup, array $addedPaths): void
  318. {
  319. $zip = new ZipArchive();
  320. if ($zip->open($backup) === true) {
  321. for ($i = 0; $i < $zip->numFiles; $i++) {
  322. $name = (string) $zip->getNameIndex($i);
  323. if (pmUpdateIsSafePath($name) && !pmUpdateIsProtected($name)) {
  324. @file_put_contents(PM_ROOT . '/' . $name, $zip->getFromIndex($i));
  325. }
  326. }
  327. $zip->close();
  328. }
  329. foreach ($addedPaths as $rel) {
  330. @unlink(PM_ROOT . '/' . $rel);
  331. }
  332. }
  333. ​
  334. function pmUpdateApply(ZipArchive $zip, array $plan, string $backup): void
  335. {
  336. try {
  337. foreach (array_merge($plan['added'], $plan['modified']) as $file) {
  338. $dest = PM_ROOT . '/' . $file['path'];
  339. $dir = dirname($dest);
  340. if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
  341. throw new PmUpdateException('Cannot create directory for ' . $file['path']);
  342. }
  343. $tmp = $dest . '.pmnew';
  344. if (file_put_contents($tmp, $zip->getFromIndex($file['index'])) === false || !@rename($tmp, $dest)) {
  345. @unlink($tmp);
  346. throw new PmUpdateException('Cannot write ' . $file['path']);
  347. }
  348. }
  349. } catch (PmUpdateException $e) {
  350. pmUpdateRestore($backup, array_column($plan['added'], 'path'));
  351. throw new PmUpdateException($e->getMessage() . ' The previous version was restored from the backup.');
  352. }
  353. if (function_exists('opcache_reset')) {
  354. @opcache_reset();
  355. }
  356. }
  357. ​
  358. function pmUpdateLock()
  359. {
  360. $dir = PM_DATA_DIR . '/cache';
  361. if (!is_dir($dir)) {
  362. @mkdir($dir, 0755, true);
  363. }
  364. $fp = fopen($dir . '/update.lock', 'c');
  365. if (!$fp || !flock($fp, LOCK_EX | LOCK_NB)) {
  366. throw new PmUpdateException('Another update is already running.');
  367. }
  368. return $fp;
  369. }
  370. ​
  371. function pmUpdatePreview(): array
  372. {
  373. @set_time_limit(300);
  374. $manifest = pmUpdateManifest();
  375. $zipPath = pmUpdateDownload($manifest);
  376. try {
  377. [$zip, $prefix] = pmUpdateOpenZip($zipPath);
  378. try {
  379. $plan = pmUpdatePlan($zip, $prefix);
  380. } finally {
  381. $zip->close();
  382. }
  383. } finally {
  384. @unlink($zipPath);
  385. }
  386. return ['manifest' => $manifest, 'plan' => $plan];
  387. }
  388. ​
  389. function pmUpdateRun(bool $automatic = false): array
  390. {
  391. @set_time_limit(300);
  392. $lock = pmUpdateLock();
  393. $zipPath = null;
  394. try {
  395. $manifest = pmUpdateManifest();
  396. if (!$manifest['is_newer']) {
  397. throw new PmUpdateException('You are already on the latest version.');
  398. }
  399. $from = (string) (pmUpdateCurrent()['version'] ?? '?');
  400. ​
  401. $zipPath = pmUpdateDownload($manifest);
  402. [$zip, $prefix] = pmUpdateOpenZip($zipPath);
  403. try {
  404. $plan = pmUpdatePlan($zip, $prefix);
  405. $backup = pmUpdateBackup($from);
  406. pmUpdateApply($zip, $plan, $backup);
  407. } finally {
  408. $zip->close();
  409. }
  410. ​
  411. $strip = static fn(array $list) => array_map(static function ($f) {
  412. unset($f['index']);
  413. return $f;
  414. }, $list);
  415. $report = [
  416. 'at' => gmdate('c'),
  417. 'automatic' => $automatic,
  418. 'from' => $from,
  419. 'to' => $manifest['version'],
  420. 'backup' => 'data/backups/' . basename($backup),
  421. 'changelog' => $manifest['changelog'],
  422. 'added' => $strip($plan['added']),
  423. 'modified' => $strip($plan['modified']),
  424. 'unchanged' => $plan['unchanged'],
  425. 'not_in_release' => $plan['not_in_release'],
  426. ];
  427. @file_put_contents(PM_DATA_DIR . '/last-update.json', json_encode($report, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
  428. return $report;
  429. } finally {
  430. if ($zipPath !== null) {
  431. @unlink($zipPath);
  432. }
  433. flock($lock, LOCK_UN);
  434. fclose($lock);
  435. }
  436. }
  437. ​
  438. function pmUpdateLastReport(): ?array
  439. {
  440. $file = PM_DATA_DIR . '/last-update.json';
  441. $data = is_file($file) ? json_decode((string) file_get_contents($file), true) : null;
  442. return is_array($data) ? $data : null;
  443. }
  444. ​
  445. function pmUpdateSize(int $bytes): string
  446. {
  447. if ($bytes >= 1048576) {
  448. return round($bytes / 1048576, 1) . ' MB';
  449. }
  450. return $bytes >= 1024 ? round($bytes / 1024, 1) . ' KB' : $bytes . ' B';
  451. }
  452. ​
  453. function pmUpdateSummarize(array $f): string
  454. {
  455. if (!isset($f['added_lines'])) {
  456. return isset($f['old_size']) ? 'binary file, ' . pmUpdateSize($f['old_size']) . ' → ' . pmUpdateSize($f['size']) : pmUpdateSize($f['size']);
  457. }
  458. $parts = ['+' . $f['added_lines'] . ' / −' . $f['removed_lines'] . ' lines'];
  459. foreach (['added' => 'new', 'changed' => 'changed', 'removed' => 'removed'] as $key => $label) {
  460. $names = $f['functions'][$key] ?? [];
  461. if ($names) {
  462. $parts[] = $label . ' functions: ' . implode(', ', array_slice($names, 0, 6)) . (count($names) > 6 ? ' +' . (count($names) - 6) . ' more' : '');
  463. }
  464. }
  465. return implode(' · ', $parts);
  466. }
  467. ​