italix/storage

File storage where the caller never chooses the filename: content-hash paths, extension from sniffed MIME through a whitelist

Maintainers

Package info

github.com/italix-net/storage

pkg:composer/italix/storage

Transparency log

Statistics

Installs: 4

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

3.0.0 2026-08-29 22:30 UTC

This package is not auto-updated.

Last update: 2026-08-30 22:54:34 UTC


README

PHP Version License

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.php is a string in a column and never a path;
  • the extension comes from sniffing the bytes through a whitelist, so a file called logo.png whose 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, and StoreRegistry does 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() and temporary_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 coreFileStore is 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.