Lines 53.99% 142 / 263
Methods 52.17% 12 / 23
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 5 / 5 100.00% 1 / 1 1
 checkFilesystem 74.19% 23 / 31 0.00% 0 / 1 20.40
 downloadPackage 73.68% 14 / 19 0.00% 0 / 1 5.46
 verifyPackage 0.00% 0 / 15 0.00% 0 / 1 20
 extractPackage 0.00% 0 / 21 0.00% 0 / 1 42
 secureExtractZip 87.50% 14 / 16 0.00% 0 / 1 6.07
 isPathSafe 100.00% 18 / 18 100.00% 1 / 1 7
 createTemporaryBackup 0.00% 0 / 41 0.00% 0 / 1 90
 installPackage 83.33% 35 / 42 0.00% 0 / 1 13.78
 resetOpcache 80.00% 4 / 5 0.00% 0 / 1 2.03
 cleanUp 0.00% 0 / 16 0.00% 0 / 1 30
 getDownloadHost 100.00% 3 / 3 100.00% 1 / 1 2
 getPath 100.00% 3 / 3 100.00% 1 / 1 2
 getFilename 66.66% 2 / 3 0.00% 0 / 1 2.15
 setUpgradeDirectory 100.00% 1 / 1 100.00% 1 / 1 1
 setInstallationDirectory 100.00% 1 / 1 100.00% 1 / 1 1
 isNightly 100.00% 1 / 1 100.00% 1 / 1 1
 setIsNightly 100.00% 1 / 1 100.00% 1 / 1 1
 isMaintenanceEnabled 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Setup\AbstractSetup] checkMinimumPhpVersion 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Setup\AbstractSetup] checkMinimumUpdateVersion 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Setup\AbstractSetup] checkMaintenanceMode 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Setup\AbstractSetup] checkPreUpgrade 76.47% 13 / 17 0.00% 0 / 1 8.83
40class Upgrade extends AbstractSetup
41{
42    final public const string GITHUB_PATH = 'thorsten/phpMyFAQ/releases/download/development-nightly-%s/';
43
44    private const string GITHUB_FILENAME = 'phpMyFAQ-nightly-%s.zip';
45
46    private const string PHPMYFAQ_FILENAME = 'phpMyFAQ-%s.zip';
47
48    public string $upgradeDirectory = PMF_CONTENT_DIR . '/upgrades';
49
50    private string $installationDirectory;
51
52    private bool $isNightly;
53
54    private HttpClientInterface $httpClient;
55
56    public function __construct(
57        protected System $system,
58        private readonly Configuration $configuration,
59        ?HttpClientInterface $httpClient = null,
60    ) {
61        parent::__construct($this->system);
62
63        $this->installationDirectory = (string) PMF_ROOT_DIR;
64
65        $this->isNightly =
66            $this->configuration->get(item: 'upgrade.releaseEnvironment') === ReleaseType::NIGHTLY->value;
67
68        $this->httpClient = $httpClient ?? HttpClient::create(['timeout' => 60]);
69    }
70
71    /**
72     * Method to check if the filesystem is ready for the upgrade
73     *
74     * @throws Exception
75     */
76    public function checkFilesystem(): bool
77    {
78        if (!is_dir($this->upgradeDirectory) && !mkdir($this->upgradeDirectory)) {
79            throw new Exception(message: 'The folder ' . $this->upgradeDirectory . ' is missing.');
80        }
81
82        if (!is_dir(PMF_CONTENT_DIR . '/user/attachments')) {
83            throw new Exception(message: 'The folder /content/user/attachments is missing.');
84        }
85
86        if (!is_dir(PMF_CONTENT_DIR . '/user/images')) {
87            throw new Exception(message: 'The folder /content/user/images is missing.');
88        }
89
90        if (!is_dir(PMF_CONTENT_DIR . '/core/data')) {
91            throw new Exception(message: 'The folder /content/core/data is missing.');
92        }
93
94        if (!is_dir((string) PMF_ROOT_DIR . '/assets/templates')) {
95            throw new Exception(message: 'The folder /phpmyfaq/assets/templates is missing.');
96        }
97
98        if (
99            !is_file(PMF_CONTENT_DIR . '/core/config/constants.php')
100            || !is_file(PMF_CONTENT_DIR . '/core/config/database.php')
101        ) {
102            throw new Exception(message: 'The files /content/core/config/constant.php and'
103            . ' /content/core/config/database.php are missing.');
104        }
105
106        if (
107            $this->configuration->isElasticsearchActive()
108            && !is_file(PMF_CONTENT_DIR . '/core/config/elasticsearch.php')
109        ) {
110            throw new Exception(message: 'The file /content/core/config/elasticsearch.php is missing.');
111        }
112
113        if ($this->configuration->isLdapActive() && !is_file(PMF_CONTENT_DIR . '/core/config/ldap.php')) {
114            throw new Exception(message: 'The file /content/core/config/ldap.php is missing.');
115        }
116
117        if (
118            $this->configuration->isSignInWithMicrosoftActive() && !is_file(PMF_CONTENT_DIR . '/core/config/azure.php')
119        ) {
120            throw new Exception(message: 'The file /content/core/config/azure.php is missing.');
121        }
122
123        // The install step overwrites the whole installation tree, so every
124        // existing file and directory must be writable for the web server
125        // user. A non-writable path would otherwise leave a partially updated
126        // installation behind. The upgrade directory is skipped because it is
127        // managed by the updater itself.
128        $nonWritablePaths = WritablePathScanner::getNonWritablePaths(
129            $this->installationDirectory,
130            $this->upgradeDirectory,
131        );
132
133        if ($nonWritablePaths !== []) {
134            throw new Exception(
135                message: 'The following files or directories are not writable for the web server: '
136                    . WritablePathScanner::formatPathList($nonWritablePaths),
137            );
138        }
139
140        return true;
141    }
142
143    /**
144     * Method to download a phpMyFAQ package, throws an exception if it doesn't work
145     *
146     * @throws Exception
147     * @todo handle possible proxy servers
148     */
149    public function downloadPackage(string $version): string
150    {
151        $url = $this->getDownloadHost() . $this->getPath() . $this->getFilename($version);
152
153        $attempts = 3;
154        $lastExceptionMessage = null;
155
156        for ($i = 0; $i < $attempts; $i++) {
157            try {
158                $response = $this->httpClient->request(method: 'GET', url: $url);
159
160                if ($response->getStatusCode() !== 200) {
161                    throw new Exception(
162                        message: 'Cannot download package (HTTP Status: ' . $response->getStatusCode() . ').',
163                    );
164                }
165
166                $package = $response->getContent();
167
168                $targetPath = $this->upgradeDirectory . DIRECTORY_SEPARATOR . $this->getFilename($version);
169                file_put_contents($targetPath, $package);
170
171                return $targetPath;
172            } catch (
173                TransportExceptionInterface|ClientExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface $exception
174            ) {
175                $lastExceptionMessage = $exception->getMessage();
176
177                // After the last attempt, throw the exception outward
178                if ($i === ($attempts - 1)) {
179                    throw new Exception('Download failed after ' . $attempts . ' attempts: ' . $lastExceptionMessage);
180                }
181
182                // Short sleep to mitigate transient network issues
183                usleep(microseconds: 250_000); // 250ms
184            }
185        }
186
187        // Should not be reached, but for safety
188        throw new Exception('Download failed: ' . ($lastExceptionMessage ?? 'unknown error'));
189    }
190
191    /**
192     * Method to verify the downloaded phpMyFAQ package
193     *
194     * @param string $path | Path to a zip file
195     * @param string $version | Version to verify
196     * @throws TransportExceptionInterface|ClientExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface|JsonException
197     */
198    public function verifyPackage(string $path, string $version): bool
199    {
200        $response = $this->httpClient->request(
201            method: 'GET',
202            url: DownloadHostType::PHPMYFAQ->value . 'info/' . $version,
203        );
204
205        try {
206            $responseContent = json_decode(
207                $response->getContent(),
208                associative: true,
209                depth: 512,
210                flags: JSON_THROW_ON_ERROR,
211            );
212
213            $expectedMd5 = is_array($responseContent) ? $responseContent['zip']['md5'] ?? null : null;
214
215            return is_string($expectedMd5) && md5_file($path) === $expectedMd5;
216        } catch (
217            TransportExceptionInterface|ClientExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface $e
218        ) {
219            $this->configuration->getLogger()->log(Level::Error, $e->getMessage());
220
221            return false;
222        }
223    }
224
225    /**
226     * Method to extract the downloaded phpMyFAQ package
227     *
228     * @param string   $path | Path of the package
229     * @throws Exception
230     */
231    public function extractPackage(string $path, callable $progressCallback): bool
232    {
233        $zipArchive = new ZipArchive();
234
235        if (!is_file($path)) {
236            throw new Exception(message: 'Given path to download package is not valid.');
237        }
238
239        // Defense in depth: the package must live inside the controlled upgrade
240        // directory (where downloadPackage() stores verified downloads). This
241        // prevents extraction of an arbitrary file path injected into the
242        // upgrade.lastDownloadedPackage configuration value.
243        $realPath = realpath($path);
244        $realUpgradeDirectory = realpath($this->upgradeDirectory);
245
246        if (
247            $realPath === false
248            || $realUpgradeDirectory === false
249            || !str_starts_with($realPath, $realUpgradeDirectory . DIRECTORY_SEPARATOR)
250        ) {
251            throw new Exception(message: 'Given path to download package is outside the upgrade directory.');
252        }
253
254        $zipFile = $zipArchive->open($realPath);
255
256        $zipArchive->registerProgressCallback(rate: 0.05, callback: static function (float $rate) use (
257            $progressCallback,
258        ): void {
259            $progress = (int) ($rate * 100) . '%';
260            $progressCallback($progress);
261        });
262
263        if ($zipFile) {
264            // Secure extraction to prevent Zip Slip vulnerability
265            $extractPath = $this->upgradeDirectory . '/new/';
266            $this->secureExtractZip($zipArchive, $extractPath);
267            return $zipArchive->close();
268        }
269
270        throw new Exception(message: 'Cannot open zipped download package.');
271    }
272
273    /**
274     * Securely extracts a ZIP archive, preventing Zip Slip attacks
275     *
276     * @param ZipArchive $zipArchive The ZIP archive to extract
277     * @param string $destination The destination directory
278     * @throws Exception If a malicious path is detected
279     */
280    private function secureExtractZip(ZipArchive $zipArchive, string $destination): void
281    {
282        // Normalize destination path
283        $resolvedDestination = realpath($destination);
284        $destination =
285            rtrim($resolvedDestination !== false ? $resolvedDestination : $destination, DIRECTORY_SEPARATOR)
286            . DIRECTORY_SEPARATOR;
287
288        // Create destination directory if it doesn't exist
289        if (!is_dir($destination)) {
290            mkdir(directory: $destination, permissions: 0o755, recursive: true);
291        }
292
293        // Iterate through all entries in the archive
294        for ($i = 0; $i < $zipArchive->numFiles; $i++) {
295            $entry = $zipArchive->getNameIndex($i);
296            if ($entry === false) {
297                continue;
298            }
299
300            // Validate the entry path to prevent directory traversal
301            if (!$this->isPathSafe($entry, $destination)) {
302                $this->configuration->getLogger()->error('Zip Slip attack detected in package', [
303                    'malicious_entry' => $entry,
304                ]);
305                throw new Exception(message: sprintf('Malicious path detected in archive: %s', $entry));
306            }
307
308            // Extract individual file
309            $zipArchive->extractTo($destination, $entry);
310        }
311    }
312
313    /**
314     * Validates that a ZIP entry path is safe and doesn't escape the destination directory
315     *
316     * @param string $entryPath The path from the ZIP entry
317     * @param string $destination The destination directory
318     * @return bool True if path is safe, false otherwise
319     */
320    private function isPathSafe(string $entryPath, string $destination): bool
321    {
322        // Remove any null bytes
323        $entryPath = str_replace(search: "\0", replace: '', subject: $entryPath);
324
325        // Build the full destination path
326        $fullPath = $destination . $entryPath;
327
328        // Resolve the real path (this resolves .. and . sequences)
329        $realPath = realpath(dirname($fullPath));
330        if ($realPath === false) {
331            // Path doesn't exist yet, construct it manually
332            $realPath = (string) realpath($destination) . DIRECTORY_SEPARATOR . dirname($entryPath);
333        }
334
335        // Normalize both paths for comparison
336        $resolvedNormalizedDestination = realpath($destination);
337        $normalizedDestination = rtrim(
338            $resolvedNormalizedDestination !== false ? $resolvedNormalizedDestination : $destination,
339            DIRECTORY_SEPARATOR,
340        );
341        $normalizedPath = rtrim($realPath, DIRECTORY_SEPARATOR);
342
343        // Check if the resolved path is within the destination directory
344        if (!str_starts_with($normalizedPath, $normalizedDestination)) {
345            return false;
346        }
347
348        // Additional check: reject paths with directory traversal sequences
349        if (preg_match('#(\.\./)|(\.\.)|(\./)|(^/)#', $entryPath)) {
350            return false;
351        }
352
353        // Check for absolute paths (Unix and Windows)
354        if (str_starts_with($entryPath, '/') || preg_match('#^[a-zA-Z]:#', $entryPath)) {
355            return false;
356        }
357
358        return true;
359    }
360
361    /**
362     * Method to create a temporary backup of the current files
363     *
364     * @param string   $backupName | Name of the created backup
365     * @throws Exception
366     */
367    public function createTemporaryBackup(string $backupName, callable $progressCallback): bool
368    {
369        $outputZipFile = $this->upgradeDirectory . DIRECTORY_SEPARATOR . $backupName;
370
371        if (file_exists($outputZipFile)) {
372            throw new Exception(message: 'Backup file already exists.');
373        }
374
375        $zipArchive = new ZipArchive();
376        if ($zipArchive->open($outputZipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
377            throw new Exception(message: 'Cannot create backup file.');
378        }
379
380        $sourceDir = (string) PMF_ROOT_DIR;
381        $files = new RecursiveIteratorIterator(
382            new RecursiveDirectoryIterator($sourceDir),
383            RecursiveIteratorIterator::SELF_FIRST,
384        );
385
386        $zipArchive->registerProgressCallback(rate: 0.05, callback: static function (float $rate) use (
387            $progressCallback,
388        ): void {
389            $progress = (int) ($rate * 100) . '%';
390            $progressCallback($progress);
391        });
392
393        foreach ($files as $file) {
394            if (!$file instanceof \SplFileInfo) {
395                continue;
396            }
397
398            $filePath = $file->getRealPath();
399            if ($filePath === false) {
400                continue;
401            }
402
403            if (str_contains($filePath, $this->upgradeDirectory . DIRECTORY_SEPARATOR)) {
404                continue;
405            }
406
407            if (is_dir($filePath)) {
408                $zipArchive->addEmptyDir(str_replace(
409                    $sourceDir . DIRECTORY_SEPARATOR,
410                    replace: '',
411                    subject: $filePath . DIRECTORY_SEPARATOR,
412                ));
413                continue;
414            }
415
416            if (!is_file($filePath)) {
417                continue;
418            }
419
420            $zipArchive->addFile($filePath, str_replace(
421                $sourceDir . DIRECTORY_SEPARATOR,
422                replace: '',
423                subject: $filePath,
424            ));
425        }
426
427        $zipArchive->close();
428
429        return file_exists($outputZipFile);
430    }
431
432    /**
433     * Method to install the package. Throws an exception if any file cannot
434     * be copied, so a partially updated installation is never reported as a
435     * success.
436     *
437     * @throws Exception
438     */
439    public function installPackage(callable $progressCallback): bool
440    {
441        // realpath() is required because getRealPath() below returns resolved
442        // paths: with a symlinked installation the configured prefix would
443        // never match and every file would be copied to a wrong destination.
444        $sourceDir = realpath($this->upgradeDirectory . '/new/phpmyfaq');
445
446        if ($sourceDir === false) {
447            throw new Exception(message: 'The extracted package is missing, please run the extract step again.');
448        }
449
450        $destinationDir = $this->installationDirectory;
451
452        $sourceDirIterator = new RecursiveIteratorIterator(
453            new RecursiveDirectoryIterator($sourceDir, FilesystemIterator::SKIP_DOTS),
454            RecursiveIteratorIterator::SELF_FIRST,
455        );
456
457        $totalFiles = iterator_count($sourceDirIterator);
458        $currentFile = 0;
459        $failedPaths = [];
460
461        // Failures are collected and reported via the exception below; the
462        // error handler keeps PHP warnings from failed copies out of the
463        // streamed JSON progress response.
464        set_error_handler(static fn(): bool => true);
465
466        try {
467            foreach ($sourceDirIterator as $item) {
468                if (!$item instanceof \SplFileInfo) {
469                    continue;
470                }
471
472                $source = $item->getRealPath();
473                if ($source === false) {
474                    continue;
475                }
476
477                $relativePath = substr($source, strlen($sourceDir) + 1);
478                $destination = $destinationDir . DIRECTORY_SEPARATOR . $relativePath;
479
480                if ($item->isDir()) {
481                    if (!is_dir($destination) && !mkdir($destination, permissions: 0o755, recursive: true)) {
482                        $failedPaths[] = $relativePath;
483                    }
484                }
485
486                if (!$item->isDir() && !copy($source, $destination)) {
487                    $failedPaths[] = $relativePath;
488                }
489
490                ++$currentFile;
491                if (($currentFile % 10) !== 0) {
492                    continue;
493                }
494
495                $progress = 100;
496                if ($totalFiles > 0) {
497                    $progress = (int) (($currentFile / $totalFiles) * 100) . '%';
498                }
499
500                $progressCallback($progress);
501            }
502        } finally {
503            restore_error_handler();
504        }
505
506        if ($failedPaths !== []) {
507            throw new Exception(message: sprintf(
508                'Could not copy %d path(s) into the installation directory: %s.'
509                . ' Please check the file permissions and run the update again.',
510                count($failedPaths),
511                WritablePathScanner::formatPathList($failedPaths),
512            ));
513        }
514
515        $this->resetOpcache();
516
517        return true;
518    }
519
520    /**
521     * Drops all cached bytecode after the files have been replaced. With
522     * opcache.validate_timestamps=0 (or an exhausted cache) PHP would
523     * otherwise keep executing classes of the previous version and fail with
524     * undefined-method errors against the freshly installed code.
525     */
526    private function resetOpcache(): void
527    {
528        if (!function_exists('opcache_reset')) {
529            return;
530        }
531
532        // opcache.restrict_api can make the reset fail with a warning; a
533        // failed reset must not abort an otherwise successful installation.
534        set_error_handler(static fn(): bool => true);
535
536        try {
537            opcache_reset();
538        } finally {
539            restore_error_handler();
540        }
541    }
542
543    /**
544     * Method to clean up the upgrade directory
545     */
546    public function cleanUp(): bool
547    {
548        $directoryToDelete = $this->upgradeDirectory . '/new/phpmyfaq/';
549
550        $files = new RecursiveIteratorIterator(
551            new RecursiveDirectoryIterator($directoryToDelete, FilesystemIterator::SKIP_DOTS),
552            RecursiveIteratorIterator::CHILD_FIRST,
553        );
554
555        foreach ($files as $file) {
556            if (!$file instanceof \SplFileInfo) {
557                continue;
558            }
559
560            $filePath = $file->getRealPath();
561            if ($filePath === false) {
562                continue;
563            }
564
565            if ($file->isDir()) {
566                rmdir($filePath);
567                continue;
568            }
569
570            unlink($filePath);
571        }
572
573        return rmdir($directoryToDelete);
574    }
575
576    /**
577     * Returns the host for download packages, so either github.com or download.phpmyfaq.de
578     */
579    public function getDownloadHost(): string
580    {
581        if ($this->isNightly()) {
582            return DownloadHostType::GITHUB->value;
583        }
584
585        return DownloadHostType::PHPMYFAQ->value;
586    }
587
588    /**
589     * Returns the path to the download package, it's an empty string for development and production releases
590     */
591    public function getPath(): string
592    {
593        if ($this->isNightly()) {
594            return sprintf(self::GITHUB_PATH, date(format: 'Y-m-d'));
595        }
596
597        return '';
598    }
599
600    /**
601     * Returns the filename of the download package
602     */
603    public function getFilename(string $version): string
604    {
605        if ($this->isNightly()) {
606            return sprintf(self::GITHUB_FILENAME, date(format: 'Y-m-d'));
607        }
608
609        return sprintf(self::PHPMYFAQ_FILENAME, $version);
610    }
611
612    public function setUpgradeDirectory(string $upgradeDirectory): void
613    {
614        $this->upgradeDirectory = $upgradeDirectory;
615    }
616
617    public function setInstallationDirectory(string $installationDirectory): void
618    {
619        $this->installationDirectory = $installationDirectory;
620    }
621
622    public function isNightly(): bool
623    {
624        return $this->isNightly;
625    }
626
627    public function setIsNightly(bool $isNightly): void
628    {
629        $this->isNightly = $isNightly;
630    }
631
632    public function isMaintenanceEnabled(): bool
633    {
634        return true === $this->configuration->get(item: 'main.maintenanceMode');
635    }
636}

Inherited from phpMyFAQ\Setup\AbstractSetup

37    public function checkMinimumPhpVersion(): bool
38    {
39        return version_compare(version1: PHP_VERSION, version2: System::VERSION_MINIMUM_PHP) > 0;
40    }
45    public function checkMinimumUpdateVersion(string $version): bool
46    {
47        return version_compare(version1: $version, version2: '3.1.0', operator: '>');
48    }
53    public function checkMaintenanceMode(): bool
54    {
55        return (bool) Configuration::getConfigurationInstance()->get(item: 'main.maintenanceMode');
56    }
62    public function checkPreUpgrade(string $databaseType): void
63    {
64        $database = null;
65        if (!$this->checkMinimumPhpVersion()) {
66            throw new Exception(sprintf('Sorry, but you need PHP %s or later!', System::VERSION_MINIMUM_PHP));
67        }
68
69        if (
70            !is_readable((string) PMF_ROOT_DIR . '/content/core/config/database.php')
71            && !is_readable((string) PMF_ROOT_DIR . '/config/database.php')
72        ) {
73            throw new Exception(
74                'Sorry, but the database configuration file is not readable. Please check the permissions.',
75            );
76        }
77
78        if ('' !== $databaseType) {
79            $databaseFound = false;
80            foreach (array_keys($this->system->getSupportedDatabases()) as $database) {
81                if ($database !== $databaseType) {
82                    continue;
83                }
84
85                $databaseFound = true;
86                break;
87            }
88
89            if (!$databaseFound) {
90                throw new Exception(sprintf('Sorry, but the database %s is not supported!', ucfirst($databaseType)));
91            }
92        }
93    }