Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 180
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 / 180
0.00% covered (danger)
0.00%
0 / 9
5700
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 / 7
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
702
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)
297    {
298        $filename = preg_replace(['/[^\x20-\x7e]/', '/%/', '/\//', '/\\\\/'], '', $filename);
299
300        return (!empty($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
374        /**
375         * Event to modify PM attachments download auth
376         *
377         * @event core.modify_pm_attach_download_auth
378         * @var    bool    allowed        Whether the user is allowed to download from that PM or not
379         * @var    int        msg_id        The id of the PM to download from
380         * @var    int        user_id        The user id for auth check
381         * @since 3.1.11-RC1
382         */
383        $vars = array('allowed', 'msg_id', 'user_id');
384        extract($this->dispatcher->trigger_event('core.modify_pm_attach_download_auth', compact($vars)));
385
386        if (!$allowed)
387        {
388            throw new http_exception(403, 'ERROR_NO_ATTACHMENT');
389        }
390    }
391
392    /**
393     * Checks whether a user can download from a particular PM
394     *
395     * @param int $msg_id The id of the PM that we are downloading from
396     *
397     * @return bool Whether the user is allowed to download from that PM or not
398     */
399    protected function phpbb_download_check_pm_auth(int $msg_id): bool
400    {
401        $user_id = $this->user->data['user_id'];
402
403        // Check if the attachment is within the users scope...
404        $sql = 'SELECT msg_id
405            FROM ' . PRIVMSGS_TO_TABLE . '
406            WHERE msg_id = ' . (int) $msg_id . '
407                AND (
408                    user_id = ' . (int) $user_id . '
409                    OR author_id = ' . (int) $user_id . '
410                )';
411        $result = $this->db->sql_query_limit($sql, 1);
412        $allowed = (bool) $this->db->sql_fetchfield('msg_id');
413        $this->db->sql_freeresult($result);
414
415        return $allowed;
416    }
417
418    /**
419     * Increments the download count of all provided attachments
420     *
421     * @param int $id The attach_id of the attachment
422     *
423     * @return void
424     */
425    protected function phpbb_increment_downloads(int $id): void
426    {
427        $sql = 'UPDATE ' . ATTACHMENTS_TABLE . '
428            SET download_count = download_count + 1
429            WHERE attach_id = ' . $id;
430        $this->db->sql_query($sql);
431    }
432
433    /**
434     * Check if downloading item is allowed
435     * FIXME (See: https://tracker.phpbb.com/browse/PHPBB3-15264 and http://area51.phpbb.com/phpBB/viewtopic.php?f=81&t=51921)
436     */
437    protected function download_allowed(): bool
438    {
439        if (!$this->config['secure_downloads'])
440        {
441            return true;
442        }
443
444        $url = htmlspecialchars_decode($this->request->header('Referer'));
445
446        if (!$url)
447        {
448            return ($this->config['secure_allow_empty_referer']) ? true : false;
449        }
450
451        // Split URL into domain and script part
452        $url = @parse_url($url);
453
454        if ($url === false)
455        {
456            return ($this->config['secure_allow_empty_referer']) ? true : false;
457        }
458
459        $hostname = $url['host'];
460        unset($url);
461
462        $allowed = ($this->config['secure_allow_deny']) ? false : true;
463        $iplist = array();
464
465        if (($ip_ary = @gethostbynamel($hostname)) !== false)
466        {
467            foreach ($ip_ary as $ip)
468            {
469                if ($ip)
470                {
471                    $iplist[] = $ip;
472                }
473            }
474        }
475
476        // Check for own server...
477        $server_name = $this->user->host;
478
479        // Forcing server vars is the only way to specify/override the protocol
480        if ($this->config['force_server_vars'] || !$server_name)
481        {
482            $server_name = $this->config['server_name'];
483        }
484
485        if (preg_match('#^.*?' . preg_quote($server_name, '#') . '.*?$#i', $hostname))
486        {
487            $allowed = true;
488        }
489
490        // Get IP's and Hostnames
491        if (!$allowed)
492        {
493            $sql = 'SELECT site_ip, site_hostname, ip_exclude
494                FROM ' . SITELIST_TABLE;
495            $result = $this->db->sql_query($sql);
496
497            while ($row = $this->db->sql_fetchrow($result))
498            {
499                $site_ip = trim($row['site_ip']);
500                $site_hostname = trim($row['site_hostname']);
501
502                if ($site_ip)
503                {
504                    foreach ($iplist as $ip)
505                    {
506                        if (preg_match('#^' . str_replace('\*', '.*?', preg_quote($site_ip, '#')) . '$#i', $ip))
507                        {
508                            if ($row['ip_exclude'])
509                            {
510                                $allowed = ($this->config['secure_allow_deny']) ? false : true;
511                                break 2;
512                            }
513                            else
514                            {
515                                $allowed = ($this->config['secure_allow_deny']) ? true : false;
516                            }
517                        }
518                    }
519                }
520
521                if ($site_hostname)
522                {
523                    if (preg_match('#^' . str_replace('\*', '.*?', preg_quote($site_hostname, '#')) . '$#i', $hostname))
524                    {
525                        if ($row['ip_exclude'])
526                        {
527                            $allowed = ($this->config['secure_allow_deny']) ? false : true;
528                            break;
529                        }
530                        else
531                        {
532                            $allowed = ($this->config['secure_allow_deny']) ? true : false;
533                        }
534                    }
535                }
536            }
537            $this->db->sql_freeresult($result);
538        }
539
540        return $allowed;
541    }
542}