diff --git a/API Tests/BGApp/Circle/Create.yml b/API Tests/BGApp/Circle/Create.yml new file mode 100644 index 0000000..95ce799 --- /dev/null +++ b/API Tests/BGApp/Circle/Create.yml @@ -0,0 +1,30 @@ +info: + name: Create + type: http + seq: 1 + +http: + method: POST + url: "{{BASE_URL}}/{{SECTOR}}" + body: + type: json + data: |- + { + "name": "{{Name}}", + "isPublic": {{IsPublic}}, + "colour": "#FFFFFF" + } + auth: inherit + +runtime: + variables: + - name: Name + value: Test Private Circle + - name: IsPublic + value: "false" + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/API Tests/BGApp/Circle/Delete.yml b/API Tests/BGApp/Circle/Delete.yml new file mode 100644 index 0000000..ef837d4 --- /dev/null +++ b/API Tests/BGApp/Circle/Delete.yml @@ -0,0 +1,20 @@ +info: + name: Delete + type: http + seq: 4 + +http: + method: DELETE + url: "{{BASE_URL}}/{{SECTOR}}/{{CircleID}}" + auth: inherit + +runtime: + variables: + - name: CircleID + value: 6Z4214 + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/API Tests/BGApp/Circle/Get.yml b/API Tests/BGApp/Circle/Get.yml new file mode 100644 index 0000000..fc0aa00 --- /dev/null +++ b/API Tests/BGApp/Circle/Get.yml @@ -0,0 +1,20 @@ +info: + name: Get + type: http + seq: 2 + +http: + method: GET + url: "{{BASE_URL}}/{{SECTOR}}/{{CircleID}}" + auth: inherit + +runtime: + variables: + - name: CircleID + value: P17GOJ + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/API Tests/BGApp/Circle/Search.yml b/API Tests/BGApp/Circle/Search.yml new file mode 100644 index 0000000..2bf06d0 --- /dev/null +++ b/API Tests/BGApp/Circle/Search.yml @@ -0,0 +1,24 @@ +info: + name: Search + type: http + seq: 5 + +http: + method: GET + url: "{{BASE_URL}}/{{SECTOR}}/search/{{Query}}/{{PageSize}}/{{Page}}" + auth: inherit + +runtime: + variables: + - name: Query + value: test + - name: PageSize + value: "5" + - name: Page + value: "1" + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/API Tests/BGApp/Circle/Update.yml b/API Tests/BGApp/Circle/Update.yml new file mode 100644 index 0000000..a427564 --- /dev/null +++ b/API Tests/BGApp/Circle/Update.yml @@ -0,0 +1,28 @@ +info: + name: Update + type: http + seq: 3 + +http: + method: PATCH + url: "{{BASE_URL}}/{{SECTOR}}/{{CircleID}}" + body: + type: json + data: |- + { + "name":"{{Name}}" + } + auth: inherit + +runtime: + variables: + - name: CircleID + value: 6Z4214 + - name: Name + value: Test update + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/API Tests/BGApp/Circle/folder.yml b/API Tests/BGApp/Circle/folder.yml new file mode 100644 index 0000000..fca9bb4 --- /dev/null +++ b/API Tests/BGApp/Circle/folder.yml @@ -0,0 +1,10 @@ +info: + name: Circle + type: folder + seq: 1 + +request: + auth: inherit + variables: + - name: SECTOR + value: circles diff --git a/API Tests/BGApp/Game/Update.yml b/API Tests/BGApp/Game/Update.yml index 2f0d62f..80b36eb 100644 --- a/API Tests/BGApp/Game/Update.yml +++ b/API Tests/BGApp/Game/Update.yml @@ -17,9 +17,9 @@ http: runtime: variables: - name: GameID - value: "" + value: DM5GMY - name: Name - value: "" + value: Update test settings: encodeUrl: true diff --git a/API Tests/BGApp/opencollection.yml b/API Tests/BGApp/opencollection.yml index dea3a3b..39338db 100644 --- a/API Tests/BGApp/opencollection.yml +++ b/API Tests/BGApp/opencollection.yml @@ -1,4 +1,3 @@ -#file: noinspection SpellCheckingInspection,SpellCheckingInspection,SpellCheckingInspection,SpellCheckingInspection opencollection: 1.0.0 info: @@ -19,16 +18,6 @@ request: auth: type: bearer token: "{{BEARER_TOKEN}}" - actions: - - type: set-variable - phase: after-response - selector: - expression: ${token} - method: jsonq - variable: - name: Token - scope: runtime - disabled: true bundled: false extensions: bruno: diff --git a/src/endpoints/circles.ts b/src/endpoints/circles.ts new file mode 100644 index 0000000..2eb4f47 --- /dev/null +++ b/src/endpoints/circles.ts @@ -0,0 +1,74 @@ +import { orm } from '../orm/orm'; +import { UnwrappedRequest } from '../utilities/guard'; +import { CreatedResponse, ErrorResponse, OkResponse, PagedResponse } from '../utilities/responseHelper'; +import { CreateCircleRequest, InviteToCircleRequest, UpdateCircleRequest } from '../utilities/requestModels'; +import { CircleId, PlayerId, UserId } from '../utilities/secureIds'; + +async function create(request: UnwrappedRequest): Promise { + try { + return new CreatedResponse(await orm.circles.create(request.body, request.claims)); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +async function invite(request: UnwrappedRequest): Promise { + try { + let relatedEntityId: UserId | PlayerId | string | undefined; + if (request.body.userId) { + relatedEntityId = UserId.fromHash(request.body.userId); + } else if (request.body.playerId) { + relatedEntityId = PlayerId.fromHash(request.body.playerId); + } else { + relatedEntityId = request.body.email; + } + return new CreatedResponse( + await orm.circles.invite(CircleId.fromHash(request.params.id), relatedEntityId, request.claims), + ); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +async function get(request: UnwrappedRequest): Promise { + try { + return new OkResponse(await orm.circles.get(CircleId.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.circles.update(CircleId.fromHash(request.params.id), request.body), + ); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +async function drop(request: UnwrappedRequest): Promise { + try { + return new OkResponse(await orm.circles.drop(CircleId.fromHash(request.params.id))); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +async function query(request: UnwrappedRequest): Promise { + try { + return new PagedResponse(request, await orm.circles.query(request.params.query)); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +export default { + create, + get, + update, + drop, + query, + invite, +}; diff --git a/src/index.ts b/src/index.ts index a6e9e06..3f36633 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import collections from './routes/collections'; import { buildRoute } from './utilities/routeBuilder'; import { MatchId } from './utilities/secureIds'; import matches from './routes/matches'; +import circles from './routes/circles'; const server = Bun.serve({ routes: buildRoute({ @@ -19,6 +20,7 @@ const server = Bun.serve({ invites, collections, matches, + circles, }, test: { GET: () => { diff --git a/src/orm/circles.ts b/src/orm/circles.ts new file mode 100644 index 0000000..ee78fbe --- /dev/null +++ b/src/orm/circles.ts @@ -0,0 +1,188 @@ +import { Claims } from './claims'; +import { sql } from 'bun'; +import { first } from 'lodash'; +import { BadRequestError, NotFoundError, UnauthorizedError } from '../utilities/errors'; +import { CreateCircleRequest, UpdateCircleRequest } from '../utilities/requestModels'; +import { memo } from '../utilities/helpers'; +import { CircleId, PlayerId, UserId } from '../utilities/secureIds'; +import { orm } from './orm'; +import { User } from './user'; + +export class Circle { + id: CircleId; + owningUserId: UserId; + name: string; + isPublic: boolean; + imagePath?: string; + colour?: string; + + constructor({ + id, + owningUserId, + name, + isPublic, + imagePath, + colour, + }: { + id: CircleId; + owningUserId: UserId; + name: string; + isPublic: boolean; + imagePath?: string; + colour?: string; + }) { + this.id = id; + this.owningUserId = owningUserId; + this.name = name; + this.isPublic = isPublic; + this.imagePath = imagePath; + this.colour = colour; + } +} + +export class CircleOrm { + async create(model: CreateCircleRequest, claims?: Claims): Promise { + if (model.isPublic && claims && !claims.test(Claims.ADMIN, Claims.CIRCLES.PUBLIC.CREATE)) { + throw new UnauthorizedError(); + } + + await sql`INSERT INTO circles (owning_user_id, name, is_public, colour) + VALUES (${claims?.userId.raw}, + ${model.name}, + ${model.isPublic}, + ${model.colour})`; + const newCircleId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string; + + return await this.get(CircleId.fromID(newCircleId)); + } + + async get(id: CircleId, claims?: Claims): Promise { + const circleResult: any = first( + await sql`SELECT * + FROM circles + WHERE id = ${id.raw} + LIMIT 1`, + ); + + if (!circleResult) { + throw new NotFoundError('No matching game exists'); + } + + let user: User; + if ( + claims && + !claims.test(Claims.ADMIN) && + !(claims.test(Claims.CIRCLES.PUBLIC.READ) && circleResult.is_public) && + !(claims.test(Claims.CIRCLES.OWNED.READ) && circleResult.owning_user_id === claims.userId.raw) && + !( + claims.test(Claims.CIRCLES.PRIVATE.READ_IF_MEMBER) && + (user = await orm.users.get(claims.userId)) && + (await sql`SELECT * FROM player_circles WHERE circle_id = ${id.raw}`).some( + (x: { player_id: string }) => x.player_id === user.playerId.raw, + ) + ) + ) { + throw new UnauthorizedError(); + } + + return new Circle({ + id: CircleId.fromID(circleResult.id), + owningUserId: UserId.fromID(circleResult.owning_user_id), + name: circleResult.name, + isPublic: circleResult.is_public, + imagePath: circleResult.image_path, + colour: circleResult.colour, + }); + } + + async update(id: CircleId, patch: UpdateCircleRequest): Promise { + const recordToUpdate = await this.get(id); + recordToUpdate.name = patch.name ?? recordToUpdate.name; + recordToUpdate.colour = patch.colour ?? recordToUpdate.colour; + recordToUpdate.imagePath = patch.imagePath ?? recordToUpdate.imagePath; + + await sql`UPDATE circles + SET name=${recordToUpdate.name}, + colour=${recordToUpdate.colour}, + image_path=${recordToUpdate.imagePath} + WHERE id = ${id.raw}`; + + return await this.get(id); + } + + async drop(id: CircleId): Promise { + // Ensure record exists before attempting to delete + await this.get(id); + await sql.transaction(async (tx) => { + await tx`DELETE + FROM player_circles + WHERE circle_id = ${id.raw}`; + await tx`DELETE + FROM circle_invites + WHERE circle_id = ${id.raw}`; + await tx`DELETE + FROM circle_comments + WHERE circle_id = ${id.raw}`; + await tx`DELETE + FROM circles + WHERE id = ${id.raw}`; + }); + + return; + } + + async invite(circleId: CircleId, relatedRecord: PlayerId | UserId | string | undefined, claims: Claims): Promise { + if(relatedRecord === undefined) { + throw new BadRequestError(); + } + + const circle = await this.get(circleId, claims); + if ( + claims && + ((circle.isPublic && !claims.test(Claims.CIRCLES.PUBLIC.USERS.INVITE)) || + (!circle.isPublic && + !(claims.test(Claims.CIRCLES.OWNED.USERS.INVITE) && circle.owningUserId === claims.userId))) + ) { + throw new UnauthorizedError(); + } + + let invitedUserId: UserId | undefined; + if (relatedRecord instanceof UserId) { + invitedUserId = relatedRecord; + } else if (relatedRecord instanceof PlayerId) { + invitedUserId = (await orm.users.getByPlayer(relatedRecord))?.id; + } else { + invitedUserId = (await orm.users.getByEmail(relatedRecord))?.id; + } + + if (!invitedUserId) { + throw new BadRequestError(); + } + await sql`INSERT INTO circle_invites(invited_user_id, invited_by_user_id, circle_id) + VALUES (${invitedUserId.raw}, ${claims.userId.raw}, ${circleId.raw})`; + } + + query: (query: string) => Promise = memo<(query: string) => Promise, Circle[]>(this.#query); + async #query(query: string): Promise { + const dbResult: any = await sql` SELECT + id, name, owning_user_id, is_public + FROM (SELECT *, SIMILARITY(${query}, name) as similarity FROM circles WHERE is_public=true) + WHERE similarity > 0 + ORDER BY similarity + LIMIT 5;`; + + if (!dbResult) { + throw new NotFoundError('No matching circles exists'); + } + + return dbResult.map( + (x: { id: string; name: string; owning_user_id: string; is_public: boolean }) => + new Circle({ + id: CircleId.fromID(x.id), + name: x.name, + isPublic: x.is_public, + owningUserId: UserId.fromID(x.owning_user_id), + }), + ); + } +} diff --git a/src/orm/orm.ts b/src/orm/orm.ts index ef55a67..1f7715d 100644 --- a/src/orm/orm.ts +++ b/src/orm/orm.ts @@ -5,6 +5,7 @@ import { GamesOrm } from './games'; import { InvitesOrm } from './invites'; import { CollectionsOrm } from './collections'; import { MatchOrm } from './matches'; +import { CircleOrm } from './circles'; class Orm { readonly claims: ClaimsOrm = new ClaimsOrm(); @@ -14,6 +15,7 @@ class Orm { readonly invites: InvitesOrm = new InvitesOrm(); readonly collections: CollectionsOrm = new CollectionsOrm(); readonly matches: MatchOrm = new MatchOrm(); + readonly circles: CircleOrm = new CircleOrm(); } export const orm = new Orm(); diff --git a/src/orm/user.ts b/src/orm/user.ts index 9527de5..5471868 100644 --- a/src/orm/user.ts +++ b/src/orm/user.ts @@ -89,6 +89,69 @@ export class UsersOrm { ); } + async getByPlayer(id: PlayerId, claims?: Claims): Promise { + + const dbResult: any = first( + await sql`SELECT * + FROM users + WHERE player_id = ${id.raw} + AND is_active = true + LIMIT 1`, + ); + + if (!dbResult) { + throw new NotFoundError('No matching user exists'); + } + + if ( + claims && + !( + claims.test(Claims.ADMIN, Claims.USERS.OTHER.READ) || + (claims.test(Claims.USERS.SELF.READ) && dbResult.id === claims?.userId.raw) + ) + ) { + throw new UnauthorizedError(); + } + + return new User( + UserId.fromID(dbResult.id), + PlayerId.fromID(dbResult.player_id), + dbResult.email, + dbResult.is_admin, + ); + } + + async getByEmail(email:string, claims?: Claims): Promise { + const dbResult: any = first( + await sql`SELECT * + FROM users + WHERE email = ${email} + AND is_active = true + LIMIT 1`, + ); + + if (!dbResult) { + throw new NotFoundError('No matching user exists'); + } + + if ( + claims && + !( + claims.test(Claims.ADMIN, Claims.USERS.OTHER.READ) || + (claims.test(Claims.USERS.SELF.READ) && dbResult.id === claims?.userId.raw) + ) + ) { + throw new UnauthorizedError(); + } + + return new User( + UserId.fromID(dbResult.id), + PlayerId.fromID(dbResult.player_id), + dbResult.email, + dbResult.is_admin, + ); + } + async update(id: UserId, patch: UpdateUserRequest, claims?: Claims): Promise { if ( claims && diff --git a/src/routes/circles.ts b/src/routes/circles.ts new file mode 100644 index 0000000..f66f3ac --- /dev/null +++ b/src/routes/circles.ts @@ -0,0 +1,30 @@ +import { guard } from '../utilities/guard'; +import { Claims } from '../orm/claims'; +import circles from '../endpoints/circles'; + +export default { + 'POST': guard(circles.create, [Claims.ADMIN, Claims.CIRCLES.PUBLIC.CREATE, Claims.CIRCLES.PRIVATE.CREATE]), + ':id': { + GET: guard(circles.get, [ + Claims.ADMIN, + Claims.CIRCLES.PUBLIC.READ, + Claims.CIRCLES.PRIVATE.READ, + Claims.CIRCLES.PRIVATE.READ_IF_MEMBER, + ]), + PATCH: guard(circles.update, [Claims.ADMIN, Claims.CIRCLES.OWNED.UPDATE]), + DELETE: guard(circles.drop, [Claims.ADMIN, Claims.CIRCLES.OWNED.DELETE]), + invite: { + POST: guard(circles.invite, [ + Claims.ADMIN, + Claims.CIRCLES.PUBLIC.USERS.INVITE, + Claims.CIRCLES.OWNED.USERS.INVITE, + ]), + }, + }, + 'search': { + ':query': { + variants: [':pageSize/:page', ':page'], + GET: guard(circles.query, [Claims.ADMIN, Claims.CIRCLES.PUBLIC.READ]), + }, + }, +}; diff --git a/src/utilities/claimDefinitions.ts b/src/utilities/claimDefinitions.ts index 71b17de..5a0fe5a 100644 --- a/src/utilities/claimDefinitions.ts +++ b/src/utilities/claimDefinitions.ts @@ -36,7 +36,8 @@ export class ClaimDefinition { ADD: 'CIRCLES_PUBLIC_COMMENTS_ADD', }, USERS: { - INVITE: 'CIRCLES_OWNED_USER_INVITE', + INVITE: 'CIRCLES_PUBLIC_USER_INVITE', + LIST: 'CIRCLES_PUBLIC_USER_LIST', }, }, PRIVATE: { diff --git a/src/utilities/requestModels.ts b/src/utilities/requestModels.ts index 3d26d35..022746f 100644 --- a/src/utilities/requestModels.ts +++ b/src/utilities/requestModels.ts @@ -54,3 +54,19 @@ export interface CreateMatchRequest { gameId: string; participants: { playerId: string; standing: number }[]; } +export interface CreateCircleRequest { + name: string; + isPublic: boolean; + imagePath?: string; + colour: string; +} +export interface UpdateCircleRequest { + name: string; + imagePath?: string; + colour: string; +} +export interface InviteToCircleRequest { + email?:string; + userId?:string; + playerId?:string; +} \ No newline at end of file diff --git a/src/utilities/responseHelper.ts b/src/utilities/responseHelper.ts index 52f14b7..77071d4 100644 --- a/src/utilities/responseHelper.ts +++ b/src/utilities/responseHelper.ts @@ -1,5 +1,5 @@ import { BadRequestError, NotFoundError, UnauthorizedError } from './errors'; -import { clamp, isObject } from 'lodash'; +import { clamp, isArray, isObject } from 'lodash'; import { UnwrappedRequest } from './guard'; export class ErrorResponse extends Response { @@ -52,7 +52,7 @@ export class OkResponse extends Response { constructor(body?: any) { if (body) { return Response.json( - isObject(body) + isObject(body) && !isArray(body) ? { ...body, } diff --git a/src/utilities/routeBuilder.ts b/src/utilities/routeBuilder.ts index fb994dd..a9c24ff 100644 --- a/src/utilities/routeBuilder.ts +++ b/src/utilities/routeBuilder.ts @@ -28,6 +28,7 @@ export function buildRoute( const endpointDefinition = { POST: route.POST, GET: route.GET, + PATCH: route.PATCH, PUT: route.PUT, DELETE: route.DELETE, }; diff --git a/src/utilities/secureIds.ts b/src/utilities/secureIds.ts index 1faf666..9960ad7 100644 --- a/src/utilities/secureIds.ts +++ b/src/utilities/secureIds.ts @@ -66,83 +66,104 @@ class SecureId { export class UserId extends SecureId { protected static override hashPrefix: string = 'UserId'; + // This method exists to force type errors when using an incorrect ID class. + #uniqueMethodUser(){} + public static fromHash(hash: string): UserId { - return super.fromHash(hash, UserId); + return super.fromHash(hash, UserId) as UserId; } public static fromID(id: string): UserId { - return super.fromID(id, UserId); + return super.fromID(id, UserId) as UserId; } } export class PlayerId extends SecureId { protected static override hashPrefix: string = 'PlayerId'; + // This method exists to force type errors when using an incorrect ID class. + #uniqueMethodPlayer(){} + public static fromHash(hash: string): PlayerId { - return super.fromHash(hash, PlayerId); + return super.fromHash(hash, PlayerId) as PlayerId; } public static fromID(id: string): PlayerId { - return super.fromID(id, PlayerId); + return super.fromID(id, PlayerId) as PlayerId; } } export class InviteId extends SecureId { protected static override hashPrefix: string = 'InviteId'; + // This method exists to force type errors when using an incorrect ID class. + #uniqueMethodInvite(){} + public static fromHash(hash: string): InviteId { - return super.fromHash(hash, InviteId); + return super.fromHash(hash, InviteId) as InviteId; } public static fromID(id: string): InviteId { - return super.fromID(id, InviteId); + return super.fromID(id, InviteId) as InviteId; } } export class GameId extends SecureId { protected static override hashPrefix: string = 'GameId'; + // This method exists to force type errors when using an incorrect ID class. + #uniqueMethodGame(){} + public static fromHash(hash: string): GameId { - return super.fromHash(hash, GameId); + return super.fromHash(hash, GameId) as GameId; } public static fromID(id: string): GameId { - return super.fromID(id, GameId); + return super.fromID(id, GameId) as GameId; } } export class CollectionId extends SecureId { protected static override hashPrefix: string = 'CollectionId'; + // This method exists to force type errors when using an incorrect ID class. + #uniqueMethodCollection(){} + public static fromHash(hash: string): CollectionId { - return super.fromHash(hash, CollectionId); + return super.fromHash(hash, CollectionId) as CollectionId; } public static fromID(id: string): CollectionId { - return super.fromID(id, CollectionId); + return super.fromID(id, CollectionId) as CollectionId; } } export class MatchId extends SecureId { protected static override hashPrefix: string = 'MatchId'; + // This method exists to force type errors when using an incorrect ID class. + #uniqueMethodMatch(){} + public static fromHash(hash: string): MatchId { - return super.fromHash(hash, MatchId); + return super.fromHash(hash, MatchId) as MatchId; } public static fromID(id: string): MatchId { - return super.fromID(id, MatchId); + return super.fromID(id, MatchId) as MatchId; } } export class CircleId extends SecureId { protected static override hashPrefix: string = 'CircleId'; + // This method exists to force type errors when using an incorrect ID class. + #uniqueMethodCircle(){} + public static fromHash(hash: string): CircleId { - return super.fromHash(hash, CircleId); + return super.fromHash(hash, CircleId) as CircleId; } public static fromID(id: string): CircleId { - return super.fromID(id, CircleId); + return super.fromID(id, CircleId) as CircleId; } }