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

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
*
!src/*
!public/*
!views/*
!bio-proxy-configuration-for-docker/*
!package.json
!package-lock.json
!tsconfig.json

2
.eslintignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
out

232
.gitignore vendored Normal file
View File

@@ -0,0 +1,232 @@
tmp/*
!tmp/redis-data
tmp/redis-data/*
!tmp/redis-data/.gitkeep
!tmp/default-external.conf
bundle.js
*.map
# Created by https://www.toptal.com/developers/gitignore/api/webstorm,node,sass
# Edit at https://www.toptal.com/developers/gitignore?templates=webstorm,node,sass
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
.env*.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
### Sass ###
.sass-cache/
*.css.map
*.sass.map
*.scss.map
### WebStorm ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### WebStorm Patch ###
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
# *.iml
# modules.xml
# .idea/misc.xml
# *.ipr
# Sonarlint plugin
# https://plugins.jetbrains.com/plugin/7973-sonarlint
.idea/**/sonarlint/
# SonarQube Plugin
# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
.idea/**/sonarIssues.xml
# Markdown Navigator plugin
# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
.idea/**/markdown-navigator.xml
.idea/**/markdown-navigator-enh.xml
.idea/**/markdown-navigator/
# Cache file creation bug
# See https://youtrack.jetbrains.com/issue/JBR-2257
.idea/$CACHE_FILE$
# CodeStream plugin
# https://plugins.jetbrains.com/plugin/12206-codestream
.idea/codestream.xml
# End of https://www.toptal.com/developers/gitignore/api/webstorm,node,sass

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "bio-proxy-configuration-for-docker"]
path = bio-proxy-configuration-for-docker
url = https://git.biotronik.int/scm/coe-bs-website/bio-proxy-configuration-for-docker.git

28
.idea/codeStyles/Project.xml generated Normal file
View File

@@ -0,0 +1,28 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<DBN-PSQL>
<case-options enabled="true">
<option name="KEYWORD_CASE" value="lower" />
<option name="FUNCTION_CASE" value="lower" />
<option name="PARAMETER_CASE" value="lower" />
<option name="DATATYPE_CASE" value="lower" />
<option name="OBJECT_CASE" value="preserve" />
</case-options>
<formatting-settings enabled="false" />
</DBN-PSQL>
<DBN-SQL>
<case-options enabled="true">
<option name="KEYWORD_CASE" value="lower" />
<option name="FUNCTION_CASE" value="lower" />
<option name="PARAMETER_CASE" value="lower" />
<option name="DATATYPE_CASE" value="lower" />
<option name="OBJECT_CASE" value="preserve" />
</case-options>
<formatting-settings enabled="false">
<option name="STATEMENT_SPACING" value="one_line" />
<option name="CLAUSE_CHOP_DOWN" value="chop_down_if_statement_long" />
<option name="ITERATION_ELEMENTS_WRAPPING" value="chop_down_if_not_single" />
</formatting-settings>
</DBN-SQL>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

6
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="TypeScriptCompiler">
<option name="showAllErrors" value="true" />
</component>
</project>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

6
.idea/jsLibraryMappings.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<includedPredefinedLibrary name="Node.js Core" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/template-nodejs-express.iml" filepath="$PROJECT_DIR$/.idea/template-nodejs-express.iml" />
</modules>
</component>
</project>

12
.idea/template-nodejs-express.iml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

7
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/bio-proxy-configuration-for-docker" vcs="Git" />
</component>
</project>

39
Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
FROM node:12-alpine
# set app user
RUN addgroup --gid 800 appuser && \
adduser --uid 800 --ingroup appuser --disabled-password --gecos "" appuser && \
mkdir /app && \
chown appuser:appuser /app
USER appuser
WORKDIR /app
# add proxy configuration
USER root
COPY ./bio-proxy-configuration-for-docker /tmp/bio-data
ARG add_proxy
RUN if [ -n "$add_proxy" ] ; then sed -i 's/\r$//g' /tmp/bio-data/alpine-based.sh && \
chmod a+x /tmp/bio-data/alpine-based.sh && \
/tmp/bio-data/alpine-based.sh && \
rm -r /tmp/bio-data ; fi
USER appuser
# copy data
COPY . .
# install
RUN npm run install-prod
ENV NODE_ENV production
# cleanup
USER root
RUN rm -r ./src ./public/js-source ./bio-proxy-configuration-for-docker && \
npm cache clean --force
USER appuser
# healthcheck
HEALTHCHECK --interval=10s --timeout=2s --start-period=15s \
CMD node ./out/healthcheck.js
# set command
CMD npm run production

49
docker-compose.debug.yml Normal file
View File

@@ -0,0 +1,49 @@
version: "3.7"
services:
redis:
image: redis
command: redis-server --appendonly yes
volumes:
- ./tmp/redis-data:/data
ports:
- 6379:6379
networks:
- backend
proxy:
image: docker-registry.biotronik.int/web/auth-proxy
depends_on:
- redis
environment:
- "PROXY_USERINFO_SECRET=3d513168-c92d-4f57-8c78-8fb2efad8a34"
- "PROXY_TARGET_URI=http://host.docker.internal:3000"
- "HOST=0.0.0.0"
- "COOKIE_SECRET=05e8cc4b-f95f-4a70-b4a1-b22ce295348d"
- "WELLKNOWN_CONFIG_URI=https://nodejs1-2.biotronik.int/auth/realms/CoE-BS/.well-known/openid-configuration"
- "CLIENT_ID=demo"
- "CLIENT_SECRET=fb49b346-c515-4680-adb1-beee0bd5b66e"
- "CLIENT_SCOPE=openid email profile roles groups"
- "NODE_ENV=debug"
- "SSL_VERIFY=false"
- "EXT_RESOURCE_URI=http://localhost"
- "REDIS_URL=redis://redis:6379"
- "NO_PROXY=redis:6379"
ports:
- 3001:3000
networks:
- frontend
- backend
nginx:
image: nginx:alpine
depends_on:
- proxy
volumes:
- ./tmp/default-external.conf:/etc/nginx/conf.d/default.conf
ports:
- 80:80
networks:
- frontend
networks:
backend:
frontend:

61
docker-compose.yml Normal file
View File

@@ -0,0 +1,61 @@
version: "3.7"
services:
app:
build:
context: .
args:
- add_proxy=1
environment:
- "REDIS_URL=redis://redis:6379"
- "SESSION_SECRET=replace with random"
- "USERINFO_HEADER=X-Userinfo-Token"
- "AUTH_PROXY_URL=http://proxy:3000/oauth"
- "NO_PROXY=redis:6379"
depends_on:
- redis
networks:
- backend
redis:
image: redis
command: redis-server --appendonly yes
volumes:
- ./tmp/redis-data:/data
networks:
- backend
proxy:
image: docker-registry.biotronik.int/web/auth-proxy
depends_on:
- app
- redis
environment:
- "PROXY_USERINFO_SECRET=3d513168-c92d-4f57-8c78-8fb2efad8a34"
- "PROXY_TARGET_URI=http://app:3000"
- "HOST=0.0.0.0"
- "COOKIE_SECRET=05e8cc4b-f95f-4a70-b4a1-b22ce295348d"
- "WELLKNOWN_CONFIG_URI=https://nodejs1-2.biotronik.int/auth/realms/CoE-BS/.well-known/openid-configuration"
- "CLIENT_ID=demo"
- "CLIENT_SECRET=fb49b346-c515-4680-adb1-beee0bd5b66e"
- "CLIENT_SCOPE=openid email profile roles groups"
- "NODE_ENV=debug"
- "SSL_VERIFY=false"
- "EXT_RESOURCE_URI=http://localhost"
- "REDIS_URL=redis://redis:6379"
- "NO_PROXY=redis:6379"
networks:
- backend
- frontend
nginx:
image: nginx:alpine
depends_on:
- proxy
volumes:
- ./tmp/default-external.conf:/etc/nginx/conf.d/default.conf
ports:
- 80:80
networks:
- frontend
networks:
backend:
frontend:

3752
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "template-nodejs-express",
"version": "0.0.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
"lint-fix": "eslint . --ext .ts --fix",
"debug": "tsc-watch --onSuccess \"node --enable-source-maps --use-openssl-ca --unhandled-rejections=strict ./out/index\"",
"build": "tsc",
"production": "node --use-openssl-ca --unhandled-rejections=strict ./out/index",
"install-debug": "npm install && npm run build",
"install-prod": "npm install --only=dev && npm install --only=prod && npm run build && rm -r node_modules && npm install --only=prod",
"install-prod-win": "npm install --only=dev && npm install --only=prod && npm run build && rmdir /Q/S node_modules && npm install --only=prod"
},
"dependencies": {
"connect-redis": "^5.0.0",
"cookie-parser": "~1.4.4",
"env-var": "^6.3.0",
"express": "~4.16.1",
"express-session": "^1.17.1",
"http-errors": "~1.6.3",
"json-prune": "^1.1.0",
"node-fetch": "^2.6.1",
"node-sass-middleware": "0.11.0",
"proper-url-join": "^2.1.1",
"pug": "^3.0.0",
"redis": "^3.0.2",
"uuid": "^8.3.1",
"winston": "^3.3.3"
},
"devDependencies": {
"@types/connect-redis": "0.0.15",
"@types/express": "^4.17.8",
"@types/express-session": "^1.17.1",
"@types/http-errors": "^1.8.0",
"@types/node": "^14.14.7",
"@types/node-fetch": "^2.5.7",
"@types/node-sass-middleware": "0.0.31",
"@types/proper-url-join": "^2.0.0",
"@types/redis": "^2.8.28",
"@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^4.7.0",
"@typescript-eslint/parser": "^4.7.0",
"eslint": "^7.13.0",
"eslint-plugin-no-null": "^1.0.2",
"eslint-plugin-promise": "^4.2.1",
"tsc-watch": "^4.2.9",
"typescript": "^4.0.5"
}
}

View File

@@ -0,0 +1,39 @@
// eslint-disable-next-line no-undef
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: [
'@typescript-eslint',
'no-null',
],
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"],
"no-restricted-imports": ["error",
"assert", "buffer", "child_process", "cluster", "crypto", "dgram", "dns", "domain", "events", "freelist",
"fs", "http", "https", "module", "net", "os", "path", "punycode", "querystring", "readline", "repl",
"smalloc", "stream", "string_decoder", "sys", "timers", "tls", "tracing", "tty", "url", "util", "vm", "zlib",
],
},
};

View File

@@ -0,0 +1,7 @@
import {getUserInfo} from './utils';
export async function getUserName(): Promise<string> {
const info = await getUserInfo();
return info?.name ?? "No name found!";
}

View File

@@ -0,0 +1,12 @@
// Export setConfig so that the external url can set set automatically
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export {setConfig} from './utils';
import {getUserName} from './SomeModule';
getUserName().then((name) => {
const node = document.createElement('span');
node.innerText = `This user name is fetched with Javascript: ${name}`;
document.getElementsByTagName("body")[0].appendChild(node);
});

View File

@@ -0,0 +1,78 @@
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> {
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; })());
}
}
}

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,
};

View File

@@ -0,0 +1,23 @@
import {WaitForSync} from './resolvable';
export interface ClientConfig {
EXTERNAL_BASE_URL: string
}
let config: ClientConfig|undefined;
const configWaiter = new WaitForSync<ClientConfig>();
export function setConfig(clientConfig: ClientConfig): void {
config = clientConfig;
configWaiter.setData(config);
}
export function getConfig(): Promise<ClientConfig> {
return configWaiter.resolve();
}
export async function getUserInfo(): Promise<UserInfo|undefined> {
const getBaseUrl = await getConfig();
const res = await fetch(getBaseUrl.EXTERNAL_BASE_URL + "/api/user");
return res.json();
}

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"module": "amd",
"lib": ["es5", "es6", "dom"],
"removeComments": true,
"preserveConstEnums": true,
"outFile": "../js/bundle.js",
"target": "es5",
"sourceMap": true,
"forceConsistentCasingInFileNames": true,
"noImplicitReturns": true,
"strict": true,
"sourceRoot": "./src",
"rootDir": ".",
"skipLibCheck": true,
"baseUrl": "."
},
"include": [
"./src/**/*.ts"
],
"exclude": [
]
}

5
public/js/require-2.3.6.min.js vendored Normal file

File diff suppressed because one or more lines are too long

8
public/styles/style.css Normal file
View File

@@ -0,0 +1,8 @@
body {
padding: 50px;
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; }
a {
color: #00B7FF; }
/*# sourceMappingURL=style.css.map */

6
public/styles/style.sass Normal file
View File

@@ -0,0 +1,6 @@
body
padding: 50px
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif
a
color: #00B7FF

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,
};

11
tmp/default-external.conf Normal file
View File

@@ -0,0 +1,11 @@
server {
listen 80;
listen [::]:80;
access_log /var/log/nginx/reverse-access.log;
error_log /var/log/nginx/reverse-error.log;
location / {
proxy_pass http://proxy:3000/;
}
}

0
tmp/redis-data/.gitkeep Normal file
View File

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es6",
"sourceMap": true,
"outDir": "out",
"forceConsistentCasingInFileNames": true,
"noImplicitReturns": true,
"strict": true
},
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules",
"public"
]
}

6
views/error.pug Normal file
View File

@@ -0,0 +1,6 @@
extends layout
block content
h1= message
h2= error.status
pre #{error.stack}

7
views/index.pug Normal file
View File

@@ -0,0 +1,7 @@
extends layout
block content
h1= title
p Welcome to #{title}!&nbsp;
a(href="/logout") Want so say goodbye?
p This name is fetched on server-side: #{email}

14
views/layout.pug Normal file
View File

@@ -0,0 +1,14 @@
doctype html
html
head
title= title
link(rel='stylesheet', href='/styles/style.css')
body
block content
script(type='text/javascript', src='/js/require-2.3.6.min.js')
script(type='text/javascript', src='/js/bundle.js')
script.
require(['src/index'], function (index) {
console.log(index);
index.setConfig({EXTERNAL_BASE_URL: '#{externalUrl}'});
});