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
88 lines
3.6 KiB
Rust
88 lines
3.6 KiB
Rust
//! One-shot write probe: PUT a tiny file, report the status, DELETE it.
|
|
use dr_plat::PlatformSecretStore;
|
|
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
|
|
use dr_sync_nextcloud::{NextcloudBackend, SessionStore};
|
|
|
|
#[tokio::main(flavor = "current_thread")]
|
|
async fn main() {
|
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
|
|
|
let store = SessionStore::open(Box::new(PlatformSecretStore::new()));
|
|
let Some(session) = store.current() else {
|
|
println!("no stored session");
|
|
return;
|
|
};
|
|
let creds = match store.credentials(&session) {
|
|
Ok(c) => c,
|
|
Err(e) => { println!("credentials: {e}"); return; }
|
|
};
|
|
println!("account: {} root={}", session.describe(), session.root);
|
|
|
|
let backend = NextcloudBackend::new(&creds, &session.user_id).unwrap();
|
|
|
|
// Read, which we know works.
|
|
let dir = RemotePath::new(&session.root);
|
|
match backend.list(&dir, None).await {
|
|
Ok(v) => println!("READ ok: {} entries", v.len()),
|
|
Err(e) => println!("READ FAILED: {e}"),
|
|
}
|
|
|
|
// Raw HTTP, to see the status the connector maps away.
|
|
{
|
|
let url = format!("{}/remote.php/dav/files/{}/{}/.darkroom-write-test",
|
|
creds.server.trim_end_matches('/'), session.user_id, session.root);
|
|
let c = dr_sync_nextcloud::http_client("DarkRoom").unwrap();
|
|
match c.put(&url).basic_auth(&creds.login_name, Some(&creds.app_password))
|
|
.body("probe").send().await {
|
|
Ok(r) => {
|
|
println!("RAW PUT status: {}", r.status());
|
|
let body = r.text().await.unwrap_or_default();
|
|
let body = body.trim();
|
|
if !body.is_empty() {
|
|
println!("RAW body: {}", &body[..body.len().min(400)]);
|
|
}
|
|
}
|
|
Err(e) => println!("RAW PUT transport error: {e}"),
|
|
}
|
|
}
|
|
|
|
// Is it the folder or the account? Probe the account root too: a
|
|
// read-only *share* refuses writes inside it while the account writes
|
|
// freely elsewhere, which is a different fix from a credential problem.
|
|
{
|
|
let c = dr_sync_nextcloud::http_client("DarkRoom").unwrap();
|
|
let base = format!("{}/remote.php/dav/files/{}",
|
|
creds.server.trim_end_matches('/'), session.user_id);
|
|
for (what, url) in [
|
|
("account root", format!("{base}/.darkroom-write-test")),
|
|
("library root", format!("{base}/{}/.darkroom-write-test", session.root)),
|
|
] {
|
|
match c.put(&url).basic_auth(&creds.login_name, Some(&creds.app_password))
|
|
.body("probe").send().await {
|
|
Ok(r) => {
|
|
println!("PUT {what}: {}", r.status());
|
|
if r.status().is_success() {
|
|
let _ = c.delete(&url)
|
|
.basic_auth(&creds.login_name, Some(&creds.app_password))
|
|
.send().await;
|
|
}
|
|
}
|
|
Err(e) => println!("PUT {what}: transport error {e}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Write into the library root.
|
|
let p = RemotePath::new(format!("{}/.darkroom-write-test", session.root));
|
|
match backend.put(&p, b"darkroom write probe\n".to_vec(), None).await {
|
|
Ok(v) => {
|
|
println!("WRITE ok: etag={}", v.as_str());
|
|
match backend.delete(&RemoteId::Path(p.clone()), None).await {
|
|
Ok(()) => println!("CLEAN ok"),
|
|
Err(e) => println!("CLEAN failed (harmless): {e}"),
|
|
}
|
|
}
|
|
Err(e) => println!("WRITE FAILED: {e}"),
|
|
}
|
|
}
|