xref: /webtrees/app/Http/RequestHandlers/UpgradeWizardStep.php (revision d556820b1a7ded827b1d7cc803988e0ebd0451f7)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2022 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\Http\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\FilesystemOperator;
36use Psr\Http\Message\ResponseInterface;
37use Psr\Http\Message\ServerRequestInterface;
38use Psr\Http\Server\RequestHandlerInterface;
39use Throwable;
40
41use function assert;
42use function date;
43use function e;
44use function fclose;
45use function intdiv;
46use function microtime;
47use function response;
48use function route;
49use function version_compare;
50use function view;
51
52/**
53 * Upgrade to a new version of webtrees.
54 */
55class UpgradeWizardStep implements RequestHandlerInterface
56{
57    // We make the upgrade in a number of small steps to keep within server time limits.
58    private const STEP_CHECK    = 'Check';
59    private const STEP_PREPARE  = 'Prepare';
60    private const STEP_PENDING  = 'Pending';
61    private const STEP_EXPORT   = 'Export';
62    private const STEP_DOWNLOAD = 'Download';
63    private const STEP_UNZIP    = 'Unzip';
64    private const STEP_COPY     = 'Copy';
65
66    // Where to store our temporary files.
67    private const UPGRADE_FOLDER = 'data/tmp/upgrade/';
68
69    // Where to store the downloaded ZIP archive.
70    private const ZIP_FILENAME = 'data/tmp/webtrees.zip';
71
72    // The ZIP archive stores everything inside this top-level folder.
73    private const ZIP_FILE_PREFIX = 'webtrees';
74
75    // Cruft can accumulate after upgrades.
76    private const FOLDERS_TO_CLEAN = [
77        'app',
78        'resources',
79        'vendor',
80    ];
81
82    private GedcomExportService $gedcom_export_service;
83
84    private UpgradeService $upgrade_service;
85
86    private TreeService $tree_service;
87
88    /**
89     * UpgradeController constructor.
90     *
91     * @param GedcomExportService $gedcom_export_service
92     * @param TreeService         $tree_service
93     * @param UpgradeService      $upgrade_service
94     */
95    public function __construct(
96        GedcomExportService $gedcom_export_service,
97        TreeService $tree_service,
98        UpgradeService $upgrade_service
99    ) {
100        $this->gedcom_export_service = $gedcom_export_service;
101        $this->tree_service          = $tree_service;
102        $this->upgrade_service       = $upgrade_service;
103    }
104
105    /**
106     * Perform one step of the wizard
107     *
108     * @param ServerRequestInterface $request
109     *
110     * @return ResponseInterface
111     */
112    public function handle(ServerRequestInterface $request): ResponseInterface
113    {
114        $root_filesystem = Registry::filesystem()->root();
115        $data_filesystem = Registry::filesystem()->data();
116
117        // Somewhere to unpack a .ZIP file
118        $temporary_filesystem = new Filesystem(new ChrootAdapter($root_filesystem, self::UPGRADE_FOLDER));
119
120        $zip_file   = Webtrees::ROOT_DIR . self::ZIP_FILENAME;
121        $zip_folder = Webtrees::ROOT_DIR . self::UPGRADE_FOLDER;
122
123
124        $step = $request->getQueryParams()['step'] ?? self::STEP_CHECK;
125
126        switch ($step) {
127            case self::STEP_CHECK:
128                return $this->wizardStepCheck();
129
130            case self::STEP_PREPARE:
131                return $this->wizardStepPrepare($root_filesystem);
132
133            case self::STEP_PENDING:
134                return $this->wizardStepPending();
135
136            case self::STEP_EXPORT:
137                $tree_name = $request->getQueryParams()['tree'] ?? '';
138                $tree      = $this->tree_service->all()[$tree_name];
139                assert($tree instanceof Tree);
140
141                return $this->wizardStepExport($tree, $data_filesystem);
142
143            case self::STEP_DOWNLOAD:
144                return $this->wizardStepDownload($root_filesystem);
145
146            case self::STEP_UNZIP:
147                return $this->wizardStepUnzip($zip_file, $zip_folder);
148
149            case self::STEP_COPY:
150                return $this->wizardStepCopyAndCleanUp($zip_file, $root_filesystem, $temporary_filesystem);
151
152            default:
153                return response('', StatusCodeInterface::STATUS_NO_CONTENT);
154        }
155    }
156
157    /**
158     * @return ResponseInterface
159     */
160    private function wizardStepCheck(): ResponseInterface
161    {
162        $latest_version = $this->upgrade_service->latestVersion();
163
164        if ($latest_version === '') {
165            throw new HttpServerErrorException(I18N::translate('No upgrade information is available.'));
166        }
167
168        if (version_compare(Webtrees::VERSION, $latest_version) >= 0) {
169            $message = I18N::translate('This is the latest version of webtrees. No upgrade is available.');
170            throw new HttpServerErrorException($message);
171        }
172
173        /* I18N: %s is a version number, such as 1.2.3 */
174        $alert = I18N::translate('Upgrade to webtrees %s.', e($latest_version));
175
176        return response(view('components/alert-success', [
177            'alert' => $alert,
178        ]));
179    }
180
181    /**
182     * Make sure the temporary folder exists.
183     *
184     * @param FilesystemOperator $root_filesystem
185     *
186     * @return ResponseInterface
187     */
188    private function wizardStepPrepare(FilesystemOperator $root_filesystem): ResponseInterface
189    {
190        $root_filesystem->deleteDirectory(self::UPGRADE_FOLDER);
191        $root_filesystem->createDirectory(self::UPGRADE_FOLDER);
192
193        return response(view('components/alert-success', [
194            'alert' => I18N::translate('The folder %s has been created.', e(self::UPGRADE_FOLDER)),
195        ]));
196    }
197
198    /**
199     * @return ResponseInterface
200     */
201    private function wizardStepPending(): ResponseInterface
202    {
203        $changes = DB::table('change')->where('status', '=', 'pending')->exists();
204
205        if ($changes) {
206            return response(view('components/alert-danger', [
207                'alert' => I18N::translate('You should accept or reject all pending changes before upgrading.'),
208            ]), StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR);
209        }
210
211        return response(view('components/alert-success', [
212            'alert' => I18N::translate('There are no pending changes.'),
213        ]));
214    }
215
216    /**
217     * @param Tree               $tree
218     * @param FilesystemOperator $data_filesystem
219     *
220     * @return ResponseInterface
221     */
222    private function wizardStepExport(Tree $tree, FilesystemOperator $data_filesystem): ResponseInterface
223    {
224        $filename = $tree->name() . date('-Y-m-d') . '.ged';
225
226        $stream = $this->gedcom_export_service->export($tree);
227
228        $data_filesystem->writeStream($tree->name() . date('-Y-m-d') . '.ged', $stream);
229        fclose($stream);
230
231        return response(view('components/alert-success', [
232            'alert' => I18N::translate('The family tree has been exported to %s.', e($filename)),
233        ]));
234    }
235
236    /**
237     * @param FilesystemOperator $root_filesystem
238     *
239     * @return ResponseInterface
240     */
241    private function wizardStepDownload(FilesystemOperator $root_filesystem): ResponseInterface
242    {
243        $start_time   = microtime(true);
244        $download_url = $this->upgrade_service->downloadUrl();
245
246        try {
247            $bytes = $this->upgrade_service->downloadFile($download_url, $root_filesystem, self::ZIP_FILENAME);
248        } catch (Throwable $exception) {
249            throw new HttpServerErrorException($exception->getMessage());
250        }
251
252        $kb       = I18N::number(intdiv($bytes + 1023, 1024));
253        $end_time = microtime(true);
254        $seconds  = I18N::number($end_time - $start_time, 2);
255
256        return response(view('components/alert-success', [
257            'alert' => I18N::translate('%1$s KB were downloaded in %2$s seconds.', $kb, $seconds),
258        ]));
259    }
260
261    /**
262     * For performance reasons, we use direct filesystem access for this step.
263     *
264     * @param string $zip_file
265     * @param string $zip_folder
266     *
267     * @return ResponseInterface
268     */
269    private function wizardStepUnzip(string $zip_file, string $zip_folder): ResponseInterface
270    {
271        $start_time = microtime(true);
272        $this->upgrade_service->extractWebtreesZip($zip_file, $zip_folder);
273        $count    = $this->upgrade_service->webtreesZipContents($zip_file)->count();
274        $end_time = microtime(true);
275        $seconds  = I18N::number($end_time - $start_time, 2);
276
277        /* I18N: …from the .ZIP file, %2$s is a (fractional) number of seconds */
278        $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);
279
280        return response(view('components/alert-success', [
281            'alert' => $alert,
282        ]));
283    }
284
285    /**
286     * @param string             $zip_file
287     * @param FilesystemOperator $root_filesystem
288     * @param FilesystemOperator $temporary_filesystem
289     *
290     * @return ResponseInterface
291     */
292    private function wizardStepCopyAndCleanUp(
293        string $zip_file,
294        FilesystemOperator $root_filesystem,
295        FilesystemOperator $temporary_filesystem
296    ): ResponseInterface {
297        $source_filesystem = new Filesystem(new ChrootAdapter($temporary_filesystem, self::ZIP_FILE_PREFIX));
298
299        $this->upgrade_service->startMaintenanceMode();
300        $this->upgrade_service->moveFiles($source_filesystem, $root_filesystem);
301        $this->upgrade_service->endMaintenanceMode();
302
303        // While we have time, clean up any old files.
304        $files_to_keep    = $this->upgrade_service->webtreesZipContents($zip_file);
305        $folders_to_clean = new Collection(self::FOLDERS_TO_CLEAN);
306
307        $this->upgrade_service->cleanFiles($root_filesystem, $folders_to_clean, $files_to_keep);
308
309        $url    = route(ControlPanel::class);
310        $alert  = I18N::translate('The upgrade is complete.');
311        $button = '<a href="' . e($url) . '" class="btn btn-primary">' . I18N::translate('continue') . '</a>';
312
313        return response(view('components/alert-success', [
314            'alert' => $alert . ' ' . $button,
315        ]));
316    }
317}
318