diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index 4b1a56e8..b5260fed 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -1508,6 +1508,18 @@ Acceptance: - Payment history lists past invoices with amount and billing period, most recent first. +### US-122 — Export and import a hosted relay's data + +As a space owner, I want to take a copy of everything my relay holds and put +events back, so that I can keep a backup and move between hosts. + +Acceptance: + +- "Import / export data" in the relay actions menu opens a Relay data dialog, + whose "Download events" downloads the relay's events as `.jsonl`. +- Importing a file reports how many events were stored and names the lines the + relay refused. + ## Notifications & navigation ### US-103 — See and clear unread indicators diff --git a/e2e/harness/net/http.ts b/e2e/harness/net/http.ts index 4ad6ff40..6e7be392 100644 --- a/e2e/harness/net/http.ts +++ b/e2e/harness/net/http.ts @@ -452,7 +452,9 @@ export const mockHosting = async (context: BrowserContext, fixtures: HostingFixt await context.route(`${HOSTING_ORIGIN}/**`, route => { const request = route.request() const method = request.method() - const body: HostingRecord = request.postDataJSON() ?? {} + // An import posts JSONL, which postDataJSON() throws on. Every other write posts json. + const isJson = request.headers()["content-type"] === "application/json" + const body: HostingRecord = (isJson && request.postDataJSON()) || {} const [resource, id, sub, detail] = new URL(request.url()).pathname.split("/").filter(Boolean) const missing = (what: string) => route.fulfill({status: 404, json: {error: `No such ${what}`}}) @@ -528,6 +530,30 @@ export const mockHosting = async (context: BrowserContext, fixtures: HostingFixt return route.fulfill({json: {data: {activity: relay.activity ?? []}}}) } + // The dump is whatever the fixture left on the record, served as the download itself rather + // than in a json envelope. + if (sub === "export") { + return route.fulfill({ + contentType: "application/x-ndjson", + body: String(relay.events ?? ""), + }) + } + + // Storing events is the relay's own business, so the fake counts the lines and refuses the + // ones that don't look signed, which is the shape zooid reports problems in. + if (sub === "import") { + const lines = (request.postData() ?? "").split("\n").filter(line => line.trim()) + const problems = lines.flatMap((line, index) => + line.includes(`"sig"`) ? [] : [{line: index + 1, event_id: "", message: "invalid event"}], + ) + + return route.fulfill({ + json: { + data: {imported: lines.length - problems.length, invalid: problems.length, problems}, + }, + }) + } + const updated = {...relay, ...(method === "PUT" ? body : {})} if (sub === "deactivate") { diff --git a/e2e/specs/admin.spec.ts b/e2e/specs/admin.spec.ts index 1a153eac..0cf90116 100644 --- a/e2e/specs/admin.spec.ts +++ b/e2e/specs/admin.spec.ts @@ -1101,3 +1101,40 @@ test("US-102 pause a relay and settle the bill", async ({seed, as}) => { await expect(invoices.nth(1)).toContainText("$5.00") await expect(invoices.nth(1)).toContainText(await period(paidPeriod)) }) + +test("US-122 export and import a hosted relay's data", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + }) + + const {url} = scenario.space("space") + const dump = '{"id":"a","sig":"a"}\n{"id":"b","sig":"b"}\n' + const page = await as(users.alice, spacePath(url) + "/admin", { + hosting: {plans: PLANS, relays: [hostedRelay({events: dump})]}, + }) + + await page.getByRole("button", {name: "Relay actions"}).click() + await menuItem(page, "Import / export data").click() + + const modal = dialog(page, "Relay data") + const download = page.waitForEvent("download") + + await modal.getByRole("button", {name: "Download events"}).click() + + expect((await download).suggestedFilename()).toBe("space.jsonl") + + await modal.locator("input[type=file]").setInputFiles({ + name: "events.jsonl", + mimeType: "application/x-ndjson", + buffer: Buffer.from('{"id":"c","sig":"c"}\nnot an event\n'), + }) + + await modal.getByRole("button", {name: "Import events"}).click() + + // The relay keeps what it could verify and says which line it refused + await expect(modal.getByText("Imported 1 event, skipped 1.")).toBeVisible() + await expect(modal.getByText("line 2: invalid event")).toBeVisible() +}) diff --git a/src/app/components/hosting/DataTransferModal.svelte b/src/app/components/hosting/DataTransferModal.svelte new file mode 100644 index 00000000..fd59f7cc --- /dev/null +++ b/src/app/components/hosting/DataTransferModal.svelte @@ -0,0 +1,154 @@ + + + + + + Relay data + + Everything {relayLabel} holds, as one event per line. Uploaded media is not included. + + + +
+

Export

+

+ Download a copy of every event on the relay, for a backup or a move to another host. +

+ +
+ + + +
+

Import

+

+ Add the events in a file to the relay. Each one carries its own signature, so a tampered or + unsigned line is skipped and reported, and importing the same file twice changes nothing. +

+ +
+ + {#if file} + {file.name} + {/if} +
+ +
+ + {#if result} +
+

+ Imported {result.imported} + {result.imported === 1 ? "event" : "events"}{result.invalid > 0 + ? `, skipped ${result.invalid}` + : ""}. +

+ {#each result.problems?.slice(0, 5) || [] as problem (problem.line)} +

+ {problem.line ? `line ${problem.line}: ` : ""}{problem.message} +

+ {/each} +
+ {/if} +
+ + + +
diff --git a/src/app/components/hosting/RelayDetailCard.svelte b/src/app/components/hosting/RelayDetailCard.svelte index 6e6293e7..eb371b38 100644 --- a/src/app/components/hosting/RelayDetailCard.svelte +++ b/src/app/components/hosting/RelayDetailCard.svelte @@ -12,6 +12,7 @@ import Copy from "@assets/icons/copy.svg?dataurl" import Refresh from "@assets/icons/refresh.svg?dataurl" import BillList from "@assets/icons/bill-list.svg?dataurl" + import Database from "@assets/icons/database.svg?dataurl" import {fly} from "@lib/transition" import {ucFirst} from "@lib/util" import Icon from "@lib/components/Icon.svelte" @@ -26,6 +27,7 @@ import ToggleInput from "@lib/components/ToggleInput.svelte" import Confirm from "@lib/components/Confirm.svelte" import CustomDomainModal from "@app/components/hosting/CustomDomainModal.svelte" + import DataTransferModal from "@app/components/hosting/DataTransferModal.svelte" import PlanModal from "@app/components/hosting/PlanModal.svelte" import {roomLists} from "@app/core" import RelayForm from "@app/components/hosting/RelayForm.svelte" @@ -139,6 +141,10 @@ }) } + const openDataTransfer = () => { + pushModal(DataTransferModal, {relay: current}) + } + const openCustomDomain = () => { pushModal(CustomDomainModal, {relay: current, onUpdate: setCurrentAndFollowHost}) } @@ -268,6 +274,12 @@ Change plan +
  • + +
  • {#if current.status === "active"}