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;