方法名调研

框架

方法名

Flask (Python)

send_file

Express (Node.js)

res.sendFile

,

res.download

Javalin (Kotlin/Java)

result(inputStream)

— 无专用方法

Ktor (Kotlin)

call.respondFile

,

call.respondBytes

Gin (Go)

c.File

,

c.FileFromFS

Fiber (Go)

c.SendFile

Hono (TS)

无专用方法

Gin 的 File 最简洁,且 Response 里其他方法确实都是单词:texthtmljsonbytesstreamsseredirectfile 完全一致,推荐

关于放入 util 的判断

inferContentTypebuildContentDisposition 值得移入 util,理由:

Disposition enum 应留在顶层 API(与 Response 同包),用户需要直接引用它。

最终实现

util/http/ContentType.kt

package io.github.cymoo.colleen.util.http

import java.nio.file.Path

/**
 * Infers MIME type from a file path's extension.
 *
 * Prefers JDK built-in detection via [java.nio.file.Files.probeContentType],
 * which reads the system's mime.types database. Falls back to a curated table
 * for common web types, since probeContentType may return null in minimal
 * JRE environments (e.g. stripped container images).
 */
internal fun inferContentType(path: String): String =
    runCatching { java.nio.file.Files.probeContentType(Path.of(path)) }
        .getOrNull()
        ?.takeIf { it.isNotBlank() }
        ?: when (path.substringAfterLast('.').lowercase()) {
            "html", "htm" -> "text/html; charset=utf-8"
            "css"         -> "text/css; charset=utf-8"
            "js", "mjs"   -> "application/javascript; charset=utf-8"
            "json"        -> "application/json; charset=utf-8"
            "xml"         -> "application/xml; charset=utf-8"
            "txt"         -> "text/plain; charset=utf-8"
            "svg"         -> "image/svg+xml"
            "png"         -> "image/png"
            "jpg", "jpeg" -> "image/jpeg"
            "gif"         -> "image/gif"
            "webp"        -> "image/webp"
            "ico"         -> "image/x-icon"
            "pdf"         -> "application/pdf"
            "woff"        -> "font/woff"
            "woff2"       -> "font/woff2"
            "mp4"         -> "video/mp4"
            "webm"        -> "video/webm"
            else          -> "application/octet-stream"
        }

util/http/ContentDisposition.kt

package io.github.cymoo.colleen.util.http

import io.github.cymoo.colleen.Disposition
import java.net.URLEncoder

/**
 * Builds a `Content-Disposition` header value.
 *
 * For [Disposition.ATTACHMENT], the filename is dual-encoded per RFC 5987:
 * - `filename=` provides an ASCII fallback for legacy clients (non-ASCII replaced with `_`)
 * - `filename*=` provides the RFC 5987 encoded value for modern clients
 *
 * Example output:
 * `attachment; filename="report___.pdf"; filename*=UTF-8''report%E4%B8%AD%E6%96%87.pdf`
 */
internal fun buildContentDisposition(disposition: Disposition, filename: String): String {
    if (disposition == Disposition.INLINE) return "inline"
    val ascii = filename.replace(Regex("[^\\x20-\\x7E]"), "_")
    val encoded = URLEncoder.encode(filename, "UTF-8").replace("+", "%20")
    return """attachment; filename="$ascii"; filename*=UTF-8''$encoded"""
}

util/http/Resources.kt

package io.github.cymoo.colleen.util.http

import java.io.File
import java.io.InputStream

/**
 * Resolves a file as an [InputStream].
 *
 * Resolution strategy:
 * - If [classpathOnly] is true: classpath only, throws if not found.
 * - Otherwise: filesystem first, classpath as fallback.
 *
 * @param path         Filesystem path or classpath resource path.
 * @param classpathOnly  Skip filesystem lookup entirely.
 * @param baseDir      If provided, the resolved file must reside within this directory.
 *                     Prevents path traversal. Only applies to filesystem resolution.
 * @throws IllegalArgumentException if [baseDir] is provided and the path escapes it.
 * @throws IllegalStateException if the resource cannot be found.
 */
internal fun resolveFileStream(
    path: String,
    classpathOnly: Boolean,
    baseDir: String?,
): InputStream {
    if (classpathOnly) {
        return classpathStream(path)
            ?: error("Classpath resource not found: $path")
    }

    val file = File(path)
    if (file.exists() && file.isFile) {
        if (baseDir != null) {
            val target = file.canonicalFile
            val base = File(baseDir).canonicalFile
            require(target.startsWith(base)) {
                "Path traversal detected: '$path' escapes base directory '$baseDir'"
            }
        }
        return file.inputStream()
    }

    return classpathStream(path)
        ?: error("File not found on filesystem or classpath: $path")
}

private fun classpathStream(path: String): InputStream? =
    Thread.currentThread().contextClassLoader.getResourceAsStream(path)

Response.kt 新增部分

import io.github.cymoo.colleen.util.http.buildContentDisposition
import io.github.cymoo.colleen.util.http.inferContentType
import io.github.cymoo.colleen.util.http.resolveFileStream

/**
 * Send a file as the response body.
 *
 * Resolution order (when [classpath] is false):
 * 1. Filesystem — absolute or relative to the working directory
 * 2. Classpath  — fallback for packaged resources
 *
 * Set [classpath] to true to skip filesystem lookup entirely,
 * which is appropriate for resources bundled in the application JAR.
 *
 * @param path        Filesystem path or classpath resource path, e.g. `"static/index.html"`
 * @param classpath   Resolve from classpath only; skips filesystem lookup
 * @param baseDir     Restrict filesystem access to this directory; prevents path traversal.
 *                    Strongly recommended when [path] originates from user input.
 * @param disposition [Disposition.INLINE] renders in browser; [Disposition.ATTACHMENT] triggers download
 * @param filename    Filename sent to the client for downloads (defaults to the last path segment).
 *                    Supports Unicode — encoded per RFC 5987.
 * @param contentType MIME type; inferred from file extension if null
 */
@JvmOverloads
fun file(
    path: String,
    classpath: Boolean = false,
    baseDir: String? = null,
    disposition: Disposition = Disposition.INLINE,
    filename: String? = null,
    contentType: String? = null,
) {
    headers["Content-Type"] = contentType ?: inferContentType(path)
    headers["Content-Disposition"] = buildContentDisposition(
        disposition,
        filename ?: path.substringAfterLast('/')
    )
    body = ResponseBody.Stream(resolveFileStream(path, classpath, baseDir))
}

Disposition.kt(顶层,与 Response 同包)

package io.github.cymoo.colleen

enum class Disposition { INLINE, ATTACHMENT }

典型用法一览

// Serve a bundled SPA entry point
ctx.response.file("static/index.html", classpath = true)

// Serve a filesystem file, locked to a safe directory
ctx.response.file(filename, baseDir = "/var/uploads")

// Trigger a file download with a custom name
ctx.response.file("/data/export.csv", disposition = Disposition.ATTACHMENT, filename = "users.csv")

// Unicode filename download
ctx.response.file("/data/报告.pdf", disposition = Disposition.ATTACHMENT)