方法名调研
框架
方法名
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 里其他方法确实都是单词:text、html、json、bytes、stream、sse、redirect。file 完全一致,推荐。
关于放入 util 的判断
inferContentType 和 buildContentDisposition 值得移入 util,理由:
inferContentType是纯粹的 MIME 工具,与 Response 无耦合,未来静态文件中间件、multipart 响应都可能复用buildContentDisposition同理,是 HTTP 头的构造逻辑resolveFileStream涉及文件系统和 classpath 查找,属于资源加载逻辑,也不是 Response 的核心职责
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)