From 2996a2eb95ae2bef4bdadef4ebc6ef84ad5ed293 Mon Sep 17 00:00:00 2001 From: jd Date: Wed, 18 Feb 2026 21:32:28 +0000 Subject: [PATCH] Slight restructure, updated auth, implement player and game endpoints --- .env.dev | 2 +- .prettierrc.json | 12 + API Tests/Auth.http | 12 - API Tests/User.http | 12 - package.json | 4 +- scripts/dbCreate.sql | 633 ++++++++++++++++++++++++++++-- src/endpoints/auth.ts | 108 ++++- src/endpoints/games.ts | 70 ++++ src/endpoints/player.ts | 46 +++ src/endpoints/user.ts | 54 ++- src/index.ts | 31 +- src/orm/claims.ts | 21 +- src/orm/games.ts | 125 ++++++ src/orm/orm.ts | 21 +- src/orm/players.ts | 140 +++++++ src/orm/user.ts | 257 ++++++++---- src/routes/auth.ts | 22 ++ src/routes/game.ts | 17 + src/routes/player.ts | 14 + src/routes/user.ts | 14 + src/tests/auth.test.ts | 119 ++++++ src/tests/global-mocks.ts | 4 +- src/tests/test-setup.ts | 13 +- src/tests/user.test.ts | 190 ++++++++- src/utilities/claimDefinitions.ts | 131 +++++++ src/utilities/elo.ts | 45 +++ src/utilities/errors.ts | 8 +- src/utilities/guard.ts | 58 ++- src/utilities/helpers.ts | 20 + src/utilities/requestModels.ts | 75 ++++ src/utilities/responseHelper.ts | 79 +++- tsconfig.json | 2 +- 32 files changed, 2093 insertions(+), 266 deletions(-) create mode 100644 .prettierrc.json delete mode 100644 API Tests/Auth.http delete mode 100644 API Tests/User.http create mode 100644 src/endpoints/games.ts create mode 100644 src/endpoints/player.ts create mode 100644 src/orm/games.ts create mode 100644 src/orm/players.ts create mode 100644 src/routes/auth.ts create mode 100644 src/routes/game.ts create mode 100644 src/routes/player.ts create mode 100644 src/routes/user.ts create mode 100644 src/tests/auth.test.ts create mode 100644 src/utilities/claimDefinitions.ts create mode 100644 src/utilities/elo.ts create mode 100644 src/utilities/helpers.ts create mode 100644 src/utilities/requestModels.ts diff --git a/.env.dev b/.env.dev index 64772e8..777669c 100644 --- a/.env.dev +++ b/.env.dev @@ -1,2 +1,2 @@ -DATABASE_URL=postgres://admin:iiyama12@192.168.1.166:5432/bgApp +DATABASE_URL=postgres://ApiUser:2 { try { - const requestBody = request.json; - console.log(`/api/auth/login: username=${requestBody.username}`); - const claims: Claims | null = await orm.users.verify(requestBody.username, requestBody.password); - console.log(claims); - if (claims) { - const token = jwt.sign({...claims}, process.env.JWT_SECRET_KEY as string, {expiresIn: "24h"}); - return Response.json({token: token, claims: claims}, {status: 200}); + const requestBody = request.body as LoginRequest; + const verify: { + userId: SecureId; + refreshCount: string; + } | null = await orm.users.verifyCredentials(requestBody.username, requestBody.password); + if (!verify) { + throw new UnauthorizedError('Invalid credentials'); } - throw new UnauthorizedError('Invalid credentials'); + // Build refresh token that expires in 30 days, return as secure HTTP only cookie. + const tokenLifeSpanInDays = 30; + const token = jwt.sign( + { + u: verify.userId.raw, + r: verify.refreshCount, + }, + process.env.JWT_SECRET_KEY as string, + { expiresIn: `${tokenLifeSpanInDays * 24}h` }, + ); + const cookies = request?.request?.cookies; + cookies?.set({ + name: 'refresh', + value: token, + httpOnly: true, + secure: true, + maxAge: tokenLifeSpanInDays * 24 * 60 * 60, + }); + return new OkResponse(); } catch (error: any) { return new ErrorResponse(error as Error); } } -async function test(request: UnwrappedRequest) { - return Response.json(request.claims, {status: 200}); + +async function token(request: UnwrappedRequest): Promise { + try { + const cookies = request.request.cookies; + const refreshCookie = cookies.get('refresh'); + if (!refreshCookie) { + throw new UnauthorizedError('No refresh token found'); + } + + const refreshToken: { + u: string; + r: string; + } = jwt.verify(refreshCookie, process.env.JWT_SECRET_KEY as string) as { u: string; r: string }; + + if (!(await orm.users.verifyRefreshCount(SecureId.fromID(refreshToken.u), refreshToken.r))) { + const response = new UnauthorizedResponse('Invalid refresh token'); + response.headers.set('Clear-Site-Data', '"cookies","cache","storage","executionContexts"'); + return response; + } + + const claims: Claims | null = await orm.claims.getByUserId(refreshToken.u); + + const token = jwt.sign({ ...claims }, process.env.JWT_SECRET_KEY as string, { expiresIn: '1h' }); + return new OkResponse({ token }); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +async function logout(request: UnwrappedRequest): Promise { + try { + const response = new OkResponse(); + response.headers.set('Clear-Site-Data', '"cookies","cache","storage","executionContexts"'); + return response; + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +async function changePassword(request: UnwrappedRequest): Promise { + try { + const requestBody = request.body as ChangePasswordRequest; + return new OkResponse( + await orm.users.changePassword( + SecureId.fromHash(request.params.id), + requestBody.oldPassword, + requestBody.newPassword, + request.claims, + ), + ); + } catch (error: any) { + return new ErrorResponse(error as Error); + } } export default { login, - test -}; \ No newline at end of file + token, + logout, + changePassword, +}; diff --git a/src/endpoints/games.ts b/src/endpoints/games.ts new file mode 100644 index 0000000..68d0f16 --- /dev/null +++ b/src/endpoints/games.ts @@ -0,0 +1,70 @@ +import { orm } from '../orm/orm'; +import { UnwrappedRequest } from '../utilities/guard'; +import { CreatedResponse, ErrorResponse, OkResponse } from '../utilities/responseHelper'; +import { + CreateGameRequest, + SecureId, + UpdateGameRequest, +} from '../utilities/requestModels'; + +async function create(request: UnwrappedRequest): Promise { + try { + const newUser = await orm.games.create( + { + name: request.body.name, + bggId: request.body.bggId, + imagePath: request.body.imagePath, + }, + request.claims, + ); + return new CreatedResponse(newUser); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +async function get(request: UnwrappedRequest): Promise { + try { + return new OkResponse(await orm.games.get(SecureId.fromHash(request.params.id))); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +async function update(request: UnwrappedRequest): Promise { + try { + return new OkResponse( + await orm.games.update( + SecureId.fromHash(request.params.id), + request.body, + request.claims, + ), + ); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +async function drop(request: UnwrappedRequest): Promise { + try { + return new OkResponse(await orm.games.drop(SecureId.fromHash(request.params.id))); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +async function query(request: UnwrappedRequest): Promise { + try { + return new OkResponse(await orm.games.query(request.params.query)); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +export default { + create, + get, + update, + drop, + query, +}; diff --git a/src/endpoints/player.ts b/src/endpoints/player.ts new file mode 100644 index 0000000..69acfd6 --- /dev/null +++ b/src/endpoints/player.ts @@ -0,0 +1,46 @@ +import { orm } from '../orm/orm'; +import { UnwrappedRequest } from '../utilities/guard'; +import { CreatedResponse, ErrorResponse, OkResponse } from '../utilities/responseHelper'; +import { CreatePlayerRequest, SecureId, UpdatePlayerRequest } from '../utilities/requestModels'; + +async function create(request: UnwrappedRequest): Promise { + try { + const newPlayer = await orm.players.create(request.body, request.claims); + return new CreatedResponse(newPlayer); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +async function get(request: UnwrappedRequest): Promise { + try { + return new OkResponse(await orm.players.get(SecureId.fromHash(request.params.id), request.claims)); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +async function update(request: UnwrappedRequest): Promise { + try { + return new OkResponse( + await orm.players.update(SecureId.fromHash(request.params.id), request.body, request.claims), + ); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +async function drop(request: UnwrappedRequest): Promise { + try { + return new OkResponse(await orm.players.drop(SecureId.fromHash(request.params.id), request.claims)); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +export default { + create, + get, + update, + drop, +}; diff --git a/src/endpoints/user.ts b/src/endpoints/user.ts index 3b6aad9..fc40100 100644 --- a/src/endpoints/user.ts +++ b/src/endpoints/user.ts @@ -1,22 +1,18 @@ -import {orm} from "../orm/orm"; -import {UnwrappedRequest} from "../utilities/guard"; -import {ErrorResponse} from "../utilities/responseHelper"; +import { orm } from '../orm/orm'; +import { UnwrappedRequest } from '../utilities/guard'; +import { CreatedResponse, ErrorResponse, OkResponse } from '../utilities/responseHelper'; +import { CreateUserRequest, SecureId, UpdateUserRequest } from '../utilities/requestModels'; -async function create(request: UnwrappedRequest): Promise { +async function create(request: UnwrappedRequest): Promise { try { - const requestBody = request.json; - - const newUser = await orm.users.create(requestBody.username, requestBody.password, request.claims); - if(!newUser) { - return new Response(null,{status: 201}) - } - - return Response.json( + const newUser = await orm.users.create( { - ...newUser + ...request.body, + playerId: SecureId.fromHash(request.body.playerId), }, - {status: 201} + request.claims, ); + return new CreatedResponse(newUser); } catch (error: any) { return new ErrorResponse(error as Error); } @@ -24,9 +20,29 @@ async function create(request: UnwrappedRequest): Promise { async function get(request: UnwrappedRequest): Promise { try { - return Response.json({ - ...(await orm.users.get(request.params.id, request.claims)) - }, {status: 200}); + return new OkResponse(await orm.users.get(SecureId.fromHash(request.params.id), request.claims)); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +async function update(request: UnwrappedRequest): Promise { + try { + return new OkResponse( + await orm.users.update( + SecureId.fromHash(request.params.id), + request.body, + request.claims, + ), + ); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +async function drop(request: UnwrappedRequest): Promise { + try { + return new OkResponse(await orm.users.drop(SecureId.fromHash(request.params.id), request.claims)); } catch (error: any) { return new ErrorResponse(error as Error); } @@ -35,4 +51,6 @@ async function get(request: UnwrappedRequest): Promise { export default { create, get, -} \ No newline at end of file + update, + drop, +}; diff --git a/src/index.ts b/src/index.ts index 8e460f6..6265a9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,27 +1,26 @@ -import {unwrapMethod, guard} from './utilities/guard'; -import auth from "./endpoints/auth"; -import user from "./endpoints/user"; +import auth from './routes/auth'; +import user from './routes/user'; +import player from './routes/player'; +import game from './routes/game'; +import { OkResponse } from './utilities/responseHelper'; const server = Bun.serve({ routes: { - "/api/auth/login": { - POST: unwrapMethod(auth.login), - }, - "/api/auth/test": { - GET: guard(auth.test, ['ADMIN', 'USERS_OTHER_DELETE']) - }, - "/api/user": { - POST: guard(user.create, ['ADMIN', 'USERS_CREATE']) - }, - "/api/user/:id": { - GET: guard(user.get, ['ADMIN', 'USERS_OTHERS_READ', 'USERS_SELF_READ']) + ...auth, + ...user, + ...player, + ...game, + '/test': { + GET: (request) => { + return new OkResponse(); + }, }, }, // (optional) fallback for unmatched routes: fetch(): Response { - return Response.json({message: "Not found"}, {status: 404}); + return Response.json({ message: 'Not found' }, { status: 404 }); }, }); -console.log(`Server running at ${server.url}`); \ No newline at end of file +console.log(`Server running at ${server.url}`); diff --git a/src/orm/claims.ts b/src/orm/claims.ts index 34e1301..a3bec1a 100644 --- a/src/orm/claims.ts +++ b/src/orm/claims.ts @@ -1,11 +1,12 @@ -import {sql} from 'bun'; +import { sql } from 'bun'; +import { ClaimDefinition } from '../utilities/claimDefinitions'; -export class Claims { - userId: string | undefined; +export class Claims extends ClaimDefinition { + userId?: string; claims: string[] = []; - public static test(userClaims: Claims, guardClaim: string): Boolean { - return userClaims.claims.some(x => x === guardClaim); + public static test(guardClaim: string, userClaims?: Claims): Boolean { + return userClaims === undefined || userClaims.claims.some((x) => x === guardClaim); } } @@ -13,11 +14,11 @@ export class ClaimsOrm { async getByUserId(userId: string): Promise { const dbResults: any[] = await sql`SELECT c.name from user_claims as uc - JOIN claims as c on uc.claimid = c.id - where uc.userid = ${userId};`; + JOIN claims as c on uc.claim_id = c.id + where uc.user_id = ${userId};`; const claims = new Claims(); claims.userId = userId; - claims.claims = dbResults.map(x => x.name); + claims.claims = dbResults.map((x) => x.name); return claims; } @@ -25,6 +26,6 @@ export class ClaimsOrm { const dbResults: any[] = await sql`SELECT id FROM claims WHERE is_default = true;`; - return dbResults.map(x => x.id); + return dbResults.map((x) => x.id); } -} \ No newline at end of file +} diff --git a/src/orm/games.ts b/src/orm/games.ts new file mode 100644 index 0000000..a711cfb --- /dev/null +++ b/src/orm/games.ts @@ -0,0 +1,125 @@ +import { Claims } from './claims'; +import { sql } from 'bun'; +import { first, memoize } from 'lodash'; +import { NotFoundError, UnauthorizedError } from '../utilities/errors'; +import { CreateGameRequest, SecureId, UpdateGameRequest } from '../utilities/requestModels'; +import { memo } from '../utilities/helpers'; + +export class Game { + id: SecureId; + name: string; + imagePath?: string; + bggId?: string; + + constructor(input: { id: SecureId; name: string; imagePath?: string; bggId?: string }) { + this.id = input.id; + this.name = input?.name; + this.imagePath = input?.imagePath; + this.bggId = input?.bggId; + } +} + +export class GamesOrm { + async create(model: CreateGameRequest, claims?: Claims): Promise { + await sql`INSERT INTO games (name, image_path, bgg_id) + VALUES (${model.name}, ${Claims.test(Claims.GAMES.MANAGE_IMAGES, claims) ? model.imagePath : null}, ${model.bggId})`; + const newGameId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string; + + try { + return await this.get(SecureId.fromID(newGameId)); + } catch (error) { + if (error instanceof UnauthorizedError) { + return null; + } + throw error; + } + } + + async get(id: SecureId): Promise { + const dbResult: any = first( + await sql`SELECT * + FROM games + WHERE id = ${id.raw} + LIMIT 1`, + ); + + if (!dbResult) { + throw new NotFoundError('No matching game exists'); + } + + return new Game({ + id: SecureId.fromID(dbResult.id), + name: dbResult.name, + bggId: dbResult.bgg_id, + imagePath: dbResult.image_path, + }); + } + + async update(id: SecureId, patch: UpdateGameRequest, claims?: Claims): Promise { + const gameToUpdate = await this.get(id); + gameToUpdate.name = patch.name ?? gameToUpdate.name; + gameToUpdate.bggId = patch.bggId ?? gameToUpdate.bggId; + + if (Claims.test(Claims.GAMES.MANAGE_IMAGES, claims)) { + gameToUpdate.imagePath = patch.imagePath ?? gameToUpdate.imagePath; + } + + await sql`UPDATE games + SET name=${gameToUpdate.name}, + bgg_id=${gameToUpdate.bggId}, + image_path=${gameToUpdate.imagePath} + WHERE id = ${id.raw}`; + + try { + return await this.get(id); + } catch (error) { + if (error instanceof UnauthorizedError) { + return null; + } + throw error; + } + } + + async drop(id: SecureId): Promise { + // Ensure player exists before attempting to delete + await this.get(id); + await sql.transaction(async (tx) => { + await tx`DELETE + FROM collection_games + WHERE game_id = ${id.raw}`; + await tx`DELETE + FROM match_players + WHERE match_id IN (SELECT id FROM matches WHERE game_id = ${id.raw})`; + await tx`DELETE + FROM matches + WHERE game_id = ${id.raw}`; + await tx`DELETE + FROM games + WHERE id = ${id.raw}`; + }); + + return; + } + + query: (query: string) => Promise = memo<(query: string) => Promise,Game[]>(this.#query); + async #query(query: string): Promise { + const dbResult: any = await sql` SELECT + id, name + FROM (SELECT *, SIMILARITY(${query}, name) as similarity FROM games) + WHERE similarity > 0 + ORDER BY similarity + LIMIT 5;`; + + if (!dbResult) { + throw new NotFoundError('No matching game exists'); + } + + return dbResult.map( + (x: { id: string; name: string }) => + new Game({ + id: SecureId.fromID(x.id), + name: x.name, + }), + ); + } +} diff --git a/src/orm/orm.ts b/src/orm/orm.ts index ba547fd..d6699e0 100644 --- a/src/orm/orm.ts +++ b/src/orm/orm.ts @@ -1,16 +1,13 @@ -import {ClaimsOrm} from "./claims"; -import {UsersOrm} from "./user"; - +import { ClaimsOrm } from './claims'; +import { UsersOrm } from './user'; +import { PlayersOrm } from './players'; +import { GamesOrm } from './games'; class Orm { - claims: ClaimsOrm; - users: UsersOrm; - - constructor() { - this.claims = new ClaimsOrm(); - this.users = new UsersOrm(this.claims); - } - + readonly claims: ClaimsOrm = new ClaimsOrm(); + readonly users: UsersOrm = new UsersOrm(); + readonly players: PlayersOrm = new PlayersOrm(); + readonly games: GamesOrm = new GamesOrm(); } -export const orm = new Orm(); \ No newline at end of file +export const orm = new Orm(); diff --git a/src/orm/players.ts b/src/orm/players.ts new file mode 100644 index 0000000..49b5b5e --- /dev/null +++ b/src/orm/players.ts @@ -0,0 +1,140 @@ +import { Claims } from './claims'; +import { sql } from 'bun'; +import { first } from 'lodash'; +import { BadRequestError, NotFoundError, UnauthorizedError } from '../utilities/errors'; +import { orm } from './orm'; +import { SecureId, UpdatePlayerRequest } from '../utilities/requestModels'; + +export class Player { + id: SecureId; + name: string; + elo: number; + isRatingLocked: boolean; + canBeMultiple: boolean; + + constructor(input: { + id: SecureId; + name: string; + elo?: number; + isRatingLocked?: boolean; + canBeMultiple?: boolean; + }) { + this.id = input.id; + this.name = input?.name; + this.elo = input?.elo ?? 1000; + this.isRatingLocked = input?.isRatingLocked ?? false; + this.canBeMultiple = input?.canBeMultiple ?? false; + } +} + +export class PlayersOrm { + async create(model: {name: string}, claims?: Claims): Promise { + await sql`INSERT INTO players (name) + VALUES (${model.name})`; + const newPlayerId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string; + + try { + return await this.get(SecureId.fromID(newPlayerId), claims); + } catch (error) { + if (error instanceof UnauthorizedError) { + return null; + } + throw error; + } + } + + async get(id: SecureId, claims?: Claims): Promise { + if (!(Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.PLAYERS.OTHER.READ, claims))) { + throw new UnauthorizedError(); + } else if (Claims.test(Claims.PLAYERS.SELF.READ, claims) && claims?.userId) { + const user = await orm.users.get(SecureId.fromHash(claims.userId)); + if (id.raw !== user.playerId.raw) { + throw new UnauthorizedError(); + } + } + const dbResult: any = first( + await sql`SELECT * + FROM players + WHERE id = ${id.raw} + LIMIT 1`, + ); + + if (!dbResult) { + throw new NotFoundError('No matching player exists'); + } + + return new Player({ + id: SecureId.fromID(dbResult.id), + name: dbResult.name, + elo: dbResult.elo, + isRatingLocked: dbResult.is_rating_locked, + canBeMultiple: dbResult.can_be_multiple, + }); + } + + async update( + id: SecureId, + patch: { + name?: string; + isRatingLocked?: boolean; + canBeMultiple?: boolean; + }, + claims?: Claims, + ): Promise { + if (!(Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.PLAYERS.OTHER.UPDATE, claims))) { + throw new UnauthorizedError(); + } else if (Claims.test(Claims.PLAYERS.SELF.UPDATE, claims) && claims?.userId) { + const user = await orm.users.get(SecureId.fromHash(claims.userId)); + if (id.raw !== user.playerId.raw) { + throw new UnauthorizedError(); + } + } + + const playerToUpdate = await this.get(id); + playerToUpdate.name = patch.name ?? playerToUpdate.name; + playerToUpdate.isRatingLocked = patch.isRatingLocked ?? playerToUpdate.isRatingLocked; + playerToUpdate.canBeMultiple = patch.canBeMultiple ?? playerToUpdate.canBeMultiple; + + await sql`UPDATE players + SET name=${playerToUpdate.name}, + is_rating_locked=${playerToUpdate.isRatingLocked}, + can_be_multiple=${playerToUpdate.canBeMultiple} + WHERE id = ${id.raw}`; + + try { + return await this.get(id, claims); + } catch (error) { + if (error instanceof UnauthorizedError) { + return null; + } + throw error; + } + } + + async drop(id: SecureId, claims?: Claims): Promise { + if (!(Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.PLAYERS.OTHER.DELETE, claims))) { + throw new UnauthorizedError(); + } else if (Claims.test(Claims.PLAYERS.SELF.DELETE, claims) && claims?.userId) { + const user = await orm.users.get(SecureId.fromHash(claims.userId)); + if (id.raw !== user.playerId.raw) { + throw new UnauthorizedError(); + } + } + + // Ensure player exists before attempting to delete + await this.get(id); + await sql.transaction(async (tx) => { + await tx`DELETE + FROM player_circles + WHERE player_id = ${id.raw}`; + await tx`DELETE + FROM match_players + WHERE player_id = ${id.raw}`; + await tx`DELETE + FROM players + WHERE id = ${id.raw}`; + }); + + return; + } +} diff --git a/src/orm/user.ts b/src/orm/user.ts index 3b0cae8..c777c57 100644 --- a/src/orm/user.ts +++ b/src/orm/user.ts @@ -1,17 +1,21 @@ -import {Claims, ClaimsOrm} from "./claims"; -import {sql} from "bun"; -import {first} from "lodash"; -import argon2 from "argon2"; -import {BadRequestError, NotFoundError, UnauthorizedError} from "../utilities/errors"; +import { Claims } from './claims'; +import { sql } from 'bun'; +import { first } from 'lodash'; +import argon2 from 'argon2'; +import { BadRequestError, NotFoundError, UnauthorizedError } from '../utilities/errors'; +import { SecureId, UpdateUserRequest } from '../utilities/requestModels'; +import { orm } from './orm'; export class User { - id: string; + id: SecureId; + playerId: SecureId; name: string; isAdmin: boolean; isActive: boolean; - constructor(id: string, name: string, isAdmin: boolean = false, isActive: boolean = true) { + constructor(id: SecureId, playerId: SecureId, name: string, isAdmin: boolean = false, isActive: boolean = true) { this.id = id; + this.playerId = playerId; this.name = name; this.isAdmin = isAdmin; this.isActive = isActive; @@ -19,79 +23,200 @@ export class User { } export class UsersOrm { - #claims: ClaimsOrm; - - constructor(claims: ClaimsOrm) { - this.#claims = claims; - } - - async get(id: string, claims: Claims): Promise { - if (!( - Claims.test(claims, 'ADMIN') || - Claims.test(claims, 'USERS_OTHER_READ') || - (Claims.test(claims, 'USERS_SELF_READ') && id === claims.userId) - )) { - throw new - UnauthorizedError(); + async create( + model: { username: string; password: string; playerId: SecureId }, + claims?: Claims, + ): Promise { + const existingUser: any = first( + await sql`SELECT id + FROM users + WHERE username = ${model.username} + LIMIT 1`, + ); + if (existingUser) { + throw new BadRequestError(`User ${model.username} already exists`); } - const dbResult: any = first(await sql`select * - from users - where id = ${id} - and is_active = true - limit 1`); + const defaultClaims: number[] = await orm.claims.getDefaultClaims(); + const passwordHash = await argon2.hash(model.password); + await sql`INSERT INTO users (username, pass_hash, player_id) + VALUES (${model.username}, ${passwordHash}, ${model.playerId.raw})`; + const newUserId: SecureId = SecureId.fromID((first(await sql`SELECT lastval();`) as any)?.lastval as string); + await sql.transaction(async (tx) => { + for (let i in defaultClaims) { + await tx`INSERT INTO user_claims (user_id, claim_id) + VALUES (${newUserId.raw}, ${defaultClaims[i]})`; + } + }); - if(!dbResult) { - throw new NotFoundError('No matching user exists'); - } - - return new User(dbResult.id, dbResult.username, dbResult.is_admin); - } - - async verify(username: string, password: string): Promise { try { - const dbResult: any = first(await sql`select * - from users - where username = ${username} - limit 1`); - if (!await argon2.verify(dbResult.pass_hash, password)) { + return await this.get(newUserId, claims); + } catch (error) { + if (error instanceof UnauthorizedError) { return null; } - - return this.#claims.getByUserId(dbResult.id); - } catch (error) { throw error; } } - async create(username: string, password: string, claims: Claims): Promise { - const existingUser: any = first(await sql`SELECT id - FROM users - WHERE username = ${username} - LIMIT 1`); - if (existingUser) { - throw new BadRequestError(`User ${username} already exists`); + async get(id: SecureId, claims?: Claims): Promise { + if ( + !( + Claims.test(Claims.ADMIN, claims) || + Claims.test(Claims.USERS.OTHER.READ, claims) || + (Claims.test(Claims.USERS.SELF.READ, claims) && id.raw === claims?.userId) + ) + ) { + throw new UnauthorizedError(); } - const defaultClaims: number[] = await this.#claims.getDefaultClaims(); - const passwordHash = await argon2.hash(password); - await sql`INSERT INTO users (username, pass_hash) - VALUES (${username}, ${passwordHash})`; - const newUserId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string; - await sql.transaction(async (tx) => { - for (let i in defaultClaims) { - await tx`INSERT INTO user_claims (userid, claimid) - VALUES (${newUserId}, ${defaultClaims[i]})`; - } - }) + const dbResult: any = first( + await sql`SELECT * + FROM users + WHERE id = ${id.raw} + AND is_active = true + LIMIT 1`, + ); - if (!( - Claims.test(claims, 'ADMIN') || - Claims.test(claims, 'USERS_OTHER_READ') - )) { + if (!dbResult) { + throw new NotFoundError('No matching user exists'); + } + + return new User( + SecureId.fromID(dbResult.id), + SecureId.fromID(dbResult.player_id), + dbResult.username, + dbResult.is_admin, + ); + } + + async update( + id: SecureId, + patch: { isActive?: boolean; isAdmin?: boolean }, + claims?: Claims, + ): Promise { + if ( + !( + Claims.test(Claims.ADMIN, claims) || + Claims.test(Claims.USERS.OTHER.UPDATE, claims) || + (Claims.test(Claims.USERS.SELF.UPDATE, claims) && id.raw === claims?.userId) + ) + ) { + throw new UnauthorizedError(); + } + + const userToUpdate = await this.get(id); + if (Claims.test(Claims.ADMIN, claims)) { + userToUpdate.isActive = patch.isActive ?? userToUpdate.isActive; + userToUpdate.isAdmin = patch.isAdmin ?? userToUpdate.isAdmin; + } + + await sql`UPDATE users + SET is_active=${userToUpdate.isActive}, + is_admin=${userToUpdate.isAdmin} + WHERE id = ${id.raw}`; + + try { + return await this.get(id, claims); + } catch (error) { + if (error instanceof UnauthorizedError) { + return null; + } + throw error; + } + } + + async drop(id: SecureId, claims?: Claims): Promise { + if ( + !( + Claims.test(Claims.ADMIN, claims) || + Claims.test(Claims.USERS.OTHER.DELETE, claims) || + (Claims.test(Claims.USERS.SELF.DELETE, claims) && id.raw === claims?.userId) + ) + ) { + throw new UnauthorizedError(); + } + + // Ensure user exists before attempting to delete + await this.get(id); + await sql.transaction(async (tx) => { + await tx`DELETE + FROM user_claims + WHERE user_id = ${id.raw}`; + await tx`DELETE + FROM users + WHERE id = ${id.raw}`; + }); + + return null; + } + + async verifyCredentials( + username: string, + password: string, + ): Promise<{ userId: SecureId; refreshCount: string } | null> { + const dbResult: any = first( + await sql`SELECT * + FROM users + WHERE username = ${username} + AND is_active = true + limit 1`, + ); + if (!dbResult) { + throw new UnauthorizedError(); + } + + if (!(await argon2.verify(dbResult.pass_hash, password))) { return null; } - return await this.get(newUserId, claims); + return { + userId: SecureId.fromID(dbResult.id), + refreshCount: dbResult.refresh_count, + }; } -} \ No newline at end of file + + async verifyRefreshCount(id: SecureId, refreshCount: string): Promise { + const dbResult: any = first( + await sql`SELECT * + FROM users + WHERE id = ${id.raw} + LIMIT 1`, + ); + console.log(dbResult.refresh_count, refreshCount); + return dbResult.refresh_count === refreshCount; + } + + async changePassword( + id: SecureId, + oldPassword: string | null, + newPassword: string, + claims?: Claims, + ): Promise { + const isAdmin = Claims.test(Claims.ADMIN, claims); + if (!(isAdmin || (Claims.test(Claims.USERS.SELF.UPDATE, claims) && id.raw === claims?.userId))) { + throw new UnauthorizedError(); + } + + if (!isAdmin && oldPassword === null) { + throw new BadRequestError('Password is required'); + } + + const dbUser: any = first( + await sql`SELECT * + FROM users + WHERE id = ${id.raw} + LIMIT 1`, + ); + + if (!isAdmin && !(await argon2.verify(dbUser.pass_hash, oldPassword as string))) { + throw new UnauthorizedError(); + } + + const passwordHash = await argon2.hash(newPassword); + await sql`UPDATE users + SET pass_hash=${passwordHash} + WHERE id = ${id.raw}`; + return; + } +} diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..78302ba --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,22 @@ +import { guard, unwrapMethod } from '../utilities/guard'; +import auth from '../endpoints/auth'; +import { OkResponse } from '../utilities/responseHelper'; +import { Claims } from '../orm/claims'; + +export default { + '/api/auth/login': { + POST: unwrapMethod(auth.login), + }, + '/api/auth/token': { + GET: unwrapMethod(auth.token), + }, + '/api/auth/logout': { + POST: unwrapMethod(auth.logout), + }, + '/api/auth/changePassword/:id': { + PATCH: guard(auth.changePassword, [Claims.ADMIN, Claims.USERS.SELF.UPDATE]), + }, + '/api/auth/test': { + GET: () => new OkResponse(), + }, +}; diff --git a/src/routes/game.ts b/src/routes/game.ts new file mode 100644 index 0000000..7c4b709 --- /dev/null +++ b/src/routes/game.ts @@ -0,0 +1,17 @@ +import { guard } from '../utilities/guard'; +import { Claims } from '../orm/claims'; +import games from '../endpoints/games'; + +export default { + '/api/game': { + POST: guard(games.create, [Claims.ADMIN, Claims.GAMES.CREATE]), + }, + '/api/game/:id': { + GET: guard(games.get, [Claims.ADMIN, Claims.GAMES.READ]), + PATCH: guard(games.update, [Claims.ADMIN, Claims.GAMES.UPDATE]), + DELETE: guard(games.drop, [Claims.ADMIN, Claims.GAMES.DELETE]), + }, + '/api/game/search/:query': { + GET: guard(games.query, [Claims.ADMIN, Claims.GAMES.READ]), + }, +}; diff --git a/src/routes/player.ts b/src/routes/player.ts new file mode 100644 index 0000000..f36942d --- /dev/null +++ b/src/routes/player.ts @@ -0,0 +1,14 @@ +import { guard } from '../utilities/guard'; +import { Claims } from '../orm/claims'; +import player from '../endpoints/player'; + +export default { + '/api/player': { + POST: guard(player.create, [Claims.ADMIN, Claims.PLAYERS.CREATE]), + }, + '/api/player/:id': { + GET: guard(player.get, [Claims.ADMIN, Claims.PLAYERS.OTHER.READ, Claims.PLAYERS.SELF.READ]), + PATCH: guard(player.update, [Claims.ADMIN, Claims.PLAYERS.OTHER.UPDATE, Claims.PLAYERS.SELF.UPDATE]), + DELETE: guard(player.drop, [Claims.ADMIN, Claims.PLAYERS.OTHER.DELETE, Claims.PLAYERS.SELF.DELETE]), + }, +}; diff --git a/src/routes/user.ts b/src/routes/user.ts new file mode 100644 index 0000000..e1baff2 --- /dev/null +++ b/src/routes/user.ts @@ -0,0 +1,14 @@ +import { guard } from '../utilities/guard'; +import user from '../endpoints/user'; +import { Claims } from '../orm/claims'; + +export default { + '/api/user': { + POST: guard(user.create, [Claims.ADMIN, Claims.USERS.CREATE]), + }, + '/api/user/:id': { + GET: guard(user.get, [Claims.ADMIN, Claims.USERS.OTHER.READ, Claims.USERS.SELF.READ]), + PATCH: guard(user.update, [Claims.ADMIN, Claims.USERS.OTHER.UPDATE, Claims.USERS.SELF.UPDATE]), + DELETE: guard(user.drop, [Claims.ADMIN, Claims.USERS.OTHER.UPDATE, Claims.USERS.SELF.UPDATE]), + }, +}; diff --git a/src/tests/auth.test.ts b/src/tests/auth.test.ts new file mode 100644 index 0000000..76a70d8 --- /dev/null +++ b/src/tests/auth.test.ts @@ -0,0 +1,119 @@ +import { expect, test } from 'bun:test'; +import auth from '../endpoints/auth'; +import { UnwrappedRequest } from '../utilities/guard'; +import { Claims } from '../orm/claims'; +import { orm } from '../orm/orm'; +import { User } from '../orm/user'; + +test('login', async () => { + await orm.users.create('authTest', 'test123'); + + const request = new UnwrappedRequest({ + json: { + username: 'authTest', + password: 'test123', + }, + }); + + const response = await auth.login(request); + + expect(response.status).toBe(200); +}); + +test("login user that doesn't exist", async () => { + const request = new UnwrappedRequest({ + json: { + username: 'thisUserDoesNotExist', + password: 'test123', + }, + }); + + const response = await auth.login(request); + + expect(response.status).toBe(401); +}); + +test('login with invalid password', async () => { + const createdUser = (await orm.users.create('authTest2', 'test123')) as User; + + const request = new UnwrappedRequest({ + json: { + username: 'authTest2', + password: 'wrongPassword', + }, + }); + + const response = await auth.login(request); + + expect(response.status).toBe(401); +}); + +test('Change password', async () => { + const claims = new Claims(); + claims.claims.push(Claims.USERS.SELF.UPDATE); + + const testUser = (await orm.users.create('authTest3', 'test123')) as User; + claims.userId = testUser.id; + + const request = new UnwrappedRequest({ + claims, + params: { + id: testUser.id, + }, + json: { + oldPassword: 'test123', + newPassword: 'test1234', + }, + }); + + const response = await auth.changePassword(request); + + expect(response.status).toBe(200); + expect(response.body).toBeNull(); +}); + +test('Change password with incorrect old password', async () => { + const claims = new Claims(); + claims.claims.push(Claims.USERS.SELF.UPDATE); + + const testUser = (await orm.users.create('authTest4', 'test123')) as User; + claims.userId = testUser.id; + + const request = new UnwrappedRequest({ + claims, + params: { + id: testUser.id, + }, + json: { + oldPassword: 'wrongPassword', + newPassword: 'test1234', + }, + }); + + const response = await auth.changePassword(request); + + expect(response.status).toBe(401); +}); + +test('Change password as admin', async () => { + const claims = new Claims(); + claims.userId = '1'; + claims.claims.push(Claims.ADMIN); + + const testUser = (await orm.users.create('authTest5', 'test123')) as User; + + const request = new UnwrappedRequest({ + claims, + params: { + id: testUser.id, + }, + json: { + newPassword: 'test1234', + }, + }); + + const response = await auth.changePassword(request); + + expect(response.status).toBe(200); + expect(response.body).toBeNull(); +}); diff --git a/src/tests/global-mocks.ts b/src/tests/global-mocks.ts index b5bb4f5..27567e0 100644 --- a/src/tests/global-mocks.ts +++ b/src/tests/global-mocks.ts @@ -1,2 +1,2 @@ -import {expect, test, beforeAll} from 'bun:test'; -import {sql} from "bun"; +import { expect, test, beforeAll } from 'bun:test'; +import { sql } from 'bun'; diff --git a/src/tests/test-setup.ts b/src/tests/test-setup.ts index d9750f1..675394b 100644 --- a/src/tests/test-setup.ts +++ b/src/tests/test-setup.ts @@ -1,6 +1,6 @@ -import {beforeAll} from 'bun:test'; +import { beforeAll } from 'bun:test'; import Bun from 'bun'; -import {sql} from "bun"; +import { sql } from 'bun'; beforeAll(async () => { console.log(process.env.DATABASE_URL); @@ -24,12 +24,3 @@ beforeAll(async () => { await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_OTHER_READ', true)`; await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_SELF_DELETE', false)`; }); - - - - - - - - - diff --git a/src/tests/user.test.ts b/src/tests/user.test.ts index b1af5be..1cb8424 100644 --- a/src/tests/user.test.ts +++ b/src/tests/user.test.ts @@ -1,11 +1,13 @@ -import {expect, test} from 'bun:test'; +import { expect, test } from 'bun:test'; import user from '../endpoints/user'; -import {UnwrappedRequest} from "../utilities/guard"; -import {Claims} from "../orm/claims"; +import { UnwrappedRequest } from '../utilities/guard'; +import { Claims } from '../orm/claims'; +import { orm } from '../orm/orm'; +import { User } from '../orm/user'; test('Create user as admin', async () => { const claims = new Claims(); - claims.claims.push('ADMIN'); + claims.claims.push(Claims.ADMIN); const request = new UnwrappedRequest({ claims, @@ -24,7 +26,7 @@ test('Create user as admin', async () => { test('Create user without read access', async () => { const claims = new Claims(); - claims.claims.push('USERS_CREATE'); + claims.claims.push(Claims.USERS.CREATE); const request = new UnwrappedRequest({ claims, @@ -43,7 +45,7 @@ test('Create user without read access', async () => { test('Create user that already exists', async () => { const claims = new Claims(); - claims.claims.push('USERS_CREATE'); + claims.claims.push(Claims.USERS.CREATE); const request = new UnwrappedRequest({ claims, @@ -61,13 +63,13 @@ test('Create user that already exists', async () => { test('Get user', async () => { const claims = new Claims(); - claims.claims.push('USERS_OTHER_READ'); + claims.claims.push(Claims.USERS.OTHER.READ); const request = new UnwrappedRequest({ claims, request: null, params: { - id: 1 + id: 1, }, }); @@ -79,14 +81,14 @@ test('Get user', async () => { test('Get user self with only self read permission', async () => { const claims = new Claims(); - claims.userId = "1"; - claims.claims.push('USERS_OTHER_READ'); + claims.userId = '1'; + claims.claims.push(Claims.USERS.OTHER.READ); const request = new UnwrappedRequest({ claims, request: null, params: { - id: 1 + id: 1, }, }); @@ -98,14 +100,14 @@ test('Get user self with only self read permission', async () => { test('Get other user without read permissions', async () => { const claims = new Claims(); - claims.userId = "2"; - claims.claims.push('USERS_SELF_READ'); + claims.userId = '2'; + claims.claims.push(Claims.USERS.SELF.READ); const request = new UnwrappedRequest({ claims, request: null, params: { - id: 1 + id: 1, }, }); @@ -113,18 +115,170 @@ test('Get other user without read permissions', async () => { expect(response.status).toBe(401); }); -test('Get user that doesn\'t exist', async () => { +test("Get user that doesn't exist", async () => { const claims = new Claims(); - claims.claims.push('ADMIN'); + claims.claims.push(Claims.ADMIN); const request = new UnwrappedRequest({ claims, request: null, params: { - id: 101 + id: 101, }, }); const response = await user.get(request); expect(response.status).toBe(404); -}); \ No newline at end of file +}); + +test('Update user', async () => { + const claims = new Claims(); + claims.claims.push(Claims.ADMIN); + + const request = new UnwrappedRequest({ + claims, + request: null, + json: { + isAdmin: true, + }, + params: { + id: 2, + }, + }); + + const response = await user.update(request); + expect(response.status).toBe(200); + expect(response.body).toBeDefined(); +}); + +test('Update user without read access', async () => { + const claims = new Claims(); + claims.userId = '1'; + claims.claims.push(Claims.USERS.OTHER.UPDATE); + + const request = new UnwrappedRequest({ + claims, + request: null, + json: { + isAdmin: true, + }, + params: { + id: 2, + }, + }); + + const response = await user.update(request); + expect(response.status).toBe(200); + expect(response.body).toBeNull(); +}); + +test('Update user without permissions', async () => { + const claims = new Claims(); + claims.userId = '1'; + + const request = new UnwrappedRequest({ + claims, + request: null, + json: { + isAdmin: true, + }, + params: { + id: 2, + }, + }); + + const response = await user.update(request); + expect(response.status).toBe(401); +}); + +test("Update user that doesn't exist", async () => { + const claims = new Claims(); + claims.userId = '1'; + claims.claims.push(Claims.ADMIN); + + const request = new UnwrappedRequest({ + claims, + request: null, + json: { + isAdmin: true, + }, + params: { + id: 101, + }, + }); + + const response = await user.update(request); + expect(response.status).toBe(404); +}); + +test('Delete user', async () => { + const claims = new Claims(); + claims.claims.push(Claims.ADMIN); + + const createdUser = (await orm.users.create('test3', 'test123')) as User; + + const request = new UnwrappedRequest({ + claims, + request: null, + params: { + id: createdUser.id, + }, + }); + + const response = await user.drop(request); + expect(response.status).toBe(200); +}); + +test('Delete user without delete permissions', async () => { + const claims = new Claims(); + const createdUser = (await orm.users.create('test4', 'test123')) as User; + + const request = new UnwrappedRequest({ + claims, + request: null, + params: { + id: createdUser.id, + }, + }); + + const response = await user.drop(request); + expect(response.status).toBe(401); +}); + +test('Delete self user with only self delete permissions', async () => { + const claims = new Claims(); + claims.claims.push(Claims.USERS.SELF.DELETE); + + const createdUser = (await orm.users.create('test5', 'test123')) as User; + claims.userId = createdUser.id; + + const request = new UnwrappedRequest({ + claims, + request: null, + params: { + id: createdUser.id, + }, + }); + + const response = await user.drop(request); + expect(response.status).toBe(200); +}); + +test('Delete other user with only self delete permissions', async () => { + const claims = new Claims(); + claims.userId = '1'; + claims.claims.push(Claims.USERS.SELF.DELETE); + + const createdUser = (await orm.users.create('test6', 'test123')) as User; + + const request = new UnwrappedRequest({ + claims, + request: null, + params: { + id: createdUser.id, + }, + }); + + const response = await user.drop(request); + expect(response.status).toBe(401); +}); diff --git a/src/utilities/claimDefinitions.ts b/src/utilities/claimDefinitions.ts new file mode 100644 index 0000000..d8be20c --- /dev/null +++ b/src/utilities/claimDefinitions.ts @@ -0,0 +1,131 @@ +export class ClaimDefinition { + public static readonly ADMIN = 'ADMIN'; + public static readonly USERS = { + CREATE: 'USERS_CREATE', + SELF: { + READ: 'USERS_SELF_READ', + UPDATE: 'USERS_SELF_UPDATE', + DELETE: 'USERS_SELF_DELETE', + }, + OTHER: { + READ: 'USERS_OTHER_READ', + UPDATE: 'USERS_OTHER_UPDATE', + DELETE: 'USERS_OTHER_DELETE', + }, + }; + public static readonly PLAYERS = { + CREATE: 'PLAYERS_CREATE', + SELF: { + READ: 'PLAYERS_SELF_READ', + UPDATE: 'PLAYERS_SELF_UPDATE', + DELETE: 'PLAYERS_SELF_DELETE', + }, + OTHER: { + READ: 'PLAYERS_OTHER_READ', + UPDATE: 'PLAYERS_OTHER_UPDATE', + DELETE: 'PLAYERS_OTHER_DELETE', + }, + }; + public static readonly CIRCLES = { + PUBLIC: { + CREATE: 'CIRCLES_PUBLIC_CREATE', + JOIN: 'CIRCLES_PUBLIC_JOIN', + USERS: { + ADD: 'CIRCLES_PUBLIC_USER_ADD', + LIST: 'CIRCLES_PUBLIC_USER_LIST', + INVITE: 'CIRCLES_PUBLIC_USER_INVITE', + }, + COMMENTS: { + ADD: 'CIRCLES_PUBLIC_COMMENTS_ADD', + DELETE: 'CIRCLES_PUBLIC_COMMENTS_DELETE', + }, + }, + PRIVATE: { + CREATE: 'CIRCLES_PRIVATE_CREATE', + USERS: { + INVITE: 'CIRCLES_PRIVATE_USER_INVITE', + }, + }, + OWNED: { + READ: 'CIRCLES_OWNED_READ', + UPDATE: 'CIRCLES_OWNED_UPDATE', + DELETE: 'CIRCLES_OWNED_DELETE', + USERS: { + ADD: 'CIRCLES_OWNED_USER_ADD', + LIST: 'CIRCLES_OWNED_USER_LIST', + USERS: { + INVITE: 'CIRCLES_OWNED_USER_INVITE', + }, + }, + COMMENTS: { + ADD: 'CIRCLES_OWNED_COMMENTS_ADD', + DELETE: 'CIRCLES_OWNED_COMMENTS_DELETE', + }, + }, + UNOWNED: { + READ: 'CIRCLES_UNOWNED_READ', + UPDATE: 'CIRCLES_UNOWNED_UPDATE', + DELETE: 'CIRCLES_UNOWNED_DELETE', + COMMENTS: { + ADD: 'CIRCLES_UNOWNED_COMMENTS_ADD', + DELETE: 'CIRCLES_UNOWNED_COMMENTS_DELETE', + }, + }, + }; + public static readonly GAMES = { + CREATE: 'GAMES_CREATE', + READ: 'GAMES_READ', + UPDATE: 'GAMES_UPDATE', + DELETE: 'GAMES_DELETE', + MANAGE_IMAGES: 'GAMES_IMAGES_MANAGE', + }; + public static readonly MATCHES = { + CREATE: 'MATCHES_CREATE', + OWNED: { + READ: 'MATCHES_OWNED_READ', + UPDATE: 'MATCHES_OWNED_UPDATE', + DELETE: 'MATCHES_OWNED_DELETE', + COMMENTS: { + ADD: 'MATCHES_OWNED_COMMENTS_ADD', + DELETE: 'MATCHES_OWNED_COMMENTS_DELETE', + }, + }, + UNOWNED: { + READ: 'MATCHES_UNOWNED_READ', + UPDATE: 'MATCHES_UNOWNED_UPDATE', + DELETE: 'MATCHES_UNOWNED_DELETE', + COMMENTS: { + ADD: 'MATCHES_UNOWNED_COMMENTS_ADD', + DELETE: 'MATCHES_UNOWNED_COMMENTS_DELETE', + }, + }, + }; + public static readonly COLLECTIONS = { + CREATE: 'COLLECTIONS_CREATE', + OWNED: { + READ: 'COLLECTIONS_OWNED_READ', + UPDATE: 'COLLECTIONS_OWNED_UPDATE', + DELETE: 'COLLECTIONS_OWNED_DELETE', + COMMENTS: { + DELETE: 'COLLECTIONS_OWNED_COMMENTS_DELETE', + }, + }, + UNOWNED: { + READ: 'COLLECTIONS_UNOWNED_READ', + UPDATE: 'COLLECTIONS_UNOWNED_UPDATE', + DELETE: 'COLLECTIONS_UNOWNED_DELETE', + }, + }; + public static readonly COMMENTS = { + OWNED: { + READ: 'COMMENTS_OWNED_READ', + UPDATE: 'COMMENTS_OWNED_UPDATE', + DELETE: 'COMMENTS_OWNED_DELETE', + }, + UNOWNED: { + READ: 'COMMENTS_UNOWNED_READ', + UPDATE: 'COMMENTS_UNOWNED_UPDATE', + DELETE: 'COMMENTS_UNOWNED_DELETE', + }, + }; +} diff --git a/src/utilities/elo.ts b/src/utilities/elo.ts new file mode 100644 index 0000000..090aaa4 --- /dev/null +++ b/src/utilities/elo.ts @@ -0,0 +1,45 @@ +import { orderBy } from 'lodash'; + +interface GamePlayer { + id: string; + elo: number; + gamesPlayed: number; + standing: number; + eloChange?: number; +} +export interface GamePlayed { + players: GamePlayer[]; +} + +export function calculateElos(game: GamePlayed, provisionalPeriod: number = 1): GamePlayed { + const orderedResults = orderBy(game.players, 'standing', 'asc'); + for (let i = 0; i < orderedResults.length - 1; i++) { + for (let j = i + 1; j < orderedResults.length; j++) { + const challengerResults = calculateEloChange(orderedResults[i].elo, orderedResults[j].elo); + orderedResults[i].eloChange = + (orderedResults[i].eloChange ?? 0) + + challengerResults.winnerChange * Math.min(1, orderedResults[j].gamesPlayed / provisionalPeriod); + orderedResults[j].eloChange = + (orderedResults[j].eloChange ?? 0) + + challengerResults.loserChange * Math.min(1, orderedResults[i].gamesPlayed / provisionalPeriod); + } + } + return { + players: orderedResults, + }; +} + +interface EloResult { + winnerChange: number; + loserChange: number; +} +function calculateEloChange(winnerElo: number, loserElo: number, draw: boolean = false): EloResult { + const ratingStep = 32; + const expectedWinnerResult = 1 / (1 + Math.pow(10, (loserElo - winnerElo) / 400)); + const expectedLoserResult = 1 / (1 + Math.pow(10, (winnerElo - loserElo) / 400)); + + return { + winnerChange: ratingStep * ((draw ? 0.5 : 1) - expectedWinnerResult), + loserChange: ratingStep * ((draw ? 0.5 : 0) - expectedLoserResult), + }; +} diff --git a/src/utilities/errors.ts b/src/utilities/errors.ts index 235cc00..54b117b 100644 --- a/src/utilities/errors.ts +++ b/src/utilities/errors.ts @@ -1,17 +1,17 @@ export class BadRequestError extends Error { - constructor(message: string | undefined = undefined) { + constructor(message?: string | undefined) { super(message); } } export class UnauthorizedError extends Error { - constructor(message: string | undefined = undefined) { + constructor(message?: string | undefined) { super(message); } } export class NotFoundError extends Error { - constructor(message: string | undefined = undefined) { + constructor(message?: string | undefined) { super(message); } -} \ No newline at end of file +} diff --git a/src/utilities/guard.ts b/src/utilities/guard.ts index b71ea92..6cc9f48 100644 --- a/src/utilities/guard.ts +++ b/src/utilities/guard.ts @@ -1,10 +1,17 @@ -import {BunRequest as Request} from 'bun'; -import jwt from 'jsonwebtoken'; -import {ErrorResponse} from "./responseHelper"; -import {UnauthorizedError} from "./errors"; -import {Claims} from "../orm/claims"; +import { BunRequest as Request } from 'bun'; +import jwt, { TokenExpiredError } from 'jsonwebtoken'; +import { ErrorResponse, UnauthorizedResponse } from './responseHelper'; +import { UnauthorizedError } from './errors'; +import { Claims } from '../orm/claims'; +import HashIds from 'hashids'; -export function guardRedirect(method: Function, redirectMethod: Function, guardedClaims: string[] | undefined = undefined) { +export const hashIds = new HashIds(process.env.JWT_SECRET, 4); + +export function guardRedirect( + method: (request: UnwrappedRequest) => Promise | Response, + redirectMethod: Function, + guardedClaims: string[], +) { try { return guard(method, guardedClaims); } catch (e) { @@ -12,47 +19,56 @@ export function guardRedirect(method: Function, redirectMethod: Function, guarde } } -export function guard(method: Function, guardedClaims: string[] | undefined = undefined): (r: Request) => Promise { +export function guard( + method: (request: UnwrappedRequest) => Promise | Response, + guardedClaims: string[], +): (r: Request) => Promise { return async (request: Request): Promise => { - const authHeader: string | null = request.headers.get('Authorization')?.replace(/^Bearer /, '') as string ?? null; + const authHeader: string | null = + (request.headers.get('Authorization')?.replace(/^Bearer /, '') as string) ?? null; try { const userClaims: Claims = jwt.verify(authHeader as string, process.env.JWT_SECRET_KEY as string) as Claims; - if (guardedClaims !== undefined && !userClaims.claims.some((x: string): boolean => guardedClaims.includes(x))) { + if (!userClaims.claims.some((x: string): boolean => guardedClaims.includes(x))) { throw new UnauthorizedError('Unauthorized'); } return method(await unwrap(request, userClaims)); } catch (error: any) { + if (error instanceof TokenExpiredError) { + return new UnauthorizedResponse(error.message); + } return new ErrorResponse(error as Error); } - } + }; } -export class UnwrappedRequest { - readonly json: any; +export class UnwrappedRequest { + readonly body: T; readonly request: Request; readonly params: { [x: string]: string }; readonly claims: Claims; constructor(input: any) { - this.json = input.json; + this.body = input.body; this.request = input.request; - this.claims = input.claims; + this.claims = input.claims || new Claims(); this.params = input.params; } } -export async function unwrap(request: Request, claims: Claims | null = null) { - return new UnwrappedRequest({ +export async function unwrap(request: Request, claims?: Claims) { + return new UnwrappedRequest({ request, claims, - json: request.body ? await request.json() : null, + body: request.body ? await request.json() : null, params: request.params, - }) + }); } -export function unwrapMethod(methodToUnwrap: ((r: UnwrappedRequest) => Response) | ((r: UnwrappedRequest) => Promise)): (r: Request) => Promise { +export function unwrapMethod( + methodToUnwrap: ((r: UnwrappedRequest) => Response) | ((r: UnwrappedRequest) => Promise), +): (r: Request) => Promise { return async (request: Request) => { - const unwrappedRequest = await unwrap(request); + const unwrappedRequest = await unwrap(request); return await methodToUnwrap(unwrappedRequest); }; -} \ No newline at end of file +} diff --git a/src/utilities/helpers.ts b/src/utilities/helpers.ts new file mode 100644 index 0000000..2369bae --- /dev/null +++ b/src/utilities/helpers.ts @@ -0,0 +1,20 @@ +export function memo {}, S>( + func: T, + lifespan: number = 5 * 60 * 1000, + keyDelegate?: (...args: any[]) => string, +): T { + const cache: { [key: string]: { value: S; timestamp: number } } = {}; + return ((...args: any[]): S => { + const key: string = (keyDelegate ? keyDelegate(...args) : args?.[0]?.toString()) ?? ''; + const now = Date.now(); + + if (!cache[key] || now - cache[key].timestamp > lifespan) { + cache[key] = { + value: func(...args) as S, + timestamp: now, + }; + } + + return cache[key].value; + }) as unknown as T; +} diff --git a/src/utilities/requestModels.ts b/src/utilities/requestModels.ts new file mode 100644 index 0000000..cf66089 --- /dev/null +++ b/src/utilities/requestModels.ts @@ -0,0 +1,75 @@ +import { hashIds } from './guard'; + +export interface LoginRequest { + username: string; + password: string; +} +export interface ChangePasswordRequest { + oldPassword: string | null; + newPassword: string; +} +export interface CreateUserRequest { + username: string; + password: string; + playerId: string; +} +export interface UpdateUserRequest { + isActive?: boolean; + isAdmin?: boolean; +} +export interface CreatePlayerRequest { + name: string; +} +export interface UpdatePlayerRequest { + name?: string; + isRatingLocked?:boolean; + canBeMultiple?:boolean; +} +export interface CreateGameRequest { + name: string; + imagePath?: string; + bggId?: string; +} +export interface UpdateGameRequest { + name: string; + imagePath?: string; + bggId?: string; +} + +export class SecureId { + #hashedValue?: string; + #secureValue?: string; + get value(): string | undefined { + return this.#hashedValue; + } + set value(value: string) { + this.#hashedValue = value; + this.#secureValue = hashIds.decode(value)?.toString(); + } + get raw(): string | undefined { + return this.#secureValue; + } + set raw(value: string) { + this.#hashedValue = hashIds.encode(value); + this.#secureValue = value; + } + constructor(id: { public?: string; secure?: string }) { + if (id.public) { + this.value = id.public; + } else if (id.secure) { + this.raw = id.secure; + } + } + + toJSON(): string | undefined { + return this.#hashedValue; + } + + public static fromHash(hash: string) { + return new SecureId({ public: hash }); + } + + public static fromID(id: string) { + return new SecureId({ secure: id }); + } +} diff --git a/src/utilities/responseHelper.ts b/src/utilities/responseHelper.ts index 14a6247..008d9f8 100644 --- a/src/utilities/responseHelper.ts +++ b/src/utilities/responseHelper.ts @@ -1,18 +1,75 @@ -import {BadRequestError, NotFoundError, UnauthorizedError} from "./errors"; +import { BadRequestError, NotFoundError, UnauthorizedError } from './errors'; +import { hashIds } from './guard'; export class ErrorResponse extends Response { //@ts-ignore constructor(error: Error) { - if(error instanceof BadRequestError) { - return Response.json({message: error.message}, {status: 400}); - } - else if(error instanceof UnauthorizedError){ - return Response.json({message: error.message}, {status: 401}); - } - else if(error instanceof NotFoundError){ - return Response.json({message: error.message}, {status: 404}); + if (error instanceof BadRequestError) { + return new BadRequestResponse(error.message); + } else if (error instanceof UnauthorizedError) { + return new UnauthorizedResponse(error.message); + } else if (error instanceof NotFoundError) { + return new NotFoundResponse(error.message); } - return Response.json({message: error.message}, {status: 500}); + return Response.json({ message: error.message }, { status: 500 }); } -} \ No newline at end of file +} + +export class BadRequestResponse extends Response { + // @ts-ignore + constructor(message?: string) { + return Response.json({ message: message }, { status: 400 }); + } +} + +export class UnauthorizedResponse extends Response { + // @ts-ignore + constructor(message?: string) { + return Response.json({ message: message }, { status: 401 }); + } +} + +export class NotFoundResponse extends Response { + // @ts-ignore + constructor(message?: string) { + return Response.json({ message: message }, { status: 404 }); + } +} + +export class OkResponse extends Response { + // @ts-ignore + constructor(body?: Model | null) { + if (body) { + return Response.json( + { + ...body, + }, + { + status: 200, + headers: { + 'Access-Control-Allow-Origin': '*', + }, + }, + ); + } + + return new Response(null, { + status: 200, + headers: { + 'Access-Control-Allow-Origin': '*', + }, + }); + } +} + +export class CreatedResponse extends Response { + // @ts-ignore + constructor(body?: any) { + if (body) { + return Response.json({ ...body }, { status: 201 }); + } + + return new Response(null, { status: 201 }); + } +} diff --git a/tsconfig.json b/tsconfig.json index 3ffa57b..8d13adf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,6 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, - "skipLibCheck": true + "skipLibCheck": true, } }