From 59d2819750a27a75c06f84e25f7809360f72c1bc Mon Sep 17 00:00:00 2001 From: jd Date: Sat, 21 Feb 2026 15:25:37 +0000 Subject: [PATCH] Implemented Collections, reworked SecureIds to prevent duplication across records, renamed files to be consistently plural. --- .gitignore | 2 - .idea_old/.gitignore | 10 ++ .idea_old/codeStyles/Project.xml | 48 +++++++++ .idea_old/codeStyles/codeStyleConfig.xml | 5 + .idea_old/vcs.xml | 6 ++ API Tests/BGApp/Collections/Create.yml | 21 ++++ API Tests/BGApp/Collections/Delete.yml | 19 ++++ API Tests/BGApp/Collections/Get.yml | 19 ++++ API Tests/BGApp/Collections/List.yml | 15 +++ API Tests/BGApp/Collections/Update.yml | 27 +++++ API Tests/BGApp/Collections/folder.yml | 7 ++ API Tests/BGApp/opencollection.yml | 2 +- src/endpoints/auth.ts | 9 +- src/endpoints/collections.ts | 56 ++++++++++ src/endpoints/games.ts | 8 +- src/endpoints/{invite.ts => invites.ts} | 6 +- src/endpoints/{player.ts => players.ts} | 11 +- src/endpoints/{user.ts => users.ts} | 14 +-- src/index.ts | 20 ++-- src/orm/claims.ts | 10 +- src/orm/collections.ts | 130 +++++++++++++++++++++++ src/orm/games.ts | 23 ++-- src/orm/invites.ts | 8 +- src/orm/orm.ts | 2 + src/orm/players.ts | 23 ++-- src/orm/user.ts | 32 +++--- src/routes/collections.ts | 17 +++ src/routes/{game.ts => games.ts} | 0 src/routes/{invite.ts => invites.ts} | 2 +- src/routes/{player.ts => players.ts} | 2 +- src/routes/{user.ts => users.ts} | 10 +- src/tests/user.test.ts | 2 +- src/utilities/claimDefinitions.ts | 1 + src/utilities/errors.ts | 5 + src/utilities/guard.ts | 3 - src/utilities/requestModels.ts | 48 +-------- src/utilities/secureIds.ts | 124 +++++++++++++++++++++ 37 files changed, 608 insertions(+), 139 deletions(-) create mode 100644 .idea_old/.gitignore create mode 100644 .idea_old/codeStyles/Project.xml create mode 100644 .idea_old/codeStyles/codeStyleConfig.xml create mode 100644 .idea_old/vcs.xml create mode 100644 API Tests/BGApp/Collections/Create.yml create mode 100644 API Tests/BGApp/Collections/Delete.yml create mode 100644 API Tests/BGApp/Collections/Get.yml create mode 100644 API Tests/BGApp/Collections/List.yml create mode 100644 API Tests/BGApp/Collections/Update.yml create mode 100644 API Tests/BGApp/Collections/folder.yml create mode 100644 src/endpoints/collections.ts rename src/endpoints/{invite.ts => invites.ts} (83%) rename src/endpoints/{player.ts => players.ts} (78%) rename src/endpoints/{user.ts => users.ts} (68%) create mode 100644 src/orm/collections.ts create mode 100644 src/routes/collections.ts rename src/routes/{game.ts => games.ts} (100%) rename src/routes/{invite.ts => invites.ts} (87%) rename src/routes/{player.ts => players.ts} (93%) rename src/routes/{user.ts => users.ts} (62%) create mode 100644 src/utilities/secureIds.ts diff --git a/.gitignore b/.gitignore index e96b170..52ac7f9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,4 @@ package-lock.json .env.test .env* .DS_Store -Thumbs.db -.DS_Store Thumbs.db \ No newline at end of file diff --git a/.idea_old/.gitignore b/.idea_old/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea_old/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea_old/codeStyles/Project.xml b/.idea_old/codeStyles/Project.xml new file mode 100644 index 0000000..4c8ee25 --- /dev/null +++ b/.idea_old/codeStyles/Project.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea_old/codeStyles/codeStyleConfig.xml b/.idea_old/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea_old/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea_old/vcs.xml b/.idea_old/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea_old/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/API Tests/BGApp/Collections/Create.yml b/API Tests/BGApp/Collections/Create.yml new file mode 100644 index 0000000..4b6ad67 --- /dev/null +++ b/API Tests/BGApp/Collections/Create.yml @@ -0,0 +1,21 @@ +info: + name: Create + type: http + seq: 1 + +http: + method: POST + url: http://localhost:3000/api/collection + body: + type: json + data: |- + { + "name": "Invited player2" + } + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/API Tests/BGApp/Collections/Delete.yml b/API Tests/BGApp/Collections/Delete.yml new file mode 100644 index 0000000..0dac8f7 --- /dev/null +++ b/API Tests/BGApp/Collections/Delete.yml @@ -0,0 +1,19 @@ +info: + name: Delete + type: http + seq: 4 + +http: + method: DELETE + url: http://localhost:3000/api/collection/:id + params: + - name: id + value: bmOe + type: path + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/API Tests/BGApp/Collections/Get.yml b/API Tests/BGApp/Collections/Get.yml new file mode 100644 index 0000000..bacde05 --- /dev/null +++ b/API Tests/BGApp/Collections/Get.yml @@ -0,0 +1,19 @@ +info: + name: Get + type: http + seq: 2 + +http: + method: GET + url: http://localhost:3000/api/collection/:id + params: + - name: id + value: ejRe + type: path + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/API Tests/BGApp/Collections/List.yml b/API Tests/BGApp/Collections/List.yml new file mode 100644 index 0000000..554f35c --- /dev/null +++ b/API Tests/BGApp/Collections/List.yml @@ -0,0 +1,15 @@ +info: + name: List + type: http + seq: 5 + +http: + method: GET + url: http://localhost:3000/api/collection/list + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/API Tests/BGApp/Collections/Update.yml b/API Tests/BGApp/Collections/Update.yml new file mode 100644 index 0000000..dbb4fe0 --- /dev/null +++ b/API Tests/BGApp/Collections/Update.yml @@ -0,0 +1,27 @@ +info: + name: Update + type: http + seq: 3 + +http: + method: PATCH + url: http://localhost:3000/api/collection/:id + params: + - name: id + value: bmOe + type: path + body: + type: json + data: |- + { + "name": "Test Player", + "isRatingLocked": true, + "canBeMultiple": false + } + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/API Tests/BGApp/Collections/folder.yml b/API Tests/BGApp/Collections/folder.yml new file mode 100644 index 0000000..de23efa --- /dev/null +++ b/API Tests/BGApp/Collections/folder.yml @@ -0,0 +1,7 @@ +info: + name: Collections + type: folder + seq: 6 + +request: + auth: inherit diff --git a/API Tests/BGApp/opencollection.yml b/API Tests/BGApp/opencollection.yml index ea6e588..7b744d0 100644 --- a/API Tests/BGApp/opencollection.yml +++ b/API Tests/BGApp/opencollection.yml @@ -17,7 +17,7 @@ config: request: auth: type: bearer - token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJlalJlIiwiY2xhaW1zIjpbIkFETUlOIiwiVVNFUlNfQ1JFQVRFIiwiVVNFUlNfU0VMRl9SRUFEIiwiVVNFUlNfU0VMRl9VUERBVEUiLCJVU0VSU19TRUxGX0RFTEVURSIsIlVTRVJTX09USEVSX1JFQUQiLCJVU0VSU19PVEhFUl9VUERBVEUiXSwiaWF0IjoxNzcxNjE4NTQzLCJleHAiOjE4MDMxNTQ1NDN9.R-3Qb5CEcLJBSt7DnsO9b0IGRVYDIZuFfH1m9TikVXU + token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJNUFJLTFIiLCJjbGFpbXMiOlsiQURNSU4iLCJVU0VSU19DUkVBVEUiLCJVU0VSU19TRUxGX1JFQUQiLCJVU0VSU19TRUxGX1VQREFURSIsIlVTRVJTX1NFTEZfREVMRVRFIiwiVVNFUlNfT1RIRVJfUkVBRCIsIlVTRVJTX09USEVSX1VQREFURSJdLCJpYXQiOjE3NzE2ODcxNzAsImV4cCI6MTgwMzIyMzE3MH0.inf1q3LTMuTkzLI-lEezYduPCpidJDaqsWZNNIY_doE actions: - type: set-variable phase: after-response diff --git a/src/endpoints/auth.ts b/src/endpoints/auth.ts index 73e67f6..49dab6e 100644 --- a/src/endpoints/auth.ts +++ b/src/endpoints/auth.ts @@ -3,12 +3,13 @@ import jwt from 'jsonwebtoken'; import { UnwrappedRequest } from '../utilities/guard'; import { ErrorResponse, OkResponse, UnauthorizedResponse } from '../utilities/responseHelper'; import { Claims } from '../orm/claims'; -import { ChangePasswordRequest, LoginRequest, SecureId } from '../utilities/requestModels'; +import { ChangePasswordRequest, LoginRequest } from '../utilities/requestModels'; +import { UserId } from '../utilities/secureIds'; async function login(request: UnwrappedRequest): Promise { try { const verify: { - userId: SecureId; + userId: UserId; refreshCount: string; } | null = await orm.users.verifyCredentials(request.body.email, request.body.password); if (!verify) { @@ -53,7 +54,7 @@ async function token(request: UnwrappedRequest): Promise { 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))) { + if (!(await orm.users.verifyRefreshCount(UserId.fromID(refreshToken.u), refreshToken.r))) { const response = new UnauthorizedResponse('Invalid refresh token'); response.headers.set('Clear-Site-Data', '"cookies","cache","storage","executionContexts"'); return response; @@ -84,7 +85,7 @@ async function changePassword(request: UnwrappedRequest): try { return new OkResponse( await orm.users.changePassword( - SecureId.fromHash(request.params.id), + UserId.fromHash(request.params.id), request.body.oldPassword, request.body.newPassword, request.claims, diff --git a/src/endpoints/collections.ts b/src/endpoints/collections.ts new file mode 100644 index 0000000..12c5bf5 --- /dev/null +++ b/src/endpoints/collections.ts @@ -0,0 +1,56 @@ +import { orm } from '../orm/orm'; +import { UnwrappedRequest } from '../utilities/guard'; +import { CreatedResponse, ErrorResponse, OkResponse } from '../utilities/responseHelper'; +import { CreateCollectionRequest, UpdateCollectionRequest } from '../utilities/requestModels'; +import { CollectionId } from '../utilities/secureIds'; + +async function create(request: UnwrappedRequest): Promise { + try { + const newPlayer = await orm.collections.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.collections.get(CollectionId.fromHash(request.params.id), request.claims)); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +async function list(request: UnwrappedRequest): Promise { + try { + return new OkResponse(await orm.collections.list(request.claims)); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +async function update(request: UnwrappedRequest): Promise { + try { + return new OkResponse( + await orm.collections.update(CollectionId.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.collections.drop(CollectionId.fromHash(request.params.id), request.claims)); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +export default { + create, + get, + list, + update, + drop, +}; diff --git a/src/endpoints/games.ts b/src/endpoints/games.ts index 68d0f16..a8bd145 100644 --- a/src/endpoints/games.ts +++ b/src/endpoints/games.ts @@ -3,9 +3,9 @@ import { UnwrappedRequest } from '../utilities/guard'; import { CreatedResponse, ErrorResponse, OkResponse } from '../utilities/responseHelper'; import { CreateGameRequest, - SecureId, UpdateGameRequest, } from '../utilities/requestModels'; +import { GameId } from '../utilities/secureIds'; async function create(request: UnwrappedRequest): Promise { try { @@ -25,7 +25,7 @@ async function create(request: UnwrappedRequest): Promise { try { - return new OkResponse(await orm.games.get(SecureId.fromHash(request.params.id))); + return new OkResponse(await orm.games.get(GameId.fromHash(request.params.id))); } catch (error: any) { return new ErrorResponse(error as Error); } @@ -35,7 +35,7 @@ async function update(request: UnwrappedRequest): Promise): Promise { try { - return new OkResponse(await orm.games.drop(SecureId.fromHash(request.params.id))); + return new OkResponse(await orm.games.drop(GameId.fromHash(request.params.id))); } catch (error: any) { return new ErrorResponse(error as Error); } diff --git a/src/endpoints/invite.ts b/src/endpoints/invites.ts similarity index 83% rename from src/endpoints/invite.ts rename to src/endpoints/invites.ts index 824bec8..feadfae 100644 --- a/src/endpoints/invite.ts +++ b/src/endpoints/invites.ts @@ -4,16 +4,16 @@ import { CreatedResponse, ErrorResponse } from '../utilities/responseHelper'; import { AcceptInviteRequest, InviteUserRequest, - SecureId, } from '../utilities/requestModels'; +import { PlayerId, UserId } from '../utilities/secureIds'; async function create(request: UnwrappedRequest): Promise { try { const newUser = await orm.invites.create( { ...request.body, - playerId: SecureId.fromHash(request.body.playerId), - invitedByUserId: request.claims.userId as SecureId, + playerId: PlayerId.fromHash(request.body.playerId), + invitedByUserId: request.claims.userId as UserId, }, ); return new CreatedResponse(newUser); diff --git a/src/endpoints/player.ts b/src/endpoints/players.ts similarity index 78% rename from src/endpoints/player.ts rename to src/endpoints/players.ts index d97fc87..5551291 100644 --- a/src/endpoints/player.ts +++ b/src/endpoints/players.ts @@ -1,11 +1,12 @@ import { orm } from '../orm/orm'; import { UnwrappedRequest } from '../utilities/guard'; import { CreatedResponse, ErrorResponse, OkResponse } from '../utilities/responseHelper'; -import { CreatePlayerRequest, SecureId, UpdatePlayerRequest } from '../utilities/requestModels'; +import { CreatePlayerRequest, UpdatePlayerRequest } from '../utilities/requestModels'; +import { PlayerId } from '../utilities/secureIds'; async function create(request: UnwrappedRequest): Promise { try { - const newPlayer = await orm.players.create(request.body, request.claims); + const newPlayer = await orm.players.create(request.body); return new CreatedResponse(newPlayer); } catch (error: any) { return new ErrorResponse(error as Error); @@ -14,7 +15,7 @@ async function create(request: UnwrappedRequest): Promise { try { - return new OkResponse(await orm.players.get(SecureId.fromHash(request.params.id), request.claims)); + return new OkResponse(await orm.players.get(PlayerId.fromHash(request.params.id), request.claims)); } catch (error: any) { return new ErrorResponse(error as Error); } @@ -31,7 +32,7 @@ async function list(request: UnwrappedRequest): Promise { async function update(request: UnwrappedRequest): Promise { try { return new OkResponse( - await orm.players.update(SecureId.fromHash(request.params.id), request.body, request.claims), + await orm.players.update(PlayerId.fromHash(request.params.id), request.body, request.claims), ); } catch (error: any) { return new ErrorResponse(error as Error); @@ -40,7 +41,7 @@ async function update(request: UnwrappedRequest): Promise { try { - return new OkResponse(await orm.players.drop(SecureId.fromHash(request.params.id), request.claims)); + return new OkResponse(await orm.players.drop(PlayerId.fromHash(request.params.id), request.claims)); } catch (error: any) { return new ErrorResponse(error as Error); } diff --git a/src/endpoints/user.ts b/src/endpoints/users.ts similarity index 68% rename from src/endpoints/user.ts rename to src/endpoints/users.ts index bc355b4..55a2ae8 100644 --- a/src/endpoints/user.ts +++ b/src/endpoints/users.ts @@ -1,16 +1,16 @@ import { orm } from '../orm/orm'; import { UnwrappedRequest } from '../utilities/guard'; import { CreatedResponse, ErrorResponse, OkResponse } from '../utilities/responseHelper'; -import { CreateUserRequest, SecureId, UpdateUserRequest } from '../utilities/requestModels'; +import { CreateUserRequest, UpdateUserRequest } from '../utilities/requestModels'; +import { PlayerId, UserId } from '../utilities/secureIds'; async function create(request: UnwrappedRequest): Promise { try { const newUser = await orm.users.create( { ...request.body, - playerId: SecureId.fromHash(request.body.playerId), - }, - request.claims, + playerId: PlayerId.fromHash(request.body.playerId), + } ); return new CreatedResponse(newUser); } catch (error: any) { @@ -20,7 +20,7 @@ async function create(request: UnwrappedRequest): Promise { try { - return new OkResponse(await orm.users.get(SecureId.fromHash(request.params.id), request.claims)); + return new OkResponse(await orm.users.get(UserId.fromHash(request.params.id), request.claims)); } catch (error: any) { return new ErrorResponse(error as Error); } @@ -29,7 +29,7 @@ async function get(request: UnwrappedRequest): Promise { async function update(request: UnwrappedRequest): Promise { try { return new OkResponse( - await orm.users.update(SecureId.fromHash(request.params.id), request.body, request.claims), + await orm.users.update(UserId.fromHash(request.params.id), request.body, request.claims), ); } catch (error: any) { return new ErrorResponse(error as Error); @@ -38,7 +38,7 @@ async function update(request: UnwrappedRequest): Promise { try { - return new OkResponse(await orm.users.drop(SecureId.fromHash(request.params.id), request.claims)); + return new OkResponse(await orm.users.drop(UserId.fromHash(request.params.id), request.claims)); } catch (error: any) { return new ErrorResponse(error as Error); } diff --git a/src/index.ts b/src/index.ts index ec78d99..1733b1a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,19 @@ -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'; -import invite from './routes/invite'; +import auth from './routes/auth'; +import users from './routes/users'; +import players from './routes/players'; +import games from './routes/games'; +import invites from './routes/invites'; +import collections from './routes/collections'; const server = Bun.serve({ routes: { ...auth, - ...user, - ...player, - ...game, - ...invite, + ...users, + ...players, + ...games, + ...invites, + ...collections, '/test': { GET: () => { return new OkResponse(); diff --git a/src/orm/claims.ts b/src/orm/claims.ts index c732823..cb788d9 100644 --- a/src/orm/claims.ts +++ b/src/orm/claims.ts @@ -1,14 +1,14 @@ import { sql } from 'bun'; import { ClaimDefinition } from '../utilities/claimDefinitions'; -import { SecureId } from '../utilities/requestModels'; +import { SecureId, UserId } from '../utilities/secureIds'; export class Claims extends ClaimDefinition { - userId?: SecureId; + userId?: UserId; claims: string[] = []; - constructor(raw?:{userId?:string, claims?: string[]}) { + constructor(raw?: { userId?: string; claims?: string[] }) { super(); - this.userId = raw?.userId ? SecureId.fromHash(raw.userId) : undefined; + this.userId = raw?.userId ? UserId.fromHash(raw.userId) : undefined; this.claims = raw?.claims ?? []; } @@ -24,7 +24,7 @@ export class ClaimsOrm { JOIN claims as c on uc.claim_id = c.id where uc.user_id = ${userId};`; const claims = new Claims(); - claims.userId = SecureId.fromID(userId); + claims.userId = UserId.fromID(userId); claims.claims = dbResults.map((x) => x.name); return claims; } diff --git a/src/orm/collections.ts b/src/orm/collections.ts new file mode 100644 index 0000000..0863c77 --- /dev/null +++ b/src/orm/collections.ts @@ -0,0 +1,130 @@ +import { Claims } from './claims'; +import { sql } from 'bun'; +import { first } from 'lodash'; +import { NotFoundError, UnauthorizedError } from '../utilities/errors'; +import { UpdateCollectionRequest } from '../utilities/requestModels'; +import { Game } from './games'; +import { CollectionId, GameId } from '../utilities/secureIds'; + +export class Collection { + id: CollectionId; + name: string; + games: Game[]; + + constructor(input: { id: CollectionId; name: string; games?: Game[] }) { + this.id = input.id; + this.name = input?.name; + this.games = input.games ?? []; + } +} + +export class CollectionsOrm { + async create(model: { name: string }, claims: Claims): Promise { + await sql`INSERT INTO collections (name, user_id) + VALUES (${model.name}, ${claims?.userId?.raw})`; + const newPCollectionId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string; + + return await this.get(CollectionId.fromID(newPCollectionId)); + } + + async get(id: CollectionId, claims?: Claims): Promise { + const dbResult: any = await sql`SELECT + c.id AS collection_id, + c.name AS collection_name, + c.user_id AS user_id, + g.id AS game_id, + g.name AS game_name + FROM collections c + LEFT JOIN collection_games cg ON cg.collection_id = c.id + LEFT JOIN games g ON g.id = cg.game_id + WHERE c.id = ${id.raw}`; + + if (!(Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.COLLECTIONS.UNOWNED.READ, claims))) { + throw new UnauthorizedError(); + } else if ( + Claims.test(Claims.COLLECTIONS.OWNED.READ, claims) && + claims?.userId && + dbResult?.[0]?.user_id !== claims.userId.raw + ) { + throw new UnauthorizedError(); + } + + if (!dbResult?.length) { + throw new NotFoundError('No matching player exists'); + } + + return new Collection({ + id: CollectionId.fromID(dbResult[0].collection_id), + name: dbResult[0].collection_name, + games: dbResult + .filter((x: { game_id: string; game_name: string }) => x.game_id) + .map( + (x: { game_id: string; game_name: string }) => + new Game({ + id: GameId.fromID(x.game_id), + name: x.game_name, + }), + ), + }); + } + + async list(claims?: Claims): Promise { + if (!claims || Claims.test(Claims.ADMIN, claims)) { + return (await sql`SELECT * FROM collections`).map( + (x: { id: string; name: string }) => + new Collection({ + id: CollectionId.fromID(x.id), + name: x.name, + }), + ); + } + + if (!Claims.test(Claims.COLLECTIONS.OWNED.LIST, claims)) { + throw new UnauthorizedError(); + } + + return (await sql`SELECT * FROM collections WHERE user_id=${claims.userId?.raw}`).map( + (x: { id: string; name: string }) => + new Collection({ + id: CollectionId.fromID(x.id), + name: x.name, + }), + ); + } + + async update(id: CollectionId, patch: UpdateCollectionRequest, claims?: Claims): Promise { + const collection = await this.get(id); + + if (!(Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.COLLECTIONS.UNOWNED.UPDATE, claims))) { + throw new UnauthorizedError(); + } else if ( + Claims.test(Claims.COLLECTIONS.OWNED.UPDATE, claims) && + claims?.userId && + collection.id !== claims.userId + ) { + throw new UnauthorizedError(); + } + collection.name = patch.name ?? collection.name; + + await sql`UPDATE collections SET name=${collection.name} WHERE id=${id.raw}`; + + return await this.get(id); + } + + async drop(id: CollectionId, claims?: Claims): Promise { + const collection = await this.get(id); + if (!(Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.COLLECTIONS.UNOWNED.DELETE, claims))) { + throw new UnauthorizedError(); + } else if ( + Claims.test(Claims.COLLECTIONS.OWNED.DELETE, claims) && + claims?.userId && + collection.id !== claims.userId + ) { + throw new UnauthorizedError(); + } + + await sql`DELETE FROM collections WHERE id=${id.raw}`; + + return; + } +} diff --git a/src/orm/games.ts b/src/orm/games.ts index 930d50f..baf1dd1 100644 --- a/src/orm/games.ts +++ b/src/orm/games.ts @@ -1,17 +1,18 @@ import { Claims } from './claims'; import { sql } from 'bun'; import { first } from 'lodash'; -import { NotFoundError, UnauthorizedError } from '../utilities/errors'; -import { CreateGameRequest, SecureId, UpdateGameRequest } from '../utilities/requestModels'; +import { NotFoundError } from '../utilities/errors'; +import { CreateGameRequest, UpdateGameRequest } from '../utilities/requestModels'; import { memo } from '../utilities/helpers'; +import { GameId } from '../utilities/secureIds'; export class Game { - id: SecureId; + id: GameId; name: string; imagePath?: string; bggId?: string; - constructor(input: { id: SecureId; name: string; imagePath?: string; bggId?: string }) { + constructor(input: { id: GameId; name: string; imagePath?: string; bggId?: string }) { this.id = input.id; this.name = input?.name; this.imagePath = input?.imagePath; @@ -25,10 +26,10 @@ export class GamesOrm { 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; - return await this.get(SecureId.fromID(newGameId)); + return await this.get(GameId.fromID(newGameId)); } - async get(id: SecureId): Promise { + async get(id: GameId): Promise { const dbResult: any = first( await sql`SELECT * FROM games @@ -41,14 +42,14 @@ export class GamesOrm { } return new Game({ - id: SecureId.fromID(dbResult.id), + id: GameId.fromID(dbResult.id), name: dbResult.name, bggId: dbResult.bgg_id, imagePath: dbResult.image_path, }); } - async update(id: SecureId, patch: UpdateGameRequest, claims?: Claims): Promise { + async update(id: GameId, patch: UpdateGameRequest, claims?: Claims): Promise { const gameToUpdate = await this.get(id); gameToUpdate.name = patch.name ?? gameToUpdate.name; gameToUpdate.bggId = patch.bggId ?? gameToUpdate.bggId; @@ -66,7 +67,7 @@ export class GamesOrm { return await this.get(id); } - async drop(id: SecureId): Promise { + async drop(id: GameId): Promise { // Ensure player exists before attempting to delete await this.get(id); await sql.transaction(async (tx) => { @@ -87,7 +88,7 @@ export class GamesOrm { return; } - query: (query: string) => Promise = memo<(query: string) => Promise,Game[]>(this.#query); + query: (query: string) => Promise = memo<(query: string) => Promise, Game[]>(this.#query); async #query(query: string): Promise { const dbResult: any = await sql` SELECT id, name @@ -103,7 +104,7 @@ export class GamesOrm { return dbResult.map( (x: { id: string; name: string }) => new Game({ - id: SecureId.fromID(x.id), + id: GameId.fromID(x.id), name: x.name, }), ); diff --git a/src/orm/invites.ts b/src/orm/invites.ts index eb5debd..7c0160e 100644 --- a/src/orm/invites.ts +++ b/src/orm/invites.ts @@ -1,7 +1,7 @@ import { sql } from 'bun'; import { first } from 'lodash'; import { BadRequestError, InternalServerError, NotFoundError, UnauthorizedError } from '../utilities/errors'; -import { SecureId } from '../utilities/requestModels'; +import { PlayerId, UserId } from '../utilities/secureIds'; import { createRandomString } from '../utilities/helpers'; import { Resend } from 'resend'; import { orm } from './orm'; @@ -17,8 +17,8 @@ export class InvitesOrm { invitedByUserId, }: { email: string; - playerId: SecureId; - invitedByUserId: SecureId; + playerId: PlayerId; + invitedByUserId: UserId; }, claims?: Claims, ): Promise { @@ -120,7 +120,7 @@ export class InvitesOrm { const createdUser = await orm.users.create({ email: invite.email, - playerId: SecureId.fromID(invite.player_id), + playerId: PlayerId.fromID(invite.player_id), password, }); diff --git a/src/orm/orm.ts b/src/orm/orm.ts index 9bcd7ac..1f154c7 100644 --- a/src/orm/orm.ts +++ b/src/orm/orm.ts @@ -3,6 +3,7 @@ import { UsersOrm } from './user'; import { PlayersOrm } from './players'; import { GamesOrm } from './games'; import { InvitesOrm } from './invites'; +import { CollectionsOrm } from './collections'; class Orm { readonly claims: ClaimsOrm = new ClaimsOrm(); @@ -10,6 +11,7 @@ class Orm { readonly players: PlayersOrm = new PlayersOrm(); readonly games: GamesOrm = new GamesOrm(); readonly invites: InvitesOrm = new InvitesOrm(); + readonly collections: CollectionsOrm = new CollectionsOrm(); } export const orm = new Orm(); diff --git a/src/orm/players.ts b/src/orm/players.ts index 7e366a0..59e0a38 100644 --- a/src/orm/players.ts +++ b/src/orm/players.ts @@ -3,17 +3,18 @@ import { sql } from 'bun'; import { first } from 'lodash'; import { NotFoundError, UnauthorizedError } from '../utilities/errors'; import { orm } from './orm'; -import { SecureId, UpdatePlayerRequest } from '../utilities/requestModels'; +import { UpdatePlayerRequest } from '../utilities/requestModels'; +import { PlayerId } from '../utilities/secureIds'; export class Player { - id: SecureId; + id: PlayerId; name: string; elo: number; isRatingLocked: boolean; canBeMultiple: boolean; constructor(input: { - id: SecureId; + id: PlayerId; name: string; elo?: number; isRatingLocked?: boolean; @@ -28,15 +29,15 @@ export class Player { } export class PlayersOrm { - async create(model: { name: string }, claims?: Claims): Promise { + async create(model: { name: string }): Promise { await sql`INSERT INTO players (name) VALUES (${model.name})`; const newPlayerId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string; - return await this.get(SecureId.fromID(newPlayerId)); + return await this.get(PlayerId.fromID(newPlayerId)); } - async get(id: SecureId, claims?: Claims): Promise { + async get(id: PlayerId, 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) { @@ -57,7 +58,7 @@ export class PlayersOrm { } return new Player({ - id: SecureId.fromID(dbResult.id), + id: PlayerId.fromID(dbResult.id), name: dbResult.name, elo: parseInt(dbResult.elo), isRatingLocked: dbResult.is_rating_locked, @@ -70,7 +71,7 @@ export class PlayersOrm { return (await sql`SELECT * FROM players`).map( (x: { id: string; name: string; elo: string; is_rating_locked: boolean; can_be_multiple: boolean }) => new Player({ - id: SecureId.fromID(x.id), + id: PlayerId.fromID(x.id), name: x.name, elo: parseInt(x.elo), isRatingLocked: x.is_rating_locked, @@ -98,7 +99,7 @@ export class PlayersOrm { ).map( (x: { id: string; name: string; elo: string; is_rating_locked: boolean; can_be_multiple: boolean }) => new Player({ - id: SecureId.fromID(x.id), + id: PlayerId.fromID(x.id), name: x.name, elo: parseInt(x.elo), isRatingLocked: x.is_rating_locked, @@ -107,7 +108,7 @@ export class PlayersOrm { ); } - async update(id: SecureId, patch: UpdatePlayerRequest, claims?: Claims): Promise { + async update(id: PlayerId, patch: UpdatePlayerRequest, 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) { @@ -131,7 +132,7 @@ export class PlayersOrm { return await this.get(id); } - async drop(id: SecureId, claims?: Claims): Promise { + async drop(id: PlayerId, 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) { diff --git a/src/orm/user.ts b/src/orm/user.ts index 28a70de..7e388cd 100644 --- a/src/orm/user.ts +++ b/src/orm/user.ts @@ -3,17 +3,18 @@ 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 { UpdateUserRequest } from '../utilities/requestModels'; import { orm } from './orm'; +import { PlayerId, UserId } from '../utilities/secureIds'; export class User { - id: SecureId; - playerId: SecureId; + id: UserId; + playerId: PlayerId; email: string; isAdmin: boolean; isActive: boolean; - constructor(id: SecureId, playerId: SecureId, email: string, isAdmin: boolean = false, isActive: boolean = true) { + constructor(id: UserId, playerId: PlayerId, email: string, isAdmin: boolean = false, isActive: boolean = true) { this.id = id; this.playerId = playerId; this.email = email; @@ -24,8 +25,7 @@ export class User { export class UsersOrm { async create( - { email, password, playerId }: { email: string; password: string; playerId: SecureId }, - claims?: Claims, + { email, password, playerId }: { email: string; password: string; playerId: PlayerId }, ): Promise { const existingUser: any = first( await sql`SELECT id @@ -41,7 +41,7 @@ export class UsersOrm { const passwordHash = await argon2.hash(password); await sql`INSERT INTO users (email, pass_hash, player_id) VALUES (${email}, ${passwordHash}, ${playerId.raw})`; - const newUserId: SecureId = SecureId.fromID((first(await sql`SELECT lastval();`) as any)?.lastval as string); + const newUserId: UserId = UserId.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) @@ -52,7 +52,7 @@ export class UsersOrm { return await this.get(newUserId); } - async get(id: SecureId, claims?: Claims): Promise { + async get(id: UserId, claims?: Claims): Promise { if ( !( Claims.test(Claims.ADMIN, claims) || @@ -76,14 +76,14 @@ export class UsersOrm { } return new User( - SecureId.fromID(dbResult.id), - SecureId.fromID(dbResult.player_id), + UserId.fromID(dbResult.id), + PlayerId.fromID(dbResult.player_id), dbResult.email, dbResult.is_admin, ); } - async update(id: SecureId, patch: UpdateUserRequest, claims?: Claims): Promise { + async update(id: UserId, patch: UpdateUserRequest, claims?: Claims): Promise { if ( !( Claims.test(Claims.ADMIN, claims) || @@ -108,7 +108,7 @@ export class UsersOrm { return await this.get(id); } - async drop(id: SecureId, claims?: Claims): Promise { + async drop(id: UserId, claims?: Claims): Promise { if ( !( Claims.test(Claims.ADMIN, claims) || @@ -136,7 +136,7 @@ export class UsersOrm { async verifyCredentials( email: string, password: string, - ): Promise<{ userId: SecureId; refreshCount: string } | null> { + ): Promise<{ userId: UserId; refreshCount: string } | null> { const dbResult: any = first( await sql`SELECT * FROM users @@ -153,12 +153,12 @@ export class UsersOrm { } return { - userId: SecureId.fromID(dbResult.id), + userId: UserId.fromID(dbResult.id), refreshCount: dbResult.refresh_count, }; } - async verifyRefreshCount(id: SecureId, refreshCount: string): Promise { + async verifyRefreshCount(id: UserId, refreshCount: string): Promise { const dbResult: any = first( await sql`SELECT * FROM users @@ -169,7 +169,7 @@ export class UsersOrm { } async changePassword( - id: SecureId, + id: UserId, oldPassword: string | null, newPassword: string, claims?: Claims, diff --git a/src/routes/collections.ts b/src/routes/collections.ts new file mode 100644 index 0000000..67c03ff --- /dev/null +++ b/src/routes/collections.ts @@ -0,0 +1,17 @@ +import { guard } from '../utilities/guard'; +import { Claims } from '../orm/claims'; +import collections from '../endpoints/collections'; + +export default { + '/api/collection': { + POST: guard(collections.create, [Claims.ADMIN, Claims.COLLECTIONS.CREATE]), + }, + '/api/collection/:id': { + GET: guard(collections.get, [Claims.ADMIN, Claims.COLLECTIONS.UNOWNED.READ, Claims.COLLECTIONS.OWNED.READ]), + // PATCH: guard(collections.update, [Claims.ADMIN, Claims.PLAYERS.OTHER.UPDATE, Claims.PLAYERS.SELF.UPDATE]), + // DELETE: guard(collections.drop, [Claims.ADMIN, Claims.PLAYERS.OTHER.DELETE, Claims.PLAYERS.SELF.DELETE]), + }, + '/api/collection/list': { + GET: guard(collections.list, [Claims.ADMIN, Claims.COLLECTIONS.OWNED.LIST]), + }, +}; diff --git a/src/routes/game.ts b/src/routes/games.ts similarity index 100% rename from src/routes/game.ts rename to src/routes/games.ts diff --git a/src/routes/invite.ts b/src/routes/invites.ts similarity index 87% rename from src/routes/invite.ts rename to src/routes/invites.ts index c6c3128..e9f17ec 100644 --- a/src/routes/invite.ts +++ b/src/routes/invites.ts @@ -1,6 +1,6 @@ import { guard, unwrapMethod } from '../utilities/guard'; import { Claims } from '../orm/claims'; -import invite from '../endpoints/invite'; +import invite from '../endpoints/invites'; export default { '/api/invite': { diff --git a/src/routes/player.ts b/src/routes/players.ts similarity index 93% rename from src/routes/player.ts rename to src/routes/players.ts index 13a6c31..7b2219d 100644 --- a/src/routes/player.ts +++ b/src/routes/players.ts @@ -1,6 +1,6 @@ import { guard } from '../utilities/guard'; import { Claims } from '../orm/claims'; -import player from '../endpoints/player'; +import player from '../endpoints/players'; export default { '/api/player': { diff --git a/src/routes/user.ts b/src/routes/users.ts similarity index 62% rename from src/routes/user.ts rename to src/routes/users.ts index 12ac068..55325a9 100644 --- a/src/routes/user.ts +++ b/src/routes/users.ts @@ -1,17 +1,11 @@ -import { guard, unwrap, unwrapMethod } from '../utilities/guard'; -import user from '../endpoints/user'; +import { guard } from '../utilities/guard'; +import user from '../endpoints/users'; import { Claims } from '../orm/claims'; export default { '/api/user': { POST: guard(user.create, [Claims.ADMIN, Claims.USERS.CREATE]), }, - '/api/user/invite': { - POST: guard(user.invite, [Claims.ADMIN, Claims.USERS.INVITE]), - }, - '/api/user/invite/accept': { - POST: unwrapMethod(user.acceptInvite), - }, '/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]), diff --git a/src/tests/user.test.ts b/src/tests/user.test.ts index 1cb8424..bcc75d5 100644 --- a/src/tests/user.test.ts +++ b/src/tests/user.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'bun:test'; -import user from '../endpoints/user'; +import user from '../endpoints/users'; import { UnwrappedRequest } from '../utilities/guard'; import { Claims } from '../orm/claims'; import { orm } from '../orm/orm'; diff --git a/src/utilities/claimDefinitions.ts b/src/utilities/claimDefinitions.ts index 41512c2..c78b56f 100644 --- a/src/utilities/claimDefinitions.ts +++ b/src/utilities/claimDefinitions.ts @@ -107,6 +107,7 @@ export class ClaimDefinition { READ: 'COLLECTIONS_OWNED_READ', UPDATE: 'COLLECTIONS_OWNED_UPDATE', DELETE: 'COLLECTIONS_OWNED_DELETE', + LIST: 'COLLECTIONS_OWNED_LIST', COMMENTS: { DELETE: 'COLLECTIONS_OWNED_COMMENTS_DELETE', }, diff --git a/src/utilities/errors.ts b/src/utilities/errors.ts index 6ec87fe..0b4982d 100644 --- a/src/utilities/errors.ts +++ b/src/utilities/errors.ts @@ -21,3 +21,8 @@ export class NotFoundError extends Error { super(message); } } +export class NotImplementedError extends Error { + constructor(message?: string | undefined) { + super(message); + } +} diff --git a/src/utilities/guard.ts b/src/utilities/guard.ts index eeac0f3..1a65fe7 100644 --- a/src/utilities/guard.ts +++ b/src/utilities/guard.ts @@ -2,9 +2,6 @@ import { BunRequest as Request } from 'bun'; import jwt, { TokenExpiredError } from 'jsonwebtoken'; import { ErrorResponse, UnauthorizedResponse } from './responseHelper'; import { Claims } from '../orm/claims'; -import HashIds from 'hashids'; - -export const hashIds = new HashIds(process.env.JWT_SECRET, 4); export function guardRedirect( method: (request: UnwrappedRequest) => Promise | Response, diff --git a/src/utilities/requestModels.ts b/src/utilities/requestModels.ts index 4459fc4..324b600 100644 --- a/src/utilities/requestModels.ts +++ b/src/utilities/requestModels.ts @@ -1,5 +1,3 @@ -import { hashIds } from './guard'; - export interface LoginRequest { email: string; password: string; @@ -43,45 +41,9 @@ export interface UpdateGameRequest { 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; - } - - valueOf(): string | undefined { - return this.#secureValue; - } - - public static fromHash(hash: string) { - return new SecureId({ public: hash }); - } - - public static fromID(id: string) { - return new SecureId({ secure: id }); - } +export interface CreateCollectionRequest { + name: string; +} +export interface UpdateCollectionRequest { + name?: string; } diff --git a/src/utilities/secureIds.ts b/src/utilities/secureIds.ts new file mode 100644 index 0000000..5ba74e5 --- /dev/null +++ b/src/utilities/secureIds.ts @@ -0,0 +1,124 @@ +import HashIds from 'hashids'; + +class SecureId { + protected static hashPrefix: string = ''; + protected static get hashScheme(): HashIds { + return new HashIds( + `${this.hashPrefix}_${process.env.HASHID_SALT_BASE}`, + parseInt(process.env.HASHID_LENGTH ?? '6'), + process.env.HASHID_ALPHABET, + ); + } + + #hashedValue?: string; + #secureValue?: string; + #hashScheme: HashIds; + + constructor(id: { public?: string; secure?: string }, hashScheme?: HashIds) { + this.#hashScheme = hashScheme ?? (this.constructor as any).hashScheme; + + if (id.public) { + this.value = id.public; + } else if (id.secure) { + this.raw = id.secure; + } + } + get value(): string | undefined { + return this.#hashedValue; + } + set value(value: string) { + this.#hashedValue = value; + this.#secureValue = this.#hashScheme.decode(value)?.toString(); + } + get raw(): string | undefined { + return this.#secureValue; + } + set raw(value: string) { + this.#hashedValue = this.#hashScheme.encode(value); + this.#secureValue = value; + } + + toJSON(): string | undefined { + return this.#hashedValue; + } + + valueOf(): string | undefined { + return this.#secureValue; + } + + public static fromHash( + hash: string, + type?: { new (id: { public?: string; secure?: string }): T }, + ): SecureId { + const t = type ?? SecureId; + return new t({ public: hash }); + } + + public static fromID( + id: string, + type?: { new (id: { public?: string; secure?: string }): T }, + ): SecureId { + const t = type ?? SecureId; + return new t({ secure: id }); + } +} + +export class UserId extends SecureId { + protected static override hashPrefix: string = 'UserId'; + + public static fromHash(hash: string): UserId { + return super.fromHash(hash, UserId); + } + + public static fromID(id: string): UserId { + return super.fromID(id, UserId); + } +} + +export class PlayerId extends SecureId { + protected static override hashPrefix: string = 'PlayerId'; + + public static fromHash(hash: string): PlayerId { + return super.fromHash(hash, PlayerId); + } + + public static fromID(id: string): PlayerId { + return super.fromID(id, PlayerId); + } +} + +export class InviteId extends SecureId { + protected static override hashPrefix: string = 'InviteId'; + + public static fromHash(hash: string): InviteId { + return super.fromHash(hash, InviteId); + } + + public static fromID(id: string): InviteId { + return super.fromID(id, InviteId); + } +} + +export class GameId extends SecureId { + protected static override hashPrefix: string = 'GameId'; + + public static fromHash(hash: string): GameId { + return super.fromHash(hash, GameId); + } + + public static fromID(id: string): GameId { + return super.fromID(id, GameId); + } +} + +export class CollectionId extends SecureId { + protected static override hashPrefix: string = 'CollectionId'; + + public static fromHash(hash: string): CollectionId { + return super.fromHash(hash, CollectionId); + } + + public static fromID(id: string): CollectionId { + return super.fromID(id, CollectionId); + } +}