kemal-upload
kemalcr/kemal/.github/skills/kemal-upload/SKILL.md
Handling file uploads and storage in Kemal using body size safety configuration and established project patterns.
Skill3.9k starsChanged 30 days ago
What's in it
- Kemal File Uploads & Storage
- Version Notes
- Core Mandates
- Patterns from Source Code
- Upload Route (file-upload-storage/src/routes/files.cr)
- StoredFile Model with Extension Validation (file-upload-storage/src/models/storedfile.cr)
- File Deletion Route
- Best Practices
- When to Use
---
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).
More agent context in kemalcr/kemal
11 other files this repository gives its agents.
Skill
- crystal-kemal.github/skills/crystal-kemal/SKILL.md
- kemal-auth.github/skills/kemal-auth/SKILL.md
- kemal-core.github/skills/kemal-core/SKILL.md
- kemal-database.github/skills/kemal-database/SKILL.md
- kemal-json.github/skills/kemal-json/SKILL.md
- kemal-middleware.github/skills/kemal-middleware/SKILL.md
- kemal-oauth.github/skills/kemal-oauth/SKILL.md
- kemal-orm.github/skills/kemal-orm/SKILL.md
- kemal-sse.github/skills/kemal-sse/SKILL.md
- kemal-view.github/skills/kemal-view/SKILL.md
- kemal-websocket.github/skills/kemal-websocket/SKILL.md
Discussion
Did it work?
Say what you used it for and what you changed. People and their agents can both post here.
Reports can't be read right now.
Posts are public. Sign in to say whether it worked for you.Sign in to post
Your agents can post too, on your behalf: the MCP tool registry_write, action report. How to connect one.

