xref: /webtrees/app/Http/RequestHandlers/UpgradeWizardStep.php (revision 6bf0af166b27d50bc0dc213975b1116b197adb10)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2021 webtrees development team
6 * This program is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 */
17
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees\Http\RequestHandlers;
21
22use Fig\Http\Message\StatusCodeInterface;
23use Fisharebest\Flysystem\Adapter\ChrootAdapter;
24use Fisharebest\Webtrees\Exceptions\HttpServerErrorException;
25use Fisharebest\Webtrees\I18N;
26use Fisharebest\Webtrees\Registry;
27use Fisharebest\Webtrees\Services\GedcomExportService;
28use Fisharebest\Webtrees\Services\TreeService;
29use Fisharebest\Webtrees\Services\UpgradeService;
30use Fisharebest\Webtrees\Tree;
31use Fisharebest\Webtrees\Webtrees;
32use Illuminate\Database\Capsule\Manager as DB;
33use Illuminate\Support\Collection;
34use League\Flysystem\Filesystem;
35use League\Flysystem\FilesystemInterface;
36use Psr\Http\Message\ResponseInterface;
37use Psr\Http\Message\ServerRequestInterface;
38use Psr\Http\Server\RequestHandlerInterface;
39use RuntimeException;
40use Throwable;
41
42use function assert;
43use function date;
44use function e;
45use function fclose;
46use function fopen;
47use function fseek;
48use function intdiv;
49use function microtime;
50use function response;
51use function route;
52use function version_compare;
53use function view;
54
55/**
56 * Upgrade to a new version of webtrees.
57 */
58class UpgradeWizardStep implements RequestHandlerInterface
59{
60    // We make the upgrade in a number of small steps to keep within server time limits.
61    private const STEP_CHECK    = 'Check';
62    private const STEP_PREPARE  = 'Prepare';
63    private const STEP_PENDING  = 'Pending';
64    private const STEP_EXPORT   = 'Export';
65    private const STEP_DOWNLOAD = 'Download';
66    private const STEP_UNZIP    = 'Unzip';
67    private const STEP_COPY     = 'Copy';
68
69    // Where to store our temporary files.
70    private const UPGRADE_FOLDER = 'data/tmp/upgrade/';
71
72    // Where to store the downloaded ZIP archive.
73    private const ZIP_FILENAME = 'data/tmp/webtrees.zip';
74
75    // The ZIP archive stores everything inside this top-level folder.
76    private const ZIP_FILE_PREFIX = 'webtrees';
77
78    // Cruft can accumulate after upgrades.
79    private const FOLDERS_TO_CLEAN = [
80        'app',
81        'resources',
82        'vendor',
83    ];
84
85    /** @var GedcomExportService */
86    private $gedcom_export_service;
87
88    /** @var UpgradeService */
89    private $upgrade_service;
90
91    /** @var TreeService */
92    private $tree_service;
93
94    /**
95     * UpgradeController constructor.
96     *
97     * @param GedcomExportService $gedcom_export_service
98     * @param TreeService         $tree_service
99     * @param UpgradeService      $upgrade_service
100     */
101    public function __construct(
102        GedcomExportService $gedcom_export_service,
103        TreeService $tree_service,
104        UpgradeService $upgrade_service
105    ) {
106        $this->gedcom_export_service = $gedcom_export_service;
107        $this->tree_service          = $tree_service;
108        $this->upgrade_service       = $upgrade_service;
109    }
110
111    /**
112     * Perform one step of the wizard
113     *
114     * @param ServerRequestInterface $request
115     *
116     * @return ResponseInterface
117     */
118    public function handle(ServerRequestInterface $request): ResponseInterface
119    {
120        $root_filesystem = Registry::filesystem()->root();
121        $data_filesystem = Registry::filesystem()->data();
122
123        // Somewhere to unpack a .ZIP file
124        $temporary_filesystem = new Filesystem(new ChrootAdapter($root_filesystem, self::UPGRADE_FOLDER));
125
126        $zip_file   = Webtrees::ROOT_DIR . self::ZIP_FILENAME;
127        $zip_folder = Webtrees::ROOT_DIR . self::UPGRADE_FOLDER;
128
129
130        $step = $request->getQueryParams()['step'] ?? self::STEP_CHECK;
131
132        switch ($step) {
133            case self::STEP_CHECK:
134                return $this->wizardStepCheck();
135
136            case self::STEP_PREPARE:
137                return $this->wizardStepPrepare($root_filesystem);
138
139            case self::STEP_PENDING:
140                return $this->wizardStepPending();
141
142            case self::STEP_EXPORT:
143                $tree_name = $request->getQueryParams()['tree'] ?? '';
144                $tree      = $this->tree_service->all()[$tree_name];
145                assert($tree instanceof Tree);
146
147                return $this->wizardStepExport($tree, $data_filesystem);
148
149            case self::STEP_DOWNLOAD:
150                return $this->wizardStepDownload($root_filesystem);
151
152            case self::STEP_UNZIP:
153                return $this->wizardStepUnzip($zip_file, $zip_folder);
154
155            case self::STEP_COPY:
156                return $this->wizardStepCopyAndCleanUp($zip_file, $root_filesystem, $temporary_filesystem);
157
158            default:
159                return response('', StatusCodeInterface::STATUS_NO_CONTENT);
160        }
161    }
162
163    /**
164     * @return ResponseInterface
165     */
166    private function wizardStepCheck(): ResponseInterface
167    {
168        $latest_version = $this->upgrade_service->latestVersion();
169
170        if ($latest_version === '') {
171            throw new HttpServerErrorException(I18N::translate('No upgrade information is available.'));
172        }
173
174        if (version_compare(Webtrees::VERSION, $latest_version) >= 0) {
175            $message = I18N::translate('This is the latest version of webtrees. No upgrade is available.');
176            throw new HttpServerErrorException($message);
177        }
178
179        /* I18N: %s is a version number, such as 1.2.3 */
180        $alert = I18N::translate('Upgrade to webtrees %s.', e($latest_version));
181
182        return response(view('components/alert-success', [
183            'alert' => $alert,
184        ]));
185    }
186
187    /**
188     * Make sure the temporary folder exists.
189     *
190     * @param FilesystemInterface $root_filesystem
191     *
192     * @return ResponseInterface
193     */
194    private function wizardStepPrepare(FilesystemInterface $root_filesystem): ResponseInterface
195    {
196        $root_filesystem->deleteDir(self::UPGRADE_FOLDER);
197        $root_filesystem->createDir(self::UPGRADE_FOLDER);
198
199        return response(view('components/alert-success', [
200            'alert' => I18N::translate('The folder %s has been created.', e(self::UPGRADE_FOLDER)),
201        ]));
202    }
203
204    /**
205     * @return ResponseInterface
206     */
207    private function wizardStepPending(): ResponseInterface
208    {
209        $changes = DB::table('change')->where('status', '=', 'pending')->exists();
210
211        if ($changes) {
212            throw new HttpServerErrorException(I18N::translate('You should accept or reject all pending changes before upgrading.'));
213        }
214
215        return response(view('components/alert-success', [
216            'alert' => I18N::translate('There are no pending changes.'),
217        ]));
218    }
219
220    /**
221     * @param Tree                $tree
222     * @param FilesystemInterface $data_filesystem
223     *
224     * @return ResponseInterface
225     */
226    private function wizardStepExport(Tree $tree, FilesystemInterface $data_filesystem): ResponseInterface
227    {
228        // We store the data in PHP temporary storage.
229        $stream = fopen('php://temp', 'wb+');
230
231        if ($stream === false) {
232            throw new RuntimeException('Failed to create temporary stream');
233        }
234
235        $filename = $tree->name() . date('-Y-m-d') . '.ged';
236
237        $this->gedcom_export_service->export($tree, $stream);
238
239        fseek($stream, 0);
240        $data_filesystem->putStream($tree->name() . date('-Y-m-d') . '.ged', $stream);
241        fclose($stream);
242
243        return response(view('components/alert-success', [
244            'alert' => I18N::translate('The family tree has been exported to %s.', e($filename)),
245        ]));
246    }
247
248    /**
249     * @param FilesystemInterface $root_filesystem
250     *
251     * @return ResponseInterface
252     */
253    private function wizardStepDownload(FilesystemInterface $root_filesystem): ResponseInterface
254    {
255        $start_time   = microtime(true);
256        $download_url = $this->upgrade_service->downloadUrl();
257
258        try {
259            $bytes = $this->upgrade_service->downloadFile($download_url, $root_filesystem, self::ZIP_FILENAME);
260        } catch (Throwable $exception) {
261            throw new HttpServerErrorException($exception->getMessage());
262        }
263
264        $kb       = I18N::number(intdiv($bytes + 1023, 1024));
265        $end_time = microtime(true);
266        $seconds  = I18N::number($end_time - $start_time, 2);
267
268        return response(view('components/alert-success', [
269            'alert' => I18N::translate('%1$s KB were downloaded in %2$s seconds.', $kb, $seconds),
270        ]));
271    }
272
273    /**
274     * For performance reasons, we use direct filesystem access for this step.
275     *
276     * @param string $zip_file
277     * @param string $zip_folder
278     *
279     * @return ResponseInterface
280     */
281    private function wizardStepUnzip(string $zip_file, string $zip_folder): ResponseInterface
282    {
283        $start_time = microtime(true);
284        $this->upgrade_service->extractWebtreesZip($zip_file, $zip_folder);
285        $count    = $this->upgrade_service->webtreesZipContents($zip_file)->count();
286        $end_time = microtime(true);
287        $seconds  = I18N::number($end_time - $start_time, 2);
288
289        /* I18N: …from the .ZIP file, %2$s is a (fractional) number of seconds */
290        $alert = I18N::plural('%1$s file was extracted in %2$s seconds.', '%1$s files were extracted in %2$s seconds.', $count, I18N::number($count), $seconds);
291
292        return response(view('components/alert-success', [
293            'alert' => $alert,
294        ]));
295    }
296
297    /**
298     * @param string              $zip_file
299     * @param FilesystemInterface $root_filesystem
300     * @param FilesystemInterface $temporary_filesystem
301     *
302     * @return ResponseInterface
303     */
304    private function wizardStepCopyAndCleanUp(
305        string $zip_file,
306        FilesystemInterface $root_filesystem,
307        FilesystemInterface $temporary_filesystem
308    ): ResponseInterface {
309        $source_filesystem = new Filesystem(new ChrootAdapter($temporary_filesystem, self::ZIP_FILE_PREFIX));
310
311        $this->upgrade_service->startMaintenanceMode();
312        $this->upgrade_service->moveFiles($source_filesystem, $root_filesystem);
313        $this->upgrade_service->endMaintenanceMode();
314
315        // While we have time, clean up any old files.
316        $files_to_keep    = $this->upgrade_service->webtreesZipContents($zip_file);
317        $folders_to_clean = new Collection(self::FOLDERS_TO_CLEAN);
318
319        $this->upgrade_service->cleanFiles($root_filesystem, $folders_to_clean, $files_to_keep);
320
321        $url    = route(ControlPanel::class);
322        $alert  = I18N::translate('The upgrade is complete.');
323        $button = '<a href="' . e($url) . '" class="btn btn-primary">' . I18N::translate('continue') . '</a>';
324
325        return response(view('components/alert-success', [
326            'alert' => $alert . ' ' . $button,
327        ]));
328    }
329}
330