Most APIs need to handle files. Profile pictures, document uploads, image galleries — file handling is a common requirement.
In this tutorial, you will learn how to serve static files, handle file uploads via multipart form data, validate uploads, and protect against common security issues.
Serving Static Files
Ktor can serve static files from your resources directory or from the filesystem.
From Resources
Put files in src/main/resources/static/:
src/main/resources/static/
├── index.html
├── style.css
└── logo.png
Then configure the route:
routing {
staticResources("/static", "static")
}
Now http://localhost:8080/static/index.html serves your file. Ktor sets the correct Content-Type header based on the file extension.
From the Filesystem
To serve files from a directory on disk (useful for uploaded files):
routing {
staticFiles("/files", File("uploads"))
}
This serves files from the uploads/ directory at /files/{filename}.
Index Files
You can set a default file for directories:
staticResources("/static", "static") {
default("index.html")
}
Now /static/ serves index.html automatically.
File Uploads with Multipart Form Data
When a client uploads a file, it sends a multipart/form-data request. This is different from JSON — the request contains multiple “parts”, each with its own content type.
Basic Upload Handler
post("/api/upload") {
val multipart = call.receiveMultipart()
multipart.forEachPart { part ->
when (part) {
is PartData.FileItem -> {
val fileName = part.originalFileName ?: "unknown"
val inputStream = part.streamProvider()
val bytes = inputStream.readBytes()
inputStream.close()
// Save the file
File("uploads/$fileName").writeBytes(bytes)
}
is PartData.FormItem -> {
// Text form fields
val fieldName = part.name
val value = part.value
}
else -> {}
}
part.dispose()
}
call.respondText("File uploaded", status = HttpStatusCode.Created)
}
Understanding Multipart Parts
| Part Type | What It Contains | Example |
|---|---|---|
FileItem | Binary file data | Image, PDF, document |
FormItem | Text field value | Description, tags |
BinaryItem | Raw binary data | Less common |
BinaryChannelItem | Streaming binary data | Large files |
Upload Response Model
Return useful information about the uploaded file:
@Serializable
data class UploadResponse(
val fileName: String,
val originalName: String,
val size: Long,
val url: String
)
Validation and Security
File uploads need careful validation. Never trust user input.
1. File Extension Validation
Only allow specific file types:
private val ALLOWED_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "pdf", "txt")
val extension = originalName.substringAfterLast(".", "")
if (extension.lowercase() !in ALLOWED_EXTENSIONS) {
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse("File type .$extension is not allowed", 400)
)
return@forEachPart
}
2. File Size Validation
Prevent huge uploads from crashing your server:
private const val MAX_FILE_SIZE = 10 * 1024 * 1024L // 10 MB
if (bytes.size > MAX_FILE_SIZE) {
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse("File too large. Max size is 10 MB", 400)
)
return@forEachPart
}
3. Unique File Names
Never use the original file name directly. Generate a unique name to prevent conflicts and security issues:
val fileName = "${UUID.randomUUID()}.$extension"
This produces file names like a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg.
4. Path Traversal Protection
Prevent attackers from accessing files outside the uploads directory:
get("/api/files/{fileName}") {
val fileName = call.parameters["fileName"] ?: return@get
// Block path traversal attempts
if (fileName.contains("..") || fileName.contains("/")) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid file name", 400))
return@get
}
val file = File(uploadsDir, fileName)
if (!file.exists()) {
call.respond(HttpStatusCode.NotFound, ErrorResponse("File not found", 404))
return@get
}
call.respondFile(file)
}
Without this check, an attacker could request /api/files/../../etc/passwd to read system files.
Complete File Routes
Here is the complete file with all routes:
fun Route.fileRoutes() {
val uploadsDir = File("uploads").also { it.mkdirs() }
// Upload a file
post("/upload") {
val multipart = call.receiveMultipart()
var uploadResponse: UploadResponse? = null
multipart.forEachPart { part ->
when (part) {
is PartData.FileItem -> {
val originalName = part.originalFileName ?: "unknown"
val extension = originalName.substringAfterLast(".", "")
if (extension.lowercase() !in ALLOWED_EXTENSIONS) {
call.respond(HttpStatusCode.BadRequest,
ErrorResponse("File type not allowed", 400))
part.dispose()
return@forEachPart
}
val fileName = "${UUID.randomUUID()}.$extension"
val inputStream = part.streamProvider()
val bytes = inputStream.readBytes()
inputStream.close()
if (bytes.size > MAX_FILE_SIZE) {
call.respond(HttpStatusCode.BadRequest,
ErrorResponse("File too large", 400))
part.dispose()
return@forEachPart
}
File(uploadsDir, fileName).writeBytes(bytes)
uploadResponse = UploadResponse(
fileName = fileName,
originalName = originalName,
size = bytes.size.toLong(),
url = "/api/files/$fileName"
)
}
else -> {}
}
part.dispose()
}
if (uploadResponse != null) {
call.respond(HttpStatusCode.Created, uploadResponse!!)
} else {
call.respond(HttpStatusCode.BadRequest,
ErrorResponse("No file provided", 400))
}
}
// Download a file
get("/files/{fileName}") {
val fileName = call.parameters["fileName"] ?: return@get
if (fileName.contains("..") || fileName.contains("/")) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid name", 400))
return@get
}
val file = File(uploadsDir, fileName)
if (!file.exists()) {
call.respond(HttpStatusCode.NotFound, ErrorResponse("Not found", 404))
return@get
}
call.respondFile(file)
}
// Delete a file
delete("/files/{fileName}") {
val fileName = call.parameters["fileName"] ?: return@delete
if (fileName.contains("..") || fileName.contains("/")) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid name", 400))
return@delete
}
val file = File(uploadsDir, fileName)
if (!file.exists()) {
call.respond(HttpStatusCode.NotFound, ErrorResponse("Not found", 404))
return@delete
}
file.delete()
call.respond(HttpStatusCode.NoContent)
}
}
Testing File Uploads
Use Ktor’s MultiPartFormDataContent to send files in tests:
@Test
fun `upload and download file`() = testApplication {
application { module() }
val client = jsonClient()
val uploadResponse = client.post("/api/upload") {
setBody(MultiPartFormDataContent(
formData {
append("file", "Hello, World!".toByteArray(), Headers.build {
append(HttpHeaders.ContentDisposition, "filename=\"test.txt\"")
})
}
))
}
assertEquals(HttpStatusCode.Created, uploadResponse.status)
val uploaded = uploadResponse.body<UploadResponse>()
assertEquals("test.txt", uploaded.originalName)
val downloadResponse = client.get(uploaded.url)
assertEquals(HttpStatusCode.OK, downloadResponse.status)
assertEquals("Hello, World!", downloadResponse.bodyAsText())
}
Testing with curl
# Upload a file
curl -X POST http://localhost:8080/api/upload \
-F "file=@photo.jpg"
# Download the file
curl http://localhost:8080/api/files/{fileName}
# Delete the file
curl -X DELETE http://localhost:8080/api/files/{fileName}
Multiple File Uploads
To upload multiple files in a single request, loop through all file parts:
post("/upload/multiple") {
val multipart = call.receiveMultipart()
val uploadedFiles = mutableListOf<UploadResponse>()
multipart.forEachPart { part ->
when (part) {
is PartData.FileItem -> {
val originalName = part.originalFileName ?: "unknown"
val extension = originalName.substringAfterLast(".", "")
if (extension.lowercase() in ALLOWED_EXTENSIONS) {
val fileName = "${UUID.randomUUID()}.$extension"
val inputStream = part.streamProvider()
val bytes = inputStream.readBytes()
inputStream.close()
if (bytes.size <= MAX_FILE_SIZE) {
File(uploadsDir, fileName).writeBytes(bytes)
uploadedFiles.add(UploadResponse(
fileName = fileName,
originalName = originalName,
size = bytes.size.toLong(),
url = "/api/files/$fileName"
))
}
}
}
else -> {}
}
part.dispose()
}
if (uploadedFiles.isNotEmpty()) {
call.respond(HttpStatusCode.Created, uploadedFiles)
} else {
call.respond(HttpStatusCode.BadRequest,
ErrorResponse("No valid files provided", 400))
}
}
Content Type Detection
Ktor automatically sets the Content-Type header when serving files based on the file extension. But you can also set it manually:
get("/files/{fileName}") {
val file = File(uploadsDir, fileName)
val contentType = when (file.extension.lowercase()) {
"jpg", "jpeg" -> ContentType.Image.JPEG
"png" -> ContentType.Image.PNG
"gif" -> ContentType.Image.GIF
"pdf" -> ContentType("application", "pdf")
"txt" -> ContentType.Text.Plain
else -> ContentType.Application.OctetStream
}
call.respondFile(file)
}
File Upload with Form Fields
Often you want to upload a file along with metadata (like a description or category). Handle both FileItem and FormItem parts:
post("/upload") {
val multipart = call.receiveMultipart()
var description = ""
var uploadResponse: UploadResponse? = null
multipart.forEachPart { part ->
when (part) {
is PartData.FormItem -> {
if (part.name == "description") {
description = part.value
}
}
is PartData.FileItem -> {
// Handle file upload (same as before)
}
else -> {}
}
part.dispose()
}
}
The client sends this as:
curl -X POST http://localhost:8080/api/upload \
-F "file=@photo.jpg" \
-F "description=Profile picture"
Cleanup Strategy
Uploaded files can accumulate over time. Consider adding a cleanup job:
// Delete files older than 30 days
fun cleanupOldFiles() {
val uploadsDir = File("uploads")
val thirtyDaysAgo = System.currentTimeMillis() - (30L * 24 * 60 * 60 * 1000)
uploadsDir.listFiles()?.forEach { file ->
if (file.lastModified() < thirtyDaysAgo) {
file.delete()
}
}
}
You could run this as a scheduled task or a background coroutine.
Storage Strategies
For a production application, consider these storage options:
| Strategy | Pros | Cons |
|---|---|---|
| Local filesystem | Simple, fast | Does not scale, single server only |
| AWS S3 / GCS | Scalable, CDN support | External dependency, costs |
| Database (BLOB) | Transactional, backup included | Slow for large files, database bloat |
For this tutorial, we use local storage. In production, you would typically upload to S3 or similar cloud storage.
Source Code
You can find the source code for this tutorial on GitHub:
github.com/kemalcodes/ktor-tutorial — Branch: tutorial-09-files
What’s Next?
In the next tutorial, we will add database migrations with Flyway. You will learn why migrations matter, how to write them, and how to manage your database schema over time.
Ktor Tutorial #10: Database Migrations with Flyway
Related Articles
- Ktor Tutorial #8: Relationships — Advanced database queries
- Ktor Tutorial #7: CRUD Operations — Building a REST API
- Kotlin Tutorial: Complete Series — Learn Kotlin from scratch