Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.00% covered (success)
80.00%
40 / 50
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileDownloader
80.00% covered (success)
80.00%
40 / 50
60.00% covered (warning)
60.00%
3 / 5
11.97
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 send
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 getResponse
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 setHttpHeaders
78.95% covered (warning)
78.95%
30 / 38
0.00% covered (danger)
0.00%
0 / 1
5.23
 streamContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Simple FileDownloader class based on Symfony HttpFoundation.
5 * This class manages the stream of a generic content
6 * taking into account the correct http headers settings
7 *
8 * Currently, it supports only 3 content (mime) types:
9 * - PDF: application/pdf
10 * - CSV: text/csv
11 * - JSON: application/json
12 * - Generic file: application/octet-stream
13 *
14 * This Source Code Form is subject to the terms of the Mozilla Public License,
15 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
16 * obtain one at https://mozilla.org/MPL/2.0/.
17 *
18 * @package   phpMyFAQ
19 * @author    Matteo Scaramuccia <matteo@scaramuccia.com>
20 * @copyright 2005-2026 phpMyFAQ Team
21 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
22 * @link      https://www.phpmyfaq.de
23 * @since     2005-11-02
24 */
25
26declare(strict_types=1);
27
28namespace phpMyFAQ\Administration;
29
30use DateTime;
31use phpMyFAQ\Core\Exception;
32use phpMyFAQ\Export;
33use Symfony\Component\HttpFoundation\HeaderUtils;
34use Symfony\Component\HttpFoundation\Response;
35
36/**
37 * Class FileDownloader
38 *
39 * @package phpMyFAQ
40 */
41class FileDownloader
42{
43    /** HTTP Content Disposition. */
44    private string $disposition = HeaderUtils::DISPOSITION_INLINE;
45
46    /** HTTP streaming data length. */
47    private readonly int $size;
48
49    private Response $response;
50
51    /**
52     * Constructor.
53     *
54     * @param string $type Type
55     * @param string $content Content
56     */
57    public function __construct(
58        private readonly string $type,
59        private readonly string $content,
60    ) {
61        $this->size = strlen($this->content);
62    }
63
64    /**
65     * Sends data.
66     *
67     * @param string $disposition Disposition
68     * @throws Exception
69     */
70    public function send(string $disposition): void
71    {
72        // Sanity checks
73        if (headers_sent()) {
74            throw new Exception(message: 'Error: unable to send my headers: someone already sent other headers!');
75        }
76
77        if (ob_get_contents()) {
78            throw new Exception(message: 'Error: unable to send my data: someone already sent other data!');
79        }
80
81        $this->getResponse($disposition)->send();
82    }
83
84    /**
85     * Builds the streaming response so it can be returned by a controller and
86     * sent by the HTTP kernel, instead of being flushed directly to the client.
87     *
88     * @param string $disposition Disposition
89     */
90    public function getResponse(string $disposition): Response
91    {
92        $this->disposition = $disposition;
93
94        $this->response = new Response();
95        $this->setHttpHeaders();
96        $this->response->setContent(content: $this->streamContent());
97
98        return $this->response;
99    }
100
101    /**
102     * Sends HTTP Headers.
103     */
104    private function setHttpHeaders(): void
105    {
106        // Evaluate data upon export type request
107        switch ($this->type) {
108            case 'pdf':
109                $filename = 'phpmyfaq.pdf';
110                $description = 'phpMyFAQ PDF export file';
111                $mimeType = 'application/pdf';
112                break;
113            case 'csv':
114                $filename = 'phpmyfaq.csv';
115                $description = 'phpMyFAQ CSV export file';
116                $mimeType = 'text/csv';
117                break;
118            case 'json':
119                $filename = 'phpmyfaq.json';
120                $description = 'phpMyFAQ JSON export file';
121                $mimeType = 'application/json';
122                break;
123            // In this case, no default statement is required:
124            // the one above is just for clean coding style
125            default:
126                $filename = 'phpmyfaq.pmf';
127                $description = 'phpMyFAQ Generic export file';
128                $mimeType = 'application/octet-stream';
129                break;
130        }
131
132        $filename = Export::getExportTimestamp() . '_' . $filename;
133
134        // Set the correct HTTP headers:
135        // 1. Prevent proxies&browsers caching
136        $this->response->setLastModified(date: new DateTime());
137        $this->response->setExpires(date: new DateTime());
138        $this->response->setCache(options: [
139            'must_revalidate' => true,
140            'no_cache' => true,
141            'no_store' => true,
142            'no_transform' => false,
143            'public' => false,
144            'private' => true,
145        ]);
146
147        // 2. Set the correct values for file streaming
148        $this->response->headers->set(key: 'Content-Type', values: $mimeType);
149
150        // 3. RFC2616, ยง19.5.1: $filename must be a quoted-string
151        $disposition = HeaderUtils::makeDisposition(disposition: $this->disposition, filename: $filename);
152        $this->response->headers->set(key: 'Content-Disposition', values: $disposition);
153        $this->response->headers->set(key: 'Content-Description', values: $description);
154        $this->response->headers->set(key: 'Content-Transfer-Encoding', values: 'binary');
155        $this->response->headers->set(key: 'Accept-Ranges', values: 'none');
156        $this->response->headers->set(key: 'Content-Length', values: (string) $this->size);
157    }
158
159    /**
160     * Streams the content.
161     */
162    private function streamContent(): string
163    {
164        return $this->content;
165    }
166}