Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
34.39% covered (danger)
34.39%
76 / 221
47.06% covered (danger)
47.06%
8 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Upgrade
34.39% covered (danger)
34.39%
76 / 221
47.06% covered (danger)
47.06%
8 / 17
1796.37
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 checkFilesystem
63.64% covered (warning)
63.64%
14 / 22
0.00% covered (danger)
0.00%
0 / 1
25.82
 downloadPackage
73.68% covered (warning)
73.68%
14 / 19
0.00% covered (danger)
0.00%
0 / 1
5.46
 verifyPackage
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 extractPackage
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
42
 secureExtractZip
87.50% covered (success)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
6.07
 isPathSafe
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
7
 createTemporaryBackup
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
90
 installPackage
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
110
 cleanUp
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 getDownloadHost
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getPath
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getFilename
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 setUpgradeDirectory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isNightly
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setIsNightly
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isMaintenanceEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * The Upgrade class used for upgrading/installing phpMyFAQ from a ZIP file.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2023-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2023-06-30
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Setup;
21
22use FilesystemIterator;
23use JsonException;
24use Monolog\Level;
25use phpMyFAQ\Configuration;
26use phpMyFAQ\Core\Exception;
27use phpMyFAQ\Enums\DownloadHostType;
28use phpMyFAQ\Enums\ReleaseType;
29use phpMyFAQ\System;
30use RecursiveDirectoryIterator;
31use RecursiveIteratorIterator;
32use Symfony\Component\HttpClient\HttpClient;
33use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
34use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
35use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
36use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
37use Symfony\Contracts\HttpClient\HttpClientInterface;
38use ZipArchive;
39
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 bool $isNightly;
51
52    private HttpClientInterface $httpClient;
53
54    public function __construct(
55        protected System $system,
56        private readonly Configuration $configuration,
57        ?HttpClientInterface $httpClient = null,
58    ) {
59        parent::__construct($this->system);
60
61        $this->isNightly =
62            $this->configuration->get(item: 'upgrade.releaseEnvironment') === ReleaseType::NIGHTLY->value;
63
64        $this->httpClient = $httpClient ?? HttpClient::create(['timeout' => 60]);
65    }
66
67    /**
68     * Method to check if the filesystem is ready for the upgrade
69     *
70     * @throws Exception
71     */
72    public function checkFilesystem(): bool
73    {
74        if (!is_dir($this->upgradeDirectory) && !mkdir($this->upgradeDirectory)) {
75            throw new Exception(message: 'The folder ' . $this->upgradeDirectory . ' is missing.');
76        }
77
78        if (!is_dir(PMF_CONTENT_DIR . '/user/attachments')) {
79            throw new Exception(message: 'The folder /content/user/attachments is missing.');
80        }
81
82        if (!is_dir(PMF_CONTENT_DIR . '/user/images')) {
83            throw new Exception(message: 'The folder /content/user/images is missing.');
84        }
85
86        if (!is_dir(PMF_CONTENT_DIR . '/core/data')) {
87            throw new Exception(message: 'The folder /content/core/data is missing.');
88        }
89
90        if (!is_dir((string) PMF_ROOT_DIR . '/assets/templates')) {
91            throw new Exception(message: 'The folder /phpmyfaq/assets/templates is missing.');
92        }
93
94        if (
95            !is_file(PMF_CONTENT_DIR . '/core/config/constants.php')
96            || !is_file(PMF_CONTENT_DIR . '/core/config/database.php')
97        ) {
98            throw new Exception(message: 'The files /content/core/config/constant.php and'
99            . ' /content/core/config/database.php are missing.');
100        }
101
102        if (
103            $this->configuration->isElasticsearchActive()
104            && !is_file(PMF_CONTENT_DIR . '/core/config/elasticsearch.php')
105        ) {
106            throw new Exception(message: 'The file /content/core/config/elasticsearch.php is missing.');
107        }
108
109        if ($this->configuration->isLdapActive() && !is_file(PMF_CONTENT_DIR . '/core/config/ldap.php')) {
110            throw new Exception(message: 'The file /content/core/config/ldap.php is missing.');
111        }
112
113        if (
114            $this->configuration->isSignInWithMicrosoftActive() && !is_file(PMF_CONTENT_DIR . '/core/config/azure.php')
115        ) {
116            throw new Exception(message: 'The file /content/core/config/azure.php is missing.');
117        }
118
119        return true;
120    }
121
122    /**
123     * Method to download a phpMyFAQ package, throws an exception if it doesn't work
124     *
125     * @throws Exception
126     * @todo handle possible proxy servers
127     */
128    public function downloadPackage(string $version): string
129    {
130        $url = $this->getDownloadHost() . $this->getPath() . $this->getFilename($version);
131
132        $attempts = 3;
133        $lastExceptionMessage = null;
134
135        for ($i = 0; $i < $attempts; $i++) {
136            try {
137                $response = $this->httpClient->request(method: 'GET', url: $url);
138
139                if ($response->getStatusCode() !== 200) {
140                    throw new Exception(
141                        message: 'Cannot download package (HTTP Status: ' . $response->getStatusCode() . ').',
142                    );
143                }
144
145                $package = $response->getContent();
146
147                $targetPath = $this->upgradeDirectory . DIRECTORY_SEPARATOR . $this->getFilename($version);
148                file_put_contents($targetPath, $package);
149
150                return $targetPath;
151            } catch (
152                TransportExceptionInterface|ClientExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface $exception
153            ) {
154                $lastExceptionMessage = $exception->getMessage();
155
156                // After the last attempt, throw the exception outward
157                if ($i === ($attempts - 1)) {
158                    throw new Exception('Download failed after ' . $attempts . ' attempts: ' . $lastExceptionMessage);
159                }
160
161                // Short sleep to mitigate transient network issues
162                usleep(microseconds: 250_000); // 250ms
163            }
164        }
165
166        // Should not be reached, but for safety
167        throw new Exception('Download failed: ' . ($lastExceptionMessage ?? 'unknown error'));
168    }
169
170    /**
171     * Method to verify the downloaded phpMyFAQ package
172     *
173     * @param string $path | Path to a zip file
174     * @param string $version | Version to verify
175     * @throws TransportExceptionInterface|ClientExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface|JsonException
176     */
177    public function verifyPackage(string $path, string $version): bool
178    {
179        $response = $this->httpClient->request(
180            method: 'GET',
181            url: DownloadHostType::PHPMYFAQ->value . 'info/' . $version,
182        );
183
184        try {
185            $responseContent = json_decode(
186                $response->getContent(),
187                associative: true,
188                depth: 512,
189                flags: JSON_THROW_ON_ERROR,
190            );
191
192            $expectedMd5 = is_array($responseContent) ? $responseContent['zip']['md5'] ?? null : null;
193
194            return is_string($expectedMd5) && md5_file($path) === $expectedMd5;
195        } catch (
196            TransportExceptionInterface|ClientExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface $e
197        ) {
198            $this->configuration->getLogger()->log(Level::Error, $e->getMessage());
199
200            return false;
201        }
202    }
203
204    /**
205     * Method to extract the downloaded phpMyFAQ package
206     *
207     * @param string   $path | Path of the package
208     * @throws Exception
209     */
210    public function extractPackage(string $path, callable $progressCallback): bool
211    {
212        $zipArchive = new ZipArchive();
213
214        if (!is_file($path)) {
215            throw new Exception(message: 'Given path to download package is not valid.');
216        }
217
218        // Defense in depth: the package must live inside the controlled upgrade
219        // directory (where downloadPackage() stores verified downloads). This
220        // prevents extraction of an arbitrary file path injected into the
221        // upgrade.lastDownloadedPackage configuration value.
222        $realPath = realpath($path);
223        $realUpgradeDirectory = realpath($this->upgradeDirectory);
224
225        if (
226            $realPath === false
227            || $realUpgradeDirectory === false
228            || !str_starts_with($realPath, $realUpgradeDirectory . DIRECTORY_SEPARATOR)
229        ) {
230            throw new Exception(message: 'Given path to download package is outside the upgrade directory.');
231        }
232
233        $zipFile = $zipArchive->open($realPath);
234
235        $zipArchive->registerProgressCallback(rate: 0.05, callback: static function (float $rate) use (
236            $progressCallback,
237        ): void {
238            $progress = (int) ($rate * 100) . '%';
239            $progressCallback($progress);
240        });
241
242        if ($zipFile) {
243            // Secure extraction to prevent Zip Slip vulnerability
244            $extractPath = $this->upgradeDirectory . '/new/';
245            $this->secureExtractZip($zipArchive, $extractPath);
246            return $zipArchive->close();
247        }
248
249        throw new Exception(message: 'Cannot open zipped download package.');
250    }
251
252    /**
253     * Securely extracts a ZIP archive, preventing Zip Slip attacks
254     *
255     * @param ZipArchive $zipArchive The ZIP archive to extract
256     * @param string $destination The destination directory
257     * @throws Exception If a malicious path is detected
258     */
259    private function secureExtractZip(ZipArchive $zipArchive, string $destination): void
260    {
261        // Normalize destination path
262        $resolvedDestination = realpath($destination);
263        $destination =
264            rtrim($resolvedDestination !== false ? $resolvedDestination : $destination, DIRECTORY_SEPARATOR)
265            . DIRECTORY_SEPARATOR;
266
267        // Create destination directory if it doesn't exist
268        if (!is_dir($destination)) {
269            mkdir(directory: $destination, permissions: 0o755, recursive: true);
270        }
271
272        // Iterate through all entries in the archive
273        for ($i = 0; $i < $zipArchive->numFiles; $i++) {
274            $entry = $zipArchive->getNameIndex($i);
275            if ($entry === false) {
276                continue;
277            }
278
279            // Validate the entry path to prevent directory traversal
280            if (!$this->isPathSafe($entry, $destination)) {
281                $this->configuration->getLogger()->error('Zip Slip attack detected in package', [
282                    'malicious_entry' => $entry,
283                ]);
284                throw new Exception(message: sprintf('Malicious path detected in archive: %s', $entry));
285            }
286
287            // Extract individual file
288            $zipArchive->extractTo($destination, $entry);
289        }
290    }
291
292    /**
293     * Validates that a ZIP entry path is safe and doesn't escape the destination directory
294     *
295     * @param string $entryPath The path from the ZIP entry
296     * @param string $destination The destination directory
297     * @return bool True if path is safe, false otherwise
298     */
299    private function isPathSafe(string $entryPath, string $destination): bool
300    {
301        // Remove any null bytes
302        $entryPath = str_replace(search: "\0", replace: '', subject: $entryPath);
303
304        // Build the full destination path
305        $fullPath = $destination . $entryPath;
306
307        // Resolve the real path (this resolves .. and . sequences)
308        $realPath = realpath(dirname($fullPath));
309        if ($realPath === false) {
310            // Path doesn't exist yet, construct it manually
311            $realPath = (string) realpath($destination) . DIRECTORY_SEPARATOR . dirname($entryPath);
312        }
313
314        // Normalize both paths for comparison
315        $resolvedNormalizedDestination = realpath($destination);
316        $normalizedDestination = rtrim(
317            $resolvedNormalizedDestination !== false ? $resolvedNormalizedDestination : $destination,
318            DIRECTORY_SEPARATOR,
319        );
320        $normalizedPath = rtrim($realPath, DIRECTORY_SEPARATOR);
321
322        // Check if the resolved path is within the destination directory
323        if (!str_starts_with($normalizedPath, $normalizedDestination)) {
324            return false;
325        }
326
327        // Additional check: reject paths with directory traversal sequences
328        if (preg_match('#(\.\./)|(\.\.)|(\./)|(^/)#', $entryPath)) {
329            return false;
330        }
331
332        // Check for absolute paths (Unix and Windows)
333        if (str_starts_with($entryPath, '/') || preg_match('#^[a-zA-Z]:#', $entryPath)) {
334            return false;
335        }
336
337        return true;
338    }
339
340    /**
341     * Method to create a temporary backup of the current files
342     *
343     * @param string   $backupName | Name of the created backup
344     * @throws Exception
345     */
346    public function createTemporaryBackup(string $backupName, callable $progressCallback): bool
347    {
348        $outputZipFile = $this->upgradeDirectory . DIRECTORY_SEPARATOR . $backupName;
349
350        if (file_exists($outputZipFile)) {
351            throw new Exception(message: 'Backup file already exists.');
352        }
353
354        $zipArchive = new ZipArchive();
355        if ($zipArchive->open($outputZipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
356            throw new Exception(message: 'Cannot create backup file.');
357        }
358
359        $sourceDir = (string) PMF_ROOT_DIR;
360        $files = new RecursiveIteratorIterator(
361            new RecursiveDirectoryIterator($sourceDir),
362            RecursiveIteratorIterator::SELF_FIRST,
363        );
364
365        $zipArchive->registerProgressCallback(rate: 0.05, callback: static function (float $rate) use (
366            $progressCallback,
367        ): void {
368            $progress = (int) ($rate * 100) . '%';
369            $progressCallback($progress);
370        });
371
372        foreach ($files as $file) {
373            if (!$file instanceof \SplFileInfo) {
374                continue;
375            }
376
377            $filePath = $file->getRealPath();
378            if ($filePath === false) {
379                continue;
380            }
381
382            if (str_contains($filePath, $this->upgradeDirectory . DIRECTORY_SEPARATOR)) {
383                continue;
384            }
385
386            if (is_dir($filePath)) {
387                $zipArchive->addEmptyDir(str_replace(
388                    $sourceDir . DIRECTORY_SEPARATOR,
389                    replace: '',
390                    subject: $filePath . DIRECTORY_SEPARATOR,
391                ));
392                continue;
393            }
394
395            if (!is_file($filePath)) {
396                continue;
397            }
398
399            $zipArchive->addFile($filePath, str_replace(
400                $sourceDir . DIRECTORY_SEPARATOR,
401                replace: '',
402                subject: $filePath,
403            ));
404        }
405
406        $zipArchive->close();
407
408        return file_exists($outputZipFile);
409    }
410
411    /**
412     * Method to install the package
413     */
414    public function installPackage(callable $progressCallback): bool
415    {
416        $sourceDir = $this->upgradeDirectory . '/new/phpmyfaq/';
417        $destinationDir = (string) PMF_ROOT_DIR;
418
419        $sourceDirIterator = new RecursiveIteratorIterator(
420            new RecursiveDirectoryIterator($sourceDir, FilesystemIterator::SKIP_DOTS),
421            RecursiveIteratorIterator::SELF_FIRST,
422        );
423
424        $totalFiles = iterator_count($sourceDirIterator);
425        $currentFile = 0;
426
427        foreach ($sourceDirIterator as $item) {
428            if (!$item instanceof \SplFileInfo) {
429                continue;
430            }
431
432            $source = $item->getRealPath();
433            if ($source === false) {
434                continue;
435            }
436
437            $relativePath = str_replace($sourceDir, replace: '', subject: $source);
438            $destination = $destinationDir . DIRECTORY_SEPARATOR . $relativePath;
439
440            if ($item->isDir()) {
441                if (!is_dir($destination)) {
442                    mkdir($destination, permissions: 0o755, recursive: true);
443                }
444                ++$currentFile;
445                if (($currentFile % 10) !== 0) {
446                    continue;
447                }
448
449                $progress = 100;
450                if ($totalFiles > 0) {
451                    $progress = (int) (($currentFile / $totalFiles) * 100) . '%';
452                }
453
454                $progressCallback($progress);
455                continue;
456            }
457
458            copy($source, $destination);
459
460            ++$currentFile;
461            if (($currentFile % 10) !== 0) {
462                continue;
463            }
464
465            $progress = 100;
466            if ($totalFiles > 0) {
467                $progress = (int) (($currentFile / $totalFiles) * 100) . '%';
468            }
469
470            $progressCallback($progress);
471        }
472
473        return true;
474    }
475
476    /**
477     * Method to clean up the upgrade directory
478     */
479    public function cleanUp(): bool
480    {
481        $directoryToDelete = $this->upgradeDirectory . '/new/phpmyfaq/';
482
483        $files = new RecursiveIteratorIterator(
484            new RecursiveDirectoryIterator($directoryToDelete, FilesystemIterator::SKIP_DOTS),
485            RecursiveIteratorIterator::CHILD_FIRST,
486        );
487
488        foreach ($files as $file) {
489            if (!$file instanceof \SplFileInfo) {
490                continue;
491            }
492
493            $filePath = $file->getRealPath();
494            if ($filePath === false) {
495                continue;
496            }
497
498            if ($file->isDir()) {
499                rmdir($filePath);
500                continue;
501            }
502
503            unlink($filePath);
504        }
505
506        return rmdir($directoryToDelete);
507    }
508
509    /**
510     * Returns the host for download packages, so either github.com or download.phpmyfaq.de
511     */
512    public function getDownloadHost(): string
513    {
514        if ($this->isNightly()) {
515            return DownloadHostType::GITHUB->value;
516        }
517
518        return DownloadHostType::PHPMYFAQ->value;
519    }
520
521    /**
522     * Returns the path to the download package, it's an empty string for development and production releases
523     */
524    public function getPath(): string
525    {
526        if ($this->isNightly()) {
527            return sprintf(self::GITHUB_PATH, date(format: 'Y-m-d'));
528        }
529
530        return '';
531    }
532
533    /**
534     * Returns the filename of the download package
535     */
536    public function getFilename(string $version): string
537    {
538        if ($this->isNightly()) {
539            return sprintf(self::GITHUB_FILENAME, date(format: 'Y-m-d'));
540        }
541
542        return sprintf(self::PHPMYFAQ_FILENAME, $version);
543    }
544
545    public function setUpgradeDirectory(string $upgradeDirectory): void
546    {
547        $this->upgradeDirectory = $upgradeDirectory;
548    }
549
550    public function isNightly(): bool
551    {
552        return $this->isNightly;
553    }
554
555    public function setIsNightly(bool $isNightly): void
556    {
557        $this->isNightly = $isNightly;
558    }
559
560    public function isMaintenanceEnabled(): bool
561    {
562        return true === $this->configuration->get(item: 'main.maintenanceMode');
563    }
564}