Passed
Push — master ( 618eec...a04bb9 )
by Greg
05:12
created

MediaFileController   A

Complexity

Total Complexity 26

Size/Duplication

Total Lines 263
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 115
dl 0
loc 263
rs 10
c 2
b 0
f 0
wmc 26

8 Methods

Rating   Name   Duplication   Size   Complexity  
A graphicsDriver() 0 9 2
B mediaDownload() 0 40 7
A fileExtensionAsImage() 0 9 1
A generateImage() 0 43 4
B mediaThumbnail() 0 36 7
A httpStatusAsImage() 0 8 1
A glideSignature() 0 5 1
A unusedMediaThumbnail() 0 39 3
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2019 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 <http://www.gnu.org/licenses/>.
16
 */
17
18
declare(strict_types=1);
19
20
namespace Fisharebest\Webtrees\Http\Controllers;
21
22
use Fig\Http\Message\StatusCodeInterface;
23
use Fisharebest\Flysystem\Adapter\ChrootAdapter;
24
use Fisharebest\Webtrees\Carbon;
25
use Fisharebest\Webtrees\Exceptions\MediaNotFoundException;
26
use Fisharebest\Webtrees\Log;
27
use Fisharebest\Webtrees\Media;
28
use Fisharebest\Webtrees\MediaFile;
29
use Fisharebest\Webtrees\Site;
30
use Fisharebest\Webtrees\Tree;
31
use Intervention\Image\Exception\NotReadableException;
32
use League\Flysystem\Adapter\Local;
33
use League\Flysystem\Filesystem;
34
use League\Flysystem\FilesystemInterface;
35
use League\Glide\Filesystem\FileNotFoundException;
36
use League\Glide\ServerFactory;
37
use League\Glide\Signatures\Signature;
38
use League\Glide\Signatures\SignatureException;
39
use League\Glide\Signatures\SignatureFactory;
40
use Psr\Http\Message\ResponseInterface;
41
use Psr\Http\Message\ServerRequestInterface;
42
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
43
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
44
use Throwable;
45
46
use function addcslashes;
47
use function array_map;
48
use function assert;
49
use function basename;
50
use function dirname;
51
use function explode;
52
use function extension_loaded;
53
use function implode;
54
use function md5;
55
use function redirect;
56
use function response;
57
use function strlen;
58
use function strtolower;
59
use function urlencode;
60
61
/**
62
 * Controller for the media page and displaying images.
63
 */
64
class MediaFileController extends AbstractBaseController
65
{
66
    /**
67
     * Download a non-image media file.
68
     *
69
     * @param ServerRequestInterface $request
70
     *
71
     * @return ResponseInterface
72
     */
73
    public function mediaDownload(ServerRequestInterface $request): ResponseInterface
74
    {
75
        $tree = $request->getAttribute('tree');
76
        assert($tree instanceof Tree);
77
78
        $data_filesystem = $request->getAttribute('filesystem.data');
79
        assert($data_filesystem instanceof FilesystemInterface);
80
81
        $params  = $request->getQueryParams();
82
        $xref    = $params['xref'];
83
        $fact_id = $params['fact_id'];
84
        $media   = Media::getInstance($xref, $tree);
85
86
        if ($media === null) {
87
            throw new MediaNotFoundException();
88
        }
89
90
        if (!$media->canShow()) {
91
            throw new AccessDeniedHttpException();
92
        }
93
94
        foreach ($media->mediaFiles() as $media_file) {
95
            if ($media_file->factId() === $fact_id) {
96
                if ($media_file->isExternal()) {
97
                    return redirect($media_file->filename());
98
                }
99
100
                if ($media_file->fileExists($data_filesystem)) {
101
                    $data = $media_file->media()->tree()->mediaFilesystem()->read($media_file->filename());
102
103
                    return response($data, StatusCodeInterface::STATUS_OK, [
104
                        'Content-Type'        => $media_file->mimeType(),
105
                        'Content-Lengt'       => strlen($data),
106
                        'Content-Disposition' => 'attachment; filename="' . addcslashes($media_file->filename(), '"') . '"',
107
                    ]);
108
                }
109
            }
110
        }
111
112
        throw new NotFoundHttpException();
113
    }
114
115
    /**
116
     * Show an image/thumbnail, with/without a watermark.
117
     *
118
     * @param ServerRequestInterface $request
119
     *
120
     * @return ResponseInterface
121
     */
122
    public function mediaThumbnail(ServerRequestInterface $request): ResponseInterface
123
    {
124
        $tree = $request->getAttribute('tree');
125
        assert($tree instanceof Tree);
126
127
        $data_filesystem = $request->getAttribute('filesystem.data');
128
        assert($data_filesystem instanceof FilesystemInterface);
129
130
        $params  = $request->getQueryParams();
131
        $xref    = $params['xref'];
132
        $fact_id = $params['fact_id'];
133
        $media   = Media::getInstance($xref, $tree);
134
135
        if ($media === null) {
136
            return $this->httpStatusAsImage(StatusCodeInterface::STATUS_NOT_FOUND);
137
        }
138
139
        if (!$media->canShow()) {
140
            return $this->httpStatusAsImage(StatusCodeInterface::STATUS_FORBIDDEN);
141
        }
142
143
        foreach ($media->mediaFiles() as $media_file) {
144
            if ($media_file->factId() === $fact_id) {
145
                if ($media_file->isExternal()) {
146
                    return redirect($media_file->filename());
147
                }
148
149
                if ($media_file->isImage()) {
150
                    return $this->generateImage($media_file, $data_filesystem, $request->getQueryParams());
151
                }
152
153
                return $this->fileExtensionAsImage($media_file->extension());
154
            }
155
        }
156
157
        return $this->httpStatusAsImage(StatusCodeInterface::STATUS_NOT_FOUND);
158
    }
159
160
    /**
161
     * Send a dummy image, to replace one that could not be found or created.
162
     *
163
     * @param int $status HTTP status code
164
     *
165
     * @return ResponseInterface
166
     */
167
    private function httpStatusAsImage(int $status): ResponseInterface
168
    {
169
        $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>';
170
171
        // We can't use the actual status code, as browser's won't show images with 4xx/5xx
172
        return response($svg, StatusCodeInterface::STATUS_OK, [
173
            'Content-Type'   => 'image/svg+xml',
174
            'Content-Length' => strlen($svg),
175
        ]);
176
    }
177
178
    /**
179
     * Generate a thumbnail image for a file.
180
     *
181
     * @param MediaFile           $media_file
182
     * @param FilesystemInterface $data_filesystem
183
     * @param array               $params
184
     *
185
     * @return ResponseInterface
186
     */
187
    private function generateImage(MediaFile $media_file, FilesystemInterface $data_filesystem, array $params): ResponseInterface
188
    {
189
        try {
190
            // Validate HTTP signature
191
            unset($params['route']);
192
            $params['tree'] = $media_file->media()->tree()->name();
193
            $this->glideSignature()->validateRequest('', $params);
194
195
            $path = $media_file->media()->tree()->getPreference('MEDIA_DIRECTORY', 'media/') .  $media_file->filename();
196
            $folder = dirname($path);
197
198
            $cache_path           = 'thumbnail-cache/' . md5($folder);
199
            $cache_filesystem     = new Filesystem(new ChrootAdapter($data_filesystem, $cache_path));
200
            $source_filesystem    = $media_file->media()->tree()->mediaFilesystem($data_filesystem);
201
            $watermark_filesystem = new Filesystem(new Local('resources/img'));
202
203
            $server = ServerFactory::create([
204
                'cache'      => $cache_filesystem,
205
                'driver'     => $this->graphicsDriver(),
206
                'source'     => $source_filesystem,
207
                'watermarks' => $watermark_filesystem,
208
            ]);
209
210
            // Workaround for https://github.com/thephpleague/glide/issues/227
211
            $file = implode('/', array_map('rawurlencode', explode('/', $media_file->filename())));
212
213
            $path = $server->makeImage($file, $params);
214
215
            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

215
            return response($server->getCache()->read($path), StatusCodeInterface::STATUS_OK, /** @scrutinizer ignore-type */ [
Loading history...
216
                'Content-Type'   => $server->getCache()->getMimetype($path),
217
                'Content-Length' => $server->getCache()->getSize($path),
218
                'Cache-Control'  => 'max-age=31536000, public',
219
                'Expires'        => Carbon::now()->addYears(10)->toRfc7231String(),
220
            ]);
221
        } catch (SignatureException $ex) {
222
            return $this->httpStatusAsImage(StatusCodeInterface::STATUS_FORBIDDEN)
223
                ->withHeader('X-Signature-Exception', $ex->getMessage());
224
        } catch (FileNotFoundException $ex) {
225
            return $this->httpStatusAsImage(StatusCodeInterface::STATUS_NOT_FOUND);
226
        } catch (Throwable $ex) {
227
            Log::addErrorLog('Cannot create thumbnail ' . $ex->getMessage());
228
229
            return $this->httpStatusAsImage(StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR);
230
        }
231
    }
232
233
    /**
234
     * Generate a signature, to verify the request parameters.
235
     *
236
     * @return Signature
237
     */
238
    private function glideSignature(): Signature
239
    {
240
        $glide_key = Site::getPreference('glide-key');
241
242
        return SignatureFactory::create($glide_key);
243
    }
244
245
    /**
246
     * Which graphics driver should we use for glide/intervention?
247
     * Prefer ImageMagick
248
     *
249
     * @return string
250
     */
251
    private function graphicsDriver(): string
252
    {
253
        if (extension_loaded('imagick')) {
254
            $driver = 'imagick';
255
        } else {
256
            $driver = 'gd';
257
        }
258
259
        return $driver;
260
    }
261
262
    /**
263
     * Send a dummy image, to replace a non-image file.
264
     *
265
     * @param string $extension
266
     *
267
     * @return ResponseInterface
268
     */
269
    private function fileExtensionAsImage(string $extension): ResponseInterface
270
    {
271
        $extension = '.' . strtolower($extension);
272
273
        $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>';
274
275
        return response($svg, StatusCodeInterface::STATUS_OK, [
276
            'Content-Type'   => 'image/svg+xml',
277
            'Content-Length' => strlen($svg),
278
        ]);
279
    }
280
281
    /**
282
     * Generate a thumbnail for an unsed media file (i.e. not used by any media object).
283
     *
284
     * @param ServerRequestInterface $request
285
     *
286
     * @return ResponseInterface
287
     */
288
    public function unusedMediaThumbnail(ServerRequestInterface $request): ResponseInterface
289
    {
290
        $data_filesystem = $request->getAttribute('filesystem.data');
291
        assert($data_filesystem instanceof FilesystemInterface);
292
293
        $params = $request->getQueryParams();
294
295
        $path   = $params['path'];
296
297
        // Workaround for https://github.com/thephpleague/glide/issues/227
298
        $path = implode('/', array_map('rawurlencode', explode('/', $path)));
299
300
        $folder = dirname($path);
301
        $file   = basename($path);
302
303
        try {
304
            $cache_path        = 'thumbnail-cache/' . md5($folder);
305
            $cache_filesystem  = new Filesystem(new ChrootAdapter($data_filesystem, $cache_path));
306
            $source_filesystem = new Filesystem(new ChrootAdapter($data_filesystem, $folder));
307
308
            $server = ServerFactory::create([
309
                'cache'  => $cache_filesystem,
310
                'driver' => $this->graphicsDriver(),
311
                'source' => $source_filesystem,
312
            ]);
313
314
            $thumbnail = $server->makeImage($file, $params);
315
            $cache     = $server->getCache();
316
317
            return response($cache->read($thumbnail), 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

317
            return response($cache->read($thumbnail), StatusCodeInterface::STATUS_OK, /** @scrutinizer ignore-type */ [
Loading history...
318
                'Content-Type'   => $cache->getMimetype($thumbnail),
319
                'Content-Length' => $cache->getSize($thumbnail),
320
                'Cache-Control'  => 'max-age=31536000, public',
321
                'Expires'        => Carbon::now()->addYears(10)->toRfc7231String(),
322
            ]);
323
        } catch (FileNotFoundException $ex) {
324
            return $this->httpStatusAsImage(StatusCodeInterface::STATUS_NOT_FOUND);
325
        } catch (NotReadableException | Throwable $ex) {
326
            return $this->httpStatusAsImage(StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR);
327
        }
328
    }
329
}
330