diff --git a/app/Http/DownloadResponseFactory.php b/app/Http/DownloadResponseFactory.php index 01b3502d4..8384484ad 100644 --- a/app/Http/DownloadResponseFactory.php +++ b/app/Http/DownloadResponseFactory.php @@ -70,7 +70,7 @@ class DownloadResponseFactory public function streamedInline($stream, string $fileName, int $fileSize): StreamedResponse { $rangeStream = new RangeSupportedStream($stream, $fileSize, $this->request); - $mime = $rangeStream->sniffMime(); + $mime = $rangeStream->sniffMime(pathinfo($fileName, PATHINFO_EXTENSION)); $headers = array_merge($this->getHeaders($fileName, $fileSize, $mime), $rangeStream->getResponseHeaders()); return response()->stream( diff --git a/app/Http/RangeSupportedStream.php b/app/Http/RangeSupportedStream.php index fce1e9acc..c4b007789 100644 --- a/app/Http/RangeSupportedStream.php +++ b/app/Http/RangeSupportedStream.php @@ -32,12 +32,12 @@ class RangeSupportedStream /** * Sniff a mime type from the stream. */ - public function sniffMime(): string + public function sniffMime(string $extension = ''): string { $offset = min(2000, $this->fileSize); $this->sniffContent = fread($this->stream, $offset); - return (new WebSafeMimeSniffer())->sniff($this->sniffContent); + return (new WebSafeMimeSniffer())->sniff($this->sniffContent, $extension); } /** diff --git a/app/Util/WebSafeMimeSniffer.php b/app/Util/WebSafeMimeSniffer.php index b182d8ac1..4a82de85d 100644 --- a/app/Util/WebSafeMimeSniffer.php +++ b/app/Util/WebSafeMimeSniffer.php @@ -13,7 +13,7 @@ class WebSafeMimeSniffer /** * @var string[] */ - protected $safeMimes = [ + protected array $safeMimes = [ 'application/json', 'application/octet-stream', 'application/pdf', @@ -48,16 +48,28 @@ class WebSafeMimeSniffer 'video/av1', ]; + protected array $textTypesByExtension = [ + 'css' => 'text/css', + 'js' => 'text/javascript', + 'json' => 'application/json', + 'csv' => 'text/csv', + ]; + /** * Sniff the mime-type from the given file content while running the result * through an allow-list to ensure a web-safe result. * Takes the content as a reference since the value may be quite large. + * Accepts an optional $extension which can be used for further guessing. */ - public function sniff(string &$content): string + public function sniff(string &$content, string $extension = ''): string { $fInfo = new finfo(FILEINFO_MIME_TYPE); $mime = $fInfo->buffer($content) ?: 'application/octet-stream'; + if ($mime === 'text/plain' && $extension) { + $mime = $this->textTypesByExtension[$extension] ?? 'text/plain'; + } + if (in_array($mime, $this->safeMimes)) { return $mime; } diff --git a/tests/ThemeTest.php b/tests/ThemeTest.php index 837b94eee..b3c85d8f7 100644 --- a/tests/ThemeTest.php +++ b/tests/ThemeTest.php @@ -464,6 +464,34 @@ END; }); } + public function test_public_folder_contents_accessible_via_route() + { + $this->usingThemeFolder(function (string $themeFolderName) { + $publicDir = theme_path('public'); + mkdir($publicDir, 0777, true); + + $text = 'some-text ' . md5(random_bytes(5)); + $css = "body { background-color: tomato !important; }"; + file_put_contents("{$publicDir}/file.txt", $text); + file_put_contents("{$publicDir}/file.css", $css); + copy($this->files->testFilePath('test-image.png'), "{$publicDir}/image.png"); + + $resp = $this->asAdmin()->get("/theme/{$themeFolderName}/file.txt"); + $resp->assertStreamedContent($text); + $resp->assertHeader('Content-Type', 'text/plain; charset=UTF-8'); + $resp->assertHeader('Cache-Control', 'max-age=86400, private'); + + $resp = $this->asAdmin()->get("/theme/{$themeFolderName}/image.png"); + $resp->assertHeader('Content-Type', 'image/png'); + $resp->assertHeader('Cache-Control', 'max-age=86400, private'); + + $resp = $this->asAdmin()->get("/theme/{$themeFolderName}/file.css"); + $resp->assertStreamedContent($css); + $resp->assertHeader('Content-Type', 'text/css; charset=UTF-8'); + $resp->assertHeader('Cache-Control', 'max-age=86400, private'); + }); + } + protected function usingThemeFolder(callable $callback) { // Create a folder and configure a theme