- name
- kemal-upload
- description
- Handling file uploads and storage in Kemal using body size safety configuration and established project patterns.
- license
- MIT
# 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`](https://github.com/sdogruyol/kemal-by-example/tree/master/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:
```crystal
# 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:
```crystal
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):
```crystal
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:
```crystal
unless StoredFile.allowed_extension?(original_name)
env.redirect "/files?error=Only+images,+PDF,+and+TXT+files+are+allowed."
next ""
end
```
- **Handling Files:**
```crystal
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:
```crystal
mime_type = uploaded_file.headers["Content-Type"]? || "application/octet-stream"
```
- **File Deletion:** Always check existence before deleting:
```crystal
::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)
```crystal
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)
```crystal
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
```crystal
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).
Ver en GitHub