mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 23:01:26 +02:00
feat: restore points
This commit is contained in:
1 parent
8f3f8afd2c
commit
cb0c31f9a4
11 files changed
+1172
-22
No files matched your search
@@ -416,4 +416,78 @@ impl SyncClient {
|
||||
.await
|
||||
.map_err(|e| SyncError::SerializationError(e.to_string()))
|
||||
}
|
||||
|
||||
async fn post_json<B: serde::Serialize, T: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
) -> SyncResult<T> {
|
||||
let response = self
|
||||
.client
|
||||
.post(self.url(path))
|
||||
.header("Authorization", format!("Bearer {}", self.token))
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::NetworkError(e.to_string()))?;
|
||||
let status = response.status();
|
||||
if status.is_client_error() || status.is_server_error() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(SyncError::AuthError(format!("({status}) {body}")));
|
||||
}
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| SyncError::SerializationError(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn snapshots_list(
|
||||
&self,
|
||||
profile_root: &str,
|
||||
) -> SyncResult<Vec<super::restore_points::RestorePoint>> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Listed {
|
||||
snapshots: Vec<super::restore_points::RestorePoint>,
|
||||
}
|
||||
let listed: Listed = self
|
||||
.post_json(
|
||||
"snapshots/list",
|
||||
&serde_json::json!({ "profileRoot": profile_root }),
|
||||
)
|
||||
.await?;
|
||||
Ok(listed.snapshots)
|
||||
}
|
||||
|
||||
pub async fn snapshots_create(
|
||||
&self,
|
||||
profile_root: &str,
|
||||
kind: &str,
|
||||
) -> SyncResult<super::restore_points::RestorePoint> {
|
||||
self
|
||||
.post_json(
|
||||
"snapshots/create",
|
||||
&serde_json::json!({ "profileRoot": profile_root, "kind": kind }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn snapshots_delete(&self, profile_root: &str, id: &str) -> SyncResult<()> {
|
||||
let _: serde_json::Value = self
|
||||
.post_json(
|
||||
"snapshots/delete",
|
||||
&serde_json::json!({ "profileRoot": profile_root, "id": id }),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn snapshots_restore_files(&self, profile_root: &str, id: &str) -> SyncResult<()> {
|
||||
let _: serde_json::Value = self
|
||||
.post_json(
|
||||
"snapshots/restore-files",
|
||||
&serde_json::json!({ "profileRoot": profile_root, "id": id }),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -540,6 +540,7 @@ pub fn is_sync_configured() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SyncEngine {
|
||||
pub(super) client: SyncClient,
|
||||
/// A team launcher's engine: the team's copy of every setting wins, and
|
||||
@@ -604,7 +605,7 @@ impl SyncEngine {
|
||||
}
|
||||
|
||||
/// Get the key prefix for team profiles. Returns empty string for personal profiles.
|
||||
async fn get_team_key_prefix(profile: &BrowserProfile) -> String {
|
||||
pub(super) async fn get_team_key_prefix(profile: &BrowserProfile) -> String {
|
||||
if profile.created_by_id.is_some() {
|
||||
if let Some(auth) = crate::cloud_auth::CLOUD_AUTH.get_user().await {
|
||||
if let Some(team_id) = &auth.user.team_id {
|
||||
@@ -624,6 +625,25 @@ impl SyncEngine {
|
||||
.is_some_and(|auth| auth.user.team_id.is_some())
|
||||
}
|
||||
|
||||
/// The key that seals an end-to-end encrypted profile's files, or `None`.
|
||||
pub(super) fn profile_encryption_key(profile: &BrowserProfile) -> SyncResult<Option<[u8; 32]>> {
|
||||
if !profile.is_encrypted_sync() {
|
||||
return Ok(None);
|
||||
}
|
||||
let password = encryption::load_e2e_password()
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to load E2E password: {e}")))?
|
||||
.ok_or_else(|| {
|
||||
let _ = events::emit("profile-sync-e2e-password-required", ());
|
||||
SyncError::InvalidData("E2E password not set".to_string())
|
||||
})?;
|
||||
let salt = profile.encryption_salt.as_deref().ok_or_else(|| {
|
||||
SyncError::InvalidData("Encryption salt missing on encrypted profile".to_string())
|
||||
})?;
|
||||
let key = encryption::derive_profile_key(&password, salt)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Key derivation failed: {e}")))?;
|
||||
Ok(Some(key))
|
||||
}
|
||||
|
||||
/// Check if this is a self-hosted sync (no cloud login).
|
||||
async fn is_self_hosted_sync() -> bool {
|
||||
!crate::cloud_auth::CLOUD_AUTH.is_logged_in().await
|
||||
@@ -873,22 +893,7 @@ impl SyncEngine {
|
||||
let profile = &reconciled_profile;
|
||||
|
||||
// Derive encryption key if encrypted sync
|
||||
let encryption_key = if profile.is_encrypted_sync() {
|
||||
let password = encryption::load_e2e_password()
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to load E2E password: {e}")))?
|
||||
.ok_or_else(|| {
|
||||
let _ = events::emit("profile-sync-e2e-password-required", ());
|
||||
SyncError::InvalidData("E2E password not set".to_string())
|
||||
})?;
|
||||
let salt = profile.encryption_salt.as_deref().ok_or_else(|| {
|
||||
SyncError::InvalidData("Encryption salt missing on encrypted profile".to_string())
|
||||
})?;
|
||||
let key = encryption::derive_profile_key(&password, salt)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Key derivation failed: {e}")))?;
|
||||
Some(key)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let encryption_key = Self::profile_encryption_key(profile)?;
|
||||
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles_dir = profile_manager.get_profiles_dir();
|
||||
@@ -1191,10 +1196,13 @@ impl SyncEngine {
|
||||
diff.files_to_delete_remote.len(),
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
if !diff.files_to_upload.is_empty() {
|
||||
self.maybe_auto_restore_point(&updated_profile);
|
||||
}
|
||||
Ok(ProfileSyncOutcome::Completed)
|
||||
}
|
||||
|
||||
async fn download_manifest(
|
||||
pub(super) async fn download_manifest(
|
||||
&self,
|
||||
key: &str,
|
||||
encryption_key: Option<&[u8; 32]>,
|
||||
@@ -1227,7 +1235,7 @@ impl SyncEngine {
|
||||
))
|
||||
}
|
||||
|
||||
async fn upload_manifest(
|
||||
pub(super) async fn upload_manifest(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
manifest: &SyncManifest,
|
||||
|
||||
@@ -434,6 +434,9 @@ pub enum DiffBias {
|
||||
/// the local clock genuinely is later; only the caller knows that the remote
|
||||
/// copy is the one that matters.
|
||||
PreferRemote,
|
||||
/// Remote wins, for the pull that follows a restore point: the server now
|
||||
/// holds the restored files, and the local copy is the one being replaced.
|
||||
Restore,
|
||||
}
|
||||
|
||||
pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> ManifestDiff {
|
||||
@@ -479,7 +482,7 @@ pub fn compute_diff_with_bias(
|
||||
let remote_updated = remote.updated_at_datetime();
|
||||
|
||||
let local_is_newer = match bias {
|
||||
DiffBias::PreferRemote => false,
|
||||
DiffBias::PreferRemote | DiffBias::Restore => false,
|
||||
DiffBias::Auto => match (local_updated, remote_updated) {
|
||||
(Some(l), Some(r)) => l > r,
|
||||
(Some(_), None) => true,
|
||||
|
||||
@@ -5,6 +5,7 @@ mod engine;
|
||||
pub mod manifest;
|
||||
pub mod pending_deletes;
|
||||
pub mod preflight;
|
||||
pub mod restore_points;
|
||||
pub mod scheduler;
|
||||
pub mod subscription;
|
||||
pub mod types;
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
//! Restore points: copies of a synced profile's files that the sync server
|
||||
//! keeps beside the live ones. One is made a day after a sync that changed
|
||||
//! something, and on demand; restoring one puts its files back and makes every
|
||||
//! device take them.
|
||||
//!
|
||||
//! The copies are made inside the store (no byte travels through this
|
||||
//! computer), and the server keeps the newest three of each kind.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::engine::SyncEngine;
|
||||
use super::manifest::DiffBias;
|
||||
use super::types::{SyncError, SyncResult};
|
||||
use crate::profile::{BrowserProfile, ProfileManager};
|
||||
|
||||
/// How long after one automatic restore point the next may be made.
|
||||
const AUTO_EVERY_SECS: u64 = 24 * 60 * 60;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RestorePoint {
|
||||
pub id: String,
|
||||
pub created_at: String,
|
||||
/// `auto` (daily) or `manual`.
|
||||
pub kind: String,
|
||||
pub files: u64,
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
/// When each profile last got an automatic restore point, kept on this
|
||||
/// computer only.
|
||||
static LAST_AUTO: Mutex<Option<HashMap<String, u64>>> = Mutex::new(None);
|
||||
|
||||
fn state_path() -> std::path::PathBuf {
|
||||
crate::app_dirs::data_dir().join("restore_points.json")
|
||||
}
|
||||
|
||||
fn last_auto(profile_id: &str) -> u64 {
|
||||
let Ok(mut guard) = LAST_AUTO.lock() else {
|
||||
return 0;
|
||||
};
|
||||
let map = guard.get_or_insert_with(|| {
|
||||
std::fs::read_to_string(state_path())
|
||||
.ok()
|
||||
.and_then(|text| serde_json::from_str(&text).ok())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
map.get(profile_id).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn note_auto(profile_id: &str, at: u64) {
|
||||
let Ok(mut guard) = LAST_AUTO.lock() else {
|
||||
return;
|
||||
};
|
||||
let map = guard.get_or_insert_with(HashMap::new);
|
||||
map.insert(profile_id.to_string(), at);
|
||||
if let Ok(text) = serde_json::to_string(map) {
|
||||
let _ = std::fs::write(state_path(), text);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an automatic restore point is due for a profile last given one at
|
||||
/// `last`, at `now`.
|
||||
pub fn auto_due(last: u64, now: u64) -> bool {
|
||||
now.saturating_sub(last) >= AUTO_EVERY_SECS
|
||||
}
|
||||
|
||||
fn code(code: &str) -> String {
|
||||
crate::backend_error(code)
|
||||
}
|
||||
|
||||
/// A sync failure as the code the app shows: the server's own code when it
|
||||
/// sent one, `RESTORE_POINTS_UNSUPPORTED` for a server too old to know the
|
||||
/// routes.
|
||||
fn failure(error: SyncError) -> String {
|
||||
let text = error.to_string();
|
||||
if let Some(start) = text.find("{\"") {
|
||||
if let Ok(body) = serde_json::from_str::<serde_json::Value>(&text[start..]) {
|
||||
if let Some(found) = body
|
||||
.get("code")
|
||||
.or_else(|| body.pointer("/message/code"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
{
|
||||
return code(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
if text.contains("(404") {
|
||||
return code("RESTORE_POINTS_UNSUPPORTED");
|
||||
}
|
||||
crate::backend_error_with_detail("RESTORE_POINT_FAILED", text)
|
||||
}
|
||||
|
||||
impl SyncEngine {
|
||||
async fn profile_root(profile: &BrowserProfile) -> String {
|
||||
format!(
|
||||
"{}profiles/{}/",
|
||||
Self::get_team_key_prefix(profile).await,
|
||||
profile.id
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn list_restore_points(
|
||||
&self,
|
||||
profile: &BrowserProfile,
|
||||
) -> SyncResult<Vec<RestorePoint>> {
|
||||
let root = Self::profile_root(profile).await;
|
||||
self.client.snapshots_list(&root).await
|
||||
}
|
||||
|
||||
pub async fn create_restore_point(
|
||||
&self,
|
||||
profile: &BrowserProfile,
|
||||
kind: &str,
|
||||
) -> SyncResult<RestorePoint> {
|
||||
let root = Self::profile_root(profile).await;
|
||||
let point = self.client.snapshots_create(&root, kind).await?;
|
||||
log::info!(
|
||||
"Restore point created profile={} kind={kind} files={} bytes={}",
|
||||
profile.id,
|
||||
point.files,
|
||||
point.bytes
|
||||
);
|
||||
Ok(point)
|
||||
}
|
||||
|
||||
pub async fn delete_restore_point(&self, profile: &BrowserProfile, id: &str) -> SyncResult<()> {
|
||||
let root = Self::profile_root(profile).await;
|
||||
self.client.snapshots_delete(&root, id).await
|
||||
}
|
||||
|
||||
/// Put restore point `id` back: its files replace the live ones on the
|
||||
/// server, its manifest goes up stamped as the newest, and this computer
|
||||
/// takes the result as it would after a remote session.
|
||||
pub async fn restore_point(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
id: &str,
|
||||
) -> SyncResult<()> {
|
||||
let root = Self::profile_root(profile).await;
|
||||
let key_prefix = Self::get_team_key_prefix(profile).await;
|
||||
let encryption_key = Self::profile_encryption_key(profile)?;
|
||||
let snapshot_manifest = format!("snapshots/{root}{id}/manifest.json");
|
||||
let mut manifest = self
|
||||
.download_manifest(&snapshot_manifest, encryption_key.as_ref())
|
||||
.await?
|
||||
.ok_or_else(|| SyncError::InvalidData("restore point has no manifest".to_string()))?;
|
||||
self.client.snapshots_restore_files(&root, id).await?;
|
||||
manifest.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
self
|
||||
.upload_manifest(
|
||||
&profile.id.to_string(),
|
||||
&manifest,
|
||||
encryption_key.as_ref(),
|
||||
&key_prefix,
|
||||
)
|
||||
.await?;
|
||||
self
|
||||
.sync_profile_with_bias(app_handle, profile, DiffBias::Restore)
|
||||
.await?;
|
||||
log::info!(
|
||||
"Restore point restored profile={} files={}",
|
||||
profile.id,
|
||||
manifest.files.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// After a sync that changed something, the day's automatic restore point,
|
||||
/// in the background.
|
||||
pub(super) fn maybe_auto_restore_point(&self, profile: &BrowserProfile) {
|
||||
if self.read_only || !profile.is_sync_enabled() {
|
||||
return;
|
||||
}
|
||||
let profile_id = profile.id.to_string();
|
||||
let now = crate::proxy_manager::now_secs();
|
||||
if !auto_due(last_auto(&profile_id), now) {
|
||||
return;
|
||||
}
|
||||
note_auto(&profile_id, now);
|
||||
let engine = self.clone();
|
||||
let profile = profile.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = engine.create_restore_point(&profile, "auto").await {
|
||||
log::warn!(
|
||||
"Automatic restore point failed profile={} err=\"{e}\"",
|
||||
profile.id
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The profile, and an engine for it, when restore points can serve it.
|
||||
async fn engine_for(
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile_id: &str,
|
||||
) -> Result<(SyncEngine, BrowserProfile), String> {
|
||||
let profile = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| crate::wrap_backend_error(e, "Failed to list profiles"))?
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| code("PROFILE_NOT_FOUND"))?;
|
||||
if !profile.is_sync_enabled() {
|
||||
return Err(code("RESTORE_POINTS_NEED_SYNC"));
|
||||
}
|
||||
let engine = SyncEngine::create_from_settings(app_handle)
|
||||
.await
|
||||
.map_err(|_| code("RESTORE_POINTS_NEED_SYNC"))?;
|
||||
Ok((engine, profile))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_restore_points(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
) -> Result<Vec<RestorePoint>, String> {
|
||||
let (engine, profile) = engine_for(&app_handle, &profile_id).await?;
|
||||
engine.list_restore_points(&profile).await.map_err(failure)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_restore_point(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
) -> Result<RestorePoint, String> {
|
||||
let (engine, profile) = engine_for(&app_handle, &profile_id).await?;
|
||||
engine
|
||||
.create_restore_point(&profile, "manual")
|
||||
.await
|
||||
.map_err(failure)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_restore_point(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
let (engine, profile) = engine_for(&app_handle, &profile_id).await?;
|
||||
engine
|
||||
.delete_restore_point(&profile, &id)
|
||||
.await
|
||||
.map_err(failure)?;
|
||||
log::info!("Restore point deleted profile={profile_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore `id` into a closed profile. The data it replaces is kept first, as
|
||||
/// a manual restore point, so a restore can itself be undone.
|
||||
#[tauri::command]
|
||||
pub async fn restore_restore_point(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
let (engine, profile) = engine_for(&app_handle, &profile_id).await?;
|
||||
if crate::browser_runner::browser_is_running_for(&profile_id) {
|
||||
return Err(code("PROFILE_RUNNING"));
|
||||
}
|
||||
engine
|
||||
.create_restore_point(&profile, "manual")
|
||||
.await
|
||||
.map_err(failure)?;
|
||||
engine
|
||||
.restore_point(&app_handle, &profile, &id)
|
||||
.await
|
||||
.map_err(failure)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_automatic_point_is_made_once_a_day() {
|
||||
assert!(auto_due(0, AUTO_EVERY_SECS));
|
||||
assert!(!auto_due(1_000, 1_000 + AUTO_EVERY_SECS - 1));
|
||||
assert!(auto_due(1_000, 1_000 + AUTO_EVERY_SECS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_code_reaches_the_app_and_an_old_server_says_so() {
|
||||
let refused = SyncError::AuthError(
|
||||
"(400 Bad Request) {\"message\":{\"code\":\"SNAPSHOT_NOTHING_TO_KEEP\"},\"statusCode\":400}"
|
||||
.to_string(),
|
||||
);
|
||||
assert!(failure(refused).contains("SNAPSHOT_NOTHING_TO_KEEP"));
|
||||
let read_only =
|
||||
SyncError::AuthError("(403 Forbidden) {\"code\":\"TEAM_ROLE_READ_ONLY\"}".to_string());
|
||||
assert!(failure(read_only).contains("TEAM_ROLE_READ_ONLY"));
|
||||
let old = SyncError::AuthError("(404 Not Found) Cannot POST".to_string());
|
||||
assert!(failure(old).contains("RESTORE_POINTS_UNSUPPORTED"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user