1use std::path::{Path, PathBuf};
2
3use super::Result;
4
5use crate::{config::WebDavInfo, paths::PathExt, secrets::SecretsApi};
6use reqwest::{
7 Method, StatusCode, {Client, RequestBuilder},
8};
9use tracing::debug;
10
11pub struct WebDavStore<'s> {
12 client: Client,
13 cfg: WebDavInfo,
14 secrets: &'s SecretsApi<'s>,
15}
16
17fn calc_mkdir_all_paths(dir: &Path) -> Vec<PathBuf> {
18 let mut r = dir.components().fold(vec![], |mut bs, v| {
19 if bs.is_empty() {
20 bs.push(PathBuf::from(v.as_os_str()));
21 } else {
22 bs.push(bs[bs.len() - 1].join(Path::new(v.as_os_str())))
23 }
24 bs
25 });
26 if dir.is_absolute() {
27 r.remove(0);
28 }
29 r
30}
31
32impl<'s> WebDavStore<'s> {
33 pub fn new(cfg: WebDavInfo, secrets: &'s SecretsApi) -> Self {
34 Self {
35 client: Client::new(),
36 cfg,
37 secrets,
38 }
39 }
40
41 async fn mk_req_abs(&self, method: Method, url: &str) -> Result<RequestBuilder> {
42 debug!("dispatching {method:?} request to {url}");
43 let psk = self.cfg.psk.as_ref().map(|s| async move {
44 match s {
45 crate::config::Secret::SystemSecret(name) => {
46 assert!(
47 self.secrets.available(),
48 "system secrets must be available to use them in a config"
49 );
50 let s = self.secrets.get_item(name).await?;
51 let sv = s.ok_or_else(|| {
52 super::BackendError::CouldNotLocateSecret(name.to_owned())
53 })?;
54 let s = String::from_utf8(sv).expect("failed to convert from secret to utf8");
55 Ok::<_, super::BackendError>(s)
56 }
57 crate::config::Secret::Plain(p) => Ok(p.to_owned()),
58 }
59 });
60 let psk = if let Some(p) = psk {
61 Some(p.await?)
62 } else {
63 None
64 };
65 Ok(self
66 .client
67 .request(method, url)
68 .basic_auth(&self.cfg.username, psk.as_deref()))
69 }
70
71 async fn mk_req(&self, method: Method, path: &Path) -> Result<RequestBuilder> {
72 let url = Path::new(&self.cfg.url)
73 .join_good(
74 self.cfg
75 .root
76 .join(path)
77 .to_str()
78 .expect("failed to concat path"),
79 )
80 .to_str()
81 .unwrap()
82 .to_owned();
83 self.mk_req_abs(method, &url).await
84 }
85
86 async fn mkdir_abs(&self, dir: &Path) -> Result<()> {
90 debug!("mkdir for {dir:?}");
91 let url = Path::new(&self.cfg.url)
92 .join_good(dir)
93 .to_str()
94 .unwrap()
95 .to_owned();
96 let resp = self
97 .mk_req_abs(
98 Method::from_bytes(b"MKCOL").expect("failed to make mkcol method"),
99 &url,
100 )
101 .await?
102 .send()
103 .await?;
104 match resp.status() {
105 StatusCode::METHOD_NOT_ALLOWED => {
106 Ok(())
108 }
109 _ => {
110 resp.error_for_status()?;
111 Ok(())
112 }
113 }
114 }
115
116 async fn mkdir_all(&self, dir: &Path) -> Result<()> {
117 let dir = self.cfg.root.join_good(dir);
118 debug!("mkdir all for {dir:?}");
119 let parts = calc_mkdir_all_paths(&dir);
120 for p in parts.into_iter() {
121 self.mkdir_abs(&p).await?;
122 }
123 Ok(())
124 }
125}
126
127impl WebDavStore<'_> {
128 pub async fn write_file(&self, at: &Path, bytes: &[u8]) -> super::Result<()> {
129 debug!("writing to {at:?}");
130 if !self
131 .exists(at.parent().expect("no parent path for file"))
132 .await?
133 {
134 debug!("creating parent directories for {at:?}");
135 self.mkdir_all(at.parent().unwrap()).await?;
136 }
137 let resp = self
138 .mk_req(Method::PUT, at)
139 .await?
140 .body(bytes.to_owned())
141 .send()
142 .await?;
143 if resp.status() == StatusCode::CONFLICT {
144 panic!("invalidly scoped but we should've checked for that?");
145 } else {
146 resp.error_for_status()?;
147 }
148 Ok(())
149 }
150
151 pub async fn read_file(&self, at: &Path) -> super::Result<Vec<u8>> {
152 debug!("read {at:?}");
153 let data = self
154 .mk_req(Method::GET, at)
155 .await?
156 .send()
157 .await?
158 .error_for_status()?;
159 let d = data.bytes().await?;
160 Ok(d.to_vec())
161 }
162
163 pub async fn exists(&self, f: &Path) -> super::Result<bool> {
164 debug!("check exists for {f:?}");
165 let req = self.mk_req(Method::GET, f).await?.send().await?;
166 if req.status() == StatusCode::NOT_FOUND {
167 Ok(false)
168 } else {
169 req.error_for_status()?;
170 Ok(true)
171 }
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use std::path::{Path, PathBuf};
178
179 use crate::{backends::webdav::WebDavStore, config::WebDavInfo, secrets::SecretsApi};
180 use test_log::test;
181
182 use super::calc_mkdir_all_paths;
183
184 #[test]
185 fn calc_mkdir_all_paths_gives_individual_segments() {
186 let paths = calc_mkdir_all_paths(Path::new("/hello/world/hmm"));
187 assert_eq!(
188 paths.as_slice(),
189 &["/hello", "/hello/world", "/hello/world/hmm"].map(PathBuf::from)
190 )
191 }
192 async fn test_prefix_case(prefix: &str) {
193 let mut server = mockito::Server::new_async().await;
194 let url = server.url();
195
196 let cinc = server
197 .mock("MKCOL", "/cinc")
198 .with_status(200)
199 .create_async()
200 .await;
201 let hmm = server
202 .mock("MKCOL", "/cinc/hmm")
203 .with_status(201)
204 .create_async()
205 .await;
206 let s = SecretsApi::new_unavailable();
207
208 let store = WebDavStore::new(
209 WebDavInfo {
210 url,
211 username: "".to_owned(),
212 psk: None,
213 root: prefix.into(),
214 },
215 &s,
216 );
217
218 store.mkdir_all(Path::new("hmm")).await.unwrap();
219
220 hmm.assert_async().await;
221 cinc.assert_async().await;
222 }
223
224 #[test(tokio::test)]
225 async fn using_non_absolute_prefix_is_okay() {
226 test_prefix_case("cinc").await;
227 }
228
229 #[test(tokio::test)]
230 async fn using_absolute_prefix_is_okay() {
231 test_prefix_case("/cinc").await;
232 }
233
234 #[test(tokio::test)]
235 async fn mkdir_abs_doesnt_fall_over_on_already_exists() {
236 let mut server = mockito::Server::new_async().await;
237 let url = server.url();
238
239 let hmm = server
240 .mock("MKCOL", "/cinc/hmm")
241 .with_status(405)
242 .create_async()
243 .await;
244 let s = SecretsApi::new_unavailable();
245
246 let store = WebDavStore::new(
247 WebDavInfo {
248 url,
249 username: "".to_owned(),
250 psk: None,
251 root: "cinc".into(),
252 },
253 &s,
254 );
255
256 store.mkdir_abs(Path::new("cinc/hmm")).await.unwrap();
257
258 hmm.assert_async().await;
259 }
260
261 #[test(tokio::test)]
262 async fn using_no_prefix_is_okay() {
263 let mut server = mockito::Server::new_async().await;
264 let url = server.url();
265
266 let hmm = server
267 .mock("MKCOL", "/hmm")
268 .with_status(201)
269 .create_async()
270 .await;
271 let s = SecretsApi::new_unavailable();
272
273 let store = WebDavStore::new(
274 WebDavInfo {
275 url,
276 username: "".to_owned(),
277 psk: None,
278 root: "".into(),
279 },
280 &s,
281 );
282
283 store.mkdir_all(Path::new("hmm")).await.unwrap();
284
285 hmm.assert_async().await;
286 }
287}