diff --git a/Cargo.lock b/Cargo.lock index eaa6ce0..248f0e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1565,6 +1565,7 @@ dependencies = [ "pollster", "quick-xml", "resvg", + "rustls", "semver", "serde", "serde_json", @@ -1576,6 +1577,7 @@ dependencies = [ "wayland-client", "wayland-protocols", "webbrowser", + "webpki-roots 0.26.11", "wgpu", "winit", ] diff --git a/Cargo.toml b/Cargo.toml index b790537..b34e2be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,10 @@ semver = "1.0" sha2 = "0.10" ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] } url = "2.5" +# Nextcloud sync (Issue #49): pinning a self-signed certificate needs our own verifier; +# same versions and features as ureq uses +rustls = { version = "0.23", default-features = false, features = ["logging", "ring", "std", "tls12"] } +webpki-roots = "0.26" # Bilder einfügen: Dekodieren (PNG/JPEG/GIF/WebP/BMP) und Systemzwischenablage image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] } diff --git a/README.de.md b/README.de.md index 2c3419f..d4fef2d 100644 --- a/README.de.md +++ b/README.de.md @@ -74,6 +74,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra - **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an. - `Strg + S` speichert sofort. Der Punkt am ☰-Knopf zeigt den Stand: grün = gespeichert, orange = Änderungen warten, gelb = speichert gerade, rot = Fehler. - **Speicherort** (☰-Menü, „Speicherort“): Liegen die Notizbücher in einem Ordner, den **Nextcloud, Google Drive, Syncthing oder Dropbox** abgleichen, sind sie auf allen Geräten verfügbar. Die Notizbücher liegen im Unterordner `ink-notes`; ist er noch leer, werden die vorhandenen Notizbücher dorthin kopiert (die alten bleiben erhalten). Änderungen von einem anderen Gerät erscheinen nach wenigen Sekunden ohne neu zu öffnen – aber nur, solange lokal alles gespeichert ist, damit nichts überschrieben wird. Legt der Sync-Client eine Konfliktkopie einer Seite an, wird sie als eigene Seite („Konfliktkopie: …“) übernommen statt verloren zu gehen. Ist ein Notizbuch gerade auf einem anderen Gerät geöffnet, warnt die App beim Öffnen. +- **Nextcloud eingebaut** (☰-Menü, „Nextcloud“; auch unter Android und ohne Sync-Client): Adresse der eigenen Nextcloud eingeben – auch ein privater Server, in einem Unterordner (`example.org/nextcloud`) oder mit eigenem Port. Die Anmeldung läuft im Browser (auch mit Zwei-Faktor-Anmeldung); die App bekommt ein eigenes App-Passwort, das sich in Nextcloud unter Einstellungen → Sicherheit jederzeit widerrufen lässt, und sieht das echte Passwort nie. Die Notizbücher werden alle 30 Sekunden und kurz nach dem Speichern mit dem Ordner `ink-notes` in den Nextcloud-Dateien abgeglichen; offline arbeitet die App normal weiter. Wurde eine Seite auf zwei Geräten geändert, bleiben beide Fassungen erhalten (die andere als eigene Seite); gelöschte Notizbücher und Seiten landen im Papierkorb (lokal `.trash`, auf dem Server dessen Papierkorb). Einem selbst signierten Zertifikat vertraut die App, nachdem du seinen Fingerabdruck bestätigt hast; Adressen mit `http://` gehen nur mit Warnung, weil das App-Passwort dann im Netz lesbar wäre. „Trennen“ beendet den Abgleich und widerruft das App-Passwort; die Notizbücher bleiben erhalten. Bitte entweder den eingebauten Abgleich oder den Ordner eines Sync-Clients als Speicherort verwenden, nicht beides für dieselben Notizbücher. ### Export - **PDF** (`Strg + E`) nach `~/Documents/.pdf`: Striche und Formen als Vektoren, Textfelder als echter, durchsuchbarer Text, importierte PDF-Seiten als Vektor-Unterlage, Seitentitel als Lesezeichen. @@ -195,6 +196,8 @@ src/ ├── history.rs # Rückgängig/Wiederholen ├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb, Speicherort ├── disk_watch.rs # Änderungen anderer Geräte übernehmen (Sync-Clients) +├── nextcloud.rs # Nextcloud: Anmeldung im Browser, WebDAV, gemerkte Zertifikate +├── cloud_sync.rs # Eingebauter Abgleich mit Nextcloud (Vergleich, Zusammenführen, Hintergrund) ├── library.rs # Ordner der Notizbücher (library.json) ├── math.rs # Exaktes Ausrechnen handgeschriebener Formeln (Issue #46) ├── math_ink.rs # Erkennung handgeschriebener Formeln ($P-Punktwolken) diff --git a/README.md b/README.md index 6afe5a9..1a99024 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ The interface is available in English and German; ink-notes follows the system l - **When closing** (window or `Ctrl + Q`) and when switching notebooks, everything pending is written before continuing. If that fails, the app stays open and offers to retry or to make an emergency backup. - `Ctrl + S` saves immediately. The dot on the ☰ button shows the state: green = saved, orange = changes pending, yellow = saving, red = error. - **Storage location** (☰ menu, "Storage location"): put the notebooks into a folder that **Nextcloud, Google Drive, Syncthing or Dropbox** keep in sync, and they are available on all your devices. The notebooks are kept in the subfolder `ink-notes`; if it is still empty, the existing notebooks are copied there (the old ones stay). Changes arriving from another device show up within a few seconds without reopening – but only while everything local is saved, so nothing gets overwritten. If the sync client makes a conflict copy of a page, it is added as a page of its own („Conflict copy: …“) instead of getting lost. Opening a notebook that is open on another device shows a warning. +- **Nextcloud built in** (☰ menu, "Nextcloud"; also on Android and without a sync client): enter the address of your Nextcloud – also a private server, in a subfolder (`example.org/nextcloud`) or with its own port. Signing in happens in the browser (also with two-factor sign-in); the app gets an app password of its own, which you can revoke in Nextcloud under Settings → Security at any time, and never sees your real password. The notebooks are synced with the folder `ink-notes` in your Nextcloud files every 30 seconds and shortly after saving; the app keeps working offline. If a page was changed on two devices, both versions are kept (the other one as a page of its own); deleted notebooks and pages go into the trash (locally `.trash`, on the server its trash bin). A self-signed certificate is trusted once you have confirmed its fingerprint; addresses with `http://` only work with a warning, since the app password would be readable on the network. "Disconnect" stops syncing and revokes the app password; the notebooks stay. Use either the built-in sync or a sync client's folder as storage location, not both for the same notebooks. ### Export - **PDF** (`Ctrl + E`) to `~/Documents/.pdf`: strokes and shapes as vectors, text boxes as real, searchable text, imported PDF pages as vector background, page titles as bookmarks. @@ -197,6 +198,8 @@ src/ ├── history.rs # Undo/redo ├── storage.rs # Loading/saving (atomic), notebooks, trash, storage location ├── disk_watch.rs # Taking over changes from other devices (sync clients) +├── nextcloud.rs # Nextcloud: sign-in in the browser, WebDAV, pinned certificates +├── cloud_sync.rs # Built-in sync with Nextcloud (comparison, merging, background thread) ├── library.rs # Notebook folders (library.json) ├── math.rs # Exact evaluation of handwritten math (Issue #46) ├── math_ink.rs # Recognition of handwritten math ($P point clouds) diff --git a/src/app.rs b/src/app.rs index c5956af..e16d6b4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1483,6 +1483,8 @@ pub fn run_with( ui_state.library = Library::load(&storage_base_dir); ui_state.storage_dir = storage_base_dir.clone(); ui_state.storage_dir_custom = ui_settings.storage_dir.is_some(); + // Built-in Nextcloud sync of the storage folder (Issue #49, stage 2) + let mut cloud = crate::cloud_sync::CloudSync::new(storage_base_dir.clone()); if let Some(warning) = startup_warning { refresh_notebooks(&storage_base_dir, &mut ui_state); ui_state.set_export_message(warning); @@ -1732,6 +1734,21 @@ pub fn run_with( ); } + // Built-in sync: changes from the server to the open notebook wait while it + // has unsaved changes; a pass soon after saving + let unsaved = new_status != AutosaveStatus::Saved + || is_drawing + || is_stylus_drawing + || ui_state.text_edit.is_some(); + if cloud.update(&manifest.id, unsaved) { + disk_watch.check_soon(Instant::now()); + refresh_notebooks(&storage_base_dir, &mut ui_state); + window.request_redraw(); + } + if prev_status != AutosaveStatus::Saved && new_status == AutosaveStatus::Saved { + cloud.sync_soon(); + } + // Take over changes from other devices (Issue #49), but only while everything // local is saved and nothing is being drawn or typed if disk_watch.dir != storage_dir { @@ -2461,6 +2478,7 @@ pub fn run_with( long_press.deadline(), ui_state.math.deadline(), Some(disk_watch.deadline()), + cloud.deadline(), // The updater thread does not wake the loop: look for its results now and then matches!( updater.status(), @@ -2578,6 +2596,7 @@ pub fn run_with( WindowEvent::Focused(true) => { // Coming back from another app: look for changes right away disk_watch.check_soon(Instant::now()); + cloud.sync_soon(); window.request_redraw(); } @@ -4180,6 +4199,7 @@ pub fn run_with( .as_ref() .filter(|(id, _, _)| *id == manifest.id) .map(|(_, index, _)| index + 1); + ui_state.cloud = cloud.view(); let actions = ui::render_ui( &egui_ctx, &mut ui_state, @@ -5241,6 +5261,26 @@ pub fn run_with( } window.request_redraw(); } + UiAction::CloudConnect { server, trusted } => { + cloud.connect(&server, trusted); + window.request_redraw(); + } + UiAction::CloudCancel => { + cloud.cancel_login(); + window.request_redraw(); + } + UiAction::CloudSyncNow => { + let _ = autosave.flush(); + cloud.sync_now(); + window.request_redraw(); + } + UiAction::CloudDisconnect => { + cloud.disconnect(); + ui_state.set_export_message( + t!("Nextcloud getrennt – die Notizbücher bleiben auf diesem Gerät").to_string(), + ); + window.request_redraw(); + } UiAction::OpenUrlInBrowser(url) => { let _ = webbrowser::open(&url); } @@ -5343,6 +5383,7 @@ pub fn run_with( ui_state.storage_dir = storage_base_dir.clone(); ui_state.storage_dir_custom = ui_settings.storage_dir.is_some(); + cloud.set_local_dir(storage_base_dir.clone()); refresh_notebooks(&storage_base_dir, &mut ui_state); // Same notebook if it exists there, otherwise the first one let open_id = ui_state diff --git a/src/cloud_sync.rs b/src/cloud_sync.rs new file mode 100644 index 0000000..bcb7ed8 --- /dev/null +++ b/src/cloud_sync.rs @@ -0,0 +1,1945 @@ +//! Built-in sync of the notebooks with a Nextcloud server (Issue #49, stage 2). +//! +//! The local folder stays the working copy: the app reads and writes it as before and works +//! offline. A background thread compares it with the folder `ink-notes` on the server every +//! 30 seconds and shortly after saving, using what both sides looked like after the last sync +//! (`nextcloud-sync.json`): +//! - changed on one side only: copied to the other side +//! - changed on both sides: manifests and `library.json` are merged; for a page the server's +//! version is kept as a conflict copy next to it, which becomes a page of its own once the +//! notebook is open (see `disk_watch.rs`), so nothing is lost +//! - deleted on one side and unchanged on the other: deleted there too, locally into `.trash`, +//! on the server into its trash bin; a change wins over a deletion +//! +//! Only the notebook files themselves travel (manifests, pages, images, open markers); lock +//! files, backups and the trash stay local. While the open notebook has unsaved changes, +//! changes from the server to it wait for the next pass, so saving can't overwrite them. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::nextcloud::{self, Account, Entry, Error, Expect, Remote}; +use crate::storage; +use crate::t; + +/// Time between two passes. +pub const INTERVAL: Duration = Duration::from_secs(30); +/// Wait after saving before syncing, so a burst of saves goes up in one pass. +const AFTER_SAVE: Duration = Duration::from_secs(2); +/// A file whose size and time are as after the last pass counts as unchanged, unless its time +/// is this recent: file times are only as fine as the kernel clock tick, so a file rewritten +/// right after we looked could keep its time. +const SETTLED: Duration = Duration::from_secs(2); +/// How long the app waits for the sign-in in the browser. +const LOGIN_TIMEOUT: Duration = Duration::from_secs(20 * 60); + +// --------------------------------------------------------------------------------------------- +// Which files travel + +fn is_notebook_name(name: &str) -> bool { + !name.is_empty() + && !name.starts_with('.') + && !name.contains(['\\', ':']) + && !name.chars().any(char::is_control) +} + +/// Files that are synced, as `/`-separated paths below the storage folder. +pub fn is_synced_file(path: &str) -> bool { + let parts: Vec<&str> = path.split('/').collect(); + match parts.as_slice() { + ["library.json"] => true, + [notebook, name] => { + is_notebook_name(notebook) + && (*name == "manifest.json" + || *name == storage::OPEN_MARKER_FILE + || crate::disk_watch::page_file_id(name).is_some()) + } + [notebook, "assets", name] => { + is_notebook_name(notebook) && crate::assets::is_valid_asset_name(name) + } + _ => false, + } +} + +fn is_synced_dir(path: &str) -> bool { + let parts: Vec<&str> = path.split('/').collect(); + match parts.as_slice() { + [notebook] | [notebook, "assets"] => is_notebook_name(notebook), + _ => false, + } +} + +/// Files merged field by field when both sides changed them. +fn is_merged_file(path: &str) -> bool { + path == "library.json" || path.ends_with("/manifest.json") +} + +fn local_path(dir: &Path, path: &str) -> PathBuf { + path.split('/') + .fold(dir.to_path_buf(), |p, part| p.join(part)) +} + +/// Notebook folder a path belongs to. +fn notebook_of(path: &str) -> Option<&str> { + path.split_once('/').map(|(notebook, _)| notebook) +} + +fn hash_bytes(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +// --------------------------------------------------------------------------------------------- +// What both sides looked like after the last pass + +/// A file as it was on both sides after the last pass. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileState { + pub etag: String, + pub hash: String, + pub size: u64, + /// Local modification time (ns since 1970) + pub mtime: u64, + /// Content of a merged file, as common ancestor for the next merge + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base: Option<String>, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SyncState { + /// Local folder and server folder this state belongs to + pub local: PathBuf, + pub remote: String, + pub files: BTreeMap<String, FileState>, + /// ETags of server folders whose content in `files` is complete + pub dirs: BTreeMap<String, String>, +} + +impl SyncState { + pub fn file_path() -> Option<PathBuf> { + crate::paths::data_dir().map(|d| d.join("nextcloud-sync.json")) + } + + /// The state for this pair of folders; a fresh one if it belongs to another pair (a fresh + /// state never deletes anything). + pub fn load(path: &Path, local: &Path, remote: &str) -> Self { + fs::read(path) + .ok() + .and_then(|b| serde_json::from_slice::<SyncState>(&b).ok()) + .filter(|s| s.local == local && s.remote == remote) + .unwrap_or_else(|| SyncState { + local: local.to_path_buf(), + remote: remote.to_string(), + ..Default::default() + }) + } + + pub fn save(&self, path: &Path) -> Result<(), String> { + storage::atomic_write_json(path, self).map_err(|e| e.to_string()) + } + + /// Forgets everything below a notebook folder (its files then count as new). + fn forget(&mut self, notebook: &str) { + let prefix = format!("{notebook}/"); + self.files.retain(|p, _| !p.starts_with(&prefix)); + self.dirs + .retain(|p, _| p != notebook && !p.starts_with(&prefix)); + } +} + +// --------------------------------------------------------------------------------------------- +// Looking at both sides + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LocalFile { + size: u64, + mtime: u64, + hash: String, + /// Not changed for a while, see [`SETTLED`] + settled: bool, +} + +fn mtime_of(meta: &fs::Metadata) -> u64 { + meta.modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map_or(0, |d| d.as_nanos() as u64) +} + +fn scan_local(dir: &Path, state: &SyncState) -> BTreeMap<String, LocalFile> { + fn walk(dir: &Path, prefix: &str, state: &SyncState, out: &mut BTreeMap<String, LocalFile>) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let Ok(name) = entry.file_name().into_string() else { + continue; + }; + let path = if prefix.is_empty() { + name + } else { + format!("{prefix}/{name}") + }; + let Ok(kind) = entry.file_type() else { + continue; + }; + if kind.is_dir() && is_synced_dir(&path) { + walk(&entry.path(), &path, state, out); + } else if kind.is_file() && is_synced_file(&path) { + let Ok(meta) = entry.metadata() else { + continue; + }; + let (size, mtime) = (meta.len(), mtime_of(&meta)); + let settled = meta + .modified() + .ok() + .and_then(|t| t.elapsed().ok()) + .is_some_and(|age| age >= SETTLED); + let known = state + .files + .get(&path) + .filter(|s| settled && s.size == size && s.mtime == mtime); + let hash = match known { + Some(s) => s.hash.clone(), + None => match fs::read(entry.path()) { + Ok(bytes) => hash_bytes(&bytes), + Err(_) => continue, + }, + }; + out.insert( + path, + LocalFile { + size, + mtime, + hash, + settled, + }, + ); + } + } + } + let mut out = BTreeMap::new(); + walk(dir, "", state, &mut out); + out +} + +#[derive(Debug, Default)] +struct RemoteTree { + files: BTreeMap<String, String>, + dirs: BTreeMap<String, String>, + /// The folder didn't exist and was just created + created: bool, +} + +/// Lists the server folder; subfolders whose ETag didn't change since the last pass are taken +/// from the state instead of being listed again. +fn scan_remote(remote: &mut dyn Remote, state: &SyncState) -> Result<RemoteTree, Error> { + let mut tree = RemoteTree::default(); + let Some(entries) = remote.list("")? else { + remote.mkdir("")?; + tree.created = true; + return Ok(tree); + }; + let mut pending: Vec<Entry> = entries; + while let Some(entry) = pending.pop() { + if !entry.is_dir { + if is_synced_file(&entry.path) { + tree.files.insert(entry.path, entry.etag); + } + continue; + } + if !is_synced_dir(&entry.path) { + continue; + } + let prefix = format!("{}/", entry.path); + if state.dirs.get(&entry.path) == Some(&entry.etag) { + for (path, file) in state.files.range(prefix.clone()..) { + if !path.starts_with(&prefix) { + break; + } + tree.files.insert(path.clone(), file.etag.clone()); + } + for (path, etag) in state.dirs.range(prefix.clone()..) { + if !path.starts_with(&prefix) { + break; + } + tree.dirs.insert(path.clone(), etag.clone()); + } + } else if let Some(children) = remote.list(&entry.path)? { + pending.extend(children); + } else { + continue; + } + tree.dirs.insert(entry.path, entry.etag); + } + Ok(tree) +} + +// --------------------------------------------------------------------------------------------- +// Merging manifests and library.json + +/// Three-way merge of two JSON values with their common ancestor: what only one side changed +/// is taken over; lists of pages (objects with `id`) and of plain values are merged by +/// element; numbers changed on both sides take the larger value (page and stroke counters), +/// anything else changed on both sides keeps ours. +pub fn merge_json(base: Option<&Value>, ours: &Value, theirs: &Value) -> Value { + if ours == theirs { + return ours.clone(); + } + if let Some(base) = base { + if ours == base { + return theirs.clone(); + } + if theirs == base { + return ours.clone(); + } + } + match (ours, theirs) { + (Value::Object(o), Value::Object(t)) => { + let b = base.and_then(Value::as_object); + let keys: BTreeSet<&String> = o.keys().chain(t.keys()).collect(); + let mut out = serde_json::Map::new(); + for key in keys { + let bv = b.and_then(|b| b.get(key)); + let merged = match (o.get(key), t.get(key)) { + (Some(ov), Some(tv)) => Some(merge_json(bv, ov, tv)), + // Removed on one side: gone unless the other side changed it + (Some(v), None) | (None, Some(v)) => (bv != Some(v)).then(|| v.clone()), + (None, None) => None, + }; + if let Some(v) = merged { + out.insert(key.clone(), v); + } + } + Value::Object(out) + } + (Value::Array(o), Value::Array(t)) => { + let b = base.and_then(Value::as_array); + let by_id = |v: &Value| v.get("id").cloned(); + let all = o.iter().chain(t.iter()).chain(b.into_iter().flatten()); + let all: Vec<&Value> = all.collect(); + if all.iter().all(|v| by_id(v).is_some()) { + merge_list(b, o, t, &|v: &Value| by_id(v).unwrap_or(Value::Null)) + } else if all.iter().all(|v| !v.is_object() && !v.is_array()) { + merge_list(b, o, t, &|v: &Value| v.clone()) + } else { + ours.clone() + } + } + (Value::Number(a), Value::Number(b)) => match (a.as_f64(), b.as_f64()) { + (Some(x), Some(y)) if y > x => theirs.clone(), + _ => ours.clone(), + }, + _ => ours.clone(), + } +} + +/// Merges two versions of a list whose elements have a key; keeps our order and puts +/// elements only they added after their predecessor in their list. +fn merge_list( + base: Option<&Vec<Value>>, + ours: &[Value], + theirs: &[Value], + key: &dyn Fn(&Value) -> Value, +) -> Value { + let find = |list: &[Value], k: &Value| list.iter().find(|v| key(v) == *k).cloned(); + let base_list: &[Value] = base.map_or(&[], |b| b.as_slice()); + let mut out: Vec<Value> = Vec::new(); + for o in ours { + let k = key(o); + let b = find(base_list, &k); + match find(theirs, &k) { + Some(t) => out.push(merge_json(b.as_ref(), o, &t)), + // They removed it: gone unless we changed it + None if b.as_ref() == Some(o) => {} + None => out.push(o.clone()), + } + } + for (i, t) in theirs.iter().enumerate() { + let k = key(t); + if out.iter().any(|v| key(v) == k) { + continue; + } + let in_ours = ours.iter().any(|v| key(v) == k); + let b = find(base_list, &k); + // We removed it: gone unless they changed it + if in_ours || b.as_ref() == Some(t) { + continue; + } + let after = theirs[..i] + .iter() + .rev() + .find_map(|p| out.iter().position(|v| key(v) == key(p))); + out.insert(after.map_or(0, |pos| pos + 1), t.clone()); + } + Value::Array(out) +} + +/// Merges two versions of a manifest or `library.json`; `None` if one can't be read. +fn merge_file(path: &str, base: Option<&str>, ours: &[u8], theirs: &[u8]) -> Option<Vec<u8>> { + let ours: Value = serde_json::from_slice(ours).ok()?; + let theirs: Value = serde_json::from_slice(theirs).ok()?; + let base: Option<Value> = base.and_then(|b| serde_json::from_str(b).ok()); + let merged = merge_json(base.as_ref(), &ours, &theirs); + // Must still be a valid file + let valid = if path == "library.json" { + serde_json::from_value::<crate::library::Library>(merged.clone()).is_ok() + } else { + serde_json::from_value::<storage::NotebookManifest>(merged.clone()).is_ok() + }; + valid.then(|| serde_json::to_vec(&merged).ok()).flatten() +} + +// --------------------------------------------------------------------------------------------- +// One pass + +/// What the app tells a pass. +#[derive(Debug, Clone, Default)] +pub struct Context { + /// Notebook open in the app + pub open_notebook: Option<String>, + /// The open notebook has unsaved changes: changes from the server to it wait + pub busy: bool, +} + +/// What a pass did. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Report { + pub downloaded: Vec<String>, + pub uploaded: usize, + pub deleted_local: usize, + pub deleted_remote: usize, + pub conflicts: usize, + pub merged: usize, + /// Changes left for the next pass + pub deferred: usize, +} + +impl Report { + /// Something changed in the local folder. + pub fn changed_local(&self) -> bool { + !self.downloaded.is_empty() || self.deleted_local > 0 || self.conflicts > 0 + } +} + +struct Pass<'a> { + remote: &'a mut dyn Remote, + dir: &'a Path, + state: &'a mut SyncState, + ctx: &'a Context, + report: Report, + /// Server folders known to exist + remote_dirs: BTreeSet<String>, + /// ETags of server folders after this pass, `None` once something in them was left over + dir_etags: BTreeMap<String, String>, + trash: Option<PathBuf>, +} + +/// Moves a file or folder into `.trash` instead of deleting it. +fn to_trash(dir: &Path, from: &Path, name: &str) -> std::io::Result<()> { + let trash = dir.join(".trash"); + storage::create_secure_dir_all(&trash)?; + fs::rename(from, trash.join(name)) +} + +impl Pass<'_> { + fn leave_for_later(&mut self, path: &str) { + self.report.deferred += 1; + let mut dir = path; + while let Some((parent, _)) = dir.rsplit_once('/') { + self.dir_etags.remove(parent); + dir = parent; + } + } + + /// Changes from the server to the open notebook wait while it has unsaved changes. + fn must_wait(&self, path: &str) -> bool { + self.ctx.busy + && notebook_of(path).is_some() + && notebook_of(path) == self.ctx.open_notebook.as_deref() + } + + /// State of a file with this content. `meta` was read before the content; without it the + /// file is hashed again next time. + fn file_state( + path: &str, + bytes: &[u8], + etag: String, + meta: Option<&fs::Metadata>, + ) -> FileState { + FileState { + etag, + hash: hash_bytes(bytes), + size: meta.map_or(bytes.len() as u64, |m| m.len()), + mtime: meta.map_or(0, mtime_of), + base: is_merged_file(path).then(|| String::from_utf8_lossy(bytes).into_owned()), + } + } + + /// Writes a file unless it changed locally since we looked (`expected` is its hash then, + /// `None` if it didn't exist). + fn write_local(&self, path: &str, bytes: &[u8], expected: Option<&str>) -> Result<bool, Error> { + let target = local_path(self.dir, path); + let current = fs::read(&target).ok().map(|b| hash_bytes(&b)); + if current.as_deref() != expected { + return Ok(false); + } + storage::atomic_write_file(&target, bytes) + .map(|_| true) + .map_err(|e| Error::Other(e.to_string())) + } + + fn ensure_remote_parents(&mut self, path: &str) -> Result<(), Error> { + let parts: Vec<&str> = path.split('/').collect(); + for i in 1..parts.len() { + let dir = parts[..i].join("/"); + if !self.remote_dirs.contains(&dir) { + self.remote.mkdir(&dir)?; + self.remote_dirs.insert(dir); + } + } + Ok(()) + } + + fn upload(&mut self, path: &str, expect: Expect<'_>) -> Result<(), Error> { + let source = local_path(self.dir, path); + let meta = fs::metadata(&source).ok(); + let Ok(bytes) = fs::read(&source) else { + self.leave_for_later(path); + return Ok(()); + }; + self.ensure_remote_parents(path)?; + match self.remote.put(path, &bytes, expect) { + Ok(etag) => { + let state = Self::file_state(path, &bytes, etag, meta.as_ref()); + self.state.files.insert(path.to_string(), state); + self.report.uploaded += 1; + Ok(()) + } + // Changed or created on the server meanwhile: next pass + Err(Error::PreconditionFailed) => { + self.leave_for_later(path); + Ok(()) + } + Err(err) => Err(err), + } + } + + fn download( + &mut self, + path: &str, + listed_etag: &str, + local: Option<&str>, + ) -> Result<(), Error> { + let (bytes, etag) = match self.remote.get(path) { + Ok(got) => got, + Err(Error::NotFound) => { + self.leave_for_later(path); + return Ok(()); + } + Err(err) => return Err(err), + }; + let etag = if etag.is_empty() { + listed_etag.to_string() + } else { + etag + }; + if self.write_local(path, &bytes, local)? { + let state = Self::file_state(path, &bytes, etag, None); + self.state.files.insert(path.to_string(), state); + self.report.downloaded.push(path.to_string()); + } else { + self.leave_for_later(path); + } + Ok(()) + } + + /// Both sides have the file and at least one changed it (or there is no common state). + fn reconcile(&mut self, path: &str, local: &LocalFile, listed_etag: &str) -> Result<(), Error> { + let (theirs, etag) = match self.remote.get(path) { + Ok(got) => got, + Err(Error::NotFound) => { + self.leave_for_later(path); + return Ok(()); + } + Err(err) => return Err(err), + }; + let etag = if etag.is_empty() { + listed_etag.to_string() + } else { + etag + }; + let Ok(ours) = fs::read(local_path(self.dir, path)) else { + self.leave_for_later(path); + return Ok(()); + }; + if hash_bytes(&ours) != local.hash { + self.leave_for_later(path); + return Ok(()); + } + if hash_bytes(&theirs) == local.hash { + let state = Self::file_state(path, &ours, etag, None); + self.state.files.insert(path.to_string(), state); + return Ok(()); + } + if is_merged_file(path) { + let base = self.state.files.get(path).and_then(|s| s.base.clone()); + if let Some(merged) = merge_file(path, base.as_deref(), &ours, &theirs) { + if !self.write_local(path, &merged, Some(&local.hash))? { + self.leave_for_later(path); + return Ok(()); + } + self.report.merged += 1; + self.report.downloaded.push(path.to_string()); + return self.upload(path, Expect::Etag(&etag)); + } + } + let is_page = + crate::disk_watch::page_file_id(path.rsplit('/').next().unwrap_or(path)).is_some(); + // An empty page (e.g. of the notebook every new installation starts with) gives way + let blank = |bytes: &[u8]| { + serde_json::from_slice::<storage::PageData>(bytes) + .is_ok_and(|p| p.strokes.is_empty() && p.pdf_background.is_none()) + }; + if is_page && blank(&ours) && !blank(&theirs) { + if self.write_local(path, &theirs, Some(&local.hash))? { + let state = Self::file_state(path, &theirs, etag, None); + self.state.files.insert(path.to_string(), state); + self.report.downloaded.push(path.to_string()); + } else { + self.leave_for_later(path); + } + return Ok(()); + } + if is_page && !blank(&theirs) { + // Keep their page next to ours; disk_watch turns it into a page of its own + let stem = path.trim_end_matches(".json"); + if self.has_copy(stem, &theirs) { + // Kept by an earlier pass whose upload didn't go through + return self.upload(path, Expect::Etag(&etag)); + } + let ts = unix_now(); + let mut n = 0; + let copy = loop { + let name = match n { + 0 => format!("{stem} (conflict {ts}).json"), + n => format!("{stem} (conflict {ts}-{n}).json"), + }; + if !local_path(self.dir, &name).exists() { + break name; + } + n += 1; + }; + storage::atomic_write_file(&local_path(self.dir, ©), &theirs) + .map_err(|e| Error::Other(e.to_string()))?; + self.report.conflicts += 1; + } + // Ours goes up (open markers, images with the same name have the same content) + self.upload(path, Expect::Etag(&etag)) + } + + /// A conflict copy of `stem` with this content exists already (maybe already taken over + /// as a page, then it ends in `.imported`). + fn has_copy(&self, stem: &str, content: &[u8]) -> bool { + let (folder, name) = stem.rsplit_once('/').unwrap_or(("", stem)); + let prefix = format!("{name} (conflict "); + let Ok(entries) = fs::read_dir(local_path(self.dir, folder)) else { + return false; + }; + entries.flatten().any(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|n| n.starts_with(&prefix)) + && fs::read(entry.path()).is_ok_and(|b| b == content) + }) + } + + fn delete_local(&mut self, path: &str, local: &LocalFile) -> Result<(), Error> { + let source = local_path(self.dir, path); + let unchanged = fs::read(&source).ok().map(|b| hash_bytes(&b)) == Some(local.hash.clone()); + if !unchanged { + self.leave_for_later(path); + return Ok(()); + } + let trash = self + .trash + .get_or_insert_with(|| PathBuf::from(format!("sync_{}", unix_now()))) + .clone(); + let target = local_path(&self.dir.join(".trash").join(trash), path); + let moved = target + .parent() + .map(storage::create_secure_dir_all) + .unwrap_or(Ok(())) + .and_then(|_| fs::rename(&source, &target)); + match moved { + Ok(()) => { + self.state.files.remove(path); + self.report.deleted_local += 1; + } + Err(_) => self.leave_for_later(path), + } + Ok(()) + } + + fn delete_remote(&mut self, path: &str, etag: &str) -> Result<(), Error> { + match self.remote.delete(path, Some(etag)) { + Ok(()) => { + self.state.files.remove(path); + self.report.deleted_remote += 1; + Ok(()) + } + Err(Error::PreconditionFailed) => { + self.leave_for_later(path); + Ok(()) + } + Err(err) => Err(err), + } + } + + /// A whole notebook vanished on one side. + fn notebook_deletions( + &mut self, + local: &mut BTreeMap<String, LocalFile>, + remote: &mut RemoteTree, + ) -> Result<(), Error> { + let known: BTreeSet<String> = self + .state + .files + .keys() + .filter_map(|p| notebook_of(p).map(str::to_string)) + .collect(); + for notebook in known { + let prefix = format!("{notebook}/"); + let under = |p: &String| p.starts_with(&prefix); + let local_dir = self.dir.join(¬ebook); + let here = local_dir.is_dir(); + let there = remote.dirs.contains_key(¬ebook); + if here && !there { + // Deleted on the server: here too, unless it's open or changed here + let changed = local + .iter() + .filter(|(p, _)| under(p)) + .any(|(p, f)| self.state.files.get(p).map(|s| &s.hash) != Some(&f.hash)); + if self.ctx.open_notebook.as_deref() != Some(notebook.as_str()) && !changed { + let name = format!("{notebook}_{}", unix_now()); + if to_trash(self.dir, &local_dir, &name).is_ok() { + local.retain(|p, _| !under(p)); + self.report.deleted_local += 1; + } + } + // Otherwise it goes up again as new + self.state.forget(¬ebook); + } else if there && !here { + // Deleted here: on the server too, unless changed there + let theirs: BTreeMap<&String, &String> = + remote.files.iter().filter(|(p, _)| under(p)).collect(); + let known: BTreeMap<&String, &String> = self + .state + .files + .iter() + .filter(|(p, _)| under(p)) + .map(|(p, s)| (p, &s.etag)) + .collect(); + let unchanged = theirs == known; + if unchanged { + self.remote.delete(¬ebook, None)?; + remote.files.retain(|p, _| !under(p)); + remote.dirs.retain(|p, _| *p != notebook && !under(p)); + self.remote_dirs.retain(|p| *p != notebook && !under(p)); + self.report.deleted_remote += 1; + } + // Otherwise it comes down again as new + self.state.forget(¬ebook); + } + } + Ok(()) + } +} + +/// Brings the local folder `dir` and the server folder in line. The state is updated as far as +/// the pass got, also when it fails; save it in either case. +pub fn run_pass( + remote: &mut dyn Remote, + dir: &Path, + state: &mut SyncState, + ctx: &Context, +) -> Result<Report, Error> { + // Rather stop than delete everything on one side: the storage folder is gone (e.g. a + // drive not mounted) or the folder on the server was removed or renamed + if !state.files.is_empty() && !dir.is_dir() { + return Err(Error::Other(t!( + "Der Speicherort {dir} fehlt. Zur Sicherheit wird nichts gelöscht.", + dir = dir.display() + ))); + } + storage::create_secure_dir_all(dir).map_err(|e| Error::Other(e.to_string()))?; + let mut remote_tree = scan_remote(remote, state)?; + let mut local = scan_local(dir, state); + if !state.files.is_empty() && remote_tree.created && !local.is_empty() { + return Err(Error::Other( + t!("Der Ordner „ink-notes“ auf dem Server fehlt. Zur Sicherheit wird nichts gelöscht – zum erneuten Hochladen die Verbindung trennen und neu verbinden.") + .to_string(), + )); + } + + let mut pass = Pass { + remote_dirs: remote_tree.dirs.keys().cloned().collect(), + dir_etags: BTreeMap::new(), + remote, + dir, + state, + ctx, + report: Report::default(), + trash: None, + }; + pass.notebook_deletions(&mut local, &mut remote_tree)?; + // From here on, folder ETags only count again where the pass finishes everything + pass.dir_etags = remote_tree.dirs.clone(); + pass.state.dirs.clear(); + + let paths: BTreeSet<String> = local + .keys() + .chain(remote_tree.files.keys()) + .chain(pass.state.files.keys()) + .cloned() + .collect(); + for path in paths { + let known = pass.state.files.get(&path).cloned(); + let here = local.get(&path); + let there = remote_tree.files.get(&path); + let changed_here = here.map(|f| &f.hash) != known.as_ref().map(|s| &s.hash); + let changed_there = there != known.as_ref().map(|s| &s.etag); + match (here, there) { + (None, None) => { + pass.state.files.remove(&path); + } + (Some(f), Some(_)) if !changed_here && !changed_there => { + // Remember size and time, so the file needn't be read next time + if let Some(known) = pass.state.files.get_mut(&path).filter(|_| f.settled) { + known.size = f.size; + known.mtime = f.mtime; + } + } + (Some(_), Some(etag)) if !changed_there => pass.upload(&path, Expect::Etag(etag))?, + (Some(f), Some(etag)) if !changed_here => { + if pass.must_wait(&path) { + pass.leave_for_later(&path); + } else { + pass.download(&path, etag, Some(&f.hash))?; + } + } + (Some(f), Some(etag)) => { + if pass.must_wait(&path) { + pass.leave_for_later(&path); + } else { + pass.reconcile(&path, f, etag)?; + } + } + (Some(f), None) => match known { + // Deleted on the server, unchanged here + Some(_) if !changed_here => { + if pass.must_wait(&path) { + pass.leave_for_later(&path); + } else { + pass.delete_local(&path, f)?; + } + } + _ => pass.upload(&path, Expect::New)?, + }, + (None, Some(etag)) => match known { + // Deleted here, unchanged on the server + Some(_) if !changed_there => pass.delete_remote(&path, etag)?, + _ => { + if pass.must_wait(&path) { + pass.leave_for_later(&path); + } else { + pass.download(&path, etag, None)?; + } + } + }, + } + } + + let Pass { + report, dir_etags, .. + } = pass; + state.dirs = dir_etags; + Ok(report) +} + +// --------------------------------------------------------------------------------------------- +// Running in the background + +/// Connection and sync status for the interface. +#[derive(Debug, Clone, PartialEq)] +pub enum Status { + /// Not connected + Off, + /// Looking for the server + Connecting, + /// The server's certificate needs the user's confirmation + Untrusted { + server: String, + fingerprint: String, + }, + /// Waiting for the sign-in in the browser + WaitingForLogin { + url: String, + }, + /// Connected; time of the last finished pass + Idle { + last: Option<SystemTime>, + }, + Syncing, + Offline(String), + Failed(String), +} + +impl Status { + pub fn is_connected(&self) -> bool { + matches!( + self, + Status::Idle { .. } | Status::Syncing | Status::Offline(_) | Status::Failed(_) + ) + } +} + +/// What the interface shows. +#[derive(Debug, Clone, PartialEq)] +pub struct View { + pub status: Status, + /// `user@server` while connected + pub account: Option<String>, + /// Connected without encryption (http) + pub insecure: bool, +} + +impl Default for View { + fn default() -> Self { + Self { + status: Status::Off, + account: None, + insecure: false, + } + } +} + +#[derive(Debug)] +struct Shared { + status: Status, + local_dir: PathBuf, + ctx: Context, + next_pass: Instant, + /// Set when a pass changed local files + changed: bool, + /// Finished sign-in, to be stored by the app + signed_in: Option<Account>, + stop: bool, +} + +type SharedState = Arc<(Mutex<Shared>, Condvar)>; + +/// Sync in the background (owned by the app's main loop). +pub struct CloudSync { + shared: SharedState, + account: Option<Account>, + worker: Option<std::thread::JoinHandle<()>>, + login_cancel: Option<Arc<AtomicBool>>, +} + +fn lock(shared: &SharedState) -> std::sync::MutexGuard<'_, Shared> { + shared.0.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn set_status(shared: &SharedState, status: Status) { + lock(shared).status = status; +} + +impl CloudSync { + /// Starts syncing `local_dir` if an account is stored. + pub fn new(local_dir: PathBuf) -> Self { + let mut sync = Self { + shared: Arc::new(( + Mutex::new(Shared { + status: Status::Off, + local_dir, + ctx: Context::default(), + next_pass: Instant::now(), + changed: false, + signed_in: None, + stop: false, + }), + Condvar::new(), + )), + account: None, + worker: None, + login_cancel: None, + }; + if let Some(account) = Account::file_path().and_then(|p| Account::load_from(&p)) { + sync.start(account); + } + sync + } + + pub fn view(&self) -> View { + View { + status: lock(&self.shared).status.clone(), + account: self.account.as_ref().map(Account::label), + insecure: self.account.as_ref().is_some_and(Account::is_insecure), + } + } + + fn start(&mut self, account: Account) { + self.stop_worker(); + { + let mut shared = lock(&self.shared); + shared.stop = false; + shared.status = Status::Idle { last: None }; + shared.next_pass = Instant::now(); + } + let shared = self.shared.clone(); + let worker_account = account.clone(); + self.worker = Some(std::thread::spawn(move || worker(shared, worker_account))); + self.account = Some(account); + } + + fn stop_worker(&mut self) { + if let Some(handle) = self.worker.take() { + lock(&self.shared).stop = true; + self.shared.1.notify_all(); + // A pass in progress finishes in the background + drop(handle); + } + } + + /// Syncs soon, e.g. after saving or when the window gets the focus again. + pub fn sync_soon(&self) { + self.sync_after(AFTER_SAVE); + } + + pub fn sync_now(&self) { + self.sync_after(Duration::ZERO); + } + + fn sync_after(&self, delay: Duration) { + if self.worker.is_none() { + return; + } + let mut shared = lock(&self.shared); + let at = Instant::now() + delay; + if at < shared.next_pass { + shared.next_pass = at; + self.shared.1.notify_all(); + } + } + + /// Another storage folder is used from now on. + pub fn set_local_dir(&self, dir: PathBuf) { + lock(&self.shared).local_dir = dir; + self.sync_now(); + } + + /// Called from the main loop: tells which notebook is open and whether it has unsaved + /// changes; stores a finished sign-in. Returns true when local files changed. + pub fn update(&mut self, open_notebook: &str, busy: bool) -> bool { + let (signed_in, changed) = { + let mut shared = lock(&self.shared); + if shared.ctx.open_notebook.as_deref() != Some(open_notebook) { + shared.ctx.open_notebook = Some(open_notebook.to_string()); + } + shared.ctx.busy = busy; + (shared.signed_in.take(), std::mem::take(&mut shared.changed)) + }; + if let Some(account) = signed_in { + self.login_cancel = None; + if let Some(path) = Account::file_path() { + if let Err(err) = account.save_to(&path) { + set_status( + &self.shared, + Status::Failed(t!("Zugang nicht gespeichert: {err}", err = err)), + ); + return changed; + } + } + self.start(account); + } + changed + } + + /// When the main loop should look again (while signing in or syncing). + pub fn deadline(&self) -> Option<Instant> { + let busy = matches!( + lock(&self.shared).status, + Status::Connecting | Status::WaitingForLogin { .. } | Status::Syncing + ); + busy.then(|| Instant::now() + Duration::from_millis(500)) + } + + /// Looks for the server and signs in through the browser. `trusted` is the fingerprint of + /// a certificate the user confirmed. + pub fn connect(&mut self, input: &str, trusted: Option<String>) { + let server = match nextcloud::normalize_server(input) { + Ok(server) => server, + Err(msg) => { + set_status(&self.shared, Status::Failed(msg)); + return; + } + }; + self.cancel_login(); + let cancel = Arc::new(AtomicBool::new(false)); + self.login_cancel = Some(cancel.clone()); + set_status(&self.shared, Status::Connecting); + let shared = self.shared.clone(); + std::thread::spawn(move || { + let result = sign_in(&shared, &server, trusted, &cancel); + if cancel.load(Ordering::Relaxed) { + return; + } + match result { + Ok(account) => { + let mut shared = lock(&shared); + shared.signed_in = Some(account); + } + Err(Error::Untrusted { fingerprint }) => set_status( + &shared, + Status::Untrusted { + server, + fingerprint, + }, + ), + Err(err) => set_status(&shared, Status::Failed(err.to_string())), + } + }); + } + + pub fn cancel_login(&mut self) { + if let Some(cancel) = self.login_cancel.take() { + cancel.store(true, Ordering::Relaxed); + } + if self.account.is_none() { + set_status(&self.shared, Status::Off); + } + } + + /// Stops syncing, forgets the account and revokes its app password. The notebooks stay. + pub fn disconnect(&mut self) { + self.cancel_login(); + self.stop_worker(); + if let Some(account) = self.account.take() { + std::thread::spawn(move || { + if let Err(err) = nextcloud::revoke(&account) { + eprintln!("[Nextcloud] App-Passwort nicht widerrufen: {err}"); + } + }); + } + for path in [Account::file_path(), SyncState::file_path()] + .into_iter() + .flatten() + { + let _ = fs::remove_file(path); + } + set_status(&self.shared, Status::Off); + } +} + +fn sign_in( + shared: &SharedState, + server: &str, + trusted: Option<String>, + cancel: &AtomicBool, +) -> Result<Account, Error> { + let client = nextcloud::Client::new(trusted.clone()); + nextcloud::check_server(&client, server)?; + let flow = nextcloud::start_login(&client, server)?; + if webbrowser::open(&flow.login_url).is_err() { + eprintln!( + "[Nextcloud] Browser nicht geöffnet, Anmeldung unter {}", + flow.login_url + ); + } + set_status( + shared, + Status::WaitingForLogin { + url: flow.login_url.clone(), + }, + ); + let started = Instant::now(); + loop { + std::thread::sleep(Duration::from_secs(2)); + if cancel.load(Ordering::Relaxed) { + return Err(Error::Other(String::new())); + } + if started.elapsed() > LOGIN_TIMEOUT { + return Err(Error::Other( + t!("Anmeldung abgelaufen – bitte erneut verbinden").to_string(), + )); + } + match nextcloud::poll_login(&client, &flow) { + Ok(Some((login_name, app_password))) => { + let user_id = nextcloud::user_id(&client, server, &login_name, &app_password)?; + return Ok(Account { + server: server.to_string(), + login_name, + user_id, + app_password, + pinned_cert: trusted, + }); + } + Ok(None) => {} + // Briefly offline while switching to the browser (Android): keep waiting + Err(Error::Offline(_)) => {} + Err(err) => return Err(err), + } + } +} + +fn worker(shared: SharedState, account: Account) { + let Some(state_path) = SyncState::file_path() else { + set_status(&shared, Status::Failed(t!("Kein Datenordner").to_string())); + return; + }; + let mut remote = match nextcloud::WebDav::new(&account) { + Ok(remote) => remote, + Err(err) => { + set_status(&shared, Status::Failed(err.to_string())); + return; + } + }; + let remote_id = format!("{}/{}", account.server, account.user_id); + loop { + let (dir, ctx) = { + let mut guard = lock(&shared); + loop { + if guard.stop { + return; + } + let now = Instant::now(); + if now >= guard.next_pass { + break; + } + let wait = guard.next_pass - now; + guard = shared + .1 + .wait_timeout(guard, wait) + .map(|(g, _)| g) + .unwrap_or_else(|e| e.into_inner().0); + } + guard.status = Status::Syncing; + guard.next_pass = Instant::now() + INTERVAL; + (guard.local_dir.clone(), guard.ctx.clone()) + }; + let mut state = SyncState::load(&state_path, &dir, &remote_id); + let result = run_pass(&mut remote, &dir, &mut state, &ctx); + if let Err(err) = state.save(&state_path) { + eprintln!("[Nextcloud] Sync-Stand nicht gespeichert: {err}"); + } + let mut guard = lock(&shared); + if guard.stop { + return; + } + guard.status = match result { + Ok(report) => { + if report.changed_local() { + guard.changed = true; + } + if report != Report::default() { + println!("[Nextcloud] {report:?}"); + } + // Left over: try again soon + if report.deferred > 0 { + guard.next_pass = guard.next_pass.min(Instant::now() + AFTER_SAVE * 2); + } + Status::Idle { + last: Some(SystemTime::now()), + } + } + Err(Error::Offline(msg)) => Status::Offline(msg), + Err(err) => Status::Failed(err.to_string()), + }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + /// Server folder in memory; folder ETags change with anything inside, like Nextcloud's. + #[derive(Default)] + struct FakeRemote { + files: BTreeMap<String, (Vec<u8>, String)>, + dirs: BTreeSet<String>, + exists: bool, + /// Refuse this many uploads as changed meanwhile + refuse_puts: usize, + counter: u64, + calls: HashMap<&'static str, usize>, + } + + impl FakeRemote { + fn new() -> Self { + Self { + exists: true, + ..Default::default() + } + } + + fn next_etag(&mut self) -> String { + self.counter += 1; + format!("\"e{}\"", self.counter) + } + + fn set(&mut self, path: &str, content: &str) { + let etag = self.next_etag(); + self.add_parents(path); + self.files + .insert(path.to_string(), (content.as_bytes().to_vec(), etag)); + } + + fn add_parents(&mut self, path: &str) { + let parts: Vec<&str> = path.split('/').collect(); + for i in 1..parts.len() { + self.dirs.insert(parts[..i].join("/")); + } + } + + fn content(&self, path: &str) -> Option<String> { + self.files + .get(path) + .map(|(b, _)| String::from_utf8_lossy(b).into_owned()) + } + + fn dir_etag(&self, dir: &str) -> String { + let prefix = format!("{dir}/"); + let etags: String = self + .files + .iter() + .filter(|(p, _)| p.starts_with(&prefix)) + .map(|(p, (_, e))| format!("{p}{e},")) + .collect(); + hash_bytes(etags.as_bytes()) + } + } + + impl Remote for FakeRemote { + fn list(&mut self, dir: &str) -> Result<Option<Vec<Entry>>, Error> { + *self.calls.entry("list").or_default() += 1; + if !self.exists || (!dir.is_empty() && !self.dirs.contains(dir)) { + return Ok(None); + } + let prefix = if dir.is_empty() { + String::new() + } else { + format!("{dir}/") + }; + let direct = |p: &String| { + p.strip_prefix(&prefix) + .is_some_and(|rest| !rest.is_empty() && !rest.contains('/')) + }; + let mut entries: Vec<Entry> = self + .files + .iter() + .filter(|(p, _)| direct(p)) + .map(|(p, (_, e))| Entry { + path: p.clone(), + is_dir: false, + etag: e.clone(), + }) + .collect(); + for d in self.dirs.iter().filter(|d| direct(d)) { + entries.push(Entry { + path: d.clone(), + is_dir: true, + etag: self.dir_etag(d), + }); + } + Ok(Some(entries)) + } + + fn get(&mut self, path: &str) -> Result<(Vec<u8>, String), Error> { + *self.calls.entry("get").or_default() += 1; + self.files.get(path).cloned().ok_or(Error::NotFound) + } + + fn put(&mut self, path: &str, data: &[u8], expect: Expect<'_>) -> Result<String, Error> { + *self.calls.entry("put").or_default() += 1; + if self.refuse_puts > 0 { + self.refuse_puts -= 1; + return Err(Error::PreconditionFailed); + } + let current = self.files.get(path).map(|(_, e)| e.as_str()); + let ok = match expect { + Expect::New => current.is_none(), + Expect::Etag(etag) => current == Some(etag), + }; + if !ok { + return Err(Error::PreconditionFailed); + } + let parent = path.rsplit_once('/').map(|(p, _)| p); + if parent.is_some_and(|p| !self.dirs.contains(p)) { + return Err(Error::Other("no parent".into())); + } + let etag = self.next_etag(); + self.files + .insert(path.to_string(), (data.to_vec(), etag.clone())); + Ok(etag) + } + + fn mkdir(&mut self, path: &str) -> Result<(), Error> { + if path.is_empty() { + self.exists = true; + } else { + self.dirs.insert(path.to_string()); + } + Ok(()) + } + + fn delete(&mut self, path: &str, etag: Option<&str>) -> Result<(), Error> { + if let Some(etag) = etag { + if self.files.get(path).map(|(_, e)| e.as_str()) != Some(etag) { + return Err(Error::PreconditionFailed); + } + } + let prefix = format!("{path}/"); + self.files + .retain(|p, _| p != path && !p.starts_with(&prefix)); + self.dirs.retain(|p| p != path && !p.starts_with(&prefix)); + Ok(()) + } + } + + struct Device { + dir: tempfile::TempDir, + state: SyncState, + ctx: Context, + } + + impl Device { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + Self { + state: SyncState { + local: dir.path().to_path_buf(), + remote: "test".into(), + ..Default::default() + }, + dir, + ctx: Context::default(), + } + } + + fn write(&self, path: &str, content: &str) { + let target = local_path(self.dir.path(), path); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + fs::write(target, content).unwrap(); + } + + fn read(&self, path: &str) -> Option<String> { + fs::read_to_string(local_path(self.dir.path(), path)).ok() + } + + fn remove(&self, path: &str) { + fs::remove_file(local_path(self.dir.path(), path)).unwrap(); + } + + fn sync(&mut self, remote: &mut FakeRemote) -> Report { + run_pass(remote, self.dir.path(), &mut self.state, &self.ctx).unwrap() + } + + fn files(&self) -> Vec<String> { + let mut out: Vec<String> = Vec::new(); + fn walk(dir: &Path, prefix: &str, out: &mut Vec<String>) { + for e in fs::read_dir(dir).unwrap().flatten() { + let name = e.file_name().into_string().unwrap(); + let path = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + if e.file_type().unwrap().is_dir() { + walk(&e.path(), &path, out); + } else { + out.push(path); + } + } + } + walk(self.dir.path(), "", &mut out); + out.sort(); + out + } + } + + fn page(text: &str) -> String { + format!("{{\"text\":\"{text}\"}}") + } + + #[test] + fn filters_synced_files() { + assert!(is_synced_file("library.json")); + assert!(is_synced_file("nb_1/manifest.json")); + assert!(is_synced_file("nb_1/page_12.json")); + assert!(is_synced_file("nb_1/.opened_by.json")); + let asset = format!("nb_1/assets/{}.png", "a".repeat(64)); + assert!(is_synced_file(&asset)); + for local_only in [ + "nb_1/.lock", + "nb_1/page_1.json.tmp.12.34", + "nb_1/page_1 (conflict 5).json", + "nb_1/page_1 (conflict 5).json.imported", + "nb_1/manifest.json.bak", + "nb_1/page_1.json.corrupt-5", + ".trash/nb_1/manifest.json", + "nb_1/assets/x.png", + "other.txt", + "a/b/c/d.json", + ] { + assert!(!is_synced_file(local_only), "{local_only}"); + } + } + + #[test] + fn copies_new_files_both_ways() { + let mut server = FakeRemote::new(); + let mut a = Device::new(); + a.write("nb/manifest.json", "{}"); + a.write("nb/page_1.json", &page("a")); + a.write("nb/.lock", ""); + let report = a.sync(&mut server); + assert_eq!(report.uploaded, 2); + assert_eq!(server.content("nb/page_1.json"), Some(page("a"))); + assert!(server.content("nb/.lock").is_none()); + + let mut b = Device::new(); + let report = b.sync(&mut server); + assert_eq!(report.downloaded.len(), 2); + assert_eq!(b.read("nb/page_1.json"), Some(page("a"))); + + // Nothing to do afterwards, and unchanged folders aren't listed again + a.sync(&mut server); + server.calls.clear(); + assert_eq!(a.sync(&mut server), Report::default()); + assert_eq!(b.sync(&mut server), Report::default()); + assert_eq!(server.calls.get("list"), Some(&2)); + assert_eq!(server.calls.get("get"), None); + } + + #[test] + fn creates_the_server_folder() { + let mut server = FakeRemote::default(); + let mut a = Device::new(); + a.write("library.json", "{}"); + a.sync(&mut server); + assert!(server.exists); + assert_eq!(server.content("library.json"), Some("{}".into())); + } + + #[test] + fn passes_changes_on() { + let mut server = FakeRemote::new(); + let (mut a, mut b) = (Device::new(), Device::new()); + a.write("nb/page_1.json", &page("a")); + a.sync(&mut server); + b.sync(&mut server); + + b.write("nb/page_1.json", &page("b")); + assert_eq!(b.sync(&mut server).uploaded, 1); + assert_eq!(a.sync(&mut server).downloaded, vec!["nb/page_1.json"]); + assert_eq!(a.read("nb/page_1.json"), Some(page("b"))); + } + + #[test] + fn keeps_both_versions_of_a_page_changed_on_both_sides() { + let mut server = FakeRemote::new(); + let (mut a, mut b) = (Device::new(), Device::new()); + a.write("nb/page_1.json", &page("a")); + a.sync(&mut server); + b.sync(&mut server); + + a.write("nb/page_1.json", &page("from a")); + b.write("nb/page_1.json", &page("from b")); + a.sync(&mut server); + let report = b.sync(&mut server); + assert_eq!(report.conflicts, 1); + // Ours stays and goes up, theirs is kept next to it (locally only) + assert_eq!(b.read("nb/page_1.json"), Some(page("from b"))); + assert_eq!(server.content("nb/page_1.json"), Some(page("from b"))); + let copy = b + .files() + .into_iter() + .find(|f| f.contains("(conflict")) + .unwrap(); + assert_eq!(b.read(©), Some(page("from a"))); + assert_eq!( + crate::disk_watch::conflict_copy_of(copy.rsplit('/').next().unwrap()), + Some(1) + ); + assert!(!server.files.keys().any(|f| f.contains("conflict"))); + } + + #[test] + fn keeps_a_conflict_copy_only_once() { + let mut server = FakeRemote::new(); + let (mut a, mut b) = (Device::new(), Device::new()); + a.write("nb/page_1.json", &page("a")); + a.sync(&mut server); + b.write("nb/page_1.json", &page("b")); + // The upload after keeping the copy is refused (changed again meanwhile) + server.refuse_puts = 1; + assert_eq!(b.sync(&mut server).deferred, 1); + b.sync(&mut server); + let copies = b + .files() + .into_iter() + .filter(|f| f.contains("(conflict")) + .count(); + assert_eq!(copies, 1); + assert_eq!(server.content("nb/page_1.json"), Some(page("b"))); + } + + #[test] + fn an_empty_page_gives_way() { + let empty = |id: u64| { + serde_json::to_string(&storage::PageData::from(&crate::document::Page::new( + id, + crate::document::PageBackground::Grid5mm, + ))) + .unwrap() + }; + let mut server = FakeRemote::new(); + let (mut a, mut b) = (Device::new(), Device::new()); + a.write("default/page_1.json", &page("written")); + a.write("default/page_2.json", &empty(2)); + a.sync(&mut server); + // A new installation with its own empty default notebook + b.write("default/page_1.json", &empty(1)); + b.write("default/page_2.json", &page("written on b")); + let report = b.sync(&mut server); + assert_eq!(report.conflicts, 0); + assert_eq!(b.read("default/page_1.json"), Some(page("written"))); + assert_eq!( + server.content("default/page_2.json"), + Some(page("written on b")) + ); + } + + #[test] + fn same_content_on_both_sides_is_no_conflict() { + let mut server = FakeRemote::new(); + let (mut a, mut b) = (Device::new(), Device::new()); + a.write("nb/page_1.json", &page("same")); + b.write("nb/page_1.json", &page("same")); + a.sync(&mut server); + let report = b.sync(&mut server); + assert_eq!(report.conflicts, 0); + assert_eq!(report.uploaded, 0); + assert_eq!(b.sync(&mut server), Report::default()); + } + + #[test] + fn deletions_travel_but_changes_win() { + let mut server = FakeRemote::new(); + let (mut a, mut b) = (Device::new(), Device::new()); + a.write("nb/page_1.json", &page("1")); + a.write("nb/page_2.json", &page("2")); + a.sync(&mut server); + b.sync(&mut server); + + // Deleted on a: gone on the server, then on b (into its trash) + a.remove("nb/page_1.json"); + assert_eq!(a.sync(&mut server).deleted_remote, 1); + assert!(server.content("nb/page_1.json").is_none()); + assert_eq!(b.sync(&mut server).deleted_local, 1); + assert!(b.read("nb/page_1.json").is_none()); + assert!(b + .files() + .iter() + .any(|f| f.starts_with(".trash/") && f.ends_with("nb/page_1.json"))); + + // Deleted on a, changed on b: comes back + a.remove("nb/page_2.json"); + b.write("nb/page_2.json", &page("2 changed")); + b.sync(&mut server); + a.sync(&mut server); + assert_eq!(a.read("nb/page_2.json"), Some(page("2 changed"))); + assert_eq!(server.content("nb/page_2.json"), Some(page("2 changed"))); + } + + #[test] + fn deleted_notebooks_go_to_the_trash() { + let mut server = FakeRemote::new(); + let (mut a, mut b) = (Device::new(), Device::new()); + a.write("nb/manifest.json", "{}"); + a.write("nb/page_1.json", &page("1")); + a.sync(&mut server); + b.sync(&mut server); + + // The app moves deleted notebooks into .trash + fs::create_dir_all(a.dir.path().join(".trash")).unwrap(); + fs::rename(a.dir.path().join("nb"), a.dir.path().join(".trash/nb_1")).unwrap(); + assert_eq!(a.sync(&mut server).deleted_remote, 1); + assert!(!server.dirs.contains("nb")); + assert!(server.files.is_empty()); + + b.write("nb/.lock", ""); + assert_eq!(b.sync(&mut server).deleted_local, 1); + assert!(!b.dir.path().join("nb").exists()); + assert!(b + .files() + .iter() + .any(|f| f.starts_with(".trash/nb_") && f.ends_with("page_1.json"))); + } + + #[test] + fn an_open_notebook_deleted_elsewhere_comes_back() { + let mut server = FakeRemote::new(); + let (mut a, mut b) = (Device::new(), Device::new()); + a.write("nb/manifest.json", "{}"); + a.write("nb/page_1.json", &page("1")); + a.write("other.json", ""); + a.sync(&mut server); + b.sync(&mut server); + server.delete("nb", None).unwrap(); + server.set("library.json", "{}"); + + b.ctx.open_notebook = Some("nb".into()); + b.sync(&mut server); + assert_eq!(b.read("nb/page_1.json"), Some(page("1"))); + assert_eq!(server.content("nb/page_1.json"), Some(page("1"))); + } + + #[test] + fn waits_with_changes_to_an_open_notebook_with_unsaved_changes() { + let mut server = FakeRemote::new(); + let (mut a, mut b) = (Device::new(), Device::new()); + a.write("nb/page_1.json", &page("1")); + a.write("nb2/page_1.json", &page("1")); + a.sync(&mut server); + b.sync(&mut server); + + a.write("nb/page_1.json", &page("new")); + a.write("nb2/page_1.json", &page("new")); + a.sync(&mut server); + b.ctx = Context { + open_notebook: Some("nb".into()), + busy: true, + }; + let report = b.sync(&mut server); + assert_eq!(report.deferred, 1); + assert_eq!(report.downloaded, vec!["nb2/page_1.json"]); + assert_eq!(b.read("nb/page_1.json"), Some(page("1"))); + + // The folder ETag must not hide the waiting change + b.ctx.busy = false; + assert_eq!(b.sync(&mut server).downloaded, vec!["nb/page_1.json"]); + assert_eq!(b.read("nb/page_1.json"), Some(page("new"))); + } + + #[test] + fn stops_instead_of_deleting_everything() { + let mut server = FakeRemote::new(); + let mut a = Device::new(); + a.write("nb/page_1.json", &page("1")); + a.sync(&mut server); + + // Server folder removed or renamed: nothing is deleted locally + server.files.clear(); + server.dirs.clear(); + server.exists = false; + assert!(run_pass(&mut server, a.dir.path(), &mut a.state, &a.ctx).is_err()); + assert_eq!(a.read("nb/page_1.json"), Some(page("1"))); + + // A fresh state (after reconnecting) uploads again + a.state.files.clear(); + a.state.dirs.clear(); + a.sync(&mut server); + assert_eq!(server.content("nb/page_1.json"), Some(page("1"))); + + // Storage folder missing (drive not mounted): nothing is deleted on the server + let mut b = Device::new(); + b.sync(&mut server); + let gone = b.dir.path().join("missing"); + let mut state = b.state.clone(); + state + .files + .insert("nb/page_1.json".into(), FileState::default()); + assert!(run_pass(&mut server, &gone, &mut state, &b.ctx).is_err()); + assert!(server.content("nb/page_1.json").is_some()); + } + + #[test] + fn a_first_sync_with_both_sides_filled_deletes_nothing() { + let mut server = FakeRemote::new(); + server.set("nb/page_1.json", &page("server")); + let mut a = Device::new(); + a.write("nb/page_2.json", &page("local")); + a.sync(&mut server); + assert_eq!(a.read("nb/page_1.json"), Some(page("server"))); + assert_eq!(server.content("nb/page_2.json"), Some(page("local"))); + } + + #[test] + fn merges_manifests_changed_on_both_sides() { + let manifest = |pages: &[(u64, &str)], next: u64| { + let mut m = storage::NotebookManifest::new("nb", "Title"); + m.pages = pages + .iter() + .map(|(id, title)| { + let mut meta = storage::PageMeta::new(*id, None); + meta.title = (!title.is_empty()).then(|| title.to_string()); + meta + }) + .collect(); + m.page_ids = pages.iter().map(|(id, _)| *id).collect(); + m.next_page_id = next; + m.created_at = 1; + m.modified_at = 1; + serde_json::to_string(&m).unwrap() + }; + let mut server = FakeRemote::new(); + let (mut a, mut b) = (Device::new(), Device::new()); + a.write("nb/manifest.json", &manifest(&[(1, ""), (2, "")], 3)); + a.sync(&mut server); + b.sync(&mut server); + + // a adds page 3 after page 1 and titles page 2; b removes page 1 and adds page 4 + a.write( + "nb/manifest.json", + &manifest(&[(1, ""), (3, ""), (2, "Two")], 4), + ); + b.write("nb/manifest.json", &manifest(&[(2, ""), (4, "")], 5)); + a.sync(&mut server); + let report = b.sync(&mut server); + assert_eq!(report.merged, 1); + let merged: storage::NotebookManifest = + serde_json::from_str(&b.read("nb/manifest.json").unwrap()).unwrap(); + let pages: Vec<(u64, Option<String>)> = merged + .pages + .iter() + .map(|p| (p.id, p.title.clone())) + .collect(); + assert_eq!(pages, vec![(3, None), (2, Some("Two".into())), (4, None)]); + assert_eq!(merged.next_page_id, 5); + a.sync(&mut server); + assert_eq!(a.read("nb/manifest.json"), b.read("nb/manifest.json")); + } + + #[test] + fn merges_json_lists_and_values() { + let base = serde_json::json!({"folders": ["A", "B"], "notebooks": {"x": "A"}, "n": 1}); + let ours = serde_json::json!({"folders": ["A", "B", "C"], "notebooks": {"x": "B"}, "n": 2}); + let theirs = + serde_json::json!({"folders": ["B", "D"], "notebooks": {"x": "A", "y": "D"}, "n": 3}); + assert_eq!( + merge_json(Some(&base), &ours, &theirs), + serde_json::json!({"folders": ["B", "D", "C"], "notebooks": {"x": "B", "y": "D"}, "n": 3}) + ); + // Without a common ancestor nothing is removed + assert_eq!( + merge_json(None, &serde_json::json!(["A"]), &serde_json::json!(["B"])), + serde_json::json!(["B", "A"]) + ); + } + + /// Against a real server: `INK_NOTES_NEXTCLOUD_TEST=<file>` with address, login name and app + /// password on three lines; works in the folder `ink-notes-test` only. + /// `cargo test --lib real_server -- --ignored` + #[test] + #[ignore] + fn real_server() { + let file = std::env::var("INK_NOTES_NEXTCLOUD_TEST").expect("INK_NOTES_NEXTCLOUD_TEST"); + let lines: Vec<String> = fs::read_to_string(file) + .unwrap() + .lines() + .map(|l| l.trim().to_string()) + .collect(); + let server = nextcloud::normalize_server(&lines[0]).unwrap(); + let client = nextcloud::Client::new(None); + nextcloud::check_server(&client, &server).unwrap(); + let user_id = nextcloud::user_id(&client, &server, &lines[1], &lines[2]).unwrap(); + let account = Account { + server, + login_name: lines[1].clone(), + user_id, + app_password: lines[2].clone(), + pinned_cert: None, + }; + let mut remote = nextcloud::WebDav::with_folder(&account, "ink-notes-test").unwrap(); + remote.delete("", None).unwrap(); + + let sync = |device: &mut Device, remote: &mut nextcloud::WebDav| { + run_pass(remote, device.dir.path(), &mut device.state, &device.ctx).unwrap() + }; + let (mut a, mut b) = (Device::new(), Device::new()); + let asset = format!("Mathe ü 1/assets/{}.png", "ab".repeat(32)); + a.write("library.json", r#"{"folders":[],"notebooks":{}}"#); + a.write("Mathe ü 1/page_1.json", &page("eins")); + a.write("Mathe ü 1/page_2.json", &page("zwei")); + a.write(&asset, "png"); + assert_eq!(sync(&mut a, &mut remote).uploaded, 4); + assert_eq!(sync(&mut b, &mut remote).downloaded.len(), 4); + assert_eq!(b.read(&asset), Some("png".into())); + sync(&mut a, &mut remote); + assert_eq!(sync(&mut a, &mut remote), Report::default()); + assert_eq!(sync(&mut b, &mut remote), Report::default()); + + // Change, conflict, deletion + a.write("Mathe ü 1/page_1.json", &page("eins von a")); + b.write("Mathe ü 1/page_1.json", &page("eins von b")); + a.remove("Mathe ü 1/page_2.json"); + sync(&mut a, &mut remote); + let report = sync(&mut b, &mut remote); + assert_eq!(report.conflicts, 1); + assert_eq!(report.deleted_local, 1); + sync(&mut a, &mut remote); + assert_eq!(a.read("Mathe ü 1/page_1.json"), Some(page("eins von b"))); + assert!(b.read("Mathe ü 1/page_2.json").is_none()); + + // Deleting the notebook + fs::create_dir_all(a.dir.path().join(".trash")).unwrap(); + fs::rename( + a.dir.path().join("Mathe ü 1"), + a.dir.path().join(".trash/m"), + ) + .unwrap(); + assert_eq!(sync(&mut a, &mut remote).deleted_remote, 1); + assert_eq!(sync(&mut b, &mut remote).deleted_local, 1); + assert!(!b.dir.path().join("Mathe ü 1").exists()); + remote.delete("", None).unwrap(); + } + + #[test] + fn state_belongs_to_one_pair_of_folders() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state.json"); + let mut state = SyncState::load(&path, Path::new("/a"), "srv"); + state + .files + .insert("library.json".into(), FileState::default()); + state.save(&path).unwrap(); + assert_eq!(SyncState::load(&path, Path::new("/a"), "srv"), state); + assert!(SyncState::load(&path, Path::new("/b"), "srv") + .files + .is_empty()); + assert!(SyncState::load(&path, Path::new("/a"), "other") + .files + .is_empty()); + } +} diff --git a/src/disk_watch.rs b/src/disk_watch.rs index 1d56834..9ad5a8d 100644 --- a/src/disk_watch.rs +++ b/src/disk_watch.rs @@ -87,7 +87,7 @@ fn scan(dir: &Path) -> HashMap<String, Fingerprint> { } /// Page id of a regular page file (`page_<id>.json`). -fn page_file_id(name: &str) -> Option<u64> { +pub(crate) fn page_file_id(name: &str) -> Option<u64> { name.strip_prefix("page_")? .strip_suffix(".json")? .parse() diff --git a/src/i18n.rs b/src/i18n.rs index 3db237c..21cca01 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -172,6 +172,8 @@ mod tests { ("rnote.rs", include_str!("rnote.rs")), ("saber.rs", include_str!("saber.rs")), ("xopp.rs", include_str!("xopp.rs")), + ("nextcloud.rs", include_str!("nextcloud.rs")), + ("cloud_sync.rs", include_str!("cloud_sync.rs")), ]; /// Alle Texte in `t!("…")` und `tn!("…")` eines Quelltexts, auch über mehrere Zeilen diff --git a/src/i18n/en.rs b/src/i18n/en.rs index f22ce9e..32a65f9 100644 --- a/src/i18n/en.rs +++ b/src/i18n/en.rs @@ -13,6 +13,7 @@ pub const EN: &[(&str, &str)] = &[ ("1 Objekt | [R] Drehen [Entf] Löschen", "1 object | [R] Rotate [Del] Delete"), ("Abbrechen", "Cancel"), ("Abfrage fehlgeschlagen:", "Request failed:"), + ("Abgleich beenden und das App-Passwort widerrufen; die Notizbücher bleiben auf diesem Gerät und auf dem Server", "Stop syncing and revoke the app password; the notebooks stay on this device and on the server"), ("Achsenkreuz", "Axis cross"), ("Achteck (Oktagon)", "Octagon"), ("Aktuelle Version:", "Current version:"), @@ -20,6 +21,9 @@ pub const EN: &[(&str, &str)] = &[ ("Alle", "All"), ("Alles gespeichert", "All saved"), ("Als leere Seite bearbeiten (Schreibschutz aufheben)", "Edit as blank page (remove write protection)"), + ("Anmeldung abgelaufen – bitte erneut verbinden", "Sign-in expired – please connect again"), + ("Anmeldung abgelehnt – bitte neu verbinden", "Sign-in rejected – please connect again"), + ("Anmeldung im Browser; die App bekommt ein eigenes App-Passwort, das sich in Nextcloud jederzeit widerrufen lässt", "Sign in in the browser; the app gets an app password of its own, which can be revoked in Nextcloud at any time"), ("AppImage (Portabel)", "AppImage (portable)"), ("Asset-Name darf nicht leer sein.", "Asset name must not be empty."), ("Auch per Strg+V aus der Zwischenablage oder durch Ziehen einer Bilddatei ins Fenster", "Also with Ctrl+V from the clipboard or by dragging an image file into the window"), @@ -44,6 +48,8 @@ pub const EN: &[(&str, &str)] = &[ ("Bild mit transparentem Hintergrund, nur Inhalt", "Image with transparent background, content only"), ("Bild nicht eingefügt: {err}", "Image not inserted: {err}"), ("Bild, Text oder kopierte Objekte aus der Zwischenablage hier einfügen", "Paste an image, text or copied objects from the clipboard here"), + ("Bitte die Adresse des Nextcloud-Servers eingeben", "Please enter the address of the Nextcloud server"), + ("Bitte im Browser anmelden und den Zugriff erlauben …", "Please sign in in the browser and grant access …"), ("Blanko", "Blank"), ("Blau", "Blue"), ("Code", "Code"), @@ -56,15 +62,34 @@ pub const EN: &[(&str, &str)] = &[ ("Den Ordner gibt es nicht mehr", "The folder no longer exists"), ("Den Ordner „{name}“ gibt es hier schon", "A folder “{name}” already exists here"), ("Den Zielordner gibt es nicht mehr", "The target folder no longer exists"), + ("Der Ordner „ink-notes“ auf dem Server fehlt. Zur Sicherheit wird nichts gelöscht – zum erneuten Hochladen die Verbindung trennen und neu verbinden.", "The folder “ink-notes” is missing on the server. To be safe, nothing is deleted – to upload again, disconnect and connect again."), ("Der Ordnername ist leer", "The folder name is empty"), ("Der Ordnername ist zu lang (höchstens {max} Zeichen)", "The folder name is too long (at most {max} characters)"), + ("Der Server leitet nach {to} weiter – bitte diese Adresse verwenden", "The server redirects to {to} – please use that address"), + ("Der Speicherort {dir} fehlt. Zur Sicherheit wird nichts gelöscht.", "The storage location {dir} is missing. To be safe, nothing is deleted."), ("Der übergeordnete Ordner existiert nicht mehr", "The parent folder no longer exists"), ("DICKEN-PRESETS", "WIDTH PRESETS"), + ("Die Adresse muss mit https:// beginnen", "The address must start with https://"), ("Die beschädigte Originaldatei wurde in Quarantäne gesichert:", "The damaged original file was saved to quarantine:"), ("Die Datei ist kein lesbares PDF", "The file is not a readable PDF"), ("Die einzige Seite eines Notizbuchs kann nicht gelöscht werden", "The only page of a notebook cannot be deleted"), ("Die Flatpak-Version wird über die Software-Verwaltung aktualisiert.", "The Flatpak version is updated through the software manager."), ("Die heruntergeladene Datei existiert nicht mehr.", "The downloaded file no longer exists."), + ("Inzwischen geändert", "Changed meanwhile"), + ("Kein Datenordner", "No data folder"), + ("NEXTCLOUD", "NEXTCLOUD"), + ("Nextcloud getrennt – die Notizbücher bleiben auf diesem Gerät", "Nextcloud disconnected – the notebooks stay on this device"), + ("Nicht gefunden", "Not found"), + ("Server nicht erreichbar: {msg}", "Server not reachable: {msg}"), + ("Server-Fehler {code}", "Server error {code}"), + ("Sofort mit dem Server abgleichen", "Sync with the server right away"), + ("Trennen", "Disconnect"), + ("Unerwartete Antwort des Servers", "Unexpected answer from the server"), + ("Ungültige Adresse: {e}", "Invalid address: {e}"), + ("Unter dieser Adresse antwortet kein Nextcloud-Server", "No Nextcloud server answers at this address"), + ("Vertrauen", "Trust"), + ("Zertifikat nicht vertrauenswürdig (SHA-256 {fingerprint})", "Certificate not trusted (SHA-256 {fingerprint})"), + ("Zugang nicht gespeichert: {err}", "Sign-in not saved: {err}"), ("die oberste Ebene", "the top level"), ("Die Zwischenablage ist leer", "The clipboard is empty"), ("Diese Version wird über die Software-Verwaltung aktualisiert (z. B. GNOME Software oder „flatpak update“).", "This version is updated through the software manager (e.g. GNOME Software or “flatpak update”)."), @@ -124,6 +149,7 @@ pub const EN: &[(&str, &str)] = &[ ("Graphit-Grau", "Graphite gray"), ("Größere Schrift (jetzt {size} pt)", "Larger font (now {size} pt)"), ("Grün", "Green"), + ("gerade eben", "just now"), ("hart", "hard"), ("Hell", "Light"), ("Hellgrün", "Light green"), @@ -366,6 +392,8 @@ pub const EN: &[(&str, &str)] = &[ ("Verzeichnis '{path}' enthält kein manifest.json", "Folder '{path}' contains no manifest.json"), ("Verzeichnis '{path}' existiert nicht", "Folder '{path}' does not exist"), ("Vorherige Seite (Bild auf)", "Previous page (Page Up)"), + ("vor {hours} h", "{hours} h ago"), + ("vor {minutes} min", "{minutes} min ago"), ("weich", "soft"), ("Weich: schon leichter Druck ergibt kräftige Linien.\nHart: erst fester Druck ergibt kräftige Linien.", "Soft: even light pressure gives bold lines.\nHard: only firm pressure gives bold lines."), ("Weitere Funktionen – Update verfügbar", "More – update available"), @@ -385,6 +413,7 @@ pub const EN: &[(&str, &str)] = &[ ("Zugriffsfehler (I/O). Die Datei existiert, kann aber nicht gelesen werden.\nDie Seite bleibt schreibgeschützt, um ein Überschreiben zu verhindern.", "Access error (I/O). The file exists but cannot be read.\nThe page stays read-only to prevent overwriting it."), ("Zurück zu Seiten geht nur, solange alles im Startausschnitt liegt", "Back to pages only works while everything lies in the starting area"), ("Zurücksetzen (Strg+0)", "Reset (Ctrl+0)"), + ("z. B. cloud.example.org", "e.g. cloud.example.org"), ("{count} Objekt(e) eingefügt", "{count} object(s) pasted"), ("{count} Objekt(e) gelöscht", "{count} object(s) deleted"), ("{count} Objekt(e) kopiert – auch als Bild für andere Programme", "{count} object(s) copied – also as an image for other apps"), @@ -405,21 +434,28 @@ pub const EN: &[(&str, &str)] = &[ ("„{title}“ verschieben nach:", "Move “{title}” to:"), ("↩ Rückgängig", "↩ Undo"), ("⏳ Update lädt … {percent} %", "⏳ Downloading update … {percent} %"), + ("⏳ Verbinde …", "⏳ Connecting …"), ("⏷ {mm} mm Platz", "⏷ {mm} mm space"), ("♾ Endlos", "♾ Endless"), ("⚙ Erweiterte Server-Einstellungen", "⚙ Advanced server settings"), ("⚠ Beschädigt", "⚠ Damaged"), + ("⚠ Das Zertifikat von {server} ist von keiner bekannten Stelle signiert. Nur vertrauen, wenn dieser Fingerabdruck mit dem des Servers übereinstimmt:", "⚠ The certificate of {server} isn't signed by a known authority. Only trust it if this fingerprint matches the server's:"), ("⚠ Datenintegritäts-Warnung", "⚠ Data integrity warning"), ("⚠ In anderer Instanz geöffnet (Schreibschutz)", "⚠ Opened in another instance (read-only)"), + ("⚠ Ohne https ist das App-Passwort im Netz lesbar – nur im eigenen Heimnetz verwenden", "⚠ Without https the app password can be read on the network – only use this in your own home network"), ("⚠ Ungespeicherte Änderungen", "⚠ Unsaved changes"), + ("⚠ Unverschlüsselt (http)", "⚠ Unencrypted (http)"), ("⚠\u{fe0f} {name} (Beschädigt - kein Manifest)", "⚠\u{fe0f} {name} (damaged - no manifest)"), ("⚠\u{fe0f} {name} (Beschädigt)", "⚠\u{fe0f} {name} (damaged)"), + ("⚪ Offline – neuer Versuch in Kürze ({msg})", "⚪ Offline – trying again shortly ({msg})"), ("⚫ Aktiv", "⚫ Active"), + ("✔ Synchronisiert {when}", "✔ Synced {when}"), ("✔ Update bereit", "✔ Update ready"), ("➕ Neue Seite einfügen", "➕ Insert new page"), ("➕ Notizbuch", "➕ Notebook"), ("⟲ Gelöschte Seite {number} wiederherstellen", "⟲ Restore deleted page {number}"), ("⬇ Downloads", "⬇ Downloads"), + ("🌐 Browser öffnen", "🌐 Open browser"), ("🌐 Release auf Gitea öffnen", "🌐 Open release on Gitea"), ("🏠 Persönlicher Ordner", "🏠 Home folder"), ("💾 Einstellungen speichern", "💾 Save settings"), @@ -435,10 +471,13 @@ pub const EN: &[(&str, &str)] = &[ ("📚 Oberste Ebene", "📚 Top level"), ("📜 Durchgehend", "📜 Continuous"), ("📦 Paket mit Software-Center öffnen / installieren", "📦 Open / install package with the software center"), + ("🔄 Jetzt", "🔄 Now"), ("🔄 Suche nach Updates …", "🔄 Checking for updates …"), + ("🔄 Synchronisiere …", "🔄 Syncing …"), ("🔄 Updates & Versionen", "🔄 Updates & versions"), ("🔄 Backup", "🔄 Backup"), ("🔄 Nach Updates suchen", "🔄 Check for updates"), + ("🔗 Verbinden …", "🔗 Connect …"), ("🖥 Schreibtisch", "🖥 Desktop"), ("🖼 Bild aus Datei …", "🖼 Image from file …"), ("🖼 Bild einfügen … (Strg+I)", "🖼 Insert image … (Ctrl+I)"), diff --git a/src/lib.rs b/src/lib.rs index 86cca22..79aee79 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod assets; pub mod autosave; pub mod background; pub mod canvas; +pub mod cloud_sync; pub mod disk_watch; pub mod document; pub mod export; @@ -21,6 +22,7 @@ pub mod library; pub mod math; pub mod math_assist; pub mod math_ink; +pub mod nextcloud; pub mod osk; pub mod page_export; pub mod paths; diff --git a/src/nextcloud.rs b/src/nextcloud.rs new file mode 100644 index 0000000..d9d6257 --- /dev/null +++ b/src/nextcloud.rs @@ -0,0 +1,853 @@ +//! Talking to a Nextcloud server (Issue #49, stage 2): finding the server, signing in through +//! the browser (Login Flow v2, the app gets its own app password), and WebDAV access to the +//! folder `ink-notes` in the user's files. The sync itself is in `cloud_sync.rs`. +//! +//! Private servers often use a self-signed certificate. Such a certificate is trusted once the +//! user has confirmed its SHA-256 fingerprint; it is then pinned for this server. + +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use base64::Engine; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::{DigitallySignedStruct, SignatureScheme}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::t; + +/// Name of the synced folder in the user's files. +pub const REMOTE_FOLDER: &str = "ink-notes"; + +/// Largest file the sync downloads (bigger ones are surely not ours). +const MAX_DOWNLOAD: u64 = 512 * 1024 * 1024; + +/// A connected Nextcloud account, stored in `nextcloud.json` next to the settings. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Account { + /// Server address including a subfolder, without trailing slash + pub server: String, + /// Login name for the app password + pub login_name: String, + /// User id in WebDAV paths (differs from the login name e.g. with e-mail logins) + pub user_id: String, + pub app_password: String, + /// SHA-256 fingerprint of a certificate the user trusts although it isn't signed by a + /// known authority + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pinned_cert: Option<String>, +} + +impl Account { + pub fn file_path() -> Option<PathBuf> { + crate::paths::config_dir().map(|d| d.join("nextcloud.json")) + } + + pub fn load_from(path: &Path) -> Option<Self> { + serde_json::from_slice(&std::fs::read(path).ok()?).ok() + } + + /// Stores the account; the file is only readable by the user. + pub fn save_to(&self, path: &Path) -> Result<(), String> { + if let Some(dir) = path.parent() { + crate::storage::create_secure_dir_all(dir).map_err(|e| e.to_string())?; + } + crate::storage::atomic_write_json(path, self).map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + } + Ok(()) + } + + /// `user@server` for the interface. + pub fn label(&self) -> String { + let host = url::Url::parse(&self.server) + .ok() + .and_then(|u| { + let port = u.port().map(|p| format!(":{p}")).unwrap_or_default(); + u.host_str() + .map(|h| format!("{h}{port}{}", u.path().trim_end_matches('/'))) + }) + .unwrap_or_else(|| self.server.clone()); + format!("{}@{host}", self.login_name) + } + + pub fn is_insecure(&self) -> bool { + self.server.starts_with("http://") + } +} + +/// Why talking to the server failed. +#[derive(Debug, Clone, PartialEq)] +pub enum Error { + /// The certificate isn't signed by a known authority (or doesn't match the pinned one) + Untrusted { + fingerprint: String, + }, + /// Server not reachable (offline, wrong address) + Offline(String), + /// App password revoked or wrong + Unauthorized, + NotFound, + /// The file changed on the server since we last saw it + PreconditionFailed, + Other(String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Untrusted { fingerprint } => write!( + f, + "{}", + t!( + "Zertifikat nicht vertrauenswürdig (SHA-256 {fingerprint})", + fingerprint = fingerprint + ) + ), + Error::Offline(msg) => write!(f, "{}", t!("Server nicht erreichbar: {msg}", msg = msg)), + Error::Unauthorized => write!(f, "{}", t!("Anmeldung abgelehnt – bitte neu verbinden")), + Error::NotFound => write!(f, "{}", t!("Nicht gefunden")), + Error::PreconditionFailed => write!(f, "{}", t!("Inzwischen geändert")), + Error::Other(msg) => write!(f, "{msg}"), + } + } +} + +/// Server address from what the user typed: `https://` is added when missing, a pasted link +/// into the web interface is cut back to the server (a subfolder such as `/nextcloud` stays). +pub fn normalize_server(input: &str) -> Result<String, String> { + let input = input.trim(); + if input.is_empty() { + return Err(t!("Bitte die Adresse des Nextcloud-Servers eingeben").to_string()); + } + let with_scheme = if input.contains("://") { + input.to_string() + } else { + format!("https://{input}") + }; + let mut url = url::Url::parse(&with_scheme).map_err(|e| t!("Ungültige Adresse: {e}", e = e))?; + if !matches!(url.scheme(), "https" | "http") || url.host_str().is_none() { + return Err(t!("Die Adresse muss mit https:// beginnen").to_string()); + } + url.set_query(None); + url.set_fragment(None); + let _ = url.set_username(""); + let _ = url.set_password(None); + let mut path = url.path().to_string(); + for marker in [ + "/index.php", + "/apps/", + "/login", + "/remote.php", + "/status.php", + "/s/", + ] { + if let Some(pos) = path.find(marker) { + path.truncate(pos); + } + } + url.set_path(path.trim_end_matches('/')); + Ok(url.as_str().trim_end_matches('/').to_string()) +} + +/// SHA-256 fingerprint as colon-separated hex pairs, as browsers show it. +pub fn fingerprint(cert: &[u8]) -> String { + Sha256::digest(cert) + .iter() + .map(|b| format!("{b:02X}")) + .collect::<Vec<_>>() + .join(":") +} + +/// Accepts what the usual authorities sign and, besides, the one pinned certificate. Remembers +/// the fingerprint of a rejected certificate so the user can be asked about it. +#[derive(Debug)] +struct Verifier { + webpki: Arc<rustls::client::WebPkiServerVerifier>, + provider: Arc<rustls::crypto::CryptoProvider>, + pinned: Option<String>, + rejected: Arc<Mutex<Option<String>>>, +} + +impl ServerCertVerifier for Verifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + server_name: &ServerName<'_>, + ocsp_response: &[u8], + now: UnixTime, + ) -> Result<ServerCertVerified, rustls::Error> { + match self.webpki.verify_server_cert( + end_entity, + intermediates, + server_name, + ocsp_response, + now, + ) { + Ok(ok) => Ok(ok), + Err(err) => { + let print = fingerprint(end_entity); + if self.pinned.as_deref() == Some(print.as_str()) { + return Ok(ServerCertVerified::assertion()); + } + if let Ok(mut slot) = self.rejected.lock() { + *slot = Some(print); + } + Err(err) + } + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result<HandshakeSignatureValid, rustls::Error> { + rustls::crypto::verify_tls12_signature( + message, + cert, + dss, + &self.provider.signature_verification_algorithms, + ) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result<HandshakeSignatureValid, rustls::Error> { + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &self.provider.signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec<SignatureScheme> { + self.provider + .signature_verification_algorithms + .supported_schemes() + } +} + +/// HTTP client for one server, with the pinned certificate if any. +pub struct Client { + agent: ureq::Agent, + rejected: Arc<Mutex<Option<String>>>, + user_agent: String, +} + +impl Client { + pub fn new(pinned: Option<String>) -> Self { + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let rejected = Arc::new(Mutex::new(None)); + let mut agent = ureq::AgentBuilder::new() + .timeout_connect(Duration::from_secs(10)) + .timeout_read(Duration::from_secs(120)) + .timeout_write(Duration::from_secs(120)) + .redirects(0); + let webpki = rustls::client::WebPkiServerVerifier::builder_with_provider( + Arc::new(roots), + provider.clone(), + ) + .build(); + let config = webpki.map_err(|e| e.to_string()).and_then(|webpki| { + let verifier = Verifier { + webpki, + provider: provider.clone(), + pinned, + rejected: rejected.clone(), + }; + rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map(|b| { + b.dangerous() + .with_custom_certificate_verifier(Arc::new(verifier)) + .with_no_client_auth() + }) + .map_err(|e| e.to_string()) + }); + match config { + Ok(config) => agent = agent.tls_config(Arc::new(config)), + Err(err) => eprintln!("[Nextcloud] TLS-Einrichtung fehlgeschlagen: {err}"), + } + Self { + agent: agent.build(), + rejected, + user_agent: format!("ink-notes ({})", crate::storage::device_name()), + } + } + + fn request(&self, method: &str, url: &str) -> ureq::Request { + if let Ok(mut slot) = self.rejected.lock() { + *slot = None; + } + self.agent + .request(method, url) + .set("User-Agent", &self.user_agent) + } + + /// Turns a failed request into an [`Error`]. + fn error(&self, err: ureq::Error) -> Error { + match err { + ureq::Error::Status(401, _) => Error::Unauthorized, + ureq::Error::Status(404, _) => Error::NotFound, + ureq::Error::Status(412, _) => Error::PreconditionFailed, + ureq::Error::Status(code, resp) => { + let location = resp.header("Location").map(str::to_string); + match location { + Some(to) if (300..400).contains(&code) => Error::Other(t!( + "Der Server leitet nach {to} weiter – bitte diese Adresse verwenden", + to = to + )), + _ => Error::Other(t!("Server-Fehler {code}", code = code)), + } + } + ureq::Error::Transport(transport) => { + if let Some(print) = self.rejected.lock().ok().and_then(|s| s.clone()) { + return Error::Untrusted { fingerprint: print }; + } + Error::Offline(transport.to_string()) + } + } + } +} + +/// Checks that a Nextcloud server answers at `server`. +pub fn check_server(client: &Client, server: &str) -> Result<(), Error> { + let resp = client + .request("GET", &format!("{server}/status.php")) + .call() + .map_err(|e| client.error(e))?; + let status: serde_json::Value = resp.into_json().map_err(|_| not_nextcloud())?; + if status.get("installed").and_then(|v| v.as_bool()) == Some(true) { + Ok(()) + } else { + Err(not_nextcloud()) + } +} + +fn not_nextcloud() -> Error { + Error::Other(t!("Unter dieser Adresse antwortet kein Nextcloud-Server").to_string()) +} + +/// A started sign-in: the user approves it in the browser, the app polls for the result. +#[derive(Debug, Clone, PartialEq)] +pub struct LoginFlow { + pub login_url: String, + poll_url: String, + token: String, +} + +/// Puts a link the server built with its own idea of its address (wrong behind a proxy) onto +/// the address the user gave. +fn onto_server(link: &str, server: &str, marker: &str) -> String { + let same_origin = match (url::Url::parse(link), url::Url::parse(server)) { + (Ok(a), Ok(b)) => a.origin() == b.origin(), + _ => false, + }; + match link.find(marker) { + Some(pos) if !same_origin => format!("{server}/index.php{}", &link[pos..]), + _ => link.to_string(), + } +} + +/// Starts Login Flow v2; the app password gets the device name as its name. +pub fn start_login(client: &Client, server: &str) -> Result<LoginFlow, Error> { + #[derive(Deserialize)] + struct Poll { + token: String, + endpoint: String, + } + #[derive(Deserialize)] + struct Start { + poll: Poll, + login: String, + } + let resp = client + .request("POST", &format!("{server}/index.php/login/v2")) + .call() + .map_err(|e| client.error(e))?; + let start: Start = resp.into_json().map_err(|_| not_nextcloud())?; + Ok(LoginFlow { + login_url: onto_server(&start.login, server, "/login/v2"), + poll_url: onto_server(&start.poll.endpoint, server, "/login/v2"), + token: start.poll.token, + }) +} + +/// Login name and app password once the user has approved the sign-in. +pub fn poll_login(client: &Client, flow: &LoginFlow) -> Result<Option<(String, String)>, Error> { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Done { + login_name: String, + app_password: String, + } + match client + .request("POST", &flow.poll_url) + .send_form(&[("token", flow.token.as_str())]) + { + Ok(resp) => { + let done: Done = resp.into_json().map_err(|e| Error::Other(e.to_string()))?; + Ok(Some((done.login_name, done.app_password))) + } + // Not approved yet + Err(ureq::Error::Status(404, _)) => Ok(None), + Err(err) => Err(client.error(err)), + } +} + +fn basic_auth(login_name: &str, password: &str) -> String { + let raw = format!("{login_name}:{password}"); + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(raw) + ) +} + +/// User id of the signed-in user (the name in WebDAV paths). +pub fn user_id( + client: &Client, + server: &str, + login_name: &str, + password: &str, +) -> Result<String, Error> { + let resp = client + .request( + "GET", + &format!("{server}/ocs/v2.php/cloud/user?format=json"), + ) + .set("OCS-APIRequest", "true") + .set("Authorization", &basic_auth(login_name, password)) + .call() + .map_err(|e| client.error(e))?; + let value: serde_json::Value = resp.into_json().map_err(|e| Error::Other(e.to_string()))?; + value["ocs"]["data"]["id"] + .as_str() + .map(str::to_string) + .ok_or_else(|| Error::Other(t!("Unerwartete Antwort des Servers").to_string())) +} + +/// Revokes the app password on the server (when disconnecting). +pub fn revoke(account: &Account) -> Result<(), Error> { + let client = Client::new(account.pinned_cert.clone()); + client + .request( + "DELETE", + &format!("{}/ocs/v2.php/core/apppassword", account.server), + ) + .set("OCS-APIRequest", "true") + .set( + "Authorization", + &basic_auth(&account.login_name, &account.app_password), + ) + .call() + .map(|_| ()) + .map_err(|e| client.error(e)) +} + +/// One entry of a folder listing on the server. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + /// Path relative to the synced folder, `/`-separated + pub path: String, + pub is_dir: bool, + pub etag: String, +} + +/// How an upload must find the file on the server. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Expect<'a> { + /// Must not exist yet + New, + /// Must still have this ETag + Etag(&'a str), +} + +/// The folder `ink-notes` on the server, as the sync sees it. +pub trait Remote { + /// Entries directly in `dir` (`""` is the synced folder); `None` if it doesn't exist. + fn list(&mut self, dir: &str) -> Result<Option<Vec<Entry>>, Error>; + /// Content and ETag of a file. + fn get(&mut self, path: &str) -> Result<(Vec<u8>, String), Error>; + /// Uploads a file and returns its new ETag. + fn put(&mut self, path: &str, data: &[u8], expect: Expect<'_>) -> Result<String, Error>; + /// Creates a folder (fine if it exists). + fn mkdir(&mut self, path: &str) -> Result<(), Error>; + /// Deletes a file or folder (into the server's trash bin); with an ETag only if unchanged. + fn delete(&mut self, path: &str, etag: Option<&str>) -> Result<(), Error>; +} + +/// [`Remote`] over WebDAV. +pub struct WebDav { + client: Client, + /// `…/remote.php/dav/files/<user>/ink-notes/` + root: url::Url, + auth: String, +} + +impl WebDav { + pub fn new(account: &Account) -> Result<Self, Error> { + Self::with_folder(account, REMOTE_FOLDER) + } + + /// Like [`Self::new`], with another folder (for tests against a real server). + pub fn with_folder(account: &Account, folder: &str) -> Result<Self, Error> { + let mut root = url::Url::parse(&format!("{}/", account.server)) + .map_err(|e| Error::Other(e.to_string()))?; + root.path_segments_mut() + .map_err(|_| Error::Other(account.server.clone()))? + .pop_if_empty() + .extend(["remote.php", "dav", "files", &account.user_id, folder, ""]); + Ok(Self { + client: Client::new(account.pinned_cert.clone()), + root, + auth: basic_auth(&account.login_name, &account.app_password), + }) + } + + fn url(&self, path: &str, dir: bool) -> String { + let mut url = self.root.clone(); + if let Ok(mut segments) = url.path_segments_mut() { + segments.pop_if_empty(); + if !path.is_empty() { + segments.extend(path.split('/')); + } + if dir { + segments.push(""); + } + } + url.to_string() + } + + fn request(&self, method: &str, url: &str) -> ureq::Request { + self.client + .request(method, url) + .set("Authorization", &self.auth) + } +} + +/// Decodes `%xx` escapes of a URL path. +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""); + if let Ok(b) = u8::from_str_radix(hex, 16) { + out.push(b); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Entries of a PROPFIND answer: (decoded href path, is folder, ETag). +pub fn parse_multistatus(xml: &str) -> Result<Vec<(String, bool, String)>, String> { + use quick_xml::events::Event; + let mut reader = quick_xml::Reader::from_str(xml); + let mut entries = Vec::new(); + let mut href = String::new(); + let mut etag = String::new(); + let mut is_dir = false; + let mut ok = false; + let mut in_tag: Option<&'static str> = None; + loop { + match reader.read_event().map_err(|e| e.to_string())? { + Event::Start(e) => { + let name = e.local_name(); + match name.as_ref() { + b"response" => { + href.clear(); + etag.clear(); + is_dir = false; + ok = false; + } + b"href" => in_tag = Some("href"), + b"getetag" => in_tag = Some("etag"), + b"status" => in_tag = Some("status"), + b"collection" => is_dir = true, + _ => {} + } + } + Event::Empty(e) => { + if e.local_name().as_ref() == b"collection" { + is_dir = true; + } + } + event @ (Event::Text(_) | Event::GeneralRef(_)) => { + let text = match event { + Event::Text(t) => t.decode().map_err(|e| e.to_string())?.into_owned(), + Event::GeneralRef(r) => match r.resolve_char_ref() { + Ok(Some(c)) => c.to_string(), + _ => r + .decode() + .ok() + .and_then(|name| quick_xml::escape::resolve_predefined_entity(&name)) + .unwrap_or_default() + .to_string(), + }, + _ => String::new(), + }; + match in_tag { + Some("href") => href.push_str(&text), + Some("etag") => etag.push_str(&text), + Some("status") => ok |= text.contains(" 200 "), + _ => {} + } + } + Event::End(e) => { + let name = e.local_name(); + match name.as_ref() { + b"href" | b"getetag" | b"status" => in_tag = None, + b"response" if ok => { + entries.push((percent_decode(href.trim()), is_dir, etag.trim().to_string())) + } + _ => {} + } + } + Event::Eof => break, + _ => {} + } + } + Ok(entries) +} + +/// ETag of a file in an answer, as PROPFIND lists it. Nextcloud's own `OC-ETag` is left alone +/// by the web server; a plain `ETag` may carry a mark of the compression. +fn etag_of(resp: &ureq::Response) -> Option<String> { + if let Some(etag) = resp.header("OC-ETag") { + return Some(etag.to_string()); + } + let etag = resp.header("ETag")?; + let plain = etag.strip_suffix('"').and_then(|unquoted| { + ["-gzip", "-br", "-zstd", "-df"] + .iter() + .find_map(|mark| unquoted.strip_suffix(mark)) + }); + Some(plain.map_or(etag.to_string(), |tag| format!("{tag}\""))) +} + +const PROPFIND_BODY: &str = r#"<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>"#; + +impl Remote for WebDav { + fn list(&mut self, dir: &str) -> Result<Option<Vec<Entry>>, Error> { + let url = self.url(dir, true); + let resp = match self + .request("PROPFIND", &url) + .set("Depth", "1") + .set("Content-Type", "application/xml; charset=utf-8") + .send_string(PROPFIND_BODY) + { + Ok(resp) => resp, + Err(ureq::Error::Status(404, _)) => return Ok(None), + Err(err) => return Err(self.client.error(err)), + }; + let body = resp + .into_string() + .map_err(|e| Error::Offline(e.to_string()))?; + let root_path = percent_decode(self.root.path()); + let entries = parse_multistatus(&body).map_err(Error::Other)?; + Ok(Some( + entries + .into_iter() + .filter_map(|(href, is_dir, etag)| { + // Hrefs are paths, sometimes full URLs + let path = match href.find("/remote.php/") { + Some(pos) if href.contains("://") => href[pos..].to_string(), + _ => href, + }; + let path = path.strip_prefix(&root_path)?.trim_end_matches('/'); + (path != dir && !path.is_empty()).then(|| Entry { + path: path.to_string(), + is_dir, + etag, + }) + }) + .collect(), + )) + } + + fn get(&mut self, path: &str) -> Result<(Vec<u8>, String), Error> { + // Uncompressed: Apache marks the ETag of a compressed answer (`"…-gzip"`) + let resp = self + .request("GET", &self.url(path, false)) + .set("Accept-Encoding", "identity") + .call() + .map_err(|e| self.client.error(e))?; + let etag = etag_of(&resp).unwrap_or_default(); + let mut data = Vec::new(); + resp.into_reader() + .take(MAX_DOWNLOAD) + .read_to_end(&mut data) + .map_err(|e| Error::Offline(e.to_string()))?; + Ok((data, etag)) + } + + fn put(&mut self, path: &str, data: &[u8], expect: Expect<'_>) -> Result<String, Error> { + let mut req = self.request("PUT", &self.url(path, false)); + req = match expect { + Expect::New => req.set("If-None-Match", "*"), + Expect::Etag(etag) => req.set("If-Match", etag), + }; + let resp = req.send_bytes(data).map_err(|e| self.client.error(e))?; + match etag_of(&resp) { + Some(etag) => Ok(etag), + None => { + // Look it up when the server didn't say + let parent = path.rsplit_once('/').map_or("", |(p, _)| p); + self.list(parent)? + .unwrap_or_default() + .into_iter() + .find(|e| e.path == path) + .map(|e| e.etag) + .ok_or(Error::NotFound) + } + } + } + + fn mkdir(&mut self, path: &str) -> Result<(), Error> { + match self.request("MKCOL", &self.url(path, true)).call() { + Ok(_) | Err(ureq::Error::Status(405, _)) => Ok(()), + Err(err) => Err(self.client.error(err)), + } + } + + fn delete(&mut self, path: &str, etag: Option<&str>) -> Result<(), Error> { + let mut req = self.request("DELETE", &self.url(path, false)); + if let Some(etag) = etag { + req = req.set("If-Match", etag); + } + match req.call() { + Ok(_) | Err(ureq::Error::Status(404, _)) => Ok(()), + Err(err) => Err(self.client.error(err)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_server_addresses() { + assert_eq!( + normalize_server("cloud.example.org").unwrap(), + "https://cloud.example.org" + ); + assert_eq!( + normalize_server(" https://example.org/nextcloud/ ").unwrap(), + "https://example.org/nextcloud" + ); + assert_eq!( + normalize_server("https://example.org/nextcloud/index.php/apps/files/?dir=/").unwrap(), + "https://example.org/nextcloud" + ); + assert_eq!( + normalize_server("http://192.168.1.5:8080/apps/dashboard/").unwrap(), + "http://192.168.1.5:8080" + ); + assert!(normalize_server("").is_err()); + assert!(normalize_server("ftp://example.org").is_err()); + } + + #[test] + fn moves_links_onto_the_given_server() { + let server = "https://cloud.example.org/nc"; + assert_eq!( + onto_server("http://localhost/login/v2/poll", server, "/login/v2"), + "https://cloud.example.org/nc/index.php/login/v2/poll" + ); + let same = "https://cloud.example.org/nc/login/v2/flow/abc"; + assert_eq!(onto_server(same, server, "/login/v2"), same); + } + + #[test] + fn parses_propfind_answers() { + let xml = r#"<?xml version="1.0"?> +<d:multistatus xmlns:d="DAV:"><d:response><d:href>/remote.php/dav/files/pia/ink-notes/</d:href><d:propstat><d:prop><d:getetag>"abc"</d:getetag><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response> +<d:response><d:href>/remote.php/dav/files/pia/ink-notes/library.json</d:href><d:propstat><d:prop><d:getetag>"e1"</d:getetag><d:resourcetype/></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response> +<d:response><d:href>/remote.php/dav/files/pia/ink-notes/Mathe%20%C3%BC/</d:href><d:propstat><d:prop><d:getetag>"e2"</d:getetag><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>"#; + let entries = parse_multistatus(xml).unwrap(); + assert_eq!( + entries, + vec![ + ( + "/remote.php/dav/files/pia/ink-notes/".to_string(), + true, + "\"abc\"".to_string() + ), + ( + "/remote.php/dav/files/pia/ink-notes/library.json".to_string(), + false, + "\"e1\"".to_string() + ), + ( + "/remote.php/dav/files/pia/ink-notes/Mathe ü/".to_string(), + true, + "\"e2\"".to_string() + ), + ] + ); + } + + #[test] + fn builds_webdav_urls() { + let account = Account { + server: "https://example.org/nc".into(), + login_name: "pia@example.org".into(), + user_id: "pia".into(), + app_password: "x".into(), + pinned_cert: None, + }; + let dav = WebDav::new(&account).unwrap(); + assert_eq!( + dav.url("nb 1/page_1.json", false), + "https://example.org/nc/remote.php/dav/files/pia/ink-notes/nb%201/page_1.json" + ); + assert_eq!( + dav.url("", true), + "https://example.org/nc/remote.php/dav/files/pia/ink-notes/" + ); + assert_eq!(account.label(), "pia@example.org@example.org/nc"); + } + + #[test] + fn account_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cfg").join("nextcloud.json"); + let account = Account { + server: "https://example.org".into(), + login_name: "pia".into(), + user_id: "pia".into(), + app_password: "secret".into(), + pinned_cert: Some("AA:BB".into()), + }; + account.save_to(&path).unwrap(); + assert_eq!(Account::load_from(&path), Some(account)); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + } +} diff --git a/src/ui.rs b/src/ui.rs index 99bf049..430e1c0 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -343,6 +343,15 @@ pub enum UiAction { ImportFile(std::path::PathBuf), /// Store the notebooks in this folder, `None` for the default location (Issue #49) SetStorageDir(Option<std::path::PathBuf>), + /// Sign in to a Nextcloud server through the browser; with the fingerprint of a + /// certificate the user trusts (Issue #49, stage 2) + CloudConnect { + server: String, + trusted: Option<String>, + }, + CloudCancel, + CloudSyncNow, + CloudDisconnect, /// Aktuelle Seite als Bild oder SVG exportieren ExportPage(PageExportKind), /// Titel einer Seite setzen (leer = entfernen) @@ -513,6 +522,9 @@ pub struct UiState { /// Folder holding the notebooks and whether the user picked it (Issue #49) pub storage_dir: std::path::PathBuf, pub storage_dir_custom: bool, + /// Nextcloud sync as the app last saw it, and the server address being typed + pub cloud: crate::cloud_sync::View, + pub cloud_server_input: String, } impl Default for UiState { @@ -601,6 +613,8 @@ impl Default for UiState { last_picker_dir: None, storage_dir: std::path::PathBuf::new(), storage_dir_custom: false, + cloud: crate::cloud_sync::View::default(), + cloud_server_input: String::new(), } } } @@ -3190,6 +3204,171 @@ pub fn key_allowed_in_view_mode(key: &winit::keyboard::Key, ctrl: bool) -> bool } } +/// "How long ago" for the last sync. +fn elapsed_label(at: std::time::SystemTime) -> String { + let secs = at.elapsed().map_or(0, |d| d.as_secs()); + if secs < 60 { + t!("gerade eben").to_string() + } else if secs < 3600 { + t!("vor {minutes} min", minutes = secs / 60) + } else { + t!("vor {hours} h", hours = secs / 3600) + } +} + +/// Nextcloud sync in the menu (Issue #49, stage 2). +fn cloud_section(ui: &mut egui::Ui, state: &mut UiState, actions: &mut Vec<UiAction>) { + use crate::cloud_sync::Status; + let small = |text: String, color: egui::Color32| { + egui::Label::new(egui::RichText::new(text).size(11.5).color(color)).wrap(true) + }; + let grey = egui::Color32::from_gray(190); + let warn = egui::Color32::from_rgb(240, 180, 80); + let button = |text: String| egui::Button::new(text).min_size(egui::vec2(115.0, 28.0)); + ui.add_space(4.0); + ui.label( + egui::RichText::new(t!("NEXTCLOUD")) + .size(10.5) + .strong() + .color(egui::Color32::from_gray(160)), + ); + let status = state.cloud.status.clone(); + if matches!( + status, + Status::Connecting | Status::WaitingForLogin { .. } | Status::Syncing + ) { + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(500)); + } + match status { + Status::Off | Status::Failed(_) if state.cloud.account.is_none() => { + if let Status::Failed(msg) = &status { + ui.add(small(format!("⚠ {msg}"), warn)); + } + let field = ui.add( + egui::TextEdit::singleline(&mut state.cloud_server_input) + .hint_text(t!("z. B. cloud.example.org")) + .margin(egui::vec2(8.0, 6.0)) + .desired_width(f32::INFINITY), + ); + if state.cloud_server_input.trim().starts_with("http://") { + ui.add(small( + t!("⚠ Ohne https ist das App-Passwort im Netz lesbar – nur im eigenen Heimnetz verwenden").to_string(), + warn, + )); + } + let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if ui + .add_enabled( + !state.cloud_server_input.trim().is_empty(), + button(t!("🔗 Verbinden …").to_string()), + ) + .on_hover_text(t!("Anmeldung im Browser; die App bekommt ein eigenes App-Passwort, das sich in Nextcloud jederzeit widerrufen lässt")) + .clicked() + || enter && !state.cloud_server_input.trim().is_empty() + { + actions.push(UiAction::CloudConnect { + server: state.cloud_server_input.clone(), + trusted: None, + }); + } + } + Status::Off | Status::Connecting => { + ui.add(small(t!("⏳ Verbinde …").to_string(), grey)); + if ui.add(button(t!("Abbrechen").to_string())).clicked() { + actions.push(UiAction::CloudCancel); + } + } + Status::Untrusted { + server, + fingerprint, + } => { + ui.add(small( + t!("⚠ Das Zertifikat von {server} ist von keiner bekannten Stelle signiert. Nur vertrauen, wenn dieser Fingerabdruck mit dem des Servers übereinstimmt:", server = server), + warn, + )); + ui.add( + egui::Label::new( + egui::RichText::new(&fingerprint) + .monospace() + .size(10.0) + .color(grey), + ) + .wrap(true), + ); + ui.horizontal(|ui| { + if ui.add(button(t!("Vertrauen").to_string())).clicked() { + actions.push(UiAction::CloudConnect { + server: server.clone(), + trusted: Some(fingerprint.clone()), + }); + } + if ui.add(button(t!("Abbrechen").to_string())).clicked() { + actions.push(UiAction::CloudCancel); + } + }); + } + Status::WaitingForLogin { url } => { + ui.add(small( + t!("Bitte im Browser anmelden und den Zugriff erlauben …").to_string(), + grey, + )); + ui.horizontal(|ui| { + if ui + .add(button(t!("🌐 Browser öffnen").to_string())) + .clicked() + { + actions.push(UiAction::OpenUrlInBrowser(url.clone())); + } + if ui.add(button(t!("Abbrechen").to_string())).clicked() { + actions.push(UiAction::CloudCancel); + } + }); + } + Status::Idle { .. } | Status::Syncing | Status::Offline(_) | Status::Failed(_) => { + if let Some(account) = &state.cloud.account { + ui.add(small(format!("👤 {account}"), grey).truncate(true)) + .on_hover_text(account); + } + let (text, color) = match &status { + Status::Idle { last: Some(at) } => ( + t!("✔ Synchronisiert {when}", when = elapsed_label(*at)), + egui::Color32::from_rgb(120, 200, 120), + ), + Status::Offline(msg) => ( + t!("⚪ Offline – neuer Versuch in Kürze ({msg})", msg = msg), + grey, + ), + Status::Failed(msg) => (format!("⚠ {msg}"), warn), + _ => (t!("🔄 Synchronisiere …").to_string(), grey), + }; + ui.add(small(text, color)); + if state.cloud.insecure { + ui.add(small(t!("⚠ Unverschlüsselt (http)").to_string(), warn)); + } + ui.horizontal(|ui| { + if ui + .add_enabled( + !matches!(status, Status::Syncing), + button(t!("🔄 Jetzt").to_string()), + ) + .on_hover_text(t!("Sofort mit dem Server abgleichen")) + .clicked() + { + actions.push(UiAction::CloudSyncNow); + } + if ui + .add(button(t!("Trennen").to_string())) + .on_hover_text(t!("Abgleich beenden und das App-Passwort widerrufen; die Notizbücher bleiben auf diesem Gerät und auf dem Server")) + .clicked() + { + actions.push(UiAction::CloudDisconnect); + } + }); + } + } +} + /// Rendert die schwebende Toolbar und alle zugehörigen UI-Komponenten mit `egui`. /// /// Gibt eine Liste von `UiAction`-Events zurück, die vom Hauptprogramm verarbeitet werden. @@ -4535,6 +4714,7 @@ pub fn render_ui( actions.push(UiAction::SetStorageDir(None)); } }); + cloud_section(ui, state, &mut actions); section(ui, t!("STATUS")); let (dot_color, status_text) = autosave_status_style(autosave_status);