Make storage pluggable, and prove it with a folder backend

`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.
This commit is contained in:
2026-08-29 09:57:52 +02:00
parent 1b8b7998a2
commit f12aece07e
40 changed files with 3617 additions and 960 deletions
+7 -8
View File
@@ -55,8 +55,8 @@ use std::sync::Arc;
use dr_ingest::{Candidate, DupKey, Imported, Ingest, Options, Report, Shot, TransferMode};
use dr_plat::{DirRef, LocalStorage, Storage, WritableStorage};
use dr_sync::{RemoteBackend, RemotePath};
use dr_sync_nextcloud::AppCredentials;
use dr_sync::{Account, Connection, RemoteBackend, RemotePath};
use dr_types::{FormatFilter, RootId};
/// Which root the card is granted as, and which the library is.
@@ -103,8 +103,7 @@ pub struct Request {
/// an import, and the photographs exist on disk either way.
#[derive(Clone)]
pub struct Upload {
pub credentials: AppCredentials,
pub user_id: String,
pub conn: Connection,
/// The library folder on the server. The dated folders from the template
/// are created beneath it, the same ones the local copy went into.
pub library: String,
@@ -122,8 +121,8 @@ pub struct Upload {
/// TRACES: FR-NC-7b
/// Where an account's imports wait between disk and the server.
pub fn staging_dir(server: &str, user_id: &str) -> PathBuf {
crate::library::catalog_path(server, user_id)
pub fn staging_dir(account: &Account) -> PathBuf {
crate::library::catalog_path(account)
.parent()
.map(|p| p.join("staging"))
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-import-staging"))
@@ -133,7 +132,7 @@ impl std::fmt::Debug for Upload {
/// Hand-written so a credential cannot reach a log through a `{:?}`.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Upload")
.field("user_id", &self.user_id)
.field("account", &self.conn.account.describe())
.field("library", &self.library)
.field("thumbs", &self.thumbs)
.field("staging", &self.staging)
@@ -385,7 +384,7 @@ fn upload_all(
};
rt.block_on(async {
let backend = match crate::remote::connect(&upload.credentials, &upload.user_id) {
let backend = match crate::remote::connect(&upload.conn) {
Ok(b) => b,
Err(e) => {
log::warn!("connecting to upload: {e}");