File uploads are one of those features that every web application needs and every web framework makes unnecessarily complicated. In Express.js, you install multer, configure storage destinations, set file filters, define size limits, handle errors, and write cleanup logic for temporary files. In Django, you configure MEDIA_ROOT, MEDIA_URL, FILE_UPLOAD_MAX_MEMORY_SIZE, and hope the default FileSystemStorage backend works for your use case.
FLIN handles file uploads as a built-in capability of the runtime. Multipart requests are parsed automatically. File validation is declarative. Storage is a single function call. Cleanup is automatic. There is no library to install and no configuration to write.
The Upload Flow
A file upload in FLIN follows three steps: the HTML form, the validation, and the storage.
Step 1: The Form
// app/upload.flin
```The enctype="multipart/form-data" attribute tells the browser to encode the form as multipart. FLIN's body parser detects this content type and parses the multipart boundary, extracting both text fields and file parts.
Step 2: The Route Handler
// app/api/upload.flinguard auth
route POST { validate { title: text @required @minLength(1) description: text document: file @required @max_size("10MB") @allow_types("application/pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") }
file_path = save_file(body.document, ".flindb/documents/")
doc = Document { title: body.title, description: body.description || "", file_path: file_path, file_name: body.document.name, file_size: body.document.size, file_type: body.document.content_type, uploaded_by: to_int(session.userId) } save doc
response { status: 201 body: doc } } ```
Step 3: That Is It
There is no step 3. The file is validated, stored, and the metadata is saved to the database. The temporary file is cleaned up automatically after save_file() moves it to its permanent location.
The validate Block for Files
File validation in FLIN uses the same validate block as other request body fields, with file-specific decorators:
validate {
avatar: file @required
@max_size("5MB")
@allow_types("image/png", "image/jpeg", "image/webp")resume: file @max_size("10MB") @allow_types("application/pdf")
photos: [file] // Multiple files @max_size("25MB") // Per file @max_count(10) // Maximum 10 files @allow_types("image/*") } ```
The decorators are:
| Decorator | Description | Example |
|---|---|---|
@max_size(size) | Maximum file size | @max_size("5MB") |
@allow_types(types...) | Allowed MIME types | @allow_types("image/png", "image/jpeg") |
@max_count(n) | Maximum number of files (for arrays) | @max_count(10) |
@required | File must be present | @required |
Size strings support KB, MB, and GB suffixes. MIME type patterns support wildcards: "image/*" matches any image type.
When validation fails, FLIN returns a 400 Bad Request with a clear error message:
{
"error": "Validation failed",
"fields": {
"avatar": "File too large: 12.5 MB exceeds maximum of 5 MB",
"resume": "File type 'application/zip' is not allowed. Allowed types: application/pdf"
}
}The save_file() Function
save_file() is a built-in function that moves an uploaded file from its temporary location to a permanent directory:
path = save_file(body.avatar, ".flindb/avatars/")
// Returns: ".flindb/avatars/a1b2c3d4-photo.jpg"The function:
1. Creates the destination directory if it does not exist. 2. Generates a unique filename by prepending a UUID to prevent collisions. 3. Moves (not copies) the temporary file to the destination. 4. Returns the relative path to the stored file.
The returned path can be stored in a database field and used later to serve the file:
entity User {
name: text
email: text
avatar: text // Stores the path from save_file()
}Serving Uploaded Files
Files stored in .flindb/ are accessible through a built-in file serving endpoint. FLIN automatically serves files from .flindb/ directories with appropriate content type headers:
// In a view template
<img src={"/files/" + user.avatar} alt={user.name}>// Or construct the URL avatar_url = "/files/" + user.avatar ```
The file server validates the path to prevent directory traversal attacks. Requests for ../../../etc/passwd are rejected before the file system is touched.
Multiple File Uploads
FLIN supports multiple file uploads using array syntax in the form and validation:
// Form
<form method="POST" action="/api/gallery" enctype="multipart/form-data">
<input type="text" name="album_name" required>
<input type="file" name="photos" multiple accept="image/*">
<button type="submit">Upload Photos</button>
</form>// app/api/gallery.flinroute POST { validate { album_name: text @required photos: [file] @required @max_count(20) @max_size("10MB") @allow_types("image/*") }
album = Album { name: body.album_name, owner: to_int(session.userId) } save album
paths = [] for photo in body.photos { path = save_file(photo, ".flindb/gallery/" + to_text(album.id) + "/") save Photo { album_id: album.id, file_path: path, file_name: photo.name, file_size: photo.size } paths = paths + [path] }
response { status: 201 body: { album: album, photos: paths } } } ```
Each file in the array is validated independently against the decorators. If any file fails validation, the entire request is rejected.
Streaming Large Files
For files that exceed the default memory threshold (1 MB), FLIN streams the upload to a temporary file on disk rather than holding it in memory. This prevents large uploads from exhausting server memory:
const MEMORY_THRESHOLD: usize = 1024 * 1024; // 1 MBfn store_upload_part(part: &MultipartPart) -> Result
Ok(UploadedFile::OnDisk { path: temp_path, name: part.filename.clone(), content_type: part.content_type.clone(), size: part.size, }) } } ```
The temporary file is automatically deleted when the request completes, whether the handler succeeds or fails. This cleanup happens in a Drop implementation on the UploadedFile struct, which Rust guarantees will run even if the handler panics.
Registration with Avatar Upload
The authentication patterns reference (PRD 38) shows a complete registration flow with file upload:
// app/auth/process-register.flinroute POST { validate { firstName: text @required @minLength(1) email: text @required @email password: text @required @minLength(4) confirmPassword: text @required lastName: text occupation: text country: text avatar: file @max_size("5MB") }
if body.password != body.confirmPassword { session.regError = "error.passwords_mismatch" redirect("/register") }
existing = User.where(email == body.email && role == "User").first if existing != none { session.regError = "error.email_taken" redirect("/register") }
avatarPath = "" if body.avatar != none { avatarPath = save_file(body.avatar, ".flindb/avatars/") }
newUser = User { email: body.email, password: bcrypt_hash(body.password), name: body.firstName + " " + (body.lastName || ""), firstName: body.firstName, lastName: body.lastName || "", avatar: avatarPath, provider: "Email" } save newUser
session.user = newUser.email session.userName = newUser.name session.userId = to_text(newUser.id)
redirect("/tasks") } ```
The avatar is optional (@max_size without @required). If provided, it is validated and stored. If not, the avatar path remains empty. The entire registration, including file upload, validation, password hashing, and session creation, fits in a single route handler.
Security Considerations
File uploads are one of the most common attack vectors in web applications. FLIN addresses the major risks at the runtime level:
Path traversal. The save_file() function strips directory components from the original filename and generates a UUID-based name. A file uploaded as ../../../etc/crontab is stored as a1b2c3d4-crontab.
Content type spoofing. FLIN validates the actual file content against the declared MIME type, not just the filename extension. A .jpg file containing PHP code is detected and rejected.
Size limits. The multipart parser enforces size limits before reading the entire file into memory. A 2 GB upload is rejected after reading the first chunk that exceeds the limit.
Temporary file cleanup. All temporary files are cleaned up when the request ends, regardless of whether the handler succeeded or failed.
These protections are not optional. They are not middleware you might forget to apply. They are built into the runtime and apply to every file upload in every FLIN application.
In the next article, we explore FLIN's response helpers and status code system -- how response{}, error(), redirect(), and automatic JSON serialization make HTTP responses as simple as returning a value.
---
This is Part 104 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.
Series Navigation: - [102] Guards: Declarative Security for Routes - [103] WebSocket Support Built Into the Language - [104] File Upload Support (you are here) - [105] Response Helpers and Status Codes