Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) dev.fast. All rights reserved.
* Licensed under the MIT License. See LICENSE in the repository root for license information.
*--------------------------------------------------------------------------------------------*/

import assert from "node:assert/strict";
import test from "node:test";

import { shareUrlFromDeepLink } from "./reviewShareLink.js";

test("a share deep link keeps its origin and revision", () => {
const t = "a".repeat(43),
rev = "5b0e9a52-4a43-4f7e-9d8e-3c1f0e7b2a11";
assert.equal(
shareUrlFromDeepLink(`/${t}`, `origin=${encodeURIComponent("https://stg.dev.fast")}&rev=${rev}`),
`https://stg.dev.fast/s/${t}?rev=${rev}`,
);
assert.equal(shareUrlFromDeepLink(`/${t}`, ""), `https://app.dev.fast/s/${t}`);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) dev.fast. All rights reserved.
* Licensed under the MIT License. See LICENSE in the repository root for license information.
*--------------------------------------------------------------------------------------------*/

export function shareUrlFromDeepLink(path: string, query: string): string {
const params = new URLSearchParams(query);
const url = new URL(`/s${path}`, params.get("origin") ?? "https://app.dev.fast");
const rev = params.get("rev");
if (rev) url.searchParams.set("rev", rev);
return url.href;
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
WorkbenchPhase,
} from "../../../workbench/common/contributions.js";
import { ReviewApiClient } from "../../common/reviewProtocol.js";
import { shareUrlFromDeepLink } from "../../common/reviewShareLink.js";
import { IReviewCanvasEditorTabsService } from "../../services/reviewCanvasEditorTabsService.js";
import { IReviewDesktopConnectionService } from "../../services/reviewDesktopConnectionService.js";

Expand Down Expand Up @@ -81,8 +82,7 @@ class SharedReviewUrlHandler extends Disposable implements IURLHandler {
async handleURL(uri: URI): Promise<boolean> {
if (uri.authority !== "share") return false;
try {
const origin = new URLSearchParams(uri.query).get("origin") ?? "https://app.dev.fast";
await openShare({ url: new URL(`/s${uri.path}`, origin).href }, this.session, this.tabs, this.notifications, this.progress);
await openShare({ url: shareUrlFromDeepLink(uri.path, uri.query) }, this.session, this.tabs, this.notifications, this.progress);
} catch {
this.notifications.error("Invalid Whiteboard share link.");
}
Expand Down
147 changes: 147 additions & 0 deletions apps/review-desktop/scripts/e2e/fixtures/share-service.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/** A share service for journeys: one link per board, a revision per newer version. */
import { randomBytes, randomUUID } from "node:crypto";
import { createServer } from "node:http";
import { createServer as createSecureServer } from "node:https";

export async function startShareService({ tls } = {}) {
const posts = [];
// Board id to { shareId, capability, revisions: [{ id, version, size, createdAt, bundle }] }, newest revision first.
const links = new Map();

const server = tls
? createSecureServer(
tls,
(request, response) => void handle(request, response),
)
: createServer((request, response) => void handle(request, response));

await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
server.unref();

const origin = `${tls ? "https" : "http"}://localhost:${server.address().port}`;

const linkUrl = (link, rev) =>
`${origin}/s/${link.capability}${rev ? `?rev=${rev}` : ""}`;

function publish(reviewId, bundle) {
let link = links.get(reviewId);

if (!link) {
link = {
shareId: randomUUID(),
capability: randomBytes(32).toString("base64url"),
revisions: [],
};
links.set(reviewId, link);
}

if (!link.revisions.length || bundle.version > link.revisions[0].version)
link.revisions.unshift({
id: randomUUID(),
version: bundle.version,
size: Buffer.byteLength(JSON.stringify(bundle)),
createdAt: Date.now(),
bundle,
});

return { shareId: link.shareId, url: linkUrl(link) };
}

const byShareId = (id) =>
[...links.values()].find((link) => link.shareId === id);

const byCapability = (cap) =>
[...links.values()].find((link) => link.capability === cap);

async function handle(request, response) {
const url = new URL(request.url, origin);

const send = (status, body) => {
response.writeHead(status, { "content-type": "application/json" });
response.end(JSON.stringify(body ?? { error: "not found" }));
};

let body = "";

for await (const chunk of request) body += chunk;

const parts = url.pathname.split("/").slice(1);
const route = `${request.method} /${parts.slice(0, 3).join("/")}`;

if (route === "GET /api/auth/token") return send(404);

if (url.pathname === "/api/v2/shares" && request.method === "GET")
return send(200, []);

if (url.pathname === "/api/v2/shares" && request.method === "POST") {
const input = JSON.parse(body);

posts.push(input);

return send(200, publish(input.bundle.reviewId, input.bundle));
}

if (route === "GET /api/v2/shares" && parts[4] === "revisions") {
const link = byShareId(parts[3]);

return link
? send(200, {
latest: link.revisions[0].id,
items: link.revisions.map(({ bundle: _, ...revision }) => ({
...revision,
url: linkUrl(link, revision.id),
})),
})
: send(404);
}

if (route === "DELETE /api/v2/shares") {
const link = byShareId(parts[3]);

if (!link) return send(404);

for (const [key, value] of links) if (value === link) links.delete(key);

return send(200, { shareId: link.shareId, revoked: true });
}

if (route === "GET /api/v2/shared") {
const link = byCapability(parts[3]);
const rev = url.searchParams.get("rev");

const revision = rev
? link?.revisions.find((candidate) => candidate.id === rev)
: link?.revisions[0];

if (!revision) return send(404);

if (parts[4] === "bundle") return send(200, revision.bundle);

return send(200, {
sender: { login: "e2e" },
sharedAt: revision.createdAt,
size: revision.size,
revision: {
id: revision.id,
version: revision.version,
createdAt: revision.createdAt,
},
latest: link.revisions[0].id,
});
}

send(404);
}

return {
origin,
posts,
links,
publish,
stop: () =>
new Promise((resolve) => {
server.closeAllConnections();
server.close(() => resolve());
}),
};
}
96 changes: 96 additions & 0 deletions apps/review-desktop/scripts/e2e/journeys/share-link.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/** A board shared, edited and shared again keeps one link, with a revision per version. */
import assert from "node:assert/strict";
import { writeFile } from "node:fs/promises";
import path from "node:path";

import { startShareService } from "../fixtures/share-service.mjs";
import { createReview, sleep } from "../harness.mjs";

export const name = "share-link";

export const phase = 1;

export const options = {
async beforeLaunch(ctx) {
ctx.service = await startShareService();
await writeFile(
path.join(ctx.home, "auth.json"),
JSON.stringify({
origin: ctx.service.origin,
token: "e2e",
login: "e2e",
savedAt: new Date().toISOString(),
}),
);
},
};

export async function run(ctx) {
const { service } = ctx;

try {
const { reviewId, canvas } = await createReview(ctx, {
title: "Shared link",
blocks: [{ type: "markdown", markdown: "First" }],
});

const page = canvas.page();
const button = page.getByRole("button", { name: "Share review" });
const input = page.getByLabel("Share link");

await button.click();
await input.waitFor();

const u1 = await input.inputValue();

assert.equal(service.posts.length, 1);
await page
.getByText("This link always opens the latest share of this board.")
.waitFor();
ctx.check("the first share uploads once and shows its link");
await page.keyboard.press("Escape");
await input.waitFor({ state: "detached" });

await ctx.apiOk("/reviews-api/commands", "POST", {
operation: {
type: "edit",
reviewId,
edit: {
type: "insert",
content: { type: "markdown", markdown: "Second" },
},
},
});
await canvas.getByText("Second", { exact: true }).waitFor();
await button.click();
await ctx.until(
async () =>
service.posts.length === 2 &&
(await input.inputValue().catch(() => "")) === u1,
"the reshared link",
);
ctx.check("an edited board uploads again behind the same link");

const { shareId } = service.links.get(reviewId);

const revisions = await (
await fetch(`${service.origin}/api/v2/shares/${shareId}/revisions`)
).json();

assert.equal(revisions.items.length, 2);
assert.ok(revisions.items[0].version > revisions.items[1].version);
assert.equal(revisions.latest, revisions.items[0].id);
ctx.check("the link holds two revisions, newest first");

await page.keyboard.press("Escape");
await input.waitFor({ state: "detached" });
await button.click();
await input.waitFor();
await sleep(2000);
assert.equal(service.posts.length, 2);
assert.equal(await input.inputValue(), u1);
ctx.check("reopening an unchanged board uploads nothing");
} finally {
await service.stop();
}
}
Loading
Loading