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:
2026-08-09 20:55:04 +02:00
parent 57f8d42a5c
commit 81e89de7ea
4 changed files with 230 additions and 2 deletions
+91 -1
View File
@@ -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),