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;