Skip to main content

adminx_core/
attach.rs

1// adminx-core/src/attach.rs
2//
3// The file-attachment seam. A resource can declare `file_fields()`; the panel
4// then renders an upload widget on the detail page and the web adapters expose
5// attach / serve / detach routes. The actual bytes live behind a pluggable
6// `Attachments` backend registered once — exactly like `storage`, `authz` and
7// `audit`. Core never names object storage, S3, or a filesystem.
8//
9// Uploads ride a *dedicated* endpoint, not the create/edit form, so the existing
10// URL-encoded form pipeline is untouched: a file is attached to a record that
11// already exists.
12
13use crate::error::CoreError;
14use crate::response::ApiResponse;
15use crate::storage::StorageError;
16use async_trait::async_trait;
17use once_cell::sync::OnceCell;
18
19/// A file field a resource exposes on its detail page. Declared from
20/// [`Resource::file_fields`](crate::resource::Resource::file_fields).
21#[derive(Debug, Clone)]
22pub struct FileField {
23    /// Stable key for the field, used in the attach/serve URLs (e.g. `avatar`).
24    pub name: String,
25    /// Human label shown above the widget.
26    pub label: String,
27    /// Restrict what the file picker offers (the `accept` attribute), e.g.
28    /// `"image/*"`. Empty means anything.
29    pub accept: String,
30}
31
32impl FileField {
33    pub fn new(name: impl Into<String>, label: impl Into<String>) -> Self {
34        Self {
35            name: name.into(),
36            label: label.into(),
37            accept: String::new(),
38        }
39    }
40
41    /// Only offer images in the picker. Advisory — the browser hint, not a
42    /// server-side content check.
43    pub fn images(mut self) -> Self {
44        self.accept = "image/*".into();
45        self
46    }
47
48    pub fn accept(mut self, accept: impl Into<String>) -> Self {
49        self.accept = accept.into();
50        self
51    }
52}
53
54/// A file arriving on an upload request. The adapter builds this from the
55/// multipart body; `bytes` is the whole file held in memory (fine for the admin
56/// use-case — logos, avatars, small docs — a streaming path can come later).
57#[derive(Clone)]
58pub struct UploadedFile {
59    pub filename: String,
60    pub content_type: String,
61    pub bytes: Vec<u8>,
62}
63
64/// One stored attachment, as the detail page and the serve route need it.
65#[derive(Debug, Clone)]
66pub struct Attachment {
67    pub field: String,
68    pub filename: String,
69    pub content_type: String,
70    pub byte_size: u64,
71    /// Opaque key the backend uses to fetch the bytes.
72    pub storage_key: String,
73}
74
75/// A pluggable attachment backend: stores the bytes somewhere and records the
76/// metadata so it can be listed and served back.
77#[async_trait]
78pub trait Attachments: Send + Sync {
79    /// Store (or replace) the file attached to `owner_type`/`owner_id` under
80    /// `field`, returning the stored metadata.
81    async fn put(
82        &self,
83        owner_type: &str,
84        owner_id: &str,
85        field: &str,
86        file: UploadedFile,
87    ) -> Result<Attachment, StorageError>;
88
89    /// The attachment stored under a single field, if any.
90    async fn get(
91        &self,
92        owner_type: &str,
93        owner_id: &str,
94        field: &str,
95    ) -> Result<Option<Attachment>, StorageError>;
96
97    /// Every attachment for a record, across all fields.
98    async fn list(
99        &self,
100        owner_type: &str,
101        owner_id: &str,
102    ) -> Result<Vec<Attachment>, StorageError>;
103
104    /// Fetch the raw bytes for a stored attachment.
105    async fn read(&self, storage_key: &str) -> Result<Vec<u8>, StorageError>;
106
107    /// Remove one field's attachment (bytes and metadata). A no-op if absent.
108    async fn delete(
109        &self,
110        owner_type: &str,
111        owner_id: &str,
112        field: &str,
113    ) -> Result<(), StorageError>;
114
115    /// Remove *all* attachments for a record. Called when the record itself is
116    /// deleted, so blobs don't outlive their owner.
117    async fn delete_all(&self, owner_type: &str, owner_id: &str) -> Result<(), StorageError>;
118}
119
120static ATTACHMENTS: OnceCell<Box<dyn Attachments>> = OnceCell::new();
121
122/// Register the global attachment backend. Set-once, matching the other seams.
123pub fn set_attachments(backend: Box<dyn Attachments>) {
124    if ATTACHMENTS.set(backend).is_err() {
125        tracing::warn!("adminx attachments backend already initialized; ignoring reset");
126    }
127}
128
129/// The registered backend, if any.
130pub fn attachments() -> Option<&'static dyn Attachments> {
131    ATTACHMENTS.get().map(|b| b.as_ref())
132}
133
134/// Whether attachment support is on. The detail page checks this before
135/// rendering upload widgets, and `crud::delete` before trying to purge.
136pub fn is_enabled() -> bool {
137    ATTACHMENTS.get().is_some()
138}
139
140/// The error returned when a file operation is attempted with no backend
141/// registered — a misconfiguration (a resource declared `file_fields()` but the
142/// app never called `adminx_storage::init`).
143fn not_configured() -> ApiResponse {
144    CoreError::Internal(
145        "file attachments are not configured; register a backend with \
146         adminx_storage::init(..)"
147            .into(),
148    )
149    .into()
150}
151
152/// Store an uploaded file, returning a ready `ApiResponse`. The adapter calls
153/// this after parsing multipart; auth is the caller's responsibility (the
154/// resource checks `Update` before invoking).
155pub async fn store(
156    owner_type: &str,
157    owner_id: &str,
158    field: &str,
159    file: UploadedFile,
160) -> Result<Attachment, ApiResponse> {
161    let backend = attachments().ok_or_else(not_configured)?;
162    backend
163        .put(owner_type, owner_id, field, file)
164        .await
165        .map_err(|e| CoreError::from(e).into())
166}
167
168/// List a record's attachments for display. Returns empty (never errors to the
169/// caller) so the detail page renders even when the backend hiccups.
170pub async fn list(owner_type: &str, owner_id: &str) -> Vec<Attachment> {
171    let Some(backend) = attachments() else {
172        return Vec::new();
173    };
174    match backend.list(owner_type, owner_id).await {
175        Ok(v) => v,
176        Err(e) => {
177            tracing::error!("adminx: failed to list attachments: {e}");
178            Vec::new()
179        }
180    }
181}
182
183/// Purge every attachment for a record. Best-effort: a failure is logged, not
184/// propagated, so a storage hiccup can't block a delete the user asked for.
185pub async fn purge(owner_type: &str, owner_id: &str) {
186    if let Some(backend) = attachments() {
187        if let Err(e) = backend.delete_all(owner_type, owner_id).await {
188            tracing::error!("adminx: failed to purge attachments for {owner_type}/{owner_id}: {e}");
189        }
190    }
191}