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