Soft delete needs to move a photograph into the trash folder and back, and the stable id must survive the trip. WebDAV MOVE is one request and preserves oc:fileid; a copy-then-delete would allocate a new one, orphaning the thumbnail shard entry and the sidecar mapping and turning a restore into a full re-download. Overwrite: F, because a header that permits overwriting is one that eventually does. create_dir does MKCOL outermost-first and treats 405 — Nextcloud's answer for an existing collection — as the goal state rather than an error. Nothing else creates the trash folder, so without it the first trashed image of every library fails with a 409 that reads like a permission problem. PermissionDenied is now separate from AuthFailed. Folding 403 into 401 sent a user to re-check a credential that was working perfectly, with reads succeeding and only the write refused (observed against a real server). The usual cause is an app password created without "Allow filesystem access" — which signing in again will not fix. Assisted-by: LLM
573 lines
20 KiB
Rust
573 lines
20 KiB
Rust
//! Nextcloud connector — the only [`RemoteBackend`] implementation.
|
|
//!
|
|
//! Hand-rolled over `reqwest` rather than built on a WebDAV crate (D7). No
|
|
//! mature Nextcloud crate exists, and the operations that matter here are
|
|
//! Nextcloud extensions: `oc:fileid`, propagating directory ETags, chunked
|
|
//! upload v2, and Login Flow v2. A general WebDAV client exposes none of them.
|
|
|
|
use std::ops::Range;
|
|
|
|
use async_trait::async_trait;
|
|
use dr_sync::{
|
|
Capabilities, ChangeDetection, ChunkConstraints, Cursor, Precondition, RemoteBackend,
|
|
RemoteChange, RemoteEntry, RemoteError, RemoteId, RemotePath, ServerPreviews, Validator,
|
|
};
|
|
|
|
pub mod auth;
|
|
pub mod desktop_client;
|
|
mod propfind;
|
|
pub mod session;
|
|
|
|
pub use auth::{AppCredentials, LoginFlow};
|
|
pub use desktop_client::DesktopClient;
|
|
pub use session::{Session, SessionError, SessionStore};
|
|
|
|
/// Chunk sizes Nextcloud's chunked upload v2 accepts.
|
|
const CHUNKS: ChunkConstraints = ChunkConstraints {
|
|
min_chunk: 5 * 1024 * 1024,
|
|
max_chunk: 5 * 1024 * 1024 * 1024,
|
|
// Below the 5 MB floor a single PUT is simpler and no slower.
|
|
single_shot_below: 5 * 1024 * 1024,
|
|
max_chunks: 10_000,
|
|
};
|
|
|
|
/// TRACES: FR-NC-12 | M-5
|
|
/// A connected Nextcloud account.
|
|
pub struct NextcloudBackend {
|
|
client: reqwest::Client,
|
|
server: String,
|
|
login: String,
|
|
password: String,
|
|
/// `/remote.php/dav/files/<user>/` — the prefix stripped from hrefs.
|
|
dav_base: String,
|
|
caps: Capabilities,
|
|
}
|
|
|
|
impl NextcloudBackend {
|
|
/// Build a backend from credentials obtained via [`auth`].
|
|
pub fn new(creds: &AppCredentials, user_id: &str) -> Result<Self, RemoteError> {
|
|
let client = http_client("DarkRoom")?;
|
|
|
|
let server = creds.server.trim_end_matches('/').to_string();
|
|
let dav_base = format!("/remote.php/dav/files/{user_id}/");
|
|
|
|
Ok(Self {
|
|
client,
|
|
server,
|
|
login: creds.login_name.clone(),
|
|
password: creds.app_password.clone(),
|
|
dav_base,
|
|
caps: Capabilities {
|
|
// The property that makes a no-op sync one request (ARCH §8.1).
|
|
change_detection: ChangeDetection::PropagatingEtags,
|
|
stable_ids: true,
|
|
range_reads: true,
|
|
chunked_upload: Some(CHUNKS),
|
|
bulk_upload: true,
|
|
conditional_write: true,
|
|
// Stock Nextcloud ships no RAW preview provider (ARCH §6.7).
|
|
// Probed per-account at setup and upgraded where present.
|
|
server_previews: ServerPreviews::CommonFormatsOnly,
|
|
},
|
|
})
|
|
}
|
|
|
|
fn url_for(&self, path: &RemotePath) -> String {
|
|
let p = path.as_str();
|
|
if p.is_empty() {
|
|
format!("{}{}", self.server, self.dav_base)
|
|
} else {
|
|
format!("{}{}{}", self.server, self.dav_base, encode_path(p))
|
|
}
|
|
}
|
|
|
|
fn url_for_id(&self, id: &RemoteId) -> Result<String, RemoteError> {
|
|
match id {
|
|
RemoteId::Path(p) => Ok(self.url_for(p)),
|
|
// A stable id alone is not addressable over WebDAV; the caller
|
|
// holds the path alongside it in the catalog.
|
|
RemoteId::Stable(_) => Err(RemoteError::Unsupported(
|
|
"fetch by fileid requires a path; use RemoteId::Path",
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn propfind(
|
|
&self,
|
|
path: &RemotePath,
|
|
depth: &str,
|
|
body: &'static str,
|
|
) -> Result<String, RemoteError> {
|
|
let resp = self
|
|
.client
|
|
.request(
|
|
reqwest::Method::from_bytes(b"PROPFIND").expect("valid method"),
|
|
self.url_for(path),
|
|
)
|
|
.basic_auth(&self.login, Some(&self.password))
|
|
.header("Depth", depth)
|
|
.header(reqwest::header::CONTENT_TYPE, "application/xml")
|
|
.body(body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
|
|
|
map_status(resp.status(), path.as_str())?;
|
|
resp.text()
|
|
.await
|
|
.map_err(|e| RemoteError::Network(e.to_string()))
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl RemoteBackend for NextcloudBackend {
|
|
fn capabilities(&self) -> &Capabilities {
|
|
&self.caps
|
|
}
|
|
|
|
fn name(&self) -> &str {
|
|
"Nextcloud"
|
|
}
|
|
|
|
async fn list(
|
|
&self,
|
|
dir: &RemotePath,
|
|
_since: Option<&Validator>,
|
|
) -> Result<Vec<RemoteEntry>, RemoteError> {
|
|
// Depth 1 only. Depth: infinity is frequently disabled and
|
|
// prohibitively expensive where it is not (ARCH §8.4).
|
|
let xml = self.propfind(dir, "1", propfind::PROPFIND_BODY).await?;
|
|
propfind::parse_multistatus(&xml, &self.dav_base)
|
|
}
|
|
|
|
async fn dir_validator(&self, dir: &RemotePath) -> Result<Validator, RemoteError> {
|
|
// The pruning probe: Depth 0, ETag only. Against the root this is the
|
|
// single request that proves a 50k-image library unchanged.
|
|
let xml = self
|
|
.propfind(dir, "0", propfind::PROPFIND_ETAG_ONLY)
|
|
.await?;
|
|
propfind::parse_self_etag(&xml)
|
|
}
|
|
|
|
async fn delta(&self, _cursor: &Cursor) -> Result<(Vec<RemoteChange>, Cursor), RemoteError> {
|
|
// Verified absent: Nextcloud's Directory.php does not implement
|
|
// ISyncCollection, and sync tokens exist only for CalDAV/CardDAV
|
|
// (ARCH §6.6). The engine falls back to ETag pruning.
|
|
Err(RemoteError::Unsupported(
|
|
"Nextcloud has no RFC 6578 sync-collection for files",
|
|
))
|
|
}
|
|
|
|
async fn get(&self, id: &RemoteId, range: Option<Range<u64>>) -> Result<Vec<u8>, RemoteError> {
|
|
let url = self.url_for_id(id)?;
|
|
let mut req = self
|
|
.client
|
|
.get(&url)
|
|
.basic_auth(&self.login, Some(&self.password));
|
|
|
|
let wanted = range.clone();
|
|
if let Some(r) = &range {
|
|
req = req.header(
|
|
reqwest::header::RANGE,
|
|
format!("bytes={}-{}", r.start, r.end.saturating_sub(1)),
|
|
);
|
|
}
|
|
|
|
let resp = req
|
|
.send()
|
|
.await
|
|
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
|
let status = resp.status();
|
|
map_status(status, &url)?;
|
|
|
|
let body = resp
|
|
.bytes()
|
|
.await
|
|
.map_err(|e| RemoteError::Network(e.to_string()))?
|
|
.to_vec();
|
|
|
|
// Nextcloud does not advertise Accept-Ranges, so support is detected
|
|
// by the response code rather than by probing with HEAD (ARCH §6.7).
|
|
// A 200 to a ranged request means the server ignored it and sent
|
|
// everything; slice locally so callers still get what they asked for.
|
|
if let Some(r) = wanted {
|
|
if status.as_u16() == 200 && body.len() as u64 > r.end {
|
|
let start = r.start.min(body.len() as u64) as usize;
|
|
let end = r.end.min(body.len() as u64) as usize;
|
|
return Ok(body[start..end].to_vec());
|
|
}
|
|
}
|
|
|
|
Ok(body)
|
|
}
|
|
|
|
async fn put(
|
|
&self,
|
|
path: &RemotePath,
|
|
body: Vec<u8>,
|
|
precond: Option<Precondition>,
|
|
) -> Result<Validator, RemoteError> {
|
|
// Chunked upload is an implementation detail of put, chosen by size —
|
|
// exposing it on the trait would leak this protocol (ARCH §8.3).
|
|
if body.len() as u64 >= CHUNKS.single_shot_below {
|
|
return Err(RemoteError::Unsupported(
|
|
"chunked upload v2 not implemented yet",
|
|
));
|
|
}
|
|
|
|
let mut req = self
|
|
.client
|
|
.put(self.url_for(path))
|
|
.basic_auth(&self.login, Some(&self.password));
|
|
|
|
match &precond {
|
|
Some(Precondition::IfMatch(v)) => {
|
|
req = req.header(reqwest::header::IF_MATCH, format!("\"{}\"", v.as_str()));
|
|
}
|
|
Some(Precondition::IfAbsent) => {
|
|
req = req.header(reqwest::header::IF_NONE_MATCH, "*");
|
|
}
|
|
None => {}
|
|
}
|
|
|
|
let resp = req
|
|
.body(body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
|
map_status(resp.status(), path.as_str())?;
|
|
|
|
resp.headers()
|
|
.get(reqwest::header::ETAG)
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(Validator::new)
|
|
.ok_or_else(|| RemoteError::Protocol("no ETag on PUT response".into()))
|
|
}
|
|
|
|
async fn delete(
|
|
&self,
|
|
id: &RemoteId,
|
|
precond: Option<Precondition>,
|
|
) -> Result<(), RemoteError> {
|
|
let url = self.url_for_id(id)?;
|
|
let mut req = self
|
|
.client
|
|
.delete(&url)
|
|
.basic_auth(&self.login, Some(&self.password));
|
|
|
|
if let Some(Precondition::IfMatch(v)) = &precond {
|
|
req = req.header(reqwest::header::IF_MATCH, format!("\"{}\"", v.as_str()));
|
|
}
|
|
|
|
let resp = req
|
|
.send()
|
|
.await
|
|
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
|
map_status(resp.status(), &url)
|
|
}
|
|
|
|
/// TRACES: FR-CAT-15
|
|
/// WebDAV `MOVE`, which preserves `oc:fileid`.
|
|
///
|
|
/// That preservation is the whole reason this is a `MOVE` and not a
|
|
/// `GET`+`PUT`+`DELETE`: the file id is what the thumbnail store keys on and
|
|
/// what the sidecar mapping records, so a move that allocated a new one
|
|
/// would orphan both and turn a trash-then-restore into a full re-download
|
|
/// of every affected file.
|
|
///
|
|
/// `Overwrite: F` — a move must never destroy something already at the
|
|
/// destination. The trash path carries the image id precisely so this cannot
|
|
/// normally fire, but a header that permits overwriting is a header that
|
|
/// eventually does.
|
|
async fn move_to(&self, from: &RemoteId, to: &RemotePath) -> Result<(), RemoteError> {
|
|
let src = self.url_for_id(from)?;
|
|
let dest = self.url_for(to);
|
|
|
|
// The parent has to exist; MOVE does not create it. Nothing else
|
|
// creates the trash folder, so the first trashed image would otherwise
|
|
// fail with a 409 that reads like a permission problem.
|
|
if let Some(parent) = to.parent() {
|
|
self.create_dir(&parent).await?;
|
|
}
|
|
|
|
let resp = self
|
|
.client
|
|
.request(
|
|
reqwest::Method::from_bytes(b"MOVE").expect("valid method"),
|
|
&src,
|
|
)
|
|
.basic_auth(&self.login, Some(&self.password))
|
|
.header("Destination", &dest)
|
|
.header("Overwrite", "F")
|
|
.send()
|
|
.await
|
|
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
|
|
|
map_status(resp.status(), &src)
|
|
}
|
|
|
|
/// `MKCOL`, treating "already there" as success.
|
|
///
|
|
/// Callers use this to guarantee a destination exists, not to claim they
|
|
/// created it — so `405 Method Not Allowed`, which is what Nextcloud returns
|
|
/// for an existing collection, is the goal state and not an error.
|
|
///
|
|
/// Parents are created outermost-first: `MKCOL` fails with `409` if the
|
|
/// parent is missing, and the trash folder's parent is the library root,
|
|
/// which may itself be several levels down.
|
|
async fn create_dir(&self, path: &RemotePath) -> Result<(), RemoteError> {
|
|
// Build the chain of ancestors, shallowest first.
|
|
let mut chain = Vec::new();
|
|
let mut current = Some(path.clone());
|
|
while let Some(p) = current {
|
|
if p.as_str().is_empty() {
|
|
break;
|
|
}
|
|
current = p.parent();
|
|
chain.push(p);
|
|
}
|
|
chain.reverse();
|
|
|
|
for dir in chain {
|
|
let url = self.url_for(&dir);
|
|
let resp = self
|
|
.client
|
|
.request(
|
|
reqwest::Method::from_bytes(b"MKCOL").expect("valid method"),
|
|
&url,
|
|
)
|
|
.basic_auth(&self.login, Some(&self.password))
|
|
.send()
|
|
.await
|
|
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
|
|
|
// 405 is "already a collection here", which is exactly what the
|
|
// caller wanted. Anything else is reported.
|
|
if resp.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED {
|
|
continue;
|
|
}
|
|
map_status(resp.status(), &url)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn thumbnail(&self, id: &RemoteId, size: u32) -> Result<Option<Vec<u8>>, RemoteError> {
|
|
let RemoteId::Stable(file_id) = id else {
|
|
return Ok(None);
|
|
};
|
|
|
|
// forceIcon=false is mandatory: the default returns a generic
|
|
// mimetype icon for files the server cannot render, which would
|
|
// otherwise be cached as though it were a thumbnail (ARCH §6.7).
|
|
let url = format!(
|
|
"{}/core/preview?fileId={file_id}&x={size}&y={size}&a=true&forceIcon=false",
|
|
self.server
|
|
);
|
|
|
|
let resp = self
|
|
.client
|
|
.get(&url)
|
|
.basic_auth(&self.login, Some(&self.password))
|
|
.send()
|
|
.await
|
|
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
|
|
|
// 404 means no preview for this file — expected for RAW, and a
|
|
// fall-through rather than an error.
|
|
if resp.status().as_u16() == 404 {
|
|
return Ok(None);
|
|
}
|
|
map_status(resp.status(), &url)?;
|
|
|
|
Ok(Some(
|
|
resp.bytes()
|
|
.await
|
|
.map_err(|e| RemoteError::Network(e.to_string()))?
|
|
.to_vec(),
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Build an HTTP client with the crypto provider already installed.
|
|
///
|
|
/// Every entry point that creates a client must go through here. The auth
|
|
/// flow runs *before* a backend exists, so leaving the install to
|
|
/// `NextcloudBackend::new` means `auth::begin` panics on first use — which is
|
|
/// exactly what happened.
|
|
pub fn http_client(user_agent: &str) -> Result<reqwest::Client, RemoteError> {
|
|
install_crypto_provider();
|
|
reqwest::Client::builder()
|
|
.user_agent(user_agent.to_string())
|
|
.build()
|
|
.map_err(|e| RemoteError::Network(e.to_string()))
|
|
}
|
|
|
|
/// Install the rustls crypto provider, once per process.
|
|
///
|
|
/// Required because we build reqwest with `rustls-no-provider` rather than
|
|
/// `rustls`: the default provider is aws-lc-rs, whose `aws-lc-sys` crate is C
|
|
/// and does not cross-compile for Android. `ring` is pure Rust apart from a
|
|
/// small assembly core that builds fine under the NDK.
|
|
///
|
|
/// Done here rather than left to callers so there is no way to construct a
|
|
/// client that panics on first use.
|
|
fn install_crypto_provider() {
|
|
use std::sync::Once;
|
|
static ONCE: Once = Once::new();
|
|
ONCE.call_once(|| {
|
|
// Errs only if a provider is already installed, which is fine.
|
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
});
|
|
}
|
|
|
|
/// Translate an HTTP status into a typed error.
|
|
fn map_status(status: reqwest::StatusCode, what: &str) -> Result<(), RemoteError> {
|
|
match status.as_u16() {
|
|
200..=299 => Ok(()),
|
|
401 => Err(RemoteError::AuthFailed),
|
|
// Not an auth failure: the credential authenticated fine and reads
|
|
// work. Reporting this as "authentication rejected" sends the user to
|
|
// re-check a working login (observed 2026-08-09: PROPFIND 207, PUT
|
|
// 403 `Sabre\DAV\Exception\Forbidden`, same app password).
|
|
403 => Err(RemoteError::PermissionDenied),
|
|
404 => Err(RemoteError::NotFound(what.to_string())),
|
|
// Drives the sidecar merge path rather than an overwrite (ARCH §8.5).
|
|
412 => Err(RemoteError::PreconditionFailed),
|
|
507 => Err(RemoteError::QuotaExceeded),
|
|
s => Err(RemoteError::Server {
|
|
status: s,
|
|
detail: what.to_string(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Percent-encode each path segment, leaving separators intact.
|
|
fn encode_path(path: &str) -> String {
|
|
path.split('/')
|
|
.map(|seg| {
|
|
seg.bytes()
|
|
.map(|b| match b {
|
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
|
(b as char).to_string()
|
|
}
|
|
_ => format!("%{b:02X}"),
|
|
})
|
|
.collect::<String>()
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("/")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn backend() -> NextcloudBackend {
|
|
NextcloudBackend::new(
|
|
&AppCredentials {
|
|
server: "https://cloud.example/".into(),
|
|
login_name: "duncan".into(),
|
|
app_password: "token".into(),
|
|
},
|
|
"duncan",
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn capabilities_declare_the_nextcloud_fast_path() {
|
|
let b = backend();
|
|
let c = b.capabilities();
|
|
// Propagating ETags are what make a no-op sync one request.
|
|
assert_eq!(c.change_detection, ChangeDetection::PropagatingEtags);
|
|
assert!(c.stable_ids);
|
|
assert!(c.range_reads);
|
|
assert!(c.conditional_write);
|
|
// Stock Nextcloud cannot render RAW previews.
|
|
assert_eq!(c.server_previews, ServerPreviews::CommonFormatsOnly);
|
|
}
|
|
|
|
#[test]
|
|
fn strategy_selects_etag_pruning() {
|
|
use dr_sync::SyncStrategy;
|
|
let s = SyncStrategy::for_capabilities(backend().capabilities());
|
|
assert_eq!(s, SyncStrategy::EtagPruning);
|
|
assert!(s.has_cheap_noop());
|
|
}
|
|
|
|
#[test]
|
|
fn urls_include_the_dav_prefix() {
|
|
let b = backend();
|
|
assert_eq!(
|
|
b.url_for(&RemotePath::new("Photos/2026")),
|
|
"https://cloud.example/remote.php/dav/files/duncan/Photos/2026"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn root_url_has_no_trailing_segment() {
|
|
let b = backend();
|
|
assert_eq!(
|
|
b.url_for(&RemotePath::root()),
|
|
"https://cloud.example/remote.php/dav/files/duncan/"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn path_segments_are_encoded_but_separators_are_not() {
|
|
assert_eq!(encode_path("My Trip/a b.CR2"), "My%20Trip/a%20b.CR2");
|
|
assert_eq!(encode_path("Photos/2026"), "Photos/2026");
|
|
}
|
|
|
|
#[test]
|
|
fn fetching_by_bare_fileid_is_rejected_clearly() {
|
|
// Callers hold the path alongside the id; failing loudly beats
|
|
// constructing a URL that cannot work.
|
|
let b = backend();
|
|
assert!(matches!(
|
|
b.url_for_id(&RemoteId::Stable(42)),
|
|
Err(RemoteError::Unsupported(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn statuses_map_to_actionable_errors() {
|
|
use reqwest::StatusCode;
|
|
assert!(map_status(StatusCode::OK, "x").is_ok());
|
|
assert!(matches!(
|
|
map_status(StatusCode::UNAUTHORIZED, "x"),
|
|
Err(RemoteError::AuthFailed)
|
|
));
|
|
// 412 must be distinguishable — it drives the sidecar merge.
|
|
assert!(matches!(
|
|
map_status(StatusCode::PRECONDITION_FAILED, "x"),
|
|
Err(RemoteError::PreconditionFailed)
|
|
));
|
|
assert!(matches!(
|
|
map_status(StatusCode::INSUFFICIENT_STORAGE, "x"),
|
|
Err(RemoteError::QuotaExceeded)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn http_client_works_before_any_backend_exists() {
|
|
// The auth flow builds a client before a backend is constructed. If
|
|
// the crypto provider is installed only in NextcloudBackend::new,
|
|
// this panics — which is exactly what happened against the live
|
|
// server.
|
|
let c = http_client("test");
|
|
assert!(c.is_ok(), "client must build without a backend");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delta_is_unsupported_and_says_why() {
|
|
// Verified absent in the server; the engine must fall back rather
|
|
// than treat this as a failure.
|
|
let b = backend();
|
|
assert!(matches!(
|
|
b.delta(&Cursor::new("x")).await,
|
|
Err(RemoteError::Unsupported(_))
|
|
));
|
|
}
|
|
}
|