Skip to main content

rustic_rs/commands/
webdav.rs

1//! `webdav` subcommand
2
3// ignore markdown clippy lints as we use doc-comments to generate clap help texts
4#![allow(clippy::doc_markdown)]
5
6use std::net::ToSocketAddrs;
7
8use crate::{
9    Application, RUSTIC_APP, RusticConfig,
10    repository::{IndexedRepo, get_filtered_snapshots},
11    status_err,
12};
13use rustic_core::vfs::{FilePolicy, IdenticalSnapshot, Latest, Vfs};
14
15use abscissa_core::{Command, FrameworkError, Runnable, Shutdown, config::Override};
16use anyhow::{Result, anyhow};
17use axum::{
18    Router,
19    extract::{Request, State},
20    response::IntoResponse,
21    routing::any,
22};
23use conflate::Merge;
24use dav_server::DavHandler;
25use log::info;
26use serde::{Deserialize, Serialize};
27
28mod webdavfs;
29use webdavfs::WebDavFS;
30
31#[derive(Clone, Command, Default, Debug, clap::Parser, Serialize, Deserialize, Merge)]
32#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
33pub struct WebDavCmd {
34    /// Address to bind the webdav server to. [default: "localhost:8000"]
35    #[clap(long, value_name = "ADDRESS")]
36    #[merge(strategy=conflate::option::overwrite_none)]
37    address: Option<String>,
38
39    /// The path template to use for snapshots. {id}, {id_long}, {time}, {username}, {hostname}, {label}, {tags}, {backup_start}, {backup_end} are replaced. [default: "[{hostname}]/[{label}]/{time}"]
40    #[clap(long)]
41    #[merge(strategy=conflate::option::overwrite_none)]
42    path_template: Option<String>,
43
44    /// The time template to use to display times in the path template. See https://pubs.opengroup.org/onlinepubs/009695399/functions/strftime.html for format options. [default: "%Y-%m-%d_%H-%M-%S"]
45    #[clap(long)]
46    #[merge(strategy=conflate::option::overwrite_none)]
47    time_template: Option<String>,
48
49    /// Use symlinks. This may not be supported by all WebDAV clients
50    #[clap(long)]
51    #[merge(strategy=conflate::bool::overwrite_false)]
52    symlinks: bool,
53
54    /// How to handle access to files. [default: "forbidden" for hot/cold repositories, else "read"]
55    #[clap(long)]
56    #[merge(strategy=conflate::option::overwrite_none)]
57    file_access: Option<String>,
58
59    /// Specify directly which snapshot/path to serve
60    ///
61    /// Snapshot can be identified the following ways: "01a2b3c4" or "latest" or "latest~N" (N >= 0)
62    #[clap(value_name = "SNAPSHOT[:PATH]")]
63    #[merge(strategy=conflate::option::overwrite_none)]
64    snapshot_path: Option<String>,
65}
66
67impl Override<RusticConfig> for WebDavCmd {
68    // Process the given command line options, overriding settings from
69    // a configuration file using explicit flags taken from command-line
70    // arguments.
71    fn override_config(&self, mut config: RusticConfig) -> Result<RusticConfig, FrameworkError> {
72        let mut self_config = self.clone();
73        // merge "webdav" section from config file, if given
74        self_config.merge(config.webdav);
75        config.webdav = self_config;
76        Ok(config)
77    }
78}
79
80impl Runnable for WebDavCmd {
81    fn run(&self) {
82        if let Err(err) = RUSTIC_APP
83            .config()
84            .repository
85            .run_indexed(|repo| self.inner_run(repo))
86        {
87            status_err!("{}", err);
88            RUSTIC_APP.shutdown(Shutdown::Crash);
89        };
90    }
91}
92
93impl WebDavCmd {
94    /// be careful about self VS RUSTIC_APP.config() usage
95    /// only the RUSTIC_APP.config() involves the TOML and ENV merged configurations
96    /// see https://github.com/rustic-rs/rustic/issues/1242
97    fn inner_run(&self, repo: IndexedRepo) -> Result<()> {
98        let config = RUSTIC_APP.config();
99
100        let path_template = config
101            .webdav
102            .path_template
103            .clone()
104            .unwrap_or_else(|| "[{hostname}]/[{label}]/{time}".to_string());
105        let time_template = config
106            .webdav
107            .time_template
108            .clone()
109            .unwrap_or_else(|| "%Y-%m-%d_%H-%M-%S".to_string());
110
111        let vfs = if let Some(snap) = &config.webdav.snapshot_path {
112            let node =
113                repo.node_from_snapshot_path(snap, |sn| config.snapshot_filter.matches(sn))?;
114            Vfs::from_dir_node(&node)
115        } else {
116            let snapshots = get_filtered_snapshots(&repo)?;
117            let (latest, identical) = if config.webdav.symlinks {
118                (Latest::AsLink, IdenticalSnapshot::AsLink)
119            } else {
120                (Latest::AsDir, IdenticalSnapshot::AsDir)
121            };
122            Vfs::from_snapshots(snapshots, &path_template, &time_template, latest, identical)?
123        };
124
125        let addr = config
126            .webdav
127            .address
128            .clone()
129            .unwrap_or_else(|| "localhost:8000".to_string())
130            .to_socket_addrs()?
131            .next()
132            .ok_or_else(|| anyhow!("no address given"))?;
133
134        let file_access = config.webdav.file_access.as_ref().map_or_else(
135            || {
136                if repo.config().is_hot == Some(true) {
137                    Ok(FilePolicy::Forbidden)
138                } else {
139                    Ok(FilePolicy::Read)
140                }
141            },
142            |s| s.parse(),
143        )?;
144
145        let webdavfs = WebDavFS::new(repo, vfs, file_access);
146        let dav_server = DavHandler::builder()
147            .filesystem(Box::new(webdavfs))
148            .build_handler();
149
150        let app = Router::new()
151            .route("/", any(handle_dav))
152            .route("/{*path}", any(handle_dav))
153            .with_state(dav_server);
154
155        info!("serving webdav on {addr}");
156        tokio::runtime::Builder::new_current_thread()
157            .enable_all()
158            .build()?
159            .block_on(async {
160                axum::serve(tokio::net::TcpListener::bind(addr).await?, app).await
161            })?;
162
163        Ok(())
164    }
165}
166
167async fn handle_dav(State(dav): State<DavHandler>, req: Request) -> impl IntoResponse {
168    dav.handle(req).await
169}