iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Software Development

Kotlin 手刻 Ktor 從零開始系列 第 24

Kotlin 手刻 Ktor 從零開始 Day 24 Static File Serving,靜態檔案服務

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260822/20121948NIqCtgaIUJ.png

API 框架常常需要順便提供靜態檔案,Swagger UI、前端打包後的 assets、上傳檔案的下載,這篇要在 Relix 加上靜態檔案路由

目標 API

希望註冊一個靜態目錄只要一行

app.routing {
    get("/") { ok("Hello!") }
    staticFiles("/public", "static")
}

之後 GET /public/index.html 就會吐出 static/index.html 的內容,GET /public/css/site.css 吐出 static/css/site.css

一行的背後有四件事要處理,URL 怎麼對到相對路徑、相對路徑怎麼安全地變成檔案、檔案怎麼決定 Content-Type、沒改過的檔案怎麼不用再傳一次

靜態檔案分成五個部分

在動手之前先看一次全貌,這五個部分按照後面實作的順序是

  • catch-all 路由,讓 /public/{filePath...} 這種樣板吃得下 prefix 後面剩下的全部 segment
  • guessContentType(),副檔名查表決定 MIME type
  • resolveStaticFile(),把相對路徑變成檔案,擋掉 path traversal 與 symlink escape
  • staticFileResponse(),ETag 比對決定回 200 還是 304
  • staticFiles()serveStaticFile(),把上面四個部分接到路由上

前面說的四件事對到的是前四個部分,第五個部分不做新的事,它只負責把那四個部分接起來

先補路由這個前提,再由內而外做檔案這條路

  • 第一輪動的是 Router,也就是第 08、09 篇一路長出來的那個 class,檔案是 Router.kt,只改 matchPath 跟它旁邊的兩個小工具
  • 中間三輪跟最後的整合全部寫在新的 StaticFiles.kt
  • 測試五輪各一個檔案,CatchAllRouteTest.ktContentTypeTest.ktStaticPathTest.ktStaticFileResponseTest.ktStaticFilesTest.kt

TDD 先確認 catch-all 路由的匹配規則

第 09 篇的 {id} 只吃一段,/users/123/posts/users/{id} 是不匹配的,因為段數對不上。靜態檔案要的剛好相反,/public/css/site/style.css 這種深度不固定的路徑都得落到同一條 route 上

這輪只驗 Router,不用起 server、也還沒有任何檔案,測試檔案放 CatchAllRouteTest.kt

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs

class CatchAllRouteTest {

    private fun router(): Router {
        val router = Router()
        router.add(Route("GET", "/public/{filePath...}", handler = { ok("static") }))
        return router
    }

    @Test
    fun `catch-all captures a single segment`() {
        val result = router().match("GET", "/public/index.html")

        assertIs<MatchResult.Matched>(result)
        assertEquals("index.html", result.pathParams["filePath"])
    }

    @Test
    fun `catch-all joins the remaining segments with a slash`() {
        val result = router().match("GET", "/public/css/site/style.css")

        assertIs<MatchResult.Matched>(result)
        assertEquals("css/site/style.css", result.pathParams["filePath"])
    }

    @Test
    fun `catch-all needs at least one segment`() {
        val result = router().match("GET", "/public")

        assertIs<MatchResult.NotFound>(result)
    }

    @Test
    fun `the fixed prefix still has to match`() {
        val result = router().match("GET", "/private/index.html")

        assertIs<MatchResult.NotFound>(result)
    }

    @Test
    fun `a static route wins over the catch-all`() {
        val router = Router()
        val catchAll: RelixHandler = { ok("static file") }
        val health: RelixHandler = { ok("ok") }
        router.add(Route("GET", "/public/{filePath...}", handler = catchAll))
        router.add(Route("GET", "/public/health", handler = health))

        val result = router.match("GET", "/public/health")

        assertIs<MatchResult.Matched>(result)
        assertEquals(health, result.route.handler)
    }

    @Test
    fun `a catch-all in the middle swallows the rest of the template`() {
        val router = Router()
        router.add(Route("GET", "/public/{filePath...}/meta", handler = { ok("static") }))

        val result = router.match("GET", "/public/a/b")

        assertIs<MatchResult.Matched>(result)
        assertEquals("a/b", result.pathParams["filePath"])
    }

    @Test
    fun `a single segment parameter still matches only one segment`() {
        val router = Router()
        router.add(Route("GET", "/users/{id}", handler = { ok("user") }))

        assertIs<MatchResult.NotFound>(router.match("GET", "/users/1/posts"))
    }
}

七個測試,前兩個是重點,一段跟三段都要進得來,而且三段要用 / 接回一個字串,因為後面要拿它當相對路徑

catch-all needs at least one segment 驗證的是 /public 本身不匹配,catch-all 至少要吃到一段,這條規則決定了「目錄本身」這個請求根本走不到檔案那個部分

a static route wins over the catch-all 是回頭確認第 09 篇的靜態優先還在,/public/health 這種真的寫成 route 的路徑不會被 catch-all 攔胡

a catch-all in the middle swallows the rest of the template 記錄的是一個限制,catch-all 只有寫在樣板最後一段才有意義,寫在中間的話它後面那些段會被無聲忽略,/public/{filePath...}/meta 實際上等於 /public/{filePath...},實作不擋這種寫法,那就用測試把它寫下來,免得有人以為框架會幫他報錯,最後一個測試則是確認 {id} 的行為沒有被這次的改動波及

Route 時把 handler 寫成具名參數,理由是第 23 篇加了 requiresAuth 之後就講過的那件事,位置參數會把呼叫端綁在欄位順序上

實作 catch-all,Router 補上 {name...}

樣板的最後一段可以寫成 {name...},匹配時把剩下的 segments 用 / 接回字串

改的是 Router.kt,下面這幾個函式跟第 09 篇一樣,都是 Router 的 private 成員,位置在 class 裡面,isCatchAll() 是新加的,paramName() 是改寫舊版

// 改寫舊版
private fun isParam(segment: String): Boolean =
    segment.startsWith("{") && segment.endsWith("}")

// 改寫舊版
private fun paramName(segment: String): String {
    val name = segment.substring(1, segment.length - 1)
    return if (name.endsWith("...")) name.dropLast(3) else name
}

// 新增的
private fun isCatchAll(segment: String): Boolean =
    segment.startsWith("{") && segment.endsWith("...}")

isCatchAll() 的條件比 isParam() 嚴,{filePath...} 兩個都成立,所以順序很重要,比對時要先問是不是 catch-all

然後是 matchPath(),一樣是 Router 的 private 成員,直接改寫第 09 篇那一版,舊版第一行就先擋段數,現在不能擋了,catch-all 本來就是段數不一樣


// 改寫舊版
private fun matchPath(
    templateSegments: List<String>,
    pathSegments: List<String>,
): Map<String, String>? {
    val params = mutableMapOf<String, String>()

    for (i in templateSegments.indices) {
        val template = templateSegments[i]

        if (isCatchAll(template)) {
            if (i >= pathSegments.size) {
                return null
            }
            params[paramName(template)] = pathSegments
                .subList(i, pathSegments.size)
                .joinToString("/")
            return params
        }

        if (i >= pathSegments.size) {
            return null
        }
        val actual = pathSegments[i]

        when {
            isParam(template) -> {
                if (actual.isEmpty()) return null
                params[paramName(template)] = actual
            }
            template == actual -> {}
            else -> return null
        }
    }

    if (templateSegments.size != pathSegments.size) {
        return null
    }
    return params
}

這四個函式都要留在 Router class 裡面,把第 09 篇的同名版本改掉。要是照著貼到檔案最外層變成 top-level 的 private 函式,編譯過得去,但 class 裡舊的 matchPath() 還在跑,新的那個沒有人呼叫,catch-all 的測試會全部停在第 09 篇的行為

段數檢查從開頭搬到結尾,中間補上 i >= pathSegments.size 的逐段檢查,這兩件事合起來的效果跟第 09 篇一樣,一般路由還是段數不符就不匹配,差別只在 catch-all 那個分支會在檢查跑到之前就 return

if (i >= pathSegments.size) return null 這行在 catch-all 分支裡就是 /public 不匹配的原因,template 有兩段、path 只有一段,subList 拿不到東西

catch-all 分支最後直接 return params,樣板後面還有什麼都不再看,這就是上一節那個「寫在中間會被忽略」的測試在記錄的行為

Router.match() 呼叫 matchPath() 的地方不用動,isStatic 的判斷也不用動,第 09 篇那條算式是 params.isEmpty() && templateSegments == pathSegments,catch-all 匹配成功一定會塞進一個參數,params 非空,isStatic 自然就是 false,靜態優先的規則還是成立

這個部分不做 percent-decoding,跟第 09 篇一致,RelixRequest.path 來自第 04 篇的 URI.path,Router 拿到的已經是解碼過的值,這裡再解一次,%25 這種內容會被重複展開

要注意的是這件事在 TestKit 裡不成立,TestKit 是直接拿字串當 path,中間沒有 URI 這一關。所以同一個 %2e%2e,走 server 的時候 Router 看到的是 ..,走測試的時候 Router 看到的是 %2e%2e 這五個字元,後面 traversal 那一輪的測試會再回來講一次這個不對稱

TDD 先確認 guessContentType() 的查表規則

路由通了,接著往裡面走,裡面那個部分是一個純函式,收檔名回 MIME type,不需要檔案存在、不需要 server,測試檔案放 ContentTypeTest.kt

import kotlin.test.Test
import kotlin.test.assertEquals

class ContentTypeTest {

    @Test
    fun `html carries the utf-8 charset`() {
        assertEquals("text/html; charset=utf-8", guessContentType("index.html"))
    }

    @Test
    fun `js and css each get their own type`() {
        assertEquals("text/javascript; charset=utf-8", guessContentType("app.js"))
        assertEquals("text/css; charset=utf-8", guessContentType("style.css"))
    }

    @Test
    fun `binary types carry no charset`() {
        assertEquals("image/png", guessContentType("logo.png"))
        assertEquals("font/woff2", guessContentType("inter.woff2"))
    }

    @Test
    fun `extension lookup is case insensitive`() {
        assertEquals("image/jpeg", guessContentType("PHOTO.JPG"))
    }

    @Test
    fun `only the last extension counts`() {
        assertEquals("text/javascript; charset=utf-8", guessContentType("app.min.js"))
    }

    @Test
    fun `an unknown extension falls back to octet-stream`() {
        assertEquals("application/octet-stream", guessContentType("archive.7z"))
    }

    @Test
    fun `a file without extension falls back to octet-stream`() {
        assertEquals("application/octet-stream", guessContentType("LICENSE"))
    }

    @Test
    fun `a dotfile is not treated as an extension`() {
        assertEquals("application/octet-stream", guessContentType(".gitignore"))
    }
}

八個測試裡面有三個是邊界,app.min.js 有兩個點,要吃最後那個,LICENSE 一個點都沒有,.gitignore 的點在最前面,這三種都得有明確答案,不能丟例外

文字類型帶 charset=utf-8、二進位類型不帶,這件事也用測試定住,binary types carry no charset 就是在講這個分界

實作 guessContentType() 與 MIME 對照表

從這裡開始都是新檔案 StaticFiles.kt

private val mimeTypes = mapOf(
    "html" to "text/html; charset=utf-8",
    "css" to "text/css; charset=utf-8",
    "js" to "text/javascript; charset=utf-8",
    "json" to "application/json; charset=utf-8",
    "png" to "image/png",
    "jpg" to "image/jpeg",
    "jpeg" to "image/jpeg",
    "gif" to "image/gif",
    "svg" to "image/svg+xml",
    "ico" to "image/x-icon",
    "txt" to "text/plain; charset=utf-8",
    "xml" to "application/xml; charset=utf-8",
    "pdf" to "application/pdf",
    "woff" to "font/woff",
    "woff2" to "font/woff2",
    "ttf" to "font/ttf",
)

fun guessContentType(fileName: String): String {
    val ext = fileName.substringAfterLast('.', "").lowercase()
    return mimeTypes[ext] ?: "application/octet-stream"
}

substringAfterLast('.', "") 一行就把三個邊界都處理掉了,有多個點就吃最後一段,app.min.js 拿到 "js",完全沒有點就回第二個參數給的空字串,.gitignore 則拿到 "gitignore",這兩個都查不到,就一律 fallback

為什麼不用 Files.probeContentType(path) ? 因為它依賴作業系統的 MIME type 資料庫,行為在 Linux、macOS、Windows 上不一致,有些環境 (Docker alpine image) 甚至沒有 MIME type 資料庫,probeContentType 回 null

自己維護一個 map,行為可預測,測試可重複,不認識的副檔名 fallback 成 application/octet-stream,至少不會讓瀏覽器嘗試把二進位檔當 HTML 執行

文字類型加 charset=utf-8 是好習慣,不加的話瀏覽器可能用 Latin-1 解碼,中文就會變亂碼

TDD 先確認路徑解析擋得住 traversal

這輪要解決的是「拿到 css/site/style.css 這種相對路徑之後,到底能不能給檔案」,安全防護全部在這個部分

這個部分一樣不用起 server,但需要真的有檔案,測試自己開一個暫存目錄,測試檔案放 StaticPathTest.kt

import java.nio.file.Files
import java.nio.file.Path
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull

// createTempDirectory 給的路徑在 macOS 上本身就是 symlink,先 toRealPath() 對齊
val projectRoot: Path = Files.createTempDirectory("relix-static").toRealPath().apply {
    resolve("secrets.txt").toFile().writeText("top secret")
}

val staticRoot: Path = projectRoot.resolve("public").apply {
    toFile().mkdirs()
    resolve("index.html").toFile().writeText("<h1>Hello</h1>")
    resolve("app.js").toFile().writeText("console.log('hi')")
    resolve("sub").toFile().mkdirs()
    resolve("sub/nested.txt").toFile().writeText("nested file")
}

class StaticPathTest {

    @Test
    fun `an existing file resolves to itself`() {
        val file = resolveStaticFile(staticRoot, "index.html")

        assertNotNull(file)
        assertEquals("<h1>Hello</h1>", file.readText())
    }

    @Test
    fun `a nested file resolves through its sub directory`() {
        val file = resolveStaticFile(staticRoot, "sub/nested.txt")

        assertNotNull(file)
        assertEquals("nested file", file.readText())
    }

    @Test
    fun `a missing file resolves to null`() {
        assertNull(resolveStaticFile(staticRoot, "nonexistent.txt"))
    }

    @Test
    fun `a directory resolves to null`() {
        assertNull(resolveStaticFile(staticRoot, "sub"))
    }

    @Test
    fun `dot dot escaping the base dir resolves to null`() {
        assertNull(resolveStaticFile(staticRoot, "../secrets.txt"))
    }

    @Test
    fun `dot dot staying inside the base dir is allowed`() {
        val file = resolveStaticFile(staticRoot, "sub/../index.html")

        assertNotNull(file)
        assertEquals("<h1>Hello</h1>", file.readText())
    }

    @Test
    fun `a percent encoded dot dot is not decoded here`() {
        assertNull(resolveStaticFile(staticRoot, "%2e%2e/secrets.txt"))
    }

    @Test
    fun `a symlink pointing outside the base dir resolves to null`() {
        val link = staticRoot.resolve("leak.txt")
        Files.deleteIfExists(link)
        Files.createSymbolicLink(link, projectRoot.resolve("secrets.txt"))

        assertNull(resolveStaticFile(staticRoot, "leak.txt"))
    }
}

projectRoot 底下擺一個 secrets.txtstaticRoot 是它下面的 public 目錄,也就是要對外開放的那一層,攻擊的目標就是那個 secrets.txt,它存在、讀得到,只是在 baseDir 外面

resolveStaticFile()File?null 就是「不給」,測試才不用去猜是 404 還是 403,那是外面那個部分的事

八個測試裡面,dot dot escaping the base dirdot dot staying inside the base dir 是一組對照,同樣有 ..,一個要擋一個要放行,這條線畫在「正規化之後還在不在 baseDir 底下」,不是「有沒有出現 .. 這兩個字」

a symlink pointing outside the base dir 是第二道防線,leak.txt 這個名字完全正常、路徑也完全在 baseDir 底下,字面上檢查不出問題,要跟著 symlink 走到底才看得到它指向外面

a percent encoded dot dot is not decoded here 定住的是這個部分不做解碼,%2e%2e 就是一個叫做 %2e%2e 的目錄名,查無此檔。真正把它變回 .. 的是 adapter 那邊的 URI.path,攔它的是上面那個 .. 的規則

這三個測試合起來才是完整的防護,少任何一個都有洞

projectRootstaticRoot 宣告在 class 外面,後面兩輪同一個 package 底下都拿得到,不用再寫一次

實作 resolveStaticFile()

相關的實作補進 StaticFiles.kt

import java.io.File
import java.nio.file.Files
import java.nio.file.Path

fun resolveStaticFile(baseDir: Path, relativePath: String): File? {
    val resolved = baseDir.resolve(relativePath).normalize()

    if (!resolved.startsWith(baseDir)) {
        return null
    }

    if (!Files.isRegularFile(resolved)) {
        return null
    }

    val realPath = resolved.toRealPath()
    if (!realPath.startsWith(baseDir)) {
        return null
    }

    return realPath.toFile()
}

函式裡四段,每一段對應上一節的一組測試

normalize() 折疊 ..startsWith() 確認結果仍在 baseDir 底下,這一對是第一道防線

val baseDir = Path.of("/app/static").toRealPath()
// baseDir = /app/static

val resolved = baseDir.resolve("../../../etc/passwd").normalize()
// resolved = /etc/passwd

resolved.startsWith(baseDir)
// false → 擋掉

Files.isRegularFile() 一次處理掉「檔案不存在」跟「這是個目錄」兩種情況,兩者都回 null,外面看到的都是 404

toRealPath() 是第二道防線,它會跟著 symlink 走到真正的目標,再檢查一次,少了這一步,只要有人在 static/ 底下放一個指向 /etc/passwd 的 symlink,前面三步全部會通過,因為字面上的路徑確實在 baseDir 裡面

兩道防線防的是不同的東西,第一道防字面上的路徑,第二道防檔案系統本身的轉向

TDD 先確認 ETag 與 304 的行為

檔案拿到了,這輪決定它要怎麼變成回應,行為分兩種,client 沒帶 If-None-Match 就回 200 加內容,帶了而且對得上就回 304 不帶 body

這輪的兩個函式都不碰 HTTP request,ETag 是純函式、回應組裝收一個字串參數,所以還是不用起 server,測試檔案放 StaticFileResponseTest.kt,檔案沿用上一輪的 staticRoot

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue

class StaticFileResponseTest {

    private val indexHtml = staticRoot.resolve("index.html").toFile()
    private val appJs = staticRoot.resolve("app.js").toFile()

    @Test
    fun `etag is built from last modified and size`() {
        assertEquals(
            "\"${indexHtml.lastModified()}-${indexHtml.length()}\"",
            fileETag(indexHtml),
        )
    }

    @Test
    fun `two different files get two different etags`() {
        assertNotEquals(fileETag(indexHtml), fileETag(appJs))
    }

    @Test
    fun `no if-none-match returns 200 with the file content`() {
        val response = staticFileResponse(indexHtml, null)

        assertEquals(200, response.statusCode)
        assertEquals("<h1>Hello</h1>", response.body.toString(Charsets.UTF_8))
    }

    @Test
    fun `200 carries the content type from the extension`() {
        val response = staticFileResponse(appJs, null)

        assertEquals(
            "text/javascript; charset=utf-8",
            response.headers["Content-Type"]?.firstOrNull(),
        )
    }

    @Test
    fun `200 carries the etag so the client can send it back`() {
        val response = staticFileResponse(indexHtml, null)

        assertEquals(fileETag(indexHtml), response.headers["ETag"]?.firstOrNull())
    }

    @Test
    fun `a matching if-none-match returns 304 without a body`() {
        val response = staticFileResponse(indexHtml, fileETag(indexHtml))

        assertEquals(304, response.statusCode)
        assertTrue(response.body.isEmpty())
    }

    @Test
    fun `304 still carries the etag`() {
        val response = staticFileResponse(indexHtml, fileETag(indexHtml))

        assertEquals(fileETag(indexHtml), response.headers["ETag"]?.firstOrNull())
    }

    @Test
    fun `a stale if-none-match returns 200 again`() {
        val response = staticFileResponse(indexHtml, "\"0-0\"")

        assertEquals(200, response.statusCode)
        assertEquals("<h1>Hello</h1>", response.body.toString(Charsets.UTF_8))
    }
}

前兩個測試是 ETag 本身,格式寫死成 "$lastModified-$size",兩個不同檔案要拿到不同的值

中間三個是 200 那條路,Content-Type 由第二輪那個 guessContentType() 決定,ETag 要跟著回應一起出去,不然 client 下次沒東西可以帶

後面三個是 304 那條路,帶對的值回 304 而且 body 是空的,帶錯的值回 200 加完整內容,a stale if-none-match returns 200 again 這個測試看起來很無聊,但它定住的是「比對是相等,不是有沒有帶」,少了它,一個寫成 if (ifNoneMatch != null) 的實作也會讓其他測試通過,然後每個 client 第二次要檔案都拿到 304,內容永遠更新不了

304 still carries the etag 也是同一種保險,304 沒有 body,唯一還能帶資訊的地方就是 header

實作 fileETag()staticFileResponse()

補進 StaticFiles.kt

fun fileETag(file: File): String = "\"${file.lastModified()}-${file.length()}\""

fun staticFileResponse(file: File, ifNoneMatch: String?): RelixResponse {
    val etag = fileETag(file)
    if (ifNoneMatch == etag) {
        return RelixResponse(304, mapOf("ETag" to listOf(etag)), ByteArray(0))
    }

    return RelixResponse(
        200,
        mapOf(
            "Content-Type" to listOf(guessContentType(file.name)),
            "ETag" to listOf(etag),
        ),
        file.readBytes(),
    )
}

ifNoneMatch == etag 這個判斷同時處理了 null 跟不相等兩種情況,兩者都往下走到 200

staticFileResponse() 收的是一個 String? 而不是整個 RelixCall,這件事讓上一節那八個測試都不用組 request 就測得到。要從 request 把這個字串挖出來,是下一輪的事

ETag 的流程長這樣

第一次請求
Client: GET /public/app.js
Server: 200 OK
        ETag: "1702345678000-2048"
        Body: (檔案內容)

第二次請求
Client: GET /public/app.js
        If-None-Match: "1702345678000-2048"
Server: 304 Not Modified
        (沒有 body)

為了簡單,這裡的 ETag 用 "$lastModified-$size",它不是內容 hash,這個選擇有一定的代價

要注意 lastModified 在大多數作業系統上只精確到秒 (HFS+/APFS 較新版本到奈秒,ext4 看 mount option,FAT32 只有 2 秒),如果同一個檔案在同一秒內被改兩次而 size 又恰好不變 (例如修一個字元),ETag 會看不出差異,client 會繼續用舊版的 cache。生產環境通常改用內容 hash (SHA-1 / xxHash),代價是每次回應前要把檔案讀過一遍 (或用啟動時 hash 加上檔案監看),這裡接受這個 race window,因為部署期靜態資源的修改頻率低,加上 build 工具一般會把 hash 直接寫進檔名 (app.a3f9.js),路徑變了 ETag 自然就變

304 回應不帶 body,只帶 ETag header,client 繼續用 cache 裡的版本。大檔案 (圖片、字型) 使用這種方式,能省下不少頻寬

TDD 把 staticFiles() 接到 HTTP 上

前四輪都沒起過 server,staticFiles() 還不存在,路由也還沒註冊,這裡要測的正是這段,DSL 有沒有真的把 route 加進去、catch-all 有沒有接到檔案那個部分、If-None-Match 有沒有從 request 挖對,這條路徑要完整走一遍才知道,測試檔案放 StaticFilesTest.kt

import java.nio.file.NoSuchFileException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue

class StaticFilesTest {

    private fun createApp(): RelixApplication {
        val app = RelixApplication()
        app.install(ErrorHandling)
        app.routing {
            staticFiles("/public", staticRoot.toString())
        }
        return app
    }

    @Test
    fun `serves an existing file`() {
        val testKit = RelixTestKit(createApp())
        val response = testKit.handleRequest("GET", "/public/index.html")

        assertEquals(200, response.statusCode)
        assertEquals("<h1>Hello</h1>", response.body.toString(Charsets.UTF_8))
        assertEquals(
            "text/html; charset=utf-8",
            response.headers["Content-Type"]?.firstOrNull(),
        )
    }

    @Test
    fun `serves a nested file through the catch-all`() {
        val testKit = RelixTestKit(createApp())
        val response = testKit.handleRequest("GET", "/public/sub/nested.txt")

        assertEquals(200, response.statusCode)
        assertEquals("nested file", response.body.toString(Charsets.UTF_8))
    }

    @Test
    fun `returns 404 for a missing file`() {
        val testKit = RelixTestKit(createApp())
        val response = testKit.handleRequest("GET", "/public/nonexistent.txt")

        assertEquals(404, response.statusCode)
    }

    @Test
    fun `returns 404 for a path traversal attempt`() {
        val testKit = RelixTestKit(createApp())
        val response = testKit.handleRequest("GET", "/public/../secrets.txt")

        assertEquals(404, response.statusCode)
    }

    @Test
    fun `the etag round trip ends in 304`() {
        val testKit = RelixTestKit(createApp())

        val first = testKit.handleRequest("GET", "/public/index.html")
        val etag = first.headers["ETag"]?.firstOrNull()
        assertTrue(etag != null)

        val second = testKit.handleRequest(
            method = "GET",
            path = "/public/index.html",
            headers = mapOf("If-None-Match" to listOf(etag)),
        )

        assertEquals(304, second.statusCode)
        assertTrue(second.body.isEmpty())
    }

    @Test
    fun `a missing base dir fails at registration`() {
        val app = RelixApplication()

        assertFailsWith<NoSuchFileException> {
            app.routing {
                staticFiles("/public", "no-such-dir")
            }
        }
    }

    @Test
    fun `a normal route still works next to staticFiles`() {
        val app = RelixApplication()
        app.install(ErrorHandling)
        app.routing {
            get("/") { ok("Hello!") }
            staticFiles("/public", staticRoot.toString())
        }

        assertEquals(200, RelixTestKit(app).handleRequest("GET", "/").statusCode)
    }
}

七個整合測試,前兩個確認一般檔案跟巢狀檔案都出得來,後面兩個確認擋掉的東西真的變成 404

returns 404 for a path traversal attempt 這個測試要留意它擋住的理由,TestKit 沒有 URI 那一關,所以 /public/../secrets.txt 裡的 .. 是字面上的兩個點,一路傳到 resolveStaticFile() 才被 normalize()startsWith() 擋掉,要是攻擊者送的是 %2e%2e,這個測試裡它只會變成一個查無此檔的目錄名,走真的 server 才會先被 URI.path 解回 ..,再被同一條規則擋掉,兩種輸入最後都是 404,但只有實際打一次才驗得到後面那條路,這也是 main 那節要用 curl 再跑一遍的原因

the etag round trip ends in 304 是別的輪次代替不了的一個,前面 staticFileResponse() 那輪的 304 是自己把 ETag 傳進參數,這裡是真的先發一次請求、把回應的 header 拿出來、再當成 request header 送回去,中間多了「從 request 挖 If-None-Match」這一段

a missing base dir fails at registration 定住的是啟動就爆掉這個行為,目錄不存在時例外發生在註冊路由的當下,不是等到第一個請求進來才安靜地回 404

a normal route still works next to staticFiles 是回頭確認 catch-all 沒有把整個 Router 弄壞,/ 這條一般路由還是走得到

createApp() 裡那行 install(ErrorHandling) 是慣例,pathParam() 拿不到參數是用 throw 表達的,讀檔也可能丟 IO 例外,路徑上只要有 throw 就要有人接,不然 exception 會穿過整條 pipeline 跑出來,測試拿到的不是 status code,是直接失敗,第 21、22 篇都踩過同一個坑

實作 serveStaticFile()staticFiles()

最後一段也是補進 StaticFiles.kt

fun RelixCall.serveStaticFile(baseDir: Path, relativePath: String): RelixResponse {
    val file = resolveStaticFile(baseDir, relativePath)
        ?: return RelixResponse(404, mapOf(), "Not Found".toByteArray())

    return staticFileResponse(file, request.header("If-None-Match"))
}

fun RoutingBuilder.staticFiles(urlPrefix: String, baseDir: String) {
    val basePath = Path.of(baseDir).toRealPath()

    get("$urlPrefix/{filePath...}") {
        serveStaticFile(basePath, pathParam("filePath"))
    }
}

serveStaticFile() 只做兩件事,把 null 翻成 404、把 request header 挖出來交給 staticFileResponse(),真正的邏輯都在前面,這裡就是整合而已

request.header("If-None-Match") 一定要用第 17 篇那個 helper,不能寫成 request.headers["If-None-Match"],JDK HttpServer 會把收到的 header 名稱正規化成 If-none-match,只有開頭一個大寫字母,直接用 map 查會查不到,然後 304 永遠不會發生,這個坑在 TestKit 裡完全看不出來,因為測試是自己組 map、自己決定 key 怎麼寫,只有真的起 server 打一次才會現形

staticFiles() 在註冊時就把 baseDir 解析成真實絕對路徑,之後每個請求都拿同一個 basePath 去比對,這裡用 toRealPath() 而不是 toAbsolutePath(),一方面 resolveStaticFile() 第四步比的就是真實路徑,兩邊得用同一種座標,另一方面目錄不存在時 toRealPath() 會丟 NoSuchFileException,上一節那個測試就是在定住這件事

{filePath...} 就是第一輪加的 catch-all 參數,urlPrefix 後面的部分整段變成相對路徑

在 main 裡組起來跑一次

先準備兩個檔案,前面測試用的是暫存目錄,真的要跑就在專案底下開一個 static/

mkdir -p static
echo '<h1>Relix</h1>' > static/index.html
echo 'console.log("hi")' > static/app.js

staticFiles() 跟一般路由註冊在同一個 routing { } 裡,第 23 篇的 main 也有一條 /public,那是拿來當不需認證的公開路由,這次它的意思是靜態目錄的前綴

fun main() {
    val app = RelixApplication()

    app.install(Logging) {
        logger = ConsoleLogger()
    }

    app.routing {
        get("/") { ok("Hello!") }
        staticFiles("/public", "static")
    }

    JdkHttpServerAdapter(app).start(8080)
}

拿一個 HTML 檔

curl -i localhost:8080/public/index.html
HTTP/1.1 200 OK
Content-type: text/html; charset=utf-8
Etag: "1787209727770-15"
Content-length: 15

<h1>Relix</h1>

Content-typeguessContentType() 從副檔名查表查出來的,不是問作業系統的,換成 /public/app.js 就會看到 text/javascript; charset=utf-8

header 名稱印出來是 Etag 不是 ETag,程式裡明明寫的是 ETag,這是 JDK HttpServer 的 Headers 在做的事,它會把 header 名稱正規化成開頭一個大寫、其餘小寫,request 那邊也是同一套規則,前面 request.header() 那個坑就是這樣來的,HTTP header 的名稱本來就是大小寫不敏感的,client 不會因此看不懂

帶著 ETag 再要一次

把上一個回應吐出來的 ETag 原封不動放進 If-None-Match

curl -i -H 'If-None-Match: "1787209727770-15"' localhost:8080/public/index.html
HTTP/1.1 304 Not Modified
Etag: "1787209727770-15"

304 完全沒有 body,檔案內容一個 byte 都沒有再傳一次,這是 ETag 唯一真正有價值的地方,而它在單元測試裡看起來只是「回傳的 statusCode 是 304」,實際打一次才感覺得到差別,ETag 的值每台機器都不一樣,因為它是 "$lastModified-$size" 組出來的,要換成你自己拿到的那個

檔案不存在

curl -i localhost:8080/public/nope.txt
HTTP/1.1 404 Not Found
Content-length: 9

Not Found

這個 404 沒有 Content-type,因為 serveStaticFile() 回的那個 RelixResponse header map 是空的,要補也可以,只是靜態檔案這條路的 404 body 就是一行純文字,有沒有標型別差別不大

試著跳出 baseDir

curl -i --path-as-is localhost:8080/public/../../etc/passwd
HTTP/1.1 404 Not Found
Content-length: 9

Not Found

--path-as-is 這個參數是重點,curl 預設會先幫你把路徑正規化,/public/../../etc/passwd 在送出去之前就被它自己解成 /etc/passwd 了,那樣打到的是完全不同的路徑,根本驗不到防護有沒有生效,加上 --path-as-is 才會把 .. 原封不動送出去,讓 server 這邊的 normalize()startsWith(baseDir) 真的被考驗一次

回的是 404 而不是 403,這是刻意的,後面會說

log 這邊四筆

[Relix] GET /public/index.html -> 200 (4ms)
[Relix] GET /public/index.html -> 304 (0ms)
[Relix] GET /public/nope.txt -> 404 (1ms)
[Relix] GET /public/../../etc/passwd -> 404 (0ms)

最後一筆的 path 是原始的、沒有被處理過的字串,正好可以拿來查有沒有人在掃你的 server

常見陷阱與設計取捨

為什麼 path traversal 回 404 不回 403 ?

403 告訴攻擊者「這個路徑存在但你不能存取」,404 告訴攻擊者「什麼都沒有」,安全性原則,對惡意 request 洩漏的資訊越少越好

readBytes() 會不會把大檔案塞進記憶體 ?

會,而且成本要乘以併發數,100 個 client 同時下載同一個 20 MB 的檔案,瞬間就是 2 GB 壓在 heap 上,這裡只適合幾 MB 以下的靜態資源,真要服務影片或大型下載就交給 nginx / CDN,這不是應用框架要做的事

要自己扛就得做 streaming (用 InputStream 分塊寫出),這裡不做,避免引入 chunked transfer encoding 的複雜度,Range request (Range: bytes=0-1023) 跟 206 Partial Content 也是有了 streaming 才有辦法支援

為什麼不支援 index.html 自動回傳 ?

GET /public/ 要不要自動回傳 index.html 是一個設計選擇,有些 server 預設支援 (Nginx),有些不支援,這裡回 404,比較簡單明確,要加也不難,在 resolveStaticFile() 判斷是不是 regular file 那一步,先檢查 resolved.resolve("index.html") 存不存在

為什麼把 resolveStaticFile()staticFileResponse() 拆成兩個函式 ?

因為它們的失敗模式完全不同,前者回答的是「這個路徑能不能給」,答案只有給或不給,跟 HTTP 沒有關係,測試餵一個相對路徑就跑得動,後者回答的是「這個檔案要包成什麼回應」,它認識 status code 跟 header,但完全不管路徑安全

寫成一個函式當然也行,只是那個函式的測試就得同時準備檔案、準備 request、準備 header,相關路徑安全的測試每一個都要多繞一圈,拆開之後兩邊各測各的,剩下的整合才交給最後的整合測試


小結

靜態檔案路由用 terminal catch-all {filePath...} 把 URL 對到目錄,Path.normalize() 先擋 ..toRealPath() 再防止 symlink escape,MIME type 由 extension map 判斷,ETag 則用 lastModified-size 搭配 If-None-Match 實作 304 快取


下一篇

下一篇做 Configuration,讓框架行為可透過程式碼 DSL、設定檔、環境變數調整,並實作 Graceful Shutdown


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin 手刻 Ktor 從零開始 Day 23 Authentication / Authorization,認證與授權
下一篇
Kotlin 手刻 Ktor 從零開始 Day 25 Configuration,框架的設定系統
系列文
Kotlin 手刻 Ktor 從零開始26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言