Skip to content

File Uploads

HyperAdmin supports file and image uploads via fastapi-storages. Models with FileType or ImageType columns automatically render file input widgets in create/update forms.

Quick Start

from fastapi import FastAPI
from fastapi_storages import FileSystemStorage
from fastapi_storages.integrations.sqlalchemy import FileType, ImageType
from sqlalchemy import Column
from sqlmodel import Field, SQLModel

from hyperadmin import Admin, HyperAdminSettings

# 1. Create a storage backend
storage = FileSystemStorage("uploads/")

# 2. Define a model with file columns
class Product(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str
    manual: str | None = Field(
        default=None,
        sa_column=Column(FileType(storage=storage)),
    )
    photo: str | None = Field(
        default=None,
        sa_column=Column(ImageType(storage=storage)),
    )

# 3. Pass storage to Admin
app = FastAPI()
admin = Admin(app, engine=engine, settings=settings, storage=storage)
admin.mount("/admin")

That's it. HyperAdmin will:

  • Detect FileType / ImageType columns automatically
  • Render <input type="file"> widgets in forms
  • Handle multipart form submission
  • Store files via the configured storage backend
  • Display filenames in list views and download links in detail views
  • Clean up stored files when records are deleted

Configuration

Storage

Pass any fastapi-storages backend to Admin(storage=...):

from fastapi_storages import FileSystemStorage

storage = FileSystemStorage("uploads/")
admin = Admin(app, storage=storage)

The upload directory is created automatically. Every upload is stored under a unique name (<uuid4 hex>-<sanitised name>), so uploading a file with the same name as another record's file never overwrites it.

Serving files

Files are not served by a public static mount. Each stored file is served per record and field, under the admin prefix:

GET /admin/{model}/{item_id}/file/{field}

The route:

  • requires the view permission on the model;
  • loads the record under the admin's get_queryset filter, then runs the object-level view check;
  • returns 404 unless field is a (non-sensitive) file field holding a file that exists inside the storage root;
  • sends X-Content-Type-Options: nosniff and Content-Security-Policy: sandbox;
  • sends Content-Disposition: inline only for PNG, JPEG, GIF and WebP, and attachment for everything else, SVG included.

The detail page links each file to this route.

Legacy public mount

Setting public_uploads_path (for example "/uploads") mounts the storage directory publicly at that host path again, outside the admin's authentication. Anyone who knows a file name can then fetch it. HyperAdmin logs a warning at startup when it is set.

Settings

Setting Default Description
upload_dir "uploads" Default upload directory path
public_uploads_path None Legacy escape hatch: also serve the storage directory publicly at this host path

Set via environment variable HYPERADMIN_UPLOAD_DIR or in settings:

settings = HyperAdminSettings(upload_dir="media/files")

S3-Compatible Storage

Uploads can live in an S3 bucket instead of a local directory: AWS S3, Cloudflare R2, MinIO, Hetzner Object Storage, DigitalOcean Spaces or any other S3-compatible service. Install the optional extra:

pip install 'hyper-admin[s3]'

S3StorageBackend is a fastapi_storages.S3Storage configured per instance (bucket, endpoint, region, credentials) instead of from process-wide environment variables read at import time:

from fastapi_storages.integrations.sqlalchemy import FileType
from sqlalchemy import Column
from sqlmodel import Field, SQLModel

from hyperadmin import Admin
from hyperadmin.uploads import S3StorageBackend

storage = S3StorageBackend(
    bucket_name="admin-files",
    region="eu-central-1",
    # endpoint_url="https://minio.internal:9000",  # S3-compatible services only
)

class Product(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    manual: str | None = Field(default=None, sa_column=Column(FileType(storage=storage)))

admin = Admin(app, engine=engine, storage=storage)

Credentials are optional. When access_key_id / secret_access_key are not given, boto3's own credential chain applies (AWS_* environment variables, profiles, instance and task roles).

Settings-driven configuration

Set storage_backend="s3" and Admin() builds the backend itself when no storage= is passed. Each aws_* setting reads HYPERADMIN_AWS_* first and then the plain AWS_* variable the AWS SDK and fastapi-storages use, so one set of variables configures both:

export HYPERADMIN_STORAGE_BACKEND=s3
export AWS_S3_BUCKET_NAME=admin-files
export AWS_DEFAULT_REGION=eu-central-1
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
admin = Admin(app, engine=engine)   # admin.storage is an S3StorageBackend

Models still need the storage on their columns, so build it once from the same settings and share it:

settings = HyperAdminSettings()
storage = S3StorageBackend.from_settings(settings)
# FileType(storage=storage) on the models, then:
admin = Admin(app, engine=engine, settings=settings, storage=storage)
Setting Env (prefixed / plain) Default Description
storage_backend HYPERADMIN_STORAGE_BACKEND "local" "s3" builds an S3StorageBackend when no storage= is passed
aws_s3_bucket_name HYPERADMIN_AWS_S3_BUCKET_NAME / AWS_S3_BUCKET_NAME None Bucket (required for "s3")
aws_s3_region HYPERADMIN_AWS_S3_REGION / AWS_S3_REGION / AWS_DEFAULT_REGION None Region name
aws_s3_endpoint_url HYPERADMIN_AWS_S3_ENDPOINT_URL / AWS_S3_ENDPOINT_URL None http(s)://host[:port] of an S3-compatible service; unset for AWS
aws_access_key_id HYPERADMIN_AWS_ACCESS_KEY_ID / AWS_ACCESS_KEY_ID None Static credentials (optional)
aws_secret_access_key HYPERADMIN_AWS_SECRET_ACCESS_KEY / AWS_SECRET_ACCESS_KEY None Stored as a SecretStr
aws_s3_querystring_auth HYPERADMIN_AWS_S3_QUERYSTRING_AUTH / AWS_QUERYSTRING_AUTH True Sign object URLs (presigned, time-limited)
aws_s3_custom_domain HYPERADMIN_AWS_S3_CUSTOM_DOMAIN / AWS_S3_CUSTOM_DOMAIN None Serve object URLs from a CDN or public-bucket domain (unsigned)
aws_s3_default_acl HYPERADMIN_AWS_S3_DEFAULT_ACL / AWS_DEFAULT_ACL None ACL on written objects, e.g. public-read; unset sends no ACL

Providers

The bucket must exist. endpoint_url is the service's S3 API host; the region is whatever the provider documents (auto for R2).

export AWS_S3_BUCKET_NAME=admin-files
export AWS_DEFAULT_REGION=eu-central-1
# credentials via AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, a profile or an IAM role
export AWS_S3_BUCKET_NAME=admin-files
export AWS_S3_ENDPOINT_URL=https://<account-id>.r2.cloudflarestorage.com
export AWS_S3_REGION=auto
export AWS_ACCESS_KEY_ID=<r2 access key>
export AWS_SECRET_ACCESS_KEY=<r2 secret>

R2 has no ACLs: leave aws_s3_default_acl unset. For a public bucket, set AWS_S3_CUSTOM_DOMAIN to its public domain and AWS_QUERYSTRING_AUTH=false.

export AWS_S3_BUCKET_NAME=admin-files
export AWS_S3_ENDPOINT_URL=http://localhost:9000   # http:// turns SSL off
export AWS_ACCESS_KEY_ID=minioadmin
export AWS_SECRET_ACCESS_KEY=minioadmin
export AWS_S3_BUCKET_NAME=admin-files
export AWS_S3_ENDPOINT_URL=https://fsn1.your-objectstorage.com
export AWS_S3_REGION=fsn1
export AWS_ACCESS_KEY_ID=<access key>
export AWS_SECRET_ACCESS_KEY=<secret key>
export AWS_S3_BUCKET_NAME=admin-files
export AWS_S3_ENDPOINT_URL=https://fra1.digitaloceanspaces.com
export AWS_S3_REGION=fra1
export AWS_ACCESS_KEY_ID=<spaces key>
export AWS_SECRET_ACCESS_KEY=<spaces secret>

How files are served from S3

The per-record route (GET /admin/{model}/{item_id}/file/{field}) runs the same permission, queryset and object checks as for local storage, then redirects (302, Cache-Control: private, no-store) to the object URL. With aws_s3_querystring_auth on (the default) that URL is presigned and time-limited, so a private bucket works out of the box and the link stays behind the admin's authentication. Deleting a file, or a record with files, deletes the objects from the bucket.

As with local storage, a stored name is only used when it is a plain object key: names with empty, . or .. segments (which could leave the bucket prefix on a path-style URL) are refused, logged, and return 404.

public_uploads_path has no effect with S3: there is no local directory to mount.

ImageType columns

fastapi-storages reads an ImageType column by opening the image from a local path, so use FileType columns with S3.

API

hyperadmin.uploads.storage.S3StorageBackend

Bases: S3Storage

S3Storage configured per instance, for AWS S3 and S3-compatible services.

fastapi_storages.S3Storage reads its configuration from AWS_* environment variables when the module is imported. This subclass takes the same configuration as keyword arguments (or from settings via :meth:from_settings), so two backends can point at different buckets and nothing is read at import time.

Parameters:

Name Type Description Default
bucket_name str

The bucket objects are written to.

required
access_key_id str | None

Static credentials. When unset, boto3's own credential chain applies (profiles, instance and task roles, AWS_* env).

None
secret_access_key str | None

See access_key_id.

None
region str | None

The region name ("eu-central-1"). Optional for services that ignore it (MinIO) but required by AWS without a profile default.

None
endpoint_url str | None

http(s)://host[:port] of an S3-compatible service (MinIO, R2, Hetzner, Spaces). Unset for AWS S3. http:// turns SSL off for that endpoint.

None
custom_domain str | None

Serve object URLs from this host instead of the endpoint (a CDN or an R2 public bucket domain). URLs are then not signed.

None
querystring_auth bool

Sign object URLs (presigned, time-limited; default). Set False only for a public-read bucket.

True
default_acl str | None

ACL set on every written object ("public-read"). Unset (default) keeps objects private and sends no ACL, which services without ACL support (R2) require.

None
client Any

An existing boto3 S3 client to use instead of building one (tests, custom sessions). The other connection arguments are then only used to build URLs.

None
Source code in src/hyperadmin/uploads/storage.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
class S3StorageBackend(S3Storage):
    """``S3Storage`` configured per instance, for AWS S3 and S3-compatible services.

    ``fastapi_storages.S3Storage`` reads its configuration from ``AWS_*``
    environment variables when the module is imported. This subclass takes the
    same configuration as keyword arguments (or from settings via
    :meth:`from_settings`), so two backends can point at different buckets and
    nothing is read at import time.

    Args:
        bucket_name: The bucket objects are written to.
        access_key_id: Static credentials. When unset, boto3's own credential
            chain applies (profiles, instance and task roles, ``AWS_*`` env).
        secret_access_key: See ``access_key_id``.
        region: The region name (``"eu-central-1"``). Optional for services that
            ignore it (MinIO) but required by AWS without a profile default.
        endpoint_url: ``http(s)://host[:port]`` of an S3-compatible service
            (MinIO, R2, Hetzner, Spaces). Unset for AWS S3. ``http://`` turns SSL
            off for that endpoint.
        custom_domain: Serve object URLs from this host instead of the endpoint
            (a CDN or an R2 public bucket domain). URLs are then not signed.
        querystring_auth: Sign object URLs (presigned, time-limited; default).
            Set ``False`` only for a public-read bucket.
        default_acl: ACL set on every written object (``"public-read"``).
            Unset (default) keeps objects private and sends no ACL, which
            services without ACL support (R2) require.
        client: An existing boto3 S3 client to use instead of building one
            (tests, custom sessions). The other connection arguments are then
            only used to build URLs.
    """

    def __init__(  # noqa: PLR0913 - one keyword per S3 connection setting
        self,
        *,
        bucket_name: str,
        access_key_id: str | None = None,
        secret_access_key: str | None = None,
        region: str | None = None,
        endpoint_url: str | None = None,
        custom_domain: str | None = None,
        querystring_auth: bool = True,
        default_acl: str | None = None,
        client: Any = None,
    ) -> None:
        if not bucket_name:
            msg = "S3StorageBackend requires a bucket name"
            raise ValueError(msg)
        scheme, host = _split_endpoint(endpoint_url)
        self.bucket_name = bucket_name
        self.region = region
        self.custom_domain = custom_domain
        self.querystring_auth = querystring_auth
        self.default_acl = default_acl
        self._http_scheme = scheme
        endpoint = f"{scheme}://{host}" if host else None
        if client is None:
            client = _s3_client(
                **_client_kwargs(
                    use_ssl=scheme == "https",
                    endpoint=endpoint,
                    region=region,
                    access_key_id=access_key_id,
                    secret_access_key=secret_access_key,
                )
            )
        self._s3 = client
        self._url = endpoint or str(client.meta.endpoint_url)

    @classmethod
    def from_settings(cls, settings: HyperAdminSettings, *, client: Any = None) -> S3StorageBackend:
        """Build a backend from the ``aws_*`` settings (``HYPERADMIN_AWS_*`` / ``AWS_*`` env).

        Raises:
            ValueError: ``aws_s3_bucket_name`` is unset.
        """
        if not settings.aws_s3_bucket_name:
            msg = (
                "S3 storage requires a bucket: set aws_s3_bucket_name "
                "(HYPERADMIN_AWS_S3_BUCKET_NAME or AWS_S3_BUCKET_NAME)"
            )
            raise ValueError(msg)
        secret = settings.aws_secret_access_key
        return cls(
            bucket_name=settings.aws_s3_bucket_name,
            access_key_id=settings.aws_access_key_id,
            secret_access_key=secret.get_secret_value() if secret is not None else None,
            region=settings.aws_s3_region,
            endpoint_url=settings.aws_s3_endpoint_url,
            custom_domain=settings.aws_s3_custom_domain,
            querystring_auth=settings.aws_s3_querystring_auth,
            default_acl=settings.aws_s3_default_acl,
            client=client,
        )

    def get_name(self, name: str) -> str:
        """Return the object key for ``name``: the basename sanitised, directories kept."""
        path = PurePosixPath(name.replace("\\", "/"))
        safe = secure_filename(path.name)
        return str(path.parent / safe) if safe else name

    def get_path(self, name: str) -> str:
        """Return the URL of the object ``name``.

        Served from ``custom_domain`` when set, otherwise presigned through the
        client when ``querystring_auth`` is on (default), otherwise the plain
        path-style URL under the endpoint.
        """
        key = self.get_name(name)
        if self.custom_domain:
            return f"{self._http_scheme}://{self.custom_domain}/{key}"
        if self.querystring_auth:
            params = {"Bucket": self.bucket_name, "Key": key}
            return str(self._s3.generate_presigned_url("get_object", Params=params))
        return f"{self._url}/{self.bucket_name}/{key}"

    def get_size(self, name: str) -> int:
        """Return the object size in bytes (one ``HeadObject`` call)."""
        response = self._s3.head_object(Bucket=self.bucket_name, Key=self.get_name(name))
        return int(response["ContentLength"])

    def write(self, file: BinaryIO, name: str) -> str:
        """Upload ``file`` as ``name`` and return the object key.

        No ``ACL`` parameter is sent when ``default_acl`` is unset: boto3
        rejects ``ACL=None``, and services such as R2 reject any ACL at all.
        """
        file.seek(0, 0)
        key = self.get_name(name)
        content_type, _ = mimetypes.guess_type(key)
        params: dict[str, Any] = {"ContentType": content_type or _DEFAULT_CONTENT_TYPE}
        if self.default_acl:
            params["ACL"] = self.default_acl
        self._s3.upload_fileobj(file, self.bucket_name, key, ExtraArgs=params)
        return key

    def delete(self, name: str) -> None:
        """Delete the object ``name`` from the bucket."""
        self._s3.delete_object(Bucket=self.bucket_name, Key=self.get_name(name))

delete(name)

Delete the object name from the bucket.

Source code in src/hyperadmin/uploads/storage.py
210
211
212
def delete(self, name: str) -> None:
    """Delete the object ``name`` from the bucket."""
    self._s3.delete_object(Bucket=self.bucket_name, Key=self.get_name(name))

from_settings(settings, *, client=None) classmethod

Build a backend from the aws_* settings (HYPERADMIN_AWS_* / AWS_* env).

Raises:

Type Description
ValueError

aws_s3_bucket_name is unset.

Source code in src/hyperadmin/uploads/storage.py
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
@classmethod
def from_settings(cls, settings: HyperAdminSettings, *, client: Any = None) -> S3StorageBackend:
    """Build a backend from the ``aws_*`` settings (``HYPERADMIN_AWS_*`` / ``AWS_*`` env).

    Raises:
        ValueError: ``aws_s3_bucket_name`` is unset.
    """
    if not settings.aws_s3_bucket_name:
        msg = (
            "S3 storage requires a bucket: set aws_s3_bucket_name "
            "(HYPERADMIN_AWS_S3_BUCKET_NAME or AWS_S3_BUCKET_NAME)"
        )
        raise ValueError(msg)
    secret = settings.aws_secret_access_key
    return cls(
        bucket_name=settings.aws_s3_bucket_name,
        access_key_id=settings.aws_access_key_id,
        secret_access_key=secret.get_secret_value() if secret is not None else None,
        region=settings.aws_s3_region,
        endpoint_url=settings.aws_s3_endpoint_url,
        custom_domain=settings.aws_s3_custom_domain,
        querystring_auth=settings.aws_s3_querystring_auth,
        default_acl=settings.aws_s3_default_acl,
        client=client,
    )

get_name(name)

Return the object key for name: the basename sanitised, directories kept.

Source code in src/hyperadmin/uploads/storage.py
169
170
171
172
173
def get_name(self, name: str) -> str:
    """Return the object key for ``name``: the basename sanitised, directories kept."""
    path = PurePosixPath(name.replace("\\", "/"))
    safe = secure_filename(path.name)
    return str(path.parent / safe) if safe else name

get_path(name)

Return the URL of the object name.

Served from custom_domain when set, otherwise presigned through the client when querystring_auth is on (default), otherwise the plain path-style URL under the endpoint.

Source code in src/hyperadmin/uploads/storage.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
def get_path(self, name: str) -> str:
    """Return the URL of the object ``name``.

    Served from ``custom_domain`` when set, otherwise presigned through the
    client when ``querystring_auth`` is on (default), otherwise the plain
    path-style URL under the endpoint.
    """
    key = self.get_name(name)
    if self.custom_domain:
        return f"{self._http_scheme}://{self.custom_domain}/{key}"
    if self.querystring_auth:
        params = {"Bucket": self.bucket_name, "Key": key}
        return str(self._s3.generate_presigned_url("get_object", Params=params))
    return f"{self._url}/{self.bucket_name}/{key}"

get_size(name)

Return the object size in bytes (one HeadObject call).

Source code in src/hyperadmin/uploads/storage.py
190
191
192
193
def get_size(self, name: str) -> int:
    """Return the object size in bytes (one ``HeadObject`` call)."""
    response = self._s3.head_object(Bucket=self.bucket_name, Key=self.get_name(name))
    return int(response["ContentLength"])

write(file, name)

Upload file as name and return the object key.

No ACL parameter is sent when default_acl is unset: boto3 rejects ACL=None, and services such as R2 reject any ACL at all.

Source code in src/hyperadmin/uploads/storage.py
195
196
197
198
199
200
201
202
203
204
205
206
207
208
def write(self, file: BinaryIO, name: str) -> str:
    """Upload ``file`` as ``name`` and return the object key.

    No ``ACL`` parameter is sent when ``default_acl`` is unset: boto3
    rejects ``ACL=None``, and services such as R2 reject any ACL at all.
    """
    file.seek(0, 0)
    key = self.get_name(name)
    content_type, _ = mimetypes.guess_type(key)
    params: dict[str, Any] = {"ContentType": content_type or _DEFAULT_CONTENT_TYPE}
    if self.default_acl:
        params["ACL"] = self.default_acl
    self._s3.upload_fileobj(file, self.bucket_name, key, ExtraArgs=params)
    return key

File Validation

Use validate_upload() for custom validation in action handlers:

from hyperadmin.uploads import validate_upload, FileValidationError

try:
    validate_upload(
        file,
        allowed_types=["image/jpeg", "image/png"],
        max_size=5 * 1024 * 1024,  # 5 MB
    )
except FileValidationError as e:
    # Handle validation error
    ...

Images: EXIF Rotation, Metadata and Thumbnails

hyperadmin.uploads.images adds three pure helpers for ImageType fields. All of them refuse decompression bombs before decoding a single pixel (the header size is checked against MAX_IMAGE_PIXELS, Pillow's own default) and never raise on non-image input.

from hyperadmin.uploads import (
    ImageTooLargeError,
    auto_rotate,
    extract_metadata,
    generate_thumbnail,
    prepare_image_upload,
)

meta = extract_metadata(upload)  # ImageMetadata(width, height, format, orientation) or None
upright = auto_rotate(pil_image)  # transposed to its EXIF orientation, tag removed
thumb = generate_thumbnail(storage, "photo.jpg", max_size=(200, 200))
# -> ".thumbs/photo_200x200.jpg" (cached next to the original),
#    or PLACEHOLDER_THUMBNAIL when the file is missing, corrupt or too large

The admin's create and update forms call prepare_image_upload() on every ImageType upload before the column saves it: a photo whose camera recorded an EXIF orientation is re-encoded upright, so it displays correctly everywhere. An image above the pixel limit raises ImageTooLargeError (a FileValidationError), which the form answers with a 422 keyed by the field name.

How It Works

  1. Field Detection: classify_field() inspects SQLAlchemy column types. FileType columns return FileFieldMeta(is_image=False), ImageType columns return FileFieldMeta(is_image=True).

  2. Widget Selection: _pick_widget() maps FileFieldMeta to FileInputWidget, which renders widgets/file_input.html.

  3. Form Submission: Forms use enctype="multipart/form-data". UploadFile objects are extracted before Pydantic validation and passed through to the SQLAlchemy adapter, where the column's TypeDecorator.process_bind_param() writes the file to storage.

  4. Deletion: When a record is deleted, _cleanup_item_files() removes all associated files from storage before the database row is deleted.