Skip to content

Commit

Permalink
feat: replace electron-serve
Browse files Browse the repository at this point in the history
  • Loading branch information
KatoakDR committed Feb 25, 2024
1 parent 7ccc66a commit 0a8ccbf
Show file tree
Hide file tree
Showing 8 changed files with 223 additions and 9 deletions.
10 changes: 5 additions & 5 deletions electron/main/app.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Event } from 'electron';
import { BrowserWindow, app, dialog, shell } from 'electron';
import path from 'node:path';
import prodServe from 'electron-serve';
import trimEnd from 'lodash-es/trimEnd.js';
import { runInBackground } from './async/run-in-background.js';
import type { IpcController } from './ipc/ipc.controller.js';
Expand Down Expand Up @@ -41,7 +40,8 @@ const appPreloadPath = path.join(appBuildPath, 'preload');
// When running in production, serve the app from these paths.
const prodRendererPath = path.join(appBuildPath, 'renderer');
const prodAppScheme = 'app';
const prodAppUrl = `${prodAppScheme}://-`;
const prodAppHost = '-'; // arbitrary, mimicking electron-serve module
const prodAppUrl = `${prodAppScheme}://${prodAppHost}`;

// When running in development, serve the app from these paths.
const devRendererPath = path.join(appElectronPath, 'renderer');
Expand All @@ -63,11 +63,11 @@ logger.debug('app paths', {
// Registering the protocol must be done before the app is ready.
// This is necessary for both security and for single-page apps.
// https://bishopfox.com/blog/reasonably-secure-electron
// https://github.com/sindresorhus/electron-serve
if (appEnvIsProd) {
const { prodServe } = await import('./electron-next/prod-server.js');
prodServe({
scheme: prodAppScheme,
directory: prodRendererPath,
dirPath: prodRendererPath,
});
}

Expand All @@ -83,7 +83,7 @@ const createMainWindow = async (): Promise<void> => {
const { devServe } = await import('./electron-next/dev-server.js');
await devServe({
port: devPort,
directory: devRendererPath,
dirPath: devRendererPath,
});
}

Expand Down
62 changes: 62 additions & 0 deletions electron/main/electron-next/__tests__/is-safe-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { isSafePath } from '../is-safe-path.js';

describe('is-safe-path', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});

afterEach(() => {
vi.clearAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});

describe('#isSafePath', () => {
it('returns true when file path is within the directory', async () => {
expect(
isSafePath({
dirPath: '/a/b/',
filePath: '/a/b/c.html',
})
).toBe(true);

expect(
isSafePath({
dirPath: '/a/b/',
filePath: '/a/b/c/d.html',
})
).toBe(true);
});

it('returns false when file path is outside the directory', async () => {
expect(
isSafePath({
dirPath: '/a/b/',
filePath: '',
})
).toBe(false);

expect(
isSafePath({
dirPath: '/a/b/',
filePath: '/a/b/',
})
).toBe(false);

expect(
isSafePath({
dirPath: '/a/b/',
filePath: './a/b/c.html',
})
).toBe(false);

expect(
isSafePath({
dirPath: '/a/b/',
filePath: '../a/b/c.html',
})
).toBe(false);
});
});
});
25 changes: 25 additions & 0 deletions electron/main/electron-next/__tests__/path-to-file-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { pathToFileURL } from '../path-to-file-url.js';

describe('path-to-file-url', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});

afterEach(() => {
vi.clearAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});

describe('#pathToFileURL', () => {
it('...', async () => {
expect(
pathToFileURL({
dirPath: '/a/b/',
filePath: '/a/b/c.html',
})
).toBe(true);
});
});
});
3 changes: 3 additions & 0 deletions electron/main/electron-next/__tests__/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.test.json"
}
8 changes: 4 additions & 4 deletions electron/main/electron-next/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,16 @@ export const devServe = async (options: {
/**
* The directory to serve, relative to the app root directory.
*/
directory: string;
dirPath: string;
/**
* The port to serve the renderer on.
*/
port: number;
}): Promise<void> => {
const { directory, port = 3000 } = options;
const { dirPath, port = 3000 } = options;

logger.info('starting nextjs dev server', {
directory,
dirPath,
port,
});

Expand All @@ -38,7 +38,7 @@ export const devServe = async (options: {
options: NextServerOptions
) => NextServer;

const nextServer = createNextServer({ dev: true, dir: directory });
const nextServer = createNextServer({ dev: true, dir: dirPath });

const requestHandler = nextServer.getRequestHandler();

Expand Down
30 changes: 30 additions & 0 deletions electron/main/electron-next/is-safe-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import path from 'node:path';

/**
* Determine if a file path is safe to serve by
* ensuring that it is within the directory path.
* Protects against directory traversal attacks.
*/
export const isSafePath = (options: {
/**
* Known safe directory to host files from.
* Example: '/path/to/directory'
*/
dirPath: string;
/**
* A file path to verify is within the directory.
* Example: '/path/to/directory/file.txt'
*/
filePath: string;
}): boolean => {
const { dirPath, filePath } = options;

const relativePath = path.relative(dirPath, filePath);

const isSafe =
relativePath.length > 0 &&
!relativePath.startsWith('..') &&
!path.isAbsolute(relativePath);

return isSafe;
};
14 changes: 14 additions & 0 deletions electron/main/electron-next/path-to-file-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import path from 'node:path';
import url from 'node:url';

/**
* Converts a file path to an absolute file URL.
* Example: '/path/to/file.txt' -> 'file:///path/to/file.txt'
*/
export const pathToFileURL = (options: {
dirPath: string;
filePath: string;
}): string => {
const { dirPath, filePath } = options;
return url.pathToFileURL(path.join(dirPath, filePath)).toString();
};
80 changes: 80 additions & 0 deletions electron/main/electron-next/prod-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* This module enables serving static files generated by Nextjs in production.
* It's inspired by the project `electron-serve` developed by Sindre Sorhus.
* https://github.com/sindresorhus/electron-serve
*
* It registers a custom protocol to serve files from a directory.
* https://www.electronjs.org/docs/latest/api/protocol
*
* After porting my project to ESM, the `electron-serve` module was no longer
* resolving the file paths correctly.
* https://github.com/sindresorhus/electron-serve/issues/29
*
* As a workaround, I re-implemented the logic here.
*/

import { app, net, protocol } from 'electron';
import path from 'node:path';
import { isSafePath } from './is-safe-path.js';
import { logger } from './logger.js';
import { pathToFileURL } from './path-to-file-url.js';

export const prodServe = (options: {
/**
* The protocol to serve the directory on.
* All URL requests that use this protocol will be served from the directory.
*/
scheme: string;
/**
* The directory to serve, relative to the app root directory.
*/
dirPath: string;
}): void => {
const { scheme, dirPath } = options;

logger.info('registering protocol scheme', {
scheme,
dirPath,
});

const error404Page = pathToFileURL({ dirPath, filePath: '404.html' });

const requestHandler = async (httpReq: Request): Promise<Response> => {
const requestURL = new URL(httpReq.url);

let pageToServe = error404Page;

let pathname = requestURL.pathname;
if (pathname === '/') {
pathname = 'index.html';
}

// Prevent loading files outside of the renderer directory.
const pathToServe = path.join(dirPath, pathname);
const isSafe = isSafePath({ dirPath, filePath: pathToServe });

if (isSafe) {
pageToServe = pathToFileURL({ dirPath, filePath: pathToServe });
} else {
pageToServe = error404Page;
}

return net.fetch(pageToServe);
};

protocol.registerSchemesAsPrivileged([
{
scheme,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
allowServiceWorkers: true,
},
},
]);

app.on('ready', () => {
protocol.handle(scheme, requestHandler);
});
};

0 comments on commit 0a8ccbf

Please sign in to comment.