Skip to content

Commit f5cf508

Browse files
authored
Add eTags and LastModified headers (#175)
Signed-off-by: Arnau Mora Gras <[email protected]>
1 parent 391b4de commit f5cf508

File tree

9 files changed

+115
-13
lines changed

9 files changed

+115
-13
lines changed

build.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ repositories {
1515

1616
dependencies {
1717
// Ktor dependencies
18+
implementation(libs.ktor.server.conditionalHeaders)
1819
implementation(libs.ktor.server.contentNegotiation)
1920
implementation(libs.ktor.server.core)
2021
implementation(libs.ktor.server.cors)

gradle/libs.versions.toml

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ ktor-client-contentNegotiation = { module = "io.ktor:ktor-client-content-negotia
2121
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
2222
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
2323
ktor-serializationJson = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
24+
ktor-server-conditionalHeaders = { module = "io.ktor:ktor-server-conditional-headers", version.ref = "ktor" }
2425
ktor-server-contentNegotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
2526
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
2627
ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }

src/main/kotlin/server/endpoints/files/DownloadFileEndpoint.kt

+11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package server.endpoints.files
22

3+
import io.ktor.http.HttpHeaders
34
import io.ktor.server.response.header
45
import io.ktor.server.response.respondFile
56
import io.ktor.server.response.respondOutputStream
@@ -10,7 +11,10 @@ import kotlinx.coroutines.Dispatchers
1011
import kotlinx.coroutines.withContext
1112
import server.endpoints.EndpointBase
1213
import server.error.Errors
14+
import server.response.FileSource
15+
import server.response.FileUUID
1316
import server.response.respondFailure
17+
import storage.FileType
1418
import storage.Storage
1519
import utils.ImageUtils
1620

@@ -23,6 +27,13 @@ object DownloadFileEndpoint : EndpointBase("/download/{uuid}") {
2327

2428
val file = Storage.find(uuid) ?: return respondFailure(Errors.FileNotFound)
2529

30+
// Add the file's UUID to the response
31+
call.response.header(HttpHeaders.FileUUID, uuid)
32+
33+
// Check if the file is an image or a track
34+
val source = if (file.parentFile == Storage.ImagesDir) FileType.IMAGE else FileType.TRACK
35+
call.response.header(HttpHeaders.FileSource, source.headerValue)
36+
2637
// Add the file's MIME type to the response
2738
withContext(Dispatchers.IO) {
2839
Files.probeContentType(file.toPath())
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package server.plugins
2+
3+
import io.ktor.http.HttpHeaders
4+
import io.ktor.http.content.EntityTagVersion
5+
import io.ktor.server.http.content.LastModifiedVersion
6+
import io.ktor.server.plugins.conditionalheaders.ConditionalHeadersConfig
7+
import java.security.MessageDigest
8+
import server.response.FileSource
9+
import server.response.FileUUID
10+
import storage.FileType
11+
import storage.HashUtils
12+
import storage.MessageDigestAlgorithm
13+
14+
fun ConditionalHeadersConfig.configure() {
15+
version { call, outgoingContent ->
16+
val fileUUID = call.response.headers[HttpHeaders.FileUUID]
17+
val fileSource = call.response.headers[HttpHeaders.FileSource]
18+
val fileType = FileType.entries.find { it.headerValue == fileSource }
19+
if (fileUUID != null && fileType != null) {
20+
val file = fileType.fetcher(fileUUID)?.takeIf { it.exists() }
21+
if (file != null) {
22+
val modificationDate = file.lastModified()
23+
val checkSumSha256 = HashUtils.getCheckSumFromFile(
24+
MessageDigest.getInstance(MessageDigestAlgorithm.SHA_256),
25+
file
26+
)
27+
return@version listOf(
28+
EntityTagVersion(checkSumSha256),
29+
LastModifiedVersion(modificationDate)
30+
)
31+
}
32+
}
33+
34+
emptyList()
35+
}
36+
}

src/main/kotlin/server/plugins/Plugins.kt

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import database.serialization.Json
44
import io.ktor.serialization.kotlinx.json.json
55
import io.ktor.server.application.Application
66
import io.ktor.server.application.install
7+
import io.ktor.server.plugins.conditionalheaders.ConditionalHeaders
78
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
89
import io.ktor.server.plugins.statuspages.StatusPages
910

@@ -16,6 +17,7 @@ import io.ktor.server.plugins.statuspages.StatusPages
1617
* @receiver The application on which this method is called.
1718
*/
1819
fun Application.installPlugins() {
20+
install(ConditionalHeaders) { configure() }
1921
install(ContentNegotiation) { json(Json) }
2022
install(StatusPages) { configureStatusPages() }
2123
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package server.response
2+
3+
import io.ktor.http.HttpHeaders
4+
import storage.FileType
5+
6+
/**
7+
* A header included in the responses of file requests, containing the UUID of the file.
8+
*/
9+
val HttpHeaders.FileUUID: String get() = "X-File-UUID"
10+
11+
/**
12+
* A header included in the responses of file requests, where the file comes from. Basically one of the following:
13+
* - `Images` ([FileType.IMAGE])
14+
* - `Tracks` ([FileType.TRACK])
15+
* @see FileType
16+
*/
17+
val HttpHeaders.FileSource: String get() = "X-File-Source"

src/main/kotlin/storage/FileType.kt

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package storage
2+
3+
import java.io.File
4+
5+
enum class FileType(val headerValue: String, val fetcher: (uuid: String) -> File?) {
6+
IMAGE("Images", Storage::imageFile),
7+
TRACK("Tracks", Storage::trackFile)
8+
}

src/main/kotlin/storage/Storage.kt

+3-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ object Storage {
1717
val ImagesDir by lazy { File(BaseDir, "images").also { if (!it.exists()) it.mkdirs() } }
1818
val TracksDir by lazy { File(BaseDir, "tracks").also { if (!it.exists()) it.mkdirs() } }
1919

20-
fun imageFile(path: String) = File(ImagesDir, path)
20+
fun imageFile(uuid: String) = ImagesDir.listFiles().find { it.name.startsWith(uuid) }
21+
22+
fun trackFile(uuid: String) = TracksDir.listFiles().find { it.name.startsWith(uuid) }
2123

2224
/**
2325
* Finds a file based on the given UUID.

src/test/kotlin/server/endpoints/files/TestFileDownloading.kt

+36-12
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ import assertions.assertSuccess
44
import database.entity.Area
55
import io.ktor.client.statement.bodyAsChannel
66
import io.ktor.client.statement.readRawBytes
7+
import io.ktor.http.HttpHeaders
8+
import io.ktor.http.etag
79
import io.ktor.http.isSuccess
10+
import io.ktor.http.lastModified
811
import io.ktor.utils.io.readBuffer
912
import java.awt.image.BufferedImage
1013
import java.io.File
14+
import java.security.MessageDigest
1115
import javax.imageio.ImageIO
1216
import kotlin.test.Test
1317
import kotlin.test.assertEquals
@@ -17,27 +21,32 @@ import kotlinx.io.copyTo
1721
import server.DataProvider
1822
import server.base.ApplicationTestBase
1923
import server.base.StubApplicationTestBuilder
24+
import server.response.FileSource
25+
import server.response.FileUUID
26+
import storage.FileType
27+
import storage.HashUtils
28+
import storage.MessageDigestAlgorithm
2029
import storage.Storage
2130

2231
class TestFileDownloading : ApplicationTestBase() {
2332
private suspend inline fun StubApplicationTestBuilder.provideImageFile(
2433
imageFile: String = "/images/alcoi.jpg",
25-
block: (imageUUID: String) -> Unit
34+
block: (imageUUID: String, imageFile: File) -> Unit
2635
) {
2736
val areaId = DataProvider.provideSampleArea(this, imageFile = imageFile)
2837

29-
var image: String? = null
38+
var imageFile: File? = null
3039

3140
get("/area/$areaId").apply {
3241
assertSuccess<Area> { data ->
3342
assertNotNull(data)
34-
image = data.image.toRelativeString(Storage.ImagesDir)
43+
imageFile = data.image
3544
}
3645
}
3746

38-
assertNotNull(image)
47+
assertNotNull(imageFile)
3948

40-
block(image)
49+
block(imageFile.toRelativeString(Storage.ImagesDir), imageFile)
4150
}
4251

4352
private fun downloadResized(
@@ -46,7 +55,7 @@ class TestFileDownloading : ApplicationTestBase() {
4655
fetch: (BufferedImage) -> Int,
4756
imageFile: String = "/images/alcoi.jpg"
4857
) = test {
49-
provideImageFile(imageFile) { image ->
58+
provideImageFile(imageFile) { image, _ ->
5059
val tempFile = File.createTempFile("eaic", null)
5160
val response = get("/download/$image?$argument=$value")
5261
assertTrue(
@@ -69,14 +78,29 @@ class TestFileDownloading : ApplicationTestBase() {
6978

7079
@Test
7180
fun `test downloading files`() = test {
72-
provideImageFile { image ->
81+
provideImageFile { image, imageFile ->
7382
get("/download/$image").apply {
74-
headers["Content-Type"].let { contentType ->
75-
assertEquals(
76-
"image/jpeg",
77-
contentType,
78-
"Content-Type header is not JPEG. Got: $contentType"
83+
headers[HttpHeaders.ContentType].let { contentType ->
84+
assertEquals("image/jpeg", contentType, "Content-Type header is not JPEG. Got: $contentType")
85+
}
86+
assertEquals(image, headers[HttpHeaders.FileUUID], "File UUID header is not correct")
87+
assertEquals(
88+
FileType.IMAGE.headerValue,
89+
headers[HttpHeaders.FileSource],
90+
"File source header is not correct."
91+
)
92+
etag().let {
93+
val hash = HashUtils.getCheckSumFromFile(
94+
MessageDigest.getInstance(MessageDigestAlgorithm.SHA_256),
95+
imageFile
7996
)
97+
assertEquals("\"$hash\"", it, "ETag header is not correct")
98+
}
99+
lastModified()?.time.let {
100+
// Value may be truncated to seconds and converted again to ms, so we need to truncate it
101+
val fileLastModified = imageFile.lastModified() / 1000 * 1000
102+
val headerLastModified = it?.div(1000)?.times(1000)
103+
assertEquals(fileLastModified, headerLastModified, "Last-Modified header is not correct")
80104
}
81105
readRawBytes()
82106
}

0 commit comments

Comments
 (0)