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