adminx-attachments 3.1.0

Pluggable file attachments for the adminx admin-panel framework. Attach files to any resource, stored on local disk (S3/object-store backends to follow), with metadata over SeaORM or MongoDB and attach/serve/detach routes in the panel.
Documentation

adminx-attachments

crates.io docs.rs license: MIT

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

[dependencies]
adminx = { version = "3", features = ["axum", "seaorm", "attachments"] }

Use

// 1. storage first
let store = adminx::seaorm::connect(&database_url).await?;

// 2. the metadata table (SQL backends only; Mongo auto-creates)
for stmt in adminx::attachments::migrate_sql() {
    store.execute_sql(stmt).await?;
}
adminx::set_storage(Box::new(store));

// 3. register a backend — local disk here; swap for object-store in production
adminx::attachments::init_local("./adminx_uploads");

Then a resource opts in by declaring file fields:

impl Resource for Post {
    // ...
    fn file_fields(&self) -> Vec<adminx::attach::FileField> {
        vec![adminx::attach::FileField::new("cover", "Cover image").images()]
    }
}

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-Disposition header — 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 Read check), 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:

adminx::attachments::init(adminx::cloud::s3_from_env("my-bucket")?);   // or gcs_/azure_

To store bytes anywhere else, implement BlobStore (three methods — put / get / delete) and register it with init(Box::new(your_store)).

#[async_trait]
impl BlobStore for MyS3Store {
    async fn put(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError> { /* ... */ }
    async fn get(&self, key: &str) -> Result<Vec<u8>, StorageError> { /* ... */ }
    async fn delete(&self, key: &str) -> Result<(), StorageError> { /* ... */ }
}

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