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