Compare commits

...

15 Commits

92 changed files with 2372 additions and 581 deletions

View File

@@ -1,3 +0,0 @@
DATABASE_URL=
JWT_SECRET_KEY=
RESEND_KEY=

View File

@@ -1,3 +0,0 @@
DATABASE_URL=
JWT_SECRET_KEY=
RESEND_KEY=

5
.gitignore vendored
View File

@@ -3,6 +3,9 @@ node_modules/
bun.lock bun.lock
package-lock.json package-lock.json
.dockerignore .dockerignore
bgapp ./bgapp/
.env.dev .env.dev
.env.test .env.test
.env*
.DS_Store
Thumbs.db

10
.idea_old/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,48 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<HTMLCodeStyleSettings>
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="120" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="SOFT_MARGINS" value="120" />
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="120" />
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="120" />
<indentOptions>
<option name="INDENT_SIZE" value="4" />
<option name="TAB_SIZE" value="4" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea_old/vcs.xml Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -5,7 +5,7 @@ info:
http: http:
method: POST method: POST
url: http://localhost:3000/api/auth/login url: "{{BASE_URL}}/{{SECTOR}}/login"
headers: headers:
- name: Content-Type - name: Content-Type
value: application/json value: application/json
@@ -18,6 +18,16 @@ http:
} }
auth: inherit auth: inherit
runtime:
scripts:
- type: after-response
code: |-
function onResponse(res) {
console.log(res.getHeader('set-cookie'));
bru.setEnvVar("REFRESH_COOKIE", res.getHeader('set-cookie'));
}
onResponse(res);
settings: settings:
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0

View File

@@ -5,12 +5,22 @@ info:
http: http:
method: GET method: GET
url: http://localhost:3000/api/auth/token url: "{{BASE_URL}}/{{SECTOR}}/token"
headers: headers:
- name: Cookie - name: Cookie
value: refresh=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1IjoiMSIsInIiOiIxIiwiaWF0IjoxNzcxNTk3NjQ2LCJleHAiOjE3NzQxODk2NDZ9.07ViS5Nie3Bi2OgnlHyybDNZ9bdXPRRiqO-RFLhjoKo; Path=/; Max-Age=2592000; Secure; HttpOnly; SameSite=Lax value: "{{REFRESH_COOKIE}}"
auth: inherit auth: inherit
runtime:
scripts:
- type: after-response
code: |-
function onResponse(res) {
let data = res.getBody();
bru.setEnvVar("BEARER_TOKEN", data.token);
}
onResponse(res);
settings: settings:
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0

View File

@@ -2,3 +2,8 @@ info:
name: Auth name: Auth
type: folder type: folder
seq: 1 seq: 1
request:
variables:
- name: SECTOR
value: auth

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
info:
name: Circle
type: folder
seq: 1
request:
auth: inherit
variables:
- name: SECTOR
value: circles

View File

@@ -0,0 +1,28 @@
info:
name: Add game
type: http
seq: 6
http:
method: POST
url: "{{BASE_URL}}/{{SECTOR}}/{{CollectionID}}/add"
body:
type: json
data: |-
{
"gameId": "{{GameID}}"
}
auth: inherit
runtime:
variables:
- name: CollectionID
value: ""
- name: GameID
value: ""
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,26 @@
info:
name: Create
type: http
seq: 1
http:
method: POST
url: "{{BASE_URL}}/{{SECTOR}}"
body:
type: json
data: |-
{
"name": "{{Name}}"
}
auth: inherit
runtime:
variables:
- name: Name
value: ""
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,20 @@
info:
name: Delete
type: http
seq: 4
http:
method: DELETE
url: "{{BASE_URL}}/{{SECTOR}}/{{CollectionID}}"
auth: inherit
runtime:
variables:
- name: CollectionID
value: ""
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,20 @@
info:
name: Get
type: http
seq: 2
http:
method: GET
url: "{{BASE_URL}}/{{SECTOR}}/{{CollectionID}}"
auth: inherit
runtime:
variables:
- name: CollectionID
value: ""
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,22 @@
info:
name: List
type: http
seq: 5
http:
method: GET
url: "{{BASE_URL}}/{{SECTOR}}/list/{{PageSize}}/{{Page}}"
auth: inherit
runtime:
variables:
- name: PageSize
value: "5"
- name: Page
value: "1"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,28 @@
info:
name: Remove game
type: http
seq: 7
http:
method: POST
url: "{{BASE_URL}}/{{SECTOR}}/{{CollectionID}}/remove"
body:
type: json
data: |-
{
"gameId": "{{GameID}}"
}
auth: inherit
runtime:
variables:
- name: CollectionID
value: ""
- name: GameID
value: ""
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,28 @@
info:
name: Update
type: http
seq: 3
http:
method: PATCH
url: "{{BASE_URL}}/{{SECTOR}}/{{CollectionID}}"
body:
type: json
data: |-
{
"name": "{{Name}}"
}
auth: inherit
runtime:
variables:
- name: CollectionID
value: ""
- name: Name
value: ""
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,10 @@
info:
name: Collections
type: folder
seq: 1
request:
auth: inherit
variables:
- name: SECTOR
value: collections

View File

@@ -5,15 +5,20 @@ info:
http: http:
method: POST method: POST
url: http://localhost:3000/api/game url: "{{BASE_URL}}/{{SECTOR}}"
body: body:
type: json type: json
data: |- data: |-
{ {
"name": "Test Game3" "name": "{{Name}}"
} }
auth: inherit auth: inherit
runtime:
variables:
- name: Name
value: ""
settings: settings:
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0

View File

@@ -5,13 +5,14 @@ info:
http: http:
method: DELETE method: DELETE
url: http://localhost:3000/api/game/:id url: "{{BASE_URL}}/{{SECTOR}}/{{GameID}}"
params:
- name: id
value: bk5e
type: path
auth: inherit auth: inherit
runtime:
variables:
- name: GameID
value: ""
settings: settings:
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0

View File

@@ -5,13 +5,14 @@ info:
http: http:
method: GET method: GET
url: http://localhost:3000/api/game/:id url: "{{BASE_URL}}/{{SECTOR}}/{{GameID}}"
params:
- name: id
value: bk5e
type: path
auth: inherit auth: inherit
runtime:
variables:
- name: GameID
value: ""
settings: settings:
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0

View File

@@ -5,13 +5,18 @@ info:
http: http:
method: GET method: GET
url: http://localhost:3000/api/game/search/:query url: "{{BASE_URL}}/{{SECTOR}}/search/{{Query}}/{{PageSize}}/{{Page}}"
params:
- name: query
value: game
type: path
auth: inherit auth: inherit
runtime:
variables:
- name: Query
value: test
- name: PageSize
value: "5"
- name: Page
value: "1"
settings: settings:
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0

View File

@@ -5,19 +5,22 @@ info:
http: http:
method: PATCH method: PATCH
url: http://localhost:3000/api/game/:id url: "{{BASE_URL}}/{{SECTOR}}/{{GameID}}"
params:
- name: id
value: el5a
type: path
body: body:
type: json type: json
data: |- data: |-
{ {
"name":"Updated game" "name":"{{Name}}"
} }
auth: inherit auth: inherit
runtime:
variables:
- name: GameID
value: DM5GMY
- name: Name
value: Update test
settings: settings:
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0

View File

@@ -1,7 +1,10 @@
info: info:
name: Game name: Game
type: folder type: folder
seq: 4 seq: 1
request: request:
auth: inherit auth: inherit
variables:
- name: SECTOR
value: games

View File

@@ -5,16 +5,23 @@ info:
http: http:
method: POST method: POST
url: http://localhost:3000/api/invite/accept url: "{{BASE_URL}}/{{SECTOR}}/accept"
body: body:
type: json type: json
data: |- data: |-
{ {
"inviteCode": "3ST6N8", "inviteCode": "{{InviteCode}}",
"password": "test123" "password": "{{Password}}
} }
auth: inherit auth: inherit
runtime:
variables:
- name: InviteCode
value: ""
- name: Password
value: ""
settings: settings:
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0

View File

@@ -5,16 +5,23 @@ info:
http: http:
method: POST method: POST
url: http://localhost:3000/api/invite url: "{{BASE_URL}}/{{SECTOR}}"
body: body:
type: json type: json
data: |- data: |-
{ {
"email": "james+test2@dardry.com", "email": "{{Email}}",
"playerId": "boja" "playerId": "{{PlayerID}}"
} }
auth: inherit auth: inherit
runtime:
variables:
- name: Email
value: ""
- name: PlayerID
value: ""
settings: settings:
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0

View File

@@ -1,7 +1,10 @@
info: info:
name: Invites name: Invites
type: folder type: folder
seq: 5 seq: 1
request: request:
auth: inherit auth: inherit
variables:
- name: SECTOR
value: invites

View File

@@ -0,0 +1,35 @@
info:
name: Create
type: http
seq: 1
http:
method: POST
url: "{{BASE_URL}}/{{SECTOR}}"
body:
type: json
data: |-
{
"gameId": "RM089N",
"participants": [
{"playerId": "Q3M1PR", "standing": 0},
{"playerId": "539DPX", "standing": 1},
{"playerId": "JZ7939", "standing": 2}
]
}
auth: inherit
runtime:
variables:
- name: Email
value: ""
- name: Password
value: ""
- name: PlayerID
value: ""
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,20 @@
info:
name: Delete
type: http
seq: 3
http:
method: DELETE
url: "{{BASE_URL}}/{{SECTOR}}/{{MatchID}}"
auth: inherit
runtime:
variables:
- name: MatchID
value: 846M1L
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,20 @@
info:
name: Get
type: http
seq: 2
http:
method: GET
url: "{{BASE_URL}}/{{SECTOR}}/{{MatchID}}"
auth: inherit
runtime:
variables:
- name: MatchID
value: 848O12
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,23 @@
info:
name: Leave
type: http
seq: 4
http:
method: POST
url: "{{BASE_URL}}/{{SECTOR}}/{{MatchID}}/leave"
body:
type: json
data: ""
auth: inherit
runtime:
variables:
- name: MatchID
value: 848O12
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,10 @@
info:
name: Matches
type: folder
seq: 1
request:
auth: inherit
variables:
- name: SECTOR
value: matches

View File

@@ -5,15 +5,20 @@ info:
http: http:
method: POST method: POST
url: http://localhost:3000/api/player url: "{{BASE_URL}}/{{SECTOR}}"
body: body:
type: json type: json
data: |- data: |-
{ {
"name": "Invited player2" "name": "{{Name}}"
} }
auth: inherit auth: inherit
runtime:
variables:
- name: Name
value: Test
settings: settings:
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0

View File

@@ -5,13 +5,14 @@ info:
http: http:
method: DELETE method: DELETE
url: http://localhost:3000/api/player/:id url: "{{BASE_URL}}/{{SECTOR}}/{{PlayerID}}"
params:
- name: id
value: bmOe
type: path
auth: inherit auth: inherit
runtime:
variables:
- name: PlayerID
value: ""
settings: settings:
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0

View File

@@ -5,13 +5,14 @@ info:
http: http:
method: GET method: GET
url: http://localhost:3000/api/player/:id url: "{{BASE_URL}}/{{SECTOR}}/{{PlayerID}}"
params:
- name: id
value: ejRe
type: path
auth: inherit auth: inherit
runtime:
variables:
- name: PlayerID
value: 539DPX
settings: settings:
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0

View File

@@ -5,9 +5,16 @@ info:
http: http:
method: GET method: GET
url: http://localhost:3000/api/player/list url: "{{BASE_URL}}/{{SECTOR}}/list/{{PageSize}}/{{Page}}"
auth: inherit auth: inherit
runtime:
variables:
- name: PageSize
value: "100"
- name: Page
value: "1"
settings: settings:
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0

View File

@@ -5,21 +5,24 @@ info:
http: http:
method: PATCH method: PATCH
url: http://localhost:3000/api/player/:id url: "{{BASE_URL}}/{{SECTOR}}/{{PlayerID}}"
params:
- name: id
value: bmOe
type: path
body: body:
type: json type: json
data: |- data: |-
{ {
"name": "Test Player", "name": "{{Name}}",
"isRatingLocked": true, "isRatingLocked": false,
"canBeMultiple": false "canBeMultiple": false
} }
auth: inherit auth: inherit
runtime:
variables:
- name: Name
value: Foo
- name: PlayerID
value: ""
settings: settings:
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0

View File

@@ -1,7 +1,10 @@
info: info:
name: Players name: Players
type: folder type: folder
seq: 2 seq: 1
request: request:
auth: inherit auth: inherit
variables:
- name: SECTOR
value: players

View File

@@ -5,17 +5,26 @@ info:
http: http:
method: POST method: POST
url: http://localhost:3000/api/user url: "{{BASE_URL}}/{{SECTOR}}"
body: body:
type: json type: json
data: |- data: |-
{ {
"email": "Test User", "email": "{{Email}}",
"password": "Test123", "password": "{{Password}}",
"playerId": "enRe" "playerId": "{{PlayerID}}"
} }
auth: inherit auth: inherit
runtime:
variables:
- name: Email
value: ""
- name: Password
value: ""
- name: PlayerID
value: ""
settings: settings:
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0

View File

@@ -5,13 +5,14 @@ info:
http: http:
method: DELETE method: DELETE
url: http://localhost:3000/api/user/:id url: "{{BASE_URL}}/{{SECTOR}}/{{UserID}}"
params:
- name: id
value: ""
type: path
auth: inherit auth: inherit
runtime:
variables:
- name: UserID
value: ""
settings: settings:
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0

View File

@@ -5,13 +5,14 @@ info:
http: http:
method: GET method: GET
url: http://localhost:3000/api/user/:id url: "{{BASE_URL}}/{{SECTOR}}/{{UserID}}"
params:
- name: id
value: ejRe
type: path
auth: inherit auth: inherit
runtime:
variables:
- name: UserID
value: ""
settings: settings:
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0

View File

@@ -5,20 +5,28 @@ info:
http: http:
method: PATCH method: PATCH
url: http://localhost:3000/api/user/:id url: "{{BASE_URL}}/{{SECTOR}}/{{UserID}}"
params:
- name: id
value: ""
type: path
body: body:
type: json type: json
data: |- data: |-
{ {
"isActive": true, "isActive": {{IsActive}},
"isAdmin": false "isAdmin": {{IsAdmin}},
"email": "{{Email}}"
} }
auth: inherit auth: inherit
runtime:
variables:
- name: UserID
value: ""
- name: IsActive
value: ""
- name: IsAdmin
value: ""
- name: Email
value: ""
settings: settings:
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0

View File

@@ -1,7 +1,10 @@
info: info:
name: User name: User
type: folder type: folder
seq: 3 seq: 1
request: request:
auth: inherit auth: inherit
variables:
- name: SECTOR
value: users

View File

@@ -0,0 +1,9 @@
#file: noinspection SpellCheckingInspection
name: BGApp
variables:
- name: BEARER_TOKEN
value: ""
- name: REFRESH_COOKIE
value: ""
- name: BASE_URL
value: http://localhost:3000/api

View File

@@ -17,17 +17,7 @@ config:
request: request:
auth: auth:
type: bearer type: bearer
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJlalJlIiwiY2xhaW1zIjpbIkFETUlOIiwiVVNFUlNfQ1JFQVRFIiwiVVNFUlNfU0VMRl9SRUFEIiwiVVNFUlNfU0VMRl9VUERBVEUiLCJVU0VSU19TRUxGX0RFTEVURSIsIlVTRVJTX09USEVSX1JFQUQiLCJVU0VSU19PVEhFUl9VUERBVEUiXSwiaWF0IjoxNzcxNjE4NTQzLCJleHAiOjE4MDMxNTQ1NDN9.R-3Qb5CEcLJBSt7DnsO9b0IGRVYDIZuFfH1m9TikVXU token: "{{BEARER_TOKEN}}"
actions:
- type: set-variable
phase: after-response
selector:
expression: ${token}
method: jsonq
variable:
name: Token
scope: runtime
disabled: true
bundled: false bundled: false
extensions: extensions:
bruno: bruno:

View File

@@ -1,3 +1,81 @@
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- noinspection SpellCheckingInspectionForFile
-- --
-- PostgreSQL database dump -- PostgreSQL database dump
-- --

View File

@@ -1,6 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { brandColours } from '../utilities/helpers'; import { brandColours } from '../utilities/helpers';
import { size } from 'lodash';
interface InviteEmailProperties { interface InviteEmailProperties {
playerName: string; playerName: string;
@@ -21,57 +20,59 @@ export const InviteEmail = (props: InviteEmailProperties) => (
cellSpacing={0} cellSpacing={0}
cellPadding={0} cellPadding={0}
> >
<tr> <tbody>
<td align="center"> <tr>
<div <td align="center">
style={{ <div
padding: '20px',
borderRadius: '20px',
background: brandColours.white,
margin: '50px',
color: brandColours.dark,
maxWidth: '450px',
}}
>
<h1>You're in, {props.playerName}!</h1>
<p>
You've been invited to join {process.env.PRODUCT_NAME}, please click the button below to
finish signing up.
</p>
<p
style={{ style={{
marginBottom: '40px', padding: '20px',
borderRadius: '20px',
background: brandColours.white,
margin: '50px',
color: brandColours.dark,
maxWidth: '450px',
}} }}
> >
<a <h1>You're in, {props.playerName}!</h1>
<p>
You've been invited to join {process.env.PRODUCT_NAME}, please click the button below to
finish signing up.
</p>
<p
style={{ style={{
display: 'inline-block', marginBottom: '40px',
padding: '10px 20px',
borderRadius: '5px',
background: brandColours.primary,
textDecoration: 'none',
color: brandColours.light,
fontSize: '20px',
fontWeight: 'bold',
}} }}
href={`${process.env.ROOT_URL}/invitation/${props.inviteCode}`}
> >
Join {process.env.PRODUCT_NAME} <a
</a> style={{
</p> display: 'inline-block',
<p padding: '10px 20px',
style={{ borderRadius: '5px',
fontSize: '0.8rem', background: brandColours.primary,
opacity: '80%', textDecoration: 'none',
}} color: brandColours.light,
> fontSize: '20px',
If above button does not work, copy the link below into a new browser tab: fontWeight: 'bold',
<br /> }}
{`${process.env.ROOT_URL}/invitation/${props.inviteCode}`} href={`${process.env.ROOT_URL}/invitation/${props.inviteCode}`}
</p> >
</div> Join {process.env.PRODUCT_NAME}
</td> </a>
</tr> </p>
<p
style={{
fontSize: '0.8rem',
opacity: '80%',
}}
>
If above button does not work, copy the link below into a new browser tab:
<br />
{`${process.env.ROOT_URL}/invitation/${props.inviteCode}`}
</p>
</div>
</td>
</tr>
</tbody>
</table> </table>
</div> </div>
); );

View File

@@ -3,12 +3,13 @@ import jwt from 'jsonwebtoken';
import { UnwrappedRequest } from '../utilities/guard'; import { UnwrappedRequest } from '../utilities/guard';
import { ErrorResponse, OkResponse, UnauthorizedResponse } from '../utilities/responseHelper'; import { ErrorResponse, OkResponse, UnauthorizedResponse } from '../utilities/responseHelper';
import { Claims } from '../orm/claims'; 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<LoginRequest>): Promise<Response> { async function login(request: UnwrappedRequest<LoginRequest>): Promise<Response> {
try { try {
const verify: { const verify: {
userId: SecureId; userId: UserId;
refreshCount: string; refreshCount: string;
} | null = await orm.users.verifyCredentials(request.body.email, request.body.password); } | null = await orm.users.verifyCredentials(request.body.email, request.body.password);
if (!verify) { if (!verify) {
@@ -19,10 +20,10 @@ async function login(request: UnwrappedRequest<LoginRequest>): Promise<Response>
const tokenLifeSpanInDays = 30; const tokenLifeSpanInDays = 30;
const token = jwt.sign( const token = jwt.sign(
{ {
u: verify.userId.raw, u: verify.userId,
r: verify.refreshCount, r: verify.refreshCount,
}, },
process.env.JWT_SECRET_KEY as string, process.env.JWT_REFRESH_KEY as string,
{ expiresIn: `${tokenLifeSpanInDays * 24}h` }, { expiresIn: `${tokenLifeSpanInDays * 24}h` },
); );
const cookies = request?.request?.cookies; const cookies = request?.request?.cookies;
@@ -32,9 +33,9 @@ async function login(request: UnwrappedRequest<LoginRequest>): Promise<Response>
httpOnly: true, httpOnly: true,
secure: true, secure: true,
maxAge: tokenLifeSpanInDays * 24 * 60 * 60, maxAge: tokenLifeSpanInDays * 24 * 60 * 60,
path: '/api/auth/token' path: '/api/auth/token',
}); });
return new OkResponse(); return new OkResponse({ token });
} catch (error: any) { } catch (error: any) {
return new ErrorResponse(error as Error); return new ErrorResponse(error as Error);
} }
@@ -51,15 +52,15 @@ async function token(request: UnwrappedRequest): Promise<Response> {
const refreshToken: { const refreshToken: {
u: string; u: string;
r: string; r: string;
} = jwt.verify(refreshCookie, process.env.JWT_SECRET_KEY as string) as { u: string; r: string }; } = jwt.verify(refreshCookie, process.env.JWT_REFRESH_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.fromHash(refreshToken.u), refreshToken.r))) {
const response = new UnauthorizedResponse('Invalid refresh token'); const response = new UnauthorizedResponse('Invalid refresh token');
response.headers.set('Clear-Site-Data', '"cookies","cache","storage","executionContexts"'); response.headers.set('Clear-Site-Data', '"cookies","cache","storage","executionContexts"');
return response; return response;
} }
const claims: Claims | null = await orm.claims.getByUserId(refreshToken.u); const claims: Claims | null = await orm.claims.getByUserId(UserId.fromHash(refreshToken.u));
const token = jwt.sign({ ...claims }, process.env.JWT_SECRET_KEY as string, { const token = jwt.sign({ ...claims }, process.env.JWT_SECRET_KEY as string, {
expiresIn: process.env.JWT_LIFESPAN as any, expiresIn: process.env.JWT_LIFESPAN as any,
@@ -84,7 +85,7 @@ async function changePassword(request: UnwrappedRequest<ChangePasswordRequest>):
try { try {
return new OkResponse( return new OkResponse(
await orm.users.changePassword( await orm.users.changePassword(
SecureId.fromHash(request.params.id), UserId.fromHash(request.params.id),
request.body.oldPassword, request.body.oldPassword,
request.body.newPassword, request.body.newPassword,
request.claims, request.claims,

74
src/endpoints/circles.ts Normal file
View File

@@ -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<CreateCircleRequest>): Promise<Response> {
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<InviteToCircleRequest>): Promise<Response> {
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<Response> {
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<UpdateCircleRequest>): Promise<Response> {
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<Response> {
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<Response> {
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,
};

View File

@@ -0,0 +1,85 @@
import { orm } from '../orm/orm';
import { UnwrappedRequest } from '../utilities/guard';
import { CreatedResponse, ErrorResponse, OkResponse, PagedResponse } from '../utilities/responseHelper';
import { GameToCollectionRequest, CreateCollectionRequest, UpdateCollectionRequest } from '../utilities/requestModels';
import { CollectionId, GameId } from '../utilities/secureIds';
async function create(request: UnwrappedRequest<CreateCollectionRequest>): Promise<Response> {
try {
return new CreatedResponse(await orm.collections.create(request.body, request.claims));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function get(request: UnwrappedRequest): Promise<Response> {
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<Response> {
try {
return new PagedResponse(request, await orm.collections.list(request.claims));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function update(request: UnwrappedRequest<UpdateCollectionRequest>): Promise<Response> {
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<Response> {
try {
return new OkResponse(await orm.collections.drop(CollectionId.fromHash(request.params.id), request.claims));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function addGame(request: UnwrappedRequest<GameToCollectionRequest>): Promise<Response> {
try {
return new OkResponse(
await orm.collections.addGame(
CollectionId.fromHash(request.params.id),
GameId.fromHash(request.body.gameId),
request.claims,
),
);
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function removeGame(request: UnwrappedRequest<GameToCollectionRequest>): Promise<Response> {
try {
return new OkResponse(
await orm.collections.removeGame(
CollectionId.fromHash(request.params.id),
GameId.fromHash(request.body.gameId),
request.claims,
),
);
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
export default {
create,
get,
list,
update,
drop,
addGame,
removeGame,
};

View File

@@ -1,42 +1,18 @@
import { orm } from '../orm/orm'; import { orm } from '../orm/orm';
import { UnwrappedRequest } from '../utilities/guard'; import { UnwrappedRequest } from '../utilities/guard';
import { CreatedResponse, ErrorResponse, OkResponse } from '../utilities/responseHelper'; import { CreatedResponse, ErrorResponse, OkResponse, PagedResponse } from '../utilities/responseHelper';
import { import { CreateGameRequest, UpdateGameRequest } from '../utilities/requestModels';
CreateGameRequest, import { GameId } from '../utilities/secureIds';
SecureId,
UpdateGameRequest,
} from '../utilities/requestModels';
async function create(request: UnwrappedRequest<CreateGameRequest>): Promise<Response> { async function create(request: UnwrappedRequest<CreateGameRequest>): Promise<Response> {
try { try {
const newUser = await orm.games.create( return new CreatedResponse(
{ await orm.games.create(
name: request.body.name, {
bggId: request.body.bggId, name: request.body.name,
imagePath: request.body.imagePath, 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<Response> {
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<UpdateGameRequest>): Promise<Response> {
try {
return new OkResponse(
await orm.games.update(
SecureId.fromHash(request.params.id),
request.body,
request.claims, request.claims,
), ),
); );
@@ -45,9 +21,25 @@ async function update(request: UnwrappedRequest<UpdateGameRequest>): Promise<Res
} }
} }
async function get(request: UnwrappedRequest): Promise<Response> {
try {
return new OkResponse(await orm.games.get(GameId.fromHash(request.params.id)));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function update(request: UnwrappedRequest<UpdateGameRequest>): Promise<Response> {
try {
return new OkResponse(await orm.games.update(GameId.fromHash(request.params.id), request.body, request.claims));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function drop(request: UnwrappedRequest): Promise<Response> { async function drop(request: UnwrappedRequest): Promise<Response> {
try { 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) { } catch (error: any) {
return new ErrorResponse(error as Error); return new ErrorResponse(error as Error);
} }
@@ -55,7 +47,7 @@ async function drop(request: UnwrappedRequest): Promise<Response> {
async function query(request: UnwrappedRequest): Promise<Response> { async function query(request: UnwrappedRequest): Promise<Response> {
try { try {
return new OkResponse(await orm.games.query(request.params.query)); return new PagedResponse(request, await orm.games.query(request.params.query));
} catch (error: any) { } catch (error: any) {
return new ErrorResponse(error as Error); return new ErrorResponse(error as Error);
} }

View File

@@ -1,22 +1,18 @@
import { orm } from '../orm/orm'; import { orm } from '../orm/orm';
import { UnwrappedRequest } from '../utilities/guard'; import { UnwrappedRequest } from '../utilities/guard';
import { CreatedResponse, ErrorResponse } from '../utilities/responseHelper'; import { CreatedResponse, ErrorResponse } from '../utilities/responseHelper';
import { import { AcceptInviteRequest, InviteUserRequest } from '../utilities/requestModels';
AcceptInviteRequest, import { PlayerId, UserId } from '../utilities/secureIds';
InviteUserRequest,
SecureId,
} from '../utilities/requestModels';
async function create(request: UnwrappedRequest<InviteUserRequest>): Promise<Response> { async function create(request: UnwrappedRequest<InviteUserRequest>): Promise<Response> {
try { try {
const newUser = await orm.invites.create( return new CreatedResponse(
{ await orm.invites.create({
...request.body, ...request.body,
playerId: SecureId.fromHash(request.body.playerId), playerId: PlayerId.fromHash(request.body.playerId),
invitedByUserId: request.claims.userId as SecureId, invitedByUserId: request.claims.userId as UserId,
}, }),
); );
return new CreatedResponse(newUser);
} catch (error: any) { } catch (error: any) {
return new ErrorResponse(error as Error); return new ErrorResponse(error as Error);
} }
@@ -24,8 +20,7 @@ async function create(request: UnwrappedRequest<InviteUserRequest>): Promise<Res
async function accept(request: UnwrappedRequest<AcceptInviteRequest>): Promise<Response> { async function accept(request: UnwrappedRequest<AcceptInviteRequest>): Promise<Response> {
try { try {
const newUser = await orm.invites.accept(request.body); return new CreatedResponse(await orm.invites.accept(request.body));
return new CreatedResponse(newUser);
} catch (error: any) { } catch (error: any) {
return new ErrorResponse(error as Error); return new ErrorResponse(error as Error);
} }

55
src/endpoints/matches.ts Normal file
View File

@@ -0,0 +1,55 @@
import { orm } from '../orm/orm';
import { UnwrappedRequest } from '../utilities/guard';
import { CreatedResponse, ErrorResponse, OkResponse } from '../utilities/responseHelper';
import { CreateMatchRequest } from '../utilities/requestModels';
import { GameId, MatchId, PlayerId, UserId } from '../utilities/secureIds';
import { MatchParticipant } from '../orm/matches';
async function create(request: UnwrappedRequest<CreateMatchRequest>): Promise<Response> {
try {
return new CreatedResponse(
await orm.matches.create({
gameId: GameId.fromHash(request.body.gameId),
ownerId: request.claims.userId as UserId,
participants: request.body.participants.map(
(x) => new MatchParticipant(PlayerId.fromHash(x.playerId), x.standing),
),
}),
);
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function get(request: UnwrappedRequest): Promise<Response> {
try {
return new OkResponse(await orm.matches.get(MatchId.fromHash(request.params.id), request.claims));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function drop(request: UnwrappedRequest): Promise<Response> {
try {
return new OkResponse(await orm.matches.drop(MatchId.fromHash(request.params.id), request.claims));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function leave(request: UnwrappedRequest): Promise<Response> {
try {
return new OkResponse(
await orm.matches.removePlayer(MatchId.fromHash(request.params.id), request.claims.userId as UserId),
);
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
export default {
create,
get,
drop,
leave,
};

View File

@@ -1,12 +1,12 @@
import { orm } from '../orm/orm'; import { orm } from '../orm/orm';
import { UnwrappedRequest } from '../utilities/guard'; import { UnwrappedRequest } from '../utilities/guard';
import { CreatedResponse, ErrorResponse, OkResponse } from '../utilities/responseHelper'; import { CreatedResponse, ErrorResponse, OkResponse, PagedResponse } 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<CreatePlayerRequest>): Promise<Response> { async function create(request: UnwrappedRequest<CreatePlayerRequest>): Promise<Response> {
try { try {
const newPlayer = await orm.players.create(request.body, request.claims); return new CreatedResponse(await orm.players.create(request.body));
return new CreatedResponse(newPlayer);
} catch (error: any) { } catch (error: any) {
return new ErrorResponse(error as Error); return new ErrorResponse(error as Error);
} }
@@ -14,7 +14,7 @@ async function create(request: UnwrappedRequest<CreatePlayerRequest>): Promise<R
async function get(request: UnwrappedRequest): Promise<Response> { async function get(request: UnwrappedRequest): Promise<Response> {
try { 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) { } catch (error: any) {
return new ErrorResponse(error as Error); return new ErrorResponse(error as Error);
} }
@@ -22,7 +22,7 @@ async function get(request: UnwrappedRequest): Promise<Response> {
async function list(request: UnwrappedRequest): Promise<Response> { async function list(request: UnwrappedRequest): Promise<Response> {
try { try {
return new OkResponse(await orm.players.list(request.claims)); return new PagedResponse(request, await orm.players.list(request.claims));
} catch (error: any) { } catch (error: any) {
return new ErrorResponse(error as Error); return new ErrorResponse(error as Error);
} }
@@ -31,7 +31,7 @@ async function list(request: UnwrappedRequest): Promise<Response> {
async function update(request: UnwrappedRequest<UpdatePlayerRequest>): Promise<Response> { async function update(request: UnwrappedRequest<UpdatePlayerRequest>): Promise<Response> {
try { try {
return new OkResponse( 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) { } catch (error: any) {
return new ErrorResponse(error as Error); return new ErrorResponse(error as Error);
@@ -40,7 +40,7 @@ async function update(request: UnwrappedRequest<UpdatePlayerRequest>): Promise<R
async function drop(request: UnwrappedRequest): Promise<Response> { async function drop(request: UnwrappedRequest): Promise<Response> {
try { 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) { } catch (error: any) {
return new ErrorResponse(error as Error); return new ErrorResponse(error as Error);
} }

View File

@@ -1,18 +1,17 @@
import { orm } from '../orm/orm'; import { orm } from '../orm/orm';
import { UnwrappedRequest } from '../utilities/guard'; import { UnwrappedRequest } from '../utilities/guard';
import { CreatedResponse, ErrorResponse, OkResponse } from '../utilities/responseHelper'; 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<CreateUserRequest>): Promise<Response> { async function create(request: UnwrappedRequest<CreateUserRequest>): Promise<Response> {
try { try {
const newUser = await orm.users.create( return new CreatedResponse(
{ await orm.users.create({
...request.body, ...request.body,
playerId: SecureId.fromHash(request.body.playerId), playerId: PlayerId.fromHash(request.body.playerId),
}, }),
request.claims,
); );
return new CreatedResponse(newUser);
} catch (error: any) { } catch (error: any) {
return new ErrorResponse(error as Error); return new ErrorResponse(error as Error);
} }
@@ -20,7 +19,7 @@ async function create(request: UnwrappedRequest<CreateUserRequest>): Promise<Res
async function get(request: UnwrappedRequest): Promise<Response> { async function get(request: UnwrappedRequest): Promise<Response> {
try { 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) { } catch (error: any) {
return new ErrorResponse(error as Error); return new ErrorResponse(error as Error);
} }
@@ -28,9 +27,7 @@ async function get(request: UnwrappedRequest): Promise<Response> {
async function update(request: UnwrappedRequest<UpdateUserRequest>): Promise<Response> { async function update(request: UnwrappedRequest<UpdateUserRequest>): Promise<Response> {
try { try {
return new OkResponse( return new OkResponse(await orm.users.update(UserId.fromHash(request.params.id), request.body, request.claims));
await orm.users.update(SecureId.fromHash(request.params.id), request.body, request.claims),
);
} catch (error: any) { } catch (error: any) {
return new ErrorResponse(error as Error); return new ErrorResponse(error as Error);
} }
@@ -38,7 +35,7 @@ async function update(request: UnwrappedRequest<UpdateUserRequest>): Promise<Res
async function drop(request: UnwrappedRequest): Promise<Response> { async function drop(request: UnwrappedRequest): Promise<Response> {
try { 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) { } catch (error: any) {
return new ErrorResponse(error as Error); return new ErrorResponse(error as Error);
} }

View File

@@ -1,23 +1,33 @@
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 { 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';
import { buildRoute } from './utilities/routeBuilder';
import { MatchId } from './utilities/secureIds';
import matches from './routes/matches';
import circles from './routes/circles';
const server = Bun.serve({ const server = Bun.serve({
routes: { routes: buildRoute({
...auth, [process.env.API_ROOT_PATH ?? '']: {
...user, auth,
...player, users,
...game, players,
...invite, games,
'/test': { invites,
collections,
matches,
circles,
},
test: {
GET: () => { GET: () => {
return new OkResponse(); return new OkResponse(MatchId.fromID('2').value);
}, },
}, },
}, }) as any,
// (optional) fallback for unmatched routes: // (optional) fallback for unmatched routes:
fetch(): Response { fetch(): Response {

192
src/orm/circles.ts Normal file
View File

@@ -0,0 +1,192 @@
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<Circle> {
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 newRecordId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string;
return await this.get(CircleId.fromID(newRecordId));
}
async get(id: CircleId, claims?: Claims): Promise<Circle> {
const record: any = first(
await sql`SELECT *
FROM circles
WHERE id = ${id.raw}
LIMIT 1`,
);
if (!record) {
throw new NotFoundError('No matching game exists');
}
let user: User;
if (
claims &&
!claims.test(Claims.ADMIN) &&
!(claims.test(Claims.CIRCLES.PUBLIC.READ) && record.is_public) &&
!(claims.test(Claims.CIRCLES.OWNED.READ) && record.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(record.id),
owningUserId: UserId.fromID(record.owning_user_id),
name: record.name,
isPublic: record.is_public,
imagePath: record.image_path,
colour: record.colour,
});
}
async update(id: CircleId, patch: UpdateCircleRequest): Promise<Circle> {
const circle = await this.get(id);
circle.name = patch.name ?? circle.name;
circle.colour = patch.colour ?? circle.colour;
circle.imagePath = patch.imagePath ?? circle.imagePath;
await sql`UPDATE circles
SET name=${circle.name},
colour=${circle.colour},
image_path=${circle.imagePath}
WHERE id = ${id.raw}`;
return await this.get(id);
}
async drop(id: CircleId): Promise<void> {
// 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<void> {
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<Circle[]> = memo<(query: string) => Promise<Circle[]>, Circle[]>(this.#query);
async #query(query: string): Promise<Circle[]> {
const queryResult: 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 (!queryResult) {
throw new NotFoundError('No matching circles exists');
}
return queryResult.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),
}),
);
}
}

View File

@@ -1,38 +1,49 @@
import { sql } from 'bun'; import { sql } from 'bun';
import { ClaimDefinition } from '../utilities/claimDefinitions'; import { ClaimDefinition } from '../utilities/claimDefinitions';
import { SecureId } from '../utilities/requestModels'; import { UserId } from '../utilities/secureIds';
export class Claims extends ClaimDefinition { export class Claims extends ClaimDefinition {
userId?: SecureId; userId: UserId;
claims: string[] = []; claims: string[] = [];
constructor(raw?:{userId?:string, claims?: string[]}) { constructor(raw?: { userId?: string | UserId; claims?: string[] }) {
super(); super();
this.userId = raw?.userId ? SecureId.fromHash(raw.userId) : undefined; if (raw?.userId instanceof UserId) {
this.userId = raw.userId;
} else {
this.userId = UserId.fromHash(raw?.userId ?? '');
}
this.claims = raw?.claims ?? []; this.claims = raw?.claims ?? [];
} }
public static test(guardClaim: string, userClaims?: Claims): Boolean { test(...guardClaims: string[]): Boolean {
return userClaims === undefined || userClaims.claims.some((x) => x === guardClaim); return Claims.test(this, ...guardClaims);
}
public static test(userClaims?: Claims, ...guardClaims: string[]): Boolean {
return (
userClaims === undefined ||
userClaims.claims.some((x: string): boolean => guardClaims.some((y: string): boolean => x === y))
);
} }
} }
export class ClaimsOrm { export class ClaimsOrm {
async getByUserId(userId: string): Promise<Claims> { async getByUserId(userId: UserId): Promise<Claims> {
const dbResults: any[] = await sql`SELECT c.name const records: any[] = await sql`SELECT c.name
from user_claims as uc from user_claims as uc
JOIN claims as c on uc.claim_id = c.id JOIN claims as c on uc.claim_id = c.id
where uc.user_id = ${userId};`; where uc.user_id = ${userId.raw};`;
const claims = new Claims(); return new Claims({
claims.userId = SecureId.fromID(userId); userId: userId,
claims.claims = dbResults.map((x) => x.name); claims: records.map((x) => x.name),
return claims; });
} }
async getDefaultClaims(): Promise<number[]> { async getDefaultClaims(): Promise<number[]> {
const dbResults: any[] = await sql`SELECT id const records: any[] = await sql`SELECT id
FROM claims FROM claims
WHERE is_default = true;`; WHERE is_default = true;`;
return dbResults.map((x) => x.id); return records.map((x) => x.id);
} }
} }

180
src/orm/collections.ts Normal file
View File

@@ -0,0 +1,180 @@
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, UserId } from '../utilities/secureIds';
export class Collection {
id: CollectionId;
name: string;
ownerId: UserId;
games: Game[];
constructor(input: { id: CollectionId; name: string; ownerId:UserId; games?: Game[] }) {
this.id = input.id;
this.name = input?.name;
this.ownerId = input.ownerId;
this.games = input.games ?? [];
}
}
export class CollectionsOrm {
async create(model: { name: string }, claims: Claims): Promise<Collection> {
await sql`INSERT INTO collections (name, user_id)
VALUES (${model.name}, ${claims?.userId?.raw})`;
const newRecordId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string;
return await this.get(CollectionId.fromID(newRecordId));
}
async get(id: CollectionId, claims?: Claims): Promise<Collection> {
const records: 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 &&
!(
claims.test(Claims.ADMIN, Claims.COLLECTIONS.UNOWNED.READ) ||
(Claims.test(claims, Claims.COLLECTIONS.OWNED.READ) &&
records?.[0]?.user_id === claims?.userId?.raw)
)
) {
throw new UnauthorizedError();
}
if (!records?.length) {
throw new NotFoundError('No matching player exists');
}
return new Collection({
id: CollectionId.fromID(records[0].collection_id),
name: records[0].collection_name,
ownerId: UserId.fromID(records[0].user_id),
games: records
.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<Collection[]> {
if (!claims || claims?.test(Claims.ADMIN)) {
return (await sql`SELECT * FROM collections`).map(
(x: { id: string; name: string, user_id: string }) =>
new Collection({
id: CollectionId.fromID(x.id),
name: x.name,
ownerId: UserId.fromID(x.user_id)
}),
);
}
if (!claims.test(Claims.COLLECTIONS.OWNED.LIST)) {
throw new UnauthorizedError();
}
return (await sql`SELECT * FROM collections WHERE user_id=${claims.userId?.raw}`).map(
(x: { id: string; name: string; user_id: string }) =>
new Collection({
id: CollectionId.fromID(x.id),
name: x.name,
ownerId: UserId.fromID(x.user_id)
}),
);
}
async update(id: CollectionId, patch: UpdateCollectionRequest, claims?: Claims): Promise<Collection> {
const collection = await this.get(id);
if (
claims &&
!(
claims.test(Claims.ADMIN, Claims.COLLECTIONS.UNOWNED.UPDATE) ||
(Claims.test(claims, Claims.COLLECTIONS.OWNED.UPDATE) &&
collection.ownerId === 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<void> {
const collection = await this.get(id);
if (
claims &&
!(
claims.test(Claims.ADMIN, Claims.COLLECTIONS.UNOWNED.DELETE) ||
(Claims.test(claims, Claims.COLLECTIONS.OWNED.DELETE) &&
collection.ownerId === claims?.userId)
)
) {
throw new UnauthorizedError();
}
await sql`DELETE FROM collections WHERE id=${id.raw}`;
return;
}
async addGame(id: CollectionId, gameId: GameId, claims: Claims): Promise<void> {
const collection = await this.get(id);
if (
claims &&
!(
claims.test(Claims.ADMIN) ||
(Claims.test(claims, Claims.COLLECTIONS.OWNED.GAME.ADD) &&
collection.ownerId === claims?.userId)
)
) {
throw new UnauthorizedError();
}
await sql`INSERT INTO collection_games (collection_id, game_id)
VALUES (${id.raw}, ${gameId.raw})`;
return;
}
async removeGame(id: CollectionId, gameId: GameId, claims: Claims): Promise<void> {
const collection = await this.get(id);
if (
claims &&
!(
claims.test(Claims.ADMIN) ||
(Claims.test(claims, Claims.COLLECTIONS.OWNED.GAME.REMOVE) &&
collection.ownerId === claims?.userId)
)
) {
throw new UnauthorizedError();
}
await sql`DELETE
FROM collection_games
WHERE collection_id = ${id.raw}
AND game_id = ${gameId.raw}`;
return;
}
}

View File

@@ -1,17 +1,18 @@
import { Claims } from './claims'; import { Claims } from './claims';
import { sql } from 'bun'; import { sql } from 'bun';
import { first } from 'lodash'; import { first } from 'lodash';
import { NotFoundError, UnauthorizedError } from '../utilities/errors'; import { NotFoundError } from '../utilities/errors';
import { CreateGameRequest, SecureId, UpdateGameRequest } from '../utilities/requestModels'; import { CreateGameRequest, UpdateGameRequest } from '../utilities/requestModels';
import { memo } from '../utilities/helpers'; import { memo } from '../utilities/helpers';
import { GameId } from '../utilities/secureIds';
export class Game { export class Game {
id: SecureId; id: GameId;
name: string; name: string;
imagePath?: string; imagePath?: string;
bggId?: 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.id = input.id;
this.name = input?.name; this.name = input?.name;
this.imagePath = input?.imagePath; this.imagePath = input?.imagePath;
@@ -22,51 +23,51 @@ export class Game {
export class GamesOrm { export class GamesOrm {
async create(model: CreateGameRequest, claims?: Claims): Promise<Game> { async create(model: CreateGameRequest, claims?: Claims): Promise<Game> {
await sql`INSERT INTO games (name, image_path, bgg_id) 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})`; VALUES (${model.name}, ${claims?.test(Claims.GAMES.MANAGE_IMAGES) ? model.imagePath : null}, ${model.bggId})`;
const newGameId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string; const newRecordId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string;
return await this.get(SecureId.fromID(newGameId)); return await this.get(GameId.fromID(newRecordId));
} }
async get(id: SecureId): Promise<Game> { async get(id: GameId): Promise<Game> {
const dbResult: any = first( const record: any = first(
await sql`SELECT * await sql`SELECT *
FROM games FROM games
WHERE id = ${id.raw} WHERE id = ${id.raw}
LIMIT 1`, LIMIT 1`,
); );
if (!dbResult) { if (!record) {
throw new NotFoundError('No matching game exists'); throw new NotFoundError('No matching game exists');
} }
return new Game({ return new Game({
id: SecureId.fromID(dbResult.id), id: GameId.fromID(record.id),
name: dbResult.name, name: record.name,
bggId: dbResult.bgg_id, bggId: record.bgg_id,
imagePath: dbResult.image_path, imagePath: record.image_path,
}); });
} }
async update(id: SecureId, patch: UpdateGameRequest, claims?: Claims): Promise<Game> { async update(id: GameId, patch: UpdateGameRequest, claims?: Claims): Promise<Game> {
const gameToUpdate = await this.get(id); const game = await this.get(id);
gameToUpdate.name = patch.name ?? gameToUpdate.name; game.name = patch.name ?? game.name;
gameToUpdate.bggId = patch.bggId ?? gameToUpdate.bggId; game.bggId = patch.bggId ?? game.bggId;
if (Claims.test(Claims.GAMES.MANAGE_IMAGES, claims)) { if (claims?.test(Claims.GAMES.MANAGE_IMAGES)) {
gameToUpdate.imagePath = patch.imagePath ?? gameToUpdate.imagePath; game.imagePath = patch.imagePath ?? game.imagePath;
} }
await sql`UPDATE games await sql`UPDATE games
SET name=${gameToUpdate.name}, SET name=${game.name},
bgg_id=${gameToUpdate.bggId}, bgg_id=${game.bggId},
image_path=${gameToUpdate.imagePath} image_path=${game.imagePath}
WHERE id = ${id.raw}`; WHERE id = ${id.raw}`;
return await this.get(id); return await this.get(id);
} }
async drop(id: SecureId): Promise<void> { async drop(id: GameId): Promise<void> {
// Ensure player exists before attempting to delete // Ensure player exists before attempting to delete
await this.get(id); await this.get(id);
await sql.transaction(async (tx) => { await sql.transaction(async (tx) => {
@@ -87,23 +88,23 @@ export class GamesOrm {
return; return;
} }
query: (query: string) => Promise<Game[]> = memo<(query: string) => Promise<Game[]>,Game[]>(this.#query); query: (query: string) => Promise<Game[]> = memo<(query: string) => Promise<Game[]>, Game[]>(this.#query);
async #query(query: string): Promise<Game[]> { async #query(query: string): Promise<Game[]> {
const dbResult: any = await sql` SELECT const records: any = await sql` SELECT
id, name id, name
FROM (SELECT *, SIMILARITY(${query}, name) as similarity FROM games) FROM (SELECT *, SIMILARITY(${query}, name) as similarity FROM games)
WHERE similarity > 0 WHERE similarity > 0
ORDER BY similarity ORDER BY similarity
LIMIT 5;`; LIMIT 5;`;
if (!dbResult) { if (!records) {
throw new NotFoundError('No matching game exists'); throw new NotFoundError('No matching game exists');
} }
return dbResult.map( return records.map(
(x: { id: string; name: string }) => (x: { id: string; name: string }) =>
new Game({ new Game({
id: SecureId.fromID(x.id), id: GameId.fromID(x.id),
name: x.name, name: x.name,
}), }),
); );

View File

@@ -1,7 +1,7 @@
import { sql } from 'bun'; import { sql } from 'bun';
import { first } from 'lodash'; import { first } from 'lodash';
import { BadRequestError, InternalServerError, NotFoundError, UnauthorizedError } from '../utilities/errors'; 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 { createRandomString } from '../utilities/helpers';
import { Resend } from 'resend'; import { Resend } from 'resend';
import { orm } from './orm'; import { orm } from './orm';
@@ -17,12 +17,12 @@ export class InvitesOrm {
invitedByUserId, invitedByUserId,
}: { }: {
email: string; email: string;
playerId: SecureId; playerId: PlayerId;
invitedByUserId: SecureId; invitedByUserId: UserId;
}, },
claims?: Claims, claims?: Claims,
): Promise<void> { ): Promise<void> {
if (!Claims.test(Claims.ADMIN, claims)) { if (!claims?.test(Claims.ADMIN)) {
const userInviteCount = ( const userInviteCount = (
first( first(
await sql`SELECT COUNT(*) AS count await sql`SELECT COUNT(*) AS count
@@ -35,7 +35,7 @@ export class InvitesOrm {
throw new UnauthorizedError('Invite allowance reached.'); throw new UnauthorizedError('Invite allowance reached.');
} }
const inviteExists = ( const doesInviteExist = (
first( first(
await sql`SELECT COUNT(*) > 0 AS exists await sql`SELECT COUNT(*) > 0 AS exists
FROM user_invites FROM user_invites
@@ -45,12 +45,12 @@ export class InvitesOrm {
exists: boolean; exists: boolean;
} }
)?.exists; )?.exists;
if (inviteExists) { if (doesInviteExist) {
throw new BadRequestError('Player has already been invited.'); throw new BadRequestError('Player has already been invited.');
} }
} }
const playerHasUser = ( const isPlayerAssociatedWithUser = (
first( first(
await sql`SELECT COUNT(*) > 0 AS exists await sql`SELECT COUNT(*) > 0 AS exists
FROM users FROM users
@@ -60,7 +60,7 @@ export class InvitesOrm {
exists: boolean; exists: boolean;
} }
)?.exists; )?.exists;
if (playerHasUser) { if (isPlayerAssociatedWithUser) {
throw new BadRequestError('User has already been invited.'); throw new BadRequestError('User has already been invited.');
} }
@@ -69,7 +69,7 @@ export class InvitesOrm {
const invitationCode = createRandomString(6); const invitationCode = createRandomString(6);
await sql`INSERT INTO user_invites (invite_code, email, player_id, invited_by_user_id) await sql`INSERT INTO user_invites (invite_code, email, player_id, invited_by_user_id)
VALUES (${invitationCode}, ${email}, ${playerId.raw}, ${invitedByUserId.raw})`; VALUES (${invitationCode}, ${email}, ${playerId.raw}, ${invitedByUserId.raw})`;
const newInviteId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string; const newRecordId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string;
const resend = new Resend(process.env.RESEND_KEY); const resend = new Resend(process.env.RESEND_KEY);
const resendResponse = await resend.emails.send({ const resendResponse = await resend.emails.send({
@@ -83,51 +83,51 @@ export class InvitesOrm {
throw new InternalServerError(); throw new InternalServerError();
} }
await sql`UPDATE user_invites SET was_email_sent = true WHERE id=${newInviteId}`; await sql`UPDATE user_invites SET was_email_sent = true WHERE id=${newRecordId}`;
return; return;
} }
async accept({ inviteCode, password }: { inviteCode: string; password: string }): Promise<User> { async accept({ inviteCode, password }: { inviteCode: string; password: string }): Promise<User> {
const invite: { const record: {
id: string; id: string;
email: string; email: string;
player_id: string; player_id: string;
accepted: boolean; accepted: boolean;
} = first(await sql`SELECT * FROM user_invites WHERE invite_code=${inviteCode} LIMIT 1`); } = first(await sql`SELECT * FROM user_invites WHERE invite_code=${inviteCode} LIMIT 1`);
if (!invite) { if (!record) {
throw new NotFoundError('Invalid invite code'); throw new NotFoundError('Invalid invite code');
} }
if (invite.accepted) { if (record.accepted) {
throw new UnauthorizedError('Invite already accepted'); throw new UnauthorizedError('Invite already accepted');
} }
const playerHasUser = ( const isPlayerAssociatedWithUser = (
first( first(
await sql`SELECT COUNT(*) > 0 AS exists await sql`SELECT COUNT(*) > 0 AS exists
FROM users FROM users
WHERE player_id = ${invite.player_id} WHERE player_id = ${record.player_id}
OR email = ${invite.email}`, OR email = ${record.email}`,
) as { ) as {
exists: boolean; exists: boolean;
} }
)?.exists; )?.exists;
if (playerHasUser) { if (isPlayerAssociatedWithUser) {
throw new BadRequestError('User has already been invited.'); throw new BadRequestError('User has already been invited.');
} }
const createdUser = await orm.users.create({ const user = await orm.users.create({
email: invite.email, email: record.email,
playerId: SecureId.fromID(invite.player_id), playerId: PlayerId.fromID(record.player_id),
password, password,
}); });
await sql`UPDATE user_invites await sql`UPDATE user_invites
SET accepted = true SET accepted = true
WHERE id = ${invite.id}`; WHERE id = ${record.id}`;
return createdUser; return user;
} }
} }

205
src/orm/matches.ts Normal file
View File

@@ -0,0 +1,205 @@
import { Claims } from './claims';
import { sql } from 'bun';
import { first, orderBy } from 'lodash';
import { BadRequestError, NotFoundError, UnauthorizedError } from '../utilities/errors';
import { GameId, MatchId, PlayerId, UserId } from '../utilities/secureIds';
import { calculateElos } from '../utilities/elo';
import { orm } from './orm';
export class MatchParticipant {
matchId?: MatchId;
playerId: PlayerId;
gamesPlayed?: number;
standing: number;
elo: number;
eloChange: number;
isRatingLocked?: boolean;
constructor(
playerId: PlayerId,
standing: number,
eloChange: number = 0,
gamesPlayed?: number,
elo: number = 1000,
matchId?: MatchId,
) {
this.matchId = matchId;
this.playerId = playerId;
this.standing = standing;
this.elo = elo;
this.gamesPlayed = gamesPlayed;
this.eloChange = eloChange;
}
}
export class Match {
id: MatchId;
gameId: GameId;
players: MatchParticipant[];
owner: UserId;
constructor(id: MatchId, gameId: GameId, players: MatchParticipant[], owner: UserId) {
this.id = id;
this.gameId = gameId;
this.players = players;
this.owner = owner;
}
}
export class MatchOrm {
async create({
gameId,
participants,
ownerId,
}: {
gameId: GameId;
participants: MatchParticipant[];
ownerId: UserId;
}): Promise<Match> {
await sql`INSERT INTO matches (game_id, owning_user_id) VALUES (${gameId.raw}, ${ownerId.raw})`;
const newMatchId = MatchId.fromID((first(await sql`SELECT lastval();`) as any)?.lastval as string);
const players = await sql`
SELECT p.id,
p.is_rating_locked,
(CASE WHEN p.is_rating_locked THEN 1000 ELSE 1000 + COALESCE(sum(mp.elo_change), 0) END) as elo,
(CASE WHEN p.is_rating_locked THEN 0 ELSE count(mp.*) END) as games_played
FROM players p
LEFT JOIN match_players mp ON mp.player_id = p.id
WHERE p.id IN ${sql(participants.map((x) => x.playerId.raw))}
GROUP BY p.id;`;
for (let i in participants) {
const player = players.find((x: any) => x.id === participants[i].playerId.raw);
participants[i].elo = parseInt(player.elo);
participants[i].gamesPlayed = parseInt(player.games_played);
participants[i].isRatingLocked = player.is_rating_locked;
}
const amendedPlayers = calculateElos(participants);
await sql.transaction(async (tx) => {
for (let i in amendedPlayers) {
await tx`
INSERT INTO match_players(match_id, player_id, standing, elo_change)
VALUES (${newMatchId.raw},
${amendedPlayers[i].playerId.raw},
${amendedPlayers[i].standing},
${amendedPlayers[i].isRatingLocked ? 0 : amendedPlayers[i].eloChange})`;
if (amendedPlayers[i].isRatingLocked) {
continue;
}
await tx`UPDATE players SET elo=${amendedPlayers[i].elo} WHERE id=${amendedPlayers[i].playerId.raw}`;
}
});
return await this.get(newMatchId);
}
async get(id: MatchId, claims?: Claims): Promise<Match> {
const dbResult = await sql`
SELECT m.id as match_id,
m.owning_user_id as owner_id,
g.id as game_id,
g.name as game_name,
p.id as player_id,
p.name as player_name,
p.elo as elo,
mp.standing as standing,
mp.elo_change as elo_change
FROM matches m
LEFT JOIN games g ON g.id = m.game_id
LEFT JOIN match_players mp ON mp.match_id = m.id
LEFT JOIN players p ON p.id = mp.player_id
WHERE m.id = ${id.raw}`;
if (
claims &&
!(
claims.test(Claims.ADMIN) ||
(claims.test(Claims.MATCHES.OWNED.READ) && dbResult?.[0]?.owner_id === claims?.userId?.raw) ||
(claims.test(Claims.MATCHES.PARTICIPANT.READ) &&
dbResult?.some((x: any) => x.player_id === claims?.userId?.raw))
)
) {
throw new UnauthorizedError();
}
const matchData = dbResult?.find((x: any) => x.match_id);
if (!matchData?.match_id) {
throw new NotFoundError('No matching match exists');
}
return new Match(
MatchId.fromID(matchData?.match_id),
GameId.fromID(matchData?.game_id),
orderBy(
dbResult
.filter((x: any) => x.player_id)
.map(
(x: any) =>
new MatchParticipant(
PlayerId.fromID(x.player_id),
parseInt(x.standing),
parseInt(x.elo_change),
undefined,
parseInt(x.elo),
),
),
'standing',
'asc',
),
UserId.fromID(dbResult?.[0]?.owner_id),
);
}
async drop(id: MatchId, claims?: Claims): Promise<void> {
const match = await this.get(id);
if (
claims &&
!(claims.test(Claims.ADMIN) || (claims.test(Claims.MATCHES.OWNED.DELETE) && match.owner === claims?.userId))
) {
throw new UnauthorizedError();
}
await sql.transaction(async (tx) => {
await tx`DELETE
FROM match_players
WHERE match_id = ${id.raw}`;
await tx`DELETE
FROM matches
WHERE id = ${id.raw}`;
});
return;
}
async removePlayer(matchId: MatchId, participantId: UserId | PlayerId): Promise<void> {
let playerId: PlayerId;
if (participantId instanceof PlayerId) {
playerId = participantId;
} else {
playerId = (await orm.users.get(participantId))?.playerId;
if (!playerId) {
throw new BadRequestError('User is not a participant');
}
}
const player = await orm.players.get(playerId);
await sql.transaction(async (tx) => {
const eloRefund = parseInt(
(
await tx`SELECT elo_change FROM public.match_players WHERE match_id=${matchId.raw} AND player_id = ${playerId.raw}`
)?.[0]?.elo_change ?? 0,
);
await tx`DELETE FROM match_players WHERE match_id=${matchId.raw} AND player_id=${playerId.raw}`;
if (!player.isRatingLocked) {
await tx`UPDATE players SET elo=${player.elo - eloRefund} WHERE id=${playerId.raw}`;
}
});
return;
}
}

View File

@@ -3,6 +3,9 @@ import { UsersOrm } from './user';
import { PlayersOrm } from './players'; import { PlayersOrm } from './players';
import { GamesOrm } from './games'; import { GamesOrm } from './games';
import { InvitesOrm } from './invites'; import { InvitesOrm } from './invites';
import { CollectionsOrm } from './collections';
import { MatchOrm } from './matches';
import { CircleOrm } from './circles';
class Orm { class Orm {
readonly claims: ClaimsOrm = new ClaimsOrm(); readonly claims: ClaimsOrm = new ClaimsOrm();
@@ -10,6 +13,9 @@ class Orm {
readonly players: PlayersOrm = new PlayersOrm(); readonly players: PlayersOrm = new PlayersOrm();
readonly games: GamesOrm = new GamesOrm(); readonly games: GamesOrm = new GamesOrm();
readonly invites: InvitesOrm = new InvitesOrm(); 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(); export const orm = new Orm();

View File

@@ -3,17 +3,18 @@ import { sql } from 'bun';
import { first } from 'lodash'; import { first } from 'lodash';
import { NotFoundError, UnauthorizedError } from '../utilities/errors'; import { NotFoundError, UnauthorizedError } from '../utilities/errors';
import { orm } from './orm'; import { orm } from './orm';
import { SecureId, UpdatePlayerRequest } from '../utilities/requestModels'; import { UpdatePlayerRequest } from '../utilities/requestModels';
import { PlayerId } from '../utilities/secureIds';
export class Player { export class Player {
id: SecureId; id: PlayerId;
name: string; name: string;
elo: number; elo: number;
isRatingLocked: boolean; isRatingLocked: boolean;
canBeMultiple: boolean; canBeMultiple: boolean;
constructor(input: { constructor(input: {
id: SecureId; id: PlayerId;
name: string; name: string;
elo?: number; elo?: number;
isRatingLocked?: boolean; isRatingLocked?: boolean;
@@ -28,49 +29,50 @@ export class Player {
} }
export class PlayersOrm { export class PlayersOrm {
async create(model: { name: string }, claims?: Claims): Promise<Player> { async create(model: { name: string }): Promise<Player> {
await sql`INSERT INTO players (name) await sql`INSERT INTO players (name)
VALUES (${model.name})`; VALUES (${model.name})`;
const newPlayerId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string; const newRecordId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string;
return await this.get(SecureId.fromID(newPlayerId)); return await this.get(PlayerId.fromID(newRecordId));
} }
async get(id: SecureId, claims?: Claims): Promise<Player> { async get(id: PlayerId, claims?: Claims): Promise<Player> {
if (!(Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.PLAYERS.OTHER.READ, claims))) { if(claims) {
throw new UnauthorizedError();
} else if (Claims.test(Claims.PLAYERS.SELF.READ, claims) && claims?.userId) {
const user = await orm.users.get(claims.userId); const user = await orm.users.get(claims.userId);
if (id.raw !== user.playerId.raw) { if(!(claims.test(Claims.ADMIN, Claims.PLAYERS.OTHER.READ) ||
claims.test(Claims.PLAYERS.SELF.READ) && id === user.playerId)) {
throw new UnauthorizedError(); throw new UnauthorizedError();
} }
} }
const dbResult: any = first(
const record: any = first(
await sql`SELECT * await sql`SELECT *
FROM players FROM players
WHERE id = ${id.raw} WHERE id = ${id.raw}
LIMIT 1`, LIMIT 1`,
); );
if (!dbResult) { if (!record) {
throw new NotFoundError('No matching player exists'); throw new NotFoundError('No matching player exists');
} }
return new Player({ return new Player({
id: SecureId.fromID(dbResult.id), id: PlayerId.fromID(record.id),
name: dbResult.name, name: record.name,
elo: parseInt(dbResult.elo), elo: parseInt(record.elo),
isRatingLocked: dbResult.is_rating_locked, isRatingLocked: record.is_rating_locked,
canBeMultiple: dbResult.can_be_multiple, canBeMultiple: record.can_be_multiple,
}); });
} }
async list(claims?: Claims): Promise<Player[]> { async list(claims?: Claims): Promise<Player[]> {
if (!claims || Claims.test(Claims.ADMIN, claims)) { if (!claims || claims.test(Claims.ADMIN)) {
return (await sql`SELECT * FROM players`).map( return (await sql`SELECT * FROM players`).map(
(x: { id: string; name: string; elo: string; is_rating_locked: boolean; can_be_multiple: boolean }) => (x: { id: string; name: string; elo: string; is_rating_locked: boolean; can_be_multiple: boolean }) =>
new Player({ new Player({
id: SecureId.fromID(x.id), id: PlayerId.fromID(x.id),
name: x.name, name: x.name,
elo: parseInt(x.elo), elo: parseInt(x.elo),
isRatingLocked: x.is_rating_locked, isRatingLocked: x.is_rating_locked,
@@ -79,7 +81,7 @@ export class PlayersOrm {
); );
} }
if (!Claims.test(Claims.PLAYERS.OTHER.READ, claims)) { if (!claims.test(Claims.PLAYERS.OTHER.READ)) {
throw new UnauthorizedError(); throw new UnauthorizedError();
} }
@@ -98,7 +100,7 @@ export class PlayersOrm {
).map( ).map(
(x: { id: string; name: string; elo: string; is_rating_locked: boolean; can_be_multiple: boolean }) => (x: { id: string; name: string; elo: string; is_rating_locked: boolean; can_be_multiple: boolean }) =>
new Player({ new Player({
id: SecureId.fromID(x.id), id: PlayerId.fromID(x.id),
name: x.name, name: x.name,
elo: parseInt(x.elo), elo: parseInt(x.elo),
isRatingLocked: x.is_rating_locked, isRatingLocked: x.is_rating_locked,
@@ -107,36 +109,36 @@ export class PlayersOrm {
); );
} }
async update(id: SecureId, patch: UpdatePlayerRequest, claims?: Claims): Promise<Player> { async update(id: PlayerId, patch: UpdatePlayerRequest, claims?: Claims): Promise<Player> {
if (!(Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.PLAYERS.OTHER.UPDATE, claims))) { if(claims) {
throw new UnauthorizedError();
} else if (Claims.test(Claims.PLAYERS.SELF.UPDATE, claims) && claims?.userId) {
const user = await orm.users.get(claims.userId); const user = await orm.users.get(claims.userId);
if (id.raw !== user.playerId.raw) { if(!(claims.test(Claims.ADMIN, Claims.PLAYERS.OTHER.UPDATE) ||
(claims.test(Claims.PLAYERS.SELF.UPDATE) && id === user.playerId)
)) {
throw new UnauthorizedError(); throw new UnauthorizedError();
} }
} }
const playerToUpdate = await this.get(id); const player = await this.get(id);
playerToUpdate.name = patch.name ?? playerToUpdate.name; player.name = patch.name ?? player.name;
playerToUpdate.isRatingLocked = patch.isRatingLocked ?? playerToUpdate.isRatingLocked; player.isRatingLocked = patch.isRatingLocked ?? player.isRatingLocked;
playerToUpdate.canBeMultiple = patch.canBeMultiple ?? playerToUpdate.canBeMultiple; player.canBeMultiple = patch.canBeMultiple ?? player.canBeMultiple;
await sql`UPDATE players await sql`UPDATE players
SET name=${playerToUpdate.name}, SET name=${player.name},
is_rating_locked=${playerToUpdate.isRatingLocked}, is_rating_locked=${player.isRatingLocked},
can_be_multiple=${playerToUpdate.canBeMultiple} can_be_multiple=${player.canBeMultiple}
WHERE id = ${id.raw}`; WHERE id = ${id.raw}`;
return await this.get(id); return await this.get(id);
} }
async drop(id: SecureId, claims?: Claims): Promise<void> { async drop(id: PlayerId, claims?: Claims): Promise<void> {
if (!(Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.PLAYERS.OTHER.DELETE, claims))) { if(claims) {
throw new UnauthorizedError();
} else if (Claims.test(Claims.PLAYERS.SELF.DELETE, claims) && claims?.userId) {
const user = await orm.users.get(claims.userId); const user = await orm.users.get(claims.userId);
if (id.raw !== user.playerId.raw) { if(!(claims.test(Claims.ADMIN, Claims.PLAYERS.OTHER.DELETE) ||
(claims.test(Claims.PLAYERS.SELF.DELETE) && id === user.playerId)
)) {
throw new UnauthorizedError(); throw new UnauthorizedError();
} }
} }

View File

@@ -3,17 +3,18 @@ import { sql } from 'bun';
import { first } from 'lodash'; import { first } from 'lodash';
import argon2 from 'argon2'; import argon2 from 'argon2';
import { BadRequestError, NotFoundError, UnauthorizedError } from '../utilities/errors'; import { BadRequestError, NotFoundError, UnauthorizedError } from '../utilities/errors';
import { SecureId, UpdateUserRequest } from '../utilities/requestModels'; import { UpdateUserRequest } from '../utilities/requestModels';
import { orm } from './orm'; import { orm } from './orm';
import { PlayerId, UserId } from '../utilities/secureIds';
export class User { export class User {
id: SecureId; id: UserId;
playerId: SecureId; playerId: PlayerId;
email: string; email: string;
isAdmin: boolean; isAdmin: boolean;
isActive: 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.id = id;
this.playerId = playerId; this.playerId = playerId;
this.email = email; this.email = email;
@@ -23,10 +24,15 @@ export class User {
} }
export class UsersOrm { export class UsersOrm {
async create( async create({
{ email, password, playerId }: { email: string; password: string; playerId: SecureId }, email,
claims?: Claims, password,
): Promise<User> { playerId,
}: {
email: string;
password: string;
playerId: PlayerId;
}): Promise<User> {
const existingUser: any = first( const existingUser: any = first(
await sql`SELECT id await sql`SELECT id
FROM users FROM users
@@ -41,29 +47,29 @@ export class UsersOrm {
const passwordHash = await argon2.hash(password); const passwordHash = await argon2.hash(password);
await sql`INSERT INTO users (email, pass_hash, player_id) await sql`INSERT INTO users (email, pass_hash, player_id)
VALUES (${email}, ${passwordHash}, ${playerId.raw})`; VALUES (${email}, ${passwordHash}, ${playerId.raw})`;
const newUserId: SecureId = SecureId.fromID((first(await sql`SELECT lastval();`) as any)?.lastval as string); const newRecordId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string;
await sql.transaction(async (tx) => { await sql.transaction(async (tx) => {
for (let i in defaultClaims) { for (let i in defaultClaims) {
await tx`INSERT INTO user_claims (user_id, claim_id) await tx`INSERT INTO user_claims (user_id, claim_id)
VALUES (${newUserId.raw}, ${defaultClaims[i]})`; VALUES (${newRecordId}, ${defaultClaims[i]})`;
} }
}); });
return await this.get(newUserId); return await this.get(UserId.fromID(newRecordId));
} }
async get(id: SecureId, claims?: Claims): Promise<User> { async get(id: UserId, claims?: Claims): Promise<User> {
if ( if (
claims &&
!( !(
Claims.test(Claims.ADMIN, claims) || claims.test(Claims.ADMIN, Claims.USERS.OTHER.READ) ||
Claims.test(Claims.USERS.OTHER.READ, claims) || (claims.test(Claims.USERS.SELF.READ) && id === claims?.userId)
(Claims.test(Claims.USERS.SELF.READ, claims) && id === claims?.userId)
) )
) { ) {
throw new UnauthorizedError(); throw new UnauthorizedError();
} }
const dbResult: any = first( const record: any = first(
await sql`SELECT * await sql`SELECT *
FROM users FROM users
WHERE id = ${id.raw} WHERE id = ${id.raw}
@@ -71,49 +77,111 @@ export class UsersOrm {
LIMIT 1`, LIMIT 1`,
); );
if (!dbResult) { if (!record) {
throw new NotFoundError('No matching user exists'); throw new NotFoundError('No matching user exists');
} }
return new User( return new User(
SecureId.fromID(dbResult.id), UserId.fromID(record.id),
SecureId.fromID(dbResult.player_id), PlayerId.fromID(record.player_id),
dbResult.email, record.email,
dbResult.is_admin, record.is_admin,
); );
} }
async update(id: SecureId, patch: UpdateUserRequest, claims?: Claims): Promise<User> { async getByPlayer(id: PlayerId, claims?: Claims): Promise<User> {
const record: any = first(
await sql`SELECT *
FROM users
WHERE player_id = ${id.raw}
AND is_active = true
LIMIT 1`,
);
if (!record) {
throw new NotFoundError('No matching user exists');
}
if ( if (
claims &&
!( !(
Claims.test(Claims.ADMIN, claims) || claims.test(Claims.ADMIN, Claims.USERS.OTHER.READ) ||
Claims.test(Claims.USERS.OTHER.UPDATE, claims) || (claims.test(Claims.USERS.SELF.READ) && record.id === claims?.userId.raw)
(Claims.test(Claims.USERS.SELF.UPDATE, claims) && id === claims?.userId)
) )
) { ) {
throw new UnauthorizedError(); throw new UnauthorizedError();
} }
const userToUpdate = await this.get(id); return new User(
if (Claims.test(Claims.ADMIN, claims)) { UserId.fromID(record.id),
userToUpdate.isActive = patch.isActive ?? userToUpdate.isActive; PlayerId.fromID(record.player_id),
userToUpdate.isAdmin = patch.isAdmin ?? userToUpdate.isAdmin; record.email,
record.is_admin,
);
}
async getByEmail(email: string, claims?: Claims): Promise<User> {
const record: any = first(
await sql`SELECT *
FROM users
WHERE email = ${email}
AND is_active = true
LIMIT 1`,
);
if (!record) {
throw new NotFoundError('No matching user exists');
}
if (
claims &&
!(
claims.test(Claims.ADMIN, Claims.USERS.OTHER.READ) ||
(claims.test(Claims.USERS.SELF.READ) && record.id === claims?.userId.raw)
)
) {
throw new UnauthorizedError();
}
return new User(
UserId.fromID(record.id),
PlayerId.fromID(record.player_id),
record.email,
record.is_admin,
);
}
async update(id: UserId, patch: UpdateUserRequest, claims?: Claims): Promise<User> {
if (
claims &&
!(
claims.test(Claims.ADMIN, Claims.USERS.OTHER.UPDATE) ||
(claims.test(Claims.USERS.SELF.UPDATE) && id === claims?.userId)
)
) {
throw new UnauthorizedError();
}
const user = await this.get(id);
if (!claims || claims.test(Claims.ADMIN)) {
user.isActive = patch.isActive ?? user.isActive;
user.isAdmin = patch.isAdmin ?? user.isAdmin;
} }
await sql`UPDATE users await sql`UPDATE users
SET is_active=${userToUpdate.isActive}, SET is_active=${user.isActive},
is_admin=${userToUpdate.isAdmin} is_admin=${user.isAdmin}
WHERE id = ${id.raw}`; WHERE id = ${id.raw}`;
return await this.get(id); return await this.get(id);
} }
async drop(id: SecureId, claims?: Claims): Promise<void> { async drop(id: UserId, claims?: Claims): Promise<void> {
if ( if (
claims &&
!( !(
Claims.test(Claims.ADMIN, claims) || claims.test(Claims.ADMIN, Claims.USERS.OTHER.DELETE) ||
Claims.test(Claims.USERS.OTHER.DELETE, claims) || (claims.test(Claims.USERS.SELF.DELETE) && id === claims?.userId)
(Claims.test(Claims.USERS.SELF.DELETE, claims) && id === claims?.userId)
) )
) { ) {
throw new UnauthorizedError(); throw new UnauthorizedError();
@@ -133,49 +201,41 @@ export class UsersOrm {
return; return;
} }
async verifyCredentials( async verifyCredentials(email: string, password: string): Promise<{ userId: UserId; refreshCount: string } | null> {
email: string, const record: any = first(
password: string,
): Promise<{ userId: SecureId; refreshCount: string } | null> {
const dbResult: any = first(
await sql`SELECT * await sql`SELECT *
FROM users FROM users
WHERE email = ${email} WHERE email = ${email}
AND is_active = true AND is_active = true
limit 1`, limit 1`,
); );
if (!dbResult) { if (!record) {
throw new UnauthorizedError(); throw new UnauthorizedError();
} }
if (!(await argon2.verify(dbResult.pass_hash, password))) { if (!(await argon2.verify(record.pass_hash, password))) {
return null; return null;
} }
return { return {
userId: SecureId.fromID(dbResult.id), userId: UserId.fromID(record.id),
refreshCount: dbResult.refresh_count, refreshCount: record.refresh_count,
}; };
} }
async verifyRefreshCount(id: SecureId, refreshCount: string): Promise<boolean> { async verifyRefreshCount(id: UserId, refreshCount: string): Promise<boolean> {
const dbResult: any = first( const record: any = first(
await sql`SELECT * await sql`SELECT *
FROM users FROM users
WHERE id = ${id.raw} WHERE id = ${id.raw}
LIMIT 1`, LIMIT 1`,
); );
return dbResult.refresh_count === refreshCount; return record.refresh_count === refreshCount;
} }
async changePassword( async changePassword(id: UserId, oldPassword: string | null, newPassword: string, claims?: Claims): Promise<void> {
id: SecureId, const isAdmin = claims?.test(Claims.ADMIN) ?? true;
oldPassword: string | null, if (!(isAdmin || (claims?.test(Claims.USERS.SELF.UPDATE) && id === claims?.userId))) {
newPassword: string,
claims?: Claims,
): Promise<void> {
const isAdmin = Claims.test(Claims.ADMIN, claims);
if (!(isAdmin || (Claims.test(Claims.USERS.SELF.UPDATE, claims) && id === claims?.userId))) {
throw new UnauthorizedError(); throw new UnauthorizedError();
} }
@@ -183,14 +243,14 @@ export class UsersOrm {
throw new BadRequestError('Password is required'); throw new BadRequestError('Password is required');
} }
const dbUser: any = first( const record: any = first(
await sql`SELECT * await sql`SELECT *
FROM users FROM users
WHERE id = ${id.raw} WHERE id = ${id.raw}
LIMIT 1`, LIMIT 1`,
); );
if (!isAdmin && !(await argon2.verify(dbUser.pass_hash, oldPassword as string))) { if (!isAdmin && !(await argon2.verify(record.pass_hash, oldPassword as string))) {
throw new UnauthorizedError(); throw new UnauthorizedError();
} }

View File

@@ -1,22 +1,20 @@
import { guard, unwrapMethod } from '../utilities/guard'; import { guard, unwrapMethod } from '../utilities/guard';
import auth from '../endpoints/auth'; import auth from '../endpoints/auth';
import { OkResponse } from '../utilities/responseHelper';
import { Claims } from '../orm/claims'; import { Claims } from '../orm/claims';
export default { export default {
'/api/auth/login': { login: {
POST: unwrapMethod(auth.login), POST: unwrapMethod(auth.login),
}, },
'/api/auth/token': { token: {
GET: unwrapMethod(auth.token), GET: unwrapMethod(auth.token),
}, },
'/api/auth/logout': { logout: {
POST: unwrapMethod(auth.logout), POST: unwrapMethod(auth.logout),
}, },
'/api/auth/changePassword/:id': { changePassword: {
PATCH: guard(auth.changePassword, [Claims.ADMIN, Claims.USERS.SELF.UPDATE]), ':id': {
}, PATCH: guard(auth.changePassword, Claims.ADMIN, Claims.USERS.SELF.UPDATE),
'/api/auth/test': { },
GET: () => new OkResponse(),
}, },
}; };

32
src/routes/circles.ts Normal file
View File

@@ -0,0 +1,32 @@
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),
},
},
};

22
src/routes/collections.ts Normal file
View File

@@ -0,0 +1,22 @@
import { guard } from '../utilities/guard';
import { Claims } from '../orm/claims';
import collections from '../endpoints/collections';
export default {
'POST': guard(collections.create, Claims.ADMIN, Claims.COLLECTIONS.CREATE),
':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),
add: {
POST: guard(collections.addGame, Claims.ADMIN, Claims.COLLECTIONS.OWNED.GAME.ADD),
},
remove: {
POST: guard(collections.removeGame, Claims.ADMIN, Claims.COLLECTIONS.OWNED.GAME.REMOVE),
},
},
'list': {
variants: [':pageSize/:page', ':page'],
GET: guard(collections.list, Claims.ADMIN, Claims.COLLECTIONS.OWNED.LIST),
},
};

View File

@@ -1,17 +0,0 @@
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]),
},
};

18
src/routes/games.ts Normal file
View File

@@ -0,0 +1,18 @@
import { guard } from '../utilities/guard';
import { Claims } from '../orm/claims';
import games from '../endpoints/games';
export default {
'POST': guard(games.create, Claims.ADMIN, Claims.GAMES.CREATE),
':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),
},
'search': {
':query': {
variants: [':pageSize/:page', ':page'],
GET: guard(games.query, Claims.ADMIN, Claims.GAMES.READ),
},
},
};

View File

@@ -1,12 +0,0 @@
import { guard, unwrapMethod } from '../utilities/guard';
import { Claims } from '../orm/claims';
import invite from '../endpoints/invite';
export default {
'/api/invite': {
POST: guard(invite.create, [Claims.ADMIN, Claims.USERS.INVITE]),
},
'/api/invite/accept': {
POST: unwrapMethod(invite.accept),
},
};

10
src/routes/invites.ts Normal file
View File

@@ -0,0 +1,10 @@
import { guard, unwrapMethod } from '../utilities/guard';
import { Claims } from '../orm/claims';
import invite from '../endpoints/invites';
export default {
POST: guard(invite.create, Claims.ADMIN, Claims.USERS.INVITE),
accept: {
POST: unwrapMethod(invite.accept),
},
};

14
src/routes/matches.ts Normal file
View File

@@ -0,0 +1,14 @@
import { guard } from '../utilities/guard';
import matches from '../endpoints/matches';
import { Claims } from '../orm/claims';
export default {
'POST': guard(matches.create, Claims.ADMIN, Claims.MATCHES.CREATE),
':id': {
GET: guard(matches.get, Claims.ADMIN, Claims.MATCHES.OWNED.READ, Claims.MATCHES.PARTICIPANT.READ),
DELETE: guard(matches.drop, Claims.ADMIN, Claims.MATCHES.OWNED.DELETE, Claims.USERS.SELF.UPDATE),
leave: {
POST: guard(matches.leave, Claims.MATCHES.PARTICIPANT.LEAVE),
},
},
};

View File

@@ -1,17 +0,0 @@
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]),
},
'/api/player/list': {
GET: guard(player.list, [Claims.ADMIN, Claims.PLAYERS.OTHER.READ]),
},
};

16
src/routes/players.ts Normal file
View File

@@ -0,0 +1,16 @@
import { guard } from '../utilities/guard';
import { Claims } from '../orm/claims';
import player from '../endpoints/players';
export default {
'POST': guard(player.create, Claims.ADMIN, Claims.PLAYERS.CREATE),
':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),
},
'list': {
variants: [':pageSize/:page', ':page'],
GET: guard(player.list, Claims.ADMIN, Claims.PLAYERS.OTHER.READ),
},
};

View File

@@ -1,20 +0,0 @@
import { guard, unwrap, unwrapMethod } 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/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]),
DELETE: guard(user.drop, [Claims.ADMIN, Claims.USERS.OTHER.UPDATE, Claims.USERS.SELF.UPDATE]),
},
};

12
src/routes/users.ts Normal file
View File

@@ -0,0 +1,12 @@
import { guard } from '../utilities/guard';
import user from '../endpoints/users';
import { Claims } from '../orm/claims';
export default {
'POST': guard(user.create, Claims.ADMIN, Claims.USERS.CREATE),
':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),
},
};

View File

@@ -1,5 +1,5 @@
import { expect, test } from 'bun:test'; import { expect, test } from 'bun:test';
import user from '../endpoints/user'; import user from '../endpoints/users';
import { UnwrappedRequest } from '../utilities/guard'; import { UnwrappedRequest } from '../utilities/guard';
import { Claims } from '../orm/claims'; import { Claims } from '../orm/claims';
import { orm } from '../orm/orm'; import { orm } from '../orm/orm';

View File

@@ -29,49 +29,41 @@ export class ClaimDefinition {
}; };
public static readonly CIRCLES = { public static readonly CIRCLES = {
PUBLIC: { PUBLIC: {
READ: 'CIRCLES_OWNED_READ',
CREATE: 'CIRCLES_PUBLIC_CREATE', CREATE: 'CIRCLES_PUBLIC_CREATE',
JOIN: 'CIRCLES_PUBLIC_JOIN', JOIN: 'CIRCLES_PUBLIC_JOIN',
USERS: {
ADD: 'CIRCLES_PUBLIC_USER_ADD',
LIST: 'CIRCLES_PUBLIC_USER_LIST',
INVITE: 'CIRCLES_PUBLIC_USER_INVITE',
},
COMMENTS: { COMMENTS: {
ADD: 'CIRCLES_PUBLIC_COMMENTS_ADD', ADD: 'CIRCLES_PUBLIC_COMMENTS_ADD',
DELETE: 'CIRCLES_PUBLIC_COMMENTS_DELETE', },
USERS: {
INVITE: 'CIRCLES_PUBLIC_USER_INVITE',
LIST: 'CIRCLES_PUBLIC_USER_LIST',
}, },
}, },
PRIVATE: { PRIVATE: {
READ: 'CIRCLES_PRIVATE_READ',
READ_IF_MEMBER: 'CIRCLES_PRIVATE_READ_IF_MEMBER',
CREATE: 'CIRCLES_PRIVATE_CREATE', CREATE: 'CIRCLES_PRIVATE_CREATE',
USERS: { COMMENTS: {
INVITE: 'CIRCLES_PRIVATE_USER_INVITE', ADD: 'CIRCLES_PRIVATE_COMMENTS_ADD',
}, },
}, },
OWNED: { OWNED: {
READ: 'CIRCLES_OWNED_READ', READ: 'CIRCLES_OWNED_READ',
UPDATE: 'CIRCLES_OWNED_UPDATE', UPDATE: 'CIRCLES_OWNED_UPDATE',
DELETE: 'CIRCLES_OWNED_DELETE', DELETE: 'CIRCLES_OWNED_DELETE',
USERS: { PLAYERS: {
ADD: 'CIRCLES_OWNED_USER_ADD', ADD: 'CIRCLES_OWNED_USER_ADD',
LIST: 'CIRCLES_OWNED_USER_LIST', LIST: 'CIRCLES_OWNED_USER_LIST',
USERS: { },
INVITE: 'CIRCLES_OWNED_USER_INVITE', USERS: {
}, INVITE: 'CIRCLES_OWNED_USER_INVITE',
}, },
COMMENTS: { COMMENTS: {
ADD: 'CIRCLES_OWNED_COMMENTS_ADD', ADD: 'CIRCLES_OWNED_COMMENTS_ADD',
DELETE: 'CIRCLES_OWNED_COMMENTS_DELETE', 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 = { public static readonly GAMES = {
CREATE: 'GAMES_CREATE', CREATE: 'GAMES_CREATE',
@@ -82,24 +74,24 @@ export class ClaimDefinition {
}; };
public static readonly MATCHES = { public static readonly MATCHES = {
CREATE: 'MATCHES_CREATE', CREATE: 'MATCHES_CREATE',
COMMENTS: {
ADD: 'MATCHES_UNOWNED_COMMENTS_ADD',
},
OWNED: { OWNED: {
READ: 'MATCHES_OWNED_READ', READ: 'MATCHES_OWNED_READ',
UPDATE: 'MATCHES_OWNED_UPDATE',
DELETE: 'MATCHES_OWNED_DELETE', DELETE: 'MATCHES_OWNED_DELETE',
COMMENTS: { COMMENTS: {
ADD: 'MATCHES_OWNED_COMMENTS_ADD', ADD: 'MATCHES_OWNED_COMMENTS_ADD',
DELETE: 'MATCHES_OWNED_COMMENTS_DELETE', DELETE: 'MATCHES_OWNED_COMMENTS_DELETE',
}, },
}, },
UNOWNED: { PARTICIPANT: {
READ: 'MATCHES_UNOWNED_READ', READ: 'MATCHES_PARTICIPANT_READ',
UPDATE: 'MATCHES_UNOWNED_UPDATE', LEAVE: 'MATCHES_LEAVE',
DELETE: 'MATCHES_UNOWNED_DELETE',
COMMENTS: { COMMENTS: {
ADD: 'MATCHES_UNOWNED_COMMENTS_ADD', ADD: 'MATCHES_PARTICIPANT_COMMENTS_ADD',
DELETE: 'MATCHES_UNOWNED_COMMENTS_DELETE',
}, },
}, }
}; };
public static readonly COLLECTIONS = { public static readonly COLLECTIONS = {
CREATE: 'COLLECTIONS_CREATE', CREATE: 'COLLECTIONS_CREATE',
@@ -107,6 +99,11 @@ export class ClaimDefinition {
READ: 'COLLECTIONS_OWNED_READ', READ: 'COLLECTIONS_OWNED_READ',
UPDATE: 'COLLECTIONS_OWNED_UPDATE', UPDATE: 'COLLECTIONS_OWNED_UPDATE',
DELETE: 'COLLECTIONS_OWNED_DELETE', DELETE: 'COLLECTIONS_OWNED_DELETE',
LIST: 'COLLECTIONS_OWNED_LIST',
GAME: {
ADD: 'COLLECTIONS_OWNED_GAME_ADD',
REMOVE: 'COLLECTIONS_OWNED_GAME_REMOVE',
},
COMMENTS: { COMMENTS: {
DELETE: 'COLLECTIONS_OWNED_COMMENTS_DELETE', DELETE: 'COLLECTIONS_OWNED_COMMENTS_DELETE',
}, },

View File

@@ -1,32 +1,26 @@
import { orderBy } from 'lodash'; import { orderBy } from 'lodash';
import { MatchParticipant } from '../orm/matches';
interface GamePlayer { export function calculateElos(players: MatchParticipant[], provisionalPeriod: number = 1): MatchParticipant[] {
id: string; const orderedResults = orderBy(players, (x:any) => parseInt(x.standing ?? 0), 'asc');
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 i = 0; i < orderedResults.length - 1; i++) {
for (let j = i + 1; j < orderedResults.length; j++) { for (let j = i + 1; j < orderedResults.length; j++) {
const challengerResults = calculateEloChange(orderedResults[i].elo, orderedResults[j].elo); const challengerResults = calculateEloChange(
orderedResults[i].elo,
orderedResults[j].elo,
orderedResults[i].standing === orderedResults[j].standing,
);
orderedResults[i].eloChange = orderedResults[i].eloChange =
(orderedResults[i].eloChange ?? 0) + (orderedResults[i].eloChange ?? 0) +
challengerResults.winnerChange * Math.min(1, orderedResults[j].gamesPlayed / provisionalPeriod); challengerResults.winnerChange *
Math.min(1, ((orderedResults[j].gamesPlayed as number) + 1) / provisionalPeriod);
orderedResults[j].eloChange = orderedResults[j].eloChange =
(orderedResults[j].eloChange ?? 0) + (orderedResults[j].eloChange ?? 0) +
challengerResults.loserChange * Math.min(1, orderedResults[i].gamesPlayed / provisionalPeriod); challengerResults.loserChange *
Math.min(1, ((orderedResults[i].gamesPlayed as number) + 1) / provisionalPeriod);
} }
} }
return { return orderedResults;
players: orderedResults,
};
} }
interface EloResult { interface EloResult {

View File

@@ -21,3 +21,8 @@ export class NotFoundError extends Error {
super(message); super(message);
} }
} }
export class NotImplementedError extends Error {
constructor(message?: string | undefined) {
super(message);
}
}

View File

@@ -2,17 +2,14 @@ import { BunRequest as Request } from 'bun';
import jwt, { TokenExpiredError } from 'jsonwebtoken'; import jwt, { TokenExpiredError } from 'jsonwebtoken';
import { ErrorResponse, UnauthorizedResponse } from './responseHelper'; import { ErrorResponse, UnauthorizedResponse } from './responseHelper';
import { Claims } from '../orm/claims'; import { Claims } from '../orm/claims';
import HashIds from 'hashids';
export const hashIds = new HashIds(process.env.JWT_SECRET, 4);
export function guardRedirect( export function guardRedirect(
method: (request: UnwrappedRequest<any>) => Promise<Response> | Response, method: (request: UnwrappedRequest<any>) => Promise<Response> | Response,
redirectMethod: Function, redirectMethod: Function,
guardedClaims: string[], ...guardedClaims: string[]
) { ) {
try { try {
return guard(method, guardedClaims); return guard(method, ...guardedClaims);
} catch (e) { } catch (e) {
return redirectMethod(); return redirectMethod();
} }
@@ -20,19 +17,21 @@ export function guardRedirect(
export function guard( export function guard(
method: (request: UnwrappedRequest<any>) => Promise<Response> | Response, method: (request: UnwrappedRequest<any>) => Promise<Response> | Response,
guardedClaims: string[], ...guardedClaims: string[]
): (r: Request) => Promise<Response> { ): (r: Request) => Promise<Response> {
return async (request: Request): Promise<Response> => { return async (request: Request): Promise<Response> => {
const authHeader: string | null = const authHeader: string | null =
(request.headers.get('Authorization')?.replace(/^Bearer /, '') as string) ?? null; (request.headers.get('Authorization')?.replace(/^Bearer /, '') as string) ?? null;
try { try {
const userClaims: Claims = new Claims(jwt.verify(authHeader as string, process.env.JWT_SECRET_KEY as string) as any); const userClaims: Claims = new Claims(
if (!userClaims.claims.some((x: string): boolean => guardedClaims.includes(x))) { jwt.verify(authHeader as string, process.env.JWT_SECRET_KEY as string) as any,
);
if (!userClaims.userId.raw || !userClaims.claims.some((x: string): boolean => guardedClaims.includes(x))) {
return new UnauthorizedResponse('Unauthorized'); return new UnauthorizedResponse('Unauthorized');
} }
return method(await unwrap(request, userClaims)); return method(await unwrap(request, userClaims));
} catch (error: any) { } catch (error: any) {
console.log(error) console.log(error);
if (error instanceof TokenExpiredError) { if (error instanceof TokenExpiredError) {
return new UnauthorizedResponse(error.message); return new UnauthorizedResponse(error.message);
} }
@@ -69,6 +68,6 @@ export function unwrapMethod<T = {}>(
): (r: Request) => Promise<Response> { ): (r: Request) => Promise<Response> {
return async (request: Request) => { return async (request: Request) => {
const unwrappedRequest = await unwrap<T>(request); const unwrappedRequest = await unwrap<T>(request);
return await methodToUnwrap(unwrappedRequest); return methodToUnwrap(unwrappedRequest);
}; };
} }

View File

@@ -1,5 +1,3 @@
import { hashIds } from './guard';
export interface LoginRequest { export interface LoginRequest {
email: string; email: string;
password: string; password: string;
@@ -43,45 +41,32 @@ export interface UpdateGameRequest {
imagePath?: string; imagePath?: string;
bggId?: string; bggId?: string;
} }
export interface CreateCollectionRequest {
export class SecureId { name: string;
#hashedValue?: string; }
#secureValue?: string; export interface UpdateCollectionRequest {
get value(): string | undefined { name?: string;
return this.#hashedValue; }
} export interface GameToCollectionRequest {
set value(value: string) { gameId: string;
this.#hashedValue = value; }
this.#secureValue = hashIds.decode(value)?.toString(); export interface CreateMatchRequest {
} gameId: string;
get raw(): string | undefined { participants: { playerId: string; standing: number }[];
return this.#secureValue; }
} export interface CreateCircleRequest {
set raw(value: string) { name: string;
this.#hashedValue = hashIds.encode(value); isPublic: boolean;
this.#secureValue = value; imagePath?: string;
} colour: string;
constructor(id: { public?: string; secure?: string }) { }
if (id.public) { export interface UpdateCircleRequest {
this.value = id.public; name: string;
} else if (id.secure) { imagePath?: string;
this.raw = id.secure; colour: string;
} }
} export interface InviteToCircleRequest {
email?:string;
toJSON(): string | undefined { userId?:string;
return this.#hashedValue; playerId?:string;
}
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 });
}
} }

View File

@@ -1,5 +1,6 @@
import { BadRequestError, NotFoundError, UnauthorizedError } from './errors'; import { BadRequestError, NotFoundError, UnauthorizedError } from './errors';
import { isArray } from 'lodash'; import { clamp, isArray, isObject } from 'lodash';
import { UnwrappedRequest } from './guard';
export class ErrorResponse extends Response { export class ErrorResponse extends Response {
//@ts-ignore //@ts-ignore
@@ -37,16 +38,25 @@ export class NotFoundResponse extends Response {
} }
} }
export class PagedResponse extends Response {
//@ts-ignore
constructor(request: UnwrappedRequest, body: any[]) {
const pageSize = clamp(parseInt(request.params.pageSize ?? 100), 1, 100);
const page = Math.max(0, parseInt(request.params.page ?? 1) - 1);
return new OkResponse(body.slice(page * pageSize, page * pageSize + pageSize));
}
}
export class OkResponse extends Response { export class OkResponse extends Response {
// @ts-ignore // @ts-ignore
constructor(body?: Model | null) { constructor(body?: any) {
if (body) { if (body) {
return Response.json( return Response.json(
isArray(body) isObject(body) && !isArray(body)
? body ? {
: {
...body, ...body,
}, }
: body,
{ {
status: 200, status: 200,
headers: { headers: {

View File

@@ -0,0 +1,42 @@
export function buildRoute(
route: any,
currentPath: string = '',
): {
[x: string]: {
POST?: Function;
GET?: Function;
PUT?: Function;
DELETE?: Function;
};
} {
let returnValue: { [x: string]: any } = {};
const keys = Object.keys(route);
for (let i in keys) {
const key = keys[i];
if (key === 'POST' || key === 'GET' || key === 'PUT' || key === 'DELETE' || key === 'variants') {
continue;
}
returnValue = {
...returnValue,
...buildRoute(route[key], `${currentPath}/${key}`),
};
}
if (route.variants || route.POST || route.GET || route.PUT || route.DELETE) {
const variants: string[] = route.variants ?? [];
const endpointDefinition = {
POST: route.POST,
GET: route.GET,
PATCH: route.PATCH,
PUT: route.PUT,
DELETE: route.DELETE,
};
returnValue[currentPath] = endpointDefinition;
for (let key in variants) {
returnValue[`${currentPath}/${variants[key]}`] = endpointDefinition;
}
}
return returnValue;
}

169
src/utilities/secureIds.ts Normal file
View File

@@ -0,0 +1,169 @@
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 !== undefined) {
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<T extends SecureId>(
hash: string,
type?: { new (id: { public?: string; secure?: string }): T },
): SecureId {
const t = type ?? SecureId;
return new t({ public: hash });
}
public static fromID<T extends SecureId>(
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';
// 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) as UserId;
}
public static fromID(id: string): 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) as PlayerId;
}
public static fromID(id: string): 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) as InviteId;
}
public static fromID(id: string): 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) as GameId;
}
public static fromID(id: string): 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) as CollectionId;
}
public static fromID(id: string): 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) as MatchId;
}
public static fromID(id: string): 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) as CircleId;
}
public static fromID(id: string): CircleId {
return super.fromID(id, CircleId) as CircleId;
}
}