This commit is contained in:
cupcakearmy 2019-05-22 20:36:09 +02:00
parent dcbc4bcdd7
commit d9df3e7550
14 changed files with 419 additions and 0 deletions

31
api/package.json Normal file
View File

@ -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"
}
}

View File

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

44
api/src/entities/user.ts Normal file
View File

@ -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<User> {
const existent = await User.findOne({ where: { name } })
return existent
? existent
: await new User(name).save()
}
static getFromName(name: string, withRelations: boolean = false): Promise<User> {
return User.findOneOrFail({
where: { name },
relations: withRelations ? ['debts', 'purchases'] : undefined,
})
}
}

29
api/src/lib/auth.ts Normal file
View File

@ -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<AuthenticatedState>) => 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)
}

13
api/src/lib/config.ts Normal file
View File

@ -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)),
}

View File

@ -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`)
}

27
api/src/lib/responses.ts Normal file
View File

@ -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)
}

5
api/src/lib/util.ts Normal file
View File

@ -0,0 +1,5 @@
import { randomBytes } from 'crypto'
export function rand(length = 32): string {
return randomBytes(length / 2).toString('hex')
}

13
api/src/routes/index.ts Normal file
View File

@ -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

View File

@ -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

48
api/src/routes/user.ts Normal file
View File

@ -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<string[]>('users')
const secret = Config.get<string>('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

43
api/src/server.ts Normal file
View File

@ -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<string[]>('users')
for (const user of allowedUsers) {
await User.createOrGet(user)
}
const port = Config.get<number>('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 🚀`)
})

22
api/tsconfig.json Normal file
View File

@ -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
}
}

18
docker-compose.prod.yml Normal file
View File

@ -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