Search by

uhifadhi / storage-module

eemjema

Storage: the platform's file-storage machinery — named Flysystem storages, a private evidence API with a detected-MIME allowlist, thumbnails, and authenticated streaming behind a per-module permission contribution point.

Package info

github.com/uhifadhilabs/storage-module

Type:symfony-bundle

pkg:composer/uhifadhi/storage-module

Statistics

Installs: 73

Dependents: 2

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.1 2026-09-07 01:33 UTC

This package is auto-updated.

Last update: 2026-09-13 00:26:18 UTC


README

The uhifadhi platform's file-storage machinery: named Flysystem storages, a private evidence API with a detected-MIME allowlist, thumbnails, and one authenticated route by which any of it comes back out.

Contents

What it is

Mechanism only. The bundle owns no entities, no migrations and no screens of a module's own: it holds the named storages, the store/stream/delete API, the MIME allowlist and size cap, thumbnail generation, and the authenticated serving route. Which record a file hangs off, and who may read it, stays with the module that wrote the key — see the charter.

It also owns the whole upload feature — the component, one endpoint and a target contract — because a file used to enter the product through five doors that each drew their own box, their own error and their own missing progress bar. See Uploads.

On top of that machinery it ships one optional cross-module screen, the Files hub (/files), which is a dashboard on the widget machinery ShellBundle ships — which is why the core, uhifadhi/uhifadhi, is a hard requirement rather than a suggestion. The hub itself has no upload: a file arrives by being attached to a record, never by being put in a folder.

Installation

composer require uhifadhi/storage-module

The core is not on Packagist yet, so the installation names where it comes from — a repositories entry in a dependency's own composer.json is ignored, and this line belongs in the application's:

"repositories": [
    { "type": "vcs", "url": "https://github.com/uhifadhilabs/uhifadhi" }
]

The recipe registers the bundles and writes config/packages/storage.yaml and config/routes/storage.yaml. Without Flex, config/bundles.php needs three lines:

League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
Uhifadhi\Bundle\ShellBundle\ShellBundle::class => ['all' => true],
Uhifadhi\Storage\UhifadhiStorageBundle::class => ['all' => true],

The bundle prepends its own flysystem block, so an installation never writes config/packages/flysystem.yaml to get an evidence store.

php.ini has to accept what this module accepts

PHP applies its own upload ceiling before a line of this bundle runs, and the stock production php.ini sits well under the 12 MiB the module accepts by default — so a phone photograph arrives truncated and is refused. Raise both values, in the ini the web SAPI actually loads, to at least storage.evidence.max_bytes:

upload_max_filesize = 16M
post_max_size = 20M

post_max_size carries the whole multipart body, so keep it the larger of the two. Where the server accepts less than the configured cap, the bundle writes one warning per process naming both numbers, and a file PHP cut short is refused with the server's limit in the sentence rather than with "did not arrive intact".

ShellBundle keeps its widget layouts in two tables of its own, so after installing run your own doctrine:migrations:diff and migrate. It answers Uhifadhi\Contracts\Entity\UserInterface from TeamBundle, in the same package, so an installation writes no resolve_target_entities line.

Getting started

An unconfigured installation already has a working, private, on-disk evidence store. Three steps put files through it:

1 · Store and read bytes from the module that owns the record:

use Uhifadhi\Storage\Service\EvidenceStorage;

$stored = $evidence->store($uploadedFile, 'observation/'.$uuid, $clientUuid);

$stored->key;        // RELATIVE, always — this is what you record on your entity
$stored->mimeType;   // DETECTED, never what the client claimed
$stored->thumbKey;   // the ~400px variant, or NULL

2 · Mount the routes, so stored bytes can come back out:

# config/routes/storage.yaml
storage:
    resource: '@UhifadhiStorageBundle/src/Controller/'
    type: attribute

The serving route storage_evidence_show (GET /storage/evidence/{key}) is registered only when SecurityBundle is in the kernel.

3 · Ship a voter for your key prefix, tagged uhifadhi.evidence_access_voter. Storage cannot know what an observation is, so it asks the module that wrote the key — and denies by default until a voter claims it and agrees.

Configuration (a different adapter, a narrower allowlist, the Files hub) is optional and layered on from there.

Uploads: one interface, one Twig line

A module that wants people to be able to attach a file somewhere does not write a controller, a route, a stylesheet or a line of JavaScript. It implements UploadTargetInterface, tags it, and writes one line in its template.

final readonly class SightingEvidenceTarget implements UploadTargetInterface
{
    public function kind(): string { return 'sighting'; }                      // the key prefix
    public function accepts(string $targetId): ?object { /* the record, or null */ }
    public function mayUpload(object $record, UserInterface $user): bool { /* … */ }
    public function constraints(object $record): UploadConstraints { /* kinds, size, how many */ }
    public function received(object $record, StoredFile $file, UserInterface $user): UploadReceipt
    {
        /* write your row; say what the file became */
    }
    public function mayRemove(string $key, UserInterface $user): bool { /* … */ }
    public function removed(string $key, UserInterface $user): void { /* unpick your row */ }
}
$services->set('sighting.upload_target', SightingEvidenceTarget::class)
    ->args([/* … */])
    ->tag(UploadTargetInterface::TAG);
{{ render_upload('sighting:' ~ sighting.uuid, 'tile') }}

Two presentations — zone, a dropzone card where receiving the file IS the step, and tile, one cell of a grid of things already attached — one endpoint (POST /files/upload, DELETE /files/{key}), and every refusal a sentence written by whoever refused, naming what the TARGET takes rather than what the file is.

The shipped allowed_mime_types default covers one of each kind the hub names — photographs, application/pdf, and a GPX under the three types a track can arrive as. A deployment may narrow it; see docs/configuration.md. The full contract, the worked example, the events and the controllers.json entry are in docs/uploads.md.

Learn more

  • docs/charter.md — what belongs in this bundle and what stays in the owning module.
  • docs/configuration.md — the full storage.yaml reference: local and S3-compatible object storage, and why visibility is not a setting.
  • docs/evidence-api.mdEvidenceStorage, the key rules, the three exceptions, validation and thumbnails.
  • docs/serving-and-permissions.md — the serving route and the permission contribution point: voters, deny-by-default, and enumeration.
  • docs/uploads.md — the upload component and UploadTargetInterface: the contract, a worked module, the endpoint, the refusal sentences and the events.
  • docs/files-hub.md — the cross-module /files screens: what an installation wires, the widgets, FileSourceInterface, and removal.
  • docs/adopting-in-a-module.md — step-by-step adoption for patrol-module and incident-module.
  • docs/service-reference.md — service ids, classes, the tag and the route.
  • docs/development.md — running the suite, and what CI deliberately does without.

License

AGPL-3.0-or-later — see LICENSE: the same license as the uhifadhi platform this module is part of. Use, modify and self-host freely; if you offer a modified version to users over a network, they are entitled to the source of what they're running.