NAME

Punk::Plugin::Blob - content addressed storage for uploads

SYNOPSIS

package MyApp;
use Punk;

plugin 'Blob' => { root      => '/var/lib/app/blobs',
                   namespace => 'myapp' };

post '/avatar' => sub {
    my ($c) = @_;
    my $id = $c->blob_put($c->upload('file'));
    $c->model('User')->update($c->user->{id}, { avatar => $id });
    $c->json({ id => $id });
};

get '/blob/:id' => sub { $_[0]->blob_send($_[0]->param('id')) };

DESCRIPTION

This stores bytes by their contents, on Apophis, and hands back an id.

The user's filename never becomes a path. The address is derived from the contents, so ../../etc/passwd as a filename is a string in a metadata column and nothing more. Deduplication is free: one file uploaded by a hundred users is stored once. Integrity is checkable, because the name is the hash.

An upload is never read into memory to store it

An id derived from the contents means the contents must be read - and reading them into a scalar to hash them is exactly the copy that Punk::Upload now avoids by streaming a large part to a temp file.

So an upload that is already a file is hashed where it lies, in 64 KB chunks, and then moved into the store. Measured, a 128 MiB upload costs 0.2 MiB of resident memory to store.

The id is the same one the in-memory path produces for the same bytes. It has to be: were it not, every blob stored before this would be unreachable through the new path, and that is asserted in the test suite rather than assumed.

The move is a rename when the spill directory and the store share a filesystem, and a chunked copy when they do not - so put them on the same one. A duplicate moves nothing: the bytes are already held, so the upload is left where it is rather than taken away from the caller for no reason.

Deduplication crosses tenants

Read this before namespace looks like boilerplate.

Two accounts uploading the same file store it once, and a store that finds the id present returns without writing - measurably faster than one that writes. So an account can upload a file, time the response, and learn whether another account already holds that exact file.

For most applications that is uninteresting. For anything where possession of a document is itself the confidential fact - a legal matter, a diagnosis, a CV, a leaked document - it is a real disclosure, and the kind that never appears in a threat model because the deduplication was a performance decision made somewhere else.

namespace is the control, and it takes either form:

namespace => 'myapp'                        # one namespace, dedup everywhere
namespace => sub { 'tenant-' . $_[0]->tenant_id }   # one per tenant

Different namespaces produce different ids for the same content, so a namespace per tenant makes their ids disjoint and there is nothing to probe for. The callback receives the context and is resolved per request; the store it names is built on first use and cached for the life of the worker.

The cost is the deduplication between them: a file held by a thousand tenants is stored a thousand times. That is the trade, it is yours, and it is why namespace is required rather than defaulted.

A callback that gives back an empty namespace croaks rather than falling back to a default, because the default would be somebody else's namespace.

What namespacing does not do

It makes another tenant's id underivable, not unreachable. Apophis shards on the id, so every namespace shares one directory tree: an id changes with the namespace, but where a given id lives does not. Somebody who already holds an id can still read it.

That is not the hole it looks like - an id is 128 bits and unguessable, and the thing this closes is deriving one from content you already have. But whether this request may see that blob is authorisation, and this plugin does not do authorisation.

Two different files cannot silently share an id

Apophis identifies content with UUID v5, which is SHA-1, and its store returns immediately when the id is present without comparing the bytes - the right optimisation under the assumption content addressing usually carries, and one SHA-1 has not supported since 2017.

Which would make this possible: craft two colliding files, upload the first so it is stored and approved, and then anybody who uploads the second is handed a reference that serves the first.

So a deduplication hit reads the stored blob and compares it. Equal is the ordinary case and costs one read. Unequal croaks, because it cannot be resolved: the store holds one of the two files and either answer is wrong for somebody.

A blob is served as a download

An uploader controls the bytes and claims the type. Serving those bytes back as the claimed type is stored cross-site scripting with this application's origin on it, and it is the commonest way a file-upload feature becomes a vulnerability. So blob_send defaults to the unfriendly answer:

Content-Type: application/octet-stream
Content-Disposition: attachment
X-Content-Type-Options: nosniff

nosniff matters as much as the type. Without it a browser may sniff HTML out of a response labelled octet-stream and render it anyway, so this helper sets it always and offers no way to turn it off.

Anything friendlier is a decision made out loud, and a type that came from what a client claimed at upload time goes through the allowlist first:

my $t = $c->blob_safe_type($row->{content_type});
return $t ? $c->blob_send($id, type => $t, inline => 1)
          : $c->blob_send($id);

SVG is not on that allowlist, which is the whole reason there is a list rather than a /^image\// test: an SVG is a document that can carry script, and it passes every naive image check ever written. Neither is application/pdf.

Caching, and who may keep it

Cache-Control: private, max-age=31536000, immutable

A content-addressed URL cannot come to mean anything else, so the lifetime is right. Private by default, because public on a blob behind authorisation tells a shared cache it may hand somebody's document to the next person who asks. public => 1 says otherwise.

Deleting a blob

Deduplication means several rows may reference one blob, so blob_remove unlinks bytes that another row may still name. It is here for an application that knows that cannot happen - one namespace per tenant, or one reference per blob by construction - and it is the wrong default for everything else.

The sanctioned path is a sweep: nothing unlinks during a request, and a maintenance pass asks the application which ids are still referenced.

my ($removed, $kept) = Punk::Plugin::Blob->sweep($app,
    live  => sub { MyApp->model('Attachment')->all_blob_ids },
    grace => 3600,
);

An answer that failed is not an answer that there are no blobs. A live callback that dies, or returns nothing, aborts the sweep and unlinks nothing - because acting on it would unlink the entire store. allow_empty => 1 is how an application says, in as many words, that it really does reference none.

grace is the other guard: a blob younger than it is never collected, so a file written between the caller's snapshot of live ids and the walk is not taken before its row is committed. Anything not shaped like a blob id is left alone, because a sweep that removes what it does not recognise eventually removes something that mattered.

What this is not

It does not make large uploads possible. Punk::Upload is explicit that an upload arrives whole in memory twice before a handler sees it, and nothing here changes that. This makes storage sane, not ingestion.

It is not authorisation. Whether this request may see this blob is auth_guard's question and your model's.

OPTIONS

root

Required. Where the blobs live.

namespace

Required, and not boilerplate - see "Deduplication crosses tenants".

A string is one namespace for the application. A coderef receives the context and is resolved per request, so a namespace per tenant is:

namespace => sub { 'tenant-' . $_[0]->tenant_id }

The store a namespace names is built on first use and cached for the life of the worker. Giving back an empty namespace croaks rather than falling back to a default.

HELPERS

$c->blob_put($upload | \$bytes | $bytes)

Stores the content and returns its id, comparing on a deduplication hit.

$c->blob_send($id, %options)

A finished download response through $c->send_file, so ranges, ETags, 304s, HEAD and the sendfile path all work and the bytes never enter Perl. Options are send_file's, plus public => 1.

An id that is not an id is a 404 rather than an error: it came out of a URL, so something probing /blob/../.. is told there is nothing there instead of filling the error log. A well-formed id naming no blob is a 404 too.

$c->blob_safe_type($claimed)

The claimed type if a blob may be served inline as it, otherwise undef.

$c->blob_exists($id) / $c->blob_path($id) / $c->blob_remove($id)

Whether it is there; its on-disk path; and an immediate unlink - which blob_path croaks for a bad id where blob_send answers 404, because a program calls it deliberately.

$c->blob_store / $c->blob_root

The underlying Apophis object, and the store root. With a coderef namespace the store is the one this request resolved to, not a single application-wide object.

METHODS

Punk::Plugin::Blob->sweep($app_or_root, live => sub {...}, %opts)

Removes unreferenced blobs. See "Deleting a blob".

Punk::Plugin::Blob->safe_inline_type($claimed)

The class-method form of $c->blob_safe_type.

SEE ALSO

Punk, Punk::Plugin, Apophis, Punk::Upload.

AUTHOR

LNATION <email@lnation.org>

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION <email@lnation.org>.

This is free software, licensed under:

The Artistic License 2.0 (GPL Compatible)