1use std::time::Duration;
13
14#[derive(Debug, Clone, Default)]
16pub struct Scouted {
17 pub mail: Option<crate::discover::Found>,
18 pub dav: Option<String>,
20 pub nextcloud: Option<Nextcloud>,
21}
22
23#[derive(Debug, Clone, Default, PartialEq, Eq)]
25pub struct Nextcloud {
26 pub server: String,
27 pub product: String,
28 pub version: String,
29 pub apps: Vec<String>,
31}
32
33fn agent() -> ureq::Agent {
34 ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(15))).http_status_as_error(false).max_redirects(0).build().into()
35}
36
37fn dav_at(host: &str) -> bool {
40 let url = format!("https://{host}/.well-known/caldav");
41 agent().get(&url).header("User-Agent", "Sioul").call().is_ok_and(|r| matches!(r.status().as_u16(), 207 | 301 | 302 | 307 | 308 | 401))
42}
43
44fn nextcloud_at(host: &str) -> Option<Nextcloud> {
46 let url = format!("https://{host}/status.php");
47 let mut answer = agent().get(&url).header("User-Agent", "Sioul").call().ok()?;
48 if !answer.status().is_success() {
49 return None;
50 }
51 let text = answer.body_mut().with_config().limit(64 * 1024).read_to_string().ok()?;
52 let status: serde_json::Value = serde_json::from_str(&text).ok()?;
53 status.get("installed")?.as_bool().filter(|i| *i)?;
54 Some(Nextcloud {
55 server: format!("https://{host}"),
56 product: status.get("productname").and_then(|v| v.as_str()).unwrap_or("Nextcloud").to_string(),
57 version: status.get("versionstring").or_else(|| status.get("version")).and_then(|v| v.as_str()).unwrap_or("").to_string(),
58 apps: Vec::new(),
59 })
60}
61
62fn nextcloud_apps(server: &str, login: &str, password: &str) -> Vec<String> {
64 use base64::Engine;
65 let url = format!("{server}/ocs/v2.php/cloud/capabilities?format=json");
66 let authorization = format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(format!("{login}:{password}")));
67 let Ok(mut answer) = agent().get(&url).header("User-Agent", "Sioul").header("OCS-APIRequest", "true").header("Authorization", &authorization).call() else { return Vec::new() };
68 if !answer.status().is_success() {
69 return Vec::new();
70 }
71 let Some(body) = answer.body_mut().with_config().limit(2 * 1024 * 1024).read_to_string().ok().and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok()) else { return Vec::new() };
72 let mut apps: Vec<String> = body.pointer("/ocs/data/capabilities").and_then(|c| c.as_object()).map(|c| c.keys().cloned().collect()).unwrap_or_default();
73 apps.sort();
74 apps
75}
76
77pub fn scout(address: &str, dav_host: Option<&str>, login: Option<(&str, &str)>) -> Scouted {
82 let domain = address.rsplit_once('@').map_or(address, |(_, d)| d).trim().to_ascii_lowercase();
83 let mail = crate::discover::discover(address).ok();
84 let hosts: Vec<String> = dav_host.map(str::to_string).into_iter().chain([domain.clone(), format!("cloud.{domain}"), format!("dav.{domain}")]).collect();
85 let dav = hosts.iter().find(|h| dav_at(h)).map(|h| format!("https://{h}"));
86 let mut nextcloud = hosts.iter().find_map(|h| nextcloud_at(h));
87 if let (Some(cloud), Some((login, password)), Some(host)) = (nextcloud.as_mut(), login, dav_host)
88 && cloud.server == format!("https://{host}")
89 {
90 cloud.apps = nextcloud_apps(&cloud.server, login, password);
91 }
92 Scouted { mail, dav, nextcloud }
93}
94
95pub fn known_app(name: &str) -> Option<(&'static str, bool)> {
98 Some(match name {
99 "dav" => ("calendars-contacts", true),
100 "files" => ("files", false),
101 "notes" => ("notes", false),
102 "spreed" => ("talk", false),
103 "deck" => ("deck", false),
104 "mail" => ("webmail", false),
105 "bookmarks" => ("bookmarks", false),
106 "tables" => ("tables", false),
107 "forms" => ("forms", false),
108 "photos" => ("photos", false),
109 _ => return None,
110 })
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn apps_named() {
119 assert_eq!(known_app("files"), Some(("files", false)));
120 assert_eq!(known_app("dav"), Some(("calendars-contacts", true)));
121 assert_eq!(known_app("theming"), None, "what is no app to you is not said");
122 }
123
124 #[test]
126 #[ignore]
127 fn murena_scouted() {
128 let found = scout("someone@murena.io", Some("murena.io"), None);
129 assert_eq!(found.mail.as_ref().map(|m| m.host.as_str()), Some("mail.ecloud.global"));
130 assert_eq!(found.dav.as_deref(), Some("https://murena.io"));
131 assert_eq!(found.nextcloud.as_ref().map(|n| n.product.as_str()), Some("Nextcloud"));
132 println!("{found:?}");
133 }
134}