Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add frontend api for snapshots upload #294

Closed
wants to merge 6 commits into from
Closed
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
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ version: 2.1
executors:
best-executor:
docker:
- image: circleci/node:lts-stretch-browsers
- image: circleci/node:14-stretch-browsers
alrra marked this conversation as resolved.
Show resolved Hide resolved
working_directory: ~/best

jobs:
Expand Down
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;
}
79 changes: 79 additions & 0 deletions packages/@best/frontend/server/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* 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");
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could also be read from a file. Maybe that would be better. Thoughts?

Copy link
Collaborator

@alrra alrra Dec 6, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could also be read from a file. Maybe that would be better. Thoughts?

@dbajric A commonly used convention is to have them stored in a .env file (see also: dotenv).


/**
* 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 {
return REVOKED_TOKENS.includes(token);
}

/**
* 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;

// Send unauthorized response if token is not provided in the request
if (authHeader == null) {
res.status(401).send("Unauthorized: You must provide your token in authorization header.");
return;
}

const authHeaderParts = authHeader.split(' ');
if (authHeaderParts.length !== 2 || authHeaderParts[0] !== "Bearer") {
res.status(401).send("Unauthorized: Unrecognized authorization header format. Accepted format: Bearer <token>");
return;
}

// Second part of the auth header value is the actual token.
// Example header: "Authorization: Bearer this_is_my_token"
const token = authHeaderParts[1];

// Use last 6 chars to identify the token
const partialToken = token.slice(-6);

// Block request if token has been revoked
if (isRevoked(token)) {
res.status(403).send("Forbidden: Your token has been revoked.");
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(`Error while authorizing request using token ending in '${partialToken}'. ${err}`);

// Always send with status 403 Forbidden
res.status(403);

// Respond back with specific reasons for denial if reason is known.
if (err instanceof jwt.TokenExpiredError) {
res.send(`Token ending in '${partialToken}' expired on ${err.expiredAt}`);
} else if (err instanceof jwt.JsonWebTokenError) {
res.send(`Token ending in '${partialToken}' cannot be parsed. Error: ${err.message}`)
} else {
res.send(`Error while authorizing request with token ending in '${partialToken}': ${err}`)
}

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
73 changes: 73 additions & 0 deletions scripts/auth-token-manager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
alrra marked this conversation as resolved.
Show resolved Hide resolved
* 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
});

const generateNewToken = () => {
rl.question("Who are you generating this token for? ", (user) => {
rl.question("How long should this token last for? (e.g. '1 year', '2 days', '24 hours', '5 minutes') ", (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();
});
});
}

const verifyToken = () => {
rl.question("Enter token: ", (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", (option) => {
if (option === "1") {
generateNewToken();
} else if (option === "2") {
verifyToken();
} else {
process.exit(0);
}
});
Loading