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