Skip to main content

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}