italix / storage
File storage where the caller never chooses the filename: content-hash paths, extension from sniffed MIME through a whitelist
Requires
- php: >=7.4
- ext-fileinfo: *
- ext-mbstring: *
Requires (Dev)
- italix/testing: ^2.0
This package is not auto-updated.
Last update: 2026-08-30 22:54:34 UTC
README
File storage where the caller never chooses the filename.
Zero Composer dependencies; ext-fileinfo for sniffing, ext-mbstring for the display name.
php src/Libs/Italix/Storage/tests/StorageTest.php
The safety model
$stored = $storage->put('tenant/' . $tenant_id, $upload_tmp_path, $_FILES['logo']['name']); $stored->path(); // tenant/12/a3/9f2c….png ← what to persist $stored->sha256(); $stored->mime(); // sniffed, never what the browser claimed $stored->original_name(); // metadata only, never a path
The stored path is {folder}/{shard}/{sha256}.{extension} where:
- the hash is of the content, so nothing an uploader supplies reaches the filesystem —
../../../var/www/shell.phpis a string in a column and never a path; - the extension comes from sniffing the bytes through a whitelist, so a file called
logo.pngwhose content is PHP gets whatever its bytes actually are, and PHP is not on the list.
A pleasant side effect: identical content stores once. Re-uploading the same document is free.
A whitelist, not a blacklist
The direction is the property. A blacklist has to anticipate every dangerous extension — .php,
.phtml, .php5, .phar, .htaccess, whatever the next web server adds — and is wrong the moment
one is missed. A whitelist is wrong only by refusing something harmless, which is a support ticket
rather than a compromise.
Three types are refused with a stated reason, so a reader sees the omission is deliberate:
| why | |
|---|---|
image/svg+xml |
SVG can contain script |
text/html |
HTML served from an upload directory is stored XSS |
application/xml |
entity expansion in whatever parses it later |
The folder is checked three times
Character whitelist, explicit .. refusal, and a resolved path verified to be inside the root. Two
of those should be unreachable. The cost of being wrong is arbitrary file write, which is worth one
redundant string comparison.
Validating an upload before storing it
The vocabulary already existed, so this is two checks rather than a new engine
(italix/rules 1.1.0):
$form_meta->field('logo')->rules( Rule::mime('image/png', 'image/jpeg'), Rule::max_bytes(2 * 1024 * 1024), );
Both read the file on disk — the type from finfo, the size from filesize(). A rule trusting
$_FILES['type'] would be checking a string the uploader chose.
Storing then validates again: put() refuses an unlisted type whatever the form said. Validation
gives the visitor a message they can act on; the store gives the guarantee.
Choosing what may be stored
The whitelist is compulsory and restrictive by default. Which types are on it is yours:
$mime = MimeCatalog::defaults() ->with(MimeCatalog::group('audio_video')) ->with(['image/svg+xml' => 'svg']) // your risk, your decision ->without('application/zip'); $store = new LocalStore('/data/files', null, $mime);
Groups: images, documents, office, open_document, audio_video, archives, fonts, ebooks.
Catalogues are immutable — with() returns a new one — so a catalogue handed to a store cannot be
widened afterwards by code elsewhere.
Choosing where files land
new HashedPath(0) // {folder}/{sha}.{ext} new HashedPath(2) // default — {folder}/{ab}/{cd}/{sha}.{ext} new BucketedPath(new HashedPath(1)) // {bucket}/{folder}/… — thousands of folders new DatePath(new HashedPath(1), 'Y/m') // {folder}/2026/08/… — archives
HashedPath sizes against listing cost, not read cost — see its docblock for the measurements.
BucketedPath solves the other problem: the directory that holds the folders. A folder is still
exactly one subtree, which is what keeps "move this tenant elsewhere" an rsync of one path.
A strategy decides where, never whether: its output is verified to stay inside the root exactly like a folder is, so a third-party strategy is never a security decision.
Large files
$file = $store->put_stream('videos', $handle, $name_c); // constant memory $out = $store->read_stream($file->path()); while (!feof($out)) { echo fread($out, 8192); } fclose($out);
Measured on a 74 MB file: put() peaks at 4 MB, read() at 78 MB. read() and put_contents()
remain, and are fine for an avatar — they are the convenience, not the primary route.
More than one volume
$stores = new StoreRegistry([ 'main' => static fn () => new LocalStore('/data/files'), 'archive' => static fn () => new LocalStore('/mnt/slow/files'), ], 'main'); $store = $stores->get($row['volume_c']);
Lazy: a callable is not invoked until that store is asked for. Moving a corpus to another disk or host is a subtree copy plus a config change, because a stored path is relative to a root and never absolute. If you store absolute paths, none of this works.
What ships:
LocalStore, and nothing else. There is no S3, SFTP or Google Drive driver in this package, andStoreRegistrydoes not imply one — it names stores, it does not conjure them. What 2.0.0 added is the seam that makes such a driver writable without changing core:put_stream(),read_stream()andtemporary_url(). See DRIVERS.md for the contract, and for an honest assessment of which backends fit — S3-compatible stores fit well, SFTP fits, FTP barely, and Google Drive does not fit at all, for a reason worth reading before trying.
How deep the tree goes
{folder}/{ab}/{cd}/{sha256}.{ext} — two levels, 65,536 leaf directories, configurable 1-4 via the
third constructor argument.
Measured on ext4 with dir_index:
| entries in a directory | is_file() ×2000 |
scandir() |
|---|---|---|
| 100 | 5.1 ms | 0.2 ms |
| 10,000 | 6.6 ms | 15.6 ms |
| 100,000 | 12.0 ms | 375.5 ms |
Looking a known path up barely degrades — that is what the htree is for. Listing degrades
~1,800x, and listing is what backups, rsync, du and deletion sweeps do. So the depth is chosen to
keep a leaf small, not to speed up reads.
| depth | directories | at 1M files | at 16M files |
|---|---|---|---|
| 1 | 256 | 3,906 each | 62,500 each |
| 2 (default) | 65,536 | 15 each | 244 each |
| 3 | 16,777,216 | 0.06 each | 1 each |
Depth 3 is the tempting mistake: an empty directory costs 4 KB and an inode, so 16.7M of them is ~67 GB of metadata before storing any content, to hold about one file each.
Changing the depth is not a migration. Every read path takes the stored path as an argument and never recomputes it, so files written under the old layout keep resolving from the path already in the database. The only cost is that re-uploading already-stored content writes a second copy rather than de-duplicating against the old one.
Readable URLs without giving up the safety model
The store will not let an uploader name a file on disk. That does not mean links have to be ugly — the path and the URL are different concerns, and only the first one is a safety property.
/media/9f2c8a1b0000/summer-vacation-beach-sunset-edited.jpg
^ the truth ^ decoration
$file = $store->put('uploads', $_FILES['photo']['tmp_name'], $_FILES['photo']['name']); $url = "/media/{$file->handle_token()}/{$file->public_slug()}"; // /media/9f2c8a1b0000/summer-vacation-beach-sunset-edited.jpg
The route looks the file up by the handle and never reads the slug. Change the slug and the file
still resolves — or answer 301 to the canonical form, which a search engine prefers anyway. Because
the lookup ignores it, the slug can be readable without becoming an attack surface: it is output, and
output is italix/encode's problem.
A name that slugifies to nothing falls back to the handle, so it is never empty:
| Original name | slug() |
|---|---|
Summer Vacation — Beach Sunset (Edited).JPG |
summer-vacation-beach-sunset-edited |
Café au Lait — Recipe (Final).pdf |
cafe-au-lait-recipe-final |
../../etc/passwd |
9f2c8a1b0000 (the handle) |
"" |
9f2c8a1b0000 (the handle) |
Worth being honest about the payoff: a filename is a weak signal for image search. Alt text, surrounding copy and the page title matter more. The larger win is that a human reading a URL, a log line or a download dialog can tell what the file is.
Deliberately not
- No image manipulation.
- No S3 driver in core —
FileStoreis the seam, and an out-of-tree driver costs core nothing. - No public URL signing.
- No
put_as(string $filename). Leaving it off the interface means no driver can offer it either.