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    pin::Pin,
8};
9
10use topcoat::context::Cx;
11
12use crate::{form::FieldErrors, panel::state::current, schema::Schema};
13
14/// Stores one uploaded file and names the value a record stores; installed once
15/// per panel with [`Panel::uploads`](crate::Panel::uploads).
16///
17/// `filename` arrives sanitized to a non-empty basename without directories or
18/// control characters; `bytes` are bounded by the 10 MiB form-body cap. The
19/// framework stores the returned path verbatim.
20pub trait Uploader: Send + Sync + 'static {
21    /// Stores `bytes` and returns the value to store; the `Err` string renders
22    /// inside the field's inline error, so it must be user-actionable.
23    fn store(
24        &self,
25        filename: &str,
26        bytes: &[u8],
27    ) -> impl Future<Output = Result<String, String>> + Send;
28
29    /// Answers `true` only for a path this store returned and still resolves
30    /// inside its own root; the default answers `false`.
31    fn holds(&self, path: &str) -> impl Future<Output = bool> + Send {
32        async move {
33            let _ = path;
34            false
35        }
36    }
37}
38
39/// A submitted file part, staged only when an [`Uploader`] is installed.
40#[derive(Debug, Clone)]
41pub(crate) struct StagedUpload {
42    pub(crate) filename: String,
43    pub(crate) bytes: Vec<u8>,
44}
45
46/// The one uploader a panel was mounted with, in its `PanelState`.
47pub(crate) struct InstalledUploader(Box<dyn DynUploader + Send + Sync>);
48
49impl InstalledUploader {
50    pub(crate) fn new(uploader: impl Uploader) -> Self {
51        Self(Box::new(uploader))
52    }
53}
54
55/// Boxed future of [`DynUploader::store`].
56type StoreFuture<'a> = Pin<Box<dyn Future<Output = Result<String, String>> + Send + 'a>>;
57
58/// Boxed future of [`DynUploader::holds`].
59type HoldFuture<'a> = Pin<Box<dyn Future<Output = bool> + Send + 'a>>;
60
61/// Dyn-compatible view of [`Uploader`].
62pub(crate) trait DynUploader: Send + Sync {
63    fn store<'a>(&'a self, filename: &'a str, bytes: &'a [u8]) -> StoreFuture<'a>;
64    fn holds<'a>(&'a self, path: &'a str) -> HoldFuture<'a>;
65}
66
67impl<U: Uploader> DynUploader for U {
68    fn store<'a>(&'a self, filename: &'a str, bytes: &'a [u8]) -> StoreFuture<'a> {
69        Box::pin(Uploader::store(self, filename, bytes))
70    }
71
72    fn holds<'a>(&'a self, path: &'a str) -> HoldFuture<'a> {
73        Box::pin(Uploader::holds(self, path))
74    }
75}
76
77/// Whether this panel has an uploader installed.
78pub(crate) fn installed(cx: &Cx) -> bool {
79    installed_uploader(cx).is_some()
80}
81
82type DynUploaderRef<'a> = &'a (dyn DynUploader + Send + Sync);
83
84fn installed_uploader<'a>(cx: &'a Cx) -> Option<DynUploaderRef<'a>> {
85    current(cx)
86        .and_then(|panel| panel.uploads.as_ref())
87        .map(|installed| &*installed.0)
88}
89
90/// Whether the installed uploader still holds `path`; `false` without one.
91pub(crate) async fn holds(cx: &Cx, path: &str) -> bool {
92    match installed_uploader(cx) {
93        Some(uploader) => uploader.holds(path).await,
94        None => false,
95    }
96}
97
98/// Runs the installed uploader over this form's file parts, returning inline
99/// errors and the fields whose value is now the stored path. A failed store
100/// becomes an inline field error and drops the submitted value; call outside
101/// the write transaction.
102pub(crate) async fn store_uploads(
103    cx: &Cx,
104    schema: &Schema,
105    files: &HashMap<String, StagedUpload>,
106    values: &mut HashMap<String, String>,
107) -> (FieldErrors, HashSet<String>) {
108    let Some(uploader) = installed_uploader(cx) else {
109        return (FieldErrors::new(), HashSet::new());
110    };
111    let mut errors = FieldErrors::new();
112    let mut stored = HashSet::new();
113    // Declared uploads only: a file part the schema does not declare is not a
114    // field this form may write (the unknown-key allow-list answers for it).
115    for field in schema.fields().filter(|field| field.is_file()) {
116        let name = field.name().to_string();
117        let Some(staged) = files.get(&name) else {
118            continue;
119        };
120        match uploader.store(&staged.filename, &staged.bytes).await {
121            Ok(path) => {
122                values.insert(name.clone(), path);
123                stored.insert(name);
124            }
125            Err(reason) => {
126                values.remove(&name);
127                errors.add(
128                    name,
129                    format!("{} could not be uploaded: {reason}", field.label_str()),
130                );
131            }
132        }
133    }
134    (errors, stored)
135}