Skip to content

Commit

Permalink
feat: add frontend api for snapshots upload
Browse files Browse the repository at this point in the history
Ref: #294
Fix: #294
  • Loading branch information
Dinko Bajric authored and sf-v committed Apr 7, 2022
1 parent a7da645 commit a198ce5
Show file tree
Hide file tree
Showing 15 changed files with 270 additions and 10 deletions.
4 changes: 4 additions & 0 deletions docs/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ Command-line arguments override the same option specified in the configuration f

`string` Specifies a connection URI or path to pass to the database adapter.

### `--dbToken`

`string` Some database providers (e.g. rest/frontend) communicate over HTTP(S) and this token is used for authorization.

### `--runner`

`string` Selects the runner to execute the benchmarks. Requires the `runnerConfig` option in the Best config file. By default, Best uses `@best/runner-headless`.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"@types/express": "^4.17.0",
"@types/jest": "^26.0.0",
"@types/json2md": "^1.5.0",
"@types/jsonwebtoken": "^8.5.6",
"@types/micromatch": "^3.1.0",
"@types/mime-types": "^2.1.0",
"@types/mkdirp": "^0.5.2",
Expand Down
6 changes: 4 additions & 2 deletions packages/@best/api-db/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
"version": "4.0.0-beta10",
"dependencies": {
"pg": "^8.4.1",
"sqlite": "^3.0.3"
"sqlite": "^3.0.3",
"node-fetch": "~2.6.1"
},
"devDependencies": {
"@types/pg": "^7.4.14"
"@types/pg": "^7.4.14",
"@types/node-fetch": "2.5.12"
},
"main": "build/index.js",
"files": [
Expand Down
49 changes: 49 additions & 0 deletions packages/@best/api-db/src/rest/frontend/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2019, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: MIT
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/

import { ApiDBAdapter, TemporarySnapshot } from '../../types'
import { ApiDatabaseConfig } from '@best/types';
import fetch from 'node-fetch';

/**
* An implementation for a REST-based DB adapter.
* It provides a way to save snapshots using a REST API provided by the frontend.
*/
export default class FrontendRestDbAdapter extends ApiDBAdapter {
config: ApiDatabaseConfig

constructor(config: ApiDatabaseConfig) {
super(config);
this.config = config;
}

async saveSnapshots(snapshots: TemporarySnapshot[], projectName: string): Promise<boolean> {
const requestUrl = `${this.config.uri}/api/v1/${projectName}/snapshots`;

const response = await fetch(requestUrl, {
method: 'post',
body: JSON.stringify(snapshots),
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.token}`
},
});

// Log the response body for troubleshooting purposes
if (!response.ok) {
console.error(await response.text());
return false;
}

return true;
}

async migrate() {
// The migrate() function is called during results publishing, but it is not needed here.
// We just need to make it a no-op as the server-side implementation handles the migration.
}
}
2 changes: 1 addition & 1 deletion packages/@best/api-db/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import path from 'path';
import { FrozenGlobalConfig, FrontendConfig } from '@best/types';
import { ApiDBAdapter } from './types';

const LOCAL_ADAPTERS = ['sql/postgres', 'sql/sqlite'];
const LOCAL_ADAPTERS = ['sql/postgres', 'sql/sqlite', 'rest/frontend'];

// Handles default exports for both ES5 and ES6 syntax
function req(id: string) {
Expand Down
10 changes: 8 additions & 2 deletions packages/@best/cli/src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ export const options: { [key: string]: Options } = {
description: 'Provide a connection URI or path to be passed to the database adapter',
type: 'string',
},
dbToken: {
default: undefined,
description: 'Some database providers (e.g. rest/frontend) communicate over HTTP(S) and this token is used for authorization.',
type: 'string',
},
runner: {
default: 'default',
description:
Expand All @@ -125,7 +130,7 @@ export const options: { [key: string]: Options } = {
};

export function normalize(args: { [x: string]: any; _: string[]; $0: string }): CliConfig {
const { _, help, clearCache, clearResults, showConfigs, disableInteractive, gitIntegration, generateHTML, useHttp, externalStorage, runner, runnerConfig, config, projects, iterations, compareStats, dbAdapter, dbURI, runInBatch, runInBand } = args;
const { _, help, clearCache, clearResults, showConfigs, disableInteractive, gitIntegration, generateHTML, useHttp, externalStorage, runner, runnerConfig, config, projects, iterations, compareStats, dbAdapter, dbURI, dbToken, runInBatch, runInBand } = args;

return {
_,
Expand All @@ -147,6 +152,7 @@ export function normalize(args: { [x: string]: any; _: string[]; $0: string }):
iterations: iterations ? parseInt(iterations, 10): undefined,
compareStats,
dbAdapter,
dbURI
dbURI,
dbToken
};
}
7 changes: 6 additions & 1 deletion packages/@best/config/src/utils/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,15 @@ function setCliOptionOverrides(initialOptions: UserConfig, argsCLI: CliConfig):
break;
case 'dbAdapter':
if (argsCLI[key] !== undefined) {
options.apiDatabase ={ adapter: argsCLI[key], uri: argsCLI['dbURI'] }
options.apiDatabase = {
adapter: argsCLI[key],
uri: argsCLI['dbURI'],
token: argsCLI['dbToken']
}
}
break;
case 'dbURI':
case 'dbToken':
break
default:
options[key] = argsCLI[key];
Expand Down
2 changes: 2 additions & 0 deletions packages/@best/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@lwc/rollup-plugin": "^1.0.0",
"compression": "^1.7.4",
"express": "^4.17.1",
"jsonwebtoken": "^8.5.1",
"lwc-services": "^1",
"query-string": "^6.6.0",
"redux": "^4.0.1",
Expand All @@ -32,6 +33,7 @@
"@types/compression": "^0.0.36",
"@types/express": "^4.16.1",
"@types/helmet": "^0.0.43",
"@types/jsonwebtoken": "^8.5.6",
"concurrently": "^4.1.0",
"fetch-mock": "^7.3.3",
"nodemon": "^1.19.1",
Expand Down
17 changes: 16 additions & 1 deletion packages/@best/frontend/server/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
*/

import { Router } from 'express'
import { loadDbFromConfig } from '@best/api-db'
import { loadDbFromConfig, TemporarySnapshot } from '@best/api-db'
import { GithubApplicationFactory } from '@best/github-integration'
import { FrontendConfig } from '@best/types';
import { authorizeRequest } from './auth';

export default (config: FrontendConfig): Router => {
const db = loadDbFromConfig(config);
Expand Down Expand Up @@ -94,5 +95,19 @@ export default (config: FrontendConfig): Router => {
}
})

router.post('/:projectName/snapshots', authorizeRequest, async (req, res): Promise<void> => {
const { projectName }: { projectName?: string } = req.params
const { body: snapshots }: { body: TemporarySnapshot[] } = req

try {
await db.migrate()
await db.saveSnapshots(snapshots, projectName)

res.status(200).end()
} catch (err) {
res.status(500).json({ error: err.message })
}
})

return router;
}
59 changes: 59 additions & 0 deletions packages/@best/frontend/server/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2019, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: MIT
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/
import { Request, Response, NextFunction } from "express";
import jwt from 'jsonwebtoken';

const TOKEN_SECRET = process.env.TOKEN_SECRET as string;
const REVOKED_TOKENS = (process.env.REVOKED_TOKENS || "").split("\n");

/**
* Checks if the provided token has been revoked based on the revocation list provided
* in process.env.REVOKED_TOKENS environmental variable
* @param token The token to check if it has been revoked.
* @returns true if token is revoked, otherwise false
*/
function isRevoked(token: string): boolean {
if (REVOKED_TOKENS.includes(token)) {
return true;
}

return false;
}

/**
* Function that verifies the token provided in the request header
* and on successful authorization it will call the next function on the chain.
* The secret token must be provided via process.env.TOKEN_SECRET
*/
export function authorizeRequest(req: Request, res: Response, next: NextFunction): void {
const { authorization: authHeader } = req.headers;
const token = authHeader && authHeader.split(' ')[1]

// Send unauthorized response if token is not provided in the request
if (token == null) {
res.sendStatus(401);
return;
}

// Block request if token has been revoked
if (isRevoked(token)) {
res.sendStatus(403);
return;
}

jwt.verify(token, TOKEN_SECRET, (err): void => {
// Block request if token is invalid or expired
if (err) {
// eslint-disable-next-line no-console
console.error(err);
res.sendStatus(403);
return;
}

next();
});
}
1 change: 1 addition & 0 deletions packages/@best/frontend/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const Frontend = (config: FrontendConfig): express.Application => {
const app: express.Application = express()

app.use(compression())
app.use(express.json())

// API

Expand Down
3 changes: 3 additions & 0 deletions packages/@best/store-aws/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
"mime-types": "~2.1.24",
"node-fetch": "~2.6.1"
},
"devDependencies": {
"@types/node-fetch": "2.5.12"
},
"files": [
"build/**/*.js"
]
Expand Down
4 changes: 3 additions & 1 deletion packages/@best/types/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export interface ApiDatabaseConfig {
adapter: string;
uri: string;
ssl?: any;
token?: string;
}

export interface FrontendConfig {
Expand Down Expand Up @@ -93,7 +94,8 @@ export interface CliConfig {
compareStats: string[] | undefined,
generateHTML: boolean | undefined,
dbAdapter: string | undefined,
dbURI: string | undefined
dbURI: string | undefined,
dbToken: string | undefined
}

export interface NormalizedConfig {
Expand Down
71 changes: 71 additions & 0 deletions scripts/auth-token-manager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2019, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: MIT
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/

// Borrowed from https://github.com/facebook/jest

/**
* Copyright (c) 2014-present, Facebook, Inc. All rights reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

'use strict';

const jwt = require('jsonwebtoken');
const readline = require("readline");

const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});

function generateNewToken() {
rl.question("Who are you generating this token for? ", function (user) {
rl.question("How long should this token last for? (e.g. '1 year', '2 days', '24 hours', '5 minutes') ", function (validFor) {
const token = jwt.sign({ user }, process.env.TOKEN_SECRET, { expiresIn: validFor });
const expiration = new Date(jwt.decode(token).exp * 1000);
console.log(`Token generated for '${user}' expires on ${expiration}:\n${token}`);
rl.close();
});
});
}

function verifyToken() {
rl.question("Enter token: ", function (token) {
try {
const decoded = jwt.verify(token, process.env.TOKEN_SECRET);
const user = decoded.user;
const issueDate = new Date(decoded.iat * 1000);
const expirationDate = new Date(decoded.exp * 1000);

console.log(`Issued to: ${user}`);
console.log(`Issued Date: ${issueDate}`);
console.log(`Expiration Date: ${expirationDate}`);
} catch (e) {
if (e instanceof jwt.TokenExpiredError) {
console.error(`Token expired on ${e.expiredAt}`);
process.exit(1);
} else if (e instanceof jwt.JsonWebTokenError) {
console.error(`Unable to parse token: ${e.message}`);
process.exit(2);
} else {
process.exit(100)
}
} finally {
rl.close();
}
});
}

rl.question("(1) Generate New Token\n(2) Verify Existing Token\n(3) Exit\n", function (option) {
if (option === "1") {
generateNewToken();
} else if (option === "2") {
verifyToken();
}
});
Loading

0 comments on commit a198ce5

Please sign in to comment.