Browse Source

feat: Database, users stump

tags/v0.1.0
Dale 1 year ago
parent
commit
8797fe950d
28 changed files with 1423 additions and 74 deletions
  1. +2
    -1
      .gitignore
  2. +8
    -0
      config/local.json
  3. +8
    -0
      knexfile.js
  4. +18
    -0
      migrations/20190521153436_user-table.js
  5. +17
    -2
      package.json
  6. +2
    -4
      src/server/api/v1/index.ts
  7. +26
    -0
      src/server/api/v1/interfaces/IUser.ts
  8. +0
    -1
      src/server/api/v1/schemas/test-two.json
  9. +0
    -1
      src/server/api/v1/schemas/test.json
  10. +4
    -13
      src/server/api/v1/schemas/user.json
  11. +0
    -3
      src/server/api/v1/schemas/users.json
  12. +22
    -22
      src/server/api/v1/users.ts
  13. +24
    -0
      src/server/config.ts
  14. +46
    -0
      src/server/db/Users.ts
  15. +6
    -0
      src/server/db/index.ts
  16. +23
    -0
      src/server/lib/crypto.ts
  17. +71
    -0
      src/server/lib/http/handler/APIEndpoint.ts
  18. +12
    -0
      src/server/lib/http/interfaces.ts
  19. +1
    -2
      src/server/lib/http/middleware.ts
  20. +63
    -0
      src/server/lib/http/response.ts
  21. +23
    -0
      src/shared/lib/const.ts
  22. +2
    -1
      src/typings.d.ts
  23. +2
    -0
      test/mocha.opts
  24. +24
    -0
      test/server/lib/crypto.spec.js
  25. +27
    -0
      test/server/lib/crypto.spec.ts
  26. +4
    -2
      tsconfig.json
  27. +2
    -0
      webpack.config.server.js
  28. +986
    -22
      yarn.lock

+ 2
- 1
.gitignore View File

@@ -1,3 +1,4 @@
node_modules/
dist/
yarn-error.log
yarn-error.log
dev.sqlite3

+ 8
- 0
config/local.json View File

@@ -0,0 +1,8 @@
{
"database": {
"client": "sqlite3",
"connection": {
"filename": "./dev.sqlite3"
}
}
}

+ 8
- 0
knexfile.js View File

@@ -0,0 +1,8 @@
var fs = require('fs');
var minimist = require('minimist');

var args = minimist(process.argv.slice(2));
var configPath = args.config || process.cwd() + "/config/local.json";
var config = JSON.parse(fs.readFileSync(configPath, 'utf8'));

module.exports = config.database;

+ 18
- 0
migrations/20190521153436_user-table.js View File

@@ -0,0 +1,18 @@
exports.up = function (knex) {
return knex.schema.createTableIfNotExists('users', function (t) {
t.uuid('id');
t.string('username', 140);
t.string('email', 140);
t.text('bio');
t.boolean('singleChannelMode');
t.string('role', 140);
t.text('passwordHash');
t.text('passwordSalt');
});
};


exports.down = function (knex) {
return knex.schema.dropTableIfExists('users');
};


+ 17
- 2
package.json View File

@@ -6,26 +6,41 @@
"license": "MIT",
"private": false,
"scripts": {
"run:server": "./node_modules/.bin/webpack --config webpack.config.server.js --watch"
"test": "NODE_PATH=./src ./node_modules/.bin/mocha",
"run:server": "./node_modules/.bin/webpack --config webpack.config.server.js --watch",
"test:server": "NODE_PATH=./src ./node_modules/.bin/mocha -r ts-node/register test/server/**/*.spec.ts",
"migration:add": "./node_modules/.bin/knex migrate:make --migrations-directory ./migrations -x ts",
"migration:local": "./node_modules/.bin/knex migrate:latest",
"migration": "./node_modules/.bin/knex migrate:latest --migrations-directory ./migrations"
},
"dependencies": {
"ajv": "^6.10.0",
"express": "^4.17.0",
"knex": "^0.16.5",
"minimist": "^1.2.0",
"pg": "^7.11.0",
"ramda": "^0.26.1",
"sqlite3": "^4.0.8",
"uuid": "^3.3.2"
},
"devDependencies": {
"@types/ajv": "^1.0.0",
"@types/chai": "^4.1.7",
"@types/express": "^4.16.1",
"@types/minimist": "^1.2.0",
"@types/mocha": "^5.2.6",
"@types/node": "^12.0.2",
"@types/ramda": "^0.26.8",
"@types/uuid": "^3.4.4",
"chai": "^4.2.0",
"json-loader": "^0.5.7",
"mocha": "^6.1.4",
"nodemon-webpack-plugin": "^4.0.8",
"ts-loader": "^6.0.1",
"ts-node": "^8.1.0",
"typescript": "^3.4.5",
"webpack": "^4.32.0",
"webpack-cli": "^3.3.2"
"webpack-cli": "^3.3.2",
"webpack-node-externals": "^1.7.2"
}
}

+ 2
- 4
src/server/api/v1/index.ts View File

@@ -2,14 +2,12 @@ import { Router, json } from 'express';

import { ApiV1CommonHeaders } from 'server/api/v1/middleware';
import { acceptsSchema } from 'server/lib/http/middleware';
import userHandlers from 'server/api/v1/users';
import Users from 'server/api/v1/users';

const v1Router = Router();

v1Router.use(ApiV1CommonHeaders);
v1Router.use(json());
v1Router.get('/users', userHandlers.listUsers);
v1Router.get('/users/:id', userHandlers.getUserById);
v1Router.post('/users', acceptsSchema('api/v1/schemas/user'), userHandlers.addUser);
v1Router.use('/users', Users.getRouter());

export default v1Router;

+ 26
- 0
src/server/api/v1/interfaces/IUser.ts View File

@@ -0,0 +1,26 @@
interface ICommonUserFields {
id: string;
username: string;
email: string;
bio?: string;
singleChannelMode: boolean;
role: string;
}

export interface IUser extends ICommonUserFields {
passwordHash: string;
passwordSalt: string;
}

export interface IUserSafe extends ICommonUserFields {
}

export interface IUserCreation {
username: string;
email: string;
bio?: string;
singleChannelMode: boolean;
password: string;
}

export default IUser;

+ 0
- 1
src/server/api/v1/schemas/test-two.json View File

@@ -1 +0,0 @@
{"type": "string"}

+ 0
- 1
src/server/api/v1/schemas/test.json View File

@@ -1 +0,0 @@
{"type": "number"}

+ 4
- 13
src/server/api/v1/schemas/user.json View File

@@ -1,19 +1,10 @@
{
"type": "object",
"properties": {
"username": { "type": "string", "maxLength": 4 },
"email": { "type": "number", "maximum": 5, "minimum": 3 },
"password": { "type": "string" },
"obj": {
"type": "object",
"properties": {
"prop1": { "type": "number" },
"prop2": { "type": "string" }
},
"additionalProperties": false,
"required": [ "prop1", "prop2" ]
}
"username": { "type": "string" },
"email": { "type": "string" },
"password": { "type": "string", "minLength": 8 }
},
"additionalProperties": false,
"required": [ "username", "password", "email", "obj" ]
"required": [ "username", "password", "email" ]
}

+ 0
- 3
src/server/api/v1/schemas/users.json View File

@@ -1,3 +0,0 @@
{
"type": "array"
}

+ 22
- 22
src/server/api/v1/users.ts View File

@@ -1,32 +1,32 @@
import * as uuidv4 from 'uuid/v4';
import { Request, Response } from 'express';

let users: { [key: string]: any } = {};

export function listUsers (req: Request, res: Response) {
const userList = Object.keys(users).map(key => ({id: key, ...users[key]}));
res.send({ status: 'ok', body: userList });
}
import APIEndpoint from 'server/lib/http/handler/APIEndpoint';
import { sendData, sendCreated, dataOrNotFound } from 'server/lib/http/response';

import * as userSchema from 'server/api/v1/schemas/user.json';

import Users from 'server/db/Users';

class UsersEndpoint extends APIEndpoint {
users: { [key: string]: any } = {};

export function getUserById (req: Request, res: Response) {
const id: string = req.params.id;
const user: any = users[id];
if (user) {
res.send({ status: 'ok', body: { ...user, id } });
} else {
res.send({ status: 'ok', body: null, message: 'Not Found'});
getList = (_: Request, res: Response) => {
Users
.getV1UsersSafe()
.then(sendData(res));
}

}
getId = (req: Request, res: Response) => {
const { id }: { id: string } = req.params;
Users.getV1UserSafe(id).then(dataOrNotFound(res, { id }));
}

export function addUser (req: Request, res: Response) {
const id = uuidv4();
users[id] = req.body;
res.send({ status: 'ok', body: {...req.body, id }});
post = (req: Request, res: Response) => {
const id = uuidv4();
Users.createV1User(req.body).then(sendCreated(res));
}
}

export default {
listUsers,
addUser,
getUserById,
};
export default new UsersEndpoint({ schema: userSchema });

+ 24
- 0
src/server/config.ts View File

@@ -0,0 +1,24 @@
import { pathOr } from 'ramda';
import * as fs from 'fs'
import * as minimist from 'minimist';

interface IDBConfig {
client: 'sqlite3' | 'pg';
connection: {
hostname?: string,
password?: string,
username?: string,
database?: string,
filename?: string,
}
}

interface IConfig {
database: IDBConfig;
}

const args = minimist(process.argv.slice(2));
const configPath = pathOr(`${process.cwd()}/config/local.json`, ['config'], args);
const config: IConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
export default config;


+ 46
- 0
src/server/db/Users.ts View File

@@ -0,0 +1,46 @@
import * as uuidv4 from 'uuid/v4';

import db from 'server/db';

import IUser, { IUserCreation, IUserSafe } from 'server/api/v1/interfaces/IUser';
import { hashPassword } from 'server/lib/crypto';

class Users {

sanitzieUser = (user: IUser): IUserSafe => {
if (user) {
const {passwordHash, passwordSalt, ...userSafe } = user;
return userSafe;
} else {
return null;
}
}


getV1Users = (): PromiseLike<IUser[]> =>
db('users').select('*')
.then(users => users.map((user: any) => user as IUser));

getV1UsersSafe = (): PromiseLike<IUserSafe[]> =>
this.getV1Users().then(users => users.map(this.sanitzieUser))

getV1UserSafe = (id: string): PromiseLike<IUserSafe> =>
this.getV1User(id).then(this.sanitzieUser);

getV1User = (id: string): PromiseLike<IUser> =>
db('users').select('*').where({ id }).get(0).then(user => user as IUser);

createV1User = (user: IUserCreation): PromiseLike<IUser> => {
const { password, ...sanitizedUser } = user;
const { passwordSalt, passwordHash } = hashPassword(password);
const userObject = {
id: uuidv4(),
...sanitizedUser,
passwordSalt,
passwordHash,
};
return db('users').insert(userObject).returning('*').get(0);
}
}

export default new Users;

+ 6
- 0
src/server/db/index.ts View File

@@ -0,0 +1,6 @@
import * as Knex from 'knex';
import { pathOr } from 'ramda';

import config from 'server/config';

export default Knex(config.database);

+ 23
- 0
src/server/lib/crypto.ts View File

@@ -0,0 +1,23 @@
import * as crypto from 'crypto';

interface IHashResult {
passwordHash: string;
passwordSalt: string;
}

export function hashPassword (password: string): IHashResult {
const passwordSalt = crypto.randomBytes(128).toString('base64');
const passwordHash = crypto
.pbkdf2Sync(password, passwordSalt, 1000, 64, 'sha512')
.toString('base64');

return { passwordHash, passwordSalt };
}

export function checkPassword(password: string, passwordHash: string, passwordSalt: string): Boolean {
const hashedChallenge = crypto
.pbkdf2Sync(password, passwordSalt, 1000, 64, 'sha512')
.toString('base64');

return passwordHash === hashedChallenge;
}

+ 71
- 0
src/server/lib/http/handler/APIEndpoint.ts View File

@@ -0,0 +1,71 @@
import { Response, Router, Request } from 'express';

import { sendParams, sendNotImplemented, sendNoContent } from 'server/lib/http/response';
import { acceptsSchema } from '../middleware';

interface IDocumentationObject {
title?: string;
description?: string;
};

interface EndpointOptions {
middleware?: any;
schema?: any;
documentation?: IDocumentationObject;
}

type Handler = (req: Request, res: Response) => void;
type Handlers = { [key: string]: Handler };

export default class APIEndpoint {
schema?: any;
router: Router;
documentation?: IDocumentationObject;

getList?: Handler
getId?: Handler
post?: Handler
putId?: Handler
options?: Handler
deleteId?: Handler

constructor({ middleware = [], schema = null, documentation = {} }: EndpointOptions = {}) {
this.schema = schema;
this.documentation = documentation;
this.router = Router();
middleware.forEach(this.router.use)
}

optionsHandler = (_: any, res: Response): void => {
if (this.schema) sendParams(res)({ schema: this.schema });
else sendNoContent(res);
}

defaultHandler = (_: any, res: Response): void => {
sendNotImplemented(res)({ message: "Nope" });
}

getHandlers = (): Handlers => {
return {
'getList': this.getList || this.defaultHandler,
'getId': this.getId || this.defaultHandler,
'post': this.post || this.defaultHandler,
'putId': this.putId || this.defaultHandler,
'deleteId': this.deleteId || this.defaultHandler,
'options': this.post || this.optionsHandler,
};
}

getRouter = (): Router => {
const handlers: Handlers = this.getHandlers();
this.router.get(`/`, handlers.getList);
if (this.schema && this.post) this.router.post(`/`, acceptsSchema(this.schema), handlers.post);
else this.router.post(`/`, handlers.post);
this.router.get(`/:id`, handlers.getId);
if (this.schema && this.putId) this.router.put(`/:id`, acceptsSchema(this.schema), handlers.putId);
else this.router.put(`/`, handlers.putId);
this.router.delete(`/:id`, handlers.deleteId);
this.router.options(`/`, handlers.options);
return this.router;
}
}

+ 12
- 0
src/server/lib/http/interfaces.ts View File

@@ -0,0 +1,12 @@
export interface ErrorsObject {
[key: string]: string,
}

export interface ResponseObject<TData = any> {
status: string;
body: TData;
errors?: ErrorsObject;
errorMessages?: ErrorsObject;
schema: any;
message?: string;
}

+ 1
- 2
src/server/lib/http/middleware.ts View File

@@ -9,8 +9,7 @@ export function JsonApiHeaders (req: Request, res: Response, next: () => void) {
next();
}

export const acceptsSchema = (schemaID: object) => (req: Request, res: Response, next: () => void) => {
const schema: any = require(`server/${schemaID}.json`);
export const acceptsSchema = (schema: object) => (req: Request, res: Response, next: () => void) => {
const ajv = new AJV({ allErrors: true });
const validate = ajv.compile(schema);
const isValid = validate(req.body);


+ 63
- 0
src/server/lib/http/response.ts View File

@@ -0,0 +1,63 @@
import * as R from 'ramda';
import { Response } from 'express';

import { HTTP_CODES } from 'shared/lib/const';

export const sendData = R.curry((res: Response, data: any) => {
res.json({ body: data, status: 'ok' });
});

export const sendParams = R.curry((res: Response, params: any) => {
res.json({ ...params, status: 'ok' });
});

export const dataOrCode = R.curry((code: number, res: Response, params: Object, data: any) => {
if (!R.isNil(data)) {
sendData(res, data);
} else {
res.status(code).json({ ...params, code, status: 'error' });
}
});

export const dataOrNotFound = dataOrCode(HTTP_CODES.NOT_FOUND);
export const dataOrBadRequest = dataOrCode(HTTP_CODES.BAD_REQUEST);

export const sendUnauthorized = R.curry((res: Response, params: Object) => {
res.status(HTTP_CODES.UNAUTHORIZED).json({ ...params, status: 'error' });
});

export const sendUnauthenticated = R.curry((res: Response, params: Object) => {
res.status(HTTP_CODES.UNAUTHENTICATED).json({ ...params, status: 'error' });
});

export const sendParamError = R.curry((res: Response, params: Object) => {
res.status(HTTP_CODES.BAD_REQUEST).json({ ...params, status: 'error' });
});

export const sendServerError = R.curry((res: Response, params: Object) => {
res.status(HTTP_CODES.SERVER_ERROR).json({ ...params, status: 'error' });
});

export const sendNotImplemented = R.curry((res: Response, params: Object) => {
res.status(HTTP_CODES.NOT_IMPLEMENTED).json({ ...params, status: 'error' });
});

export const sendNotFound = R.curry((res: Response, params: Object) => {
res.status(HTTP_CODES.NOT_FOUND).json({ ...params, status: 'error' });
});

export const sendDuplicate = R.curry((res: Response, params: Object) => {
res.status(HTTP_CODES.DUPLICATE).json({ ...params, status: 'error'});
});

export const sendCreated = R.curry((res: Response, data: any) => {
res.status(HTTP_CODES.CREATED).json({ body: data, status: 'ok' });
});

export const sendNoContent = R.curry((res: Response) => {
res.status(HTTP_CODES.NO_CONTENT).end();
});

export const sendRedirect = R.curry((res: Response, url: string) => {
res.redirect(url);
});

+ 23
- 0
src/shared/lib/const.ts View File

@@ -5,3 +5,26 @@ export const ERROR_CODE_MAX_NUM = 'errors.schema.field.maximumNumber';
export const ERROR_CODE_MIN_NUM = 'errors.schema.field.minimumNumber';
export const ERROR_CODE_MAX_STRING = 'errors.schema.field.maximumStringLength';
export const ERROR_CODE_MIN_STRING = 'errors.schema.field.minimumStringLength';

export const HTTP_CODE_OK = 200
export const HTTP_CODE_CREATED = 201
export const HTTP_CODE_NO_CONTENT = 204
export const HTTP_CODE_BAD_REQUEST = 400
export const HTTP_CODE_UNAUTHENTICATED = 401
export const HTTP_CODE_UNAUTHORIZED = 403
export const HTTP_CODE_NOT_FOUND = 404
export const HTTP_CODE_SERVER_ERROR = 500
export const HTTP_CODE_DUPLICATE = 409
export const HTTP_CODE_NOT_IMPLEMENTED = 501
export const HTTP_CODES = {
OK: HTTP_CODE_OK,
CREATED: HTTP_CODE_CREATED,
NO_CONTENT: HTTP_CODE_NO_CONTENT,
BAD_REQUEST: HTTP_CODE_BAD_REQUEST,
UNAUTHENTICATED: HTTP_CODE_UNAUTHENTICATED,
UNAUTHORIZED: HTTP_CODE_UNAUTHORIZED,
NOT_FOUND: HTTP_CODE_NOT_FOUND,
SERVER_ERROR: HTTP_CODE_SERVER_ERROR,
DUPLICATE: HTTP_CODE_DUPLICATE,
NOT_IMPLEMENTED: HTTP_CODE_NOT_IMPLEMENTED,
};

+ 2
- 1
src/typings.d.ts View File

@@ -1 +1,2 @@
declare module "*.json"
declare module "*.json";
declare module "server/db/knexfile";

+ 2
- 0
test/mocha.opts View File

@@ -0,0 +1,2 @@
--require ts-node/register
test/**/*.spec.ts

+ 24
- 0
test/server/lib/crypto.spec.js View File

@@ -0,0 +1,24 @@
"use strict";
exports.__esModule = true;
require("mocha");
var chai_1 = require("chai");
var crypto_1 = require("server/lib/crypto");
describe('Cryptography', function () {
var testPassword = 'blowfish';
var bogusPassword = 'notBlowfish';
it('should return hashed password and salt as result', function () {
var resultObj = crypto_1.hashPassword(testPassword);
chai_1.expect(resultObj).to.haveOwnProperty('passwordHash').to.be.string;
chai_1.expect(resultObj).to.haveOwnProperty('passwordSalt').to.be.string;
});
it('should match hashed password', function () {
var resultObj = crypto_1.hashPassword(testPassword);
var checkResult = crypto_1.checkPassword(testPassword, resultObj.passwordHash, resultObj.passwordSalt);
chai_1.expect(checkResult).to.be["true"];
});
it('shouldn\'t match bad passwords', function () {
var resultObj = crypto_1.hashPassword(testPassword);
var checkResult = crypto_1.checkPassword(bogusPassword, resultObj.passwordHash, resultObj.passwordSalt);
chai_1.expect(checkResult).to.be["false"];
});
});

+ 27
- 0
test/server/lib/crypto.spec.ts View File

@@ -0,0 +1,27 @@
import 'mocha';
import { expect } from 'chai';

import { hashPassword, checkPassword } from 'server/lib/crypto';

describe('Cryptography password functions', () => {
const testPassword = 'blowfish';
const bogusPassword = 'notBlowfish';

it('hashPassword should return hashed password and salt as result', () => {
const resultObj = hashPassword(testPassword);
expect(resultObj).to.haveOwnProperty('passwordHash').to.be.string;
expect(resultObj).to.haveOwnProperty('passwordSalt').to.be.string;
});

it('checkPassword should match hashed password', () => {
const resultObj = hashPassword(testPassword);
const checkResult = checkPassword(testPassword, resultObj.passwordHash, resultObj.passwordSalt);
expect(checkResult).to.be.true;
});

it('checkPassword shouldn\'t match bad passwords', () => {
const resultObj = hashPassword(testPassword);
const checkResult = checkPassword(bogusPassword, resultObj.passwordHash, resultObj.passwordSalt);
expect(checkResult).to.be.false;
});
});

+ 4
- 2
tsconfig.json View File

@@ -1,13 +1,15 @@
{
"compilerOptions": {
"baseUrl": ".",
"outDir": "./dist",
"outDir": "tsDist",
"moduleResolution": "node",
"module": "commonjs",
"target": "es6",
"resolveJsonModule": true,
"typeRoots": [ "node_modules/@types", "./src/typings.d.ts" ],
"paths": {
"server/*": [ "src/server/*" ],
"client/*": [ "src/client/*" ]
"client/*": [ "src/client/*" ],
"shared/*": [ "src/shared/*" ]
}
}


+ 2
- 0
webpack.config.server.js View File

@@ -1,10 +1,12 @@
const path = require('path');
const os = require('os');
const NodemonPlugin = require('nodemon-webpack-plugin');
const nodeExternals = require('webpack-node-externals');

module.exports = {
entry: './src/server/server.ts',
target: 'node',
externals: [nodeExternals()],
mode: process.env.NODE_ENV || 'development',
module: {
rules: [


+ 986
- 22
yarn.lock
File diff suppressed because it is too large
View File


Loading…
Cancel
Save