<?php /** * This file is part of CodeIgniter 4 framework. * * (c) CodeIgniter Foundation <admin@codeigniter.com> * * For the full copyright and license information, please view * the LICENSE file that was distributed with this source code. */ namespace CodeIgniter\HTTP; use CodeIgniter\Exceptions\DownloadException; use CodeIgniter\Files\File; use Config\Mimes; /** * HTTP response when a download is requested. */ class DownloadResponse extends Response { /** * Download file name * * @var string */ private $filename; /** * Download for file * * @var File|null */ private $file; /** * mime set flag * * @var bool */ private $setMime; /** * Download for binary * * @var string|null */ private $binary; /** * Download charset * * @var string */ private $charset = 'UTF-8'; /** * Download reason * * @var string */ protected $reason = 'OK'; /** * The current status code for this response. * * @var int */ protected $statusCode = 200; /** * Constructor. */ public function __construct(string $filename, bool $setMime) { parent::__construct(config('App')); $this->filename = $filename; $this->setMime = $setMime; // Make sure the content type is either specified or detected $this->removeHeader('Content-Type'); } /** * set download for binary string. */ public function setBinary(string $binary) { if ($this->file !== null) { throw DownloadException::forCannotSetBinary(); } $this->binary = $binary; } /** * set download for file. */ public function setFilePath(string $filepath) { if ($this->binary !== null) { throw DownloadException::forCannotSetFilePath($filepath); } $this->file = new File($filepath, true); } /** * set name for the download. * * @return $this */ public function setFileName(string $filename) { $this->filename = $filename; return $this; } /** * get content length. */ public function getContentLength(): int { if (is_string($this->binary)) { return strlen($this->binary); } if ($this->file instanceof File) { return $this->file->getSize(); } return 0; } /** * Set content type by guessing mime type from file extension */ private function setContentTypeByMimeType() { $mime = null; $charset = ''; if ($this->setMime === true && ($lastDotPosition = strrpos($this->filename, '.')) !== false) { $mime = Mimes::guessTypeFromExtension(substr($this->filename, $lastDotPosition + 1)); $charset = $this->charset; } if (! is_string($mime)) { // Set the default MIME type to send $mime = 'application/octet-stream'; $charset = ''; } $this->setContentType($mime, $charset); } /** * get download filename. */ private function getDownloadFileName(): string { $filename = $this->filename; $x = explode('.', $this->filename); $extension = end($x); /* It was reported that browsers on Android 2.1 (and possibly older as well) * need to have the filename extension upper-cased in order to be able to * download it. * * Reference: http://digiblog.de/2011/04/19/android-and-the-download-file-headers/ */ // @todo: depend super global if (count($x) !== 1 && isset($_SERVER['HTTP_USER_AGENT']) && preg_match('/Android\s(1|2\.[01])/', $_SERVER['HTTP_USER_AGENT'])) { $x[count($x) - 1] = strtoupper($extension); $filename = implode('.', $x); } return $filename; } /** * get Content-Disposition Header string. */ private function getContentDisposition(): string { $downloadFilename = $this->getDownloadFileName(); $utf8Filename = $downloadFilename; if (strtoupper($this->charset) !== 'UTF-8') { $utf8Filename = mb_convert_encoding($downloadFilename, 'UTF-8', $this->charset); } $result = sprintf('attachment; filename="%s"', $downloadFilename); if ($utf8Filename) { $result .= '; filename*=UTF-8\'\'' . rawurlencode($utf8Filename); } return $result; } /** * Disallows status changing. * * @throws DownloadException */ public function setStatusCode(int $code, string $reason = '') { throw DownloadException::forCannotSetStatusCode($code, $reason); } /** * Sets the Content Type header for this response with the mime type * and, optionally, the charset. * * @return ResponseInterface */ public function setContentType(string $mime, string $charset = 'UTF-8') { parent::setContentType($mime, $charset); if ($charset !== '') { $this->charset = $charset; } return $this; } /** * Sets the appropriate headers to ensure this response * is not cached by the browsers. */ public function noCache(): self { $this->removeHeader('Cache-control'); $this->setHeader('Cache-control', ['private', 'no-transform', 'no-store', 'must-revalidate']); return $this; } /** * Disables cache configuration. * * @throws DownloadException */ public function setCache(array $options = []) { throw DownloadException::forCannotSetCache(); } /** * {@inheritDoc} * * @todo Do downloads need CSP or Cookies? Compare with ResponseTrait::send() */ public function send() { $this->buildHeaders(); $this->sendHeaders(); $this->sendBody(); return $this; } /** * set header for file download. */ public function buildHeaders() { if (! $this->hasHeader('Content-Type')) { $this->setContentTypeByMimeType(); } $this->setHeader('Content-Disposition', $this->getContentDisposition()); $this->setHeader('Expires-Disposition', '0'); $this->setHeader('Content-Transfer-Encoding', 'binary'); $this->setHeader('Content-Length', (string) $this->getContentLength()); $this->noCache(); } /** * output download file text. * * @throws DownloadException * * @return DownloadResponse */ public function sendBody() { if ($this->binary !== null) { return $this->sendBodyByBinary(); } if ($this->file !== null) { return $this->sendBodyByFilePath(); } throw DownloadException::forNotFoundDownloadSource(); } /** * output download text by file. * * @return DownloadResponse */ private function sendBodyByFilePath() { $splFileObject = $this->file->openFile('rb'); // Flush 1MB chunks of data while (! $splFileObject->eof() && ($data = $splFileObject->fread(1048576)) !== false) { echo $data; } return $this; } /** * output download text by binary * * @return DownloadResponse */ private function sendBodyByBinary() { echo $this->binary; return $this; } }