This commit is contained in:
Ben Schwartz 2022-05-13 11:58:52 -04:00
parent 41ae0f6dbb
commit 263974e1ae
19 changed files with 177 additions and 158 deletions

4
.npmrc
View file

@ -1,2 +1,6 @@
; Enforces that the user is `npm install`ing with the correct node version.
engine-strict=true
; Workaround for conflict between the default location(s) of node-forge and the
; location expected by Typescript, Jasmine, and Electron.
prefer-dedupe=true

View file

@ -51,7 +51,7 @@ tsc -p src/server_manager/electron_app/tsconfig.json --outDir build/server_manag
readonly STATIC_DIR="${OUT_DIR}/static"
mkdir -p "${STATIC_DIR}"
mkdir -p "${STATIC_DIR}/server_manager"
cp -r "${OUT_DIR}/js/"* "${STATIC_DIR}"
cp -r "${OUT_DIR}/js/electron_app/"* "${STATIC_DIR}"
cp -r "${BUILD_DIR}/server_manager/web_app/static" "${STATIC_DIR}/server_manager/web_app/"
# Electron requires a package.json file for the app's name, etc.

View file

@ -18,7 +18,7 @@ import {urlToHttpOptions} from 'url';
import type {IncomingMessage} from 'http';
import type {HttpRequest, HttpResponse} from './types';
import type {HttpRequest, HttpResponse} from '../infrastructure/path_api';
export const fetchWithPin = async (
req: HttpRequest,

View file

@ -1,34 +0,0 @@
// Copyright 2022 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// This file is imported by both the Electron and Renderer process code,
// so it cannot contain any imports that are not available in both
// environments.
// These type definitions are designed to bridge the differences between
// the Fetch API and the Node.JS HTTP API, while also being compatible
// with the Structured Clone algorithm so that they can be passed between
// the Electron and Renderer processes.
export interface HttpRequest {
url: string;
method: string;
headers?: Record<string, string>;
body?: string;
}
export interface HttpResponse {
status: number;
body?: string;
}

View file

@ -19,8 +19,8 @@ import {autoUpdater} from 'electron-updater';
import * as path from 'path';
import {URL, URLSearchParams} from 'url';
import type {HttpRequest, HttpResponse} from './http/types';
import {fetchWithPin} from './http/fetch';
import type {HttpRequest, HttpResponse} from '../infrastructure/path_api';
import {fetchWithPin} from './fetch';
import * as menu from './menu';
const app = electron.app;

View file

@ -18,7 +18,7 @@ import {URL} from 'url';
import * as digitalocean_oauth from './digitalocean_oauth';
import * as gcp_oauth from './gcp_oauth';
import {HttpRequest, HttpResponse} from './http/types';
import {HttpRequest, HttpResponse} from '../infrastructure/path_api';
import {redactManagerUrl} from './util';
// This file is run in the renderer process *before* nodeIntegration is disabled.

View file

@ -4,10 +4,10 @@
"removeComments": false,
"noImplicitAny": true,
"module": "commonjs",
"rootDir": ".",
"rootDir": "..",
"lib": ["dom", "es2021"]
},
"include": ["*.ts", "../types/*.d.ts"],
"include": ["*.ts", "../infrastructure/path_api.ts", "../types/*.d.ts"],
"exclude": ["node_modules"],
"compileOnSave": true
}

View file

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import type {HttpResponse} from '../electron_app/http/types';
import type {HttpResponse} from './path_api';
export class OutlineError extends Error {
constructor(message?: string) {

View file

@ -0,0 +1,67 @@
// Copyright 2022 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {PathApiClient} from './path_api';
describe('PathApi', () => {
// Mock fetcher
let lastRequest: HttpRequest;
let nextResponse: Promise<HttpResponse>;
const fetcher = (request: HttpRequest) => {
lastRequest = request;
return nextResponse;
};
beforeEach(() => {
lastRequest = undefined;
nextResponse = undefined;
});
const api = new PathApiClient('https://asdf.test/foo', fetcher);
it('GET', async () => {
const response = {status: 200, body: '{"asdf": true}'};
nextResponse = Promise.resolve(response);
expect(await api.request('bar')).toEqual({asdf: true});
expect(lastRequest).toEqual({
url: 'https://asdf.test/foo/bar',
method: 'GET',
});
});
it('PUT form data', async () => {
const response = {status: 200, body: '{"asdf": true}'};
nextResponse = Promise.resolve(response);
expect(await api.requestForm('bar', 'PUT', {name: 'value'})).toEqual({asdf: true});
expect(lastRequest).toEqual({
url: 'https://asdf.test/foo/bar',
method: 'PUT',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'name=value',
});
});
it('POST JSON data', async () => {
const response = {status: 200, body: '{"asdf": true}'};
nextResponse = Promise.resolve(response);
expect(await api.requestJson('bar', 'POST', {key: 'value'})).toEqual({asdf: true});
expect(lastRequest).toEqual({
url: 'https://asdf.test/foo/bar',
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: '{"key":"value"}',
});
});
});

View file

@ -12,39 +12,31 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import * as errors from '../infrastructure/errors';
import {HttpRequest, HttpResponse} from '../electron_app/http/types';
// This file is imported by both the Electron and Renderer process code,
// so it cannot contain any imports that are not available in both
// environments.
async function fetchWrapper(request: HttpRequest): Promise<HttpResponse> {
const response = await fetch(request.url, request);
return {
status: response.status,
body: await response.text(),
};
// These type definitions are designed to bridge the differences between
// the Fetch API and the Node.JS HTTP API, while also being compatible
// with the Structured Clone algorithm so that they can be passed between
// the Electron and Renderer processes.
import * as errors from './errors';
export interface HttpRequest {
url: string;
method: string;
headers?: Record<string, string>;
body?: string;
}
export interface HttpResponse {
status: number;
body?: string;
}
// A Fetcher provides the HTTP client functionality for PathApi.
export type Fetcher = typeof fetchWrapper;
/**
* @param fingerprint A SHA-256 hash of the expected leaf certificate, in binary encoding.
* @returns An HTTP client that enforces `fingerprint`, if set.
*/
function makeFetcher(fingerprint?: string): Fetcher {
if (fingerprint) {
return (request) => fetchWithPin(request, fingerprint);
}
return fetchWrapper;
}
/**
* @param base A valid URL
* @param fingerprint A SHA-256 hash of the expected leaf certificate, in binary encoding.
* @returns A fully initialized API client.
*/
export function makePathApiClient(base: string, fingerprint?: string): PathApiClient {
return new PathApiClient(base, makeFetcher(fingerprint));
}
export type Fetcher = (request: HttpRequest) => Promise<HttpResponse>;
/**
* Provides access to an HTTP API of the kind exposed by the Shadowbox server.

View file

@ -14,8 +14,8 @@
// Functions made available to the renderer process via preload.ts.
type HttpRequest = import('../electron_app/http/types').HttpRequest;
type HttpResponse = import('../electron_app/http/types').HttpResponse;
type HttpRequest = import('../infrastructure/path_api').HttpRequest;
type HttpResponse = import('../infrastructure/path_api').HttpResponse;
declare function fetchWithPin(request: HttpRequest, fingerprint: string): Promise<HttpResponse>;
declare function openImage(basename: string): void;

View file

@ -19,7 +19,7 @@ import {sleep} from '../infrastructure/sleep';
import {ValueStream} from '../infrastructure/value_stream';
import {Region} from '../model/digitalocean';
import * as server from '../model/server';
import {makePathApiClient} from './path_api';
import {makePathApiClient} from './fetcher';
import {ShadowboxServer} from './shadowbox_server';

View file

@ -0,0 +1,28 @@
// Copyright 2022 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {makePathApiClient} from './fetcher';
describe('makePathApiClient', () => {
const api = makePathApiClient('https://api.github.com/repos/Jigsaw-Code/');
if (process?.versions?.node) {
// This test relies on fetch(), which doesn't exist in Node (yet).
return;
}
it('GET', async () => {
const response = await api.request<{name: string}>('outline-server');
expect(response.name).toEqual('outline-server');
});
});

View file

@ -0,0 +1,43 @@
// Copyright 2022 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {Fetcher, PathApiClient} from '../infrastructure/path_api';
async function fetchWrapper(request: HttpRequest): Promise<HttpResponse> {
const response = await fetch(request.url, request);
return {
status: response.status,
body: await response.text(),
};
}
/**
* @param fingerprint A SHA-256 hash of the expected leaf certificate, in binary encoding.
* @returns An HTTP client that enforces `fingerprint`, if set.
*/
function makeFetcher(fingerprint?: string): Fetcher {
if (fingerprint) {
return (request) => fetchWithPin(request, fingerprint);
}
return fetchWrapper;
}
/**
* @param base A valid URL
* @param fingerprint A SHA-256 hash of the expected leaf certificate, in binary encoding.
* @returns A fully initialized API client.
*/
export function makePathApiClient(base: string, fingerprint?: string): PathApiClient {
return new PathApiClient(base, makeFetcher(fingerprint));
}

View file

@ -19,7 +19,7 @@ import {ValueStream} from '../infrastructure/value_stream';
import {Zone} from '../model/gcp';
import * as server from '../model/server';
import {DataAmount, ManagedServerHost, MonetaryCost} from '../model/server';
import {makePathApiClient} from './path_api';
import {makePathApiClient} from './fetcher';
import {ShadowboxServer} from './shadowbox_server';

View file

@ -14,7 +14,7 @@
import {hexToString} from '../infrastructure/hex_encoding';
import * as server from '../model/server';
import {makePathApiClient} from './path_api';
import {makePathApiClient} from './fetcher';
import {ShadowboxServer} from './shadowbox_server';

View file

@ -1,81 +0,0 @@
// Copyright 2022 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {makePathApiClient, PathApiClient} from './path_api';
describe('PathApi', () => {
describe('local', () => {
// Mock fetcher
let lastRequest: HttpRequest;
let nextResponse: Promise<HttpResponse>;
const fetcher = (request: HttpRequest) => {
lastRequest = request;
return nextResponse;
};
beforeEach(() => {
lastRequest = undefined;
nextResponse = undefined;
});
const api = new PathApiClient('https://asdf.test/foo', fetcher);
it('GET', async () => {
const response = {status: 200, body: '{"asdf": true}'};
nextResponse = Promise.resolve(response);
expect(await api.request('bar')).toEqual({asdf: true});
expect(lastRequest).toEqual({
url: 'https://asdf.test/foo/bar',
method: 'GET',
});
});
it('PUT form data', async () => {
const response = {status: 200, body: '{"asdf": true}'};
nextResponse = Promise.resolve(response);
expect(await api.requestForm('bar', 'PUT', {name: 'value'})).toEqual({asdf: true});
expect(lastRequest).toEqual({
url: 'https://asdf.test/foo/bar',
method: 'PUT',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'name=value',
});
});
it('POST JSON data', async () => {
const response = {status: 200, body: '{"asdf": true}'};
nextResponse = Promise.resolve(response);
expect(await api.requestJson('bar', 'POST', {key: 'value'})).toEqual({asdf: true});
expect(lastRequest).toEqual({
url: 'https://asdf.test/foo/bar',
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: '{"key":"value"}',
});
});
});
// These tests rely on the fetch() function, which is not available in Node (yet).
if (!process?.versions?.node) {
describe('remote', () => {
const api = makePathApiClient('https://api.github.com/repos/Jigsaw-Code/');
it('GET', async () => {
const response = await api.request<{name: string}>('outline-server');
expect(response.name).toEqual('outline-server');
});
});
}
});

View file

@ -15,7 +15,7 @@
import * as semver from 'semver';
import * as server from '../model/server';
import {PathApiClient} from './path_api';
import {PathApiClient} from '../infrastructure/path_api';
interface AccessKeyJson {
id: string;