From cb0c31f9a4f4e101ed469aef86e65790a8723c9d Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:00:12 +0400 Subject: [PATCH] feat: restore points --- donut-sync/src/sync/dto/sync.dto.ts | 35 +++ donut-sync/src/sync/sync.controller.ts | 42 ++++ donut-sync/src/sync/sync.service.spec.ts | 148 +++++++++++ donut-sync/src/sync/sync.service.ts | 263 +++++++++++++++++++- e2e/tests/sync.test.mjs | 93 +++++++ src-tauri/src/sync/client.rs | 74 ++++++ src-tauri/src/sync/engine.rs | 46 ++-- src-tauri/src/sync/manifest.rs | 5 +- src-tauri/src/sync/mod.rs | 1 + src-tauri/src/sync/restore_points.rs | 300 +++++++++++++++++++++++ src/components/restore-points.tsx | 187 ++++++++++++++ 11 files changed, 1172 insertions(+), 22 deletions(-) create mode 100644 src-tauri/src/sync/restore_points.rs create mode 100644 src/components/restore-points.tsx diff --git a/donut-sync/src/sync/dto/sync.dto.ts b/donut-sync/src/sync/dto/sync.dto.ts index 5e377b42..da5b6756 100644 --- a/donut-sync/src/sync/dto/sync.dto.ts +++ b/donut-sync/src/sync/dto/sync.dto.ts @@ -120,3 +120,38 @@ export class DeletePrefixResponseDto { deletedCount: number; tombstoneCreated: boolean; } + +// Restore points: copies of a profile's files and manifest +export type SnapshotKind = "auto" | "manual"; + +export class SnapshotCreateRequestDto { + /** `profiles//` or `teams//profiles//`. */ + profileRoot: string; + kind?: SnapshotKind; +} + +export class SnapshotDto { + id: string; + createdAt: string; + kind: SnapshotKind; + files: number; + bytes: number; +} + +export class SnapshotListRequestDto { + profileRoot: string; +} + +export class SnapshotListResponseDto { + snapshots: SnapshotDto[]; +} + +export class SnapshotRequestDto { + profileRoot: string; + id: string; +} + +export class SnapshotRestoreResponseDto { + restored: number; + removed: number; +} diff --git a/donut-sync/src/sync/sync.controller.ts b/donut-sync/src/sync/sync.controller.ts index b48f4041..fb7f9e47 100644 --- a/donut-sync/src/sync/sync.controller.ts +++ b/donut-sync/src/sync/sync.controller.ts @@ -28,6 +28,12 @@ import type { PresignUploadBatchResponseDto, PresignUploadRequestDto, PresignUploadResponseDto, + SnapshotCreateRequestDto, + SnapshotDto, + SnapshotListRequestDto, + SnapshotListResponseDto, + SnapshotRequestDto, + SnapshotRestoreResponseDto, StatRequestDto, StatResponseDto, } from "./dto/sync.dto.js"; @@ -114,6 +120,42 @@ export class SyncController { return this.syncService.deletePrefix(dto, this.getUserContext(req)); } + @Post("snapshots/list") + @HttpCode(200) + async listSnapshots( + @Body() dto: SnapshotListRequestDto, + @Req() req: Request, + ): Promise { + return this.syncService.listSnapshots(dto, this.getUserContext(req)); + } + + @Post("snapshots/create") + @HttpCode(200) + async createSnapshot( + @Body() dto: SnapshotCreateRequestDto, + @Req() req: Request, + ): Promise { + return this.syncService.createSnapshot(dto, this.getUserContext(req)); + } + + @Post("snapshots/delete") + @HttpCode(200) + async deleteSnapshot( + @Body() dto: SnapshotRequestDto, + @Req() req: Request, + ): Promise<{ deleted: boolean }> { + return this.syncService.deleteSnapshot(dto, this.getUserContext(req)); + } + + @Post("snapshots/restore-files") + @HttpCode(200) + async restoreSnapshotFiles( + @Body() dto: SnapshotRequestDto, + @Req() req: Request, + ): Promise { + return this.syncService.restoreSnapshotFiles(dto, this.getUserContext(req)); + } + @Get("subscribe") @Sse() subscribe(@Req() req: Request): Observable { diff --git a/donut-sync/src/sync/sync.service.spec.ts b/donut-sync/src/sync/sync.service.spec.ts index 80a9a603..d042b781 100644 --- a/donut-sync/src/sync/sync.service.spec.ts +++ b/donut-sync/src/sync/sync.service.spec.ts @@ -267,3 +267,151 @@ describe("SyncService access by group", () => { expect(listed.objects).toHaveLength(6); }); }); + +describe("SyncService restore points", () => { + /** A bucket in memory, enough for the restore-point paths. */ + function memoryService(): { + service: SyncService; + objects: Map; + } { + const { service, send } = serviceWithStubbedS3(); + const objects = new Map(); + send.mockImplementation( + async (command: { input: Record }) => { + const name = command.constructor.name; + const input = command.input; + if (name === "ListObjectsV2Command") { + const prefix = String(input.Prefix ?? ""); + return { + Contents: [...objects.keys()] + .filter((key) => key.startsWith(prefix)) + .sort() + .map((key) => ({ Key: key, Size: objects.get(key)?.length })), + }; + } + if (name === "CopyObjectCommand") { + const source = decodeURIComponent( + String(input.CopySource).split("/").slice(1).join("/"), + ); + objects.set(String(input.Key), objects.get(source) ?? ""); + return {}; + } + if (name === "PutObjectCommand") { + objects.set(String(input.Key), String(input.Body ?? "")); + return {}; + } + if (name === "GetObjectCommand") { + const body = objects.get(String(input.Key)); + if (body === undefined) { + throw Object.assign(new Error("missing"), { name: "NoSuchKey" }); + } + return { Body: { transformToString: async () => body } }; + } + if (name === "DeleteObjectsCommand") { + const doomed = (input.Delete as { Objects: { Key: string }[] }) + .Objects; + for (const { Key } of doomed) objects.delete(Key); + return {}; + } + return {}; + }, + ); + return { service, objects }; + } + + const owner: UserContext = { + mode: "cloud", + prefix: OWNER_PREFIX, + profileLimit: 0, + teamRole: "owner", + }; + const live = (relative: string) => `${OWNER_PREFIX}profiles/p1/${relative}`; + + beforeEach(() => { + jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("keeps a copy of the files and manifest, and puts it back", async () => { + const { service, objects } = memoryService(); + objects.set(live("manifest.json"), "manifest-1"); + objects.set(live("files/Default/Cookies"), "cookies-1"); + objects.set(live("metadata.json"), "settings"); + + const point = await service.createSnapshot( + { profileRoot: "profiles/p1/", kind: "manual" }, + owner, + ); + expect(point).toMatchObject({ kind: "manual", files: 1 }); + expect( + [...objects.keys()].filter((key) => key.includes("/snapshots/")), + ).toHaveLength(3); + + objects.set(live("files/Default/Cookies"), "cookies-2"); + objects.set(live("files/Default/New"), "new"); + const restored = await service.restoreSnapshotFiles( + { profileRoot: "profiles/p1/", id: point.id }, + owner, + ); + expect(restored).toEqual({ restored: 1, removed: 1 }); + expect(objects.get(live("files/Default/Cookies"))).toBe("cookies-1"); + expect(objects.has(live("files/Default/New"))).toBe(false); + expect(objects.get(live("metadata.json"))).toBe("settings"); + + const { snapshots } = await service.listSnapshots( + { profileRoot: "profiles/p1/" }, + owner, + ); + expect(snapshots.map((s) => s.id)).toEqual([point.id]); + }); + + it("keeps the newest few of each kind", async () => { + const { service, objects } = memoryService(); + objects.set(live("manifest.json"), "m"); + const ids: string[] = []; + for (let i = 0; i < SyncService.SNAPSHOTS_PER_KIND + 2; i++) { + ids.push( + ( + await service.createSnapshot( + { profileRoot: "profiles/p1/", kind: "auto" }, + owner, + ) + ).id, + ); + } + await service.createSnapshot( + { profileRoot: "profiles/p1/", kind: "manual" }, + owner, + ); + const { snapshots } = await service.listSnapshots( + { profileRoot: "profiles/p1/" }, + owner, + ); + expect(snapshots.filter((s) => s.kind === "auto")).toHaveLength( + SyncService.SNAPSHOTS_PER_KIND, + ); + expect(snapshots.filter((s) => s.kind === "manual")).toHaveLength(1); + }); + + it("refuses a launcher and a root that is not one profile", async () => { + const { service, objects } = memoryService(); + objects.set(live("manifest.json"), "m"); + await expect( + refusalCode( + service.createSnapshot( + { profileRoot: "profiles/p1/" }, + { ...owner, teamRole: "launcher" }, + ), + ), + ).resolves.toBe("TEAM_ROLE_READ_ONLY"); + for (const profileRoot of ["profiles/", "proxies/x/", "profiles/p1"]) { + await expect( + service.createSnapshot({ profileRoot }, owner), + ).rejects.toThrow(); + } + }); +}); diff --git a/donut-sync/src/sync/sync.service.ts b/donut-sync/src/sync/sync.service.ts index 34301749..e0426582 100644 --- a/donut-sync/src/sync/sync.service.ts +++ b/donut-sync/src/sync/sync.service.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { + CopyObjectCommand, CreateBucketCommand, DeleteObjectCommand, DeleteObjectsCommand, @@ -17,6 +18,7 @@ import { ForbiddenException, Injectable, Logger, + NotFoundException, type OnModuleInit, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; @@ -38,6 +40,13 @@ import type { PresignUploadBatchResponseDto, PresignUploadRequestDto, PresignUploadResponseDto, + SnapshotCreateRequestDto, + SnapshotDto, + SnapshotKind, + SnapshotListRequestDto, + SnapshotListResponseDto, + SnapshotRequestDto, + SnapshotRestoreResponseDto, StatRequestDto, StatResponseDto, SubscribeEventDto, @@ -88,8 +97,13 @@ function sanitizeMetadata( /** The `group-id` a profile without a group carries. */ export const UNGROUPED = "__ungrouped__"; const GROUP_ID = /^[A-Za-z0-9_-]{1,128}$/; -/** A profile's objects, relative to the namespace: its root and its id. */ -const PROFILE_KEY = /^((?:teams\/[^/]+\/)?profiles\/([^/]+)\/)/; +/** A profile's objects, relative to the namespace: its root and its id. A + * restore point (`snapshots/`) belongs to the profile it copies. */ +const PROFILE_KEY = /^(?:snapshots\/)?((?:teams\/[^/]+\/)?profiles\/([^/]+)\/)/; +/** A profile root as the restore-point routes take it. */ +const PROFILE_ROOT = /^(?:teams\/[^/]+\/)?profiles\/[^/]+\/$/; +/** A restore point's id: its UTC time, then a short random part. */ +const SNAPSHOT_ID = /^\d{8}T\d{6}Z-[0-9a-f]{4}$/; /** A group's config object, relative to the namespace. */ const GROUP_KEY = /^(?:teams\/[^/]+\/)?groups\/([^/]+)\.json$/; /** How long a profile's group is trusted before it is read again. */ @@ -848,6 +862,251 @@ export class SyncService implements OnModuleInit { return { items }; } + // --- Restore points -------------------------------------------------------- + + /** How many restore points of each kind a profile keeps. */ + static readonly SNAPSHOTS_PER_KIND = 3; + /** Objects copied at a time. */ + private static readonly COPY_CONCURRENCY = 8; + + /** The checked profile root, relative to the namespace. */ + private snapshotRoot(raw: string): string { + if (typeof raw !== "string" || !PROFILE_ROOT.test(raw)) { + throw new BadRequestException({ code: "SNAPSHOT_INVALID" }); + } + return raw; + } + + private snapshotId(raw: string): string { + if (typeof raw !== "string" || !SNAPSHOT_ID.test(raw)) { + throw new BadRequestException({ code: "SNAPSHOT_INVALID" }); + } + return raw; + } + + /** Every object under the scoped `prefix`. */ + private async listAll( + prefix: string, + ): Promise<{ key: string; size: number }[]> { + const out: { key: string; size: number }[] = []; + let continuationToken: string | undefined; + do { + const page = await this.s3Client.send( + new ListObjectsV2Command({ + Bucket: this.bucket, + Prefix: prefix, + ContinuationToken: continuationToken, + }), + ); + for (const object of page.Contents ?? []) { + if (object.Key) out.push({ key: object.Key, size: object.Size ?? 0 }); + } + continuationToken = page.NextContinuationToken; + } while (continuationToken); + return out; + } + + /** Run `work` over `items`, a few at a time. */ + private async inBatches( + items: T[], + work: (item: T) => Promise, + ): Promise { + for (let i = 0; i < items.length; i += SyncService.COPY_CONCURRENCY) { + await Promise.all( + items.slice(i, i + SyncService.COPY_CONCURRENCY).map(work), + ); + } + } + + private async copy(from: string, to: string): Promise { + await this.s3Client.send( + new CopyObjectCommand({ + Bucket: this.bucket, + Key: to, + CopySource: `${this.bucket}/${from.split("/").map(encodeURIComponent).join("/")}`, + }), + ); + } + + private async readSnapshot(key: string): Promise { + try { + const object = await this.s3Client.send( + new GetObjectCommand({ Bucket: this.bucket, Key: key }), + ); + const text = await object.Body?.transformToString(); + return text ? (JSON.parse(text) as SnapshotDto) : null; + } catch { + return null; + } + } + + async listSnapshots( + dto: SnapshotListRequestDto, + ctx: UserContext, + ): Promise { + const root = this.snapshotRoot(dto.profileRoot); + const base = this.scopeKey(ctx, `snapshots/${root}`); + this.validateKeyAccess(ctx, base); + await this.assertReachable(ctx, this.scopeKey(ctx, `${root}manifest.json`)); + const objects = await this.listAll(base); + const records = objects.filter((object) => + object.key.endsWith("/snapshot.json"), + ); + const snapshots = ( + await Promise.all(records.map((record) => this.readSnapshot(record.key))) + ).filter((snapshot): snapshot is SnapshotDto => snapshot !== null); + snapshots.sort((a, b) => b.id.localeCompare(a.id)); + return { snapshots }; + } + + /** + * Copy a profile's files and manifest into a new restore point, inside the + * store (no byte leaves it), and keep the newest few of each kind. + */ + async createSnapshot( + dto: SnapshotCreateRequestDto, + ctx: UserContext, + ): Promise { + const root = this.snapshotRoot(dto.profileRoot); + const kind = dto.kind === "auto" ? "auto" : "manual"; + const live = this.scopeKey(ctx, root); + const base = this.scopeKey(ctx, `snapshots/${root}`); + this.validateWriteAccess(ctx, base); + await this.assertGroupWrite(ctx, live); + const objects = (await this.listAll(live)).filter((object) => { + const relative = object.key.slice(live.length); + return relative === "manifest.json" || relative.startsWith("files/"); + }); + if (!objects.some((object) => object.key === `${live}manifest.json`)) { + throw new BadRequestException({ code: "SNAPSHOT_NOTHING_TO_KEEP" }); + } + const now = new Date(); + const id = `${now + .toISOString() + .replace(/[-:]/g, "") + .replace(/\.\d+Z$/, "Z")}-${randomUUID().slice(0, 4)}`; + const target = `${base}${id}/`; + await this.inBatches(objects, (object) => + this.copy(object.key, `${target}${object.key.slice(live.length)}`), + ); + const snapshot: SnapshotDto = { + id, + createdAt: now.toISOString(), + kind, + files: objects.length - 1, + bytes: objects.reduce((sum, object) => sum + object.size, 0), + }; + await this.s3Client.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: `${target}snapshot.json`, + Body: JSON.stringify(snapshot), + ContentType: "application/json", + }), + ); + await this.pruneSnapshots(ctx, root, kind); + this.logger.log( + `Snapshot created kind=${kind} files=${snapshot.files} bytes=${snapshot.bytes}`, + ); + return snapshot; + } + + private async pruneSnapshots( + ctx: UserContext, + root: string, + kind: SnapshotKind, + ): Promise { + const { snapshots } = await this.listSnapshots({ profileRoot: root }, ctx); + const old = snapshots + .filter((snapshot) => snapshot.kind === kind) + .slice(SyncService.SNAPSHOTS_PER_KIND); + for (const snapshot of old) { + await this.removeSnapshot(ctx, root, snapshot.id); + } + } + + private async removeSnapshot( + ctx: UserContext, + root: string, + id: string, + ): Promise { + const prefix = this.scopeKey(ctx, `snapshots/${root}${id}/`); + const objects = await this.listAll(prefix); + for (let i = 0; i < objects.length; i += 1000) { + await this.s3Client.send( + new DeleteObjectsCommand({ + Bucket: this.bucket, + Delete: { + Objects: objects.slice(i, i + 1000).map((o) => ({ Key: o.key })), + Quiet: true, + }, + }), + ); + } + } + + async deleteSnapshot( + dto: SnapshotRequestDto, + ctx: UserContext, + ): Promise<{ deleted: boolean }> { + const root = this.snapshotRoot(dto.profileRoot); + const id = this.snapshotId(dto.id); + const base = this.scopeKey(ctx, `snapshots/${root}`); + this.validateWriteAccess(ctx, base); + await this.assertGroupWrite(ctx, this.scopeKey(ctx, root)); + await this.removeSnapshot(ctx, root, id); + return { deleted: true }; + } + + /** + * Put a restore point's files back in place of the live ones. The manifest + * is left to the app: it may be sealed, and the app must stamp it as the + * newest so every device takes the restored files. + */ + async restoreSnapshotFiles( + dto: SnapshotRequestDto, + ctx: UserContext, + ): Promise { + const root = this.snapshotRoot(dto.profileRoot); + const id = this.snapshotId(dto.id); + const live = this.scopeKey(ctx, root); + const source = this.scopeKey(ctx, `snapshots/${root}${id}/`); + this.validateWriteAccess(ctx, source); + await this.assertGroupWrite(ctx, live); + const kept = (await this.listAll(`${source}files/`)).map((object) => + object.key.slice(source.length), + ); + if ( + kept.length === 0 && + !(await this.readSnapshot(`${source}snapshot.json`)) + ) { + throw new NotFoundException({ code: "SNAPSHOT_NOT_FOUND" }); + } + const keptSet = new Set(kept); + const stale = (await this.listAll(`${live}files/`)).filter( + (object) => !keptSet.has(object.key.slice(live.length)), + ); + await this.inBatches(kept, (relative) => + this.copy(`${source}${relative}`, `${live}${relative}`), + ); + for (let i = 0; i < stale.length; i += 1000) { + await this.s3Client.send( + new DeleteObjectsCommand({ + Bucket: this.bucket, + Delete: { + Objects: stale.slice(i, i + 1000).map((o) => ({ Key: o.key })), + Quiet: true, + }, + }), + ); + } + void this.bumpManifest(ctx, `${live}manifest.json`); + this.logger.log( + `Snapshot restored restored=${kept.length} removed=${stale.length}`, + ); + return { restored: kept.length, removed: stale.length }; + } + async deletePrefix( dto: DeletePrefixRequestDto, ctx: UserContext, diff --git a/e2e/tests/sync.test.mjs b/e2e/tests/sync.test.mjs index d370fd00..f1921c95 100644 --- a/e2e/tests/sync.test.mjs +++ b/e2e/tests/sync.test.mjs @@ -563,6 +563,99 @@ test("the first sync of an empty profile publishes a manifest and records comple } }); +test("a restore point keeps a profile's files and puts them back on every device", async () => { + const app = appFromEnvironment("sync-restore-points"); + try { + await app.start(); + await configureSync(app); + const profile = await createProfile(app, "Restore Point Profile"); + const data = path.join( + app.dataRoot, + "data", + "profiles", + profile.id, + "profile", + "Default", + ); + await mkdir(data, { recursive: true }); + const preferences = path.join(data, "Preferences"); + await writeFile(preferences, "kept preferences"); + await app.invoke("set_profile_sync_mode", { + profileId: profile.id, + syncMode: "Regular", + }); + const synced = async () => + (await app.invoke("list_browser_profiles")).find( + (item) => item.id === profile.id, + )?.last_sync; + await app.invoke("request_profile_sync", { profileId: profile.id }); + await waitFor( + app, + async () => (await synced()) > 0, + "the first sync of the profile", + ); + + const point = await app.invoke("create_restore_point", { + profileId: profile.id, + }); + assert.equal(point.kind, "manual"); + assert.ok(point.files >= 1, JSON.stringify(point)); + const listed = await app.invoke("list_restore_points", { + profileId: profile.id, + }); + assert.ok( + listed.some((item) => item.id === point.id), + JSON.stringify(listed), + ); + + // A change after the point, synced up. + const before = await synced(); + await new Promise((resolve) => setTimeout(resolve, 1_100)); + await writeFile(preferences, "changed preferences"); + await app.invoke("request_profile_sync", { profileId: profile.id }); + await waitFor( + app, + async () => (await synced()) > before, + "the changed profile synced", + ); + + await app.invoke("restore_restore_point", { + profileId: profile.id, + id: point.id, + }); + assert.equal(await readFile(preferences, "utf8"), "kept preferences"); + const remoteManifest = JSON.parse( + (await downloadRemote(`profiles/${profile.id}/manifest.json`)).toString( + "utf8", + ), + ); + assert.ok( + remoteManifest.files.some((file) => file.path.endsWith("Preferences")), + ); + // The data the restore replaced is a restore point of its own. + const after = await app.invoke("list_restore_points", { + profileId: profile.id, + }); + assert.equal(after.length, listed.length + 1); + + await app.invoke("delete_restore_point", { + profileId: profile.id, + id: point.id, + }); + assert.equal( + (await app.invoke("list_restore_points", { profileId: profile.id })).some( + (item) => item.id === point.id, + ), + false, + ); + } catch (error) { + await app.capture("failure"); + throw error; + } finally { + await app.close(); + } +}); + test("global config sealing and encrypted profile sync reject a wrong password, round-trip with the right one, and roll over", async () => { const source = appFromEnvironment("sync-encrypted-source"); const receiver = appFromEnvironment("sync-encrypted-receiver"); diff --git a/src-tauri/src/sync/client.rs b/src-tauri/src/sync/client.rs index a7fe162d..35d9c446 100644 --- a/src-tauri/src/sync/client.rs +++ b/src-tauri/src/sync/client.rs @@ -416,4 +416,78 @@ impl SyncClient { .await .map_err(|e| SyncError::SerializationError(e.to_string())) } + + async fn post_json( + &self, + path: &str, + body: &B, + ) -> SyncResult { + 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> { + #[derive(serde::Deserialize)] + struct Listed { + snapshots: Vec, + } + 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 { + 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(()) + } } diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index bb22b019..5b33d26d 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -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> { + 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, diff --git a/src-tauri/src/sync/manifest.rs b/src-tauri/src/sync/manifest.rs index beb389a9..8f0f8dbb 100644 --- a/src-tauri/src/sync/manifest.rs +++ b/src-tauri/src/sync/manifest.rs @@ -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, diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index d858f72a..06f829b6 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -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; diff --git a/src-tauri/src/sync/restore_points.rs b/src-tauri/src/sync/restore_points.rs new file mode 100644 index 00000000..1e161fb8 --- /dev/null +++ b/src-tauri/src/sync/restore_points.rs @@ -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>> = 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::(&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> { + 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 { + 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, 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 { + 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")); + } +} diff --git a/src/components/restore-points.tsx b/src/components/restore-points.tsx new file mode 100644 index 00000000..e31ee24d --- /dev/null +++ b/src/components/restore-points.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"; +import { Button } from "@/components/ui/button"; +import { translateBackendError } from "@/lib/backend-errors"; +import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; + +interface RestorePoint { + id: string; + createdAt: string; + kind: "auto" | "manual"; + files: number; + bytes: number; +} + +function formatBytes(bytes: number, language: string): string { + const units = ["B", "KB", "MB", "GB"]; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value.toLocaleString(language, { maximumFractionDigits: 1 })} ${units[unit]}`; +} + +/** The copies the sync server keeps of a synced profile's data. */ +export function RestorePoints({ + profileId, + isDisabled, +}: { + profileId: string; + isDisabled: boolean; +}) { + const { t, i18n } = useTranslation(); + const [points, setPoints] = React.useState(null); + const [error, setError] = React.useState(null); + const [busy, setBusy] = React.useState(false); + const [restoring, setRestoring] = React.useState(null); + + const load = React.useCallback(async () => { + try { + setPoints( + await invoke("list_restore_points", { profileId }), + ); + setError(null); + } catch (err) { + setPoints(null); + setError(translateBackendError(t, err)); + } + }, [profileId, t]); + + React.useEffect(() => { + void load(); + }, [load]); + + const run = async (work: () => Promise, done: string) => { + setBusy(true); + try { + await work(); + showSuccessToast(done); + await load(); + } catch (err) { + showErrorToast(translateBackendError(t, err)); + } finally { + setBusy(false); + } + }; + + const when = (iso: string) => { + const date = new Date(iso); + return Number.isNaN(date.getTime()) + ? iso + : date.toLocaleString(i18n.language, { + dateStyle: "medium", + timeStyle: "short", + }); + }; + + return ( +
+
+

+ {t("restorePoints.title")} +

+ +
+

+ {t("restorePoints.hint")} +

+ {error &&

{error}

} + {points && points.length === 0 && ( +

+ {t("restorePoints.empty")} +

+ )} + {points && points.length > 0 && ( +
    + {points.map((point) => ( +
  • + + {when(point.createdAt)}{" "} + + {t(`restorePoints.kind.${point.kind}`)} ยท{" "} + {formatBytes(point.bytes, i18n.language)} + + + + + + +
  • + ))} +
+ )} + setRestoring(null)} + onConfirm={async () => { + const point = restoring; + setRestoring(null); + if (!point) return; + await run( + () => invoke("restore_restore_point", { profileId, id: point.id }), + t("restorePoints.restored"), + ); + }} + title={t("restorePoints.restoreTitle")} + description={t("restorePoints.restoreDescription", { + when: restoring ? when(restoring.createdAt) : "", + })} + confirmButtonText={t("restorePoints.restore")} + isLoading={busy} + /> +
+ ); +}