`RemoteBackend` existed from the first release and bought nothing it was
designed for. Seven files in `dr-ui` constructed a `NextcloudBackend`
directly, an account *was* a server URL beside a DAV user id, the local
cache directory was named after a hostname, and the launch screen knew
that signing in meant a browser handshake. The trait was real; the seam
was documentation.
A trait over operations is only a quarter of it. Pluggable storage needs
four things, and this adds the other three:
- **Capabilities** — already there, and the reason the engine can drive
two backends at the speed each actually runs at.
- **Configuration** — `dr_sync::Account`: where a library lives, in
whatever form its connector addresses, with no server in it. Loads
every existing config unchanged (`backend` defaults to `nextcloud`,
`endpoint` is stored under its historical `server` key), and
`Account::namespace()` reproduces the old catalog directory byte for
byte, because changing it would abandon a catalog, its thumbnail
shards, and the sidecars holding unsynced offline work.
- **Registration** — `BackendProvider` and `BackendRegistry`.
`ui/dr-ui/src/remote.rs` is now the only file above `dr-sync` that
names a connector.
`Connection` (an account plus an optional `Secret`) replaces the
credentials-and-user-id pair that was threaded through fifteen
signatures in an order that could be swapped. `Secret`'s inner string is
reachable only through `expose()` and its `Debug` prints `Secret(***)`,
so the indirect leak — a `{:?}` on anything holding one — no longer
compiles into a leak.
Nextcloud is unchanged and keeps every peculiarity: propagating ETags,
chunked upload v2, `oc:fileid`, the `oc:permissions` probe on a refused
PUT, the 423 retry classification, Login Flow v2. Those are what the
capability model exists to serve, not something to hide.
`dr-sync-folder` is the second connector: a local disk, a network mount,
an external drive, or a folder a Nextcloud client already syncs. No
account, no credential — the route that works where no secrets daemon
does. It declares `LocalEtags` rather than claiming propagation a POSIX
directory cannot provide, which costs nothing because 50k `stat` calls
are not 50k PROPFINDs. Identity is a path hash, not an inode: an inode
survives a rename but differs between devices and is reused after a
delete, so two machines would disagree about which photograph a
thumbnail belonged to. Re-deriving a thumbnail is a cost; showing the
wrong one is a bug.
docs/storage.md is the contract — the traits, the four steps to add a
backend, and what each connector declares. ARCH §8.0 and §8.4a, and
FR-NC-13, say why.
177 lines
6.1 KiB
Rust
177 lines
6.1 KiB
Rust
//! One-shot write probe: PUT a tiny file, report the status, DELETE it.
|
|
use dr_plat::PlatformSecretStore;
|
|
use dr_sync::{AccountStore, RemoteBackend, RemoteId, RemotePath};
|
|
use dr_sync_nextcloud::{NextcloudBackend, NextcloudProvider};
|
|
|
|
#[tokio::main(flavor = "current_thread")]
|
|
async fn main() {
|
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
|
|
|
let store = AccountStore::open(Box::new(PlatformSecretStore::new()));
|
|
let Some(session) = store.current() else {
|
|
println!("no stored session");
|
|
return;
|
|
};
|
|
let creds = match store
|
|
.connection(&session, true)
|
|
.map_err(|e| e.to_string())
|
|
.and_then(|c| NextcloudProvider::credentials(&c).map_err(|e| e.to_string()))
|
|
{
|
|
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}"),
|
|
}
|
|
|
|
// What is actually in the derived folder on the server?
|
|
{
|
|
let dir = RemotePath::new(format!("{}/.darkroom-derived", session.root));
|
|
match backend.list(&dir, None).await {
|
|
Ok(entries) => {
|
|
println!("\n[derived] {} entry/entries on the server:", entries.len());
|
|
for e in &entries {
|
|
println!(" {:<28} {:>10} bytes", e.path.name(), e.size);
|
|
}
|
|
}
|
|
Err(e) => println!("\n[derived] listing failed: {e}"),
|
|
}
|
|
}
|
|
|
|
// Probe a file the sweep reported as 423 Locked: is it the file, the
|
|
// range request, or the folder?
|
|
{
|
|
let c = dr_sync_nextcloud::http_client("DarkRoom").unwrap();
|
|
let base = format!(
|
|
"{}/remote.php/dav/files/{}",
|
|
creds.server.trim_end_matches('/'),
|
|
session.user_id
|
|
);
|
|
let f = "PhotosRaw/Darktable/20230629_no_name/20230629_0030.jpeg";
|
|
let enc: String = f
|
|
.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("/");
|
|
let url = format!("{base}/{enc}");
|
|
|
|
for (what, range) in [
|
|
("ranged 0-256k", Some("bytes=0-262143")),
|
|
("whole file", None),
|
|
] {
|
|
let mut rq = c
|
|
.get(&url)
|
|
.basic_auth(&creds.login_name, Some(&creds.app_password));
|
|
if let Some(r) = range {
|
|
rq = rq.header("Range", r);
|
|
}
|
|
match rq.send().await {
|
|
Ok(r) => println!("GET {what}: {}", r.status()),
|
|
Err(e) => println!("GET {what}: transport {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}"),
|
|
}
|
|
}
|