All notable changes to Storehouse are documented in this file.
The format follows Keep a Changelog, and Storehouse follows Semantic Versioning.
[Unreleased]
[0.2.0] - 2026-10-23
Security
- Storehouse takes plug_crypto 2.2.1 or later, for EEF-CVE-2026-105963
(GHSA-ffw9-w85g-f2vh): with an argument of the wrong type,
Plug.Crypto's functions could write the secret they were given to the logs, in the stack trace. Storehouse's own call, inStorehouse.Local, gives it binaries only, but an app that updates Storehouse now gets the fixed plug_crypto too. - An upload's URL straight to S3 is signed with
If-None-Match: *: the bucket takes it only where no file is, so the URL cannot replace a file onceverify_upload/2checked it. The bucket's CORS must allowif-none-match; a store that refuses the header takesif_none_match: false. Storehouse.Local.Plugand the local store put an upload in place only where no file is (a link, which fails on one): two uploads of a key at once can no longer replace a checked file.:max_pixelsrefuses an image whose header gives a side of a million pixels or more, which it took as of unknown size.Storehouse.fetch/2's:max_bytesholds for every answer, a redirect's or an error's as much as the file's, and:timeoutfor the whole download: with:httpc, an answer other than a200or206was read whole, however large. Its file is created where none is.
Added
Storehouse.Ecto.add_files/4andremove_file/3: files added to a list field after those the record has, and one taken out by its key, deleted from the store once the record is saved.:max_filescaps a list field, an error of the field (reason: :too_many). The Ecto guide shows a record with many files, and maps Active Storage's calls to Storehouse's.storehouse/clientsends the page's CSRF token (its<meta name="csrf-token">) with each request to the app, unless itsheadersgive one.Resumable uploads by the tus protocol (1.0.0, with creation, creation-with-upload, termination and expiration):
Storehouse.Tus.Plug, through the app, into a bucket's multipart upload a part at a time (the adapter'smultipart_put/4), or into a file stored when it is whole, checked as every upload is; the uploads in progress kept in ETS byStorehouse.Tus, which gives up those that expired, each in the store it went to, or in aStorehouse.Tus.Stateof the app's. One owner has at most:max_uploadsin progress (10), their sizes counted against its room. Tested against tuspy, a tus client.Very large files sent from the browser straight to the bucket in parts:
Storehouse.presign_multipart/4(checked aspresign_upload/4is, with parts of a size S3 takes),presign_parts/4(each part signed with its SHA-256),multipart_parts/3(what the store has, to go on after a reload),complete_multipart/4(put together from the store's parts, never the browser's word, a missing part or another size refused, then checked asverify_upload/2checks) andabort_multipart/3; the adapter callbacks behind them, whichStorehouse.S3has;Storehouse.Plug.Multipart, the same as JSON endpoints, each request with the tokenstartsigned (its key, owner, size and parts), each part's URL signed for its size; and the JavaScript client sending large files in parts, a few at a time, each tried again, remembered across reloads.Storehouse.Sweepergives up multipart uploads never completed.A library of files, optional:
Storehouse.Ecto.Migrationsmakes a table of files (storehouse_blobs, or the app's own names) and of which records use them;Storehouse.Ecto.Blobputs a stored file in it (the same file of the same owner one row,{:existing, blob}), gives it back as a file, and checks ids are an owner's (owned/4,validate_owned/5);Storehouse.Ecto.Usagekeeps each record's set of files (record/4, from ids in JSON as much as in columns), says what uses a file (used_by/3), and refuses to delete one in use (delete/3,{:error, {:in_use, records}}), with the database's foreign key behind it.Storehouse.Ecto.Blob.Schemafor a schema of the app's own.A CDN in front:
:cache_controlonStorehouse.S3(and per call, onstore/2andpresign_upload/4) keeps aCache-Controlwith each file, however it came in (stored, presigned and signed, streamed, copied or promoted);Storehouse.Local.Plugtakes:cache_controltoo, but for a private store, whose files areprivateand kept no longer than their signed URL.Storehouse.Purgetells the CDN to forget a file deleted, its variants and the app's routes to it, by URL,Cache-Tagor prefix, as a job of the runner whose arguments never hold the credentials;Storehouse.Purge.Cloudflareis one purger.Storehouse.Proxystreams a file the app authorises through the app, with ranges,ETag,Last-Modifiedand304s. A store'sheadgives:modified_at.Responsive images:
Storehouse.Responsive.resolve/3, a photograph'ssrc,srcset,sizes, size, placeholder, colour and hashes from a preset's ladder (never wider than the original, each width at its step), as JSON or asStorehouse.Components.img/1;urls/3,warm/3andwarm_later/3, which make its sizes before the first visitor asks (in the app, or over HTTP through imgproxy and a CDN), on the app's runner, off unless configured; andStorehouse.Analyzer.Warm.Variants:
Storehouse.variant_url/3andStorehouse.variant/3, by presets only (Storehouse.Variants: widths or a ladder of them, a box to fit or fill, a crop's focus, formats chosen byAccept, quality, colour profile, metadata), at URLs that change only when their preset does. A renderer (Storehouse.Renderer) makes them in the app (Storehouse.Renderer.Vix, libvips: never enlarged, turned per EXIF, without metadata), once across a cluster's nodes, stored undervariants/<key>/and served byStorehouse.Variants.Plugfor a year; or gives URLs of a service (Storehouse.Renderer.Imgproxy, signed), or is the app's own, which refuses an original past:max_pixels(200 MP) by its header. An original deleted, swept or promoted takes its variants with it, and one past its time has none.Placeholders:
Storehouse.Analyzer.Placeholder, a step that makes a photograph's placeholder while it loads (a tiny WebP, JPEG or PNG as a data URI) and its average colour, with libvips through Vix (optional), shrinking it as it loads, turned as its EXIF says, in sRGB, with none of its metadata. Or as a hash, for a frontend to decode:Storehouse.Analyzer.ThumbHashandStorehouse.Analyzer.BlurHash, ofStorehouse.Image.ThumbHashandStorehouse.Image.BlurHash, encoders in plain Elixir that give the reference implementations' bytes.Archives:
Storehouse.Archivemakes an archive of stored files, binaries, the app's functions and streams as a stream (stream/2), into the store as it is made (store/3, S3's multipart upload), or out of the request as a job of the app's runner, then the app's function with it (store_later/3), with nothing on the disk and no more than a chunk in memory. Its formats are ofStorehouse.Archive.Format: zip (Storehouse.Archive.Zip, the default: deflate where it shrinks, the rest in deflate's stored blocks, which say where each file ends; AES-256 with a password, ZIP64 past 4 GB or 65,535 files), tar (Storehouse.Archive.Tar, pax headers, gzipped when it shrinks), or the app's.max_bytes:splits it in whole archives,expires_in:makes it expire, andsize/2gives its size before it is made. Names that could leave the folder are refused, and a stored file whose size changed stops it.Files that expire:
store/2's:expires_inkeeps a file under an expiring key (Storehouse.expiring_key/2: the prefixStorehouse.expiring_prefix/0, the days it is kept, when it expires), for a bucket's lifecycle rule of that many days;Storehouse.Sweeperdeletes them on stores with none,Storehouse.expires_at/1says when, andsigned_url/2never signs past it ({:error, :expired}after).Reprocessing:
Storehouse.Ecto.reprocess/5runs analysis steps over the files of a field in every record a query gives, a batch at a time in the order of the primary key, skipping files that have every step's result already (or with:force, none), so it can be stopped and run again; as jobs of the app's runner, ornow: truewhere it is called, with:afterto start after a primary key and:on_progress.mix storehouse.reprocessruns it with its progress.Work out of the request, on a runner the app chooses (
Storehouse.Runner): a file's analysis later, deletions later and a mirror's copies are jobs (Storehouse.Job, a worker and arguments JSON holds), kept and retried by the runner.Storehouse.Runner.Obankeeps them in Oban, through deploys, on the queue and instance ofconfig :storehouse, Storehouse.Runner.Oban, and is the default when the app runs Oban;Storehouse.Runner.Memorykeeps them in a process of the app's otherwise; an app's own module (another job system, a workflow engine) takes either's place, inconfig :storehouse, :runneror a call's:runner. Telemetry[:storehouse, :job, ...]for each run and each job given up.Moving between stores:
Storehouse.copy/2copies a file from one store to another (in S3 itself between buckets the same credentials reach, on the disk between two local stores, else a chunk at a time with its checksum checked) andStorehouse.copy_prefix/2copies many, several at once, skipping those already there, so it can run again;mix storehouse.copyruns it with its progress.Storehouse.Mirroris a store that writes to a primary and copies each file to its mirrors as jobs of the app's runner, retried until they answer, never failing the primary's write. The optional adapter callbackuploaded/1is told of a file uploaded straight to the store onceverify_upload/2keeps it. A file streamed to S3 in one request now keeps its checksum there too.Editing a photo's metadata without re-encoding it, only when asked:
Storehouse.Image.JPEGremoves the location (strip(jpeg, :gps), the Exif GPS directory zeroed and XMP's GPS properties removed), keeps only the tags named (strip(jpeg, {:keep, tags})), and writes the author's name, copyright and description (put_tags/2), in a JPEG's own segments, its tables and image data copied byte for byte and an MPF index of the pictures after it kept right.Storehouse.Analyzer.EditMetadatais the step that writes the edited file back over the original, its checksum and size now in its result, while the file's:checksumstays the upload's. It reads only a photo's metadata into memory (Storehouse.Image.JPEG.edit_file/3). What makes the changes is aStorehouse.Image.MetadataEditor, Storehouse's own (Builtin, JPEG) unless the step's:editororconfig :storehouse, :metadata_editornames one of the app's, around ExifTool for other types. A step may now give{:ok, result, path}: the steps after it read the file it left.PNG and WebP metadata, edited as a JPEG's:
Storehouse.Image.PNG(Exif, XMP and text chunks, each with its checksum; written tags also as theAuthor,CopyrightandDescriptiontext readers show; an APNG's frames copied) andStorehouse.Image.WebP(Exif and XMP chunks, and theVP8Xheader's flags; a simple WebP given a header when it is given Exif; an animation's frames copied), both reading only the metadata chunks into memory.Storehouse.Image.MetadataEditor.Builtinedits all three.Reading a file back:
Storehouse.head/2(its size, stored type and checksum, without reading it),Storehouse.read/2(all of it, refused past:max_bytesbefore a byte is read),Storehouse.stream/2(a chunk at a time, as the stream is taken;Storehouse.ReadErrorwhen the store fails halfway) andStorehouse.download/3(to a path). The optional adapter callbackshead/1andread_range/3(Storehouse.Local,Storehouse.S3), and telemetry spans for each.Cleanup:
Storehouse.list/2(the files under a prefix, a page at a time) andStorehouse.delete_prefix/2(a thousand at a time, with S3's DeleteObjects), with the optional adapter callbackslist/1anddelete_many/1.Storehouse.delete_later/2andStorehouse.delete_prefix_later/2delete as jobs of the app's runner, tried again until the store answers, andStorehouse.Sweeperdeletes the temporary files older than a day on stores with no lifecycle rules (one node of a cluster at a time;Storehouse.Sweeper.sweep/1at once).Storehouse.Memory, a store in memory for tests: every callback, each test's files its own (checkout/0,allow/2,$callers) so async tests never share them,fail_next/2for error paths, andput_upload/2for the browser's part of a direct upload.Storehouse.Test:assert_stored/1,refute_stored/1andstored_files/0.A file's own metadata:
metadataonStorehouse.File(%{}by default), kept byStorehouse.Ecto.File, given withstore/2's andverify_upload/2's:metadataand merged withStorehouse.File.put_metadata/2andStorehouse.Ecto.put_metadata/3,4.Storehouse.Metadatakeeps it to what JSON andjsonbhold. Every key of it is the app's.Storehouse.Fileas JSON (JSON.Encoder, andJason.Encoderwith Jason), without its analysis;Storehouse.File.to_map/2chooses what goes.Work after the upload:
Storehouse.Analyzer, a step of the app's that reads a stored file. What it made of the file is the file's:analysis, by step name: aStorehouse.Analysis(status, result, error, when), apart from the metadata, read withStorehouse.File.analysis/2andanalyzed/2and kept in the column byStorehouse.Ecto.File.Storehouse.analyze/3runs steps at once, each once (or again withforce: true), a failure kept with why;store/2's:analyzeruns them on the file just stored, from its path.Storehouse.analyze_later/3runs them as a job of the app's runner, then calls the app's:then.Storehouse.Ecto.analyze_later/5, andinsert/3andupdate/3with:analyze, save the results in the record's column. A span per step,[:storehouse, :analyze, ...].Storehouse.Analyzer.Size, a step that asks the store a file's real size and type.Files of an owner:
:owneronstore/2,presign_upload/4,verify_upload/2,Storehouse.Live.allow/3andconsume/3, andStorehouse.Ecto.put_file/4, puts keys under the owner's prefix and refuses another's (:not_owner);Storehouse.owns?/2andowner_key/2.:roomrefuses a file larger than the room left (:storage_full): at presign by the size declared, atverify_upload/2by the size the store gives, and as a LiveView upload streams in (mode: :stream).Storehouse.usage/2counts an owner's files in the store.What happens to files, told as it happens (
Storehouse.Notify)::stored(store/2,verify_upload/2),:analyzed(with the steps that ran; later analysis once the app's:thenhas it),:failed(a step, with why) and:deleted(a key, or a prefix and its count), to the app'sconfig :storehouse, notify: {module, function, args}, or a call'snotify:.Storehouse.Notify.PubSubbroadcasts them on the app'sPhoenix.PubSub, on a topic of the app's.The images of cameras and phones, checked by their bytes: HEIC and HEIF, AVIF (the primary image's size, cropped and turned as its
clapandirotsay), TIFF in either byte order (turned as its orientation says), and DNG asimage/x-adobe-dng, a download.Storehouse.Check.photos/0lists those a page can show beside PNG, JPEG, GIF and WebP;images/0is as it was.The same file again:
:existing, a function of a checksum, onstore/2,presign_upload/4andverify_upload/2gives{:existing, file}for a file the app has, never another owner's, with nothing stored (and, at verify, the copy just uploaded deleted).presign_upload/4's:checksum(hex or Base64,Storehouse.claimed_checksum/1) signs the upload with it: S3, the local plug andStorehouse.Memorytake only those bytes.The JavaScript is on npm as
@hattoriai/storehouse(the namestorehousethere is another's), published from CI when a release is published, by npm's trusted publishing (no token) and with provenance; a Phoenix app imports it fromdeps/storehouseas before.Several stores:
use Storehouse.S3anduse Storehouse.Localdefine a store of the app's own, with its config under its name (or another app's,otp_app:), usable wherever a store is (adapter:). An Ecto field'sstore:(Storehouse.Ecto.Fileis now a parameterized type) makes its files permanent, deletes them and analyzes them there;Storehouse.Ecto.store/2.Storehouse.Local.Plug's:storeserves a second local store.Uploads from any client:
Storehouse.Batch.presign/2andregister/2sign and check a batch of uploads straight to the store, one result per file ({:ok, _},{:existing, file}or{:error, code}), the room shared by the batch, the same bytes twice in a batch one file. Each upload comes with a token, its key and owner signed with the app's secret:register/2checks only a key with its token, and leaves any other file as it is.Storehouse.Plug.PresignandStorehouse.Plug.Registeranswer them in JSON (and takeapplication/jsononly), with the owner, the room and the files the app has asked of functions of the conn, the endpoint'ssecret_key_base, and the app's own JSON of its records from:then.storehouse/client, in the npm package: the browser's part of uploads from any client (createUploadQueue), for the presign and register plugs, with no dependencies. Each file's SHA-256 read in a Web Worker and sent with its presign, the files added together presigned in one request, a few sent at once and each tried again on a network error or the store's 5xx, registered in batches; a result, progress and cancel for each. Types instorehouse-client.d.mts.
Fixed
- The plugs of uploads from any client (
Storehouse.Plug.Presign,Register,Multipart) lost their options when a server called theirinit/1again on options already made (Bandit withplug: {plug, opts}). Storehouse.S3sent aPUTwithout a body (a copy) asapplication/octet-stream, whatever type it signed.
Changed
- Storehouse makes its requests (S3,
fetch/2, purges, warming) with Req, now a dependency, in place of:httpc: one HTTP client, which streams every answer. - The README is shorter, and its comparison table plain. Everything Storehouse does is in the new Features guide.
- A file with a HEIF
ftypbox wasapplication/octet-stream; it is nowimage/heic,image/heiforimage/avif. - Storehouse's JSON is in one place, a module of its own: the app's
config :storehouse, :json_library, else Elixir's ownJSONwhen it is there, else Jason. No other part of it calls a JSON library. - Storehouse takes Elixir 1.17 (with Jason, which 1.17 needs for JSON), and CI tests it there too.
Storehouse.Ecto.delete_files/2(and soupdate/3anddelete/3) deletes throughStorehouse.delete_later/2: retried by the cleaner when it runs, at once as before when it does not.
[0.1.0]
The first release: the storage of Kotoba and Hakushi, taken out of them.
Added
Storehouse.store/2: a file checked by its bytes and stored under a new, safe key, as aStorehouse.File, with:accept,:max_bytes,:max_pixels,:keyand:preview.Storehouse.Check: the type of a file from its bytes (PNG, JPEG, GIF and WebP images with their width and height, a JPEG's turned as its EXIF says; PDF; MP4 and WebM video; ZIP and Office documents; plain text), and every other file asapplication/octet-stream.Storehouse.Adapter, the behaviour of a store (Kotoba's and Hakushi's callbacks),Storehouse.LocalandStorehouse.Local.Plug, which serves files with their checked type, safe headers and byte ranges.Storehouse.fetch/2: a file from an address of the hosts an app allows, with redirects checked and the download stopped at:max_bytes.Storehouse.S3: Amazon S3, and the stores that speak its API (Cloudflare R2, MinIO, Tigris, LocalStack), with no other library: it signs its requests itself (Signature Version 4).- Direct uploads:
Storehouse.presign_upload/4signs an upload the browser makes straight to the store (its key, type and size), andStorehouse.verify_upload/2checks the file there by its first bytes, and deletes it when it is not what it said.Storehouse.Live.allow/3does both for a LiveView upload, with the uploader ofstorehouse.js.Storehouse.Local.Plugtakes direct uploads too, for development. - Private files:
Storehouse.signed_url/2, from S3 or the local plug (private: trueserves only signed URLs), andStorehouse.Redirect, a lasting URL that checks the person and redirects to a signed one. Storehouse.Live.consume/3: the files of a LiveView upload, each stored once, with a result for each.- Streamed uploads:
Storehouse.Live.allow/3withmode: :streamsends a file through the LiveView and on to the store as each chunk comes (Storehouse.Live.Writer), with no file on the app's disk: to S3 in the parts of a multipart upload, each with its checksum, two in flight from a process of their own, the upload held back when they are; its first bytes checked on the way. The optional adapter callbacksstream_open/2,stream_write/2,stream_close/1andstream_abort/1(Storehouse.S3,Storehouse.Local). - Temporary, then permanent:
cache: truestores a file under the temporary prefix (Storehouse.cache_prefix/0), andStorehouse.promote/2makes it permanent with a copy in the store (the optional adapter callbackcopy/2: S3's CopyObject, the local disk's copy). Storehouse.Ecto: files in a record's columns (Storehouse.Ecto.File, or a list of them), put withput_file/4, checked against the field's limits, made permanent in the save's transaction, and, withupdate/3anddelete/3, deleted from the store once the record no longer has them.- Checksums: each
Storehouse.Filehas the SHA-256 of its bytes (checksum, Base64), sent to S3 with the file (x-amz-checksum-sha256), which checks and keeps it;Storehouse.checksum/1gives it of a copy, and the optional adapter callbackchecksum/1gives a stored file's.
file or the reason a file was refused.Telemetry: `[:storehouse, :store :fetch :presign_upload :verify_upload :delete, :start :stop :exception]` spans, with the - Keys (
Storehouse.key/2,safe_name/2,valid_key?/1), display names (clean_name/1) andsafe_url?/1.