Add remote move and mkdir; distinguish 403 from 401
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
This commit is contained in:
@@ -266,6 +266,91 @@ impl RemoteBackend for NextcloudBackend {
|
||||
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);
|
||||
@@ -339,7 +424,12 @@ fn install_crypto_provider() {
|
||||
fn map_status(status: reqwest::StatusCode, what: &str) -> Result<(), RemoteError> {
|
||||
match status.as_u16() {
|
||||
200..=299 => Ok(()),
|
||||
401 | 403 => Err(RemoteError::AuthFailed),
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user