Files
DarkRoom/core/dr-sync-nextcloud/examples/connect.rs
T
dtourolle 09e3043f4c Add secure credential storage, sessions, and a launch screen
Login now persists properly rather than through the JSON file the test
harness was using.

  dr-plat            SecretStore trait plus a Secret Service backend.
                     Verified against the live GNOME Keyring: store,
                     retrieve, delete, confirm-gone all round-trip.
  Session/SessionStore   splits credentials from settings — the app
                     password goes to the keyring (FR-NC-2), while
                     server, login, chosen root and format selection are
                     ordinary config. A test asserts the credential never
                     appears in the config file.
  LaunchModel        the launch-screen state machine, testable without a
                     display server: sign in, approve in browser, choose
                     folder, tick formats, sign out.
  launch.slint       the screen itself, in its own file.

Absence of a secrets daemon is an explicit degraded mode, not a silent
fallback to plaintext — the screen says sign-in will not persist rather
than letting the user find out next launch. Android's Keystore backend
fails loudly for the same reason: a no-op store would look like it
worked and then lose the credential.

Two bugs caught by tests rather than by running it:

  - fail() after busy() signed the user out, because busy() had already
    discarded the session. A failed *scan* would have logged you out.
    Busy now carries the session.
  - normalise_server upgrades http:// to https:// rather than accepting
    it. NFR-SEC-3 requires TLS, and silently sending a credential in the
    clear is not a decision to make on the user's behalf.

launch.slint is not yet wired into app.slint. Calling slint_build::compile
twice replaces the generated module rather than adding to it, which broke
the other in-flight work on dr-ui; I reverted that immediately. Wiring it
needs an import inside app.slint, which is that work's file to change.

419 tests passing across ten crates.
2026-08-09 15:20:39 +02:00

314 lines
11 KiB
Rust

//! Exercise the connector against a real Nextcloud server.
//!
//! ```text
//! cargo run -p dr-sync-nextcloud --example connect -- <server> [path] [--raw|--formats cr2,nef]
//! ```
//!
//! With no path it lists the account root so a folder can be chosen. Given a
//! path it scans recursively for images matching the format filter, which is
//! the library-setup flow (FR-CAT-1).
//!
//! **Strictly read-only.** PROPFIND, ETag probes, range GETs and preview
//! requests only — no PUT, MOVE or DELETE — so it cannot alter a live library.
//!
//! Authentication uses Login Flow v2 (FR-NC-1): the app never sees a password.
//! A URL is printed for the browser, and the resulting app password is
//! device-scoped and revocable from the server's security settings.
//!
//! Credentials are cached under `$XDG_CACHE_HOME/darkroom/` so repeat runs do
//! not re-authenticate. That location is for *testing convenience* only;
//! FR-NC-2 requires the real app to use platform secure storage.
use std::collections::HashMap;
use std::time::Instant;
use dr_plat::PlatformSecretStore;
use dr_sync::{RemoteBackend, RemoteId, RemotePath, SyncStrategy};
use dr_sync_nextcloud::{auth, AppCredentials, NextcloudBackend, Session, SessionStore};
#[tokio::main]
async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let mut args = std::env::args().skip(1);
let Some(server) = args.next() else {
eprintln!("usage: connect <server-url> [remote/path]");
std::process::exit(2);
};
let rest: Vec<String> = args.collect();
let start_path = rest
.iter()
.find(|a| !a.starts_with("--"))
.cloned()
.unwrap_or_default();
// Format selection — the tick-boxes, as a CLI flag.
let filter = if let Some(i) = rest.iter().position(|a| a == "--formats") {
let list = rest.get(i + 1).cloned().unwrap_or_default();
dr_types::FormatFilter::from_formats(
list.split(',')
.filter_map(|s| dr_types::Format::from_extension(s.trim())),
)
} else if rest.iter().any(|a| a == "--raw") {
dr_types::FormatFilter::raw_only()
} else {
dr_types::FormatFilter::all()
};
// Sessions persist across runs: credentials in the platform keyring
// (FR-NC-2), everything else as ordinary config.
let sessions = SessionStore::open(Box::new(PlatformSecretStore::new()));
if !sessions.can_remember() {
println!("note: no secrets daemon — sign-in will not persist this session");
}
let existing = sessions
.current()
.filter(|s| s.server == server.trim_end_matches('/'));
let (session, creds) = match existing {
Some(s) => match sessions.credentials(&s) {
Ok(c) => {
println!("signed in: {}", s.describe());
(s, c)
}
Err(e) => {
// Revoked server-side, or the keyring was cleared.
println!("stored credential unusable ({e}); signing in again");
sign_in(&server, &sessions).await
}
},
None => sign_in(&server, &sessions).await,
};
let user_id = session.user_id.clone();
println!("user id: {user_id}");
let backend = NextcloudBackend::new(&creds, &user_id).expect("build backend");
println!("\nbackend: {}", backend.name());
let caps = backend.capabilities();
let strategy = SyncStrategy::for_capabilities(caps);
println!("strategy: {} ({:?})", strategy.describe(), strategy);
println!("cheap no-op sync: {}", strategy.has_cheap_noop());
let root = RemotePath::new(&start_path);
// No path given: list this level so the user can pick a folder.
if start_path.is_empty() {
println!("\n[browse] PROPFIND Depth:1 on the account root");
let t = Instant::now();
match backend.list(&root, None).await {
Ok(entries) => {
println!(
" {} entries in {:.0}ms\n",
entries.len(),
t.elapsed().as_secs_f64() * 1000.0
);
let mut dirs: Vec<_> = entries
.iter()
.filter(|e| e.kind == dr_sync::EntryKind::Directory)
.collect();
dirs.sort_by_key(|e| e.path.name().to_ascii_lowercase());
for d in &dirs {
println!(" {}/", d.path.name());
}
println!(
"\n Re-run with a folder to scan it, e.g.:\n … {} \"{}\" --raw",
server,
dirs.first().map(|d| d.path.name()).unwrap_or("Photos")
);
}
Err(e) => {
eprintln!(" FAILED: {e}");
std::process::exit(1);
}
}
return;
}
// A path was given: scan it recursively for the selected formats.
println!("\n[scan] {} under /{start_path}", describe_filter(&filter));
let t = Instant::now();
let result = match dr_sync::scan(&backend, &root, &filter, &HashMap::new(), |p| {
if p.directories_listed % 25 == 0 && p.directories_listed > 0 {
print!(
"\r {} dirs, {} images…",
p.directories_listed, p.images_found
);
let _ = std::io::Write::flush(&mut std::io::stdout());
}
})
.await
{
Ok(r) => r,
Err(e) => {
eprintln!("\n FAILED: {e}");
std::process::exit(1);
}
};
let scan_ms = t.elapsed().as_secs_f64() * 1000.0;
println!(
"\r {} images in {} directories, {:.1}s",
result.images.len(),
result.progress.directories_listed,
scan_ms / 1000.0
);
let mut by_ext: std::collections::BTreeMap<String, usize> = Default::default();
for i in &result.images {
let ext = i
.path
.name()
.rsplit_once('.')
.map(|(_, e)| e.to_ascii_uppercase())
.unwrap_or_default();
*by_ext.entry(ext).or_default() += 1;
}
for (ext, n) in &by_ext {
println!(" {ext:<6} {n}");
}
// Prove the fast path on a real RAW: metadata from a header range alone.
let raw = result.images.iter().find(|i| {
i.path
.name()
.rsplit_once('.')
.and_then(|(_, e)| dr_types::Format::from_extension(&e.to_ascii_lowercase()))
.is_some_and(|f| f.is_raw())
&& i.size > 300_000
});
if let Some(f) = raw {
println!(
"\n[range] first 256KB of {} ({})",
f.path.name(),
human(f.size)
);
let id = RemoteId::Path(f.path.clone());
let t = Instant::now();
match backend.get(&id, Some(0..262_144)).await {
Ok(bytes) => {
let ms = t.elapsed().as_secs_f64() * 1000.0;
let pct = (bytes.len() as f64 / f.size as f64) * 100.0;
println!(
" {} in {ms:.0}ms — {pct:.2}% of the file",
human(bytes.len() as u64)
);
match dr_decode::metadata(&bytes) {
Ok(m) => println!(
" metadata from that range alone: {} {} | {}",
m.make.unwrap_or_default().trim(),
m.model.unwrap_or_default().trim(),
m.iso.map(|i| format!("ISO {i}")).unwrap_or_default()
),
Err(e) => println!(" metadata: {e}"),
}
}
Err(e) => println!(" FAILED: {e}"),
}
} else {
println!("\n[range] skipped — no RAW over 300KB found");
}
// Remember what was scanned, so the next launch resumes here.
let mut updated = session.clone();
updated.root = start_path.clone();
updated.set_format_filter(&filter);
if let Err(e) = sessions.update(&updated) {
eprintln!("could not update session: {e}");
} else {
println!("\nremembered: {}", updated.describe());
}
println!("scan complete");
}
fn describe_filter(f: &dr_types::FormatFilter) -> String {
let names: Vec<&str> = f.iter().map(|x| x.label()).collect();
if names.len() >= 9 {
"all supported formats".into()
} else {
names.join(", ")
}
}
/// Run Login Flow v2 and persist the result.
async fn sign_in(server: &str, sessions: &SessionStore) -> (Session, AppCredentials) {
let client = match dr_sync_nextcloud::http_client("DarkRoom") {
Ok(c) => c,
Err(e) => {
eprintln!("could not build http client: {e}");
std::process::exit(1);
}
};
let flow = match auth::begin(&client, server, "DarkRoom (connect example)").await {
Ok(f) => f,
Err(e) => {
eprintln!("could not start login: {e}");
std::process::exit(1);
}
};
println!("\n Open this in a browser and approve:\n");
println!(" {}\n", flow.login_url);
println!(" waiting (20 minute limit)…");
let creds = match auth::poll(&client, &flow).await {
Ok(c) => c,
Err(e) => {
eprintln!("login failed: {e}");
std::process::exit(1);
}
};
println!(" authenticated as {}", creds.login_name);
let user_id = fetch_user_id(&creds).await.unwrap_or_else(|e| {
eprintln!(" could not resolve user id ({e}); using login name");
creds.login_name.clone()
});
let session = Session::new(&creds, user_id);
match sessions.save(&session, &creds) {
Ok(()) => println!(" session saved to {}", sessions.config_path().display()),
Err(e) => eprintln!(" could not persist session: {e}"),
}
(session, creds)
}
/// Resolve the real user id, which the DAV path needs.
async fn fetch_user_id(creds: &AppCredentials) -> Result<String, String> {
let client = dr_sync_nextcloud::http_client("DarkRoom").map_err(|e| e.to_string())?;
let url = format!(
"{}/ocs/v2.php/cloud/user?format=json",
creds.server.trim_end_matches('/')
);
let body = client
.get(&url)
.basic_auth(&creds.login_name, Some(&creds.app_password))
.header("OCS-APIRequest", "true")
.send()
.await
.map_err(|e| e.to_string())?
.text()
.await
.map_err(|e| e.to_string())?;
let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
v["ocs"]["data"]["id"]
.as_str()
.map(str::to_string)
.ok_or_else(|| "no id in OCS response".to_string())
}
fn human(bytes: u64) -> String {
match bytes {
b if b >= 1_000_000_000 => format!("{:.1}GB", b as f64 / 1e9),
b if b >= 1_000_000 => format!("{:.1}MB", b as f64 / 1e6),
b if b >= 1_000 => format!("{:.0}KB", b as f64 / 1e3),
b => format!("{b}B"),
}
}