Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
88.89% covered (warning)
88.89%
8 / 9
CRAP
95.96% covered (success)
95.96%
95 / 99
GSFile
0.00% covered (danger)
0.00%
0 / 1
88.89% covered (warning)
88.89%
8 / 9
31
95.96% covered (success)
95.96%
95 / 99
 sanitizeAndStoreFileAsAttachment
0.00% covered (danger)
0.00%
0 / 1
9.05
91.30% covered (success)
91.30%
42 / 46
 sendFile
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
13 / 13
 error
100.00% covered (success)
100.00%
1 / 1
4
100.00% covered (success)
100.00%
5 / 5
 getFileInfo
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
8 / 8
 getAttachmentFileInfo
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
4 / 4
 mimetypeMajor
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 mimetypeMinor
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 mimetypeBare
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
4 / 4
 ensureFilenameWithProperExtension
100.00% covered (success)
100.00%
1 / 1
8
100.00% covered (success)
100.00%
17 / 17
1<?php
2
3// {{{ License
4
5// This file is part of GNU social - https://www.gnu.org/software/social
6//
7// GNU social is free software: you can redistribute it and/or modify
8// it under the terms of the GNU Affero General Public License as published by
9// the Free Software Foundation, either version 3 of the License, or
10// (at your option) any later version.
11//
12// GNU social is distributed in the hope that it will be useful,
13// but WITHOUT ANY WARRANTY; without even the implied warranty of
14// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15// GNU Affero General Public License for more details.
16//
17// You should have received a copy of the GNU Affero General Public License
18// along with GNU social.  If not, see <http://www.gnu.org/licenses/>.
19
20// }}}
21
22namespace App\Core;
23
24use App\Core\DB\DB;
25use function App\Core\I18n\_m;
26use App\Entity\Attachment;
27use App\Util\Common;
28use App\Util\Exception\DuplicateFoundException;
29use App\Util\Exception\NoSuchFileException;
30use App\Util\Exception\NotFoundException;
31use App\Util\Exception\NotStoredLocallyException;
32use App\Util\Exception\ServerException;
33use SplFileInfo;
34use Symfony\Component\HttpFoundation\BinaryFileResponse;
35use Symfony\Component\HttpFoundation\HeaderUtils;
36use Symfony\Component\HttpFoundation\Response;
37use Symfony\Component\Mime\MimeTypes;
38
39/**
40 * GNU social's File Abstraction
41 *
42 * @category  Files
43 * @package   GNUsocial
44 *
45 * @author    Hugo Sales <hugo@hsal.es>
46 * @author    Diogo Peralta Cordeiro <mail@diogo.site>
47 * @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
48 * @license   https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
49 */
50class GSFile
51{
52    /**
53     * Perform file validation (checks and normalization), store the given file if needed and increment lives
54     *
55     * @param SplFileInfo $file
56     * @param string      $dest_dir
57     * @param null|string $title
58     * @param bool        $is_local
59     * @param null|int    $actor_id
60     *
61     * @throws DuplicateFoundException
62     *
63     * @return Attachment
64     */
65    public static function sanitizeAndStoreFileAsAttachment(SplFileInfo $file): Attachment
66    {
67        $hash = null;
68        Event::handle('HashFile', [$file->getPathname(), &$hash]);
69        try {
70            $attachment = DB::findOneBy('attachment', ['filehash' => $hash]);
71            // Attachment Exists
72            $attachment->livesIncrementAndGet();
73            if (is_null($attachment->getFilename())) {
74                $mimetype = $attachment->getMimetype();
75                $width    = $attachment->getWidth();
76                $height   = $attachment->getHeight();
77                if (Common::config('attachments', 'sanitize')) {
78                    $event_map[$mimetype]   = [];
79                    $major_mime             = self::mimetypeMajor($mimetype);
80                    $event_map[$major_mime] = [];
81                    Event::handle('FileSanitizerAvailable', [&$event_map, $mimetype]);
82                    // Always prefer specific encoders
83                    $encoders = array_merge($event_map[$mimetype], $event_map[$major_mime]);
84                    foreach ($encoders as $encoder) {
85                        if ($encoder($file, $mimetype, $width, $height)) {
86                            break; // One successful sanitizer is enough
87                        }
88                    }
89                }
90                $attachment->setFilename($hash);
91                $attachment->setMimetype($mimetype);
92                $attachment->setWidth($width);
93                $attachment->setHeight($height);
94                $attachment->setSize($file->getSize());
95                $file->move(Common::config('attachments', 'dir'), $hash);
96            }
97        } catch (NotFoundException) {
98            // Create an Attachment
99            // The following properly gets the mimetype with `file` or other
100            // available methods, so should be safe
101            $mimetype = mb_substr($file->getMimeType(), 0, 64);
102            $width    = $height    = null;
103            if (Common::config('attachments', 'sanitize')) {
104                $event_map[$mimetype]   = [];
105                $major_mime             = self::mimetypeMajor($mimetype);
106                $event_map[$major_mime] = [];
107                Event::handle('FileSanitizerAvailable', [&$event_map, $mimetype]);
108                // Always prefer specific encoders
109                $encoders = array_merge($event_map[$mimetype], $event_map[$major_mime]);
110                foreach ($encoders as $encoder) {
111                    if ($encoder($file, $mimetype, $width, $height)) {
112                        break; // One successful sanitizer is enough
113                    }
114                }
115            }
116            $attachment = Attachment::create([
117                'filehash' => $hash,
118                'mimetype' => $mimetype,
119                'filename' => $hash,
120                'size'     => $file->getSize(),
121                'width'    => $width,
122                'height'   => $height,
123            ]);
124            $file->move(Common::config('attachments', 'dir'), $hash);
125            DB::persist($attachment);
126            Event::handle('AttachmentStoreNew', [&$attachment]);
127        }
128        return $attachment;
129    }
130
131    /**
132     * Include $filepath in the response, for viewing or downloading.
133     *
134     * @throws ServerException
135     */
136    public static function sendFile(string $filepath, string $mimetype, ?string $output_filename, string $disposition = 'inline'): Response
137    {
138        if (is_file($filepath)) {
139            $response = new BinaryFileResponse(
140                $filepath,
141                Response::HTTP_OK,
142                [
143                    'Content-Description' => 'File Transfer',
144                    'Content-Type'        => $mimetype,
145                    'Content-Disposition' => HeaderUtils::makeDisposition($disposition, $output_filename ?? _m('Untitled attachment') . '.' . MimeTypes::getDefault()->getExtensions($mimetype)[0]),
146                    'Cache-Control'       => 'public',
147                ],
148                public: true,
149                // contentDisposition: $disposition,
150                autoEtag: true,
151                autoLastModified: true
152            );
153            if (Common::config('site', 'x_static_delivery')) {
154                // @codeCoverageIgnoreStart
155                $response->trustXSendfileTypeHeader();
156                // @codeCoverageIgnoreEnd
157            }
158            return $response;
159        } else {
160            // @codeCoverageIgnoreStart
161            throw new NotStoredLocallyException;
162            // @codeCoverageIgnoreEnd
163        }
164    }
165
166    /**
167     * Throw a client exception if the cache key $id doesn't contain
168     * exactly one entry
169     *
170     * @param mixed $except
171     * @param mixed $id
172     */
173    public static function error($except, $id, array $res)
174    {
175        switch (count($res)) {
176            case 0:
177                throw new $except();
178            case 1:
179                return $res[0];
180            default:
181                // @codeCoverageIgnoreStart
182                Log::error('Media query returned more than one result for identifier: \"' . $id . '\"');
183                throw new ServerException(_m('Internal server error'));
184                // @codeCoverageIgnoreEnd
185        }
186    }
187
188    /**
189     * Get the file info by id
190     *
191     * Returns the file's hash, mimetype and title
192     */
193    public static function getFileInfo(int $id)
194    {
195        return self::error(NoSuchFileException::class,
196            $id,
197            Cache::get("file-info-{$id}",
198                function () use ($id) {
199                    return DB::dql('select at.filename, at.mimetype ' .
200                        'from App\\Entity\\Attachment at ' .
201                        'where at.id = :id',
202                        ['id' => $id]);
203                }));
204    }
205
206    // ----- Attachment ------
207
208    /**
209     * Get the attachment file info by id
210     *
211     * Returns the attachment file's hash, mimetype, title and path
212     */
213    public static function getAttachmentFileInfo(int $id): array
214    {
215        $res = self::getFileInfo($id);
216        if (!is_null($res['filename'])) {
217            $res['filepath'] = Common::config('attachments', 'dir') . $res['filename'];
218        }
219        return $res;
220    }
221
222    // ------------------------
223
224    /**
225     * Get the minor part of a mimetype. image/webp -> image
226     */
227    public static function mimetypeMajor(string $mime): string
228    {
229        return explode('/', self::mimetypeBare($mime))[0];
230    }
231
232    /**
233     * Get the minor part of a mimetype. image/webp -> webp
234     */
235    public static function mimetypeMinor(string $mime): string
236    {
237        return explode('/', self::mimetypeBare($mime))[1];
238    }
239
240    /**
241     *  Get only the mimetype and not additional info (separated from bare mime with semi-colon)
242     */
243    public static function mimetypeBare(string $mimetype): string
244    {
245        $mimetype = mb_strtolower($mimetype);
246        if (($semicolon = mb_strpos($mimetype, ';')) !== false) {
247            $mimetype = mb_substr($mimetype, 0, $semicolon);
248        }
249        return trim($mimetype);
250    }
251
252    /**
253     * Given an attachment filename and mimetype allows to generate the most appropriate filename.
254     *
255     * @param string      $title    Original filename with or without extension
256     * @param string      $mimetype Original mimetype of the file
257     * @param null|string $ext      Extension we believe to be best
258     * @param bool        $force    Should we force the extension we believe to be best? Defaults to false
259     *
260     * @return null|string the most appropriate filename or null if we deem it imposible
261     */
262    public static function ensureFilenameWithProperExtension(string $title, string $mimetype, ?string $ext = null, bool $force = false): string | null
263    {
264        $valid_extensions = MimeTypes::getDefault()->getExtensions($mimetype);
265
266        // If title seems to be a filename with an extension
267        $pathinfo = pathinfo($title);
268        if ($pathinfo['extension'] ?? '' != '') {
269            $title_without_extension = $pathinfo['filename'];
270            $original_extension      = $pathinfo['extension'];
271            if (empty(MimeTypes::getDefault()->getMimeTypes($original_extension)) || !in_array($original_extension, $valid_extensions)) {
272                unset($title_without_extension, $original_extension);
273            }
274        }
275
276        $fallback = function ($title) use ($ext) {
277            if (!is_null($ext)) {
278                return ($title) . ".{$ext}";
279            }
280            return null;
281        };
282
283        if ($force) {
284            return $fallback($title_without_extension ?? $title);
285        } else {
286            if (isset($original_extension)) {
287                return $title;
288            } else {
289                if (!empty($valid_extensions)) {
290                    return "{$title}.{$valid_extensions[0]}";
291                } else {
292                    // @codeCoverageIgnoreStart
293                    return $fallback($title_without_extension ?? $title);
294                    // @codeCoverageIgnoreEnd
295                }
296            }
297        }
298    }
299}