From d9df3e755084e67018e7d6e7af540756a25c357b Mon Sep 17 00:00:00 2001 From: cupcakearmy Date: Wed, 22 May 2019 20:36:09 +0200 Subject: [PATCH] backend --- api/package.json | 31 ++++++++++++++++ api/src/entities/purchase.ts | 68 ++++++++++++++++++++++++++++++++++++ api/src/entities/user.ts | 44 +++++++++++++++++++++++ api/src/lib/auth.ts | 29 +++++++++++++++ api/src/lib/config.ts | 13 +++++++ api/src/lib/middleware.ts | 7 ++++ api/src/lib/responses.ts | 27 ++++++++++++++ api/src/lib/util.ts | 5 +++ api/src/routes/index.ts | 13 +++++++ api/src/routes/purchase.ts | 51 +++++++++++++++++++++++++++ api/src/routes/user.ts | 48 +++++++++++++++++++++++++ api/src/server.ts | 43 +++++++++++++++++++++++ api/tsconfig.json | 22 ++++++++++++ docker-compose.prod.yml | 18 ++++++++++ 14 files changed, 419 insertions(+) create mode 100644 api/package.json create mode 100644 api/src/entities/purchase.ts create mode 100644 api/src/entities/user.ts create mode 100644 api/src/lib/auth.ts create mode 100644 api/src/lib/config.ts create mode 100644 api/src/lib/middleware.ts create mode 100644 api/src/lib/responses.ts create mode 100644 api/src/lib/util.ts create mode 100644 api/src/routes/index.ts create mode 100644 api/src/routes/purchase.ts create mode 100644 api/src/routes/user.ts create mode 100644 api/src/server.ts create mode 100644 api/tsconfig.json create mode 100644 docker-compose.prod.yml diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..73d922b --- /dev/null +++ b/api/package.json @@ -0,0 +1,31 @@ +{ + "private": true, + "scripts": { + "pkg": "tsc && pkg --targets latest-alpine-x64 --output ./server dist/server.js", + "pkg:mac": "tsc && pkg --targets latest-macos-x64 --output ./server-macos dist/server.js", + "prod": "NODE_ENV=production node dist/server.js", + "dev": "tsnd --no-notify src/server.ts" + }, + "devDependencies": { + "@types/jsonwebtoken": "^8.3.2", + "@types/koa": "^2.0.48", + "@types/koa-bodyparser": "^4.2.2", + "@types/koa-router": "^7.0.40", + "@types/uuid": "^3.4.4", + "ts-node-dev": "^1.0.0-pre.39", + "typescript": "^3.4.5" + }, + "dependencies": { + "class-validator": "^0.9.1", + "jsonwebtoken": "^8.5.1", + "koa": "^2.7.0", + "koa-bodyparser": "^4.2.1", + "koa-router": "^7.4.0", + "memiens": "^1.0.1", + "pg": "^7.11.0", + "reflect-metadata": "^0.1.13", + "sqlite3": "^4.0.8", + "typeorm": "^0.2.17", + "uuid": "^3.3.2" + } +} diff --git a/api/src/entities/purchase.ts b/api/src/entities/purchase.ts new file mode 100644 index 0000000..38655d0 --- /dev/null +++ b/api/src/entities/purchase.ts @@ -0,0 +1,68 @@ +import { BaseEntity, Column, Entity, JoinTable, ManyToMany, ManyToOne, PrimaryColumn } from 'typeorm' +import UUID from 'uuid/v4' + +import User from './user' + +@Entity() +export default class Purchase extends BaseEntity { + + @PrimaryColumn() + id!: string + + @Column('real') + price: number + + @Column() + description: string + + @Column() + when: number + + @Column('blob', { nullable: true }) + photo?: string + + @ManyToOne(type => User, user => user.purchases, { eager: true }) + payer: User + + @ManyToMany(type => User, user => user.debts, { eager: true }) + @JoinTable() + debtors: User[] + + constructor(price: number, payer: User, debtors: User[], description = '') { + super() + + this.id = UUID() + this.price = price + this.payer = payer + this.debtors = debtors + this.description = description + this.when = Date.now() + } + + static async getCurrentStats() { + + // @ts-ignore + const users: { [user: string]: number } = Object.fromEntries((await User.find()).map(user => [user.name, 0])) + const all: Purchase[] = await Purchase.find() + + for (const { price, debtors, payer, description } of all) { + const each: number = price / debtors.length + + for (const debtor of debtors) + users[debtor.name] -= each + + users[payer.name] += price + } + + // Sum of all the calculations, should be 0. + const _error = Object.values(users).reduce((acc, cur) => acc + cur, 0) + + // @ts-ignore + const approximated = Object.fromEntries(Object.entries(users).map(([name, value]) => [name, Number(value.toFixed(2))])) + + return { + _error, + ...approximated, + } + } +} \ No newline at end of file diff --git a/api/src/entities/user.ts b/api/src/entities/user.ts new file mode 100644 index 0000000..2cada63 --- /dev/null +++ b/api/src/entities/user.ts @@ -0,0 +1,44 @@ +import { BaseEntity, Column, Entity, ManyToMany, OneToMany, PrimaryColumn } from 'typeorm' +import UUID from 'uuid/v4' +import Purchase from './purchase' + +@Entity() +export default class User extends BaseEntity { + + @PrimaryColumn() + id!: string + + @Column({ unique: true }) + name: string + + @Column({nullable: true}) + avatar?: string + + @OneToMany(type => Purchase, purchase => purchase.payer) + purchases!: Purchase[] + + @ManyToMany(type => Purchase, purchase => purchase.debtors) + debts!: Purchase[] + + constructor(name: string) { + super() + + this.id = UUID() + this.name = name + } + + static async createOrGet(name: string): Promise { + const existent = await User.findOne({ where: { name } }) + return existent + ? existent + : await new User(name).save() + } + + static getFromName(name: string, withRelations: boolean = false): Promise { + return User.findOneOrFail({ + where: { name }, + relations: withRelations ? ['debts', 'purchases'] : undefined, + }) + } + +} \ No newline at end of file diff --git a/api/src/lib/auth.ts b/api/src/lib/auth.ts new file mode 100644 index 0000000..23125cd --- /dev/null +++ b/api/src/lib/auth.ts @@ -0,0 +1,29 @@ +import JWT from 'jsonwebtoken' +import { Middleware } from 'koa' + +import User from '../entities/user' +import { JWTConfig } from './config' +import { FailureUnauthorized } from './responses' + +type AuthenticatedState = { + user: User +} + +export const withAuth: (mw: Middleware) => Middleware = (mw) => async (ctx, next) => { + const header = ctx.req.headers['authorization'] + + if (!header) + return FailureUnauthorized(ctx, 'Authorization header missing') + + try { + const token = header.slice(`Bearer `.length) + const { user } = JWT.verify(token, JWTConfig.secret) as any + // Throws error if user is not found + ctx.state = { user: await User.getFromName(user) } + } catch (e) { + return FailureUnauthorized(ctx, 'Invalid token') + } + + // Call middleware + return await mw(ctx, next) +} \ No newline at end of file diff --git a/api/src/lib/config.ts b/api/src/lib/config.ts new file mode 100644 index 0000000..a790c78 --- /dev/null +++ b/api/src/lib/config.ts @@ -0,0 +1,13 @@ +import Memiens from 'memiens' + +import { rand } from './util' + +export const Config = new Memiens('config.yml') + +export const JWTConfig = { + options: { + algorithm: Config.get('security.jwt.algorithm', 'HS512'), + expiresIn: Config.get('security.jwt.expiresIn', '90 days'), + }, + secret: Config.get('security.jwt.secret', rand(128)), +} \ No newline at end of file diff --git a/api/src/lib/middleware.ts b/api/src/lib/middleware.ts new file mode 100644 index 0000000..daae39d --- /dev/null +++ b/api/src/lib/middleware.ts @@ -0,0 +1,7 @@ +import { Middleware } from 'koa' + +export const responseTime: Middleware = async (ctx, next) => { + const start = Date.now() + await next() + ctx.set('X-Response-Time', `${Date.now() - start}ms`) +} \ No newline at end of file diff --git a/api/src/lib/responses.ts b/api/src/lib/responses.ts new file mode 100644 index 0000000..0ce3747 --- /dev/null +++ b/api/src/lib/responses.ts @@ -0,0 +1,27 @@ +import { Context, ParameterizedContext } from 'koa' + +type ResponseContext = Context | ParameterizedContext + +export const Success = (ctx: ResponseContext, msg?: any) => { + ctx.body = { + _error: false, + body: msg, + } +} + +export const Failure = (ctx: ResponseContext, msg: any = 'Internal Error', code: number = 500) => { + ctx.response.status = code + ctx.body = { _error: msg } +} + +export const FailureUnauthorized = (ctx: ResponseContext, msg: string = 'Unauthorized') => { + Failure(ctx, msg, 401) +} + +export const FailureForbidden = (ctx: ResponseContext, msg: string = 'Forbidden') => { + Failure(ctx, msg, 403) +} + +export const FailureBadRequest = (ctx: ResponseContext, msg: string = 'Bad Request') => { + Failure(ctx, msg, 400) +} \ No newline at end of file diff --git a/api/src/lib/util.ts b/api/src/lib/util.ts new file mode 100644 index 0000000..9d0dbca --- /dev/null +++ b/api/src/lib/util.ts @@ -0,0 +1,5 @@ +import { randomBytes } from 'crypto' + +export function rand(length = 32): string { + return randomBytes(length / 2).toString('hex') +} \ No newline at end of file diff --git a/api/src/routes/index.ts b/api/src/routes/index.ts new file mode 100644 index 0000000..8fa143e --- /dev/null +++ b/api/src/routes/index.ts @@ -0,0 +1,13 @@ +import Router from 'koa-router' + +import purchase from './purchase' +import user from './user' + +const r = new Router({ + prefix: '/api' +}) + +r.use(user.routes(), user.allowedMethods()) +r.use(purchase.routes(), purchase.allowedMethods()) + +export default r \ No newline at end of file diff --git a/api/src/routes/purchase.ts b/api/src/routes/purchase.ts new file mode 100644 index 0000000..14bb813 --- /dev/null +++ b/api/src/routes/purchase.ts @@ -0,0 +1,51 @@ +import Router from 'koa-router' +import Purchase from '../entities/purchase' + +import User from '../entities/user' +import { withAuth } from '../lib/auth' +import { FailureBadRequest, FailureForbidden, Success } from '../lib/responses' + +const r = new Router({ + prefix: '/purchases', +}) + +r.get('/', withAuth(async (ctx) => { + return Success(ctx, await Purchase.find({ order: { when: 'DESC' } })) +})) + +r.post('/', withAuth(async (ctx) => { + const { price, debtors, description } = ctx.request.body + + if (isNaN(price) || price < 0) return FailureBadRequest(ctx, 'Price should be numeric and positive') + if (!Array.isArray(debtors) || debtors.length < 1) return FailureBadRequest(ctx, 'Debtors needs to be an array and at least contain one debtor') + + const Debtors: User[] = [] + try { + for (const debtor of debtors) + Debtors.push(await User.getFromName(debtor)) + } catch (e) { + return FailureBadRequest(ctx, 'Could not find users') + } + + const purchase = await new Purchase(price, ctx.state.user, Debtors, String(description)).save() + + return Success(ctx, purchase) +})) + +r.delete('/:id', withAuth(async (ctx) => { + const { id } = ctx.params + const purchase = await Purchase.findOne({ where: { id } }) + + if (!purchase) return + + if (ctx.state.user.id !== purchase.payer.id) return FailureForbidden(ctx) + + await purchase.remove() + return Success(ctx) +})) + +r.get('/stats', withAuth(async (ctx) => { + return Success(ctx, await Purchase.getCurrentStats()) +})) + +export default r \ No newline at end of file diff --git a/api/src/routes/user.ts b/api/src/routes/user.ts new file mode 100644 index 0000000..cd925fe --- /dev/null +++ b/api/src/routes/user.ts @@ -0,0 +1,48 @@ +import JWT from 'jsonwebtoken' +import Router from 'koa-router' + +import User from '../entities/user' +import { withAuth } from '../lib/auth' +import { Config, JWTConfig } from '../lib/config' +import { FailureBadRequest, FailureUnauthorized, Success } from '../lib/responses' + +const r = new Router({ + prefix: '/users', +}) + +const allowedUsers = Config.get('users') +const secret = Config.get('security.password') + +r.get('/', withAuth(async (ctx) => + Success(ctx, await User.find()), +)) + +r.get('/names', async (ctx) => + Success(ctx, (await User.find()).map(user => user.name)), +) + +r.get('/me', withAuth(async (ctx) => + Success(ctx, await User.getFromName(ctx.state.user.name, true)), +)) + +r.post('/me/avatar', withAuth(async (ctx) => { + const { avatar } = ctx.request.body + ctx.state.user.avatar = String(avatar) + await ctx.state.user.save() + return Success(ctx) +})) + +r.post('/login', async (ctx) => { + const { user, password } = ctx.request.body + + if (!user || !password) return FailureBadRequest(ctx) + + if (!allowedUsers.includes(user.toLowerCase()) || password != secret) + return FailureUnauthorized(ctx) + + await User.createOrGet(user) + const token = JWT.sign({ user }, JWTConfig.secret, JWTConfig.options) + return Success(ctx, { token }) +}) + +export default r \ No newline at end of file diff --git a/api/src/server.ts b/api/src/server.ts new file mode 100644 index 0000000..a49a3a3 --- /dev/null +++ b/api/src/server.ts @@ -0,0 +1,43 @@ +require('reflect-metadata') + +import Koa from 'koa' +import Parser from 'koa-bodyparser' +import { join } from 'path' +import { createConnection } from 'typeorm' + +import Purchase from './entities/purchase' +import User from './entities/user' +import { Config } from './lib/config' +import { responseTime } from './lib/middleware' +import router from './routes' + + +createConnection({ + type: 'sqlite', + database: join(process.cwd(), 'db.sqlite'), + // type: "mysql", + // host: "localhost", + // port: 3306, + // username: 'test', + // password: 'test', + // database: 'data', + entities: [User, Purchase], + synchronize: true, +}).then(async () => { + + // Initialize users + const allowedUsers = Config.get('users') + for (const user of allowedUsers) { + await User.createOrGet(user) + } + + const port = Config.get('server.port') + const server = new Koa() + + server.use(responseTime) + server.use(Parser()) + server.use(router.routes()) + server.use(router.allowedMethods()) + server.listen(port) + console.log(`> Server Stared 🚀`) +}) \ No newline at end of file diff --git a/api/tsconfig.json b/api/tsconfig.json new file mode 100644 index 0000000..fd216e3 --- /dev/null +++ b/api/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "target": "esnext", + "module": "commonjs", + "removeComments": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..effb7b4 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,18 @@ +version: '3.6' + +x-defaults: &default + image: node:12-alpine + restart: always + working_dir: /app + command: npm run prod + +services: + api: + <<: *default + volumes: + - ./api:/app + + www: + <<: *default + volumes: + - ./www:/app \ No newline at end of file