Initial commit

This commit is contained in:
Mohit Nagar
2025-09-27 12:08:31 +05:30
committed by GitHub
commit f283f6043f
47 changed files with 9418 additions and 0 deletions

1
.eslintignore Normal file
View File

@@ -0,0 +1 @@
src/public/

44
.eslintrc.json Normal file
View File

@@ -0,0 +1,44 @@
{
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:node/recommended"
],
"parserOptions": {
"project": "./tsconfig.json"
},
"rules": {
"@typescript-eslint/explicit-member-accessibility": "warn",
"@typescript-eslint/no-misused-promises": 0,
"@typescript-eslint/no-floating-promises": 0,
"max-len": [
"warn",
{
"code": 80
}
],
"comma-dangle": ["warn", "always-multiline"],
"no-console": 1,
"no-extra-boolean-cast": 0,
"semi": 1,
"indent": ["warn", 2],
"quotes": ["warn", "single"],
"node/no-process-env": 1,
"node/no-unsupported-features/es-syntax": [
"error",
{ "ignores" : ["modules"] }
],
"node/no-missing-import": 0,
"node/no-unpublished-import": 0
},
"settings": {
"node": {
"tryExtensions": [".js", ".json", ".node", ".ts"]
}
}
}

136
.gitignore vendored Normal file
View File

@@ -0,0 +1,136 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-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/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# 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 variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
**/node_modules
dist/
**/**/*.log
# 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
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# 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
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.DS_Store

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 ReStruct Corporate Advantage
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

47
README.md Normal file
View File

@@ -0,0 +1,47 @@
# Seer
Armco Search and Insights APIs, analytics endpoint, search results retriever, data enrichers, adapters
## About
This project was created with [express-generator-typescript](https://github.com/seanpmaxwell/express-generator-typescript).
## Available Scripts
### `npm run dev`
Run the server in development mode.
### `npm test`
Run all unit-tests with hot-reloading.
### `npm test -- --testFile="name of test file" (i.e. --testFile=Users).`
Run a single unit-test.
### `npm run test:no-reloading`
Run all unit-tests without hot-reloading.
### `npm run lint`
Check for linting errors.
### `npm run build`
Build the project for production.
### `npm start`
Run the production build (Must be built first).
### `npm start -- --env="name of env file" (default is production).`
Run production build with a different env file.
## Additional Notes
- If `npm run dev` gives you issues with bcrypt on MacOS you may need to run: `npm rebuild bcrypt --build-from-source`.

64
build.ts Normal file
View File

@@ -0,0 +1,64 @@
/**
* Remove old files, copy front-end ones.
*/
import fs from 'fs-extra';
import logger from 'jet-logger';
import childProcess from 'child_process';
/**
* Start
*/
(async () => {
try {
// Remove current build
await remove('./dist/');
// Copy front-end files
await copy('./src/public', './dist/public');
await copy('./src/views', './dist/views');
// Copy back-end files
await exec('tsc --build tsconfig.prod.json', './');
} catch (err) {
logger.err(err);
}
})();
/**
* Remove file
*/
function remove(loc: string): Promise<void> {
return new Promise((res, rej) => {
return fs.remove(loc, (err) => {
return (!!err ? rej(err) : res());
});
});
}
/**
* Copy file.
*/
function copy(src: string, dest: string): Promise<void> {
return new Promise((res, rej) => {
return fs.copy(src, dest, (err) => {
return (!!err ? rej(err) : res());
});
});
}
/**
* Do command line command.
*/
function exec(cmd: string, loc: string): Promise<void> {
return new Promise((res, rej) => {
return childProcess.exec(cmd, {cwd: loc}, (err, stdout, stderr) => {
if (!!stdout) {
logger.info(stdout);
}
if (!!stderr) {
logger.warn(stderr);
}
return (!!err ? rej(err) : res());
});
});
}

24
env/development.env vendored Normal file
View File

@@ -0,0 +1,24 @@
## Environment ##
NODE_ENV=development
## Server ##
PORT=3000
HOST=localhost
## Setup jet-logger ##
JET_LOGGER_MODE=CONSOLE
JET_LOGGER_FILEPATH=jet-logger.log
JET_LOGGER_TIMESTAMP=TRUE
JET_LOGGER_FORMAT=LINE
## Authentication ##
COOKIE_DOMAIN=localhost
COOKIE_PATH=/
SECURE_COOKIE=false
JWT_SECRET=xxxxxxxxxxxxxx
COOKIE_SECRET=xxxxxxxxxxxxxx
# expires in 3 days
COOKIE_EXP=259200000

25
env/production.env vendored Normal file
View File

@@ -0,0 +1,25 @@
## Environment ##
NODE_ENV=production
## Server ##
PORT=8081
HOST=localhost
## Setup jet-logger ##
JET_LOGGER_MODE=FILE
JET_LOGGER_FILEPATH=jet-logger.log
JET_LOGGER_TIMESTAMP=TRUE
JET_LOGGER_FORMAT=LINE
## Authentication ##
# SECURE_COOKIE 'false' here for demo-ing. But ideally should be true.
COOKIE_DOMAIN=localhost
COOKIE_PATH=/
SECURE_COOKIE=false
JWT_SECRET=xxxxxxxxxxxxxx
COOKIE_SECRET=xxxxxxxxxxxxxx
# expires in 3 days
COOKIE_EXP=259200000

24
env/test.env vendored Normal file
View File

@@ -0,0 +1,24 @@
## Environment ##
NODE_ENV=test
## Server ##
PORT=4000
HOST=localhost
## Setup jet-logger ##
JET_LOGGER_MODE=CONSOLE
JET_LOGGER_FILEPATH=jet-logger.log
JET_LOGGER_TIMESTAMP=TRUE
JET_LOGGER_FORMAT=LINE
## Authentication ##
COOKIE_DOMAIN=localhost
COOKIE_PATH=/
SECURE_COOKIE=false
JWT_SECRET=xxxxxxxxxxxxxx
COOKIE_SECRET=xxxxxxxxxxxxxx
# expires in 3 days
COOKIE_EXP=259200000

6985
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

66
package.json Normal file
View File

@@ -0,0 +1,66 @@
{
"name": "Seer",
"version": "0.0.0",
"scripts": {
"build": "npx ts-node build.ts",
"lint": "npx eslint --ext .ts src/",
"lint:tests": "npx eslint --ext .ts spec/",
"start": "node -r module-alias/register ./dist --env=production",
"dev": "nodemon",
"test": "nodemon --config ./spec/nodemon.json",
"test:no-reloading": "npx ts-node --files -r tsconfig-paths/register ./spec"
},
"nodemonConfig": {
"watch": [
"src"
],
"ext": "ts, html",
"ignore": [
"src/public"
],
"exec": "./node_modules/.bin/ts-node --files -r tsconfig-paths/register ./src"
},
"_moduleAliases": {
"@src": "dist"
},
"engines": {
"node": ">=8.10.0"
},
"dependencies": {
"cookie-parser": "^1.4.6",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
"helmet": "^7.0.0",
"inserturlparams": "^1.0.1",
"jet-logger": "^1.3.1",
"jet-validator": "^1.1.1",
"jsonfile": "^6.1.0",
"module-alias": "^2.2.3",
"morgan": "^1.10.0",
"ts-command-line-args": "^2.5.1"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.3",
"@types/express": "^4.17.17",
"@types/find": "^0.2.1",
"@types/fs-extra": "^11.0.1",
"@types/jasmine": "^4.3.5",
"@types/jsonfile": "^6.1.1",
"@types/morgan": "^1.9.4",
"@types/node": "^20.4.2",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.44.0",
"eslint-plugin-node": "^11.1.0",
"find": "^0.3.0",
"fs-extra": "^11.1.1",
"jasmine": "^5.0.2",
"nodemon": "^3.0.1",
"supertest": "^6.3.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.6"
}
}

77
spec/index.ts Normal file
View File

@@ -0,0 +1,77 @@
import dotenv from 'dotenv';
import find from 'find';
import Jasmine from 'jasmine';
import { parse } from 'ts-command-line-args';
import logger from 'jet-logger';
// **** Types **** //
interface IArgs {
testFile: string;
}
// **** Setup **** //
// ** Init ** //
// NOTE: MUST BE FIRST!! Load env vars
const result2 = dotenv.config({
path: './env/test.env',
});
if (result2.error) {
throw result2.error;
}
// Setup command line options.
const args = parse<IArgs>({
testFile: {
type: String,
defaultValue: '',
},
});
// ** Start Jasmine ** //
// Init Jasmine
const jasmine = new Jasmine();
jasmine.exitOnCompletion = false;
// Set location of test files
jasmine.loadConfig({
random: true,
spec_dir: 'spec',
spec_files: [
'./tests/**/*.spec.ts',
],
stopSpecOnExpectationFailure: false,
});
// Run all or a single unit-test
let execResp: Promise<jasmine.JasmineDoneInfo> | undefined;
if (args.testFile) {
const testFile = args.testFile;
find.file(testFile + '.spec.ts', './spec', (files: string[]) => {
if (files.length === 1) {
jasmine.execute([files[0]]);
} else {
logger.err('Test file not found!');
}
});
} else {
execResp = jasmine.execute();
}
// Wait for tests to finish
(async () => {
if (!!execResp) {
const info = await execResp;
if (info.overallStatus === 'passed') {
logger.info('All tests have passed :)');
} else {
logger.err('At least one test has failed :(');
}
}
})();

6
spec/nodemon.json Normal file
View File

@@ -0,0 +1,6 @@
{
"watch": ["spec"],
"ext": "spec.ts",
"ignore": ["spec/support"],
"exec": "./node_modules/.bin/ts-node --files -r tsconfig-paths/register ./spec"
}

11
spec/support/jasmine.json Normal file
View File

@@ -0,0 +1,11 @@
{
"spec_dir": "spec",
"spec_files": [
"**/*[sS]pec.ts"
],
"helpers": [
"helpers/**/*.js"
],
"stopSpecOnExpectationFailure": false,
"random": true
}

1
spec/support/types.ts Normal file
View File

@@ -0,0 +1 @@
export type TReqBody = string | object | undefined;

213
spec/tests/users.spec.ts Normal file
View File

@@ -0,0 +1,213 @@
import supertest, { SuperTest, Test, Response } from 'supertest';
import { defaultErrMsg as ValidatorErr } from 'jet-validator';
import insertUrlParams from 'inserturlparams';
import app from '@src/server';
import UserRepo from '@src/repos/UserRepo';
import User from '@src/models/User';
import HttpStatusCodes from '@src/constants/HttpStatusCodes';
import { USER_NOT_FOUND_ERR } from '@src/services/UserService';
import FullPaths from '@src/routes/constants/FullPaths';
import { TReqBody } from 'spec/support/types';
// **** Variables **** //
// Paths
const {
Get,
Add,
Update,
Delete,
} = FullPaths.Users;
// StatusCodes
const {
OK,
CREATED,
NOT_FOUND,
BAD_REQUEST,
} = HttpStatusCodes;
// Dummy users for GET req
const DummyGetAllUsers = [
User.new('Sean Maxwell', 'sean.maxwell@gmail.com'),
User.new('John Smith', 'john.smith@gmail.com'),
User.new('Gordan Freeman', 'gordan.freeman@gmail.com'),
] as const;
// Dummy update user
const DummyUserData = {
user: User.new('Gordan Freeman', 'gordan.freeman@gmail.com'),
} as const;
// **** Tests **** //
describe('UserRouter', () => {
let agent: SuperTest<Test>;
// Run before all tests
beforeAll((done) => {
agent = supertest.agent(app);
done();
});
// ** Get all users ** //
describe(`"GET:${Get}"`, () => {
const callApi = () => agent.get(Get);
// Success
it('should return a JSON object with all the users and a status code ' +
`of "${OK}" if the request was successful.`, (done) => {
// Add spy
spyOn(UserRepo, 'getAll').and.resolveTo([...DummyGetAllUsers]);
// Call API
callApi()
.end((_: Error, res: Response) => {
expect(res.status).toBe(OK);
for (let i = 0; i < res.body.users.length; i++) {
const user = res.body.users[i];
expect(user).toEqual(DummyGetAllUsers[i]);
}
done();
});
});
});
// Test add user
describe(`"POST:${Add}"`, () => {
const callApi = (reqBody: TReqBody) =>
agent
.post(Add)
.type('form').send(reqBody);
// Test add user success
it(`should return a status code of "${CREATED}" if the request was ` +
'successful.', (done) => {
// Spy
spyOn(UserRepo, 'add').and.resolveTo();
// Call api
callApi(DummyUserData)
.end((_: Error, res: Response) => {
expect(res.status).toBe(CREATED);
expect(res.body.error).toBeUndefined();
done();
});
});
// Missing param
it('should return a JSON object with an error message of ' +
`"${ValidatorErr}" and a status code of "${BAD_REQUEST}" if the user ` +
'param was missing.', (done) => {
// Call api
callApi({})
.end((_: Error, res: Response) => {
expect(res.status).toBe(BAD_REQUEST);
expect(res.body.error).toBe(ValidatorErr);
done();
});
});
});
// ** Update users ** //
describe(`"PUT:${Update}"`, () => {
const callApi = (reqBody: TReqBody) =>
agent
.put(Update)
.type('form').send(reqBody);
// Success
it(`should return a status code of "${OK}" if the request was successful.`,
(done) => {
// Setup spies
spyOn(UserRepo, 'update').and.resolveTo();
spyOn(UserRepo, 'persists').and.resolveTo(true);
// Call api
callApi(DummyUserData)
.end((_: Error, res: Response) => {
expect(res.status).toBe(OK);
expect(res.body.error).toBeUndefined();
done();
});
});
// Param missing
it('should return a JSON object with an error message of ' +
`"${ValidatorErr}" and a status code of "${BAD_REQUEST}" if the user ` +
'param was missing.', (done) => {
// Call api
callApi({})
.end((_: Error, res: Response) => {
expect(res.status).toBe(BAD_REQUEST);
expect(res.body.error).toBe(ValidatorErr);
done();
});
});
// User not found
it('should return a JSON object with the error message of ' +
`"${USER_NOT_FOUND_ERR}" and a status code of "${NOT_FOUND}" if the id ` +
'was not found.', (done) => {
// Call api
callApi(DummyUserData)
.end((_: Error, res: Response) => {
expect(res.status).toBe(NOT_FOUND);
expect(res.body.error).toBe(USER_NOT_FOUND_ERR);
done();
});
});
});
// ** Delete user ** //
describe(`"DELETE:${Delete}"`, () => {
const callApi = (id: number) =>
agent
.delete(insertUrlParams(Delete, { id }));
// Success
it(`should return a status code of "${OK}" if the request was successful.`,
(done) => {
// Setup spies
spyOn(UserRepo, 'delete').and.resolveTo();
spyOn(UserRepo, 'persists').and.resolveTo(true);
// Call api
callApi(5)
.end((_: Error, res: Response) => {
expect(res.status).toBe(OK);
expect(res.body.error).toBeUndefined();
done();
});
});
// User not found
it('should return a JSON object with the error message of ' +
`"${USER_NOT_FOUND_ERR}" and a status code of "${NOT_FOUND}" if the id ` +
'was not found.', (done) => {
callApi(-1)
.end((_: Error, res: Response) => {
expect(res.status).toBe(NOT_FOUND);
expect(res.body.error).toBe(USER_NOT_FOUND_ERR);
done();
});
});
// Invalid param
it(`should return a status code of "${BAD_REQUEST}" and return an error ` +
`message of "${ValidatorErr}" if the id was not a valid number`, (done) => {
callApi('horse' as unknown as number)
.end((_: Error, res: Response) => {
expect(res.status).toBe(BAD_REQUEST);
expect(res.body.error).toBe(ValidatorErr);
done();
});
});
});
});

14
spec/types/supertest/index.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
import { IUser } from '@src/models/User';
import 'supertest';
declare module 'supertest' {
export interface Response {
headers: Record<string, string[]>;
body: {
error: string;
users: IUser[];
};
}
}

28
src/constants/EnvVars.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* Environments variables declared here.
*/
/* eslint-disable node/no-process-env */
export default {
NodeEnv: (process.env.NODE_ENV ?? ''),
Port: (process.env.PORT ?? 0),
CookieProps: {
Key: 'ExpressGeneratorTs',
Secret: (process.env.COOKIE_SECRET ?? ''),
// Casing to match express cookie options
Options: {
httpOnly: true,
signed: true,
path: (process.env.COOKIE_PATH ?? ''),
maxAge: Number(process.env.COOKIE_EXP ?? 0),
domain: (process.env.COOKIE_DOMAIN ?? ''),
secure: (process.env.SECURE_COOKIE === 'true'),
},
},
Jwt: {
Secret: (process.env.JWT_SECRET ?? ''),
Exp: (process.env.COOKIE_EXP ?? ''), // exp at the same time as the cookie
},
} as const;

View File

@@ -0,0 +1,386 @@
/* eslint-disable max-len */
/**
* This file was copied from here: https://gist.github.com/scokmen/f813c904ef79022e84ab2409574d1b45
*/
/**
* Hypertext Transfer Protocol (HTTP) response status codes.
* @see {@link https://en.wikipedia.org/wiki/List_of_HTTP_status_codes}
*/
enum HttpStatusCodes {
/**
* The server has received the request headers and the client should proceed to send the request body
* (in the case of a request for which a body needs to be sent; for example, a POST request).
* Sending a large request body to a server after a request has been rejected for inappropriate headers would be inefficient.
* To have a server check the request's headers, a client must send Expect: 100-continue as a header in its initial request
* and receive a 100 Continue status code in response before sending the body. The response 417 Expectation Failed indicates the request should not be continued.
*/
CONTINUE = 100,
/**
* The requester has asked the server to switch protocols and the server has agreed to do so.
*/
SWITCHING_PROTOCOLS = 101,
/**
* A WebDAV request may contain many sub-requests involving file operations, requiring a long time to complete the request.
* This code indicates that the server has received and is processing the request, but no response is available yet.
* This prevents the client from timing out and assuming the request was lost.
*/
PROCESSING = 102,
/**
* Standard response for successful HTTP requests.
* The actual response will depend on the request method used.
* In a GET request, the response will contain an entity corresponding to the requested resource.
* In a POST request, the response will contain an entity describing or containing the result of the action.
*/
OK = 200,
/**
* The request has been fulfilled, resulting in the creation of a new resource.
*/
CREATED = 201,
/**
* The request has been accepted for processing, but the processing has not been completed.
* The request might or might not be eventually acted upon, and may be disallowed when processing occurs.
*/
ACCEPTED = 202,
/**
* SINCE HTTP/1.1
* The server is a transforming proxy that received a 200 OK from its origin,
* but is returning a modified version of the origin's response.
*/
NON_AUTHORITATIVE_INFORMATION = 203,
/**
* The server successfully processed the request and is not returning any content.
*/
NO_CONTENT = 204,
/**
* The server successfully processed the request, but is not returning any content.
* Unlike a 204 response, this response requires that the requester reset the document view.
*/
RESET_CONTENT = 205,
/**
* The server is delivering only part of the resource (byte serving) due to a range header sent by the client.
* The range header is used by HTTP clients to enable resuming of interrupted downloads,
* or split a download into multiple simultaneous streams.
*/
PARTIAL_CONTENT = 206,
/**
* The message body that follows is an XML message and can contain a number of separate response codes,
* depending on how many sub-requests were made.
*/
MULTI_STATUS = 207,
/**
* The members of a DAV binding have already been enumerated in a preceding part of the (multistatus) response,
* and are not being included again.
*/
ALREADY_REPORTED = 208,
/**
* The server has fulfilled a request for the resource,
* and the response is a representation of the result of one or more instance-manipulations applied to the current instance.
*/
IM_USED = 226,
/**
* Indicates multiple options for the resource from which the client may choose (via agent-driven content negotiation).
* For example, this code could be used to present multiple video format options,
* to list files with different filename extensions, or to suggest word-sense disambiguation.
*/
MULTIPLE_CHOICES = 300,
/**
* This and all future requests should be directed to the given URI.
*/
MOVED_PERMANENTLY = 301,
/**
* This is an example of industry practice contradicting the standard.
* The HTTP/1.0 specification (RFC 1945) required the client to perform a temporary redirect
* (the original describing phrase was "Moved Temporarily"), but popular browsers implemented 302
* with the functionality of a 303 See Other. Therefore, HTTP/1.1 added status codes 303 and 307
* to distinguish between the two behaviours. However, some Web applications and frameworks
* use the 302 status code as if it were the 303.
*/
FOUND = 302,
/**
* SINCE HTTP/1.1
* The response to the request can be found under another URI using a GET method.
* When received in response to a POST (or PUT/DELETE), the client should presume that
* the server has received the data and should issue a redirect with a separate GET message.
*/
SEE_OTHER = 303,
/**
* Indicates that the resource has not been modified since the version specified by the request headers If-Modified-Since or If-None-Match.
* In such case, there is no need to retransmit the resource since the client still has a previously-downloaded copy.
*/
NOT_MODIFIED = 304,
/**
* SINCE HTTP/1.1
* The requested resource is available only through a proxy, the address for which is provided in the response.
* Many HTTP clients (such as Mozilla and Internet Explorer) do not correctly handle responses with this status code, primarily for security reasons.
*/
USE_PROXY = 305,
/**
* No longer used. Originally meant "Subsequent requests should use the specified proxy."
*/
SWITCH_PROXY = 306,
/**
* SINCE HTTP/1.1
* In this case, the request should be repeated with another URI; however, future requests should still use the original URI.
* In contrast to how 302 was historically implemented, the request method is not allowed to be changed when reissuing the original request.
* For example, a POST request should be repeated using another POST request.
*/
TEMPORARY_REDIRECT = 307,
/**
* The request and all future requests should be repeated using another URI.
* 307 and 308 parallel the behaviors of 302 and 301, but do not allow the HTTP method to change.
* So, for example, submitting a form to a permanently redirected resource may continue smoothly.
*/
PERMANENT_REDIRECT = 308,
/**
* The server cannot or will not process the request due to an apparent client error
* (e.g., malformed request syntax, too large size, invalid request message framing, or deceptive request routing).
*/
BAD_REQUEST = 400,
/**
* Similar to 403 Forbidden, but specifically for use when authentication is required and has failed or has not yet
* been provided. The response must include a WWW-Authenticate header field containing a challenge applicable to the
* requested resource. See Basic access authentication and Digest access authentication. 401 semantically means
* "unauthenticated",i.e. the user does not have the necessary credentials.
*/
UNAUTHORIZED = 401,
/**
* Reserved for future use. The original intention was that this code might be used as part of some form of digital
* cash or micro payment scheme, but that has not happened, and this code is not usually used.
* Google Developers API uses this status if a particular developer has exceeded the daily limit on requests.
*/
PAYMENT_REQUIRED = 402,
/**
* The request was valid, but the server is refusing action.
* The user might not have the necessary permissions for a resource.
*/
FORBIDDEN = 403,
/**
* The requested resource could not be found but may be available in the future.
* Subsequent requests by the client are permissible.
*/
NOT_FOUND = 404,
/**
* A request method is not supported for the requested resource;
* for example, a GET request on a form that requires data to be presented via POST, or a PUT request on a read-only resource.
*/
METHOD_NOT_ALLOWED = 405,
/**
* The requested resource is capable of generating only content not acceptable according to the Accept headers sent in the request.
*/
NOT_ACCEPTABLE = 406,
/**
* The client must first authenticate itself with the proxy.
*/
PROXY_AUTHENTICATION_REQUIRED = 407,
/**
* The server timed out waiting for the request.
* According to HTTP specifications:
* "The client did not produce a request within the time that the server was prepared to wait. The client MAY repeat the request without modifications at any later time."
*/
REQUEST_TIMEOUT = 408,
/**
* Indicates that the request could not be processed because of conflict in the request,
* such as an edit conflict between multiple simultaneous updates.
*/
CONFLICT = 409,
/**
* Indicates that the resource requested is no longer available and will not be available again.
* This should be used when a resource has been intentionally removed and the resource should be purged.
* Upon receiving a 410 status code, the client should not request the resource in the future.
* Clients such as search engines should remove the resource from their indices.
* Most use cases do not require clients and search engines to purge the resource, and a "404 Not Found" may be used instead.
*/
GONE = 410,
/**
* The request did not specify the length of its content, which is required by the requested resource.
*/
LENGTH_REQUIRED = 411,
/**
* The server does not meet one of the preconditions that the requester put on the request.
*/
PRECONDITION_FAILED = 412,
/**
* The request is larger than the server is willing or able to process. Previously called "Request Entity Too Large".
*/
PAYLOAD_TOO_LARGE = 413,
/**
* The URI provided was too long for the server to process. Often the result of too much data being encoded as a query-string of a GET request,
* in which case it should be converted to a POST request.
* Called "Request-URI Too Long" previously.
*/
URI_TOO_LONG = 414,
/**
* The request entity has a media type which the server or resource does not support.
* For example, the client uploads an image as image/svg+xml, but the server requires that images use a different format.
*/
UNSUPPORTED_MEDIA_TYPE = 415,
/**
* The client has asked for a portion of the file (byte serving), but the server cannot supply that portion.
* For example, if the client asked for a part of the file that lies beyond the end of the file.
* Called "Requested Range Not Satisfiable" previously.
*/
RANGE_NOT_SATISFIABLE = 416,
/**
* The server cannot meet the requirements of the Expect request-header field.
*/
EXPECTATION_FAILED = 417,
/**
* This code was defined in 1998 as one of the traditional IETF April Fools' jokes, in RFC 2324, Hyper Text Coffee Pot Control Protocol,
* and is not expected to be implemented by actual HTTP servers. The RFC specifies this code should be returned by
* teapots requested to brew coffee. This HTTP status is used as an Easter egg in some websites, including Google.com.
*/
I_AM_A_TEAPOT = 418,
/**
* The request was directed at a server that is not able to produce a response (for example because a connection reuse).
*/
MISDIRECTED_REQUEST = 421,
/**
* The request was well-formed but was unable to be followed due to semantic errors.
*/
UNPROCESSABLE_ENTITY = 422,
/**
* The resource that is being accessed is locked.
*/
LOCKED = 423,
/**
* The request failed due to failure of a previous request (e.g., a PROPPATCH).
*/
FAILED_DEPENDENCY = 424,
/**
* The client should switch to a different protocol such as TLS/1.0, given in the Upgrade header field.
*/
UPGRADE_REQUIRED = 426,
/**
* The origin server requires the request to be conditional.
* Intended to prevent "the 'lost update' problem, where a client
* GETs a resource's state, modifies it, and PUTs it back to the server,
* when meanwhile a third party has modified the state on the server, leading to a conflict."
*/
PRECONDITION_REQUIRED = 428,
/**
* The user has sent too many requests in a given amount of time. Intended for use with rate-limiting schemes.
*/
TOO_MANY_REQUESTS = 429,
/**
* The server is unwilling to process the request because either an individual header field,
* or all the header fields collectively, are too large.
*/
REQUEST_HEADER_FIELDS_TOO_LARGE = 431,
/**
* A server operator has received a legal demand to deny access to a resource or to a set of resources
* that includes the requested resource. The code 451 was chosen as a reference to the novel Fahrenheit 451.
*/
UNAVAILABLE_FOR_LEGAL_REASONS = 451,
/**
* A generic error message, given when an unexpected condition was encountered and no more specific message is suitable.
*/
INTERNAL_SERVER_ERROR = 500,
/**
* The server either does not recognize the request method, or it lacks the ability to fulfill the request.
* Usually this implies future availability (e.g., a new feature of a web-service API).
*/
NOT_IMPLEMENTED = 501,
/**
* The server was acting as a gateway or proxy and received an invalid response from the upstream server.
*/
BAD_GATEWAY = 502,
/**
* The server is currently unavailable (because it is overloaded or down for maintenance).
* Generally, this is a temporary state.
*/
SERVICE_UNAVAILABLE = 503,
/**
* The server was acting as a gateway or proxy and did not receive a timely response from the upstream server.
*/
GATEWAY_TIMEOUT = 504,
/**
* The server does not support the HTTP protocol version used in the request
*/
HTTP_VERSION_NOT_SUPPORTED = 505,
/**
* Transparent content negotiation for the request results in a circular reference.
*/
VARIANT_ALSO_NEGOTIATES = 506,
/**
* The server is unable to store the representation needed to complete the request.
*/
INSUFFICIENT_STORAGE = 507,
/**
* The server detected an infinite loop while processing the request.
*/
LOOP_DETECTED = 508,
/**
* Further extensions to the request are required for the server to fulfill it.
*/
NOT_EXTENDED = 510,
/**
* The client needs to authenticate to gain network access.
* Intended for use by intercepting proxies used to control access to the network (e.g., "captive portals" used
* to require agreement to Terms of Service before granting full Internet access via a Wi-Fi hotspot).
*/
NETWORK_AUTHENTICATION_REQUIRED = 511
}
export default HttpStatusCodes;

5
src/constants/misc.ts Normal file
View File

@@ -0,0 +1,5 @@
export enum NodeEnvs {
Dev = 'development',
Test = 'test',
Production = 'production'
}

13
src/index.ts Normal file
View File

@@ -0,0 +1,13 @@
import './pre-start'; // Must be the first import
import logger from 'jet-logger';
import EnvVars from '@src/constants/EnvVars';
import server from './server';
// **** Run **** //
const SERVER_START_MSG = ('Express server started on port: ' +
EnvVars.Port.toString());
server.listen(EnvVars.Port, () => logger.info(SERVER_START_MSG));

85
src/models/User.ts Normal file
View File

@@ -0,0 +1,85 @@
// **** Variables **** //
const INVALID_CONSTRUCTOR_PARAM = 'nameOrObj arg must a string or an ' +
'object with the appropriate user keys.';
export enum UserRoles {
Standard,
Admin,
}
// **** Types **** //
export interface IUser {
id: number;
name: string;
email: string;
pwdHash?: string;
role?: UserRoles;
}
export interface ISessionUser {
id: number;
email: string;
name: string;
role: IUser['role'];
}
// **** Functions **** //
/**
* Create new User.
*/
function new_(
name?: string,
email?: string,
role?: UserRoles,
pwdHash?: string,
id?: number, // id last cause usually set by db
): IUser {
return {
id: (id ?? -1),
name: (name ?? ''),
email: (email ?? ''),
role: (role ?? UserRoles.Standard),
pwdHash: (pwdHash ?? ''),
};
}
/**
* Get user instance from object.
*/
function from(param: object): IUser {
// Check is user
if (!isUser(param)) {
throw new Error(INVALID_CONSTRUCTOR_PARAM);
}
// Get user instance
const p = param as IUser;
return new_(p.name, p.email, p.role, p.pwdHash, p.id);
}
/**
* See if the param meets criteria to be a user.
*/
function isUser(arg: unknown): boolean {
return (
!!arg &&
typeof arg === 'object' &&
'id' in arg &&
'email' in arg &&
'name' in arg &&
'role' in arg
);
}
// **** Export default **** //
export default {
new: new_,
from,
isUser,
} as const;

17
src/other/classes.ts Normal file
View File

@@ -0,0 +1,17 @@
/**
* Miscellaneous shared classes go here.
*/
import HttpStatusCodes from '@src/constants/HttpStatusCodes';
/**
* Error with status code and message
*/
export class RouteError extends Error {
status: HttpStatusCodes;
constructor(status: HttpStatusCodes, message: string) {
super(message);
this.status = status;
}
}

4
src/other/types.ts Normal file
View File

@@ -0,0 +1,4 @@
export type Immutable<T> = {
readonly [K in keyof T]: Immutable<T[K]>;
};

37
src/pre-start.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* Pre-start is where we want to place things that must run BEFORE the express
* server is started. This is useful for environment variables, command-line
* arguments, and cron-jobs.
*/
// NOTE: DO NOT IMPORT ANY SOURCE CODE HERE
import path from 'path';
import dotenv from 'dotenv';
import { parse } from 'ts-command-line-args';
// **** Types **** //
interface IArgs {
env: string;
}
// **** Setup **** //
// Command line arguments
const args = parse<IArgs>({
env: {
type: String,
defaultValue: 'development',
alias: 'e',
},
});
// Set the env file
const result2 = dotenv.config({
path: path.join(__dirname, `../env/${args.env}.env`),
});
if (result2.error) {
throw result2.error;
}

View File

@@ -0,0 +1,24 @@
var Http = (() => {
// Setup request for json
var getOptions = (verb, data) => {
var options = {
dataType: 'json',
method: verb,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
};
if (data) {
options.body = JSON.stringify(data);
}
return options;
};
// Set Http methods
return {
get: (path) => fetch(path, getOptions('GET')),
post: (path, data) => fetch(path, getOptions('POST', data)),
put: (path, data) => fetch(path, getOptions('PUT', data)),
delete: (path) => fetch(path, getOptions('DELETE')),
};
})();

164
src/public/scripts/users.js Normal file
View File

@@ -0,0 +1,164 @@
// ***** Start **** //
displayUsers();
// ***** Fetch and display users **** //
/**
* Call api
*/
function displayUsers() {
Http
.get('/api/users/all')
.then(resp => resp.json())
.then((resp) => {
var allUsers = resp.users;
// Empty the anchor
var allUsersAnchor = document.getElementById('all-users-anchor');
allUsersAnchor.innerHTML = '';
// Append users to anchor
allUsers.forEach((user) => {
allUsersAnchor.innerHTML += getUserDisplayEle(user);
});
});
}
/**
* Get user display element
*/
function getUserDisplayEle(user) {
return (
`<div class="user-display-ele">
<div class="normal-view">
<div>Name: ${user.name}</div>
<div>Email: ${user.email}</div>
<button class="edit-user-btn" data-user-id="${user.id}" data-user-role="${user.role}">
Edit
</button>
<button class="delete-user-btn" data-user-id="${user.id}">
Delete
</button>
</div>
<div class="edit-view">
<div>
Name: <input class="name-edit-input" value="${user.name}">
</div>
<div>
Email: <input class="email-edit-input" value="${user.email}">
</div>
<button class="submit-edit-btn" data-user-id="${user.id}">
Submit
</button>
<button class="cancel-edit-btn" data-user-id="${user.id}">
Cancel
</button>
</div>
</div>`
);
}
// **** Add, Edit, and Delete Users **** //
// Setup event listener for button click
document.addEventListener('click', function (event) {
event.preventDefault();
var ele = event.target;
if (ele.matches('#add-user-btn')) {
addUser();
} else if (ele.matches('.edit-user-btn')) {
showEditView(ele.parentNode.parentNode);
} else if (ele.matches('.cancel-edit-btn')) {
cancelEdit(ele.parentNode.parentNode);
} else if (ele.matches('.submit-edit-btn')) {
submitEdit(ele);
} else if (ele.matches('.delete-user-btn')) {
deleteUser(ele);
} else if (ele.matches('#logout-btn')) {
logoutUser();
}
}, false);
/**
* Add a new user.
*/
function addUser() {
var nameInput = document.getElementById('name-input');
var emailInput = document.getElementById('email-input');
var data = {
user: {
id: -1,
name: nameInput.value,
email: emailInput.value,
role: 0,
},
};
// Call api
Http
.post('/api/users/add', data)
.then(() => displayUsers());
}
/**
* Show edit view.
*/
function showEditView(userEle) {
var normalView = userEle.getElementsByClassName('normal-view')[0];
var editView = userEle.getElementsByClassName('edit-view')[0];
normalView.style.display = 'none';
editView.style.display = 'block';
}
/**
* Cancel edit.
*/
function cancelEdit(userEle) {
var normalView = userEle.getElementsByClassName('normal-view')[0];
var editView = userEle.getElementsByClassName('edit-view')[0];
normalView.style.display = 'block';
editView.style.display = 'none';
}
/**
* Submit edit.
*/
function submitEdit(ele) {
var userEle = ele.parentNode.parentNode;
var nameInput = userEle.getElementsByClassName('name-edit-input')[0];
var emailInput = userEle.getElementsByClassName('email-edit-input')[0];
var id = ele.getAttribute('data-user-id');
var role = ele.getAttribute('data-user-role');
var data = {
user: {
id: Number(id),
name: nameInput.value,
email: emailInput.value,
role: Number(role),
},
};
Http
.put('/api/users/update', data)
.then(() => displayUsers());
}
/**
* Delete a user
*/
function deleteUser(ele) {
var id = ele.getAttribute('data-user-id');
Http
.delete('/api/users/delete/' + id)
.then(() => displayUsers());
}
// **** Logout **** //
function logoutUser() {
Http
.get('/api/auth/logout')
.then(() => window.location.href = '/');
}

View File

@@ -0,0 +1,50 @@
body {
padding: 100px;
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
}
body .users-column {
display: inline-block;
margin-right: 2em;
vertical-align: top;
}
body .users-column .column-header {
padding-bottom: 5px;
font-weight: 700;
font-size: 1.2em;
}
/** Add User Column **/
body .add-user-col input {
margin-bottom: 10px;
}
body .add-user-col #add-user-btn {
margin-top: 2px;
margin-bottom: 10px;
}
body .add-user-col #logout-btn {
margin-top: 25px;
margin-bottom: 10px;
font-size: 15px;
}
/** Users Display Column **/
body .users-column .user-display-ele {
padding-bottom: 10px;
}
body .users-column .user-display-ele button {
margin-top: 2px;
margin-bottom: 10px;
}
body .users-column .user-display-ele .edit-view {
display: none;
}

42
src/repos/MockOrm.ts Normal file
View File

@@ -0,0 +1,42 @@
import jsonfile from 'jsonfile';
import { IUser } from '@src/models/User';
// **** Variables **** //
const DB_FILE_NAME = 'database.json';
// **** Types **** //
interface IDb {
users: IUser[];
}
// **** Functions **** //
/**
* Fetch the json from the file.
*/
function openDb(): Promise<IDb> {
return jsonfile.readFile(__dirname + '/' + DB_FILE_NAME) as Promise<IDb>;
}
/**
* Update the file.
*/
function saveDb(db: IDb): Promise<void> {
return jsonfile.writeFile((__dirname + '/' + DB_FILE_NAME), db);
}
// **** Export default **** //
export default {
openDb,
saveDb,
} as const;

88
src/repos/UserRepo.ts Normal file
View File

@@ -0,0 +1,88 @@
import { IUser } from '@src/models/User';
import { getRandomInt } from '@src/util/misc';
import orm from './MockOrm';
// **** Functions **** //
/**
* Get one user.
*/
async function getOne(email: string): Promise<IUser | null> {
const db = await orm.openDb();
for (const user of db.users) {
if (user.email === email) {
return user;
}
}
return null;
}
/**
* See if a user with the given id exists.
*/
async function persists(id: number): Promise<boolean> {
const db = await orm.openDb();
for (const user of db.users) {
if (user.id === id) {
return true;
}
}
return false;
}
/**
* Get all users.
*/
async function getAll(): Promise<IUser[]> {
const db = await orm.openDb();
return db.users;
}
/**
* Add one user.
*/
async function add(user: IUser): Promise<void> {
const db = await orm.openDb();
user.id = getRandomInt();
db.users.push(user);
return orm.saveDb(db);
}
/**
* Update a user.
*/
async function update(user: IUser): Promise<void> {
const db = await orm.openDb();
for (let i = 0; i < db.users.length; i++) {
if (db.users[i].id === user.id) {
db.users[i] = user;
return orm.saveDb(db);
}
}
}
/**
* Delete one user.
*/
async function delete_(id: number): Promise<void> {
const db = await orm.openDb();
for (let i = 0; i < db.users.length; i++) {
if (db.users[i].id === id) {
db.users.splice(i, 1);
return orm.saveDb(db);
}
}
}
// **** Export default **** //
export default {
getOne,
persists,
getAll,
add,
update,
delete: delete_,
} as const;

1
src/repos/database.json Normal file
View File

@@ -0,0 +1 @@
{"users":[{"name":"Sean Maxwell","email":"sean.maxwell@gmail.com","pwdHash":"$2b$12$1mE2OI9hMS/rgH9Mi0s85OM2V5gzm7aF3gJIWH1y0S1MqVBueyjsy","role":1,"id":159123164363},{"name":"Gordan Freeman","email":"gordan.freeman@halflife.com","pwdHash":"$2b$12$1mE2OI9hMS/rgH9Mi0s85OM2V5gzm7aF3gJIWH1y0S1MqVBueyjsy","role":0,"id":906524522143},{"name":"John Smith","email":"jsmith@yahoo.com","pwdHash":"$2b$12$1mE2OI9hMS/rgH9Mi0s85OM2V5gzm7aF3gJIWH1y0S1MqVBueyjsy","role":0,"id":357437875835},{"id":75800032258,"name":"asdf","email":"asdfasdf","role":0}]}

53
src/routes/UserRoutes.ts Normal file
View File

@@ -0,0 +1,53 @@
import HttpStatusCodes from '@src/constants/HttpStatusCodes';
import UserService from '@src/services/UserService';
import { IUser } from '@src/models/User';
import { IReq, IRes } from './types/express/misc';
// **** Functions **** //
/**
* Get all users.
*/
async function getAll(_: IReq, res: IRes) {
const users = await UserService.getAll();
return res.status(HttpStatusCodes.OK).json({ users });
}
/**
* Add one user.
*/
async function add(req: IReq<{user: IUser}>, res: IRes) {
const { user } = req.body;
await UserService.addOne(user);
return res.status(HttpStatusCodes.CREATED).end();
}
/**
* Update one user.
*/
async function update(req: IReq<{user: IUser}>, res: IRes) {
const { user } = req.body;
await UserService.updateOne(user);
return res.status(HttpStatusCodes.OK).end();
}
/**
* Delete one user.
*/
async function delete_(req: IReq, res: IRes) {
const id = +req.params.id;
await UserService.delete(id);
return res.status(HttpStatusCodes.OK).end();
}
// **** Export default **** //
export default {
getAll,
add,
update,
delete: delete_,
} as const;

52
src/routes/api.ts Normal file
View File

@@ -0,0 +1,52 @@
import { Router } from 'express';
import jetValidator from 'jet-validator';
import Paths from './constants/Paths';
import User from '@src/models/User';
import UserRoutes from './UserRoutes';
// **** Variables **** //
const apiRouter = Router(),
validate = jetValidator();
// ** Add UserRouter ** //
const userRouter = Router();
// Get all users
userRouter.get(
Paths.Users.Get,
UserRoutes.getAll,
);
// Add one user
userRouter.post(
Paths.Users.Add,
validate(['user', User.isUser]),
UserRoutes.add,
);
// Update one user
userRouter.put(
Paths.Users.Update,
validate(['user', User.isUser]),
UserRoutes.update,
);
// Delete one user
userRouter.delete(
Paths.Users.Delete,
validate(['id', 'number', 'params']),
UserRoutes.delete,
);
// Add UserRouter
apiRouter.use(Paths.Users.Base, userRouter);
// **** Export default **** //
export default apiRouter;

View File

@@ -0,0 +1,40 @@
/**
* Convert paths to full paths.
*/
import Paths, { TPaths } from './Paths';
interface IPathObj {
Base: string;
[key: string]: string | IPathObj;
}
/**
* The recursive function.
*/
function getFullPaths(
parent: IPathObj,
baseUrl: string,
): IPathObj {
const url = (baseUrl + parent.Base),
keys = Object.keys(parent),
retVal: IPathObj = { Base: url };
// Iterate keys
for (const key of keys) {
const pval = parent[key];
if (key !== 'Base' && typeof pval === 'string') {
retVal[key] = (url + pval);
} else if (typeof pval === 'object') {
retVal[key] = getFullPaths(pval, url);
}
}
// Return
return retVal;
}
// **** Export default **** //
export default getFullPaths(Paths, '') as TPaths;

View File

@@ -0,0 +1,23 @@
/**
* Express router paths go here.
*/
import { Immutable } from '@src/other/types';
const Paths = {
Base: '/api',
Users: {
Base: '/users',
Get: '/all',
Add: '/add',
Update: '/update',
Delete: '/delete/:id',
},
};
// **** Export **** //
export type TPaths = Immutable<typeof Paths>;
export default Paths as TPaths;

11
src/routes/types/express/index.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
import 'express';
// **** Declaration Merging **** //
declare module 'express' {
export interface Request {
signedCookies: Record<string, string>;
}
}

View File

@@ -0,0 +1,16 @@
import * as e from 'express';
import { ISessionUser } from '@src/models/User';
// **** Express **** //
export interface IReq<T = void> extends e.Request {
body: T;
}
export interface IRes extends e.Response {
locals: {
sessionUser?: ISessionUser;
};
}

22
src/routes/types/types.ts Normal file
View File

@@ -0,0 +1,22 @@
import * as e from 'express';
import { Query } from 'express-serve-static-core';
import { ISessionUser } from '@src/models/User';
// **** Express **** //
export interface IReq<T = void> extends e.Request {
body: T;
}
export interface IReqQuery<T extends Query, U = void> extends e.Request {
query: T;
body: U;
}
export interface IRes extends e.Response {
locals: {
sessionUser: ISessionUser;
};
}

91
src/server.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* Setup express server.
*/
import cookieParser from 'cookie-parser';
import morgan from 'morgan';
import path from 'path';
import helmet from 'helmet';
import express, { Request, Response, NextFunction } from 'express';
import logger from 'jet-logger';
import 'express-async-errors';
import BaseRouter from '@src/routes/api';
import Paths from '@src/routes/constants/Paths';
import EnvVars from '@src/constants/EnvVars';
import HttpStatusCodes from '@src/constants/HttpStatusCodes';
import { NodeEnvs } from '@src/constants/misc';
import { RouteError } from '@src/other/classes';
// **** Variables **** //
const app = express();
// **** Setup **** //
// Basic middleware
app.use(express.json());
app.use(express.urlencoded({extended: true}));
app.use(cookieParser(EnvVars.CookieProps.Secret));
// Show routes called in console during development
if (EnvVars.NodeEnv === NodeEnvs.Dev) {
app.use(morgan('dev'));
}
// Security
if (EnvVars.NodeEnv === NodeEnvs.Production) {
app.use(helmet());
}
// Add APIs, must be after middleware
app.use(Paths.Base, BaseRouter);
// Add error handler
app.use((
err: Error,
_: Request,
res: Response,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
next: NextFunction,
) => {
if (EnvVars.NodeEnv !== NodeEnvs.Test) {
logger.err(err, true);
}
let status = HttpStatusCodes.BAD_REQUEST;
if (err instanceof RouteError) {
status = err.status;
}
return res.status(status).json({ error: err.message });
});
// ** Front-End Content ** //
// Set views directory (html)
const viewsDir = path.join(__dirname, 'views');
app.set('views', viewsDir);
// Set static directory (js and css).
const staticDir = path.join(__dirname, 'public');
app.use(express.static(staticDir));
// Nav to users pg by default
app.get('/', (_: Request, res: Response) => {
return res.redirect('/users');
});
// Redirect to login if not logged in.
app.get('/users', (_: Request, res: Response) => {
return res.sendFile('users.html', { root: viewsDir });
});
// **** Export default **** //
export default app;

View File

@@ -0,0 +1,56 @@
import UserRepo from '@src/repos/UserRepo';
import PwdUtil from '@src/util/PwdUtil';
import { tick } from '@src/util/misc';
import HttpStatusCodes from '@src/constants/HttpStatusCodes';
import { RouteError } from '@src/other/classes';
import { IUser } from '@src/models/User';
// **** Variables **** //
// Errors
export const Errors = {
Unauth: 'Unauthorized',
EmailNotFound(email: string) {
return `User with email "${email}" not found`;
},
} as const;
// **** Functions **** //
/**
* Login a user.
*/
async function login(email: string, password: string): Promise<IUser> {
// Fetch user
const user = await UserRepo.getOne(email);
if (!user) {
throw new RouteError(
HttpStatusCodes.UNAUTHORIZED,
Errors.EmailNotFound(email),
);
}
// Check password
const hash = (user.pwdHash ?? ''),
pwdPassed = await PwdUtil.compare(password, hash);
if (!pwdPassed) {
// If password failed, wait 500ms this will increase security
await tick(500);
throw new RouteError(
HttpStatusCodes.UNAUTHORIZED,
Errors.Unauth,
);
}
// Return
return user;
}
// **** Export default **** //
export default {
login,
} as const;

View File

@@ -0,0 +1,66 @@
import UserRepo from '@src/repos/UserRepo';
import { IUser } from '@src/models/User';
import { RouteError } from '@src/other/classes';
import HttpStatusCodes from '@src/constants/HttpStatusCodes';
// **** Variables **** //
export const USER_NOT_FOUND_ERR = 'User not found';
// **** Functions **** //
/**
* Get all users.
*/
function getAll(): Promise<IUser[]> {
return UserRepo.getAll();
}
/**
* Add one user.
*/
function addOne(user: IUser): Promise<void> {
return UserRepo.add(user);
}
/**
* Update one user.
*/
async function updateOne(user: IUser): Promise<void> {
const persists = await UserRepo.persists(user.id);
if (!persists) {
throw new RouteError(
HttpStatusCodes.NOT_FOUND,
USER_NOT_FOUND_ERR,
);
}
// Return user
return UserRepo.update(user);
}
/**
* Delete a user by their id.
*/
async function _delete(id: number): Promise<void> {
const persists = await UserRepo.persists(id);
if (!persists) {
throw new RouteError(
HttpStatusCodes.NOT_FOUND,
USER_NOT_FOUND_ERR,
);
}
// Delete user
return UserRepo.delete(id);
}
// **** Export default **** //
export default {
getAll,
addOne,
updateOne,
delete: _delete,
} as const;

39
src/util/PwdUtil.ts Normal file
View File

@@ -0,0 +1,39 @@
import bcrypt from 'bcrypt';
// **** Variables **** //
const SALT_ROUNDS = 12;
// **** Functions **** //
/**
* Get a hash from the password.
*/
function getHash(pwd: string): Promise<string> {
return bcrypt.hash(pwd, SALT_ROUNDS);
}
/**
* Useful for testing.
*/
function hashSync(pwd: string): string {
return bcrypt.hashSync(pwd, SALT_ROUNDS);
}
/**
* See if a password passes the hash.
*/
function compare(pwd: string, hash: string): Promise<boolean> {
return bcrypt.compare(pwd, hash);
}
// **** Export Default **** //
export default {
getHash,
hashSync,
compare,
} as const;

92
src/util/SessionUtil.ts Normal file
View File

@@ -0,0 +1,92 @@
import { Request, Response } from 'express';
import HttpStatusCodes from '@src/constants/HttpStatusCodes';
import { RouteError } from '@src/other/classes';
import jsonwebtoken from 'jsonwebtoken';
import EnvVars from '../constants/EnvVars';
// **** Variables **** //
// Errors
const Errors = {
ParamFalsey: 'Param is falsey',
Validation: 'JSON-web-token validation failed.',
} as const;
// Options
const Options = {
expiresIn: EnvVars.Jwt.Exp,
};
// **** Functions **** //
/**
* Get session data from request object (i.e. ISessionUser)
*/
function getSessionData<T>(req: Request): Promise<string | T | undefined> {
const { Key } = EnvVars.CookieProps,
jwt = req.signedCookies[Key];
return _decode(jwt);
}
/**
* Add a JWT to the response
*/
async function addSessionData(
res: Response,
data: string | object,
): Promise<Response> {
if (!res || !data) {
throw new RouteError(HttpStatusCodes.BAD_REQUEST, Errors.ParamFalsey);
}
// Setup JWT
const jwt = await _sign(data),
{ Key, Options } = EnvVars.CookieProps;
// Return
return res.cookie(Key, jwt, Options);
}
/**
* Remove cookie
*/
function clearCookie(res: Response): Response {
const { Key, Options } = EnvVars.CookieProps;
return res.clearCookie(Key, Options);
}
// **** Helper Functions **** //
/**
* Encrypt data and return jwt.
*/
function _sign(data: string | object | Buffer): Promise<string> {
return new Promise((res, rej) => {
jsonwebtoken.sign(data, EnvVars.Jwt.Secret, Options, (err, token) => {
return err ? rej(err) : res(token || '');
});
});
}
/**
* Decrypt JWT and extract client data.
*/
function _decode<T>(jwt: string): Promise<string | undefined | T> {
return new Promise((res, rej) => {
jsonwebtoken.verify(jwt, EnvVars.Jwt.Secret, (err, decoded) => {
return err ? rej(Errors.Validation) : res(decoded as T);
});
});
}
// **** Export default **** //
export default {
addSessionData,
getSessionData,
clearCookie,
} as const;

22
src/util/misc.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* Miscellaneous shared functions go here.
*/
/**
* Get a random number between 1 and 1,000,000,000,000
*/
export function getRandomInt(): number {
return Math.floor(Math.random() * 1_000_000_000_000);
}
/**
* Wait for a certain number of milliseconds.
*/
export function tick(milliseconds: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, milliseconds);
});
}

37
src/views/users.html Normal file
View File

@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Users</title>
<link rel="stylesheet" type="text/css" href="/stylesheets/users.css" />
</head>
<body>
<!-- Add Users -->
<div class="users-column add-user-col">
<div class="column-header">
Add User:
</div>
<div>
<input id="name-input" placeholder="Name" />
</div>
<div>
<input id="email-input" placeholder="Email" />
</div>
<div>
<button id="add-user-btn">
Add
</button>
</div>
</div>
<!-- Display Users -->
<div class="users-column">
<div class="column-header">Users:</div>
<div id="all-users-anchor"></div>
</div>
</body>
<script src="scripts/http.js"></script>
<script src="scripts/users.js"></script>
</html>

83
tsconfig.json Normal file
View File

@@ -0,0 +1,83 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "dist", /* Redirect output structure to the directory. */
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
"baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
"skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
"paths": {
"@src/*": [
"src/*"
],
},
"useUnknownInCatchVariables": false
},
"include": [
"src/**/*.ts",
"spec/**/*.ts",
"build.ts"
],
"exclude": [
"src/public/"
]
}

12
tsconfig.prod.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"sourceMap": false,
"removeComments": true
},
"exclude": [
"spec",
"src/public/",
"build.ts"
]
}