mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-19 14:07:19 +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
@@ -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;
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user