All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
0.2.0 - 2026-10-06
Added
Fil.Plug.send_file/3sends a file from a disk as the response of a controller, on every adapter, with the sameetag,304, ranges and streaming as the mounted plug, pluscontent_type:anddisposition:. A missing file returns the error before anything is sent, so anaction_fallbackcan answer it. (#39)Fil.disk/1takessigned_url:with default options forFil.signed_url/3on that disk (expires_in:,disposition:andif_exists:), so a disk from config can give every signed URL a day and every download the:attachmentdisposition. The options of a call replace them, and a default that doesn't fit the method (a disposition on an upload) is left out. (#37)Fil.ls/3takestype: :regularortype: :directoryto list only files or only directories. Withrecursive: true,type: :directorylists every directory of the subtree. (#40)Fil.wildcard/2,3lists the paths of a disk that match a pattern, such as"media/*/{withText,withoutText}", withPath.wildcard/2's syntax and dotfile rule (match_dot:), plustype:. No storage matches patterns itself, soFillists one directory level per segment, and the whole subtree once from a**on, the same on every adapter. (#40)Fil.exists?/3andFil.stat/3take the sametype:option, andFil.regular?/1,2checks for a file. On S3,Fil.regular?/1sends oneHEADandFil.dir?/1one listing, where a missing path took both before. The listing that checks for a directory now asks for two keys instead of reading the whole directory. (#40)Fil.Plugin.Validationrefuses writes by size (max_size:,min_size:), by content type (content_types:, checked against the content's magic bytes, so a GIF named.pngor HTML in a.txtfails, andimage/*doesn't take SVG) and by extension, plus acheck:of your own. A refused write returnsFil.InvalidContentErrorand writes nothing, and a stream fails as soon as it breaks a rule. Upload URLs are checked when they're signed, and a presigned S3 upload that would skip a content check is refused unlesspresigned_uploads: :check_declaredtrusts its signed content type. (#36)Fil.Op.scan_content/4andFil.Op.scan_result/4let a plugin read the content of a write or a read as it passes, without changing it. A stream keeps its chunks and its:size, so S3 still sends it in one request. A plugin refuses content by raising the newFil.InvalidContentErrorfrom a scan: the write returns the error and writes nothing, on every adapter, also when the scan raises after the last chunk.Fil.Pluganswers an upload refused this way with a413(too large), a415(content type or extension) or a422. (#33)Fil.read/3andFil.stream/3takeoffset:andlength:to read part of a file, on every adapter: S3 sends aRangeheader, the local disk reads from the offset, and the memory disk returns a sub-binary. Plugins that transform reads get the whole file andFilcuts the part from their result, so offsets count bytes of the content as you get it. (#35)Fil.Plugsendsetag,last-modifiedandaccept-ranges: bytes, answersif-none-matchandif-modified-sincewith a304, and aGETwith one byte range with a206(416for a range outside the file,if-rangehonoured). Several ranges get the whole file. Signed URLs work with ranges too, so browsers can seek in videos served from a local disk. (#35)- A tour of the API as a Livebook notebook, under Guides in the docs and linked from the README with a "Run in Livebook" badge. (#34)
Fil.LiveView.external/2lets the browser upload files straight to a disk, on every adapter: S3 with a presigned PUT, local and memory disks throughFil.Plug. The consume functions then check each file instead of writing it, and refuse one that's older than its upload URL or larger thanmax_file_size. The browser side is one import inapp.js,import {uploaders} from "phoenix-colocated/fil". (#26)Fil.signed_url/3takescontent_type:,size:andif_exists: :errorformethod: :put, and the upload has to match them, on S3 and throughFil.Plug: anothercontent-typeorcontent-lengthis a403, and withif_exists: :errorthe URL writes the file once and can't replace it. (#26)Fil.LiveView.upload_field/1is an upload field for LiveView forms, with drag and drop, previews, progress, cancel buttons, and remove buttons for the files the record already has. It's styled with daisyUI like the components Phoenix 1.8 generates, and a class attribute per part replaces the defaults.Fil.LiveView.cancel_upload/2handles its cancel buttons, andFil.LiveView.upload_error/2turns LiveView's and Fil's upload errors into{msgid, bindings}for the app'stranslate_error/1. The Phoenix guide shows a complete form. (#27)Fil.Ecto.Refstores refs in Ecto schemas: the column holds the path, and loading returns aFil.Refon the disk the field names.{:array, Fil.Ecto.Ref}holds several files in order, andFil.Ecto.Ref.removed/2returns the refs a changeset drops, to delete after the commit. A ref fits a field whenFil.Disk.same_storage?/2finds its disk on the same storage: the same adapter and the same address, which adapters return from the new optional callbackFil.Adapter.address/1(the root on a local disk, the endpoint, region, bucket and prefix on S3), so plugins and rotated credentials don't count. Ecto is a new optional dependency. (#25)Fil.LiveViewstores LiveView uploads on a disk:consume_uploaded_entries/4streams each file intoFil.write/4at a path you choose, and never replaces a file unless you passif_exists: :overwrite. If a write fails, it deletes the files it wrote and keeps the entries, so the form can be submitted again. It needs Phoenix LiveView 1.2, an optional dependency. The Phoenix guide walks through a form. (#23)Fil.Disk.resolve/1returns the disk for a disk, a 0-arity function or an MFA.Fil.Plug'sdisk:goes through it and raises anArgumentErrorwhen its function returns something other than a disk. It takes a function only as a capture such as&MyApp.Storage.uploads/0, because plug options can't hold an anonymous function. (#22)Fil.tmp/0,1creates a temporary directory and returns a ref to it, or to a file in it, on a local disk, so the whole API works on it. The directory belongs to the calling process and is removed when that process exits, also when it's killed.Fil.Tmp.path/1returns the path for tools that need one,Fil.Tmp.give_away/2hands the directory to another process, andFil.Tmp.cleanup/1removes it earlier. (#18)- A Fil disk smart cell for Livebook, which generates the
Fil.disk/1call for a Local, S3 or Memory disk, with the S3 credentials from Livebook secrets. It's there when Kino is installed. (#21) Fil.Kino.upload/3is a file field for Livebook that writes each upload to a directory of a disk, streamed from the file Livebook keeps.writable: trueonFil.Kino.browser/3adds it below the browser, together with a delete button for each file. (#20)Fil.Kino.browser/3browses a disk in Livebook: one directory at a time, with a preview of images and text and a download button. Kino is a new optional dependency. (#19)Fil.Plugin.Thumbnailswrites smaller copies of images when they're written, sized per variant, and deletes, copies and renames them with the image. They go underthumbnails/, or wherever a:variant_pathfunction puts them, such as next to the image.generate/1makes them later, withmode: :manualor for images stored before. It needs the optional Vix dependency. (#14)- Telemetry events for every operation on every disk,
[:fil, :op, :start],[:fil, :op, :stop]and[:fil, :op, :exception], with the operation, the disk, the path, the error and the bytes read or written. Each read of a stream fromFil.stream/3is a[:fil, :stream, ...]span of its own, with the bytes read. SeeFil.Telemetry. (#12) Fil.Telemetry.attach_default_logger/1logs every operation and every read of a stream, with its duration. (#12)- S3 uploads a stream without a size, a stream with
checksum:, and content over 5 GiB in parts (a multipart upload), with one part in memory at a time: 8 MiB by default, set with the new:part_sizeoption. A stream that fits in one part is still one PutObject. A part that fails because the storage is unavailable is sent once more, a second later. A failed upload is aborted, and so is the upload of a process that's killed. A:crc32checksum covers the whole file, however it's uploaded. A:sha256or:sha1checksum of an upload in parts covers each part, and S3 stores a checksum of those:Fil.stat/3returnsnilfor it, andverify_checksum: truechecks the content against it. (#11) Fil.stream/1,2,3andFil.stream!/1,2,3return a file's content as a stream of binaries, on every disk. They check the file right away and read it when the stream is enumerated. (#10)Fil.write/4takes a stream as well as iodata, and{:file, path}for a local file, such as thepathof aPlug.Upload. A stream that raises writes nothing. S3 sends a stream of known size as it's read, in one request, and uploads any other in parts.Filfinds the size of a local file, of aFile.Streamof bytes and of a stream fromFil.stream/3itself, and the newsize:option gives it for other streams.size: :unknownturns finding it off, for a file that grows while it's written (a log) or whose stat size may be wrong (/sys, network and FUSE file systems). (#10, #15, #38)Fil.Op.update_content/2andFil.Op.update_result/2take astream:function, which transforms a stream lazily, chunk by chunk or with state across chunks.op.streamingmarks a read fromFil.stream/3. A read transform that raises one ofFil's errors turns the read into that error. The plugins guide describes what a plugin can rely on. (#10)Fil.ConflictError: the file changed while the operation used it, so reading it again and retrying can help. A write of a stream whose sizeFilfound, and a copy across disks, return it when the file changes size meanwhile, and write nothing. S3 returns it when something else aborted an upload in parts.Fil.Pluganswers it with a409. (#10, #11, #15)stream/3is an optional adapter callback. Adapters without it stream the result ofread/3as one chunk. (#10)disposition:onFil.signed_url/3sets thecontent-dispositionof the download (:inline,:attachmentor{:attachment, filename}), on S3 asresponse-content-dispositionand on Local and Memory throughFil.Plug. (#1):public_endpointonFil.Adapter.S3builds public and signed URLs for a host other than the one the disk sends its requests to, such aslocalhostfor a container the application reaches by its service name. (#1)query:onFil.signed_url/3signs extra query parameters into the URL, e.g. for a page that reads them. (#1)
Changed
- A recursive
Fil.Adapter.ls/3returns the directories of the subtree too, at least every directory that holds a file, andFilleaves them out unless the caller asks withtype: :directory. Adapters of your own need to list them, ortype: :directoryandFil.wildcard/3patterns that match directories find nothing below the first level. (#40) Fil.LiveViewneeds Phoenix 1.8: the optional dependency is{:phoenix, "~> 1.8", optional: true}now, so an app with an older Phoenix can't resolvefilat all. The uploader for direct uploads is LiveView colocated JS, and LiveView 1.2 raises at compile time when a component with colocated JS compiles below Phoenix 1.8. (#26)content_type,sizeandif_existsare reserved names in thequery:option ofFil.signed_url/3, which raises for them like forexpiresordisposition. (#26)- S3's
InvalidRequesterror code is aFil.InvalidRequestErrorinstead of aFil.UnknownError. S3 sends it for a copy or a move of an object onto itself, for example.Fil.Plugstill answers it with a500and logs it, because S3 sends it for problems with the request or the bucket's configuration as well. (#16) Fil.Pluganswers an upload that the disk refuses for its content, aFil.InvalidRequestErrorthat isn't about the path, with a422instead of a404. (#14)Fil.write/4raisesArgumentErrorfor a list that isn't iodata, such as[70_000], before any plugin sees it. Before, a plugin that replaced the content or answered the call itself hid it. (#12)Fil.Op.update_content/2andupdate_result/2takeiodata:instead ofbinary:, which now raisesArgumentErrorlike any unknown transform. The function gets the same argument as before. (#10)Fil.Plugstreams uploads intoFil.write/4, with thecontent-lengthassize:, and sends downloads as a chunked response, so neither has to fit in memory. (#10)- A copy or a move across disks streams the file instead of reading it into memory, with the source's size, so S3 sends it in one request as it's read (in parts when a plugin on the source changes the content). (#10)
if_exists: :erroron a local disk writes to a temporary file and hard-links it into place, so a failed write no longer leaves a partial file behind. (#10)- Local writes name their temporary file
.fil-and a short suffix, which listings skip, so a name near the filesystem's limit works. A failed write also removes the directories it created. When the writing process is killed, its.fil-file and those directories are removed as well, as far as possible. (#10, #17) Fil.Plugrefuses a signed URL with a query parameter that wasn't signed, as S3 does. URLs signed by 0.1 still work. (#1)
Removed
- Elixir 1.16 and 1.17 are no longer supported; Fil needs Elixir 1.18 or later. (#15)
- OTP 26 and older are no longer supported; Fil needs OTP 27 or later, and supports the last three OTP releases. (#24)
chunk:inFil.Op.update_content/2andupdate_result/2: usestream:withStream.map/2. (#10)
Fixed
- A recursive
Fil.ls/3on S3 no longer returns directory markers (the empty objects whose key ends in/that consoles create for a folder) as files. (#40) Fil.Pluganswers403instead of500for signed URLs whose query parameters aren't plain strings. (#1)Fil.cp/3andFil.rename/3withif_exists: :errorreplaced an existing file within one disk, and refused only across disks. They now returnFil.AlreadyExistsErroron every adapter and leave both files as they were. The check is atomic: a hard link on local disk,:ets.insert_new/2in memory, andIf-None-Match: *on the CopyObject on S3. On local disk and in memory, a write that replaces the source during such a move stays, and the move fails with aFil.ConflictError. A move withoutif_exists: :errorin memory keeps that write too, like one on local disk. (#16)- A copy or a move on local disk that failed left behind the directories it created for the destination. It removes them now, like a write. (#16)
0.1.0 - 2026-09-27
Added
- First release.