Passed
Push — master ( 61bf91...85a166 )
by Greg
05:44
created

MediaFileController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 3
rs 10
1
<?php
2
/**
3
 * webtrees: online genealogy
4
 * Copyright (C) 2019 webtrees development team
5
 * This program is free software: you can redistribute it and/or modify
6
 * it under the terms of the GNU General Public License as published by
7
 * the Free Software Foundation, either version 3 of the License, or
8
 * (at your option) any later version.
9
 * This program is distributed in the hope that it will be useful,
10
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
 * GNU General Public License for more details.
13
 * You should have received a copy of the GNU General Public License
14
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
15
 */
16
declare(strict_types=1);
17
18
namespace Fisharebest\Webtrees\Http\Controllers;
19
20
use Fig\Http\Message\StatusCodeInterface;
21
use Fisharebest\Flysystem\Adapter\ChrootAdapter;
22
use Fisharebest\Webtrees\Carbon;
23
use Fisharebest\Webtrees\Exceptions\MediaNotFoundException;
24
use Fisharebest\Webtrees\Log;
25
use Fisharebest\Webtrees\Media;
26
use Fisharebest\Webtrees\MediaFile;
27
use Fisharebest\Webtrees\Site;
28
use Fisharebest\Webtrees\Tree;
29
use Intervention\Image\Exception\NotReadableException;
30
use League\Flysystem\Adapter\Local;
31
use League\Flysystem\Filesystem;
32
use League\Flysystem\FilesystemInterface;
33
use League\Glide\Filesystem\FileNotFoundException;
34
use League\Glide\ServerFactory;
35
use League\Glide\Signatures\Signature;
36
use League\Glide\Signatures\SignatureException;
37
use League\Glide\Signatures\SignatureFactory;
38
use Psr\Http\Message\ResponseInterface;
39
use Psr\Http\Message\ServerRequestInterface;
40
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
41
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
42
use Throwable;
43
use function addcslashes;
44
use function app;
45
use function basename;
46
use function dirname;
47
use function extension_loaded;
48
use function md5;
49
use function parse_url;
50
use function redirect;
51
use function response;
52
use function strlen;
53
use function strtolower;
54
use const PHP_URL_PATH;
55
56
/**
57
 * Controller for the media page and displaying images.
58
 */
59
class MediaFileController extends AbstractBaseController
60
{
61
    /** @var Filesystem */
62
    private $filesystem;
63
64
    /**
65
     * MediaFileController constructor.
66
     *
67
     * @param FilesystemInterface $filesystem
68
     */
69
    public function __construct(FilesystemInterface $filesystem)
70
    {
71
        $this->filesystem = $filesystem;
0 ignored issues
show
Documentation Bug introduced by
$filesystem is of type League\Flysystem\FilesystemInterface, but the property $filesystem was declared to be of type League\Flysystem\Filesystem. Are you sure that you always receive this specific sub-class here, or does it make sense to add an instanceof check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a given class or a super-class is assigned to a property that is type hinted more strictly.

Either this assignment is in error or an instanceof check should be added for that assignment.

class Alien {}

class Dalek extends Alien {}

class Plot
{
    /** @var  Dalek */
    public $villain;
}

$alien = new Alien();
$plot = new Plot();
if ($alien instanceof Dalek) {
    $plot->villain = $alien;
}
Loading history...
72
    }
73
74
    /**
75
     * Download a non-image media file.
76
     *
77
     * @param ServerRequestInterface $request
78
     * @param Tree                   $tree
79
     *
80
     * @return ResponseInterface
81
     */
82
    public function mediaDownload(ServerRequestInterface $request, Tree $tree): ResponseInterface
83
    {
84
        $params  = $request->getQueryParams();
85
        $xref    = $params['xref'];
86
        $fact_id = $params['fact_id'];
87
        $media   = Media::getInstance($xref, $tree);
88
89
        if ($media === null) {
90
            throw new MediaNotFoundException();
91
        }
92
93
        if (!$media->canShow()) {
94
            throw new AccessDeniedHttpException();
95
        }
96
97
        foreach ($media->mediaFiles() as $media_file) {
98
            if ($media_file->factId() === $fact_id) {
99
                if ($media_file->isExternal()) {
100
                    return redirect($media_file->filename());
101
                }
102
103
                if ($media_file->fileExists()) {
104
                    $data = $media_file->media()->tree()->mediaFilesystem()->read($media_file->filename());
105
106
                    return response($data, StatusCodeInterface::STATUS_OK, [
107
                        'Content-Type'        => $media_file->mimeType(),
108
                        'Content-Lengt'       => strlen($data),
109
                        'Content-Disposition' => 'attachment; filename="' . addcslashes($media_file->filename(), '"') . '"',
110
                    ]);
111
                }
112
            }
113
        }
114
115
        throw new NotFoundHttpException();
116
    }
117
118
    /**
119
     * Show an image/thumbnail, with/without a watermark.
120
     *
121
     * @param ServerRequestInterface $request
122
     * @param Tree                   $tree
123
     *
124
     * @return ResponseInterface
125
     */
126
    public function mediaThumbnail(ServerRequestInterface $request, Tree $tree): ResponseInterface
127
    {
128
        $params  = $request->getQueryParams();
129
        $xref    = $params['xref'];
130
        $fact_id = $params['fact_id'];
131
        $media   = Media::getInstance($xref, $tree);
132
133
        if ($media === null) {
134
            return $this->httpStatusAsImage(StatusCodeInterface::STATUS_NOT_FOUND);
135
        }
136
137
        if (!$media->canShow()) {
138
            return $this->httpStatusAsImage(StatusCodeInterface::STATUS_FORBIDDEN);
139
        }
140
141
        foreach ($media->mediaFiles() as $media_file) {
142
            if ($media_file->factId() === $fact_id) {
143
                if ($media_file->isExternal()) {
144
                    return redirect($media_file->filename());
145
                }
146
147
                if ($media_file->isImage()) {
148
                    return $this->generateImage($media_file, $request->getQueryParams());
149
                }
150
151
                return $this->fileExtensionAsImage($media_file->extension());
152
            }
153
        }
154
155
        return $this->httpStatusAsImage(StatusCodeInterface::STATUS_NOT_FOUND);
156
    }
157
158
    /**
159
     * Send a dummy image, to replace one that could not be found or created.
160
     *
161
     * @param int $status HTTP status code
162
     *
163
     * @return ResponseInterface
164
     */
165
    private function httpStatusAsImage(int $status): ResponseInterface
166
    {
167
        $svg = '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100" fill="#F88" /><text x="5" y="55" font-family="Verdana" font-size="35">' . $status . '</text></svg>';
168
169
        // We can't use the actual status code, as browser's won't show images with 4xx/5xx
170
        return response($svg, StatusCodeInterface::STATUS_OK, [
171
            'Content-Type'   => 'image/svg+xml',
172
            'Content-Length' => strlen($svg),
173
        ]);
174
    }
175
176
    /**
177
     * Generate a thumbnail image for a file.
178
     *
179
     * @param MediaFile $media_file
180
     * @param array     $params
181
     *
182
     * @return ResponseInterface
183
     */
184
    private function generateImage(MediaFile $media_file, array $params): ResponseInterface
185
    {
186
        try {
187
            // Validate HTTP signature
188
            $signature = $this->glideSignature();
189
190
            $base_url = app(ServerRequestInterface::class)->getAttribute('base_url');
191
192
            $signature->validateRequest(parse_url($base_url . 'index.php', PHP_URL_PATH), $params);
193
194
            $path = $media_file->media()->tree()->getPreference('MEDIA_DIRECTORY', 'media/') .  $media_file->filename();
195
            $folder = dirname($path);
196
197
            $cache_path           = 'thumbnail-cache/' . md5($folder);
198
            $cache_filesystem     = new Filesystem(new ChrootAdapter($this->filesystem, $cache_path));
199
            $source_filesystem    = $media_file->media()->tree()->mediaFilesystem();
200
            $watermark_filesystem = new Filesystem(new Local('resources/img'));
201
202
            $server = ServerFactory::create([
203
                'cache'      => $cache_filesystem,
204
                'driver'     => $this->graphicsDriver(),
205
                'source'     => $source_filesystem,
206
                'watermarks' => $watermark_filesystem,
207
            ]);
208
209
            $path = $server->makeImage($media_file->filename(), $params);
210
211
            return response($server->getCache()->read($path), StatusCodeInterface::STATUS_OK, [
0 ignored issues
show
Bug introduced by
array('Content-Type' => ...10)->toRfc7231String()) of type array<string,false|integer|string> is incompatible with the type string[] expected by parameter $headers of response(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

211
            return response($server->getCache()->read($path), StatusCodeInterface::STATUS_OK, /** @scrutinizer ignore-type */ [
Loading history...
212
                'Content-Type'   => $server->getCache()->getMimetype($path),
213
                'Content-Length' => $server->getCache()->getSize($path),
214
                'Cache-Control'  => 'max-age=31536000, public',
215
                'Expires'        => Carbon::now()->addYears(10)->toRfc7231String(),
216
            ]);
217
        } catch (SignatureException $ex) {
218
            return $this->httpStatusAsImage(StatusCodeInterface::STATUS_FORBIDDEN);
219
        } catch (FileNotFoundException $ex) {
220
            return $this->httpStatusAsImage(StatusCodeInterface::STATUS_NOT_FOUND);
221
        } catch (Throwable $ex) {
222
            Log::addErrorLog('Cannot create thumbnail ' . $ex->getMessage());
223
224
            return $this->httpStatusAsImage(StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR);
225
        }
226
    }
227
228
    /**
229
     * Generate a signature, to verify the request parameters.
230
     *
231
     * @return Signature
232
     */
233
    private function glideSignature(): Signature
234
    {
235
        $glide_key = Site::getPreference('glide-key');
236
237
        return SignatureFactory::create($glide_key);
238
    }
239
240
    /**
241
     * Which graphics driver should we use for glide/intervention?
242
     * Prefer ImageMagick
243
     *
244
     * @return string
245
     */
246
    private function graphicsDriver(): string
247
    {
248
        if (extension_loaded('imagick')) {
249
            $driver = 'imagick';
250
        } else {
251
            $driver = 'gd';
252
        }
253
254
        return $driver;
255
    }
256
257
    /**
258
     * Send a dummy image, to replace a non-image file.
259
     *
260
     * @param string $extension
261
     *
262
     * @return ResponseInterface
263
     */
264
    private function fileExtensionAsImage(string $extension): ResponseInterface
265
    {
266
        $extension = '.' . strtolower($extension);
267
268
        $svg = '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100" fill="#88F" /><text x="5" y="60" font-family="Verdana" font-size="30">' . $extension . '</text></svg>';
269
270
        return response($svg, StatusCodeInterface::STATUS_OK, [
271
            'Content-Type'   => 'image/svg+xml',
272
            'Content-Length' => strlen($svg),
273
        ]);
274
    }
275
276
    /**
277
     * Generate a thumbnail for an unsed media file (i.e. not used by any media object).
278
     *
279
     * @param ServerRequestInterface $request
280
     *
281
     * @return ResponseInterface
282
     */
283
    public function unusedMediaThumbnail(ServerRequestInterface $request): ResponseInterface
284
    {
285
        $params = $request->getQueryParams();
286
287
        // The "file" name may also include sub-folders.
288
        $path   = $params['folder'] . $params['file'];
289
        $folder = dirname($path);
290
        $file   = basename($path);
291
292
        try {
293
            $cache_path        = 'thumbnail-cache/' . md5($folder);
294
            $cache_filesystem  = new Filesystem(new ChrootAdapter($this->filesystem, $cache_path));
295
            $source_filesystem = new Filesystem(new ChrootAdapter($this->filesystem, $folder));
296
297
            $server = ServerFactory::create([
298
                'cache'  => $cache_filesystem,
299
                'driver' => $this->graphicsDriver(),
300
                'source' => $source_filesystem,
301
            ]);
302
303
            $path  = $server->makeImage($file, $params);
304
            $cache = $server->getCache();
305
306
            return response($cache->read($path), StatusCodeInterface::STATUS_OK, [
0 ignored issues
show
Bug introduced by
array('Content-Type' => ...10)->toRfc7231String()) of type array<string,false|integer|string> is incompatible with the type string[] expected by parameter $headers of response(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

306
            return response($cache->read($path), StatusCodeInterface::STATUS_OK, /** @scrutinizer ignore-type */ [
Loading history...
307
                'Content-Type'   => $cache->getMimetype($path),
308
                'Content-Length' => $cache->getSize($path),
309
                'Cache-Control'  => 'max-age=31536000, public',
310
                'Expires'        => Carbon::now()->addYears(10)->toRfc7231String(),
311
            ]);
312
        } catch (FileNotFoundException $ex) {
313
            return $this->httpStatusAsImage(StatusCodeInterface::STATUS_NOT_FOUND);
314
        } catch (NotReadableException | Throwable $ex) {
315
            return $this->httpStatusAsImage(StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR);
316
        }
317
    }
318
}
319