Repository: tipsy/javalin (kotlin)

Issue:
Range requests do not work for files larger 2GB
**Actual behavior (the bug)**
When offering a large file via Context.seekableStream the maximum size is incorrectly reported and requests for data beyond the first 2GB cause IndexOutOfBounds exceptions.

**Expected behavior**
Files > 2GB can be correctly downloaded via range requests.

**To Reproduce**
```
app.get("/file", ctx -> {
    File file = new File("/large-file.bin"); //must be > 2GB in size
    ctx.seekableStream(new FileInputStream(file), "application/octet-stream");
});
// curl http://localhost:7000/file --head
// curl http://localhost:7000/file -r 2147483647-2147483747
```

**Additional context**
The error is due to the implementation using `InputStream#available()` to determine the max size, which returns an int and so can never represent files > 2GB.
Also note that according to the doc https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/io/InputStream.html?is-external=true#available() available() does not need to return the actual size of the underlying stream. So is actually very likely to return values smaller than the actual size.
I would suggest  fixing this with a new method `Context#seekableStream(InputStream in, String contentType, long size)`

Pull request description:
This pr fixes  #1479

Patch:
diff --git a/javalin/src/main/java/io/javalin/http/Context.kt b/javalin/src/main/java/io/javalin/http/Context.kt
index 600924dd8..4685e8e4f 100644
--- a/javalin/src/main/java/io/javalin/http/Context.kt
+++ b/javalin/src/main/java/io/javalin/http/Context.kt
@@ -348,7 +348,9 @@ open class Context(@JvmField val req: HttpServletRequest, @JvmField val res: Htt
     }
 
     /** Writes the specified inputStream as a seekable stream */
-    fun seekableStream(inputStream: InputStream, contentType: String) = SeekableWriter.write(this, inputStream, contentType)
+    @JvmOverloads
+    fun seekableStream(inputStream: InputStream, contentType: String, size: Long = inputStream.available().toLong()) =
+            SeekableWriter.write(this, inputStream, contentType, size)
 
     /** Gets the current context result as an [InputStream] (if set). */
     fun resultStream(): InputStream? = resultStream
diff --git a/javalin/src/main/java/io/javalin/http/util/SeekableWriter.kt b/javalin/src/main/java/io/javalin/http/util/SeekableWriter.kt
index ab4f1f56d..a74768baa 100644
--- a/javalin/src/main/java/io/javalin/http/util/SeekableWriter.kt
+++ b/javalin/src/main/java/io/javalin/http/util/SeekableWriter.kt
@@ -7,19 +7,18 @@ import java.io.OutputStream
 
 object SeekableWriter {
     var chunkSize = 128000
-    fun write(ctx: Context, inputStream: InputStream, contentType: String) {
+    fun write(ctx: Context, inputStream: InputStream, contentType: String, totalBytes: Long) {
         if (ctx.header(Header.RANGE) == null) {
             ctx.header(Header.CONTENT_TYPE, contentType)
             ctx.result(inputStream)
             return
         }
-        val totalBytes = inputStream.available()
         val requestedRange = ctx.header(Header.RANGE)!!.split("=")[1].split("-").filter { it.isNotEmpty() }
-        val from = requestedRange[0].toInt()
+        val from = requestedRange[0].toLong()
         val to = when {
             from + chunkSize > totalBytes -> totalBytes - 1 // chunk bigger than file, write all
-            requestedRange.size == 2 -> requestedRange[1].toInt() // chunk smaller than file, to/from specified
-            else -> from + chunkSize - 1 // chunk smaller then file, to/from not specified
+            requestedRange.size == 2 -> requestedRange[1].toLong() // chunk smaller than file, to/from specified
+            else -> from + chunkSize - 1 // chunk smaller than file, to/from not specified
         }
         ctx.status(206)
         ctx.header(Header.CONTENT_TYPE, contentType)
@@ -29,11 +28,15 @@ object SeekableWriter {
         ctx.res.outputStream.write(inputStream, from, to)
     }
 
-    private fun OutputStream.write(inputStream: InputStream, from: Int, to: Int, buffer: ByteArray = ByteArray(1024)) = inputStream.use {
-        it.skip(from.toLong())
+    private fun OutputStream.write(inputStream: InputStream, from: Long, to: Long, buffer: ByteArray = ByteArray(1024)) = inputStream.use {
+        var toSkip = from
+        while (toSkip > 0) {
+            val skipped = it.skip(toSkip)
+            toSkip -= skipped
+        }
         var bytesLeft = to - from + 1
-        while (bytesLeft != 0) {
-            val read = it.read(buffer, 0, Math.min(buffer.size, bytesLeft))
+        while (bytesLeft != 0L) {
+            val read = it.read(buffer, 0, buffer.size.toLong().coerceAtMost(bytesLeft).toInt())
             this.write(buffer, 0, read)
             bytesLeft -= read
         }


Test patch:
diff --git a/javalin/src/test/java/io/javalin/LargeSeekableInput.kt b/javalin/src/test/java/io/javalin/LargeSeekableInput.kt
new file mode 100644
index 000000000..9704bb438
--- /dev/null
+++ b/javalin/src/test/java/io/javalin/LargeSeekableInput.kt
@@ -0,0 +1,44 @@
+package io.javalin
+
+import java.io.InputStream
+
+class LargeSeekableInput(private val prefixSize: Long, private val contentSize: Long): InputStream() {
+    private var alreadyRead = 0L
+
+    private fun remaining(): Long = prefixSize + contentSize - alreadyRead
+
+    override fun available(): Int {
+        val rem = remaining()
+        return if (rem < Int.MAX_VALUE) {
+            rem.toInt()
+        } else {
+            Int.MAX_VALUE
+        }
+    }
+
+    override fun skip(toSkip: Long): Long {
+        if (toSkip <= 0) {
+            return 0;
+        }
+        val rem = remaining()
+        return if (rem < toSkip) {
+            alreadyRead += rem
+            rem
+        } else {
+            alreadyRead += toSkip
+            toSkip
+        }
+    }
+
+    override fun read(): Int {
+        return if (remaining() == 0L) {
+            -1
+        } else if (alreadyRead < prefixSize) {
+            alreadyRead++
+            ' '.code
+        } else {
+            alreadyRead++
+            'J'.code
+        }
+    }
+}
diff --git a/javalin/src/test/java/io/javalin/TestResponse.kt b/javalin/src/test/java/io/javalin/TestResponse.kt
index a3ed3d5d4..e52663bac 100644
--- a/javalin/src/test/java/io/javalin/TestResponse.kt
+++ b/javalin/src/test/java/io/javalin/TestResponse.kt
@@ -196,4 +196,19 @@ class TestResponse {
         assertThat(response.length).isEqualTo(150)
     }
 
+    @Test
+    fun `seekable - large file works`() = TestUtil.test { app, http ->
+        val prefixSize = 1L shl 31 //2GB
+        val contentSize = 100L
+        app.get("/seekable-5") { ctx -> ctx.seekableStream(LargeSeekableInput(prefixSize, contentSize), ContentType.PLAIN, prefixSize + contentSize) }
+        val response = Unirest.get(http.origin + "/seekable-5")
+                .headers(mapOf(Header.RANGE to "bytes=${prefixSize}-${prefixSize + contentSize - 1}"))
+                .asString()
+
+        assertThat(response.headers[Header.CONTENT_RANGE]?.get(0)).isEqualTo("bytes ${prefixSize}-${prefixSize + contentSize - 1}/${prefixSize + contentSize}")
+        val responseBody = response.body
+        assertThat(responseBody.length).isEqualTo(contentSize)
+        assertThat(responseBody).doesNotContain(" ")
+    }
+
 }