feat: restore points

This commit is contained in:
zhom committed 2026-10-12 21:00:12 +04:00
1 parent 8f3f8afd2c
commit cb0c31f9a4
11 files changed
+1172 -22

No files matched your search

+35
View File
@@ -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/<id>/` or `teams/<team>/profiles/<id>/`. */
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;
}
+42
View File
@@ -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<SnapshotListResponseDto> {
return this.syncService.listSnapshots(dto, this.getUserContext(req));
}
@Post("snapshots/create")
@HttpCode(200)
async createSnapshot(
@Body() dto: SnapshotCreateRequestDto,
@Req() req: Request,
): Promise<SnapshotDto> {
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<SnapshotRestoreResponseDto> {
return this.syncService.restoreSnapshotFiles(dto, this.getUserContext(req));
}
@Get("subscribe")
@Sse()
subscribe(@Req() req: Request): Observable<MessageEvent> {
+148
View File
@@ -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<string, string>;
} {
const { service, send } = serviceWithStubbedS3();
const objects = new Map<string, string>();
send.mockImplementation(
async (command: { input: Record<string, unknown> }) => {
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();
}
});
});
+261 -2
View File
@@ -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/<root>`) 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<T>(
items: T[],
work: (item: T) => Promise<void>,
): Promise<void> {
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<void> {
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<SnapshotDto | null> {
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<SnapshotListResponseDto> {
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<SnapshotDto> {
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<void> {
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<void> {
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<SnapshotRestoreResponseDto> {
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,
+93
View File
@@ -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");
+74
View File
@@ -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(())
}
}
+27 -19
View File
@@ -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,
+4 -1
View File
@@ -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,
+1
View File
@@ -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;
+300
View File
@@ -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"));
}
}
+187
View File
@@ -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<RestorePoint[] | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [busy, setBusy] = React.useState(false);
const [restoring, setRestoring] = React.useState<RestorePoint | null>(null);
const load = React.useCallback(async () => {
try {
setPoints(
await invoke<RestorePoint[]>("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<unknown>, 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 (
<div
className="rounded-md border border-border px-3 py-2"
data-slot="restore-points"
>
<div className="flex items-center justify-between gap-2">
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("restorePoints.title")}
</p>
<Button
variant="soft"
size="sm"
className="h-7"
disabled={isDisabled || busy || points === null}
onClick={() =>
void run(
() => invoke("create_restore_point", { profileId }),
t("restorePoints.created"),
)
}
data-slot="restore-point-create"
>
{t("restorePoints.create")}
</Button>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("restorePoints.hint")}
</p>
{error && <p className="mt-1 text-xs text-destructive-text">{error}</p>}
{points && points.length === 0 && (
<p className="mt-1 text-xs text-muted-foreground">
{t("restorePoints.empty")}
</p>
)}
{points && points.length > 0 && (
<ul className="mt-1 flex flex-col">
{points.map((point) => (
<li
key={point.id}
className="row-reveal flex items-center justify-between gap-2 rounded-md px-1 py-1 text-sm hover:bg-foreground/5"
data-point={point.id}
>
<span className="min-w-0 truncate">
{when(point.createdAt)}{" "}
<span className="text-xs text-muted-foreground">
{t(`restorePoints.kind.${point.kind}`)} ·{" "}
{formatBytes(point.bytes, i18n.language)}
</span>
</span>
<span className="flex shrink-0 gap-1">
<Button
variant="subtle"
size="sm"
className="h-7"
disabled={isDisabled || busy}
onClick={() => setRestoring(point)}
data-slot="restore-point-restore"
>
{t("restorePoints.restore")}
</Button>
<Button
variant="subtle"
size="sm"
className="h-7"
disabled={isDisabled || busy}
onClick={() =>
void run(
() =>
invoke("delete_restore_point", {
profileId,
id: point.id,
}),
t("restorePoints.deleted"),
)
}
>
{t("common.buttons.delete")}
</Button>
</span>
</li>
))}
</ul>
)}
<DeleteConfirmationDialog
isOpen={restoring !== null}
onClose={() => 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}
/>
</div>
);
}