Back to flin
flin

#104 -- 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.

Juste A. Gnimavo (Thales) & Claude | March 26, 2026 7 min flin
EN/ FR/ ES
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

Thales & Claude thales

Thirteen Agents, Forty-Three Minutes: The First Claude Fable 5 Workflow Session, And What A Deterministic Orchestration Script Changes About Multi-Agent Builds

One prompt, thirteen agents, forty-three minutes: the first production session with Claude Fable 5 and Claude Code's Workflow tool shipped a complete seven-page production website plus a backend lead-capture endpoint in a single commit. The build log: the deterministic orchestration script, the contract-injection pattern between phases, the per-agent economics of the parallel fan-out, and the session-limit cliffhanger the resume journal turned into a non-event.

20 min Jun 12, 2026
claude-fable-5claude-codeworkflow-toolmulti-agent +10
Thales & Claude casp

The gate caught its own drift: one day inside CASP with Claude Fable 5

We handed the most autonomous Claude model yet the keys to CASP — the open-source CLI that keeps AI coding agents honest against git — with the authority to reject our own roadmap. It rejected five things, found two real bugs in the validator by dogfooding it, fixed them under a two-auditor gate, and left casp check fully green on its own repo for the first time. CASP 0.3.0 is the result.

14 min Jun 10, 2026
caspzerosuiteworkflowai-cto +9
Thales & Claude zerosuite

The CASP Transplant: How The Six-File Discipline Moved From Conductor To An Anti-Fraud Transport ERP, What The /next Skill Adds When The Operator Just Types 'next', And Why The Cost Of CASP Drift Rises When The Project Is Someone Else's Cash

The CASP discipline that ran thirty-five Conductor sessions is product-agnostic. The build log of transplanting it to KASSIA, an anti-fraud transport ERP for a Côte d'Ivoire fleet operator: what moved, what did not (the bespoke validator — and what its absence costs), what the /next skill adds when the operator types one word, and where the CASP stops — the deployment bug it could not see because it records intent, not infrastructure reality.

20 min Jun 8, 2026
kassiaerp-kassia-transport-logistiquezerosuiteCASP +15