+ + + + ); + } +} diff --git a/pages/_error.tsx b/pages/_error.tsx new file mode 100644 index 0000000..8460224 --- /dev/null +++ b/pages/_error.tsx @@ -0,0 +1,75 @@ +import c from 'classnames'; +import HTTPStatus from 'http-status'; +import React, {PureComponent} from 'react'; + +const bulma = require('../styles/global.scss'); + +export default class Page extends PureComponent { + public static getInitialProps({res, err, query}) { + const statusCode = res ? res.statusCode : err ? err.statusCode : null; + + return {statusCode, query}; + } + + public render() { + const {statusCode} = this.props; + const title = statusCode === 404 + ? 'This page could not be found' + : HTTPStatus[statusCode] || 'An unexpected error has occurred'; + + return ( +
+
+
+ {statusCode ?

{statusCode}

: null} +
+

{title}.

+
+
+
+
+ ); + } +} + +const styles = { + error: { + color: '#000', + background: '#fff', + height: '100vh', + textAlign: 'center', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + }, + + desc: { + display: 'inline-block', + textAlign: 'left', + lineHeight: '49px', + height: '49px', + verticalAlign: 'middle', + }, + + h1: { + display: 'inline-block', + borderRight: '1px solid rgba(0, 0, 0,.3)', + margin: 0, + marginRight: '20px', + padding: '10px 23px 10px 0', + fontSize: '24px', + fontWeight: 500, + verticalAlign: 'top', + color: 'black', + }, + + h2: { + fontSize: '14px', + fontWeight: 'normal', + lineHeight: 'inherit', + margin: 0, + padding: 0, + color: 'black', + }, +}; diff --git a/pages/index.tsx b/pages/index.tsx new file mode 100644 index 0000000..ed60884 --- /dev/null +++ b/pages/index.tsx @@ -0,0 +1,11 @@ +import * as React from 'react'; + +export default class extends React.Component { + public render() { + return ( +
+ Hello World! +
+ ); + } +} diff --git a/server/Controller/IndexController.ts b/server/Controller/IndexController.ts new file mode 100644 index 0000000..84b74b9 --- /dev/null +++ b/server/Controller/IndexController.ts @@ -0,0 +1,9 @@ +import {BaseHttpController, controller, httpGet, results} from 'inversify-express-utils'; + +@controller('/') +export class IndexController extends BaseHttpController { + @httpGet('/status') + private index(): results.JsonResult { + return this.json({status: 'ok'}); + } +} diff --git a/server/Controller/index.ts b/server/Controller/index.ts new file mode 100644 index 0000000..5150456 --- /dev/null +++ b/server/Controller/index.ts @@ -0,0 +1 @@ +export {default as IndexController} from './IndexController'; diff --git a/server/Kernel.ts b/server/Kernel.ts new file mode 100644 index 0000000..35c0a2a --- /dev/null +++ b/server/Kernel.ts @@ -0,0 +1,177 @@ +import * as bodyParser from 'body-parser'; +import * as ConnectDynamo from 'connect-dynamodb'; +import * as session from 'express-session'; +import {Container} from 'inversify'; +import {InversifyExpressServer} from 'inversify-express-utils'; +import * as morgan from 'morgan'; +import {Server} from 'next'; +import * as passport from 'passport'; +import {Strategy as OAuth2Strategy} from 'passport-oauth2'; +import * as request from 'request'; +import {Connection, createConnection} from 'typeorm'; +import {createLogger, format, Logger, transports} from 'winston'; + +import Types from './types'; +import {Config, Vault} from './Vault'; + +export default async (app: Server) => { + const container: Container = new Container({defaultScope: 'Singleton'}); + const server = new InversifyExpressServer(container); + const DynamoDBStore = ConnectDynamo({session}); + const store = new DynamoDBStore({table: process.env.SESSION_TABLE}); + const url = process.env.IS_OFFLINE ? 'http://localhost:3000' : 'https://apply.hotline.gg'; + server.setConfig((_app) => { + _app.use(bodyParser.urlencoded({extended: true})) + .use(bodyParser.json()) + .use(morgan('dev')) + .use((req, res, nextReq) => { + if (process.env.NODE_ENV === 'production') { + if (req.protocol === 'https' || req.headers['x-forwarded-proto'] === 'https') { + return nextReq(); + } + + return res.redirect('https://' + req.hostname + req.originalUrl); + } + }); + + const cookie: { maxAge: number, domain?: string, secure?: boolean } = { + maxAge: 1000 * 60 * 60 * 24 * 7, + secure: undefined, + }; + if (process.env.NODE_ENV === 'production') { + cookie.domain = 'www.hotline.gg'; + cookie.secure = true; + _app.set('trust proxy', 1); + } + + _app.use(session({ + secret: '9a7sd79asrh99a9', + saveUninitialized: true, + resave: false, + rolling: true, + cookie, + store, + })); + + _app.use(passport.initialize()); + _app.use(passport.session()); + }); + + // Logger + container.bind(Types.logger).toDynamicValue(() => createLogger({ + level: process.env.DEBUG || false ? 'debug' : 'info', + format: format.combine( + format.splat(), + format.colorize(), + format.timestamp(), + format.align(), + format.printf((info) => `${info.timestamp} ${info.level}: ${info.message}`), + ), + transports: [ + new transports.Console(), + ], + })); + + // Vault + container.bind(Types.vault.config).toConstantValue({ + vaultFile: process.env.VAULT_FILE, + address: process.env.VAULT_ADDR, + rootToken: process.env.VAULT_TOKEN, + roleId: process.env.VAULT_ROLE_ID, + secretId: process.env.VAULT_SECRET_ID, + }); + container.bind(Types.vault.client).to(Vault); + const vault = container.get(Types.vault.client); + await vault.initialize(); + + container.bind(Types.next.app).toConstantValue(app); + container.bind(Types.next.handler).toFunction(app.getRequestHandler); + await import('./Controller'); + + // Database/TypeORM + const connection = await createConnection({ + synchronize: true, + host: await vault.getSecret('database', 'host'), + database: await vault.getSecret('api/database', 'name'), + port: 3306, + username: await vault.getSecret('api/database', 'user'), + password: await vault.getSecret('api/database', 'password'), + type: 'mysql', + supportBigNumbers: true, + logger: this.logger, + bigNumberStrings: true, + entities: [], + }); + container.bind(Types.database).toConstantValue(connection); + + const expressApp = server.build(); + + const baseUrl = `https://discordapp.com/api`; + const authType = `discord`; + // Passport Initialization + passport.use(authType, new OAuth2Strategy( + { + authorizationURL: `${baseUrl}/oauth2/authorize`, + tokenURL: `${baseUrl}/oauth2/token`, + clientID: await vault.getSecret('discord', 'client_id'), + clientSecret: await vault.getSecret('discord', 'secret'), + callbackURL: url + '/connect/callback', + }, + function(accessToken, refreshToken, empty, cb) { + request.get( + `${baseUrl}/users/@me`, { + headers: { + Authorization: 'Bearer ' + accessToken, + }, + }, + function(err, response, body) { + if (err) { + console.error(err); + + return cb(err); + } + + const profile = JSON.parse(body); + profile.accessToken = accessToken; + profile.refreshToken = refreshToken; + + cb(undefined, profile); + }, + ); + }, + )); + + expressApp.get(`/connect`, (req, res, next) => { + const scope = ['identify', ...req.query.scopes ? req.query.scopes.split(',') : []]; + + return passport.authenticate(authType, {scope})(req, res, next); + }); + expressApp.get( + `/connect/callback`, + (req: any, res: any) => passport.authenticate(authType, (err, user) => { + if (err) { + console.error(err); + + return res.statusCode(500).send(err.message); + } + + req.logIn(user, (error) => { + if (error) { + console.error(error); + + return res.statusCode(500).send(error.message); + } + const redirect = req.session.lastUrl || url; + delete req.session.lastUrl; + + res.redirect(redirect); + }); + })(req, res), + ); + + passport.serializeUser((user, done) => done(null, user)); + passport.deserializeUser((user, done) => done(null, user)); + expressApp.get('*', app.getRequestHandler() as any); + + return expressApp; +}; diff --git a/server/Vault/Config.ts b/server/Vault/Config.ts new file mode 100644 index 0000000..45111da --- /dev/null +++ b/server/Vault/Config.ts @@ -0,0 +1,11 @@ +export default class Config { + public vaultFile?: string = undefined; + + public address?: string = undefined; + + public rootToken?: string = undefined; + + public roleId?: string = undefined; + + public secretId?: string = undefined; +} diff --git a/server/Vault/Vault.ts b/server/Vault/Vault.ts new file mode 100644 index 0000000..752d128 --- /dev/null +++ b/server/Vault/Vault.ts @@ -0,0 +1,96 @@ +import {readFileSync} from 'fs'; +import {inject, injectable} from 'inversify'; +import * as NodeVault from 'node-vault'; + +import Types from '../types'; +import Config from './Config'; + +interface Path { + insert: number; + data: { + [key: string]: string, + }; +} + +@injectable() +export default class Vault { + private vault: NodeVault.client; + + private paths: { [key: string]: Path } = {}; + + constructor( + @inject(Types.vault.config) private config: Config, + ) { + if (config.address) { + this.vault = NodeVault({endpoint: config.address, token: config.rootToken}); + } + } + + public async initialize() { + if (this.config.address && this.config.roleId && this.config.secretId) { + await this.vault.approleLogin({role_id: this.config.roleId, secret_id: this.config.secretId}); + } + } + + public async getSecret( + path: string, + field: string, + cache: boolean = true, + withPrefix: boolean = true, + ttl: number = 60 * 5, + ): Promise { + try { + return (await this.getSecrets(path, cache, withPrefix, ttl))[field]; + } catch (e) { + console.error( + 'Failed fetching secret %s from path %s. Original Error: %s\n%s', + field, + withPrefix ? 'secret/hotline/' + path : path, + e.message, + e.stack, + ); + + throw e; + } + } + + public async getSecrets( + path: string, + cache: boolean = true, + withPrefix: boolean = true, + ttl: number = 60 * 5, + ): Promise<{ [key: string]: string }> { + try { + return await this.getPath(withPrefix ? 'secret/hotline/' + path : path, cache, ttl); + } catch (e) { + console.error( + 'Failed fetching secret path %s. Original Error: %s\n%s', + withPrefix ? 'secret/hotline/' + path : path, + e.message, + e.stack, + ); + + throw e; + } + } + + private async getPath(path: string, cache: boolean = false, ttl: number = 60 * 5) { + if (cache && this.paths[path] && this.paths[path].insert + (ttl * 1000) < Date.now()) { + return this.paths[path]; + } + + let value; + if (this.config.vaultFile) { + const fileContents = JSON.parse(readFileSync(this.config.vaultFile, 'utf8')); + value = fileContents[path]; + } else { + value = (await this.vault.read(path)).data; + } + + if (cache) { + this.paths[path] = {insert: Date.now(), data: value}; + } + + return value; + } +} diff --git a/server/Vault/index.ts b/server/Vault/index.ts new file mode 100644 index 0000000..a09a1e2 --- /dev/null +++ b/server/Vault/index.ts @@ -0,0 +1,2 @@ +export {default as Vault} from './Vault'; +export {default as Config} from './Config'; diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..78ae4a1 --- /dev/null +++ b/server/index.ts @@ -0,0 +1,42 @@ +import {Application} from 'express'; +import * as next from 'next'; +import * as nextServerless from 'next-serverless/handler'; +import 'reflect-metadata'; +import 'source-map-support/register'; + +import Kernel from './Kernel'; + +const dev = process.env.NODE_ENV !== 'production'; +if (process.env.IS_OFFLINE) { + process.env.LAMBDA_TASK_ROOT = 'true'; + process.env.AWS_EXECUTION_ENV = 'true'; +} + +declare global { + namespace NodeJS { + interface Global { + app: next.Server; + kernel: Application; + handler: any; + } + } +} + +global.app = next({dev}); +module.exports.handler = async (event, context, callback) => { + if (!global.kernel) { + global.kernel = await Kernel(global.app); + } + global.handler = nextServerless(global.app, global.kernel); + + try { + console.log('Getting Response'); + debugger; + const response = await global.handler(event, context); + console.log('Response: ', {response}); + + return response; + } catch (e) { + console.error({e}); + } +}; diff --git a/server/types.ts b/server/types.ts new file mode 100644 index 0000000..175f572 --- /dev/null +++ b/server/types.ts @@ -0,0 +1,19 @@ +const Types = { + database: Symbol('database'), + discord: { + token: Symbol('discord.token'), + options: Symbol('discord.options'), + client: Symbol('discord.client'), + }, + logger: Symbol('logger'), + next: { + app: Symbol('next.app'), + handler: Symbol('next.handler'), + }, + vault: { + client: Symbol('vault.client'), + config: Symbol('vault.config'), + }, +}; + +export default Types; diff --git a/static/hotline.png b/static/hotline.png new file mode 100644 index 0000000..963212c Binary files /dev/null and b/static/hotline.png differ diff --git a/static/hotline.svg b/static/hotline.svg new file mode 100644 index 0000000..a50417a --- /dev/null +++ b/static/hotline.svg @@ -0,0 +1,58 @@ + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/static/logo.png b/static/logo.png new file mode 100644 index 0000000..220a596 Binary files /dev/null and b/static/logo.png differ diff --git a/styles/Layout.scss b/styles/Layout.scss new file mode 100644 index 0000000..44c7ce2 --- /dev/null +++ b/styles/Layout.scss @@ -0,0 +1,28 @@ +@import "variables"; + +.content { + padding-top: 2rem; +} + +.footer { + composes: footer from "./global.scss"; + flex: 1; + background: transparent; + color: $white; + + a { + color: $primary; + } +} + +.notice { + color: $white; + text-shadow: 1px 1px 3px rgba($primary, .3); + + a { + color: $primary; + &:hover { + color: darken($primary, 20%); + } + } +} diff --git a/styles/global.scss b/styles/global.scss new file mode 100644 index 0000000..e481ccd --- /dev/null +++ b/styles/global.scss @@ -0,0 +1,44 @@ +@import "variables"; +@import "~bulma"; +@import "overrides"; +@import "./mixins"; + +a.title { + color: $light; + + &:hover { + color: $primary; + border-bottom: 2px solid $primary; + + } +} + +body::-webkit-scrollbar-track { + display: none; +} + +body::-webkit-scrollbar { + display: none; +} + +body::-webkit-scrollbar-thumb { + display: none; +} + +.is-transparent { + background: transparent; + border: none; + cursor: default; + + &:active, &:focus, &:hover { + border: none; + color: initial; + outline: none; + } +} + +.button { + > svg { + margin-right: 5px; + } +} diff --git a/styles/mixins.scss b/styles/mixins.scss new file mode 100644 index 0000000..ee4fe0d --- /dev/null +++ b/styles/mixins.scss @@ -0,0 +1,90 @@ +/// Convert angle +/// @author Chris Eppstein +/// @param {Number} $value - Value to convert +/// @param {String} $unit - Unit to convert to +/// @return {Number} Converted angle +@function convert-angle($value, $unit) { + $convertable-units: deg grad turn rad; + $conversion-factors: 1 (10grad/9deg) (1turn/360deg) (3.1415926rad/180deg); + @if index($convertable-units, unit($value)) and index($convertable-units, $unit) { + @return $value + / nth($conversion-factors, index($convertable-units, unit($value))) + * nth($conversion-factors, index($convertable-units, $unit)); + } + + @warn "Cannot convert `#{unit($value)}` to `#{$unit}`."; +} +/// Test if `$value` is an angle +/// @param {*} $value - Value to test +/// @return {Bool} +@function is-direction($value) { + $is-direction: index((to top, to top right, to right top, to right, to bottom right, to right bottom, to bottom, to bottom left, to left bottom, to left, to left top, to top left), $value); + $is-angle: type-of($value) == 'number' and index('deg' 'grad' 'turn' 'rad', unit($value)); + @return $is-direction or $is-angle; +} +/// Convert a direction to legacy syntax +/// @param {Keyword | Angle} $value - Value to convert +/// @require {function} is-direction +/// @require {function} convert-angle +@function legacy-direction($value) { + @if is-direction($value) == false { + @warn "Cannot convert `#{$value}` to legacy syntax because it doesn't seem to be an angle or a direction"; + } + + $conversion-map: ( + to top : bottom, + to top right : bottom left, + to right top : left bottom, + to right : left, + to bottom right : top left, + to right bottom : left top, + to bottom : top, + to bottom left : top right, + to left bottom : right top, + to left : right, + to left top : right bottom, + to top left : bottom right + ); + + @if map-has-key($conversion-map, $value) { + @return map-get($conversion-map, $value); + } + + @return 90deg - convert-angle($value, 'deg'); +} +/// Mixin printing a linear-gradient +/// as well as a plain color fallback +/// and the `-webkit-` prefixed declaration +/// @access public +/// @param {String | List | Angle} $direction - Linear gradient direction +/// @param {Arglist} $color-stops - List of color-stops composing the gradient +@mixin linear-gradient($direction, $color-stops...) { + @if is-direction($direction) == false { + $color-stops: ($direction, $color-stops); + $direction: 180deg; + } + + background: nth(nth($color-stops, 1), 1); + background: -webkit-linear-gradient(legacy-direction($direction), $color-stops); + background: linear-gradient($direction, $color-stops); +} +@mixin box-shadow($color, $extra: 0rem) { + box-shadow: 0 0 (2rem + $extra) $color; +} +@mixin inset-box-shadow($color) { + box-shadow: inset 0 0 -28px $color; +} +@mixin radial-gradient($from, $to) { + background: -moz-radial-gradient(center, circle cover, $from 0%, $to 100%); + background: -webkit-gradient(radial, center center, 0px, center center, 100%, color-stop(0%, $from), color-stop(100%, $to)); + background: -webkit-radial-gradient(center, circle cover, $from 0%, $to 100%); + background: -o-radial-gradient(center, circle cover, $from 0%, $to 100%); + background: -ms-radial-gradient(center, circle cover, $from 0%, $to 100%); + background: radial-gradient(center, circle cover, $from 0%, $to 100%); + background-color: $from; +} + +@mixin animated($time) { + -webkit-animation-duration: $time; + animation-duration: $time +} diff --git a/styles/overrides.scss b/styles/overrides.scss new file mode 100644 index 0000000..af559f5 --- /dev/null +++ b/styles/overrides.scss @@ -0,0 +1 @@ +@import "~bulmaswatch/darkly/overrides"; diff --git a/styles/variables.scss b/styles/variables.scss new file mode 100644 index 0000000..8e42119 --- /dev/null +++ b/styles/variables.scss @@ -0,0 +1,3 @@ +@import "~bulmaswatch/darkly/variables"; +$white: $white-ter; +$navbar-height: 5rem; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..995b8b0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "target": "es2017", + "jsx": "react", + "module": "commonjs", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "removeComments": false, + "preserveConstEnums": true, + "sourceMap": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "noImplicitAny": false, + "strictNullChecks": true, + "lib": [ + "esnext", + "es2017", + "es2015", + "es6", + "es5", + "dom" + ], + "typeRoots": [ + "./node_modules/@types" + ], + "types": [ + "node", + "reflect-metadata" + ] + } +} diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..96a78dc --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,37 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "jsx": "preserve", + "allowJs": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "removeComments": false, + "preserveConstEnums": true, + "sourceMap": true, + "skipLibCheck": true, + "baseUrl": ".", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "noImplicitAny": false, + "outDir": ".next/", + "pretty": true, + "lib": [ + "es2017.object", + "es2015", + "es2017", + "esnext", + "dom" + ], + "typeRoots": [ + "./node_modules/@types" + ], + "types": [ + "node", + "reflect-metadata" + ] + } +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..c949373 --- /dev/null +++ b/tslint.json @@ -0,0 +1,43 @@ +{ + "extends": "tslint:recommended", + "rules": { + "semicolon": [ + true + ], + "curly": true, + "variable-name": [ + true, + "ban-keywords", + "check-format", + "allow-leading-underscore", + "allow-pascal-case" + ], + "align": [ + true, + "parameters", + "statements", + "arguments", + "members", + "elements" + ], + "quotemark": [ + true, + "single", + "jsx-double", + "avoid-escape" + ], + "only-arrow-functions": false, + "no-console": false, + "no-empty": false, + "no-eval": false, + "no-string-literal": false, + "object-literal-sort-keys": false, + "no-var-requires": false, + "interface-name": false, + "newline-before-return": true, + "no-bitwise": false + }, + "jsRules": { + "curly": true + } +}