Kemal File Uploads & Storage
This skill provides expert guidance on implementing file uploads and storage in Kemal, with explicit safety bounds and validation, strictly following patterns from kemal-by-example/file-upload-storage.
Version Notes
Kemal.config.max_request_body_size: since Kemal 1.9.
Kemal.config.max_multipart_form_field_size: since Kemal 1.11.
- Upload tempfile cleanup in
Kemal::InitHandler (runs for every request, including halted filters): since Kemal 1.13.0.
Core Mandates
Global Size Limit Configuration (Security in Kemal 1.9+ & 1.11+): Set global request and multipart limits in the entrypoint file:
# Maximum overall request body size (Kemal 1.9+, e.g. 50MB)
Kemal.config.max_request_body_size = 50 * 1024 * 1024
# Maximum individual multipart form field size (Kemal 1.11+, e.g. 8MB)
Kemal.config.max_multipart_form_field_size = 8 * 1024 * 1024
Uploading: Use env.params.files.has_key?("file") to check for file presence, then env.params.files["file"] to access it:
unless env.params.files.has_key?("file")
env.redirect "/files?error=Please+choose+a+file+to+upload."
next ""
end
uploaded_file = env.params.files["file"]
Pre-Validation: Check the size of the upload before storage (size is the byte count written to disk):
max_size = 50_u64 * 1024 * 1024
upload_size = uploaded_file.size || 0_u64
if upload_size > max_size
env.redirect "/files?error=File+size+must+be+50MB+or+smaller."
next ""
end
Extension Validation: Check allowed extensions before processing:
unless StoredFile.allowed_extension?(original_name)
env.redirect "/files?error=Only+images,+PDF,+and+TXT+files+are+allowed."
next ""
end
Handling Files:
extension = ::File.extname(original_name).downcase
stored_name = "#{Random::Secure.hex(16)}#{extension}"
destination = ::File.join(Kemal.config.public_folder, "uploads", stored_name)
uploaded_file.open do |upload|
::File.open(destination, "w") do |file|
IO.copy(upload, file)
end
end
MIME Type Detection: Use a fallback for missing Content-Type headers:
mime_type = uploaded_file.headers["Content-Type"]? || "application/octet-stream"
File Deletion: Always check existence before deleting:
::File.delete(stored_file.storage_path) if ::File.exists?(stored_file.storage_path)
Patterns from Source Code
Upload Route (file-upload-storage/src/routes/files.cr)
post "/files/upload" do |env|
unless env.params.files.has_key?("file")
env.redirect "/files?error=Please+choose+a+file+to+upload."
next ""
end
uploaded_file = env.params.files["file"]
original_name = uploaded_file.filename || "upload"
unless StoredFile.allowed_extension?(original_name)
env.redirect "/files?error=Only+images,+PDF,+and+TXT+files+are+allowed."
next ""
end
max_size = 50_u64 * 1024 * 1024
# Check size before copying:
upload_size = uploaded_file.size || 0_u64
if upload_size > max_size
env.redirect "/files?error=File+size+must+be+50MB+or+smaller."
next ""
end
extension = ::File.extname(original_name).downcase
stored_name = "#{Random::Secure.hex(16)}#{extension}"
destination = ::File.join(Kemal.config.public_folder, "uploads", stored_name)
uploaded_file.open do |upload|
::File.open(destination, "w") do |file|
IO.copy(upload, file)
end
end
mime_type = uploaded_file.headers["Content-Type"]? || "application/octet-stream"
StoredFile.create(original_name, stored_name, mime_type, upload_size)
env.redirect "/files?notice=File+uploaded+successfully."
end
StoredFile Model with Extension Validation (file-upload-storage/src/models/stored_file.cr)
class StoredFile
include DB::Serializable
ALLOWED_EXTENSIONS = %w[.jpg .jpeg .png .gif .pdf .txt]
getter id : Int64?
getter original_name : String
getter stored_name : String
getter mime_type : String
getter size_bytes : Int64
getter created_at : String
def storage_path : String
::File.join(Kemal.config.public_folder, "uploads", stored_name)
end
def self.allowed_extension?(filename : String) : Bool
ext = ::File.extname(filename).downcase
ALLOWED_EXTENSIONS.includes?(ext)
end
end
File Deletion Route
post "/files/:id/delete" do |env|
id = env.params.url["id"]?.try(&.to_i64?)
stored_file = id ? StoredFile.find(id) : nil
if stored_file
::File.delete(stored_file.storage_path) if ::File.exists?(stored_file.storage_path)
stored_file.delete
env.redirect "/files?notice=File+deleted+successfully."
else
env.response.status = :not_found
"File not found"
end
end
Best Practices
- Configuration: Always configure
Kemal.config.max_request_body_size and Kemal.config.max_multipart_form_field_size globally to mitigate resource exhaustion / DoS attacks.
- Extension Validation: Use a helper like
StoredFile.allowed_extension?(name) to validate extensions (e.g., .jpg, .png, .pdf, .txt).
- Unique Storage Names: Always generate unique filenames using
Random::Secure.hex(16) to prevent collisions.
- Cleanup: Always use
::File.delete(path) if ::File.exists?(path) when deleting files.
- Early Validation: Check file presence and extension before processing to fail fast.
When to Use
- When implementing file upload features.
- When configuring Kemal to handle larger request bodies safely.
- When managing stored files (e.g., deleting after use).
1---2name: kemal-upload3description: Handling file uploads and storage in Kemal using body size safety configuration and established project patterns.4license: MIT5---67# Kemal File Uploads & Storage89This skill provides expert guidance on implementing file uploads and storage in Kemal, with explicit safety bounds and validation, strictly following patterns from [`kemal-by-example/file-upload-storage`](https://github.com/sdogruyol/kemal-by-example/tree/master/file-upload-storage).1011## Version Notes1213- `Kemal.config.max_request_body_size`: since Kemal 1.9.14- `Kemal.config.max_multipart_form_field_size`: since Kemal 1.11.15- Upload tempfile cleanup in `Kemal::InitHandler` (runs for every request, including halted filters): since Kemal 1.13.0.1617## Core Mandates1819- **Global Size Limit Configuration (Security in Kemal 1.9+ & 1.11+):** Set global request and multipart limits in the entrypoint file:2021 ```crystal22 # Maximum overall request body size (Kemal 1.9+, e.g. 50MB)23 Kemal.config.max_request_body_size = 50 * 1024 * 10242425 # Maximum individual multipart form field size (Kemal 1.11+, e.g. 8MB)26 Kemal.config.max_multipart_form_field_size = 8 * 1024 * 102427 ```2829- **Uploading:** Use `env.params.files.has_key?("file")` to check for file presence, then `env.params.files["file"]` to access it:3031 ```crystal32 unless env.params.files.has_key?("file")33 env.redirect "/files?error=Please+choose+a+file+to+upload."34 next ""35 end36 uploaded_file = env.params.files["file"]37 ```3839- **Pre-Validation:** Check the size of the upload before storage (`size` is the byte count written to disk):4041 ```crystal42 max_size = 50_u64 * 1024 * 102443 upload_size = uploaded_file.size || 0_u644445 if upload_size > max_size46 env.redirect "/files?error=File+size+must+be+50MB+or+smaller."47 next ""48 end49 ```5051- **Extension Validation:** Check allowed extensions before processing:5253 ```crystal54 unless StoredFile.allowed_extension?(original_name)55 env.redirect "/files?error=Only+images,+PDF,+and+TXT+files+are+allowed."56 next ""57 end58 ```5960- **Handling Files:**6162 ```crystal63 extension = ::File.extname(original_name).downcase64 stored_name = "#{Random::Secure.hex(16)}#{extension}"65 destination = ::File.join(Kemal.config.public_folder, "uploads", stored_name)6667 uploaded_file.open do |upload|68 ::File.open(destination, "w") do |file|69 IO.copy(upload, file)70 end71 end72 ```7374- **MIME Type Detection:** Use a fallback for missing Content-Type headers:7576 ```crystal77 mime_type = uploaded_file.headers["Content-Type"]? || "application/octet-stream"78 ```7980- **File Deletion:** Always check existence before deleting:8182 ```crystal83 ::File.delete(stored_file.storage_path) if ::File.exists?(stored_file.storage_path)84 ```8586## Patterns from Source Code8788### Upload Route (file-upload-storage/src/routes/files.cr)8990```crystal91post "/files/upload" do |env|92 unless env.params.files.has_key?("file")93 env.redirect "/files?error=Please+choose+a+file+to+upload."94 next ""95 end9697 uploaded_file = env.params.files["file"]98 original_name = uploaded_file.filename || "upload"99100 unless StoredFile.allowed_extension?(original_name)101 env.redirect "/files?error=Only+images,+PDF,+and+TXT+files+are+allowed."102 next ""103 end104105 max_size = 50_u64 * 1024 * 1024106107 # Check size before copying:108 upload_size = uploaded_file.size || 0_u64109110 if upload_size > max_size111 env.redirect "/files?error=File+size+must+be+50MB+or+smaller."112 next ""113 end114115 extension = ::File.extname(original_name).downcase116 stored_name = "#{Random::Secure.hex(16)}#{extension}"117 destination = ::File.join(Kemal.config.public_folder, "uploads", stored_name)118119 uploaded_file.open do |upload|120 ::File.open(destination, "w") do |file|121 IO.copy(upload, file)122 end123 end124125 mime_type = uploaded_file.headers["Content-Type"]? || "application/octet-stream"126 StoredFile.create(original_name, stored_name, mime_type, upload_size)127128 env.redirect "/files?notice=File+uploaded+successfully."129end130```131132### StoredFile Model with Extension Validation (file-upload-storage/src/models/stored_file.cr)133134```crystal135class StoredFile136 include DB::Serializable137138 ALLOWED_EXTENSIONS = %w[.jpg .jpeg .png .gif .pdf .txt]139140 getter id : Int64?141 getter original_name : String142 getter stored_name : String143 getter mime_type : String144 getter size_bytes : Int64145 getter created_at : String146147 def storage_path : String148 ::File.join(Kemal.config.public_folder, "uploads", stored_name)149 end150151 def self.allowed_extension?(filename : String) : Bool152 ext = ::File.extname(filename).downcase153 ALLOWED_EXTENSIONS.includes?(ext)154 end155end156```157158### File Deletion Route159160```crystal161post "/files/:id/delete" do |env|162 id = env.params.url["id"]?.try(&.to_i64?)163 stored_file = id ? StoredFile.find(id) : nil164165 if stored_file166 ::File.delete(stored_file.storage_path) if ::File.exists?(stored_file.storage_path)167 stored_file.delete168 env.redirect "/files?notice=File+deleted+successfully."169 else170 env.response.status = :not_found171 "File not found"172 end173end174```175176## Best Practices177178- **Configuration:** Always configure `Kemal.config.max_request_body_size` and `Kemal.config.max_multipart_form_field_size` globally to mitigate resource exhaustion / DoS attacks.179- **Extension Validation:** Use a helper like `StoredFile.allowed_extension?(name)` to validate extensions (e.g., `.jpg`, `.png`, `.pdf`, `.txt`).180- **Unique Storage Names:** Always generate unique filenames using `Random::Secure.hex(16)` to prevent collisions.181- **Cleanup:** Always use `::File.delete(path) if ::File.exists?(path)` when deleting files.182- **Early Validation:** Check file presence and extension before processing to fail fast.183184## When to Use185186- When implementing file upload features.187- When configuring Kemal to handle larger request bodies safely.188- When managing stored files (e.g., deleting after use).