adminx-attachments
Attach files to any resource. Optional file uploads for adminx — an ActiveStorage-shaped layer for the Rust admin panel.
A resource declares which file fields it accepts; the panel then shows an upload widget on each record's detail page, and the web adapter exposes attach / serve / detach routes. Bytes live behind a pluggable backend (local disk today, object-store/S3 to follow); metadata lives in your own database.
Leave it out and adminx behaves exactly as before — no widgets, no routes, no new tables.
Two swappable layers
| Layer | Responsibility | Backend |
|---|---|---|
BlobStore |
holds the raw bytes | LocalFsStore (local disk), or S3 / GCS / Azure via adminx-cloud |
adminx_attachments table |
filename, type, size, storage key | via adminx's Storage — SeaORM or MongoDB |
The bytes go to the blob store under an opaque key; the metadata row records the original filename, content type, size, and that key. Serving reads the row, then the bytes.
Install
[]
= { = "3", = ["axum", "seaorm", "attachments"] }
Use
// 1. storage first
let store = connect.await?;
// 2. the metadata table (SQL backends only; Mongo auto-creates)
for stmt in migrate_sql
set_storage;
// 3. register a backend — local disk here; swap for object-store in production
init_local;
Then a resource opts in by declaring file fields:
An upload widget for Cover image now appears on every post's detail page, pre-filled with the current file. Images preview inline.
Routes
Each resource with file fields gains three routes under its base path:
| Method | Path | Gated on | Purpose |
|---|---|---|---|
POST |
/{resource}/{id}/attach/{field} |
Update |
upload (multipart), replacing any existing file |
GET |
/{resource}/{id}/blob/{field} |
Read |
stream the file back (inline) |
POST |
/{resource}/{id}/detach/{field} |
Update |
remove the file |
Uploads ride a dedicated multipart endpoint, not the create/edit form, so the existing URL-encoded form pipeline is untouched: attach a file to a record that already exists. All three are CSRF-protected and authorized like any other mutating route.
Lifecycle guarantees
- One file per field. Re-uploading replaces — the old bytes are deleted, no accumulation.
- Purge on delete. Deleting a record removes all its attachments (bytes and rows), so blobs never outlive their owner. Skipped on a soft delete, where the record still exists.
- No orphans on partial failure. If the metadata write fails after the bytes land, the bytes are cleaned up.
Security notes
- Filenames are sanitized before they reach the
Content-Dispositionheader — path separators and control characters are stripped, so a crafted name can't imply a path or inject a header. - Blob keys are validated at the filesystem boundary: a key containing
..or an absolute component is refused, so nothing can be written or read outside the store root. - Only declared fields are attachable. The attach endpoint rejects any field
the resource didn't list in
file_fields(). - Files are served auth-gated (the resource's
Readcheck), not via public URLs. Signed public URLs are a possible future addition.
Backend (BlobStore)
init_local(dir) is the batteries-included path. For cloud storage, add
adminx-cloud and swap one line:
init; // or gcs_/azure_
To store bytes anywhere else, implement BlobStore (three methods — put /
get / delete) and register it with init(Box::new(your_store)).
Current limits
- Whole file held in memory on upload (fine for admin assets — logos, avatars, small docs). Streaming can come later.
- No image variants / thumbnails, no signed URLs, one file per field — files are served auth-gated at full size.
Shipped since the initial cut: Actix and Axum adapters both, and S3 / GCS /
Azure backends via adminx-cloud.
Schema
migrate_sql() is SQLite-flavoured. On PostgreSQL use SERIAL PRIMARY KEY,
on MySQL INT AUTO_INCREMENT PRIMARY KEY — supply your own migration, as with
the other adminx tables.
| Column | Notes |
|---|---|
owner_type |
the resource's base_path() |
owner_id |
primary key of the owning record |
field |
the file field name (e.g. cover) |
filename / content_type / byte_size |
original upload metadata |
storage_key |
opaque key handed to the BlobStore |
created_at |
RFC 3339 |
Indexed on (owner_type, owner_id).
License
MIT