Skip to content

file_storage

Pluggable file storage with two shipped backends — local filesystem and S3-compatible — plus a browse / upload / download admin UI. Backend selection is per-deployment (DB-backed setting), so you can switch from filesystem (dev / single-node) to s3 (prod) without code changes.

ModuleMeta

FieldValue
nameFileStorage
route_prefix/api/file-storage
view_prefix/file-storage
depends_on["Settings"]

Routes

API

Method + pathBody / responsePermission
POST /api/file-storage/uploadmultipartStoredFileOut (201)file_storage.upload
GET /api/file-storage/files?page=&per_page=StoredFileListOutfile_storage.download
GET /api/file-storage/files/{file_id}StoredFileOutfile_storage.download
GET /api/file-storage/files/{file_id}/download→ 302 (S3) or stream (filesystem)file_storage.download
DELETE /api/file-storage/files/{file_id}→ 204file_storage.delete

View

Method + pathInertia componentPermission
GET /file-storage/FileStorage/Browsefile_storage.download

Public contracts

python
from file_storage.contracts import (
    StoredFileOut, StoredFileListOut,
    FileUploaded, FileDeleted,
    StorageBackend,
    StorageError, StorageNotFoundError, StorageBackendError,
    NotSupportedError, ConfigurationError,
)
ClassPurpose
StoredFileOutFile metadata: id, key, filename, content_type, size_bytes, backend, checksum_sha256, uploaded_by, created_at.
StoredFileListOutitems, total, page, per_page.
FileUploaded (event)file_id, key, backend, size_bytes, uploaded_by. Topic: file_storage.file.uploaded.
FileDeleted (event)file_id, key. Topic: file_storage.file.deleted.
StorageBackend (Protocol)The backend interface (see Writing your own backend).
StorageError + subclassesRaise these from a backend; the API maps them to 404 / 415 / 500.

Models

StoredFile (table file_storage_stored_file)

ColumnTypeNotes
idUUIDPK
keystr(512)backend-relative path; unique
filenamestr(255)original upload filename
content_typestr(128)sniffed / declared MIME type
size_bytesint
backendstr(32)"filesystem" or "s3" — recorded at upload time
checksum_sha256str(64)computed during stream-upload
extra_metadatadictper-backend extras
audit + soft-deletefrom AuditMixin + SoftDeleteMixin

Indexes on key (unique), created_by, is_deleted.

Settings

DB-backed via register_module_settings; pydantic defaults seed at boot. Live edits via /settings/modules/file_storage.

FieldDefaultPurpose
backend"filesystem"active backend id (filesystem | s3)
fs_root_path"./uploads"root dir for the filesystem backend (resolved against repo root)
s3_bucket""required when backend="s3"
s3_region""required when backend="s3"
s3_access_key_id""optional — falls back to env / IAM if empty
s3_secret_access_key""optional
s3_endpoint_url""optional — set for MinIO / Cloudflare R2
s3_presign_ttl_seconds300TTL for presigned GET URLs
max_file_size_bytes100 * 1024 * 1024upload limit (100 MB)
allowed_content_typesNoneoptional MIME-type whitelist; None ⇒ any

Backends

Filesystem (default)

Stores files on local disk under fs_root_path, sharded by the first two characters of the storage key (<root>/ab/abcd1234...) so a single directory doesn't blow up readdir. Path traversal is blocked. presigned_get_url() is unsupported — downloads stream through the API process.

S3

Async client built on aioboto3. aioboto3 is an optional dependency — install with uv add --optional s3 aioboto3 (or include it in your deployment's lockfile). Works against AWS S3 and any S3-compatible provider (MinIO, Cloudflare R2, …) by setting s3_endpoint_url. presigned_get_url() returns a time-limited URL so downloads don't have to round-trip through the API.

Writing your own backend

Implement the StorageBackend protocol:

python
from file_storage.contracts import StorageBackend, StoredFile

class GcsBackend(StorageBackend):
    backend_id = "gcs"
    supports_presigned_url = True

    async def put(self, key, stream, content_type, size): ...
    async def get(self, key): ...
    async def delete(self, key): ...
    async def exists(self, key) -> bool: ...
    async def presigned_get_url(self, key, ttl_seconds: int) -> str: ...

Register it in your module's register_settings so the file_storage service can pick it up. (The shipped two backends are wired into a private BACKEND_REGISTRY; see backends/__init__.py for the pattern.)

Permissions

CodeGranted toPurpose
file_storage.uploaduser, adminupload files
file_storage.downloaduser, adminlist / get / download
file_storage.deleteuser, admindelete
file_storage.manageadminreserved for future admin operations
LabelURLIconSectionGroupOrderRoles
Files/file-storagefilesSIDEBARContent40["admin"]

Events

The module publishes:

  • FileUploaded(file_id, key, backend, size_bytes, uploaded_by) — after the row is committed.
  • FileDeleted(file_id, key) — after delete (soft or hard) commits.

Subscribe from any other module's register_event_handlers:

python
from file_storage.contracts import FileUploaded

class MyModule(ModuleBase):
    def register_event_handlers(self, bus):
        bus.subscribe(FileUploaded, self._on_uploaded)

    async def _on_uploaded(self, event: FileUploaded) -> None:
        ...

Inertia pages

  • FileStorage/Browse.tsx — file list + upload dropzone; handles the upload progress + delete confirmation flow.
  • FileStorage/components/UploadDropzone.tsx — drag-drop upload child component.

Locales

Top-level keys in file_storage/locales/en.json: browse, table, actions, delete_dialog, toasts, errors. The errors namespace is keyed by error code (not_found, too_large, bad_type, backend_error) so the UI can render a deterministic message per StorageError subclass.

Released under the MIT License.