Skip to main content

tablo_core/
upload.rs

1//! Names where uploaded bytes go: a file field binds a `String` column and the
2//! app's [`Uploader`] decides what path the record stores.
3
4use std::{
5    collections::{HashMap, HashSet},
6    future::Future,
7    path::PathBuf,
8};
9
10use percent_encoding::percent_decode_str;
11use topcoat::context::Cx;
12
13use crate::{
14    BoxFuture,
15    form::{FieldErrors, FormValues},
16    panel::state::current,
17    schema::Schema,
18    topcoat_compat::href::encode_path_segment,
19};
20
21/// Stores one uploaded file and names the value a record stores; installed once
22/// per panel with [`Panel::uploads`](crate::Panel::uploads).
23///
24/// `filename` arrives sanitized to a non-empty basename without directories or
25/// control characters; `bytes` are bounded by the 10 MiB form-body cap. The
26/// framework stores the returned path verbatim.
27pub trait Uploader: Send + Sync + 'static {
28    /// Stores `bytes` and returns the value to store; the `Err` string renders
29    /// inside the field's inline error, so it must be user-actionable.
30    fn store(
31        &self,
32        filename: &str,
33        bytes: &[u8],
34    ) -> impl Future<Output = Result<String, String>> + Send;
35
36    /// Answers `true` only for a path this store could have written and still resolves inside its
37    /// own root; the default answers `false`.
38    ///
39    /// A file field carries a refused submission's pending upload back through a hidden
40    /// `keep_<field>`, so this is what decides which paths a record may point at: a store that
41    /// answers `true` for a file it never wrote lets a forged carry point the record at it.
42    fn holds(&self, path: &str) -> impl Future<Output = bool> + Send {
43        async move {
44            let _ = path;
45            false
46        }
47    }
48}
49
50/// Writes each upload into one directory as `{uuid}-{basename}` and stores its URL under
51/// `url_prefix`; [`Panel::uploads_dir`](crate::Panel::uploads_dir) installs it and serves the
52/// directory there.
53///
54/// ```no_run
55/// # use tablo_core::{DirUploader, Panel};
56/// Panel::new("admin")
57///     .uploads(DirUploader::new("/uploads", "var/uploads"))
58///     .serve_dir("/uploads/{*file}", "var/uploads");
59/// ```
60#[derive(Debug, Clone)]
61pub struct DirUploader {
62    url_prefix: String,
63    dir: PathBuf,
64}
65
66/// The longest basename [`DirUploader`] keeps: 255 bytes, less the 36-byte UUID and its dash.
67const MAX_STORED_BASENAME: usize = 255 - 37;
68
69/// Whether `name` is a basename [`DirUploader::store`] writes: the UUID it minted, then the `-`
70/// and the sanitized basename beside it, or nothing when that basename was empty.
71fn is_stored_name(name: &str) -> bool {
72    let Some((uuid, rest)) = name.split_at_checked(36) else {
73        return false;
74    };
75    uuid::Uuid::try_parse(uuid).is_ok_and(|parsed| parsed.to_string() == uuid)
76        && (rest.is_empty() || (rest.len() > 1 && rest.starts_with('-')))
77}
78
79impl DirUploader {
80    /// Writes into `dir`, naming each file by a URL under `url_prefix` (`"/uploads"`).
81    pub fn new(url_prefix: impl Into<String>, dir: impl Into<PathBuf>) -> Self {
82        let url_prefix = url_prefix.into().trim_end_matches('/').to_string();
83        Self {
84            url_prefix,
85            dir: dir.into(),
86        }
87    }
88
89    /// The file a stored URL names: one segment under the prefix, spelled as `store` spells it.
90    fn file_name(&self, path: &str) -> Option<String> {
91        let segment = path.strip_prefix(&self.url_prefix)?.strip_prefix('/')?;
92        let name = percent_decode_str(segment).decode_utf8().ok()?;
93        (!name.is_empty()
94            && sanitize_filename(&name) == name
95            && encode_path_segment(&name) == segment)
96            .then(|| name.into_owned())
97    }
98}
99
100impl Uploader for DirUploader {
101    async fn store(&self, filename: &str, bytes: &[u8]) -> Result<String, String> {
102        let basename = sanitize_filename(filename);
103        let mut cut = basename.len().saturating_sub(MAX_STORED_BASENAME);
104        while !basename.is_char_boundary(cut) {
105            cut += 1;
106        }
107        let name = match &basename[cut..] {
108            "" => uuid::Uuid::new_v4().to_string(),
109            basename => format!("{}-{basename}", uuid::Uuid::new_v4()),
110        };
111        tokio::fs::create_dir_all(&self.dir)
112            .await
113            .map_err(|_| "the upload directory is not writable".to_string())?;
114        tokio::fs::write(self.dir.join(&name), bytes)
115            .await
116            .map_err(|_| "the upload could not be written".to_string())?;
117        Ok(format!(
118            "{}/{}",
119            self.url_prefix,
120            encode_path_segment(&name)
121        ))
122    }
123
124    /// Whether the directory holds the file a URL `store` returned names. The name `store` writes
125    /// is the check, since the directory keeps no index of what it wrote: a file whose name it
126    /// would not write is not held.
127    async fn holds(&self, path: &str) -> bool {
128        let Some(name) = self.file_name(path) else {
129            return false;
130        };
131        if !is_stored_name(&name) {
132            return false;
133        }
134        tokio::fs::metadata(self.dir.join(name))
135            .await
136            .is_ok_and(|metadata| metadata.is_file())
137    }
138}
139
140/// Strips a client filename to a safe basename capped at 255 bytes, rejecting `.`, `..`, and
141/// Windows reserved names to empty: what a file field's [`Uploader`] receives, for a page that
142/// parses its own multipart body.
143///
144/// ```
145/// assert_eq!(
146///     tablo_core::upload::sanitize_filename("../../etc/passwd"),
147///     "passwd"
148/// );
149/// assert_eq!(
150///     tablo_core::upload::sanitize_filename("C:\\fakepath\\cover.png"),
151///     "cover.png"
152/// );
153/// assert_eq!(tablo_core::upload::sanitize_filename(".."), "");
154/// ```
155pub fn sanitize_filename(raw: &str) -> String {
156    let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw).trim();
157    let clean: String = base.chars().filter(|c| !c.is_control()).collect();
158    let trimmed = clean.trim();
159    if trimmed.is_empty() {
160        return String::new();
161    }
162    if trimmed == "." || trimmed == ".." || is_windows_reserved_name(trimmed) {
163        return String::new();
164    }
165    // Caps at 255 bytes, advancing the cut to a char boundary to avoid panicking.
166    if trimmed.len() > 255 {
167        let mut start = trimmed.len() - 255;
168        while !trimmed.is_char_boundary(start) {
169            start += 1;
170        }
171        trimmed[start..].to_string()
172    } else {
173        trimmed.to_string()
174    }
175}
176
177/// Reports whether a basename is a Windows reserved device name.
178fn is_windows_reserved_name(name: &str) -> bool {
179    let stem = match name.split_once('.') {
180        Some((stem, _)) => stem,
181        None => name,
182    };
183    let stem = stem.to_ascii_uppercase();
184    if matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") {
185        return true;
186    }
187    let Some(n) = stem
188        .strip_prefix("COM")
189        .or_else(|| stem.strip_prefix("LPT"))
190    else {
191        return false;
192    };
193    n.parse::<u8>().is_ok_and(|n| (1..=9).contains(&n))
194}
195
196/// A submitted file part, staged only when an [`Uploader`] is installed.
197#[derive(Debug, Clone)]
198pub(crate) struct StagedUpload {
199    pub(crate) filename: String,
200    pub(crate) bytes: Vec<u8>,
201}
202
203/// The one uploader a panel was mounted with, in its `PanelState`.
204pub(crate) struct InstalledUploader(Box<dyn DynUploader + Send + Sync>);
205
206impl InstalledUploader {
207    pub(crate) fn new(uploader: impl Uploader) -> Self {
208        Self(Box::new(uploader))
209    }
210}
211
212/// Dyn-compatible view of [`Uploader`].
213pub(crate) trait DynUploader: Send + Sync {
214    fn store<'a>(
215        &'a self,
216        filename: &'a str,
217        bytes: &'a [u8],
218    ) -> BoxFuture<'a, Result<String, String>>;
219    fn holds<'a>(&'a self, path: &'a str) -> BoxFuture<'a, bool>;
220}
221
222impl<U: Uploader> DynUploader for U {
223    fn store<'a>(
224        &'a self,
225        filename: &'a str,
226        bytes: &'a [u8],
227    ) -> BoxFuture<'a, Result<String, String>> {
228        Box::pin(Uploader::store(self, filename, bytes))
229    }
230
231    fn holds<'a>(&'a self, path: &'a str) -> BoxFuture<'a, bool> {
232        Box::pin(Uploader::holds(self, path))
233    }
234}
235
236/// Whether this panel has an uploader installed.
237pub(crate) fn installed(cx: &Cx) -> bool {
238    installed_uploader(cx).is_some()
239}
240
241type DynUploaderRef<'a> = &'a (dyn DynUploader + Send + Sync);
242
243fn installed_uploader<'a>(cx: &'a Cx) -> Option<DynUploaderRef<'a>> {
244    current(cx)
245        .and_then(|panel| panel.uploads.as_ref())
246        .map(|installed| &*installed.0)
247}
248
249/// Whether the installed uploader still holds `path`; `false` without one.
250pub(crate) async fn holds(cx: &Cx, path: &str) -> bool {
251    match installed_uploader(cx) {
252        Some(uploader) => uploader.holds(path).await,
253        None => false,
254    }
255}
256
257/// Runs the installed uploader over this form's file parts, returning inline
258/// errors and the fields whose value is now the stored path. A failed store
259/// becomes an inline field error and drops the submitted value; call outside
260/// the write transaction.
261pub(crate) async fn store_uploads(
262    cx: &Cx,
263    schema: &Schema,
264    files: &HashMap<String, StagedUpload>,
265    values: &mut FormValues,
266) -> (FieldErrors, HashSet<String>) {
267    let Some(uploader) = installed_uploader(cx) else {
268        return (FieldErrors::new(), HashSet::new());
269    };
270    let mut errors = FieldErrors::new();
271    let mut stored = HashSet::new();
272    // Declared uploads only: a file part the schema does not declare is not a
273    // field this form may write (the unknown-key allow-list answers for it).
274    for field in schema.fields().filter(|field| field.is_file()) {
275        let name = field.name().to_string();
276        let Some(staged) = files.get(&name) else {
277            continue;
278        };
279        match uploader.store(&staged.filename, &staged.bytes).await {
280            Ok(path) => {
281                values.insert(name.clone(), path);
282                stored.insert(name);
283            }
284            Err(reason) => {
285                values.remove(&name);
286                errors.add(
287                    name,
288                    format!("{} could not be uploaded: {reason}", field.label_str()),
289                );
290            }
291        }
292    }
293    (errors, stored)
294}
295
296#[cfg(test)]
297mod tests;