Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 181
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
attachment
0.00% covered (danger)
0.00%
0 / 181
0.00% covered (danger)
0.00%
0 / 9
5256
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 handle_attachment
0.00% covered (danger)
0.00%
0 / 85
0.00% covered (danger)
0.00%
0 / 1
1122
 filenameFallback
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 prepare
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 phpbb_download_handle_forum_auth
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
56
 phpbb_download_handle_pm_auth
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 phpbb_download_check_pm_auth
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 phpbb_increment_downloads
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 download_allowed
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
552
1<?php
2/**
3 *
4 * This file is part of the phpBB Forum Software package.
5 *
6 * @copyright (c) phpBB Limited <https://www.phpbb.com>
7 * @license GNU General Public License, version 2 (GPL-2.0)
8 *
9 * For full copyright and license information, please see
10 * the docs/CREDITS.txt file.
11 *
12 */
13
14namespace phpbb\storage\controller;
15
16use phpbb\attachment\attachment_category;
17use phpbb\auth\auth;
18use phpbb\cache\service;
19use phpbb\config\config;
20use phpbb\content_visibility;
21use phpbb\db\driver\driver_interface;
22use phpbb\event\dispatcher_interface;
23use phpbb\exception\http_exception;
24use phpbb\language\language;
25use phpbb\mimetype\extension_guesser;
26use phpbb\request\request;
27use phpbb\storage\storage;
28use phpbb\user;
29use Symfony\Component\HttpFoundation\Request as symfony_request;
30use Symfony\Component\HttpFoundation\RedirectResponse;
31use Symfony\Component\HttpFoundation\Response;
32use Symfony\Component\HttpFoundation\ResponseHeaderBag;
33use Symfony\Component\HttpFoundation\StreamedResponse;
34
35/**
36 * Controller for /download/attachment/{id} routes
37 */
38class attachment extends controller
39{
40    /** @var auth */
41    protected $auth;
42
43    /** @var config */
44    protected $config;
45
46    /** @var content_visibility */
47    protected $content_visibility;
48
49    /** @var dispatcher_interface */
50    protected $dispatcher;
51
52    /** @var language */
53    protected $language;
54
55    /** @var request */
56    protected $request;
57
58    /** @var user */
59    protected $user;
60
61    /**
62     * Constructor
63     *
64     * @param auth                    $auth
65     * @param service                $cache
66     * @param config                $config
67     * @param content_visibility    $content_visibility
68     * @param driver_interface        $db
69     * @param dispatcher_interface    $dispatcher
70     * @param extension_guesser        $extension_guesser
71     * @param language                $language
72     * @param request                $request
73     * @param storage                $storage
74     * @param symfony_request        $symfony_request
75     * @param user                    $user
76     */
77    public function __construct(auth $auth, service $cache, config $config, content_visibility $content_visibility, driver_interface $db, dispatcher_interface $dispatcher, extension_guesser $extension_guesser, language $language, request $request, storage $storage, symfony_request $symfony_request, user $user)
78    {
79        parent::__construct($cache, $db, $extension_guesser, $storage, $symfony_request);
80
81        $this->auth = $auth;
82        $this->config = $config;
83        $this->content_visibility = $content_visibility;
84        $this->dispatcher = $dispatcher;
85        $this->language = $language;
86        $this->request = $request;
87        $this->user = $user;
88    }
89
90    /**
91     * Handle attachments
92     *
93     * @param int $id File ID
94     * @param string $filename Filename
95     */
96    public function handle_attachment(int $id, string $filename): Response
97    {
98        $attach_id = $id;
99        $thumbnail = $this->request->variable('t', false);
100
101        $this->language->add_lang('viewtopic');
102
103        if (!$this->config['allow_attachments'] && !$this->config['allow_pm_attach'])
104        {
105            throw new http_exception(404, 'ATTACHMENT_FUNCTIONALITY_DISABLED');
106        }
107
108        if (!$attach_id)
109        {
110            throw new http_exception(404, 'NO_ATTACHMENT_SELECTED');
111        }
112
113        $sql = 'SELECT attach_id, post_msg_id, topic_id, in_message, poster_id,
114                is_orphan, physical_filename, real_filename, extension, mimetype,
115                filesize, filetime
116            FROM ' . ATTACHMENTS_TABLE . "
117            WHERE attach_id = $attach_id" .
118                (($filename) ? " AND real_filename = '" . $this->db->sql_escape($filename) . "'" : '');
119        $result = $this->db->sql_query($sql);
120        $attachment = $this->db->sql_fetchrow($result);
121        $this->db->sql_freeresult($result);
122
123        if (!$attachment)
124        {
125            throw new http_exception(404, 'ERROR_NO_ATTACHMENT');
126        }
127        else if (!$this->download_allowed())
128        {
129            throw new http_exception(403, 'LINKAGE_FORBIDDEN');
130        }
131
132        $attachment['physical_filename'] = utf8_basename($attachment['physical_filename']);
133
134        if ((!$attachment['in_message'] && !$this->config['allow_attachments']) ||
135            ($attachment['in_message'] && !$this->config['allow_pm_attach']))
136        {
137            throw new http_exception(404, 'ATTACHMENT_FUNCTIONALITY_DISABLED');
138        }
139
140        if ($attachment['is_orphan'])
141        {
142            // We allow admins having attachment permissions to see orphan attachments...
143            $own_attachment = $this->auth->acl_get('a_attach') || $attachment['poster_id'] == $this->user->data['user_id'];
144
145            if (!$own_attachment || ($attachment['in_message'] && !$this->auth->acl_get('u_pm_download')) ||
146                (!$attachment['in_message'] && !$this->auth->acl_get('u_download')))
147            {
148                throw new http_exception(404, 'ERROR_NO_ATTACHMENT');
149            }
150
151            // Obtain all extensions...
152            $extensions = $this->cache->obtain_attach_extensions(true);
153        }
154        else
155        {
156            if (!$attachment['in_message'])
157            {
158                $this->phpbb_download_handle_forum_auth($attachment['topic_id']);
159
160                $sql = 'SELECT forum_id, poster_id, post_visibility
161                    FROM ' . POSTS_TABLE . '
162                    WHERE post_id = ' . (int) $attachment['post_msg_id'];
163                $result = $this->db->sql_query($sql);
164                $post_row = $this->db->sql_fetchrow($result);
165                $this->db->sql_freeresult($result);
166
167                if (!$post_row || !$this->content_visibility->is_visible('post', $post_row['forum_id'], $post_row))
168                {
169                    // Attachment of a soft deleted post and the user is not allowed to see the post
170                    throw new http_exception(404, 'ERROR_NO_ATTACHMENT');
171                }
172            }
173            else
174            {
175                // Attachment is in a private message.
176                $post_row = array('forum_id' => false);
177                $this->phpbb_download_handle_pm_auth( $attachment['post_msg_id']);
178            }
179
180            $extensions = array();
181            if (!extension_allowed($post_row['forum_id'], $attachment['extension'], $extensions))
182            {
183                throw new http_exception(403, 'EXTENSION_DISABLED_AFTER_POSTING', [$attachment['extension']]);
184            }
185        }
186
187        $display_cat = $extensions[$attachment['extension']]['display_cat'];
188
189        if ($thumbnail)
190        {
191            $attachment['physical_filename'] = 'thumb_' . $attachment['physical_filename'];
192        }
193        else if ($display_cat == attachment_category::NONE && !$attachment['is_orphan'])
194        {
195            if (!(($display_cat == attachment_category::IMAGE || $display_cat == attachment_category::THUMB) && !$this->user->optionget('viewimg')))
196            {
197                // Update download count
198                $this->phpbb_increment_downloads($attachment['attach_id']);
199            }
200        }
201
202        $redirect = '';
203
204        /**
205         * Event to modify data before sending file to browser
206         *
207         * @event core.download_file_send_to_browser_before
208         * @var    int        attach_id            The attachment ID
209         * @var    array    attachment            Array with attachment data
210         * @var    array    extensions            Array with file extensions data
211         * @var    bool    thumbnail            Flag indicating if the file is a thumbnail
212         * @var    string    redirect            Do a redirection instead of reading the file
213         * @since 3.1.6-RC1
214         * @changed 3.1.7-RC1    Fixing wrong name of a variable (replacing "extension" by "extensions")
215         * @changed 3.3.0-a1        Add redirect variable
216         * @changed 3.3.0-a1        Remove display_cat variable
217         * @changed 3.3.0-a1        Remove mode variable
218         */
219        $vars = array(
220            'attach_id',
221            'attachment',
222            'extensions',
223            'thumbnail',
224            'redirect',
225        );
226        extract($this->dispatcher->trigger_event('core.download_file_send_to_browser_before', compact($vars)));
227
228        // If the redirect variable have been overwritten, do redirect there
229        if (!empty($redirect))
230        {
231            return new RedirectResponse($redirect);
232        }
233
234        // Check if the file exists in the storage table too
235        if (!$this->storage->exists($attachment['physical_filename']))
236        {
237            throw new http_exception(404, 'ERROR_NO_ATTACHMENT');
238        }
239
240        /**
241         * Event to alter attachment before it is sent to browser.
242         *
243         * @event core.send_file_to_browser_before
244         * @var    array    attachment    Attachment data
245         * @since 3.1.11-RC1
246         * @changed 3.3.0-a1        Removed category variable
247         * @changed 3.3.0-a1        Removed size variable
248         * @changed 3.3.0-a1        Removed filename variable
249         */
250        $vars = array(
251            'attachment',
252        );
253        extract($this->dispatcher->trigger_event('core.send_file_to_browser_before', compact($vars)));
254
255        // TODO: The next lines should go better in prepare, also the mimetype is handled by the storage table
256        // so probably can be removed
257
258        $response = new StreamedResponse();
259
260        // Content-type header
261        $response->headers->set('Content-Type', $attachment['mimetype']);
262
263        // Display file types in browser and force download for others
264        if (strpos($attachment['mimetype'], 'image') !== false
265            || strpos($attachment['mimetype'], 'audio') !== false
266            || strpos($attachment['mimetype'], 'video') !== false
267        )
268        {
269            $disposition = $response->headers->makeDisposition(
270                ResponseHeaderBag::DISPOSITION_INLINE,
271                $attachment['real_filename'],
272                $this->filenameFallback($attachment['real_filename'])
273            );
274        }
275        else
276        {
277            $disposition = $response->headers->makeDisposition(
278                ResponseHeaderBag::DISPOSITION_ATTACHMENT,
279                $attachment['real_filename'],
280                $this->filenameFallback($attachment['real_filename'])
281            );
282        }
283
284        $response->headers->set('Content-Disposition', $disposition);
285
286        // Set expires header for browser cache
287        $time = new \DateTime();
288        $response->setExpires($time->modify('+1 year'));
289
290        return parent::handle($attachment['physical_filename']);
291    }
292
293    /**
294     * Remove non valid characters https://github.com/symfony/http-foundation/commit/c7df9082ee7205548a97031683bc6550b5dc9551
295     */
296    protected function filenameFallback($filename): string
297    {
298        $filename = (string) preg_replace(['/[^\x20-\x7e]/', '/%/', '/\//', '/\\\\/'], '', $filename);
299
300        return !empty($filename) ? $filename : 'File';
301    }
302
303    /**
304     * {@inheritdoc}
305     */
306    protected function prepare(StreamedResponse $response, string $file): void
307    {
308        $response->setPrivate();    // By default, should be private, but make sure of it
309
310        parent::prepare($response, $file);
311    }
312
313    /**
314     * Handles authentication when downloading attachments from a post or topic
315     *
316     * @param int $topic_id The id of the topic that we are downloading from
317     *
318     * @return void
319     * @throws http_exception If attachment is not found
320     *                        If user don't have permission to download the attachment
321     */
322    protected function phpbb_download_handle_forum_auth(int $topic_id): void
323    {
324        $sql_array = [
325            'SELECT'    => 't.forum_id, t.topic_poster, t.topic_visibility, f.forum_name, f.forum_password, f.parent_id',
326            'FROM'        => [
327                TOPICS_TABLE => 't',
328                FORUMS_TABLE => 'f',
329            ],
330            'WHERE'        => 't.topic_id = ' . (int) $topic_id . '
331                AND t.forum_id = f.forum_id',
332        ];
333
334        $sql = $this->db->sql_build_query('SELECT', $sql_array);
335        $result = $this->db->sql_query($sql);
336        $row = $this->db->sql_fetchrow($result);
337        $this->db->sql_freeresult($result);
338
339        if ($row && !$this->content_visibility->is_visible('topic', $row['forum_id'], $row))
340        {
341            throw new http_exception(404, 'ERROR_NO_ATTACHMENT');
342        }
343        else if ($row && $this->auth->acl_get('u_download') && $this->auth->acl_get('f_download', $row['forum_id']))
344        {
345            if ($row['forum_password'])
346            {
347                // Do something else ... ?
348                login_forum_box($row);
349            }
350        }
351        else
352        {
353            throw new http_exception(403, 'SORRY_AUTH_VIEW_ATTACH');
354        }
355    }
356
357    /**
358     * Handles authentication when downloading attachments from PMs
359     *
360     * @param int $msg_id The id of the PM that we are downloading from
361     *
362     * @return void
363     * @throws http_exception If attachment is not found
364     */
365    protected function phpbb_download_handle_pm_auth(int $msg_id): void
366    {
367        if (!$this->auth->acl_get('u_pm_download'))
368        {
369            throw new http_exception(403, 'SORRY_AUTH_VIEW_ATTACH');
370        }
371
372        $allowed = $this->phpbb_download_check_pm_auth($msg_id);
373        $user_id = $this->user->data['user_id'];
374
375        /**
376         * Event to modify PM attachments download auth
377         *
378         * @event core.modify_pm_attach_download_auth
379         * @var    bool    allowed        Whether the user is allowed to download from that PM or not
380         * @var    int        msg_id        The id of the PM to download from
381         * @var    int        user_id        The user id for auth check
382         * @since 3.1.11-RC1
383         */
384        $vars = array('allowed', 'msg_id', 'user_id');
385        extract($this->dispatcher->trigger_event('core.modify_pm_attach_download_auth', compact($vars)));
386
387        if (!$allowed)
388        {
389            throw new http_exception(403, 'ERROR_NO_ATTACHMENT');
390        }
391    }
392
393    /**
394     * Checks whether a user can download from a particular PM
395     *
396     * @param int $msg_id The id of the PM that we are downloading from
397     *
398     * @return bool Whether the user is allowed to download from that PM or not
399     */
400    protected function phpbb_download_check_pm_auth(int $msg_id): bool
401    {
402        $user_id = $this->user->data['user_id'];
403
404        // Check if the attachment is within the users scope...
405        $sql = 'SELECT msg_id
406            FROM ' . PRIVMSGS_TO_TABLE . '
407            WHERE msg_id = ' . (int) $msg_id . '
408                AND (
409                    user_id = ' . (int) $user_id . '
410                    OR author_id = ' . (int) $user_id . '
411                )';
412        $result = $this->db->sql_query_limit($sql, 1);
413        $allowed = (bool) $this->db->sql_fetchfield('msg_id');
414        $this->db->sql_freeresult($result);
415
416        return $allowed;
417    }
418
419    /**
420     * Increments the download count of all provided attachments
421     *
422     * @param int $id The attach_id of the attachment
423     *
424     * @return void
425     */
426    protected function phpbb_increment_downloads(int $id): void
427    {
428        $sql = 'UPDATE ' . ATTACHMENTS_TABLE . '
429            SET download_count = download_count + 1
430            WHERE attach_id = ' . $id;
431        $this->db->sql_query($sql);
432    }
433
434    /**
435     * Check if downloading item is allowed
436     * FIXME (See: https://tracker.phpbb.com/browse/PHPBB3-15264 and http://area51.phpbb.com/phpBB/viewtopic.php?f=81&t=51921)
437     */
438    protected function download_allowed(): bool
439    {
440        if (!$this->config['secure_downloads'])
441        {
442            return true;
443        }
444
445        $url = htmlspecialchars_decode($this->request->header('Referer'));
446
447        if (!$url)
448        {
449            return (bool) $this->config['secure_allow_empty_referer'];
450        }
451
452        // Split URL into domain and script part
453        $url = @parse_url($url);
454
455        if ($url === false)
456        {
457            return (bool) $this->config['secure_allow_empty_referer'];
458        }
459
460        $hostname = $url['host'];
461        unset($url);
462
463        $allowed = !$this->config['secure_allow_deny'];
464        $iplist = array();
465
466        if (($ip_ary = @gethostbynamel($hostname)) !== false)
467        {
468            foreach ($ip_ary as $ip)
469            {
470                if ($ip)
471                {
472                    $iplist[] = $ip;
473                }
474            }
475        }
476
477        // Check for own server...
478        $server_name = $this->user->host;
479
480        // Forcing server vars is the only way to specify/override the protocol
481        if ($this->config['force_server_vars'] || !$server_name)
482        {
483            $server_name = $this->config['server_name'];
484        }
485
486        if (preg_match('#^.*?' . preg_quote($server_name, '#') . '.*?$#i', $hostname))
487        {
488            $allowed = true;
489        }
490
491        // Get IP's and Hostnames
492        if (!$allowed)
493        {
494            $sql = 'SELECT site_ip, site_hostname, ip_exclude
495                FROM ' . SITELIST_TABLE;
496            $result = $this->db->sql_query($sql);
497
498            while ($row = $this->db->sql_fetchrow($result))
499            {
500                $site_ip = trim($row['site_ip']);
501                $site_hostname = trim($row['site_hostname']);
502
503                if ($site_ip)
504                {
505                    foreach ($iplist as $ip)
506                    {
507                        if (preg_match('#^' . str_replace('\*', '.*?', preg_quote($site_ip, '#')) . '$#i', $ip))
508                        {
509                            if ($row['ip_exclude'])
510                            {
511                                $allowed = ($this->config['secure_allow_deny']) ? false : true;
512                                break 2;
513                            }
514                            else
515                            {
516                                $allowed = ($this->config['secure_allow_deny']) ? true : false;
517                            }
518                        }
519                    }
520                }
521
522                if ($site_hostname)
523                {
524                    if (preg_match('#^' . str_replace('\*', '.*?', preg_quote($site_hostname, '#')) . '$#i', $hostname))
525                    {
526                        if ($row['ip_exclude'])
527                        {
528                            $allowed = ($this->config['secure_allow_deny']) ? false : true;
529                            break;
530                        }
531                        else
532                        {
533                            $allowed = ($this->config['secure_allow_deny']) ? true : false;
534                        }
535                    }
536                }
537            }
538            $this->db->sql_freeresult($result);
539        }
540
541        return $allowed;
542    }
543}