mirror of
https://github.com/cupcakearmy/obolus.git
synced 2024-12-22 16:16:27 +00:00
backend
This commit is contained in:
parent
dcbc4bcdd7
commit
d9df3e7550
31
api/package.json
Normal file
31
api/package.json
Normal 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"
|
||||
}
|
||||
}
|
68
api/src/entities/purchase.ts
Normal file
68
api/src/entities/purchase.ts
Normal 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
44
api/src/entities/user.ts
Normal 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
29
api/src/lib/auth.ts
Normal 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
13
api/src/lib/config.ts
Normal 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)),
|
||||
}
|
7
api/src/lib/middleware.ts
Normal file
7
api/src/lib/middleware.ts
Normal 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
27
api/src/lib/responses.ts
Normal 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
5
api/src/lib/util.ts
Normal 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
13
api/src/routes/index.ts
Normal 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
|
51
api/src/routes/purchase.ts
Normal file
51
api/src/routes/purchase.ts
Normal 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
48
api/src/routes/user.ts
Normal 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
43
api/src/server.ts
Normal 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
22
api/tsconfig.json
Normal 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
18
docker-compose.prod.yml
Normal 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
|
Loading…
Reference in New Issue
Block a user