Initial Commit

This commit is contained in:
seedorf_s1
2020-11-13 09:09:21 +01:00
commit 5a56fc26d2
50 changed files with 5038 additions and 0 deletions

45
src/.eslintrc.js Normal file
View File

@@ -0,0 +1,45 @@
// eslint-disable-next-line no-undef
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: [
'@typescript-eslint',
'no-null',
'promise',
],
extends: [
'eslint:recommended',
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
],
rules: {
"no-console": "error",
"max-len": ["error", {"code": 128}],
"no-process-env": "error",
"no-process-exit": "error",
"no-null/no-null": "error",
"no-useless-return": "error",
"prefer-arrow-callback": "warn",
"object-curly-spacing": "error",
"consistent-return": "error",
"@typescript-eslint/explicit-function-return-type": [
"error", {
"allowExpressions": true,
},
],
"no-void": "error",
"comma-spacing": "error",
"comma-dangle": ["error", "always-multiline"],
"promise/no-return-wrap": "error",
"promise/param-names": "error",
"promise/catch-or-return": "error",
"promise/no-native": "off",
"promise/no-nesting": "warn",
"promise/no-promise-in-callback": "warn",
"promise/no-callback-in-promise": "warn",
"promise/avoid-new": "warn",
"promise/no-new-statics": "error",
"promise/no-return-in-finally": "warn",
"promise/valid-params": "warn",
},
};

75
src/app.ts Normal file
View File

@@ -0,0 +1,75 @@
import * as createError from 'http-errors';
import * as express from 'express';
import {NextFunction, Request, Response} from 'express';
import * as path from 'path';
import * as redisStore from 'connect-redis';
import * as session from 'express-session';
import * as sassMiddleware from 'node-sass-middleware';
import indexRouter from './routes';
import {HttpLogger, Redis, Config, setupAuthProxy} from './utils';
import {Store} from 'express-session';
export const app = express();
// view engine setup
app.set('views', path.join(__dirname, '../views'));
app.set('view engine', 'pug');
const router = express.Router();
// http logger
app.use(HttpLogger);
app.use(express.json());
app.use(express.urlencoded({extended: false}));
// auth proxy middleware
app.use(setupAuthProxy);
// session
let sessionStore: Store|undefined = undefined;
if (Redis.client) {
const RedisStore = redisStore(session);
sessionStore = new RedisStore({client: Redis.client});
}
app.use(session({
store: sessionStore,
secret: Config.SESSION_SECRET,
resave: false,
saveUninitialized: true,
cookie: {secure: false},
}));
// static config
router.use(sassMiddleware({
src: path.join(__dirname, '../public'),
dest: path.join(__dirname, '../public'),
indentedSyntax: true, // true = .sass and false = .scss
sourceMap: true,
}));
router.use(express.static(path.join(__dirname, '../public')));
router.use(indexRouter);
app.use(Config.BASE_PATH, router);
// catch 404 and forward to error handler
app.use((req, res, next) => {
next(createError(404));
});
// error handler
app.use((err: Error&{status?: number}, req: Request, res: Response, next: NextFunction) => {
if (res.headersSent) {
return next(err);
}
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = !Config.isProduction ? err : {};
// render the error page
res.status(err.status || 500);
res.render('error');
return undefined;
});

22
src/healthcheck.ts Normal file
View File

@@ -0,0 +1,22 @@
/* eslint-disable no-process-exit,no-console */
import * as http from "http";
import urlJoin from 'proper-url-join';
import {Config} from './utils';
const options = {
host: Config.HOSTNAME,
port: Config.PORT,
path: urlJoin(Config.BASE_PATH, '/health'),
timeout: 2000,
};
const healthCheck = http.request(options, (res) => {
process.exit(res.statusCode == 200 ? 0 : 1);
});
healthCheck.on('error', (err) => {
console.error('HEALTHCHECK ERROR', err);
process.exit(1);
});
healthCheck.end();

42
src/index.ts Normal file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env node
/* eslint-disable no-process-exit */
import * as http from 'http';
import {app} from './app';
import {HttpError} from 'http-errors';
import {Config, Logger} from './utils';
app.set('port', Config.PORT);
const server = http.createServer(app);
server.listen(Config.PORT);
server.on('error', onError);
server.on('listening', onListening);
function onError(error: HttpError): void {
if (error.syscall !== 'listen') {
Logger.error(error);
}
switch (error.code) {
case 'EACCES':
Logger.error(Config.PORT + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
Logger.error(Config.PORT + ' is already in use');
process.exit(2);
break;
default:
Logger.error(error);
process.exit(3);
}
}
function onListening(): void {
const addr = server.address();
const bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + (addr ?? {port: undefined}).port;
Logger.debug('Listening on ' + bind);
}

10
src/routes/api/user.ts Normal file
View File

@@ -0,0 +1,10 @@
import * as express from 'express';
const userRouter = express.Router();
/* GET users listing. */
userRouter.get('/', async (req, res) => {
res.json(await req.getUserInfo() || {});
});
export default userRouter;

View File

@@ -0,0 +1,9 @@
import * as express from 'express';
const healthRouter = express.Router();
healthRouter.get('/', async (req, res) => {
res.sendStatus(200);
});
export default healthRouter;

20
src/routes/index.ts Normal file
View File

@@ -0,0 +1,20 @@
import * as express from 'express';
import userRouter from './api/user';
import healthRouter from './healthcheck';
import {Config} from '../utils';
const router = express.Router();
export default router;
router.use("/api/user", userRouter);
router.use("/health", healthRouter);
/* GET home page. */
router.get('/', async (req, res) => {
const email = (await req.getUserInfo())?.email ?? "No email found!";
res.render('index', {title: 'Express', email, externalUrl: Config.EXTERNAL_BASE_URL});
});
router.get('/logout', (req, res) => {
res.initLogout();
});

37
src/utils/auth-proxy.ts Normal file
View File

@@ -0,0 +1,37 @@
import {Request, RequestHandler} from 'express';
import {Config, Logger} from '.';
import {Resolvable} from './helpers/resolvable';
import fetch from 'node-fetch';
import * as properUrlJoin from 'proper-url-join';
const urlJoin = properUrlJoin as unknown as properUrlJoin.default;
export const setupAuthProxy: RequestHandler = (req: Request, res, next) => {
const resolvable = new Resolvable<UserInfo|undefined>(async () => {
if (!Config.USERINFO_HEADER) {
return undefined;
}
const token = req.header(Config.USERINFO_HEADER);
const url = Config.AUTH_PROXY_USERINFO_URL || Config.AUTH_PROXY_URL && urlJoin(Config.AUTH_PROXY_URL, "userinfo");
if (token === undefined || url === undefined) {
return undefined;
}
try {
const res = await fetch(url, {headers: [[Config.USERINFO_HEADER, token]]});
return await res.json();
} catch (e) {
Logger.warn(e);
return undefined;
}
});
req.getUserInfo = () => resolvable.resolve();
res.initLogout = function() {
const url = Config.AUTH_PROXY_INIT_LOGOUT_URL || Config.AUTH_PROXY_URL && urlJoin(Config.AUTH_PROXY_URL, "init-logout");
if (url === undefined) {
return false;
}
this.redirect(307, url);
return true;
}
next();
}

40
src/utils/config.ts Normal file
View File

@@ -0,0 +1,40 @@
import * as env from 'env-var';
import * as properUrlJoin from 'proper-url-join';
const urlJoin = properUrlJoin as unknown as properUrlJoin.default;
const NODE_ENV = env.get('NODE_ENV').default("development").asString();
const isProduction = NODE_ENV === 'production';
const envs = {
NODE_ENV,
// port of the server
PORT: env.get('PORT').default('3000').asPortNumber(),
// hostname of the server ('0.0.0.0' listens on all network interfaces)
HOSTNAME: env.get('HOSTNAME').default('0.0.0.0').asString(),
// base path
BASE_PATH: env.get('BASE_PATH').default('/').asString(),
// external base url
EXTERNAL_BASE_URL: env.get('EXTERNAL_BASE_URL').asString(),
// url of redis session store
REDIS_URL: env.get('REDIS_URL').required(isProduction).asString() || undefined,
// cookie secret for the session id
SESSION_SECRET: env.get('REDIS_URL').required(isProduction).default('keyboard cat').asString(),
// header where user info token is stored to request auth proxy
USERINFO_HEADER: env.get('USERINFO_HEADER').asString() || undefined,
// base url to init a logout or request user info
AUTH_PROXY_URL: env.get('AUTH_PROXY_URL').asString() || undefined,
// override base url to request user info
AUTH_PROXY_USERINFO_URL: env.get('AUTH_PROXY_USERINFO_URL').asString() || undefined,
// override base url to init a logout
AUTH_PROXY_INIT_LOGOUT_URL: env.get('AUTH_PROXY_INIT_LOGOUT_URL').asString() || undefined,
};
export const Config = {
...envs,
isProduction,
EXTERNAL_BASE_URL: envs.EXTERNAL_BASE_URL || urlJoin(`http://${envs.HOSTNAME}${envs.PORT !== 80 ? `:${envs.PORT}` : ""}`),
}

View File

@@ -0,0 +1,79 @@
enum ResolvableState {
WAITING,
PENDING,
ERROR,
DONE
}
class FetchOnce<T> {
protected data: T|undefined;
protected error: unknown|undefined;
protected state: ResolvableState = ResolvableState.WAITING;
protected pendings: [(res: Promise<T>|T) => void, (reason: unknown) => void][] = [];
constructor(protected fetchMethod?: () => Promise<T>) { }
public resolve(): Promise<T> {
// eslint-disable-next-line promise/avoid-new
return new Promise((resolve, reject) => {
switch (this.state) {
case ResolvableState.WAITING:
this.state = ResolvableState.PENDING;
this.pendings.push([resolve, reject]);
if (this.fetchMethod) this.parsePromise(this.fetchMethod());
break;
case ResolvableState.PENDING:
this.pendings.push([resolve, reject]);
break;
case ResolvableState.DONE:
resolve(this.data);
break;
case ResolvableState.ERROR:
reject(this.error);
break;
}
});
}
protected isFinished(): boolean {
return this.state === ResolvableState.DONE || this.state === ResolvableState.ERROR;
}
protected parsePromise(promise: Promise<T>): void {
promise.then((data) => {
this.data = data;
this.state = ResolvableState.DONE;
this.pendings.forEach(pending => pending[0](data));
}).catch(err => {
this.error = err;
this.state = ResolvableState.ERROR;
this.pendings.forEach(pending => pending[1](err));
});
}
}
export class Resolvable<T> extends FetchOnce<T> {
constructor(fetchMethod: () => Promise<T>) {
super(fetchMethod);
}
}
export class WaitForSync<T> extends FetchOnce<T> {
protected state: ResolvableState = ResolvableState.PENDING;
constructor() {
super(undefined);
}
public setData(data: T): void {
if (!this.isFinished()) {
this.parsePromise((async () => data)());
}
}
public setError(error: unknown): void {
if (!this.isFinished()) {
this.parsePromise((async () => { throw error; })());
}
}
}

4
src/utils/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export {Config} from './config';
export {Redis} from './redis';
export {Logger, HttpLogger} from './logging';
export {setupAuthProxy} from './auth-proxy';

52
src/utils/logging.ts Normal file
View File

@@ -0,0 +1,52 @@
import * as winston from 'winston';
import {LeveledLogMethod} from 'winston';
import prune = require('json-prune');
import {RequestHandler} from 'express';
import * as colors from 'colors';
import {Config} from '.';
const logger = winston.createLogger({
level: Config.isProduction ? "info" : "silly",
format: winston.format.json(),
transports: [
new winston.transports.Console({
format: winston.format.simple(),
}),
],
});
const levels = ["error", "warn", "info", "http", "verbose", "debug", "silly"] as const;
type LogLevels = typeof levels[number];
const wrapper = (original: LeveledLogMethod) => {
return (...args: unknown[]) => {
return original(args.map((obj) => typeof obj === "string" ? obj : prune(obj)).join(" "));
};
};
export const Logger = {
} as {[level in LogLevels]: ReturnType<typeof wrapper>};
for (const level of levels) {
Logger[level] = wrapper(logger[level]);
}
export const HttpLogger: RequestHandler = (req, res, next) => {
const start = Date.now();
type Callback = (() => void)|undefined;
const end = res.end;
res.end = function(...args: [Callback] & [unknown, Callback] & [unknown, BufferEncoding, Callback]) {
const statusCode = res.statusCode;
const colorFunction = statusCode >= 500 ? colors.red
: statusCode >= 400 ? colors.yellow
: statusCode >= 300 ? colors.cyan
: statusCode >= 200 ? colors.green
: colors.gray;
const status = colorFunction(res.statusCode.toString(10));
const method = req.method.toUpperCase().padEnd(6, " ");
const responseTime = (Date.now()-start).toString(10).padStart(3, " ");
Logger.http(`${status} ${method} ${responseTime}ms ${req.path}`);
end.apply(res, args);
};
next();
}

36
src/utils/redis.ts Normal file
View File

@@ -0,0 +1,36 @@
import * as redis from 'redis';
import {Config} from '.';
import {promisify} from 'util';
const redisClient = Config.REDIS_URL && redis.createClient({url: Config.REDIS_URL});
const inMemory: {[key: string]: string} = {};
type RedisType = {
client: redis.RedisClient | undefined,
get: (key: string) => Promise<string | undefined>,
set: (key: string, value: string) => Promise<void>,
del: (...keys: string[]) => Promise<void>
}
export const Redis: RedisType = redisClient ? {
client: redisClient,
get: (async (key: string) => {
const value = await promisify(redisClient.get)(key);
// eslint-disable-next-line no-null/no-null
return value === null ? undefined : value;
}),
set: promisify(redisClient.set),
del: promisify(redisClient.del) as unknown as (...keys: string[]) => Promise<void>,
} : {
client: undefined,
get: (async (key: string) => inMemory[key] || undefined),
set: (async (key: string, value: string) => {
inMemory[key] = value;
}),
del: (async (...keys: string[]) => {
for (const key of keys) {
delete inMemory[key];
}
}),
};

9
src/utils/types/auth-proxy.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
declare namespace Express {
interface Request {
getUserInfo(): Promise<UserInfo|undefined>;
}
interface Response {
initLogout(): boolean;
}
}

5
src/utils/types/json-prune.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare module "json-prune" {
function prune(object: unknown): string;
export = prune;
}

10
src/utils/types/userinfo.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
type UserInfo = {
email: string,
email_verified: boolean,
family_name: string,
given_name: string,
groups: string[],
name: string,
preferred_username: string,
sub: string,
};