Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions apps/desktop/src/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { createKeyboardLayoutRouter } from "./keyboardLayout";
import { createMenuRouter } from "./menu";
import { createMigrationRouter } from "./migration";
import { createNotificationsRouter } from "./notifications";
import { createPageContentRouter } from "./page-content";
import { createPageRouter } from "./page";
import { createPermissionsRouter } from "./permissions";
import { createPluginsRouter } from "./plugins";
import { createPortForwardsRouter } from "./port-forwards";
Expand Down Expand Up @@ -51,7 +51,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => {
changes: createChangesRouter(),
filesystem: createFilesystemRouter(),
notifications: createNotificationsRouter(getWindow),
pageContent: createPageContentRouter(),
page: createPageRouter(),
permissions: createPermissionsRouter(),
plugins: createPluginsRouter(),
ports: createPortsRouter(),
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src/lib/trpc/routers/page-content/index.ts

This file was deleted.

1 change: 1 addition & 0 deletions apps/desktop/src/lib/trpc/routers/page/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { createPageRouter } from "./page";
10 changes: 10 additions & 0 deletions apps/desktop/src/lib/trpc/routers/page/page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { router } from "../..";
import { createPageContentRouter } from "./content";
import { createPageThumbnailRouter } from "./thumbnail";

export const createPageRouter = () => {
return router({
content: createPageContentRouter(),
thumbnail: createPageThumbnailRouter(),
});
};
21 changes: 21 additions & 0 deletions apps/desktop/src/lib/trpc/routers/page/thumbnail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ensureThumbnail, peekThumbnail } from "main/lib/pageThumbnails";
import { z } from "zod";
import { publicProcedure, router } from "../..";

const thumbnailKey = z.object({
accountId: z.string(),
pageId: z.string(),
version: z.string(),
});

export const createPageThumbnailRouter = () => {
Comment thread
harshithmullapudi marked this conversation as resolved.
Resolved
return router({
peek: publicProcedure
.input(thumbnailKey)
.query(({ input }) => peekThumbnail(input)),

ensure: publicProcedure
.input(thumbnailKey.extend({ html: z.string() }))
.mutation(({ input }) => ensureThumbnail(input)),
});
};
18 changes: 18 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ import { localDb } from "./lib/local-db";
import { requestLocalNetworkAccess } from "./lib/local-network-permission";
import { menuEmitter } from "./lib/menu-events";
import { PAGE_SCHEME, pageProtocolHandler } from "./lib/pageContent";
import {
THUMBNAIL_SCHEME,
thumbnailProtocolHandler,
} from "./lib/pageThumbnails";
import {
initTanstackDbPersistence,
shutdownTanstackDbPersistence,
Expand Down Expand Up @@ -382,6 +386,15 @@ protocol.registerSchemesAsPrivileged([
secure: true,
},
},
{
scheme: THUMBNAIL_SCHEME,
privileges: {
standard: true,
secure: true,
bypassCSP: true,
supportFetchAPI: true,
},
},
]);

const gotTheLock = app.requestSingleInstanceLock();
Expand Down Expand Up @@ -449,6 +462,11 @@ if (!gotTheLock) {
.fromPartition("persist:superset")
.protocol.handle(PAGE_SCHEME, pageProtocolHandler);

protocol.handle(THUMBNAIL_SCHEME, thumbnailProtocolHandler);
session
.fromPartition("persist:superset")
.protocol.handle(THUMBNAIL_SCHEME, thumbnailProtocolHandler);

// Serve system fonts (e.g. SF Mono on macOS) via custom protocol
// so the renderer can use @font-face with font-src 'self' CSP
if (process.platform === "darwin") {
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/main/lib/pageThumbnails/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export {
ensureThumbnail,
peekThumbnail,
THUMBNAIL_SCHEME,
type ThumbnailKey,
thumbnailProtocolHandler,
thumbnailUrl,
} from "./pageThumbnails";
254 changes: 254 additions & 0 deletions apps/desktop/src/main/lib/pageThumbnails/pageThumbnails.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
import { mkdir, rm, stat, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

const USER_DATA = join(tmpdir(), "superset-test");

mock.module("electron", () => ({
app: {
getPath: () => USER_DATA,
getName: () => "test-app",
getVersion: () => "1.0.0",
getAppPath: () => USER_DATA,
isPackaged: false,
},
BrowserWindow: mock(() => ({
webContents: { send: mock(), setAudioMuted: mock() },
loadURL: mock(),
isDestroyed: () => false,
destroy: mock(),
on: mock(),
})),
session: {
fromPartition: () => ({
protocol: { handle: mock(), isProtocolHandled: () => false },
}),
},
protocol: {
handle: mock(),
isProtocolHandled: () => false,
registerSchemesAsPrivileged: mock(),
},
dialog: {
showMessageBox: mock(),
showOpenDialog: mock(),
showSaveDialog: mock(),
},
ipcMain: { handle: mock(), on: mock() },
webContents: { fromId: () => null },
shell: { openExternal: mock(), openPath: mock() },
clipboard: { writeText: mock(), readText: () => "", writeImage: mock() },
Menu: {
buildFromTemplate: mock(() => ({ popup: mock() })),
setApplicationMenu: mock(),
},
Notification: mock(() => ({ show: mock(), on: mock() })),
}));

const {
ensureThumbnail,
peekThumbnail,
THUMBNAIL_SCHEME,
thumbnailProtocolHandler,
thumbnailUrl,
} = await import("./pageThumbnails");

const CACHE_DIR = join(USER_DATA, "page-thumbnails");

const ACCOUNT_A = "11111111-1111-1111-1111-111111111111";
const ACCOUNT_B = "22222222-2222-2222-2222-222222222222";
const PAGE = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";

const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xdb, 0x00, 0x01]);

function pathFor(accountId: string, pageId: string, version: string): string {
return join(CACHE_DIR, accountId, `${pageId}-${version}.jpg`);
}

async function seed(
accountId: string,
pageId: string,
version: string,
): Promise<string> {
const path = pathFor(accountId, pageId, version);
await mkdir(join(CACHE_DIR, accountId), { recursive: true });
await writeFile(path, JPEG_BYTES);
return path;
}

function serve(url: string): Promise<Response> {
return thumbnailProtocolHandler(new Request(url));
}

beforeEach(async () => {
await rm(CACHE_DIR, { recursive: true, force: true });
});

describe("thumbnailUrl", () => {
test("scopes the url to the account", () => {
expect(
thumbnailUrl({ accountId: ACCOUNT_A, pageId: PAGE, version: "3" }),
).toBe(`${THUMBNAIL_SCHEME}://${ACCOUNT_A}/${PAGE}/3`);
});

test("distinguishes versions of the same page", () => {
const v3 = thumbnailUrl({
accountId: ACCOUNT_A,
pageId: PAGE,
version: "3",
});
const v4 = thumbnailUrl({
accountId: ACCOUNT_A,
pageId: PAGE,
version: "4",
});
expect(v3).not.toBe(v4);
});
});

describe("peekThumbnail", () => {
test("returns null when nothing is cached", async () => {
await expect(
peekThumbnail({ accountId: ACCOUNT_A, pageId: PAGE, version: "1" }),
).resolves.toBeNull();
});

test("returns the url once a thumbnail exists", async () => {
await seed(ACCOUNT_A, PAGE, "1");
await expect(
peekThumbnail({ accountId: ACCOUNT_A, pageId: PAGE, version: "1" }),
).resolves.toBe(
thumbnailUrl({ accountId: ACCOUNT_A, pageId: PAGE, version: "1" }),
);
});

test("does not surface another account's thumbnail", async () => {
await seed(ACCOUNT_A, PAGE, "1");
await expect(
peekThumbnail({ accountId: ACCOUNT_B, pageId: PAGE, version: "1" }),
).resolves.toBeNull();
});

test("misses a version that has not been captured", async () => {
await seed(ACCOUNT_A, PAGE, "3");
await expect(
peekThumbnail({ accountId: ACCOUNT_A, pageId: PAGE, version: "4" }),
).resolves.toBeNull();
});

test("cannot escape the account directory via the page id", async () => {
await seed(ACCOUNT_B, PAGE, "1");
await expect(
peekThumbnail({
accountId: ACCOUNT_A,
pageId: `../${ACCOUNT_B}/${PAGE}`,
version: "1",
}),
).resolves.toBeNull();
});

test("cannot escape the account directory via the account id", async () => {
await seed(ACCOUNT_B, PAGE, "1");
await expect(
peekThumbnail({
accountId: `../page-thumbnails/${ACCOUNT_B}`,
pageId: PAGE,
version: "1",
}),
).resolves.toBeNull();
});

test("rejects a version that is not a number", async () => {
await seed(ACCOUNT_A, PAGE, "1");
await expect(
peekThumbnail({ accountId: ACCOUNT_A, pageId: PAGE, version: "1.jpg" }),
).resolves.toBeNull();
});
});

describe("thumbnailProtocolHandler", () => {
test("serves a cached thumbnail as an immutable jpeg", async () => {
await seed(ACCOUNT_A, PAGE, "1");
const response = await serve(
`${THUMBNAIL_SCHEME}://${ACCOUNT_A}/${PAGE}/1`,
);

expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("image/jpeg");
expect(response.headers.get("Cache-Control")).toContain("immutable");
expect(new Uint8Array(await response.arrayBuffer())).toEqual(
new Uint8Array(JPEG_BYTES),
);
});

test("404s when the thumbnail is absent", async () => {
const response = await serve(
`${THUMBNAIL_SCHEME}://${ACCOUNT_A}/${PAGE}/1`,
);
expect(response.status).toBe(404);
});

test("will not serve one account's thumbnail to another", async () => {
await seed(ACCOUNT_A, PAGE, "1");
const response = await serve(
`${THUMBNAIL_SCHEME}://${ACCOUNT_B}/${PAGE}/1`,
);
expect(response.status).toBe(404);
});

test("refuses segments that are not plain identifiers", async () => {
await seed(ACCOUNT_A, PAGE, "1");
const dotted = await serve(
`${THUMBNAIL_SCHEME}://${ACCOUNT_A}/..secrets/1`,
);
const encoded = await serve(
`${THUMBNAIL_SCHEME}://${ACCOUNT_A}/%2e%2e%2fsecrets/1`,
);
expect(dotted.status).toBe(404);
expect(encoded.status).toBe(404);
});

test("records access so eviction can order by recency", async () => {
const path = await seed(ACCOUNT_A, PAGE, "1");
const stale = new Date(Date.now() - 60_000);
await utimes(path, stale, stale);
const before = (await stat(path)).mtimeMs;

const response = await serve(
`${THUMBNAIL_SCHEME}://${ACCOUNT_A}/${PAGE}/1`,
);
expect(response.status).toBe(200);

expect((await stat(path)).mtimeMs).toBeGreaterThan(before);
});
});

describe("ensureThumbnail", () => {
test("returns the cached url without attempting a capture", async () => {
await seed(ACCOUNT_A, PAGE, "7");

await expect(
ensureThumbnail({
accountId: ACCOUNT_A,
pageId: PAGE,
version: "7",
html: "<p>never rendered</p>",
}),
).resolves.toBe(
thumbnailUrl({ accountId: ACCOUNT_A, pageId: PAGE, version: "7" }),
);
});

test("refuses a key that would read outside the account directory", async () => {
await seed(ACCOUNT_B, PAGE, "1");
await expect(
ensureThumbnail({
accountId: ACCOUNT_A,
pageId: `../${ACCOUNT_B}/${PAGE}`,
version: "1",
html: "<p>hi</p>",
}),
).rejects.toThrow("Invalid thumbnail key");
});
});
Loading
Loading