tablo-core 0.8.0

The core toolkit types for Tablo, a server-rendered admin toolkit on Topcoat and Toasty.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
//! Names where uploaded bytes go: a file field binds a `String` column and the
//! app's [`Uploader`] decides what path the record stores.

use std::{
    collections::{HashMap, HashSet},
    future::Future,
    path::PathBuf,
};

use percent_encoding::percent_decode_str;

use crate::{
    BoxFuture,
    form::{FieldErrors, FormValues},
    schema::Schema,
    topcoat_compat::href::encode_path_segment,
};

mod body;
pub(crate) mod carry;
mod key;
mod pending;
pub(crate) mod signed;

pub(crate) use self::body::{Capture, FormParts, parse_body, parse_file_body};

/// Stores one uploaded file and names the value a record stores; installed once
/// per panel with [`Panel::uploads`](crate::Panel::uploads).
///
/// `filename` arrives sanitized to a non-empty basename without directories or
/// control characters; `bytes` are bounded by the 10 MiB form-body cap. The
/// framework stores the returned path verbatim. A file a file field's
/// [`extensions`](crate::FileField::extensions) or [`max_bytes`](crate::FileField::max_bytes)
/// refuse never reaches `store`.
///
/// - `store` returns the value to save. An `Err(reason)` is shown to the user inline as `<Label>
///   could not be uploaded: <reason>`, so keep the reason free of paths and driver messages.
/// - What a file field stores comes from a file part only: `store`'s answer, the stored value on an
///   untouched edit, or nothing when cleared. A path typed into the form is never stored.
/// - Keeping a file across a failed submit. When a form re-renders with errors, the file input is
///   empty again. The form carries the path `store` just returned, under a tag the panel signs for
///   that field and that browser's CSRF cookie, so a client cannot carry a path the field's rules
///   never passed. The panel reuses it on the next submit only when the tag holds and
///   `Uploader::holds(path)` answers `true`. The default answers `false`, so the user uploads
///   again. The tag's key derives from the router's cookie `Key`, else from one drawn per process,
///   so without a shared `Key` a carry reaches only the instance that minted it. Answer `true` only
///   for a path your store could have written and still holds inside its own root; checking whether
///   an arbitrary path exists would let a client pick any file. A `DirUploader` answers `true` for
///   a URL spelled as its `store` spells one, naming a file in its directory.
/// - Editing. The edit form shows the stored file as a link and an empty file input. Leaving it
///   empty keeps the file; the "Remove the current file" checkbox (`clear_<field>`) empties the
///   field. The input is required only while nothing is stored, and clearing a required field fails
///   validation, so declare the record-form field `#[form(optional)]` when a record may lose its
///   file.
/// - Links. The stored value renders as a link, on the edit form and in a `FileColumn` on the
///   detail page, only when it is a root-relative path (`/uploads/a.png`, not `//host`) or an
///   `http(s)` URL; anything else renders as text.
/// - Serving. `Panel::uploads_dir` serves its directory only at signed URLs that expire, which the
///   panel's pages render; `Panel::serve_dir(path, dir)` serves a directory publicly unless the
///   path sits under the panel prefix, which the auth gate covers.
pub trait Uploader: Send + Sync + 'static {
    /// Stores `bytes` and returns the value to store; the `Err` string renders
    /// inside the field's inline error, so it must be user-actionable.
    fn store(
        &self,
        filename: &str,
        bytes: &[u8],
    ) -> impl Future<Output = Result<String, String>> + Send;

    /// Answers `true` only for a path this store could have written and still resolves inside its
    /// own root; the default answers `false`.
    ///
    /// A file field carries a refused submission's pending upload back through a hidden
    /// `keep_<field>`, so this is what decides which paths a record may point at: a store that
    /// answers `true` for a file it never wrote lets a forged carry point the record at it.
    fn holds(&self, path: &str) -> impl Future<Output = bool> + Send {
        async move {
            let _ = path;
            false
        }
    }

    /// Confirms that the write about to commit stores `path`; the default does nothing.
    ///
    /// The panel calls it inside the write's transaction, before the commit, for each new path a
    /// file field's create or edit stores and [`holds`](Self::holds) answers `true` for. A store
    /// that deletes what nothing kept, as [`DirUploader::expire_pending`] does, keeps `path` here.
    /// An `Err(reason)` fails the write, so a file is never deleted under a committed record.
    ///
    /// It has no default: an uploader wrapping another forwards it, or the inner one deletes the
    /// files of saved records. A store that deletes nothing answers `Ok(())`.
    fn keep(&self, path: &str) -> impl Future<Output = Result<(), String>> + Send;

    /// Deletes the file at `path`, which a committed write no longer stores; the default keeps
    /// every file.
    ///
    /// The panel calls it after the write commits, once per path, and only for a path
    /// [`holds`](Self::holds) answers `true` for: a record's files when a delete removes its row
    /// (a soft delete keeps them until the force delete), and a file field's previous file when
    /// an edit clears or replaces it. An `Err(reason)` is logged; the write stands. A path two
    /// records share goes with the first that lets it go.
    fn delete(&self, path: &str) -> impl Future<Output = Result<(), String>> + Send {
        async move {
            let _ = path;
            Ok(())
        }
    }
}

/// Writes each upload into one directory as `{uuid}-{basename}` and stores its URL under
/// `url_prefix`; [`Panel::uploads_dir`](crate::Panel::uploads_dir) installs it and serves the
/// directory there.
///
/// ```no_run
/// # use tablo_core::{DirUploader, Panel};
/// Panel::new("admin")
///     .uploads(DirUploader::new("/uploads", "var/uploads"))
///     .serve_dir("/uploads/{*file}", "var/uploads");
/// ```
#[derive(Debug, Clone)]
pub struct DirUploader {
    url_prefix: String,
    dir: PathBuf,
    pending: Option<std::sync::Arc<pending::Pending>>,
}

/// The longest basename [`DirUploader`] keeps: 255 bytes, less the 36-byte UUID and its dash.
const MAX_STORED_BASENAME: usize = 255 - 37;

/// Whether `name` is a basename [`DirUploader::store`] writes: the UUID it minted, then the `-`
/// and the sanitized basename beside it, or nothing when that basename was empty.
fn is_stored_name(name: &str) -> bool {
    let Some((uuid, rest)) = name.split_at_checked(36) else {
        return false;
    };
    uuid::Uuid::try_parse(uuid).is_ok_and(|parsed| parsed.to_string() == uuid)
        && (rest.is_empty() || (rest.len() > 1 && rest.starts_with('-')))
}

impl DirUploader {
    /// Writes into `dir`, naming each file by a URL under `url_prefix` (`"/uploads"`).
    pub fn new(url_prefix: impl Into<String>, dir: impl Into<PathBuf>) -> Self {
        let url_prefix = url_prefix.into().trim_end_matches('/').to_string();
        Self {
            url_prefix,
            dir: dir.into(),
            pending: None,
        }
    }

    /// Marks each file it stores pending until the panel's write that stores it commits, and
    /// deletes a pending file once it is `age` old: the upload of a submission that never saved,
    /// such as a refused form the user left. [`Panel::uploads_dir`](crate::Panel::uploads_dir)
    /// sets 24 hours.
    ///
    /// A store sweeps at most once an hour. Only the panel's record forms keep what they store:
    /// an app page that stores through this uploader and saves the path itself keeps it with
    /// [`Uploader::keep`], or uses an uploader without this setting.
    pub fn expire_pending(mut self, age: std::time::Duration) -> Self {
        self.pending = Some(std::sync::Arc::new(pending::Pending::new(age)));
        self
    }

    /// The file a stored URL names: one segment under the prefix, spelled as `store` spells it.
    fn file_name(&self, path: &str) -> Option<String> {
        let segment = path.strip_prefix(&self.url_prefix)?.strip_prefix('/')?;
        let name = percent_decode_str(segment).decode_utf8().ok()?;
        (!name.is_empty()
            && sanitize_filename(&name) == name
            && encode_path_segment(&name) == segment)
            .then(|| name.into_owned())
    }
}

impl Uploader for DirUploader {
    async fn store(&self, filename: &str, bytes: &[u8]) -> Result<String, String> {
        let basename = sanitize_filename(filename);
        let mut cut = basename.len().saturating_sub(MAX_STORED_BASENAME);
        while !basename.is_char_boundary(cut) {
            cut += 1;
        }
        let name = match &basename[cut..] {
            "" => uuid::Uuid::new_v4().to_string(),
            basename => format!("{}-{basename}", uuid::Uuid::new_v4()),
        };
        tokio::fs::create_dir_all(&self.dir)
            .await
            .map_err(|_| "the upload directory is not writable".to_string())?;
        tokio::fs::write(self.dir.join(&name), bytes)
            .await
            .map_err(|_| "the upload could not be written".to_string())?;
        if let Some(pending) = &self.pending {
            pending.sweep_if_due(&self.dir);
            pending
                .mark(&self.dir, &name)
                .await
                .map_err(|_| "the upload could not be written".to_string())?;
        }
        Ok(format!(
            "{}/{}",
            self.url_prefix,
            encode_path_segment(&name)
        ))
    }

    /// Whether the directory holds the file a URL `store` returned names. The name `store` writes
    /// is the check, since the directory keeps no index of what it wrote: a file whose name it
    /// would not write is not held.
    async fn holds(&self, path: &str) -> bool {
        let Some(name) = self.file_name(path) else {
            return false;
        };
        if !is_stored_name(&name) {
            return false;
        }
        tokio::fs::metadata(self.dir.join(name))
            .await
            .is_ok_and(|metadata| metadata.is_file())
    }

    /// Drops the pending mark of the file a URL `store` returned names.
    async fn keep(&self, path: &str) -> Result<(), String> {
        match self.file_name(path).filter(|name| is_stored_name(name)) {
            Some(name) => pending::keep(&self.dir, &name)
                .await
                .map_err(|error| error.to_string()),
            None => Ok(()),
        }
    }

    /// Removes the file a URL `store` returned names, and its pending mark; a file already gone
    /// is no error.
    async fn delete(&self, path: &str) -> Result<(), String> {
        let Some(name) = self.file_name(path).filter(|name| is_stored_name(name)) else {
            return Ok(());
        };
        match tokio::fs::remove_file(self.dir.join(&name)).await {
            Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
                return Err(error.to_string());
            }
            _ => {}
        }
        pending::unmark(&self.dir, &name)
            .await
            .map_err(|error| error.to_string())
    }
}

/// Strips a client filename to a safe basename capped at 255 bytes, without control or
/// bidirectional-override characters, rejecting `.`, `..`, and Windows reserved names to empty:
/// what a file field's [`Uploader`] receives, for a page that parses its own multipart body.
///
/// ```
/// assert_eq!(tablo_core::sanitize_filename("../../etc/passwd"), "passwd");
/// assert_eq!(
///     tablo_core::sanitize_filename("C:\\fakepath\\cover.png"),
///     "cover.png"
/// );
/// assert_eq!(tablo_core::sanitize_filename(".."), "");
/// ```
pub fn sanitize_filename(raw: &str) -> String {
    let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw).trim();
    let clean: String = base
        .chars()
        .filter(|&c| !c.is_control() && !is_bidi_control(c))
        .collect();
    let trimmed = clean.trim();
    if trimmed.is_empty() {
        return String::new();
    }
    if trimmed == "." || trimmed == ".." || is_windows_reserved_name(trimmed) {
        return String::new();
    }
    // Caps at 255 bytes, advancing the cut to a char boundary to avoid panicking.
    if trimmed.len() > 255 {
        let mut start = trimmed.len() - 255;
        while !trimmed.is_char_boundary(start) {
            start += 1;
        }
        trimmed[start..].to_string()
    } else {
        trimmed.to_string()
    }
}

/// Whether `c` reorders the text around it, so `a\u{202e}gnp.exe` would read as `aexe.png`.
fn is_bidi_control(c: char) -> bool {
    matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
}

/// Reports whether a basename is a Windows reserved device name.
fn is_windows_reserved_name(name: &str) -> bool {
    let stem = match name.split_once('.') {
        Some((stem, _)) => stem,
        None => name,
    };
    let stem = stem.to_ascii_uppercase();
    if matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") {
        return true;
    }
    let Some(n) = stem
        .strip_prefix("COM")
        .or_else(|| stem.strip_prefix("LPT"))
    else {
        return false;
    };
    n.parse::<u8>().is_ok_and(|n| (1..=9).contains(&n))
}

/// A submitted file part, staged only when an [`Uploader`] is installed.
#[derive(Debug, Clone)]
pub(crate) struct StagedUpload {
    pub(crate) filename: String,
    pub(crate) bytes: Vec<u8>,
}

/// The one uploader a panel was mounted with, in its `PanelState`.
pub(crate) struct InstalledUploader(Box<dyn DynUploader + Send + Sync>);

impl InstalledUploader {
    pub(crate) fn new(uploader: impl Uploader) -> Self {
        Self(Box::new(uploader))
    }
}

/// Dyn-compatible view of [`Uploader`].
pub(crate) trait DynUploader: Send + Sync {
    fn store<'a>(
        &'a self,
        filename: &'a str,
        bytes: &'a [u8],
    ) -> BoxFuture<'a, Result<String, String>>;
    fn holds<'a>(&'a self, path: &'a str) -> BoxFuture<'a, bool>;
    fn keep<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<(), String>>;
    fn delete<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<(), String>>;
}

impl<U: Uploader> DynUploader for U {
    fn store<'a>(
        &'a self,
        filename: &'a str,
        bytes: &'a [u8],
    ) -> BoxFuture<'a, Result<String, String>> {
        Box::pin(Uploader::store(self, filename, bytes))
    }

    fn holds<'a>(&'a self, path: &'a str) -> BoxFuture<'a, bool> {
        Box::pin(Uploader::holds(self, path))
    }

    fn keep<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<(), String>> {
        Box::pin(Uploader::keep(self, path))
    }

    fn delete<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<(), String>> {
        Box::pin(Uploader::delete(self, path))
    }
}

/// Whether `uploader`, a panel's installed one, still holds `path`; `false` without one.
pub(crate) async fn holds(uploader: Option<&InstalledUploader>, path: &str) -> bool {
    match uploader {
        Some(uploader) => uploader.0.holds(path).await,
        None => false,
    }
}

/// Keeps, through `uploader`, each new path `schema`'s file fields store in `after` and did not in
/// `before`, which a create has none of; only a path the uploader holds.
///
/// # Errors
///
/// The reason the uploader could not keep a path, for the write to fail on.
pub(crate) async fn keep(
    uploader: Option<&InstalledUploader>,
    schema: &Schema,
    before: Option<&FormValues>,
    after: &FormValues,
) -> Result<(), String> {
    let Some(InstalledUploader(uploader)) = uploader else {
        return Ok(());
    };
    for field in schema.fields().filter(|field| field.is_file()) {
        let Some(path) = after.text(field.name()).map(str::trim) else {
            continue;
        };
        let stored = before.and_then(|before| before.text(field.name()).map(str::trim));
        if path.is_empty() || stored == Some(path) || !uploader.holds(path).await {
            continue;
        }
        uploader.keep(path).await?;
    }
    Ok(())
}

/// Deletes, through `uploader`, the file each of `schema`'s file fields stored in `before` and no
/// longer stores in `after`, or every one when `after` is `None`; only a path the uploader holds,
/// and each path once.
pub(crate) async fn discard(
    uploader: Option<&InstalledUploader>,
    schema: &Schema,
    before: &[FormValues],
    after: Option<&FormValues>,
) {
    let Some(InstalledUploader(uploader)) = uploader else {
        return;
    };
    let mut gone = HashSet::new();
    for field in schema.fields().filter(|field| field.is_file()) {
        for values in before {
            let Some(path) = values.text(field.name()).map(str::trim) else {
                continue;
            };
            let kept =
                after.is_some_and(|after| after.text(field.name()).map(str::trim) == Some(path));
            if !path.is_empty() && !kept {
                gone.insert(path.to_owned());
            }
        }
    }
    for path in gone {
        if !uploader.holds(&path).await {
            continue;
        }
        if let Err(reason) = uploader.delete(&path).await {
            tracing::error!(path, reason, "an upload the write let go was not deleted");
        }
    }
}

/// Runs `uploader`, a panel's installed one, over this form's file parts, returning inline
/// errors and the fields whose value is now the stored path. A file the field's extensions or
/// size refuse is not stored; it, or a failed store, becomes an inline field error and drops the
/// submitted value. Call outside the write transaction.
pub(crate) async fn store_uploads(
    uploader: Option<&InstalledUploader>,
    schema: &Schema,
    files: &HashMap<String, StagedUpload>,
    values: &mut FormValues,
) -> (FieldErrors, HashSet<String>) {
    let Some(InstalledUploader(uploader)) = uploader else {
        return (FieldErrors::new(), HashSet::new());
    };
    let mut errors = FieldErrors::new();
    let mut stored = HashSet::new();
    // Declared uploads only: a file part the schema does not declare is not a
    // field this form may write (the unknown-key allow-list answers for it).
    for field in schema.fields() {
        let Some(file) = field.as_file() else {
            continue;
        };
        let name = field.name().to_string();
        let Some(staged) = files.get(&name) else {
            continue;
        };
        // A file the field's rules refuse is never stored.
        let label = field.label();
        let filename = sanitize_filename(&staged.filename);
        let stored_path = match file.refusal(&label, &filename, staged.bytes.len()) {
            Some(refusal) => Err(refusal),
            None => uploader
                .store(&staged.filename, &staged.bytes)
                .await
                .map_err(|reason| {
                    crate::text!("tablo-field-upload-failed", label = label, reason = reason)
                }),
        };
        match stored_path {
            Ok(path) => {
                values.insert(name.clone(), path);
                stored.insert(name);
            }
            Err(message) => {
                values.remove(&name);
                errors.add(name, message);
            }
        }
    }
    (errors, stored)
}

#[cfg(test)]
mod tests;