Skip to main content
IHP provides a simple file storage system for uploading files to local directories, Amazon S3, or any S3-compatible cloud service.
All uploaded files get a random UUID as their filename for security. This prevents malicious filenames and makes URLs unguessable.

Configuration

Static Directory Storage

For development, store files in your static/ directory:
-- Config/Config.hs
import IHP.FileStorage.Config

config :: ConfigBuilder
config = do
    option Development
    option (AppHostname "localhost")
    initStaticDirStorage
Files in static/ are publicly accessible. For production, use S3 or MinIO with private buckets.

AWS S3

-- Config/Config.hs
import IHP.FileStorage.Config

config :: ConfigBuilder
config = do
    option Development
    option (AppHostname "localhost")
    initS3Storage "eu-central-1" "my-bucket-name"
Set credentials via environment variables in .envrc:
export AWS_ACCESS_KEY_ID="YOUR_KEY"
export AWS_SECRET_ACCESS_KEY="YOUR_SECRET"

MinIO

Enable MinIO in flake.nix:
devenv.shells.default = {
    services.minio = {
        enable = true;
        buckets = [ "ihp-bucket" ];
    };
};
Configure in Config/Config.hs:
import IHP.FileStorage.Config

config :: ConfigBuilder
config = do
    initMinioStorage "http://127.0.0.1:9000" "ihp-bucket"
Set credentials in .envrc:
export MINIO_ACCESS_KEY="minioadmin"
export MINIO_SECRET_KEY="minioadmin"

Uploading Files

Basic Upload

Given this schema:
CREATE TABLE companies (
    id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
    name TEXT NOT NULL,
    logo_url TEXT DEFAULT NULL
);
Upload a file in your controller:
action UpdateCompanyAction { companyId } = do
    company <- fetch companyId
    company
        |> buildCompany
        |> uploadToStorage #logoUrl
        >>= ifValid \case
            Left company -> render EditView { .. }
            Right company -> do
                company <- company |> updateRecord
                redirectTo EditCompanyAction { .. }

buildCompany company = company
    |> fill @["name"]
    |> validateField #name nonEmpty
After uploadToStorage, use >>= instead of |> for function chaining.

File Upload Form

Add a file field to your form:
renderForm :: Company -> Html
renderForm company = formFor company [hsx|
    {(textField #name)}
    {(fileField #logoUrl)}
    {submitButton}
|]
With custom attributes:
{(fileField #logoUrl) { additionalAttributes = [("accept", "image/*")] }}

Image Preview

Show a preview of the uploaded image:
renderForm :: Company -> Html
renderForm company = formFor company [hsx|
    {(textField #name)}
    {(fileField #logoUrl) { 
        additionalAttributes = [
            ("accept", "image/*"), 
            ("data-preview", "#logoUrlPreview")
        ] 
    }}
    <img id="logoUrlPreview" src={fromMaybe "" company.logoUrl}/>
    {submitButton}
|]

Image Processing

Use ImageMagick to resize and optimize images:
action UpdateCompanyAction { companyId } = do
    let uploadLogo = uploadToStorageWithOptions $ def
            { preprocess = applyImageMagick "png" [
                "-resize", "512x512^", 
                "-gravity", "north", 
                "-extent", "512x512", 
                "-quality", "100%", 
                "-strip"
            ]}

    company <- fetch companyId
    company
        |> fill @["name"]
        |> uploadLogo #logoUrl
        >>= ifValid \case
            Left company -> render EditView { .. }
            Right company -> do
                company <- company |> updateRecord
                redirectTo EditCompanyAction { .. }

Installing ImageMagick

Add to default.nix:
otherDeps = p: with p; [
    imagemagick
];
Restart your development server after running devenv up.

JPEG Compression

let uploadLogo = uploadToStorageWithOptions $ def
    { preprocess = applyImageMagick "jpg" [
        "-resize", "1024x1024^",
        "-gravity", "north",
        "-extent", "1024x1024",
        "-quality", "85%",
        "-strip"
    ]}

Advanced Features

Upload Without a Record

Use storeFile to upload without associating to a model:
action UpdateLogoAction = do
    let file = fileOrNothing "logo"
            |> fromMaybe (error "No file given")
    
    storedFile <- storeFile file "logos"
    let url = storedFile.url

Required File Uploads

Validate that a file was uploaded:
action CreateCompanyAction = do
    let company = newRecord @Company
    company
        |> uploadToStorage #logoUrl
        >>= buildCompany
        >>= ifValid \case
            Left company -> render NewView { .. }
            Right company -> do
                company <- company |> createRecord
                redirectTo ShowCompanyAction { .. }

buildCompany company = company
    |> fill @["name"]
    |> validateField #name nonEmpty
    |> validateField #logoUrl nonEmpty
    |> pure

Content Disposition

Force downloads with proper filenames:
let uploadAttachment = uploadToStorageWithOptions $ def
    { contentDisposition = contentDispositionAttachmentAndFileName }

record
    |> uploadAttachment #attachmentUrl
This sets the header:
Content-Disposition: attachment; filename="the-filename-when-uploaded.pdf"

Signed URLs for Private Files

Generate temporary download URLs for S3 files:
signedUrl <- createTemporaryDownloadUrlFromPath "logos/8ed22caa-11ea-4c45-a05e-91a51e72558d"

let url :: Text = signedUrl.url
let expiredAt :: UTCTime = signedUrl.expiredAt
Signed URLs are valid for 7 days. For static directory storage, regular unsigned URLs are returned.

Upload Limits

Default limits:
  • Maximum key/filename length: 32 bytes
  • Maximum files: 10
  • Filesize: unlimited
  • Maximum parameter size: 64KB
  • Maximum header lines: 32 bytes
  • Maximum header line length: 8190 bytes
Customize in Config/Config.hs:
import qualified Network.Wai.Parse as WaiParse

config :: ConfigBuilder
config = do
    option Development
    
    option $ WaiParse.defaultParseRequestBodyOptions
        |> WaiParse.setMaxRequestNumFiles 20
See the Network.Wai.Parse documentation for all available options.

Build docs developers (and LLMs) love