v1.0.3
PriviMetrics 2
- <?php
-
- declare(strict_types=1);
-
- const PM_UPDATE_URL = 'https://weborbiton.eu/updater/privimetrics/';
- const PM_UPDATE_KEEP_BACKUPS = 5;
- const PM_UPDATE_BACKUP_MAX_AGE_DAYS = 30;
- const PM_UPDATE_TEXT_EXT = ['php', 'js', 'css', 'txt', 'svg', 'md', 'htaccess', 'json', 'html'];
- const PM_UPDATE_SKIP = ['changelog.txt', 'favicon.ico'];
-
- class PmUpdateException extends RuntimeException
- {
- }
-
- function pmUpdateCurrent(): string
- {
- return trim((string) @file_get_contents(PM_ROOT . '/version.txt')) ?: '0.0.0';
- }
-
- function pmUpdateHttp(string $url, ?string $saveTo = null, int $timeout = 20): string
- {
- if (function_exists('curl_init')) {
- $ch = curl_init($url);
- $fp = $saveTo !== null ? @fopen($saveTo, 'wb') : null;
- if ($fp === false) {
- curl_close($ch);
- throw new PmUpdateException('Cannot write the downloaded package to data/cache.');
- }
- curl_setopt_array($ch, [
- CURLOPT_RETURNTRANSFER => $saveTo === null,
- CURLOPT_FOLLOWLOCATION => true,
- CURLOPT_MAXREDIRS => 3,
- CURLOPT_TIMEOUT => $timeout,
- CURLOPT_CONNECTTIMEOUT => 10,
- CURLOPT_SSL_VERIFYPEER => true,
- CURLOPT_SSL_VERIFYHOST => 2,
- CURLOPT_USERAGENT => 'PriviMetrics-Updater/' . pmUpdateCurrent(),
- ]);
- if ($fp) {
- curl_setopt($ch, CURLOPT_FILE, $fp);
- }
- $body = curl_exec($ch);
- $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
- $err = curl_error($ch);
- curl_close($ch);
- if ($fp) {
- fclose($fp);
- }
- if ($body === false) {
- throw new PmUpdateException('Connection to the update server failed: ' . $err);
- }
- if ($status !== 200) {
- throw new PmUpdateException('Update server answered with HTTP ' . $status . '.');
- }
- return $saveTo !== null ? '' : (string) $body;
- }
-
- $ctx = stream_context_create(['http' => ['timeout' => $timeout, 'user_agent' => 'PriviMetrics-Updater', 'ignore_errors' => true]]);
- $body = @file_get_contents($url, false, $ctx);
- $status = 0;
- foreach ($http_response_header ?? [] as $h) {
- if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) {
- $status = (int) $m[1];
- }
- }
- if ($body === false || $status !== 200) {
- throw new PmUpdateException('Could not reach the update server' . ($status ? ' (HTTP ' . $status . ')' : '') . '.');
- }
- if ($saveTo !== null) {
- if (file_put_contents($saveTo, $body) === false) {
- throw new PmUpdateException('Cannot write the downloaded package to data/cache.');
- }
- return '';
- }
- return $body;
- }
-
- function pmUpdateManifest(): array
- {
- $edited = (int) @filemtime(PM_ROOT . '/version.txt');
- $data = json_decode(pmUpdateHttp(PM_UPDATE_URL . '?v=' . $edited), true);
- if (!is_array($data)) {
- throw new PmUpdateException('Update server returned an invalid response.');
- }
- if (isset($data['error'])) {
- throw new PmUpdateException('Update server: ' . (string) $data['error']);
- }
- if (empty($data['version']) || !preg_match('/^[a-f0-9]{64}$/i', (string) ($data['sha256'] ?? ''))) {
- throw new PmUpdateException('Update manifest is incomplete.');
- }
- $data['version_raw'] = (string) $data['version'];
- if (!preg_match('/\d+(?:\.\d+)+(?:-[\w.]+)?/', $data['version_raw'], $m)) {
- throw new PmUpdateException('Could not read a version number from "' . $data['version_raw'] . '".');
- }
- $data['version'] = $m[0];
- $data['changelog'] = array_values(array_map('strval', (array) ($data['changelog'] ?? [])));
- $data['is_newer'] = version_compare($data['version'], pmUpdateCurrent(), '>');
- return $data;
- }
-
- function pmUpdateDownload(array $manifest): string
- {
- $dir = PM_DATA_DIR . '/cache';
- if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
- throw new PmUpdateException('Cannot create the cache directory.');
- }
- $path = $dir . '/update-' . bin2hex(random_bytes(4)) . '.zip';
- try {
- pmUpdateHttp(PM_UPDATE_URL . '?download=1', $path, 180);
- } catch (PmUpdateException $e) {
- @unlink($path);
- throw $e;
- }
- if (!hash_equals(strtolower((string) $manifest['sha256']), (string) hash_file('sha256', $path))) {
- @unlink($path);
- throw new PmUpdateException('Downloaded package failed the SHA-256 integrity check. Nothing was changed.');
- }
- return $path;
- }
-
- function pmUpdateOpenZip(string $path): array
- {
- if (!class_exists('ZipArchive')) {
- throw new PmUpdateException('The PHP zip extension is not enabled on this server.');
- }
- $zip = new ZipArchive();
- if ($zip->open($path) !== true) {
- throw new PmUpdateException('The update package could not be opened.');
- }
-
- $best = null;
- for ($i = 0; $i < $zip->numFiles; $i++) {
- $name = (string) $zip->getNameIndex($i);
- if (in_array(basename($name), ['version.txt'], true) && ($best === null || strlen($name) < strlen($best))) {
- $best = $name;
- }
- }
- $prefix = $best === null ? '' : substr($best, 0, -strlen(basename($best)));
- return [$zip, $prefix];
- }
-
- function pmUpdateIsProtected(string $rel): bool
- {
- $rel = strtolower($rel);
- $base = basename($rel);
- return str_starts_with($rel, 'data/')
- || str_starts_with($base, '.env')
- || str_starts_with($base, '.pm-env')
- || in_array($rel, PM_UPDATE_SKIP, true);
- }
-
- function pmUpdateIsSafePath(string $rel): bool
- {
- return $rel !== ''
- && !str_contains($rel, '..')
- && !str_contains($rel, '\\')
- && !str_contains($rel, "\0")
- && $rel[0] !== '/'
- && !preg_match('/^[A-Za-z]:/', $rel);
- }
-
- function pmUpdateIsText(string $rel): bool
- {
- return in_array(strtolower(pathinfo($rel, PATHINFO_EXTENSION)), PM_UPDATE_TEXT_EXT, true);
- }
-
- function pmUpdateNormalize(string $rel, string $content): string
- {
- return pmUpdateIsText($rel) ? str_replace("\r\n", "\n", $content) : $content;
- }
-
- function pmUpdateFunctions(string $code): array
- {
- $found = [];
- if (!preg_match_all('/^[ \t]*(?:(?:public|private|protected|static|final|abstract|async)\s+)*function\s+&?(\w+)\s*\(/m', $code, $m, PREG_OFFSET_CAPTURE)) {
- return $found;
- }
- $count = count($m[0]);
- for ($i = 0; $i < $count; $i++) {
- $start = $m[0][$i][1];
- $end = $i + 1 < $count ? $m[0][$i + 1][1] : strlen($code);
- $found[$m[1][$i][0]] = md5(preg_replace('/\s+/', ' ', substr($code, $start, $end - $start)));
- }
- return $found;
- }
-
- function pmUpdateDescribe(string $rel, string $old, string $new): array
- {
- $count = static function (string $text): array {
- $lines = array_filter(array_map('trim', explode("\n", $text)), static fn($l) => $l !== '');
- return array_count_values($lines);
- };
- $a = $count($old);
- $b = $count($new);
- $added = 0;
- $removed = 0;
- foreach ($b as $line => $n) {
- $added += max(0, $n - ($a[$line] ?? 0));
- }
- foreach ($a as $line => $n) {
- $removed += max(0, $n - ($b[$line] ?? 0));
- }
-
- $out = ['added_lines' => $added, 'removed_lines' => $removed, 'functions' => ['added' => [], 'changed' => [], 'removed' => []]];
- if (in_array(strtolower(pathinfo($rel, PATHINFO_EXTENSION)), ['php', 'js'], true)) {
- $fo = pmUpdateFunctions($old);
- $fn = pmUpdateFunctions($new);
- $out['functions']['added'] = array_values(array_diff(array_keys($fn), array_keys($fo)));
- $out['functions']['removed'] = array_values(array_diff(array_keys($fo), array_keys($fn)));
- foreach ($fn as $name => $hash) {
- if (isset($fo[$name]) && $fo[$name] !== $hash) {
- $out['functions']['changed'][] = $name;
- }
- }
- }
- return $out;
- }
-
- function pmUpdateLocalFiles(): array
- {
- $files = [];
- $root = str_replace('\\', '/', PM_ROOT) . '/';
- $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(PM_ROOT, FilesystemIterator::SKIP_DOTS));
- foreach ($it as $file) {
- if (!$file->isFile()) {
- continue;
- }
- $rel = substr(str_replace('\\', '/', $file->getPathname()), strlen($root));
- if (!pmUpdateIsProtected($rel)) {
- $files[] = $rel;
- }
- }
- return $files;
- }
-
- function pmUpdatePlan(ZipArchive $zip, string $prefix): array
- {
- $plan = ['added' => [], 'modified' => [], 'unchanged' => 0, 'not_in_release' => []];
- $inRelease = [];
-
- for ($i = 0; $i < $zip->numFiles; $i++) {
- $name = (string) $zip->getNameIndex($i);
- if (str_ends_with($name, '/') || ($prefix !== '' && !str_starts_with($name, $prefix))) {
- continue;
- }
- $rel = substr($name, strlen($prefix));
- if (!pmUpdateIsSafePath($rel) || pmUpdateIsProtected($rel)) {
- continue;
- }
- $inRelease[$rel] = true;
- $new = (string) $zip->getFromIndex($i);
- $local = PM_ROOT . '/' . $rel;
-
- if (!is_file($local)) {
- $plan['added'][] = ['path' => $rel, 'index' => $i, 'size' => strlen($new)];
- continue;
- }
- $old = (string) file_get_contents($local);
- if (pmUpdateNormalize($rel, $old) === pmUpdateNormalize($rel, $new)) {
- $plan['unchanged']++;
- continue;
- }
- $entry = ['path' => $rel, 'index' => $i, 'size' => strlen($new), 'old_size' => strlen($old)];
- if (pmUpdateIsText($rel)) {
- $entry += pmUpdateDescribe($rel, pmUpdateNormalize($rel, $old), pmUpdateNormalize($rel, $new));
- }
- $plan['modified'][] = $entry;
- }
-
- foreach (pmUpdateLocalFiles() as $rel) {
- if (!isset($inRelease[$rel])) {
- $plan['not_in_release'][] = $rel;
- }
- }
- return $plan;
- }
-
- function pmUpdateBackup(string $version): string
- {
- $dir = PM_DATA_DIR . '/backups';
- if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
- throw new PmUpdateException('Cannot create the backup directory (is data/ writable?).');
- }
- $path = $dir . '/backup-v' . preg_replace('/[^\w.\-]/', '_', $version) . '-' . date('Ymd-His') . '.zip';
-
- $zip = new ZipArchive();
- if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
- throw new PmUpdateException('Cannot create the backup archive.');
- }
- foreach (pmUpdateLocalFiles() as $rel) {
- $zip->addFile(PM_ROOT . '/' . $rel, $rel);
- }
- if (!$zip->close() || !is_file($path)) {
- throw new PmUpdateException('Writing the backup archive failed. Update aborted, nothing was changed.');
- }
-
- $older = [];
- foreach (glob($dir . '/backup-*.zip') ?: [] as $file) {
- if (realpath($file) !== realpath($path)) {
- $older[$file] = (int) @filemtime($file);
- }
- }
- arsort($older);
- $cutoff = time() - PM_UPDATE_BACKUP_MAX_AGE_DAYS * 86400;
- $kept = 1;
- foreach ($older as $file => $mtime) {
- if ($kept >= PM_UPDATE_KEEP_BACKUPS || $mtime < $cutoff) {
- @unlink($file);
- } else {
- $kept++;
- }
- }
- return $path;
- }
-
- function pmUpdateRestore(string $backup, array $addedPaths): void
- {
- $zip = new ZipArchive();
- if ($zip->open($backup) === true) {
- for ($i = 0; $i < $zip->numFiles; $i++) {
- $name = (string) $zip->getNameIndex($i);
- if (pmUpdateIsSafePath($name) && !pmUpdateIsProtected($name)) {
- @file_put_contents(PM_ROOT . '/' . $name, $zip->getFromIndex($i));
- }
- }
- $zip->close();
- }
- foreach ($addedPaths as $rel) {
- @unlink(PM_ROOT . '/' . $rel);
- }
- }
-
- function pmUpdateApply(ZipArchive $zip, array $plan, string $backup): void
- {
- try {
- foreach (array_merge($plan['added'], $plan['modified']) as $file) {
- $dest = PM_ROOT . '/' . $file['path'];
- $dir = dirname($dest);
- if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
- throw new PmUpdateException('Cannot create directory for ' . $file['path']);
- }
- $tmp = $dest . '.pmnew';
- if (file_put_contents($tmp, $zip->getFromIndex($file['index'])) === false || !@rename($tmp, $dest)) {
- @unlink($tmp);
- throw new PmUpdateException('Cannot write ' . $file['path']);
- }
- }
- } catch (PmUpdateException $e) {
- pmUpdateRestore($backup, array_column($plan['added'], 'path'));
- throw new PmUpdateException($e->getMessage() . ' The previous version was restored from the backup.');
- }
- if (function_exists('opcache_reset')) {
- @opcache_reset();
- }
- }
-
- function pmUpdateLock()
- {
- $dir = PM_DATA_DIR . '/cache';
- if (!is_dir($dir)) {
- @mkdir($dir, 0755, true);
- }
- $fp = fopen($dir . '/update.lock', 'c');
- if (!$fp || !flock($fp, LOCK_EX | LOCK_NB)) {
- throw new PmUpdateException('Another update is already running.');
- }
- return $fp;
- }
-
- function pmUpdatePreview(): array
- {
- @set_time_limit(300);
- $manifest = pmUpdateManifest();
- $zipPath = pmUpdateDownload($manifest);
- try {
- [$zip, $prefix] = pmUpdateOpenZip($zipPath);
- try {
- $plan = pmUpdatePlan($zip, $prefix);
- } finally {
- $zip->close();
- }
- } finally {
- @unlink($zipPath);
- }
- return ['manifest' => $manifest, 'plan' => $plan];
- }
-
- function pmUpdateRun(bool $automatic = false): array
- {
- @set_time_limit(300);
- $lock = pmUpdateLock();
- $zipPath = null;
- try {
- $manifest = pmUpdateManifest();
- if (!$manifest['is_newer']) {
- throw new PmUpdateException('You are already on the latest version.');
- }
- $from = pmUpdateCurrent();
-
- $zipPath = pmUpdateDownload($manifest);
- [$zip, $prefix] = pmUpdateOpenZip($zipPath);
- try {
- $plan = pmUpdatePlan($zip, $prefix);
- $backup = pmUpdateBackup($from);
- pmUpdateApply($zip, $plan, $backup);
- } finally {
- $zip->close();
- }
-
- $strip = static fn(array $list) => array_map(static function ($f) {
- unset($f['index']);
- return $f;
- }, $list);
- $report = [
- 'at' => gmdate('c'),
- 'automatic' => $automatic,
- 'from' => $from,
- 'to' => $manifest['version'],
- 'backup' => 'data/backups/' . basename($backup),
- 'changelog' => $manifest['changelog'],
- 'added' => $strip($plan['added']),
- 'modified' => $strip($plan['modified']),
- 'unchanged' => $plan['unchanged'],
- 'not_in_release' => $plan['not_in_release'],
- ];
- @file_put_contents(PM_DATA_DIR . '/last-update.json', json_encode($report, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
- return $report;
- } finally {
- if ($zipPath !== null) {
- @unlink($zipPath);
- }
- flock($lock, LOCK_UN);
- fclose($lock);
- }
- }
-
- function pmUpdateLastReport(): ?array
- {
- $file = PM_DATA_DIR . '/last-update.json';
- $data = is_file($file) ? json_decode((string) file_get_contents($file), true) : null;
- return is_array($data) ? $data : null;
- }
-
- function pmUpdateSize(int $bytes): string
- {
- if ($bytes >= 1048576) {
- return round($bytes / 1048576, 1) . ' MB';
- }
- return $bytes >= 1024 ? round($bytes / 1024, 1) . ' KB' : $bytes . ' B';
- }
-
- function pmUpdateSummarize(array $f): string
- {
- if (!isset($f['added_lines'])) {
- return isset($f['old_size']) ? 'binary file, ' . pmUpdateSize($f['old_size']) . ' → ' . pmUpdateSize($f['size']) : pmUpdateSize($f['size']);
- }
- $parts = ['+' . $f['added_lines'] . ' / −' . $f['removed_lines'] . ' lines'];
- foreach (['added' => 'new', 'changed' => 'changed', 'removed' => 'removed'] as $key => $label) {
- $names = $f['functions'][$key] ?? [];
- if ($names) {
- $parts[] = $label . ' functions: ' . implode(', ', array_slice($names, 0, 6)) . (count($names) > 6 ? ' +' . (count($names) - 6) . ' more' : '');
- }
- }
- return implode(' · ', $parts);
- }
-