xref: /webtrees/app/Http/RequestHandlers/UpgradeWizardStep.php (revision e93a8df2f8d797005750082cc3766c0e80799688)
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     * @param GedcomExportService $gedcom_export_service
87     * @param TreeService         $tree_service
88     * @param UpgradeService      $upgrade_service
89     */
90    public function __construct(
91        GedcomExportService $gedcom_export_service,
92        TreeService $tree_service,
93        UpgradeService $upgrade_service
94    ) {
95        $this->gedcom_export_service = $gedcom_export_service;
96        $this->tree_service          = $tree_service;
97        $this->upgrade_service       = $upgrade_service;
98    }
99
100    /**
101     * Perform one step of the wizard
102     *
103     * @param ServerRequestInterface $request
104     *
105     * @return ResponseInterface
106     */
107    public function handle(ServerRequestInterface $request): ResponseInterface
108    {
109        $zip_file   = Webtrees::ROOT_DIR . self::ZIP_FILENAME;
110        $zip_folder = Webtrees::ROOT_DIR . self::UPGRADE_FOLDER;
111
112        $step = Validator::queryParams($request)->string('step', self::STEP_CHECK);
113
114        switch ($step) {
115            case self::STEP_CHECK:
116                return $this->wizardStepCheck();
117
118            case self::STEP_PREPARE:
119                return $this->wizardStepPrepare();
120
121            case self::STEP_PENDING:
122                return $this->wizardStepPending();
123
124            case self::STEP_EXPORT:
125                $tree_name = Validator::queryParams($request)->string('tree');
126                $tree      = $this->tree_service->all()[$tree_name];
127                assert($tree instanceof Tree);
128
129                return $this->wizardStepExport($tree);
130
131            case self::STEP_DOWNLOAD:
132                return $this->wizardStepDownload();
133
134            case self::STEP_UNZIP:
135                return $this->wizardStepUnzip($zip_file, $zip_folder);
136
137            case self::STEP_COPY:
138                return $this->wizardStepCopyAndCleanUp($zip_file);
139
140            default:
141                return response('', StatusCodeInterface::STATUS_NO_CONTENT);
142        }
143    }
144
145    /**
146     * @return ResponseInterface
147     */
148    private function wizardStepCheck(): ResponseInterface
149    {
150        $latest_version = $this->upgrade_service->latestVersion();
151
152        if ($latest_version === '') {
153            throw new HttpServerErrorException(I18N::translate('No upgrade information is available.'));
154        }
155
156        if (version_compare(Webtrees::VERSION, $latest_version) >= 0) {
157            $message = I18N::translate('This is the latest version of webtrees. No upgrade is available.');
158            throw new HttpServerErrorException($message);
159        }
160
161        /* I18N: %s is a version number, such as 1.2.3 */
162        $alert = I18N::translate('Upgrade to webtrees %s.', e($latest_version));
163
164        return response(view('components/alert-success', [
165            'alert' => $alert,
166        ]));
167    }
168
169    /**
170     * Make sure the temporary folder exists.
171     *
172     * @return ResponseInterface
173     */
174    private function wizardStepPrepare(): ResponseInterface
175    {
176        $root_filesystem = Registry::filesystem()->root();
177        $root_filesystem->deleteDirectory(self::UPGRADE_FOLDER);
178        $root_filesystem->createDirectory(self::UPGRADE_FOLDER);
179
180        return response(view('components/alert-success', [
181            'alert' => I18N::translate('The folder %s has been created.', e(self::UPGRADE_FOLDER)),
182        ]));
183    }
184
185    /**
186     * @return ResponseInterface
187     */
188    private function wizardStepPending(): ResponseInterface
189    {
190        $changes = DB::table('change')->where('status', '=', 'pending')->exists();
191
192        if ($changes) {
193            return response(view('components/alert-danger', [
194                'alert' => I18N::translate('You should accept or reject all pending changes before upgrading.'),
195            ]), StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR);
196        }
197
198        return response(view('components/alert-success', [
199            'alert' => I18N::translate('There are no pending changes.'),
200        ]));
201    }
202
203    /**
204     * @param Tree $tree
205     *
206     * @return ResponseInterface
207     */
208    private function wizardStepExport(Tree $tree): ResponseInterface
209    {
210        $data_filesystem = Registry::filesystem()->data();
211        $filename        = $tree->name() . date('-Y-m-d') . '.ged';
212        $stream          = $this->gedcom_export_service->export($tree);
213        $data_filesystem->writeStream($filename, $stream);
214        fclose($stream);
215
216        return response(view('components/alert-success', [
217            'alert' => I18N::translate('The family tree has been exported to %s.', e($filename)),
218        ]));
219    }
220
221    /**
222     * @return ResponseInterface
223     */
224    private function wizardStepDownload(): ResponseInterface
225    {
226        $root_filesystem = Registry::filesystem()->root();
227        $start_time      = Registry::timeFactory()->now();
228        $download_url    = $this->upgrade_service->downloadUrl();
229
230        try {
231            $bytes = $this->upgrade_service->downloadFile($download_url, $root_filesystem, self::ZIP_FILENAME);
232        } catch (Throwable $exception) {
233            throw new HttpServerErrorException($exception->getMessage());
234        }
235
236        $kb       = I18N::number(intdiv($bytes + 1023, 1024));
237        $end_time = Registry::timeFactory()->now();
238        $seconds  = I18N::number($end_time - $start_time, 2);
239
240        return response(view('components/alert-success', [
241            'alert' => I18N::translate('%1$s KB were downloaded in %2$s seconds.', $kb, $seconds),
242        ]));
243    }
244
245    /**
246     * For performance reasons, we use direct filesystem access for this step.
247     *
248     * @param string $zip_file
249     * @param string $zip_folder
250     *
251     * @return ResponseInterface
252     */
253    private function wizardStepUnzip(string $zip_file, string $zip_folder): ResponseInterface
254    {
255        $start_time = Registry::timeFactory()->now();
256        $this->upgrade_service->extractWebtreesZip($zip_file, $zip_folder);
257        $count    = $this->upgrade_service->webtreesZipContents($zip_file)->count();
258        $end_time = Registry::timeFactory()->now();
259        $seconds  = I18N::number($end_time - $start_time, 2);
260
261        /* I18N: …from the .ZIP file, %2$s is a (fractional) number of seconds */
262        $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);
263
264        return response(view('components/alert-success', [
265            'alert' => $alert,
266        ]));
267    }
268
269    /**
270     * @param string $zip_file
271     *
272     * @return ResponseInterface
273     */
274    private function wizardStepCopyAndCleanUp(string $zip_file): ResponseInterface
275    {
276        $source_filesystem = Registry::filesystem()->root(self::UPGRADE_FOLDER . self::ZIP_FILE_PREFIX);
277        $root_filesystem   = Registry::filesystem()->root();
278
279        $this->upgrade_service->startMaintenanceMode();
280        $this->upgrade_service->moveFiles($source_filesystem, $root_filesystem);
281        $this->upgrade_service->endMaintenanceMode();
282
283        // While we have time, clean up any old files.
284        $files_to_keep    = $this->upgrade_service->webtreesZipContents($zip_file);
285        $folders_to_clean = new Collection(self::FOLDERS_TO_CLEAN);
286
287        $this->upgrade_service->cleanFiles($root_filesystem, $folders_to_clean, $files_to_keep);
288
289        $url    = route(ControlPanel::class);
290        $alert  = I18N::translate('The upgrade is complete.');
291        $button = '<a href="' . e($url) . '" class="btn btn-primary">' . I18N::translate('continue') . '</a>';
292
293        return response(view('components/alert-success', [
294            'alert' => $alert . ' ' . $button,
295        ]));
296    }
297}
298