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