Back to flin
flin

File Upload Support

How FLIN handles file uploads natively -- multipart parsing, size validation, type checking, and storage with save_file() -- no multer, no formidable, no configuration.

Thales & Claude | March 25, 2026 7 min flin
flinfile-uploadmultipartstorage

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

flin// app/upload.flin

<form method="POST" action="/api/upload" enctype="multipart/form-data">
    <input type="text" name="title" placeholder="Document title" required>
    <input type="text" name="description" placeholder="Description">
    <input type="file" name="document" accept=".pdf,.docx,.xlsx" required>
    <button type="submit">Upload</button>
</form>

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

flin// app/api/upload.flin

guard 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:

flinvalidate {
    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:

DecoratorDescriptionExample
@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)
@requiredFile 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:

json{
    "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:

flinpath = 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:

flinentity 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:

flin// 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:

flin// 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>
flin// app/api/gallery.flin

route 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:

rustconst MEMORY_THRESHOLD: usize = 1024 * 1024; // 1 MB

fn store_upload_part(part: &MultipartPart) -> Result<UploadedFile, ParseError> {
    if part.size <= MEMORY_THRESHOLD {
        // Small file: keep in memory
        Ok(UploadedFile::InMemory {
            data: part.data.clone(),
            name: part.filename.clone(),
            content_type: part.content_type.clone(),
        })
    } else {
        // Large file: stream to temp directory
        let temp_path = temp_dir().join(format!("flin-upload-{}", generate_uuid()));
        let mut file = File::create(&temp_path)?;
        file.write_all(&part.data)?;

        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:

flin// app/auth/process-register.flin

route 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

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles