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