Skip to main content

doido_storage/
client.rs

1//! [`Storage`] — the ergonomic facade over a service + database + signer.
2//!
3//! It bundles everything the storage layer needs (the configured
4//! [`Service`](crate::Service), the sea-orm connection, and the [`Signer`]) and
5//! offers Rails-like operations: `create_and_upload`, `attach`, `one`/`many`,
6//! `purge`, `signed_id`, and URL helpers. It is `Clone` (cheap — the connection
7//! and service are reference-counted) and doubles as the axum state for
8//! [`crate::serving`].
9
10use crate::attachments;
11use crate::blob::{self, Blob};
12use crate::config;
13use crate::service::{Service, UrlOptions};
14use crate::signing::{Disposition, Signer};
15use doido_core::Result;
16use doido_model::sea_orm::DatabaseConnection;
17use serde_json::Value;
18use std::sync::Arc;
19use std::time::Duration;
20
21/// Signing purpose for a blob `signed_id` embedded in serving URLs.
22const PURPOSE_BLOB: &str = "blob";
23/// Signing purpose for a disk direct-upload token.
24pub(crate) const PURPOSE_DISK_UPLOAD: &str = "disk_upload";
25
26/// The storage facade: a service + connection + signer with high-level helpers.
27#[derive(Clone)]
28pub struct Storage {
29    conn: DatabaseConnection,
30    service: Arc<dyn Service>,
31    signer: Signer,
32    prefix: String,
33    expires_in: Duration,
34}
35
36impl Storage {
37    /// Build a storage facade from an explicit service and signer.
38    pub fn new(conn: DatabaseConnection, service: Arc<dyn Service>, signer: Signer) -> Self {
39        Self {
40            conn,
41            service,
42            signer,
43            prefix: "/doido/storage".to_string(),
44            expires_in: Duration::from_secs(300),
45        }
46    }
47
48    /// Build from the `storage` config section (current environment) plus a signer
49    /// read from `DOIDO_SECRET_KEY_BASE`.
50    pub async fn from_config(conn: DatabaseConnection) -> Result<Self> {
51        let service = config::load().build().await?;
52        Ok(Self::new(conn, service, Signer::from_env()))
53    }
54
55    /// Override the URL route prefix (default `/doido/storage`).
56    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
57        self.prefix = prefix.into();
58        self
59    }
60
61    /// Override how long generated URLs stay valid (default 5 minutes).
62    pub fn with_expiry(mut self, expires_in: Duration) -> Self {
63        self.expires_in = expires_in;
64        self
65    }
66
67    /// The sea-orm connection.
68    pub fn conn(&self) -> &DatabaseConnection {
69        &self.conn
70    }
71
72    /// The active storage service.
73    pub fn service(&self) -> &Arc<dyn Service> {
74        &self.service
75    }
76
77    /// The route prefix used by [`crate::serving::routes`].
78    pub fn prefix(&self) -> &str {
79        &self.prefix
80    }
81
82    pub(crate) fn signer(&self) -> &Signer {
83        &self.signer
84    }
85
86    pub(crate) fn expires_in(&self) -> Duration {
87        self.expires_in
88    }
89
90    /// Create the metadata tables if they don't exist (test/dev convenience).
91    pub async fn ensure_tables(&self) -> Result<()> {
92        crate::schema::ensure_tables(&self.conn).await
93    }
94
95    // --- Blobs -------------------------------------------------------------
96
97    /// Upload `data` to the service and record a blob. Detects content type,
98    /// computes the MD5 checksum and byte size.
99    pub async fn create_and_upload(
100        &self,
101        filename: &str,
102        data: Vec<u8>,
103        metadata: Option<Value>,
104    ) -> Result<Blob> {
105        let blob = blob::build(filename, &data, self.service.name(), metadata);
106        self.service
107            .upload(&blob.key, data, blob.content_type.as_deref())
108            .await?;
109        blob::insert(&self.conn, &blob).await?;
110        Ok(blob)
111    }
112
113    /// Find a blob by key.
114    pub async fn find_blob(&self, key: &str) -> Result<Option<Blob>> {
115        blob::find(&self.conn, key).await
116    }
117
118    /// Download a blob's bytes.
119    pub async fn download(&self, key: &str) -> Result<Vec<u8>> {
120        self.service.download(key).await
121    }
122
123    /// Delete a blob everywhere: the service object, its attachment rows, and the
124    /// metadata row.
125    pub async fn purge(&self, key: &str) -> Result<()> {
126        self.service.delete(key).await?;
127        attachments::delete_for_blob(&self.conn, key).await?;
128        blob::delete(&self.conn, key).await?;
129        Ok(())
130    }
131
132    // --- Attachments -------------------------------------------------------
133
134    /// Upload a file and attach it to `record` under `name` (append — for
135    /// `has_many`). Returns the new blob.
136    pub async fn attach_upload(
137        &self,
138        record_type: &str,
139        record_id: &str,
140        name: &str,
141        filename: &str,
142        data: Vec<u8>,
143    ) -> Result<Blob> {
144        let blob = self.create_and_upload(filename, data, None).await?;
145        attachments::attach(&self.conn, name, record_type, record_id, &blob.key).await?;
146        Ok(blob)
147    }
148
149    /// Attach an existing blob to `record` under `name` (append).
150    pub async fn attach(
151        &self,
152        record_type: &str,
153        record_id: &str,
154        name: &str,
155        blob_key: &str,
156    ) -> Result<()> {
157        attachments::attach(&self.conn, name, record_type, record_id, blob_key).await
158    }
159
160    /// `has_one_attached`: the attached blob, if any.
161    pub async fn one(
162        &self,
163        record_type: &str,
164        record_id: &str,
165        name: &str,
166    ) -> Result<Option<Blob>> {
167        attachments::one(&self.conn, name, record_type, record_id).await
168    }
169
170    /// `has_many_attached`: all attached blobs.
171    pub async fn many(&self, record_type: &str, record_id: &str, name: &str) -> Result<Vec<Blob>> {
172        attachments::many(&self.conn, name, record_type, record_id).await
173    }
174
175    /// Detach (but don't purge) everything in the `name` slot.
176    pub async fn detach(&self, record_type: &str, record_id: &str, name: &str) -> Result<()> {
177        attachments::detach(&self.conn, name, record_type, record_id).await
178    }
179
180    /// Purge every blob attached to a record (the `dependent: :purge` default),
181    /// then remove the attachment rows. Call from a model's after-destroy hook.
182    pub async fn purge_for_record(&self, record_type: &str, record_id: &str) -> Result<()> {
183        let keys = attachments::all_keys_for_record(&self.conn, record_type, record_id).await?;
184        for key in keys {
185            self.purge(&key).await?;
186        }
187        Ok(())
188    }
189
190    // --- Signing & URLs ----------------------------------------------------
191
192    /// A permanent signed id for a blob key (Rails `blob.signed_id`).
193    pub fn signed_id(&self, key: &str) -> String {
194        self.signer.sign(key, PURPOSE_BLOB, None)
195    }
196
197    /// Resolve a signed id back to a blob key, erroring if tampered/expired.
198    pub fn verify_signed_id(&self, signed_id: &str) -> Result<String> {
199        self.signer.verify(signed_id, PURPOSE_BLOB)
200    }
201
202    /// The redirect URL for a blob (`{prefix}/blobs/redirect/{signed_id}/{filename}`)
203    /// — the stable URL to put in HTML. The redirect handler 302s to the service's
204    /// native URL (S3/Azure) or to the proxy route (disk/memory).
205    pub fn redirect_path(&self, blob: &Blob) -> String {
206        format!(
207            "{}/blobs/redirect/{}/{}",
208            self.prefix,
209            self.signed_id(&blob.key),
210            encode_filename(&blob.filename)
211        )
212    }
213
214    /// The proxy URL for a blob (streams through the app).
215    pub fn proxy_path(&self, blob: &Blob) -> String {
216        format!(
217            "{}/blobs/proxy/{}/{}",
218            self.prefix,
219            self.signed_id(&blob.key),
220            encode_filename(&blob.filename)
221        )
222    }
223
224    /// A URL a client can GET to fetch the object: the service's native
225    /// (presigned/public) URL if it has one, else the proxy route.
226    pub async fn url_for(&self, blob: &Blob, disposition: Disposition) -> Result<String> {
227        let opts = UrlOptions {
228            expires_in: self.expires_in,
229            disposition,
230            filename: Some(blob.filename.clone()),
231            content_type: blob.content_type.clone(),
232        };
233        match self.service.url(&blob.key, &opts).await? {
234            Some(url) => Ok(url),
235            None => Ok(self.proxy_path(blob)),
236        }
237    }
238}
239
240/// Percent-ish-safe filename for a URL path segment (drops slashes).
241fn encode_filename(filename: &str) -> String {
242    filename.replace(['/', '\\'], "_")
243}