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.
314 lines
11 KiB
Rust
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"),
|
|
}
|
|
}
|