]> BookStack Code Mirror - bookstack/commitdiff
Themes: Added testing and better mime sniffing for public serving
authorDan Brown <redacted>
Mon, 13 Jan 2025 16:51:07 +0000 (16:51 +0000)
committerDan Brown <redacted>
Mon, 13 Jan 2025 16:51:07 +0000 (16:51 +0000)
Existing mime sniffer wasn't great at distinguishing between plaintext
file types, so added a custom extension based mapping for common web
formats that may be expected to be used with this.

app/Http/DownloadResponseFactory.php
app/Http/RangeSupportedStream.php
app/Util/WebSafeMimeSniffer.php
tests/ThemeTest.php

index 01b3502d4b1ad47a8f856aca82b92f266cb6ae9a..8384484ad62b2f00f82ec1308604422fd1ef2b94 100644 (file)
@@ -70,7 +70,7 @@ class DownloadResponseFactory
     public function streamedInline($stream, string $fileName, int $fileSize): StreamedResponse
     {
         $rangeStream = new RangeSupportedStream($stream, $fileSize, $this->request);
     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(
         $headers = array_merge($this->getHeaders($fileName, $fileSize, $mime), $rangeStream->getResponseHeaders());
 
         return response()->stream(
index fce1e9acce30cbc187756c5ef9dd54b7e79f51dd..c4b00778939dd00fd20a762520051c4436be5d40 100644 (file)
@@ -32,12 +32,12 @@ class RangeSupportedStream
     /**
      * Sniff a mime type from the stream.
      */
     /**
      * 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);
 
     {
         $offset = min(2000, $this->fileSize);
         $this->sniffContent = fread($this->stream, $offset);
 
-        return (new WebSafeMimeSniffer())->sniff($this->sniffContent);
+        return (new WebSafeMimeSniffer())->sniff($this->sniffContent, $extension);
     }
 
     /**
     }
 
     /**
index b182d8ac19b39ef6eec4e09acdbe00d8d86c4854..4a82de85d25ccdeb389010c9d3ba611b48326459 100644 (file)
@@ -13,7 +13,7 @@ class WebSafeMimeSniffer
     /**
      * @var string[]
      */
     /**
      * @var string[]
      */
-    protected $safeMimes = [
+    protected array $safeMimes = [
         'application/json',
         'application/octet-stream',
         'application/pdf',
         'application/json',
         'application/octet-stream',
         'application/pdf',
@@ -48,16 +48,28 @@ class WebSafeMimeSniffer
         'video/av1',
     ];
 
         '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.
     /**
      * 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';
 
     {
         $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;
         }
         if (in_array($mime, $this->safeMimes)) {
             return $mime;
         }
index 837b94eee72061bef6bc6c59f23d49fba5b7db2d..b3c85d8f7247a13a2f0c0370af1fba41d164390c 100644 (file)
@@ -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
     protected function usingThemeFolder(callable $callback)
     {
         // Create a folder and configure a theme