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;
11
12use crate::{
13    BoxFuture,
14    form::{FieldErrors, FormValue, FormValues},
15    schema::Schema,
16    topcoat_compat::href::encode_path_segment,
17};
18
19mod body;
20pub(crate) mod carry;
21mod key;
22mod pending;
23pub(crate) mod signed;
24
25pub(crate) use self::body::{Capture, FormParts, parse_body, parse_file_body};
26
27/// Stores one uploaded file and names the value a record stores; installed once
28/// per panel with [`Panel::uploads`](crate::Panel::uploads).
29///
30/// `filename` arrives sanitized to a non-empty basename without directories or
31/// control characters; `bytes` are bounded by the panel's
32/// [`Limits::body_bytes`](crate::Limits::body_bytes). The
33/// framework stores the returned path verbatim. A file a file field's
34/// [`extensions`](crate::FileField::extensions) or [`max_bytes`](crate::FileField::max_bytes)
35/// refuse never reaches `store`.
36///
37/// - `store` returns the value to save. An `Err(reason)` is shown to the user inline as `<Label>
38///   could not be uploaded: <reason>`, so keep the reason free of paths and driver messages.
39/// - What a file field stores comes from a file part only: `store`'s answer, the stored value on an
40///   untouched edit, or nothing when cleared. A path typed into the form is never stored.
41/// - Keeping a file across a failed submit. When a form re-renders with errors, the file input is
42///   empty again. The form carries the path `store` just returned, under a tag the panel signs for
43///   that field and that browser's CSRF cookie, so a client cannot carry a path the field's rules
44///   never passed. The panel reuses it on the next submit only when the tag holds and
45///   `Uploader::holds(path)` answers `true`. The default answers `false`, so the user uploads
46///   again. The tag's key derives from the router's cookie `Key`, else from one drawn per process,
47///   so without a shared `Key` a carry reaches only the instance that minted it. Answer `true` only
48///   for a path your store could have written and still holds inside its own root; checking whether
49///   an arbitrary path exists would let a client pick any file. A `DirUploader` answers `true` for
50///   a URL spelled as its `store` spells one, naming a file in its directory.
51/// - Editing. The edit form shows the stored file as a link and an empty file input. Leaving it
52///   empty keeps the file; the "Remove the current file" checkbox (`clear_<field>`) empties the
53///   field. The input is required only while nothing is stored, and clearing a required field fails
54///   validation, so declare the record-form field `#[form(optional)]` when a record may lose its
55///   file. A [`Field::files`](crate::Field::files) list shows a row per stored file, each posting
56///   its path back under `keep_<field>` to order the list, and a remove checkbox posting it under
57///   `clear_<field>`, the only way a save drops a stored file; the rows keep the order they post
58///   in, chosen files are stored after them, and a refused submission's uploads are carried beside
59///   them as a single field's is.
60/// - Links. The stored value renders as a link, on the edit form and in a `FileColumn` on the
61///   detail page, only when it is a root-relative path (`/uploads/a.png`, not `//host`) or an
62///   `http(s)` URL; anything else renders as text.
63/// - Serving. `Panel::uploads_dir` serves its directory only at signed URLs that expire, which the
64///   panel's pages render; `Panel::serve_dir(path, dir)` serves a directory publicly unless the
65///   path sits under the panel prefix, which the auth gate covers.
66pub trait Uploader: Send + Sync + 'static {
67    /// Stores `bytes` and returns the value to store; the `Err` string renders
68    /// inside the field's inline error, so it must be user-actionable.
69    fn store(
70        &self,
71        filename: &str,
72        bytes: &[u8],
73    ) -> impl Future<Output = Result<String, String>> + Send;
74
75    /// Answers `true` only for a path this store could have written and still resolves inside its
76    /// own root; the default answers `false`.
77    ///
78    /// A file field carries a refused submission's pending upload back through a hidden
79    /// `keep_<field>`, so this is what decides which paths a record may point at: a store that
80    /// answers `true` for a file it never wrote lets a forged carry point the record at it.
81    fn holds(&self, path: &str) -> impl Future<Output = bool> + Send {
82        async move {
83            let _ = path;
84            false
85        }
86    }
87
88    /// Confirms that the write about to commit stores `path`; the default does nothing.
89    ///
90    /// The panel calls it inside the write's transaction, before the commit, for each new path a
91    /// file field's create or edit stores and [`holds`](Self::holds) answers `true` for. A store
92    /// that deletes what nothing kept, as [`DirUploader::expire_pending`] does, keeps `path` here.
93    /// An `Err(reason)` fails the write, so a file is never deleted under a committed record.
94    ///
95    /// It has no default: an uploader wrapping another forwards it, or the inner one deletes the
96    /// files of saved records. A store that deletes nothing answers `Ok(())`.
97    fn keep(&self, path: &str) -> impl Future<Output = Result<(), String>> + Send;
98
99    /// Deletes the file at `path`, which a committed write no longer stores; the default keeps
100    /// every file.
101    ///
102    /// The panel calls it after the write commits, once per path, and only for a path
103    /// [`holds`](Self::holds) answers `true` for: a record's files when a delete removes its row
104    /// (a soft delete keeps them until the force delete), and a file field's previous file when
105    /// an edit clears or replaces it. An `Err(reason)` is logged; the write stands. A path two
106    /// records share goes with the first that lets it go.
107    fn delete(&self, path: &str) -> impl Future<Output = Result<(), String>> + Send {
108        async move {
109            let _ = path;
110            Ok(())
111        }
112    }
113}
114
115/// Writes each upload into one directory as `{uuid}-{basename}` and stores its URL under
116/// `url_prefix`; [`Panel::uploads_dir`](crate::Panel::uploads_dir) installs it and serves the
117/// directory there.
118///
119/// ```no_run
120/// # use tablo_core::{DirUploader, Panel};
121/// Panel::new("admin")
122///     .uploads(DirUploader::new("/uploads", "var/uploads"))
123///     .serve_dir("/uploads/{*file}", "var/uploads");
124/// ```
125#[derive(Debug, Clone)]
126pub struct DirUploader {
127    url_prefix: String,
128    dir: PathBuf,
129    pending: Option<std::sync::Arc<pending::Pending>>,
130}
131
132/// The longest basename [`DirUploader`] keeps: 255 bytes, less the 36-byte UUID and its dash.
133const MAX_STORED_BASENAME: usize = 255 - 37;
134
135/// Whether `name` is a basename [`DirUploader::store`] writes: the UUID it minted, then the `-`
136/// and the sanitized basename beside it, or nothing when that basename was empty.
137fn is_stored_name(name: &str) -> bool {
138    let Some((uuid, rest)) = name.split_at_checked(36) else {
139        return false;
140    };
141    uuid::Uuid::try_parse(uuid).is_ok_and(|parsed| parsed.to_string() == uuid)
142        && (rest.is_empty() || (rest.len() > 1 && rest.starts_with('-')))
143}
144
145impl DirUploader {
146    /// Writes into `dir`, naming each file by a URL under `url_prefix` (`"/uploads"`).
147    pub fn new(url_prefix: impl Into<String>, dir: impl Into<PathBuf>) -> Self {
148        let url_prefix = url_prefix.into().trim_end_matches('/').to_string();
149        Self {
150            url_prefix,
151            dir: dir.into(),
152            pending: None,
153        }
154    }
155
156    /// Marks each file it stores pending until the panel's write that stores it commits, and
157    /// deletes a pending file once it is `age` old: the upload of a submission that never saved,
158    /// such as a refused form the user left. [`Panel::uploads_dir`](crate::Panel::uploads_dir)
159    /// sets 24 hours.
160    ///
161    /// A store sweeps at most once an hour. Only the panel's record forms keep what they store:
162    /// an app page that stores through this uploader and saves the path itself keeps it with
163    /// [`Uploader::keep`], or uses an uploader without this setting.
164    pub fn expire_pending(mut self, age: std::time::Duration) -> Self {
165        self.pending = Some(std::sync::Arc::new(pending::Pending::new(age)));
166        self
167    }
168
169    /// The file a stored URL names: one segment under the prefix, spelled as `store` spells it.
170    fn file_name(&self, path: &str) -> Option<String> {
171        let segment = path.strip_prefix(&self.url_prefix)?.strip_prefix('/')?;
172        let name = percent_decode_str(segment).decode_utf8().ok()?;
173        (!name.is_empty()
174            && sanitize_filename(&name) == name
175            && encode_path_segment(&name) == segment)
176            .then(|| name.into_owned())
177    }
178}
179
180impl Uploader for DirUploader {
181    async fn store(&self, filename: &str, bytes: &[u8]) -> Result<String, String> {
182        let basename = sanitize_filename(filename);
183        let mut cut = basename.len().saturating_sub(MAX_STORED_BASENAME);
184        while !basename.is_char_boundary(cut) {
185            cut += 1;
186        }
187        let name = match &basename[cut..] {
188            "" => uuid::Uuid::new_v4().to_string(),
189            basename => format!("{}-{basename}", uuid::Uuid::new_v4()),
190        };
191        tokio::fs::create_dir_all(&self.dir)
192            .await
193            .map_err(|_| "the upload directory is not writable".to_string())?;
194        tokio::fs::write(self.dir.join(&name), bytes)
195            .await
196            .map_err(|_| "the upload could not be written".to_string())?;
197        if let Some(pending) = &self.pending {
198            pending.sweep_if_due(&self.dir);
199            pending
200                .mark(&self.dir, &name)
201                .await
202                .map_err(|_| "the upload could not be written".to_string())?;
203        }
204        Ok(format!(
205            "{}/{}",
206            self.url_prefix,
207            encode_path_segment(&name)
208        ))
209    }
210
211    /// Whether the directory holds the file a URL `store` returned names. The name `store` writes
212    /// is the check, since the directory keeps no index of what it wrote: a file whose name it
213    /// would not write is not held.
214    async fn holds(&self, path: &str) -> bool {
215        let Some(name) = self.file_name(path) else {
216            return false;
217        };
218        if !is_stored_name(&name) {
219            return false;
220        }
221        tokio::fs::metadata(self.dir.join(name))
222            .await
223            .is_ok_and(|metadata| metadata.is_file())
224    }
225
226    /// Drops the pending mark of the file a URL `store` returned names.
227    async fn keep(&self, path: &str) -> Result<(), String> {
228        match self.file_name(path).filter(|name| is_stored_name(name)) {
229            Some(name) => pending::keep(&self.dir, &name)
230                .await
231                .map_err(|error| error.to_string()),
232            None => Ok(()),
233        }
234    }
235
236    /// Removes the file a URL `store` returned names, and its pending mark; a file already gone
237    /// is no error.
238    async fn delete(&self, path: &str) -> Result<(), String> {
239        let Some(name) = self.file_name(path).filter(|name| is_stored_name(name)) else {
240            return Ok(());
241        };
242        match tokio::fs::remove_file(self.dir.join(&name)).await {
243            Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
244                return Err(error.to_string());
245            }
246            _ => {}
247        }
248        pending::unmark(&self.dir, &name)
249            .await
250            .map_err(|error| error.to_string())
251    }
252}
253
254/// Strips a client filename to a safe basename capped at 255 bytes, without control or
255/// bidirectional-override characters, rejecting `.`, `..`, and Windows reserved names to empty:
256/// what a file field's [`Uploader`] receives, for a page that parses its own multipart body.
257///
258/// ```
259/// assert_eq!(tablo_core::sanitize_filename("../../etc/passwd"), "passwd");
260/// assert_eq!(
261///     tablo_core::sanitize_filename("C:\\fakepath\\cover.png"),
262///     "cover.png"
263/// );
264/// assert_eq!(tablo_core::sanitize_filename(".."), "");
265/// ```
266pub fn sanitize_filename(raw: &str) -> String {
267    let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw).trim();
268    let clean: String = base
269        .chars()
270        .filter(|&c| !c.is_control() && !is_bidi_control(c))
271        .collect();
272    let trimmed = clean.trim();
273    if trimmed.is_empty() {
274        return String::new();
275    }
276    if trimmed == "." || trimmed == ".." || is_windows_reserved_name(trimmed) {
277        return String::new();
278    }
279    // Caps at 255 bytes, advancing the cut to a char boundary to avoid panicking.
280    if trimmed.len() > 255 {
281        let mut start = trimmed.len() - 255;
282        while !trimmed.is_char_boundary(start) {
283            start += 1;
284        }
285        trimmed[start..].to_string()
286    } else {
287        trimmed.to_string()
288    }
289}
290
291/// Whether `c` reorders the text around it, so `a\u{202e}gnp.exe` would read as `aexe.png`.
292fn is_bidi_control(c: char) -> bool {
293    matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
294}
295
296/// Reports whether a basename is a Windows reserved device name.
297fn is_windows_reserved_name(name: &str) -> bool {
298    let stem = match name.split_once('.') {
299        Some((stem, _)) => stem,
300        None => name,
301    };
302    let stem = stem.to_ascii_uppercase();
303    if matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") {
304        return true;
305    }
306    let Some(n) = stem
307        .strip_prefix("COM")
308        .or_else(|| stem.strip_prefix("LPT"))
309    else {
310        return false;
311    };
312    n.parse::<u8>().is_ok_and(|n| (1..=9).contains(&n))
313}
314
315/// A submitted file part, staged only when an [`Uploader`] is installed.
316#[derive(Debug, Clone)]
317pub(crate) struct StagedUpload {
318    pub(crate) filename: String,
319    pub(crate) bytes: Vec<u8>,
320}
321
322/// The one uploader a panel was mounted with, in its `PanelState`.
323pub(crate) struct InstalledUploader(Box<dyn DynUploader + Send + Sync>);
324
325impl InstalledUploader {
326    pub(crate) fn new(uploader: impl Uploader) -> Self {
327        Self(Box::new(uploader))
328    }
329}
330
331/// Dyn-compatible view of [`Uploader`].
332pub(crate) trait DynUploader: Send + Sync {
333    fn store<'a>(
334        &'a self,
335        filename: &'a str,
336        bytes: &'a [u8],
337    ) -> BoxFuture<'a, Result<String, String>>;
338    fn holds<'a>(&'a self, path: &'a str) -> BoxFuture<'a, bool>;
339    fn keep<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<(), String>>;
340    fn delete<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<(), String>>;
341}
342
343impl<U: Uploader> DynUploader for U {
344    fn store<'a>(
345        &'a self,
346        filename: &'a str,
347        bytes: &'a [u8],
348    ) -> BoxFuture<'a, Result<String, String>> {
349        Box::pin(Uploader::store(self, filename, bytes))
350    }
351
352    fn holds<'a>(&'a self, path: &'a str) -> BoxFuture<'a, bool> {
353        Box::pin(Uploader::holds(self, path))
354    }
355
356    fn keep<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<(), String>> {
357        Box::pin(Uploader::keep(self, path))
358    }
359
360    fn delete<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<(), String>> {
361        Box::pin(Uploader::delete(self, path))
362    }
363}
364
365/// Whether `uploader`, a panel's installed one, still holds `path`; `false` without one.
366pub(crate) async fn holds(uploader: Option<&InstalledUploader>, path: &str) -> bool {
367    match uploader {
368        Some(uploader) => uploader.0.holds(path).await,
369        None => false,
370    }
371}
372
373/// The paths `values` stores under the file field `name`: its one path, or each of its list's,
374/// trimmed and none blank.
375fn stored_paths<'a>(values: &'a FormValues, name: &str) -> Vec<&'a str> {
376    let paths: &[String] = match values.get(name) {
377        Some(FormValue::Text(path)) => std::slice::from_ref(path),
378        Some(FormValue::List(paths)) => paths,
379        _ => &[],
380    };
381    paths
382        .iter()
383        .map(|path| path.trim())
384        .filter(|path| !path.is_empty())
385        .collect()
386}
387
388/// Keeps, through `uploader`, each new path `schema`'s file fields store in `after` and did not in
389/// `before`, which a create has none of; only a path the uploader holds.
390///
391/// # Errors
392///
393/// The reason the uploader could not keep a path, for the write to fail on.
394pub(crate) async fn keep(
395    uploader: Option<&InstalledUploader>,
396    schema: &Schema,
397    before: Option<&FormValues>,
398    after: &FormValues,
399) -> Result<(), String> {
400    let Some(InstalledUploader(uploader)) = uploader else {
401        return Ok(());
402    };
403    for field in schema.fields().filter(|field| field.is_file()) {
404        let stored = before.map(|before| stored_paths(before, field.name()));
405        for path in stored_paths(after, field.name()) {
406            if stored.as_ref().is_some_and(|stored| stored.contains(&path))
407                || !uploader.holds(path).await
408            {
409                continue;
410            }
411            uploader.keep(path).await?;
412        }
413    }
414    Ok(())
415}
416
417/// Deletes, through `uploader`, each file `schema`'s file fields stored in `before` and no longer
418/// store in `after`, or every one when `after` is `None`; only a path the uploader holds, and each
419/// path once.
420pub(crate) async fn discard(
421    uploader: Option<&InstalledUploader>,
422    schema: &Schema,
423    before: &[FormValues],
424    after: Option<&FormValues>,
425) {
426    let Some(InstalledUploader(uploader)) = uploader else {
427        return;
428    };
429    let mut gone = HashSet::new();
430    for field in schema.fields().filter(|field| field.is_file()) {
431        let kept = after.map(|after| stored_paths(after, field.name()));
432        for values in before {
433            for path in stored_paths(values, field.name()) {
434                if !kept.as_ref().is_some_and(|kept| kept.contains(&path)) {
435                    gone.insert(path.to_owned());
436                }
437            }
438        }
439    }
440    for path in gone {
441        if !uploader.holds(&path).await {
442            continue;
443        }
444        if let Err(reason) = uploader.delete(&path).await {
445            tracing::error!(path, reason, "an upload the write let go was not deleted");
446        }
447    }
448}
449
450/// Runs `uploader`, a panel's installed one, over this form's file parts, returning inline
451/// errors and the fields whose value is now the stored path. A file the field's extensions or
452/// size refuse is not stored; it, or a failed store, becomes an inline field error and drops the
453/// submitted value. A file list's value becomes the paths it stored, in the order the files
454/// posted, whether or not one failed. Call outside the write transaction.
455pub(crate) async fn store_uploads(
456    uploader: Option<&InstalledUploader>,
457    schema: &Schema,
458    files: &HashMap<String, Vec<StagedUpload>>,
459    values: &mut FormValues,
460) -> (FieldErrors, HashSet<String>) {
461    let Some(InstalledUploader(uploader)) = uploader else {
462        return (FieldErrors::new(), HashSet::new());
463    };
464    let mut errors = FieldErrors::new();
465    let mut stored = HashSet::new();
466    // Declared uploads only: a file part the schema does not declare is not a
467    // field this form may write (the unknown-key allow-list answers for it).
468    for field in schema.fields() {
469        let Some(file) = field.as_file() else {
470            continue;
471        };
472        let name = field.name().to_string();
473        let label = field.label();
474        // A file list holds what it stores, never the names the read gathered.
475        if file.multiple {
476            values.remove(&name);
477        }
478        let staged = files.get(&name).map(Vec::as_slice).unwrap_or_default();
479        // A file field takes the last file its key posts; a list takes each.
480        let staged = match staged.split_last() {
481            Some((last, _)) if !file.multiple => std::slice::from_ref(last),
482            _ => staged,
483        };
484        if staged.is_empty() {
485            continue;
486        }
487        let mut paths = Vec::new();
488        for staged in staged {
489            // A file the field's rules refuse is never stored, nor one whose name sanitizes to
490            // nothing, as a Windows device name does.
491            let filename = sanitize_filename(&staged.filename);
492            let refusal = if filename.is_empty() {
493                Some(crate::text!(
494                    "tablo-field-upload-name",
495                    label = label.clone()
496                ))
497            } else {
498                file.refusal(&label, &filename, staged.bytes.len())
499            };
500            let stored_path = match refusal {
501                Some(refusal) => Err(refusal),
502                None => uploader
503                    .store(&staged.filename, &staged.bytes)
504                    .await
505                    .map_err(|reason| {
506                        crate::text!(
507                            "tablo-field-upload-failed",
508                            label = label.clone(),
509                            reason = reason
510                        )
511                    }),
512            };
513            match stored_path {
514                Ok(path) => paths.push(path),
515                Err(message) if !errors.contains_key(&name) => errors.add(name.clone(), message),
516                Err(_) => {}
517            }
518        }
519        if file.multiple {
520            values.insert(name, FormValue::List(paths));
521        } else if let Some(path) = paths.pop() {
522            values.insert(name.clone(), path);
523            stored.insert(name);
524        } else {
525            values.remove(&name);
526        }
527    }
528    (errors, stored)
529}
530
531#[cfg(test)]
532mod tests;