Repository: tipsy/javalin (kotlin)

Issue:
Precompressing static files not working since 5.6.0
**Actual behavior (the bug)**
Compression of static files with "precompress" option seems to be faulty and Chrome (115.0.5790.110) fails to decompress it.
What gets printed to the console:
```
localhost/:15     GET http://localhost:8081/index.js net::ERR_CONTENT_DECODING_FAILED 200 (OK)
```

**Expected behavior**
Should work exactly the same from the client perspective as without "precompress" option. It works correctly on version 5.5.0.

**To Reproduce**
Project structure:
![image](https://github.com/javalin/javalin/assets/26698228/2f6c53fe-3409-4a0c-860b-1ede21d3ac30)

Main.java:
```java

import io.javalin.Javalin;
import io.javalin.config.JavalinConfig;

import java.util.function.Consumer;

public class Main {
    public static void main(String[] args) {
        Consumer<JavalinConfig> configConsumer = config -> {
            config.staticFiles.add(staticFiles -> {
                staticFiles.directory = "/test";
                staticFiles.hostedPath = "/";
                staticFiles.precompress = true;
            });
        };
        Javalin.create(configConsumer).start(8081);
    }
}
```

index.html:
```html
<!DOCTYPE html>
<html lang="en">
<body>
<div id="root"></div>
<script src="/index.js"></script>
</body>
</html>
```

index.js (basically can be anything, but not empty):
```js
var a = 1
```

**Additional context**
So, setting `staticFiles.precompress = false;` fixes it - were there any changes to precompression logic shipped with 5.6.0 release?

Pull request:
`acceptEncoding` and `contentEncoding` headers were improperly handled.
Closes #1958

Patch:
diff --git a/javalin/src/main/java/io/javalin/jetty/JettyPrecompressingResourceHandler.kt b/javalin/src/main/java/io/javalin/jetty/JettyPrecompressingResourceHandler.kt
index d98f78fd5..cb2ffbebd 100644
--- a/javalin/src/main/java/io/javalin/jetty/JettyPrecompressingResourceHandler.kt
+++ b/javalin/src/main/java/io/javalin/jetty/JettyPrecompressingResourceHandler.kt
@@ -1,9 +1,7 @@
 package io.javalin.jetty
 
-import io.javalin.compression.Brotli
 import io.javalin.compression.CompressionStrategy
-import io.javalin.compression.CompressionStrategy.Companion.brotliImplAvailable
-import io.javalin.compression.Gzip
+import io.javalin.compression.Compressor
 import io.javalin.compression.forType
 import io.javalin.http.Header
 import io.javalin.util.JavalinLogger
@@ -22,35 +20,23 @@ object JettyPrecompressingResourceHandler {
     @JvmStatic
     fun clearCache() = compressedFiles.clear()
 
-    private val compressionStrategy: CompressionStrategy
-
-    init {
-        compressionStrategy = if (brotliImplAvailable()) {
-            CompressionStrategy(Brotli(11), Gzip(9))
-        } else {
-            CompressionStrategy(null, Gzip(9))
-        }
-
-    }
-
     @JvmField
     var resourceMaxSize: Int = 2 * 1024 * 1024 // the unit of resourceMaxSize is byte
 
-    val excludedMimeTypes = CompressionStrategy().excludedMimeTypesFromCompression
-
-    fun handle(target: String, resource: Resource, req: HttpServletRequest, res: HttpServletResponse): Boolean {
+    fun handle(target: String, resource: Resource, req: HttpServletRequest, res: HttpServletResponse, compStrat: CompressionStrategy): Boolean {
         if (resource.exists() && !resource.isDirectory) {
-            var acceptCompressType = req.getHeader(Header.ACCEPT_ENCODING) ?: ""
+            var compressor = findMatchingCompressor(req.getHeader(Header.ACCEPT_ENCODING) ?: "", compStrat)
             val contentType = MimeTypes.getDefaultMimeByExtension(target) // get content type by file extension
-            if (contentType == null || excludedMimeType(contentType)) {
-                acceptCompressType = ""
+            if (contentType == null || excludedMimeType(contentType, compStrat)) {
+                compressor = null
             }
-            val resultByteArray = getStaticResourceByteArray(resource, target, acceptCompressType) ?: return false
+            val resultByteArray = getStaticResourceByteArray(resource, target, compressor) ?: return false
             res.setContentLength(resultByteArray.size)
             res.setHeader(Header.CONTENT_TYPE, contentType)
 
-            if (acceptCompressType.isNotEmpty())
-                res.setHeader(Header.CONTENT_ENCODING, acceptCompressType)
+            compressor?.let {
+                res.setHeader(Header.CONTENT_ENCODING, compressor.encoding())
+            }
 
             val weakETag = resource.weakETag // jetty resource use weakETag too
             req.getHeader(Header.IF_NONE_MATCH)?.let { etag ->
@@ -67,7 +53,7 @@ object JettyPrecompressingResourceHandler {
         return false
     }
 
-    private fun getStaticResourceByteArray(resource: Resource, target: String, type: String): ByteArray? {
+    private fun getStaticResourceByteArray(resource: Resource, target: String, compressor: Compressor?): ByteArray? {
         if (resource.length() > resourceMaxSize) {
             JavalinLogger.warn(
                 "Static file '$target' is larger than configured max size for pre-compression ($resourceMaxSize bytes).\n" +
@@ -75,15 +61,15 @@ object JettyPrecompressingResourceHandler {
             )
             return null
         }
-        val ext = compressionStrategy.compressors.forType(type)?.extension() ?: ""
-        return compressedFiles.computeIfAbsent(target + ext) { getCompressedByteArray(resource, type) }
+        val ext = compressor?.extension() ?: ""
+        return compressedFiles.computeIfAbsent(target + ext) { getCompressedByteArray(resource, compressor) }
     }
 
-    private fun getCompressedByteArray(resource: Resource, type: String): ByteArray {
+    private fun getCompressedByteArray(resource: Resource, compressor: Compressor?): ByteArray {
         val fileInput = resource.inputStream
         val byteArrayOutputStream = ByteArrayOutputStream()
         val outputStream: OutputStream =
-            compressionStrategy.compressors.forType(type)?.compress(byteArrayOutputStream)
+            compressor?.compress(byteArrayOutputStream)
                 ?: byteArrayOutputStream
 
         fileInput.copyTo(outputStream)
@@ -92,8 +78,17 @@ object JettyPrecompressingResourceHandler {
         return byteArrayOutputStream.toByteArray()
     }
 
-    private fun excludedMimeType(mimeType: String) =
-        if (mimeType == "") false else excludedMimeTypes.any { excluded -> mimeType.contains(excluded, ignoreCase = true) }
-
+    private fun excludedMimeType(mimeType: String, compressionStrategy: CompressionStrategy) =
+        if (mimeType == "") false
+        else compressionStrategy.excludedMimeTypesFromCompression.any { excluded -> mimeType.contains(excluded, ignoreCase = true) }
+
+    private fun findMatchingCompressor(
+        contentTypeHeader: String,
+        compressionStrategy: CompressionStrategy
+    ): Compressor? =
+        contentTypeHeader
+            .split(",")
+            .map { it.trim() }
+            .firstNotNullOfOrNull { compressionStrategy.compressors.forType(it) }
 }
 
diff --git a/javalin/src/main/java/io/javalin/jetty/JettyResourceHandler.kt b/javalin/src/main/java/io/javalin/jetty/JettyResourceHandler.kt
index 5dd39e550..06e0f7905 100644
--- a/javalin/src/main/java/io/javalin/jetty/JettyResourceHandler.kt
+++ b/javalin/src/main/java/io/javalin/jetty/JettyResourceHandler.kt
@@ -48,9 +48,9 @@ class JettyResourceHandler(val pvt: PrivateConfig) : JavalinResourceHandler {
                     handler.config.headers.forEach { httpResponse.setHeader(it.key, it.value) }
                     if (handler.config.precompress) {
                         return if (resource.isDirectoryWithWelcomeFile(handler, target))  // if it's a directory, we need to serve the welcome file
-                            JettyPrecompressingResourceHandler.handle(target, getWelcomeFile(handler, target), httpRequest, httpResponse)
+                            JettyPrecompressingResourceHandler.handle(target, getWelcomeFile(handler, target), httpRequest, httpResponse, pvt.compressionStrategy)
                         else
-                            JettyPrecompressingResourceHandler.handle(target, resource, httpRequest, httpResponse)
+                            JettyPrecompressingResourceHandler.handle(target, resource, httpRequest, httpResponse, pvt.compressionStrategy)
                     }
                     httpResponse.contentType = null // Jetty will only set the content-type if it's null
                     return runCatching { handler.handle(target, jettyRequest, httpRequest, httpResponse) }.isSuccess


Test patch:
diff --git a/javalin/src/test/java/io/javalin/TestWebBrowser.kt b/javalin/src/test/java/io/javalin/TestWebBrowser.kt
index 8d1f66a32..8d5d0b9a5 100644
--- a/javalin/src/test/java/io/javalin/TestWebBrowser.kt
+++ b/javalin/src/test/java/io/javalin/TestWebBrowser.kt
@@ -12,10 +12,12 @@ import io.javalin.plugin.bundled.DevLoggingPlugin.Companion.DevLogging
 import io.javalin.testing.TestUtil
 import io.javalin.testing.TestUtil.captureStdOut
 import io.javalin.testing.WebDriverUtil
+import org.assertj.core.api.Assertions
 import org.assertj.core.api.AssertionsForClassTypes.assertThat
 import org.junit.jupiter.api.AfterEach
 import org.junit.jupiter.api.BeforeEach
 import org.junit.jupiter.api.Test
+import org.openqa.selenium.By
 import org.openqa.selenium.chrome.ChromeDriver
 import java.io.File
 import kotlin.math.ceil
@@ -86,4 +88,19 @@ class TestWebBrowser {
         }
     }
 
+    @Test
+    fun `chrome can handle precompressed files GH-1958`() = TestUtil.test(Javalin.create { config ->
+        config.compression.brotliAndGzip() // this is the default
+        config.staticFiles.add { staticFiles ->
+            staticFiles.hostedPath = "/"
+            staticFiles.directory = "/public"
+            staticFiles.precompress = true
+        }
+    }) { _, http ->
+        driver.get(http.origin + "/html.html")
+        val html = driver.findElement(By.tagName("html")).getAttribute("innerHTML")
+        Assertions.assertThat(html).contains("HTML works")
+        Assertions.assertThat(html).contains("JavaScript works")
+    }
+
 }
diff --git a/javalin/src/test/java/io/javalin/staticfiles/TestStaticFilesPrecompressor.kt b/javalin/src/test/java/io/javalin/staticfiles/TestStaticFilesPrecompressor.kt
index ca527c90e..1dc8e587b 100644
--- a/javalin/src/test/java/io/javalin/staticfiles/TestStaticFilesPrecompressor.kt
+++ b/javalin/src/test/java/io/javalin/staticfiles/TestStaticFilesPrecompressor.kt
@@ -6,6 +6,8 @@
  */
 package io.javalin.staticfiles
 
+import com.aayushatharva.brotli4j.Brotli4jLoader
+import com.aayushatharva.brotli4j.decoder.BrotliInputStream
 import io.javalin.Javalin
 import io.javalin.http.Header
 import io.javalin.jetty.JettyPrecompressingResourceHandler
@@ -16,7 +18,9 @@ import okhttp3.OkHttpClient
 import okhttp3.Request
 import okhttp3.Response
 import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Assumptions.assumeTrue
 import org.junit.jupiter.api.Test
+import java.util.zip.GZIPInputStream
 
 class TestStaticFilesPrecompressor {
 
@@ -70,6 +74,58 @@ class TestStaticFilesPrecompressor {
         assertThat(JettyPrecompressingResourceHandler.compressedFiles.size <= oldSize + 1)
     }
 
+    @Test
+    fun `first available encoding is used`() = TestUtil.test(configPrecompressionStaticResourceApp) { _, http ->
+        assertThat(http.getFile("/secret.html", "br").contentEncoding()).isEqualTo("br")
+        assertThat(http.getFile("/secret.html", "gzip").contentEncoding()).isEqualTo("gzip")
+        assertThat(http.getFile("/secret.html", "gzip, br").contentEncoding()).isEqualTo("gzip")
+        assertThat(http.getFile("/secret.html", "br, gzip").contentEncoding()).isEqualTo("br")
+        assertThat(http.getFile("/secret.html", "deflate, gzip, br").contentEncoding()).isEqualTo("gzip")
+    }
+
+    @Test
+    fun `precompressed files are served with correct content-type`() = TestUtil.test(configPrecompressionStaticResourceApp) { _, http ->
+        assertThat(http.getFile("/secret.html", "br").header("Content-Type")).isEqualTo("text/html")
+        assertThat(http.getFile("/library-1.0.0.min.js", "br").header("Content-Type")).isEqualTo("text/javascript")
+        assertThat(http.getFile("/library-1.0.0.min.js", "gzip, br").header("Content-Type")).isEqualTo("text/javascript")
+        assertThat(http.getFile("/library-1.0.0.min.js", "deflate").header("Content-Type")).isEqualTo("text/javascript")
+    }
+
+    @Test
+    fun `response encoding matches header`() = TestUtil.test(configPrecompressionStaticResourceApp) { _, http ->
+        // Gzip
+        val gzipResponse = http.getFile("/secret.html", "gzip")
+        assertThat(gzipResponse.header("Content-Encoding")).isEqualTo("gzip")
+        val gzipInputStream = GZIPInputStream(gzipResponse.body?.byteStream())
+        assertThat(gzipInputStream.readBytes().toString(Charsets.UTF_8)).contains("<h1>Secret file</h1>")
+        gzipInputStream.close()
+
+        // Brotli
+        assumeTrue(Brotli4jLoader.isAvailable())
+        val brotliResponse = http.getFile("/secret.html", "br")
+        assertThat(brotliResponse.header("Content-Encoding")).isEqualTo("br")
+        val brotliInputStream = BrotliInputStream(brotliResponse.body?.byteStream())
+        assertThat(brotliInputStream.readBytes().toString(Charsets.UTF_8)).contains("<h1>Secret file</h1>")
+        brotliInputStream.close()
+    }
+
+    @Test
+    fun `compression strategy is used`() = TestUtil.test(Javalin.create { config ->
+        config.compression.brotliOnly()
+        config.staticFiles.add { staticFiles ->
+            staticFiles.hostedPath = "/"
+            staticFiles.directory = "/public"
+            staticFiles.precompress = true
+        }
+    }) { _, http ->
+        assumeTrue(Brotli4jLoader.isAvailable())
+        assertThat(http.getFile("/html.html", "gzip").contentEncoding()).isNull()
+        assertThat(http.getFile("/html.html", "gzip, br").contentEncoding()).isEqualTo("br")
+        assertThat(http.getFile("/html.html", "br, gzip").contentEncoding()).isEqualTo("br")
+        assertThat(http.getFile("/html.html", "deflate, gzip, br").contentEncoding()).isEqualTo("br")
+    }
+
+
     private fun Response.contentLength() = this.headers.get(Header.CONTENT_LENGTH)
     private fun Response.contentEncoding() = this.headers.get(Header.CONTENT_ENCODING)