Skip to main content

sioul_sync/
scout.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! What a server offers for one address (docs/client.md, "Accounts"): mail
5//! (the provider's own settings, as `discover` finds them), calendars and
6//! contacts (DAV, at the domain's well-known addresses or on the server
7//! already known), and, on a Nextcloud (Murena's among them), its apps:
8//! files, notes, Talk, Deck… what Sioul takes, and what it does not yet.
9//! Asked without a password, except a Nextcloud's list of apps, which needs
10//! the one its calendars use.
11
12use std::time::Duration;
13
14/// What was found.
15#[derive(Debug, Clone, Default)]
16pub struct Scouted {
17    pub mail: Option<crate::discover::Found>,
18    /// The DAV server that answers, `https://host`, when one does.
19    pub dav: Option<String>,
20    pub nextcloud: Option<Nextcloud>,
21}
22
23/// A Nextcloud's name and version, and the apps it says it has (when it was asked with a password).
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
25pub struct Nextcloud {
26    pub server: String,
27    pub product: String,
28    pub version: String,
29    /// Its capabilities' names: "files", "notes", "spreed" (Talk), "deck"…
30    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
37/// Whether `https://<host>/.well-known/caldav` answers as a DAV server would:
38/// a redirection, or asking who you are.
39fn 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
44/// A Nextcloud's `status.php`: its product's name and version.
45fn 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
62/// A Nextcloud's capabilities, asked with a login and password: the apps it has.
63fn 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
77/// What the server of `address` offers: its mail's settings, a DAV server
78/// (`dav_host` when one is known already, else the domain's), and a
79/// Nextcloud's apps when `login` gives the password of the calendars at
80/// `dav_host`: it goes to that server only, never to another one found.
81pub 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
95/// What a Nextcloud app is, as the window says it, and whether Sioul takes it:
96/// ("files", false) is Files, which Sioul does not use yet.
97pub 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    /// Murena's servers, asked without a password. Run by hand: it asks them.
125    #[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}