Initial Commit
This commit is contained in:
45
src/.eslintrc.js
Normal file
45
src/.eslintrc.js
Normal 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
75
src/app.ts
Normal 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
22
src/healthcheck.ts
Normal 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
42
src/index.ts
Normal 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
10
src/routes/api/user.ts
Normal 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;
|
||||
9
src/routes/healthcheck.ts
Normal file
9
src/routes/healthcheck.ts
Normal 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
20
src/routes/index.ts
Normal 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
37
src/utils/auth-proxy.ts
Normal 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
40
src/utils/config.ts
Normal 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}` : ""}`),
|
||||
}
|
||||
79
src/utils/helpers/resolvable.ts
Normal file
79
src/utils/helpers/resolvable.ts
Normal 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
4
src/utils/index.ts
Normal 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
52
src/utils/logging.ts
Normal 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
36
src/utils/redis.ts
Normal 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
9
src/utils/types/auth-proxy.d.ts
vendored
Normal 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
5
src/utils/types/json-prune.d.ts
vendored
Normal 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
10
src/utils/types/userinfo.d.ts
vendored
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user