Skip to main content

rust_web_server/attachment/
mod.rs

1//! Files on models (18.35.0, #94): an [`Attachment`] is a model field
2//! whose bytes live in a [`Storage`] backend (local disk, S3, Azure, GCS)
3//! and whose description — key, file name, content type, size — lives in
4//! one `TEXT` column.
5//!
6//! ```rust
7//! use rust_web_server::attachment::Attachment;
8//! use rust_web_server::storage::{LocalStorage, Storage};
9//!
10//! # let dir = std::env::temp_dir().join(format!("rws-attachment-doc-{}", std::process::id()));
11//! let storage = LocalStorage::new(dir.to_str().unwrap()).with_base_url("/uploads");
12//! let avatar = Attachment::store(&storage, "avatars", "me.png", "image/png", b"\x89PNG...").unwrap();
13//! assert!(avatar.key.starts_with("avatars/"));
14//! assert_eq!(b"\x89PNG...".to_vec(), avatar.read(&storage).unwrap());
15//!
16//! // In a model: `pub avatar: Option<Attachment>` — stored as text.
17//! let column = avatar.encode();
18//! assert_eq!(Some(avatar.clone()), Attachment::decode(&column));
19//! avatar.delete(&storage).unwrap();
20//! # let _ = std::fs::remove_dir_all(&dir);
21//! ```
22//!
23//! Keys are `<prefix>/<id>/<file name>` (`id`: 32 hex digits, time first) (the name with anything but
24//! letters, digits, `.`, `-` and `_` replaced by `_`), so uploads never
25//! overwrite each other. Deleting a row doesn't delete its file: call
26//! [`Attachment::delete`] where you delete the row.
27//!
28//! The column holds `key=…&name=…&type=…&size=…`, percent-encoded. In a
29//! `#[derive(Model)]` struct the field is `Attachment` or
30//! `Option<Attachment>` (`model-*` features), with a `TEXT` column.
31
32#[cfg(test)]
33mod tests;
34
35use crate::request::Request;
36use crate::storage::{Storage, StorageError};
37
38/// A stored file's description. See the [module docs](self).
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Attachment {
41    /// Where the bytes are in the storage backend.
42    pub key: String,
43    /// The original file name, as uploaded.
44    pub filename: String,
45    pub content_type: String,
46    /// Bytes.
47    pub size: u64,
48}
49
50impl Attachment {
51    fn new_key(prefix: &str, filename: &str) -> String {
52        let safe: String = filename
53            .rsplit(['/', '\\'])
54            .next()
55            .unwrap_or("")
56            .chars()
57            .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '_' })
58            .collect();
59        let safe = safe.trim_start_matches('.');
60        let safe = if safe.is_empty() { "file" } else { safe };
61        let id = unique_id();
62        let prefix = prefix.trim_matches('/');
63        if prefix.is_empty() {
64            format!("{id}/{safe}")
65        } else {
66            format!("{prefix}/{id}/{safe}")
67        }
68    }
69
70    /// Store `data` under a new key below `prefix`.
71    pub fn store(storage: &dyn Storage, prefix: &str, filename: &str, content_type: &str, data: &[u8]) -> Result<Attachment, StorageError> {
72        let key = storage.put(&Self::new_key(prefix, filename), data, content_type)?;
73        Ok(Attachment { key, filename: filename.to_string(), content_type: content_type.to_string(), size: data.len() as u64 })
74    }
75
76    /// Store everything `reader` yields (a streamed upload) under a new key
77    /// below `prefix`; the size is counted as it's read.
78    pub fn store_stream(storage: &dyn Storage, prefix: &str, filename: &str, content_type: &str, reader: &mut dyn std::io::Read) -> Result<Attachment, StorageError> {
79        let mut counting = Counting { inner: reader, count: 0 };
80        let key = storage.put_stream(&Self::new_key(prefix, filename), &mut counting, content_type)?;
81        Ok(Attachment { key, filename: filename.to_string(), content_type: content_type.to_string(), size: counting.count })
82    }
83
84    /// Store the file uploaded in the `multipart/form-data` field `field`
85    /// of `request`. `Ok(None)` when the request has no such field or it
86    /// carries no file (no file name: an empty file input); `Err` for a body that isn't
87    /// `multipart/form-data`.
88    pub fn from_multipart(storage: &dyn Storage, prefix: &str, request: &Request, field: &str) -> Result<Option<Attachment>, StorageError> {
89        use crate::body::multipart_form_data::FormMultipartData;
90        use crate::header::content_disposition::ContentDisposition;
91
92        let content_type = request.header("content-type").map(|h| h.value.to_string()).unwrap_or_default();
93        if !content_type.to_ascii_lowercase().starts_with("multipart/form-data") {
94            return Err(StorageError::new("the request body isn't multipart/form-data"));
95        }
96        let boundary = FormMultipartData::extract_boundary(&content_type).map_err(StorageError::new)?;
97        let parts = FormMultipartData::parse(&request.body, boundary).map_err(StorageError::new)?;
98        for part in parts {
99            let Some(disposition) = part.header("content-disposition") else { continue };
100            let Ok(disposition) = ContentDisposition::parse(&disposition.value) else {
101                continue;
102            };
103            if disposition.field_name.as_deref() != Some(field) {
104                continue;
105            }
106            // An empty file input sends `filename=""`.
107            let filename = disposition.file_name.unwrap_or_default();
108            if filename.is_empty() {
109                return Ok(None);
110            }
111            let part_type = part.header("content-type").map(|h| h.value.to_string()).unwrap_or_else(|| "application/octet-stream".to_string());
112            return Self::store(storage, prefix, &filename, &part_type, &part.body).map(Some);
113        }
114        Ok(None)
115    }
116
117    /// The file's URL ([`Storage::url`]).
118    pub fn url(&self, storage: &dyn Storage) -> String {
119        storage.url(&self.key)
120    }
121
122    /// The file's bytes.
123    pub fn read(&self, storage: &dyn Storage) -> Result<Vec<u8>, StorageError> {
124        storage.get(&self.key)
125    }
126
127    /// Delete the file.
128    pub fn delete(&self, storage: &dyn Storage) -> Result<(), StorageError> {
129        storage.delete(&self.key)
130    }
131
132    /// The column text.
133    pub fn encode(&self) -> String {
134        use crate::router::build_path;
135        // `build_path` on an empty pattern is `/?<query>`: the query alone.
136        let size = self.size.to_string();
137        let query = build_path("/", &[("key", &self.key), ("name", &self.filename), ("type", &self.content_type), ("size", &size)]).unwrap_or_default();
138        query.trim_start_matches("/?").to_string()
139    }
140
141    /// Parse column text written by [`encode`](Self::encode); `None` when
142    /// it isn't one (no key, a bad size or escape).
143    pub fn decode(text: &str) -> Option<Attachment> {
144        let mut attachment = Attachment { key: String::new(), filename: String::new(), content_type: String::new(), size: 0 };
145        for pair in text.split('&').filter(|p| !p.is_empty()) {
146            let (name, value) = pair.split_once('=')?;
147            let value = percent_decode(value)?;
148            match name {
149                "key" => attachment.key = value,
150                "name" => attachment.filename = value,
151                "type" => attachment.content_type = value,
152                "size" => attachment.size = value.parse().ok()?,
153                _ => {}
154            }
155        }
156        (!attachment.key.is_empty()).then_some(attachment)
157    }
158}
159
160/// 32 hex digits: the time in nanoseconds, then 64 random bits (a
161/// process-seeded hash of a counter), so keys sort by upload time.
162fn unique_id() -> String {
163    use std::hash::{BuildHasher, Hasher};
164    use std::sync::atomic::{AtomicU64, Ordering};
165    use std::sync::OnceLock;
166    static SEED: OnceLock<std::collections::hash_map::RandomState> = OnceLock::new();
167    static COUNTER: AtomicU64 = AtomicU64::new(0);
168    let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0);
169    let mut hasher = SEED.get_or_init(Default::default).build_hasher();
170    hasher.write_u64(COUNTER.fetch_add(1, Ordering::Relaxed));
171    hasher.write_u64(nanos);
172    format!("{nanos:016x}{:016x}", hasher.finish())
173}
174
175fn percent_decode(text: &str) -> Option<String> {
176    let bytes = text.as_bytes();
177    let mut out = Vec::with_capacity(bytes.len());
178    let mut i = 0;
179    while i < bytes.len() {
180        if bytes[i] == b'%' {
181            out.push(u8::from_str_radix(text.get(i + 1..i + 3)?, 16).ok()?);
182            i += 3;
183        } else {
184            out.push(bytes[i]);
185            i += 1;
186        }
187    }
188    String::from_utf8(out).ok()
189}
190
191struct Counting<'a> {
192    inner: &'a mut dyn std::io::Read,
193    count: u64,
194}
195
196impl std::io::Read for Counting<'_> {
197    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
198        let n = self.inner.read(buf)?;
199        self.count += n as u64;
200        Ok(n)
201    }
202}
203
204#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
205mod column {
206    use super::Attachment;
207    use crate::model::{DbError, FromColumn, ToColumn, Value};
208
209    impl FromColumn for Attachment {
210        fn from_column(v: Value) -> Result<Self, DbError> {
211            match v {
212                Value::Text(text) => Attachment::decode(&text).ok_or_else(|| DbError::new(format!("not an attachment: {text:?}"))),
213                Value::Null => Err(DbError::new("unexpected NULL for Attachment")),
214                other => Err(DbError::new(format!("cannot convert {other:?} to Attachment"))),
215            }
216        }
217    }
218
219    impl ToColumn for Attachment {
220        fn to_column(&self) -> Value {
221            Value::Text(self.encode())
222        }
223    }
224}