tablo_core/panel/files.rs
1//! The panel's file storage: the uploader its file fields store through, and the directories it
2//! serves.
3
4use std::path::PathBuf;
5
6use super::{Panel, build::is_directory_pattern, headers};
7use crate::{DeclarationError, DeclarationErrorKind};
8
9impl Panel {
10 /// Installs the [`Uploader`](crate::Uploader) this panel's file fields store through; without
11 /// one a panel whose resource form declares a file field refuses to mount.
12 pub fn uploads(mut self, uploader: impl crate::Uploader) -> Self {
13 self.uploads = Some(crate::upload::InstalledUploader::new(uploader));
14 self
15 }
16
17 /// Stores file fields' uploads in `dir` and serves them at `url_prefix` (`"/uploads"`), through
18 /// a [`DirUploader`](crate::upload::DirUploader).
19 ///
20 /// ```no_run
21 /// # use tablo_core::Panel;
22 /// Panel::new("admin").uploads_dir("/uploads", "var/uploads");
23 /// ```
24 ///
25 /// The directory answers only at a signed URL that expires, so a file's bare URL opens
26 /// nothing and a leaked link stops working within two hours. The panel signs a file field's
27 /// link, a [`FileColumn`](crate::FileColumn)'s and an [`ImageColumn`](crate::ImageColumn)'s
28 /// thumbnail when it renders them, on a page whose tenant and policy let the user see the
29 /// record; a page of the app's own signs a stored path with [`url::file`](crate::url::file).
30 /// The key derives from the router's cookie `Key`, else from one drawn per process, so
31 /// without a shared `Key` a link opens only on the instance that rendered it. Any other
32 /// request answers 404. The files carry [`serve_dir`](Self::serve_dir)'s hardening headers.
33 ///
34 /// A file a record form stored for a submission that never saved is deleted after 24 hours:
35 /// [`DirUploader::expire_pending`](crate::upload::DirUploader::expire_pending).
36 pub fn uploads_dir(self, url_prefix: &str, dir: impl Into<PathBuf>) -> Self {
37 self.uploads_dir_with(url_prefix, dir, |uploader| uploader)
38 }
39
40 /// [`uploads_dir`](Self::uploads_dir), with the [`DirUploader`](crate::upload::DirUploader)
41 /// wrapped in an app's own [`Uploader`](crate::Uploader), such as one that checks the bytes
42 /// before it stores them: the files still answer only at signed URLs, and pending ones still
43 /// expire. The wrapper forwards [`keep`](crate::Uploader::keep), and
44 /// [`delete`](crate::Uploader::delete) to delete what a write lets go.
45 pub fn uploads_dir_with<U: crate::Uploader>(
46 self,
47 url_prefix: &str,
48 dir: impl Into<PathBuf>,
49 wrap: impl FnOnce(crate::upload::DirUploader) -> U,
50 ) -> Self {
51 let url_prefix = url_prefix.trim_end_matches('/');
52 let dir = dir.into();
53 self.serve(format!("{url_prefix}/{{*file}}"), dir.clone(), true)
54 .uploads(wrap(
55 crate::upload::DirUploader::new(url_prefix, dir)
56 .expire_pending(std::time::Duration::from_secs(24 * 3600)),
57 ))
58 }
59
60 /// Serves the directory `dir` at route pattern `path`, which must end in a catch-all.
61 ///
62 /// The files share the panel's origin, so every file response carries hardening headers:
63 ///
64 /// - `X-Content-Type-Options: nosniff`;
65 /// - a sandboxing `Content-Security-Policy`, which is fixed;
66 /// - `Content-Disposition: attachment`, unless the file is a common image, audio or video type
67 /// or plain text, so an uploaded HTML or SVG file downloads instead of running script.
68 ///
69 /// Serve active documents from another origin. The directory is **public**: the auth gate does
70 /// not cover it, unless `path` sits under the panel prefix, which the gate covers. Serve
71 /// uploads with [`uploads_dir`](Self::uploads_dir) instead, whose URLs expire.
72 pub fn serve_dir(self, path: impl Into<String>, dir: impl Into<PathBuf>) -> Self {
73 self.serve(path.into(), dir.into(), false)
74 }
75
76 /// Serves `dir` at `path`, at signed URLs only when `signed`.
77 fn serve(mut self, path: String, dir: PathBuf, signed: bool) -> Self {
78 if !is_directory_pattern(&path) {
79 self.configuration_errors.push(DeclarationError::panel(
80 DeclarationErrorKind::ServeDirWithoutCatchAll { path: path.clone() },
81 ));
82 }
83 self.served_dirs
84 .push(headers::ServedDir { path, dir, signed });
85 self
86 }
87}
88
89/// The patterns of `served`, and the roots of those that need a signed URL.
90pub(super) fn served_paths(served: &[headers::ServedDir]) -> (Vec<String>, Vec<String>) {
91 let paths = served.iter().map(|served| served.path.clone()).collect();
92 let signed = served
93 .iter()
94 .filter(|served| served.signed)
95 .map(|served| served_root(&served.path).to_owned())
96 .collect();
97 (paths, signed)
98}
99
100/// A served directory's pattern without its catch-all's name: the router treats
101/// `/uploads/{*file}` and `/uploads/{*path}` as one route.
102pub(super) fn served_root(path: &str) -> &str {
103 path.rsplit_once("{*").map_or(path, |(root, _)| root)
104}