xref: /webtrees/app/Http/RequestHandlers/UpgradeWizardStep.php (revision 4b3ef6caf72914d34581bb4a8d677e615e877a9b)
1*4b3ef6caSGreg Roach<?php
2*4b3ef6caSGreg Roach
3*4b3ef6caSGreg Roach/**
4*4b3ef6caSGreg Roach * webtrees: online genealogy
5*4b3ef6caSGreg Roach * Copyright (C) 2021 webtrees development team
6*4b3ef6caSGreg Roach * This program is free software: you can redistribute it and/or modify
7*4b3ef6caSGreg Roach * it under the terms of the GNU General Public License as published by
8*4b3ef6caSGreg Roach * the Free Software Foundation, either version 3 of the License, or
9*4b3ef6caSGreg Roach * (at your option) any later version.
10*4b3ef6caSGreg Roach * This program is distributed in the hope that it will be useful,
11*4b3ef6caSGreg Roach * but WITHOUT ANY WARRANTY; without even the implied warranty of
12*4b3ef6caSGreg Roach * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13*4b3ef6caSGreg Roach * GNU General Public License for more details.
14*4b3ef6caSGreg Roach * You should have received a copy of the GNU General Public License
15*4b3ef6caSGreg Roach * along with this program. If not, see <https://www.gnu.org/licenses/>.
16*4b3ef6caSGreg Roach */
17*4b3ef6caSGreg Roach
18*4b3ef6caSGreg Roachdeclare(strict_types=1);
19*4b3ef6caSGreg Roach
20*4b3ef6caSGreg Roachnamespace Fisharebest\Webtrees\Http\RequestHandlers;
21*4b3ef6caSGreg Roach
22*4b3ef6caSGreg Roachuse Fig\Http\Message\StatusCodeInterface;
23*4b3ef6caSGreg Roachuse Fisharebest\Flysystem\Adapter\ChrootAdapter;
24*4b3ef6caSGreg Roachuse Fisharebest\Webtrees\Exceptions\HttpServerErrorException;
25*4b3ef6caSGreg Roachuse Fisharebest\Webtrees\I18N;
26*4b3ef6caSGreg Roachuse Fisharebest\Webtrees\Registry;
27*4b3ef6caSGreg Roachuse Fisharebest\Webtrees\Services\GedcomExportService;
28*4b3ef6caSGreg Roachuse Fisharebest\Webtrees\Services\TreeService;
29*4b3ef6caSGreg Roachuse Fisharebest\Webtrees\Services\UpgradeService;
30*4b3ef6caSGreg Roachuse Fisharebest\Webtrees\Tree;
31*4b3ef6caSGreg Roachuse Fisharebest\Webtrees\Webtrees;
32*4b3ef6caSGreg Roachuse Illuminate\Database\Capsule\Manager as DB;
33*4b3ef6caSGreg Roachuse Illuminate\Support\Collection;
34*4b3ef6caSGreg Roachuse League\Flysystem\Filesystem;
35*4b3ef6caSGreg Roachuse League\Flysystem\FilesystemInterface;
36*4b3ef6caSGreg Roachuse Psr\Http\Message\ResponseInterface;
37*4b3ef6caSGreg Roachuse Psr\Http\Message\ServerRequestInterface;
38*4b3ef6caSGreg Roachuse Psr\Http\Server\RequestHandlerInterface;
39*4b3ef6caSGreg Roachuse RuntimeException;
40*4b3ef6caSGreg Roachuse Throwable;
41*4b3ef6caSGreg Roach
42*4b3ef6caSGreg Roachuse function assert;
43*4b3ef6caSGreg Roachuse function date;
44*4b3ef6caSGreg Roachuse function e;
45*4b3ef6caSGreg Roachuse function fclose;
46*4b3ef6caSGreg Roachuse function fopen;
47*4b3ef6caSGreg Roachuse function fseek;
48*4b3ef6caSGreg Roachuse function intdiv;
49*4b3ef6caSGreg Roachuse function microtime;
50*4b3ef6caSGreg Roachuse function response;
51*4b3ef6caSGreg Roachuse function route;
52*4b3ef6caSGreg Roachuse function version_compare;
53*4b3ef6caSGreg Roachuse function view;
54*4b3ef6caSGreg Roach
55*4b3ef6caSGreg Roach/**
56*4b3ef6caSGreg Roach * Upgrade to a new version of webtrees.
57*4b3ef6caSGreg Roach */
58*4b3ef6caSGreg Roachclass UpgradeWizardStep implements RequestHandlerInterface
59*4b3ef6caSGreg Roach{
60*4b3ef6caSGreg Roach    // We make the upgrade in a number of small steps to keep within server time limits.
61*4b3ef6caSGreg Roach    private const STEP_CHECK    = 'Check';
62*4b3ef6caSGreg Roach    private const STEP_PREPARE  = 'Prepare';
63*4b3ef6caSGreg Roach    private const STEP_PENDING  = 'Pending';
64*4b3ef6caSGreg Roach    private const STEP_EXPORT   = 'Export';
65*4b3ef6caSGreg Roach    private const STEP_DOWNLOAD = 'Download';
66*4b3ef6caSGreg Roach    private const STEP_UNZIP    = 'Unzip';
67*4b3ef6caSGreg Roach    private const STEP_COPY     = 'Copy';
68*4b3ef6caSGreg Roach
69*4b3ef6caSGreg Roach    // Where to store our temporary files.
70*4b3ef6caSGreg Roach    private const UPGRADE_FOLDER = 'data/tmp/upgrade/';
71*4b3ef6caSGreg Roach
72*4b3ef6caSGreg Roach    // Where to store the downloaded ZIP archive.
73*4b3ef6caSGreg Roach    private const ZIP_FILENAME = 'data/tmp/webtrees.zip';
74*4b3ef6caSGreg Roach
75*4b3ef6caSGreg Roach    // The ZIP archive stores everything inside this top-level folder.
76*4b3ef6caSGreg Roach    private const ZIP_FILE_PREFIX = 'webtrees';
77*4b3ef6caSGreg Roach
78*4b3ef6caSGreg Roach    // Cruft can accumulate after upgrades.
79*4b3ef6caSGreg Roach    private const FOLDERS_TO_CLEAN = [
80*4b3ef6caSGreg Roach        'app',
81*4b3ef6caSGreg Roach        'resources',
82*4b3ef6caSGreg Roach        'vendor',
83*4b3ef6caSGreg Roach    ];
84*4b3ef6caSGreg Roach
85*4b3ef6caSGreg Roach    /** @var GedcomExportService */
86*4b3ef6caSGreg Roach    private $gedcom_export_service;
87*4b3ef6caSGreg Roach
88*4b3ef6caSGreg Roach    /** @var UpgradeService */
89*4b3ef6caSGreg Roach    private $upgrade_service;
90*4b3ef6caSGreg Roach
91*4b3ef6caSGreg Roach    /** @var TreeService */
92*4b3ef6caSGreg Roach    private $tree_service;
93*4b3ef6caSGreg Roach
94*4b3ef6caSGreg Roach    /**
95*4b3ef6caSGreg Roach     * UpgradeController constructor.
96*4b3ef6caSGreg Roach     *
97*4b3ef6caSGreg Roach     * @param GedcomExportService $gedcom_export_service
98*4b3ef6caSGreg Roach     * @param TreeService         $tree_service
99*4b3ef6caSGreg Roach     * @param UpgradeService      $upgrade_service
100*4b3ef6caSGreg Roach     */
101*4b3ef6caSGreg Roach    public function __construct(
102*4b3ef6caSGreg Roach        GedcomExportService $gedcom_export_service,
103*4b3ef6caSGreg Roach        TreeService $tree_service,
104*4b3ef6caSGreg Roach        UpgradeService $upgrade_service
105*4b3ef6caSGreg Roach    ) {
106*4b3ef6caSGreg Roach        $this->gedcom_export_service = $gedcom_export_service;
107*4b3ef6caSGreg Roach        $this->tree_service          = $tree_service;
108*4b3ef6caSGreg Roach        $this->upgrade_service       = $upgrade_service;
109*4b3ef6caSGreg Roach    }
110*4b3ef6caSGreg Roach
111*4b3ef6caSGreg Roach    /**
112*4b3ef6caSGreg Roach     * Perform one step of the wizard
113*4b3ef6caSGreg Roach     *
114*4b3ef6caSGreg Roach     * @param ServerRequestInterface $request
115*4b3ef6caSGreg Roach     *
116*4b3ef6caSGreg Roach     * @return ResponseInterface
117*4b3ef6caSGreg Roach     */
118*4b3ef6caSGreg Roach    public function handle(ServerRequestInterface $request): ResponseInterface
119*4b3ef6caSGreg Roach    {
120*4b3ef6caSGreg Roach        $root_filesystem = Registry::filesystem()->root();
121*4b3ef6caSGreg Roach        $data_filesystem = Registry::filesystem()->data();
122*4b3ef6caSGreg Roach
123*4b3ef6caSGreg Roach        // Somewhere to unpack a .ZIP file
124*4b3ef6caSGreg Roach        $temporary_filesystem = new Filesystem(new ChrootAdapter($root_filesystem, self::UPGRADE_FOLDER));
125*4b3ef6caSGreg Roach
126*4b3ef6caSGreg Roach        $zip_file   = Webtrees::ROOT_DIR . self::ZIP_FILENAME;
127*4b3ef6caSGreg Roach        $zip_folder = Webtrees::ROOT_DIR . self::UPGRADE_FOLDER;
128*4b3ef6caSGreg Roach
129*4b3ef6caSGreg Roach
130*4b3ef6caSGreg Roach        $step = $request->getQueryParams()['step'] ?? self::STEP_CHECK;
131*4b3ef6caSGreg Roach
132*4b3ef6caSGreg Roach        switch ($step) {
133*4b3ef6caSGreg Roach            case self::STEP_CHECK:
134*4b3ef6caSGreg Roach                return $this->wizardStepCheck();
135*4b3ef6caSGreg Roach
136*4b3ef6caSGreg Roach            case self::STEP_PREPARE:
137*4b3ef6caSGreg Roach                return $this->wizardStepPrepare($root_filesystem);
138*4b3ef6caSGreg Roach
139*4b3ef6caSGreg Roach            case self::STEP_PENDING:
140*4b3ef6caSGreg Roach                return $this->wizardStepPending();
141*4b3ef6caSGreg Roach
142*4b3ef6caSGreg Roach            case self::STEP_EXPORT:
143*4b3ef6caSGreg Roach                $tree_name = $request->getQueryParams()['tree'] ?? '';
144*4b3ef6caSGreg Roach                $tree      = $this->tree_service->all()[$tree_name];
145*4b3ef6caSGreg Roach                assert($tree instanceof Tree);
146*4b3ef6caSGreg Roach
147*4b3ef6caSGreg Roach                return $this->wizardStepExport($tree, $data_filesystem);
148*4b3ef6caSGreg Roach
149*4b3ef6caSGreg Roach            case self::STEP_DOWNLOAD:
150*4b3ef6caSGreg Roach                return $this->wizardStepDownload($root_filesystem);
151*4b3ef6caSGreg Roach
152*4b3ef6caSGreg Roach            case self::STEP_UNZIP:
153*4b3ef6caSGreg Roach                return $this->wizardStepUnzip($zip_file, $zip_folder);
154*4b3ef6caSGreg Roach
155*4b3ef6caSGreg Roach            case self::STEP_COPY:
156*4b3ef6caSGreg Roach                return $this->wizardStepCopyAndCleanUp($zip_file, $root_filesystem, $temporary_filesystem);
157*4b3ef6caSGreg Roach
158*4b3ef6caSGreg Roach            default:
159*4b3ef6caSGreg Roach                return response('', StatusCodeInterface::STATUS_NO_CONTENT);
160*4b3ef6caSGreg Roach        }
161*4b3ef6caSGreg Roach    }
162*4b3ef6caSGreg Roach
163*4b3ef6caSGreg Roach    /**
164*4b3ef6caSGreg Roach     * @return ResponseInterface
165*4b3ef6caSGreg Roach     */
166*4b3ef6caSGreg Roach    private function wizardStepCheck(): ResponseInterface
167*4b3ef6caSGreg Roach    {
168*4b3ef6caSGreg Roach        $latest_version = $this->upgrade_service->latestVersion();
169*4b3ef6caSGreg Roach
170*4b3ef6caSGreg Roach        if ($latest_version === '') {
171*4b3ef6caSGreg Roach            throw new HttpServerErrorException(I18N::translate('No upgrade information is available.'));
172*4b3ef6caSGreg Roach        }
173*4b3ef6caSGreg Roach
174*4b3ef6caSGreg Roach        if (version_compare(Webtrees::VERSION, $latest_version) >= 0) {
175*4b3ef6caSGreg Roach            $message = I18N::translate('This is the latest version of webtrees. No upgrade is available.');
176*4b3ef6caSGreg Roach            throw new HttpServerErrorException($message);
177*4b3ef6caSGreg Roach        }
178*4b3ef6caSGreg Roach
179*4b3ef6caSGreg Roach        /* I18N: %s is a version number, such as 1.2.3 */
180*4b3ef6caSGreg Roach        $alert = I18N::translate('Upgrade to webtrees %s.', e($latest_version));
181*4b3ef6caSGreg Roach
182*4b3ef6caSGreg Roach        return response(view('components/alert-success', [
183*4b3ef6caSGreg Roach            'alert' => $alert,
184*4b3ef6caSGreg Roach        ]));
185*4b3ef6caSGreg Roach    }
186*4b3ef6caSGreg Roach
187*4b3ef6caSGreg Roach    /**
188*4b3ef6caSGreg Roach     * Make sure the temporary folder exists.
189*4b3ef6caSGreg Roach     *
190*4b3ef6caSGreg Roach     * @param FilesystemInterface $root_filesystem
191*4b3ef6caSGreg Roach     *
192*4b3ef6caSGreg Roach     * @return ResponseInterface
193*4b3ef6caSGreg Roach     */
194*4b3ef6caSGreg Roach    private function wizardStepPrepare(FilesystemInterface $root_filesystem): ResponseInterface
195*4b3ef6caSGreg Roach    {
196*4b3ef6caSGreg Roach        $root_filesystem->deleteDir(self::UPGRADE_FOLDER);
197*4b3ef6caSGreg Roach        $root_filesystem->createDir(self::UPGRADE_FOLDER);
198*4b3ef6caSGreg Roach
199*4b3ef6caSGreg Roach        return response(view('components/alert-success', [
200*4b3ef6caSGreg Roach            'alert' => I18N::translate('The folder %s has been created.', e(self::UPGRADE_FOLDER)),
201*4b3ef6caSGreg Roach        ]));
202*4b3ef6caSGreg Roach    }
203*4b3ef6caSGreg Roach
204*4b3ef6caSGreg Roach    /**
205*4b3ef6caSGreg Roach     * @return ResponseInterface
206*4b3ef6caSGreg Roach     */
207*4b3ef6caSGreg Roach    private function wizardStepPending(): ResponseInterface
208*4b3ef6caSGreg Roach    {
209*4b3ef6caSGreg Roach        $changes = DB::table('change')->where('status', '=', 'pending')->exists();
210*4b3ef6caSGreg Roach
211*4b3ef6caSGreg Roach        if ($changes) {
212*4b3ef6caSGreg Roach            throw new HttpServerErrorException(I18N::translate('You should accept or reject all pending changes before upgrading.'));
213*4b3ef6caSGreg Roach        }
214*4b3ef6caSGreg Roach
215*4b3ef6caSGreg Roach        return response(view('components/alert-success', [
216*4b3ef6caSGreg Roach            'alert' => I18N::translate('There are no pending changes.'),
217*4b3ef6caSGreg Roach        ]));
218*4b3ef6caSGreg Roach    }
219*4b3ef6caSGreg Roach
220*4b3ef6caSGreg Roach    /**
221*4b3ef6caSGreg Roach     * @param Tree                $tree
222*4b3ef6caSGreg Roach     * @param FilesystemInterface $data_filesystem
223*4b3ef6caSGreg Roach     *
224*4b3ef6caSGreg Roach     * @return ResponseInterface
225*4b3ef6caSGreg Roach     */
226*4b3ef6caSGreg Roach    private function wizardStepExport(Tree $tree, FilesystemInterface $data_filesystem): ResponseInterface
227*4b3ef6caSGreg Roach    {
228*4b3ef6caSGreg Roach        // We store the data in PHP temporary storage.
229*4b3ef6caSGreg Roach        $stream = fopen('php://temp', 'wb+');
230*4b3ef6caSGreg Roach
231*4b3ef6caSGreg Roach        if ($stream === false) {
232*4b3ef6caSGreg Roach            throw new RuntimeException('Failed to create temporary stream');
233*4b3ef6caSGreg Roach        }
234*4b3ef6caSGreg Roach
235*4b3ef6caSGreg Roach        $filename = $tree->name() . date('-Y-m-d') . '.ged';
236*4b3ef6caSGreg Roach
237*4b3ef6caSGreg Roach        $this->gedcom_export_service->export($tree, $stream);
238*4b3ef6caSGreg Roach
239*4b3ef6caSGreg Roach        fseek($stream, 0);
240*4b3ef6caSGreg Roach        $data_filesystem->putStream($tree->name() . date('-Y-m-d') . '.ged', $stream);
241*4b3ef6caSGreg Roach        fclose($stream);
242*4b3ef6caSGreg Roach
243*4b3ef6caSGreg Roach        return response(view('components/alert-success', [
244*4b3ef6caSGreg Roach            'alert' => I18N::translate('The family tree has been exported to %s.', e($filename)),
245*4b3ef6caSGreg Roach        ]));
246*4b3ef6caSGreg Roach    }
247*4b3ef6caSGreg Roach
248*4b3ef6caSGreg Roach    /**
249*4b3ef6caSGreg Roach     * @param FilesystemInterface $root_filesystem
250*4b3ef6caSGreg Roach     *
251*4b3ef6caSGreg Roach     * @return ResponseInterface
252*4b3ef6caSGreg Roach     */
253*4b3ef6caSGreg Roach    private function wizardStepDownload(FilesystemInterface $root_filesystem): ResponseInterface
254*4b3ef6caSGreg Roach    {
255*4b3ef6caSGreg Roach        $start_time   = microtime(true);
256*4b3ef6caSGreg Roach        $download_url = $this->upgrade_service->downloadUrl();
257*4b3ef6caSGreg Roach
258*4b3ef6caSGreg Roach        try {
259*4b3ef6caSGreg Roach            $bytes = $this->upgrade_service->downloadFile($download_url, $root_filesystem, self::ZIP_FILENAME);
260*4b3ef6caSGreg Roach        } catch (Throwable $exception) {
261*4b3ef6caSGreg Roach            throw new HttpServerErrorException($exception->getMessage());
262*4b3ef6caSGreg Roach        }
263*4b3ef6caSGreg Roach
264*4b3ef6caSGreg Roach        $kb       = I18N::number(intdiv($bytes + 1023, 1024));
265*4b3ef6caSGreg Roach        $end_time = microtime(true);
266*4b3ef6caSGreg Roach        $seconds  = I18N::number($end_time - $start_time, 2);
267*4b3ef6caSGreg Roach
268*4b3ef6caSGreg Roach        return response(view('components/alert-success', [
269*4b3ef6caSGreg Roach            'alert' => I18N::translate('%1$s KB were downloaded in %2$s seconds.', $kb, $seconds),
270*4b3ef6caSGreg Roach        ]));
271*4b3ef6caSGreg Roach    }
272*4b3ef6caSGreg Roach
273*4b3ef6caSGreg Roach    /**
274*4b3ef6caSGreg Roach     * For performance reasons, we use direct filesystem access for this step.
275*4b3ef6caSGreg Roach     *
276*4b3ef6caSGreg Roach     * @param string $zip_file
277*4b3ef6caSGreg Roach     * @param string $zip_folder
278*4b3ef6caSGreg Roach     *
279*4b3ef6caSGreg Roach     * @return ResponseInterface
280*4b3ef6caSGreg Roach     */
281*4b3ef6caSGreg Roach    private function wizardStepUnzip(string $zip_file, string $zip_folder): ResponseInterface
282*4b3ef6caSGreg Roach    {
283*4b3ef6caSGreg Roach        $start_time = microtime(true);
284*4b3ef6caSGreg Roach        $this->upgrade_service->extractWebtreesZip($zip_file, $zip_folder);
285*4b3ef6caSGreg Roach        $count    = $this->upgrade_service->webtreesZipContents($zip_file)->count();
286*4b3ef6caSGreg Roach        $end_time = microtime(true);
287*4b3ef6caSGreg Roach        $seconds  = I18N::number($end_time - $start_time, 2);
288*4b3ef6caSGreg Roach
289*4b3ef6caSGreg Roach        /* I18N: …from the .ZIP file, %2$s is a (fractional) number of seconds */
290*4b3ef6caSGreg Roach        $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);
291*4b3ef6caSGreg Roach
292*4b3ef6caSGreg Roach        return response(view('components/alert-success', [
293*4b3ef6caSGreg Roach            'alert' => $alert,
294*4b3ef6caSGreg Roach        ]));
295*4b3ef6caSGreg Roach    }
296*4b3ef6caSGreg Roach
297*4b3ef6caSGreg Roach    /**
298*4b3ef6caSGreg Roach     * @param string              $zip_file
299*4b3ef6caSGreg Roach     * @param FilesystemInterface $root_filesystem
300*4b3ef6caSGreg Roach     * @param FilesystemInterface $temporary_filesystem
301*4b3ef6caSGreg Roach     *
302*4b3ef6caSGreg Roach     * @return ResponseInterface
303*4b3ef6caSGreg Roach     */
304*4b3ef6caSGreg Roach    private function wizardStepCopyAndCleanUp(
305*4b3ef6caSGreg Roach        string $zip_file,
306*4b3ef6caSGreg Roach        FilesystemInterface $root_filesystem,
307*4b3ef6caSGreg Roach        FilesystemInterface $temporary_filesystem
308*4b3ef6caSGreg Roach    ): ResponseInterface {
309*4b3ef6caSGreg Roach        $source_filesystem = new Filesystem(new ChrootAdapter($temporary_filesystem, self::ZIP_FILE_PREFIX));
310*4b3ef6caSGreg Roach
311*4b3ef6caSGreg Roach        $this->upgrade_service->startMaintenanceMode();
312*4b3ef6caSGreg Roach        $this->upgrade_service->moveFiles($source_filesystem, $root_filesystem);
313*4b3ef6caSGreg Roach        $this->upgrade_service->endMaintenanceMode();
314*4b3ef6caSGreg Roach
315*4b3ef6caSGreg Roach        // While we have time, clean up any old files.
316*4b3ef6caSGreg Roach        $files_to_keep    = $this->upgrade_service->webtreesZipContents($zip_file);
317*4b3ef6caSGreg Roach        $folders_to_clean = new Collection(self::FOLDERS_TO_CLEAN);
318*4b3ef6caSGreg Roach
319*4b3ef6caSGreg Roach        $this->upgrade_service->cleanFiles($root_filesystem, $folders_to_clean, $files_to_keep);
320*4b3ef6caSGreg Roach
321*4b3ef6caSGreg Roach        $url    = route(ControlPanel::class);
322*4b3ef6caSGreg Roach        $alert  = I18N::translate('The upgrade is complete.');
323*4b3ef6caSGreg Roach        $button = '<a href="' . e($url) . '" class="btn btn-primary">' . I18N::translate('continue') . '</a>';
324*4b3ef6caSGreg Roach
325*4b3ef6caSGreg Roach        return response(view('components/alert-success', [
326*4b3ef6caSGreg Roach            'alert' => $alert . ' ' . $button,
327*4b3ef6caSGreg Roach        ]));
328*4b3ef6caSGreg Roach    }
329*4b3ef6caSGreg Roach}
330