Benchmarks / CPU and I/O (per commit) (push) Successful in 1m53s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 45m7s
Build and test / Layer separation (push) Successful in 41s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 2s
Traceability / Requirement traces (push) Successful in 40s
Build and test / Android (aarch64) (push) Successful in 28m49s
Build and test / Windows (x86_64, cross) (push) Successful in 17m9s
Build and test / Publish the release (push) Skipped
Before the previous commit, browser sign-in could store an account as http://, and the client now refuses to send to one. Left alone, such a library would fail to open with a configuration error, so its stored endpoint is rewritten before anything reads it. The endpoint is half of two keys, and both are handled: - The keyring entry is filed under it. Rewriting only the record would strand the app password under the old key and sign the user out, so AccountStore::move_endpoint copies the secret across first, rewrites the record in place (the last record is the one resumed), and deletes the old entry only once nothing refers to it. - namespace() is built from it and names the catalog directory. For an http to https rewrite it does not change, because the namespace strips either scheme. A move that would change it is refused, not performed, so no later rewrite can abandon a catalog either. The rewrite is a new BackendProvider::upgrade_endpoint hook, which does nothing by default, and not a second call to normalise_endpoint. The folder connector's normalise_endpoint canonicalises the path and needs it to exist, so running it on every launch would fail a library on an unplugged disk, or rename one whose path now resolves differently. Only Nextcloud implements the hook. If the move fails (for example, a locked keyring), it is logged, the account is left as it was, and the move is tried again on the next launch. Closes #65.
199 lines
7.4 KiB
Rust
199 lines
7.4 KiB
Rust
// 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/<login>/` from the wrong one 404s
|
|
/// every request.
|
|
pub fn account_from(creds: &AppCredentials, user_id: impl Into<String>) -> 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<AppCredentials, RemoteError> {
|
|
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<String, String> {
|
|
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<String> {
|
|
stored
|
|
.strip_prefix("http://")
|
|
.map(|rest| format!("https://{rest}"))
|
|
}
|
|
|
|
fn connect(&self, conn: &Connection) -> Result<Box<dyn RemoteBackend>, 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");
|
|
}
|
|
}
|