// TRACES: FR-NC-12 | FR-NC-1 | NFR-SEC-3 //! Registering Nextcloud as a storage backend. //! //! The account model this connector used to own now lives in //! [`dr_sync::account`], where it has no server in it. What is left here is //! the part that genuinely is Nextcloud: an endpoint is an HTTPS URL, an //! account is established through Login Flow v2, and the credential is an app //! password. //! //! Nothing above `dr_ui::remote` refers to this type. use dr_sync::{ Account, BackendProvider, Connection, RemoteBackend, RemoteError, SignIn, LEGACY_BACKEND, }; use crate::{AppCredentials, NextcloudBackend}; /// The id written to [`Account::backend`] for a Nextcloud account. /// /// The same string [`dr_sync::LEGACY_BACKEND`] freezes, because every account /// configured before there was a choice is one of these and must keep the /// catalog directory it already has. pub const BACKEND_ID: &str = LEGACY_BACKEND; /// Registers the Nextcloud connector. pub struct NextcloudProvider; impl NextcloudProvider { /// The account a completed login flow describes. /// /// `user_id` is the DAV path segment, which is not always the login name: /// a login can be an email address while the user id is something else, /// and building `/remote.php/dav/files//` from the wrong one 404s /// every request. pub fn account_from(creds: &AppCredentials, user_id: impl Into) -> Account { Account::new(BACKEND_ID, creds.server.trim_end_matches('/')) .with_login(creds.login_name.clone(), user_id) } /// The credentials a stored account plus its secret amount to. /// /// [`AppCredentials`] stays the connector's own type rather than becoming /// something general: an app password, an OAuth token and a bucket key /// pair have no useful common shape, and inventing one would produce a /// wrong answer confidently. The general form is [`Connection`]; this is /// the translation into what one protocol needs. pub fn credentials(conn: &Connection) -> Result { Ok(AppCredentials { server: conn.account.endpoint.clone(), login_name: conn.account.login.clone(), app_password: conn.require_secret()?.expose().to_string(), }) } } impl BackendProvider for NextcloudProvider { fn id(&self) -> &'static str { BACKEND_ID } fn display_name(&self) -> &'static str { "Nextcloud" } fn endpoint_label(&self) -> &'static str { "Server" } fn endpoint_placeholder(&self) -> &'static str { "https://cloud.example.com" } fn sign_in(&self) -> SignIn { SignIn::Browser } /// Normalise a server address typed by hand. /// /// Users type `cloud.example.com`, not a URL. Assume HTTPS rather than /// failing, and never silently accept plain HTTP — NFR-SEC-3 requires TLS, /// and an unencrypted default would be a security decision made on the /// user's behalf without telling them. fn normalise_endpoint(&self, input: &str) -> Result { let s = input.trim().trim_end_matches('/'); if s.is_empty() { return Err("Enter the address of your Nextcloud server.".into()); } if s.starts_with("https://") { Ok(s.to_string()) } else if let Some(rest) = s.strip_prefix("http://") { // Upgrade rather than accept. If the server genuinely has no TLS // the connection fails loudly, which is the correct outcome. Ok(format!("https://{rest}")) } else { Ok(format!("https://{s}")) } } /// TRACES: NFR-SEC-3 /// An `http://` account written before sign-in kept the address the user /// typed: it was stored as the server reported itself, and behind a proxy /// without `overwriteprotocol` that is `http`. The client refuses to send /// to it now, so it is upgraded here rather than left to fail. The /// namespace ignores the scheme, so the catalog stays where it is. fn upgrade_endpoint(&self, stored: &str) -> Option { stored .strip_prefix("http://") .map(|rest| format!("https://{rest}")) } fn connect(&self, conn: &Connection) -> Result, RemoteError> { let creds = Self::credentials(conn)?; Ok(Box::new(NextcloudBackend::new( &creds, &conn.account.user_id, )?)) } } #[cfg(test)] mod tests { use super::*; fn creds() -> AppCredentials { AppCredentials { server: "https://cloud.example/".into(), login_name: "duncan@example.com".into(), app_password: "token".into(), } } #[test] fn an_address_typed_by_hand_becomes_an_https_url() { let p = NextcloudProvider; assert_eq!( p.normalise_endpoint("cloud.example.com/").unwrap(), "https://cloud.example.com" ); // Upgraded, never accepted: NFR-SEC-3. assert_eq!( p.normalise_endpoint("http://cloud.example.com").unwrap(), "https://cloud.example.com" ); assert!(p.normalise_endpoint(" ").is_err()); } /// TRACES: NFR-SEC-3 #[test] fn a_stored_http_endpoint_is_upgraded_and_nothing_else_is_touched() { let p = NextcloudProvider; assert_eq!( p.upgrade_endpoint("http://cloud.example/nextcloud") .as_deref(), Some("https://cloud.example/nextcloud") ); assert_eq!(p.upgrade_endpoint("https://cloud.example"), None); } #[test] fn the_account_keeps_the_dav_user_id_apart_from_the_login() { // A login can be an email address while the user id is something // else; building the DAV path from the wrong one 404s everything. let a = NextcloudProvider::account_from(&creds(), "duncan"); assert_eq!(a.login, "duncan@example.com"); assert_eq!(a.user_id, "duncan"); assert_eq!(a.endpoint, "https://cloud.example"); } #[test] fn a_nextcloud_account_keeps_its_historical_catalog_directory() { // Frozen: this names the directory holding the catalog, the thumbnail // shards and un-uploaded sidecars. let a = NextcloudProvider::account_from(&creds(), "duncan"); assert_eq!(a.namespace(), "cloud-example-duncan"); } #[test] fn connecting_without_a_credential_is_unauthenticated_not_a_crash() { // A cleared keyring or a revoked app password arrives here as an // account with no secret. The caller re-runs the login flow. let account = NextcloudProvider::account_from(&creds(), "duncan"); match NextcloudProvider.connect(&Connection::new(account, None)) { Err(RemoteError::Unauthenticated) => {} Err(e) => panic!("wrong error: {e:?}"), Ok(b) => panic!("connected without a credential as {}", b.name()), } } #[test] fn a_stored_account_and_its_secret_rebuild_the_credentials() { let account = NextcloudProvider::account_from(&creds(), "duncan"); let conn = Connection::new(account, Some(dr_sync::Secret::new("token"))); let rebuilt = NextcloudProvider::credentials(&conn).unwrap(); assert_eq!(rebuilt.server, "https://cloud.example"); assert_eq!(rebuilt.login_name, "duncan@example.com"); assert_eq!(rebuilt.app_password, "token"); } }