WebOrbiton
v1.0.2

PriviMetrics 2

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