//! Exercise the connector against a real Nextcloud server. //! //! ```text //! cargo run -p dr-sync-nextcloud --example connect -- https://cloud.example [remote/path] //! ``` //! //! **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::path::PathBuf; use std::time::Instant; use dr_sync::{RemoteBackend, RemoteId, RemotePath, SyncStrategy}; use dr_sync_nextcloud::{auth, AppCredentials, NextcloudBackend}; #[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 [remote/path]"); std::process::exit(2); }; let start_path = args.next().unwrap_or_default(); let creds = match load_cached(&server) { Some(c) => { println!("using cached credentials for {}", c.login_name); c } None => match authenticate(&server).await { Ok(c) => c, Err(e) => { eprintln!("authentication failed: {e}"); std::process::exit(1); } }, }; // The DAV base needs the *user id*, which may differ from the login name // (a login can be an email address). OCS reports the real one. let user_id = fetch_user_id(&creds).await.unwrap_or_else(|e| { eprintln!("could not resolve user id ({e}); falling back to login name"); creds.login_name.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); let mut failures = 0; // ---- 1. listing ---------------------------------------------------- println!("\n[1] PROPFIND Depth:1 on /{start_path}"); let t = Instant::now(); let entries = match backend.list(&root, None).await { Ok(e) => { println!( " {} entries in {:.0}ms", e.len(), t.elapsed().as_secs_f64() * 1000.0 ); e } Err(e) => { println!(" FAILED: {e}"); failures += 1; Vec::new() } }; for e in entries.iter().take(5) { println!( " {:?} {:<40} {:>10} id={:?}{}", e.kind, truncate(e.path.name(), 40), human(e.size), e.id, if e.has_preview { " preview" } else { "" } ); } if entries.len() > 5 { println!(" … {} more", entries.len() - 5); } // ---- 2. the pruning probe ------------------------------------------ println!("\n[2] PROPFIND Depth:0 — the ETag pruning probe (FR-NC-4)"); let t = Instant::now(); match backend.dir_validator(&root).await { Ok(v) => println!( " etag {} in {:.0}ms — one request proves the tree unchanged", v.as_str(), t.elapsed().as_secs_f64() * 1000.0 ), Err(e) => { println!(" FAILED: {e}"); failures += 1; } } // ---- 3. range read ------------------------------------------------- // The mechanism the whole mobile story rests on (FR-NC-3). let file = entries .iter() .find(|e| e.kind == dr_sync::EntryKind::File && e.size > 300_000); if let Some(f) = file { println!( "\n[3] Range GET — 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!( " got {} in {ms:.0}ms ({pct:.1}% of the file)", human(bytes.len() as u64) ); if bytes.len() as u64 >= f.size { println!(" WARNING: whole file returned — server ignored Range"); failures += 1; } else { println!(" range requests work — remote browsing is viable"); } if let Some(fmt) = dr_decode::probe(&bytes) { println!(" probe: {fmt:?}"); } match dr_decode::metadata(&bytes) { Ok(m) => println!( " metadata from the range alone: {} {}", m.make.unwrap_or_default(), m.model.unwrap_or_default() ), Err(e) => println!(" metadata: {e}"), } } Err(e) => { println!(" FAILED: {e}"); failures += 1; } } // ---- 4. server preview ------------------------------------------ println!("\n[4] Server preview (ARCH §6.7 expects none for RAW)"); match backend.thumbnail(&f.id, 256).await { Ok(Some(b)) => println!(" {} returned", human(b.len() as u64)), Ok(None) => println!(" none — as expected; local extraction is the path"), Err(e) => println!(" error: {e}"), } } else { println!("\n[3] skipped — no file over 300KB at this path"); } println!( "\n{}", if failures == 0 { "all checks passed" } else { "FAILURES" } ); if failures > 0 { std::process::exit(1); } } async fn authenticate(server: &str) -> Result { let client = dr_sync_nextcloud::http_client("DarkRoom (connect example)").map_err(|e| e.to_string())?; let flow = auth::begin(&client, server, "DarkRoom (connect example)") .await .map_err(|e| e.to_string())?; println!("\n Open this in a browser and approve:\n"); println!(" {}\n", flow.login_url); println!(" waiting (20 minute limit)…"); let creds = auth::poll(&client, &flow) .await .map_err(|e| e.to_string())?; println!(" authenticated as {}", creds.login_name); save_cached(server, &creds); Ok(creds) } /// Resolve the real user id, which the DAV path needs. async fn fetch_user_id(creds: &AppCredentials) -> Result { 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 cache_path(server: &str) -> PathBuf { let dir = std::env::var_os("XDG_CACHE_HOME") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".cache")) .join("darkroom"); let _ = std::fs::create_dir_all(&dir); let key: String = server .chars() .map(|c| if c.is_alphanumeric() { c } else { '_' }) .collect(); dir.join(format!("{key}.json")) } fn load_cached(server: &str) -> Option { let text = std::fs::read_to_string(cache_path(server)).ok()?; serde_json::from_str(&text).ok() } fn save_cached(server: &str, creds: &AppCredentials) { let path = cache_path(server); if let Ok(json) = serde_json::to_string(creds) { let _ = std::fs::write(&path, json); // Testing convenience only — FR-NC-2 requires platform secure storage. #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); } println!(" cached credentials at {}", path.display()); } } 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"), } } fn truncate(s: &str, n: usize) -> String { if s.chars().count() <= n { s.to_string() } else { format!("{}…", s.chars().take(n - 1).collect::()) } }