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/ImageTypecolumns 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
viewpermission on the model; - loads the record under the admin's
get_querysetfilter, then runs the object-levelviewcheck; - returns 404 unless
fieldis a (non-sensitive) file field holding a file that exists inside the storage root; - sends
X-Content-Type-Options: nosniffandContent-Security-Policy: sandbox; - sends
Content-Disposition: inlineonly for PNG, JPEG, GIF and WebP, andattachmentfor 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, |
None
|
secret_access_key
|
str | None
|
See |
None
|
region
|
str | None
|
The region name ( |
None
|
endpoint_url
|
str | None
|
|
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 |
True
|
default_acl
|
str | None
|
ACL set on every written object ( |
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 | |
delete(name)
Delete the object name from the bucket.
Source code in src/hyperadmin/uploads/storage.py
210 211 212 | |
from_settings(settings, *, client=None)
classmethod
Build a backend from the aws_* settings (HYPERADMIN_AWS_* / AWS_* env).
Raises:
| Type | Description |
|---|---|
ValueError
|
|
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 | |
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 | |
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 | |
get_size(name)
Return the object size in bytes (one HeadObject call).
Source code in src/hyperadmin/uploads/storage.py
190 191 192 193 | |
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 | |
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
-
Field Detection:
classify_field()inspects SQLAlchemy column types.FileTypecolumns returnFileFieldMeta(is_image=False),ImageTypecolumns returnFileFieldMeta(is_image=True). -
Widget Selection:
_pick_widget()mapsFileFieldMetatoFileInputWidget, which renderswidgets/file_input.html. -
Form Submission: Forms use
enctype="multipart/form-data".UploadFileobjects are extracted before Pydantic validation and passed through to the SQLAlchemy adapter, where the column'sTypeDecorator.process_bind_param()writes the file to storage. -
Deletion: When a record is deleted,
_cleanup_item_files()removes all associated files from storage before the database row is deleted.