Compare commits
23 Commits
99c7bdc0fd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c4a37f13be | |||
| c23536b3ed | |||
| dee5b2429d | |||
| df60ad4552 | |||
| 872a79663b | |||
| 0fa00e6759 | |||
| 564ffe7c8c | |||
| 3464f32c46 | |||
| 3e9d61b8c6 | |||
| ed942060a2 | |||
| 407043c5be | |||
| afa1c13e13 | |||
| 59d2819750 | |||
| c276ee4e17 | |||
| 8b9615c14b | |||
| f81220f837 | |||
| 335f1821cd | |||
| 5742214115 | |||
| 98315db912 | |||
| 4a63f0507d | |||
| 0f6452fdd4 | |||
| 035397ea25 | |||
| 2996a2eb95 |
2
.env.dev
2
.env.dev
@@ -1,2 +0,0 @@
|
||||
DATABASE_URL=postgres://admin:iiyama12@192.168.1.166:5432/bgApp
|
||||
JWT_SECRET_KEY=MySecret
|
||||
@@ -1,2 +0,0 @@
|
||||
DATABASE_URL=postgres://admin:iiyama12@192.168.1.166:5432/bgAppTest
|
||||
JWT_SECRET_KEY=MySecret
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -3,4 +3,9 @@ node_modules/
|
||||
bun.lock
|
||||
package-lock.json
|
||||
.dockerignore
|
||||
bgapp
|
||||
./bgapp/
|
||||
.env.dev
|
||||
.env.test
|
||||
.env*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
10
.idea_old/.gitignore
vendored
Normal file
10
.idea_old/.gitignore
vendored
Normal 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/
|
||||
48
.idea_old/codeStyles/Project.xml
Normal file
48
.idea_old/codeStyles/Project.xml
Normal 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>
|
||||
5
.idea_old/codeStyles/codeStyleConfig.xml
Normal file
5
.idea_old/codeStyles/codeStyleConfig.xml
Normal 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
6
.idea_old/vcs.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
12
.prettierrc.json
Normal file
12
.prettierrc.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 120,
|
||||
"arrowParens": "always",
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"quoteProps": "consistent",
|
||||
"bracketSpacing": true,
|
||||
"vueIndentScriptAndStyle": false,
|
||||
"singleAttributePerLine": true
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
POST http://localhost:3000/api/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "jd",
|
||||
"password": "Foobar"
|
||||
}
|
||||
|
||||
###
|
||||
GET http://localhost:3000/api/auth/test
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxIiwiY2xhaW1zIjpbIkFETUlOIiwiVVNFUlNfQ1JFQVRFIiwiVVNFUlNfT1RIRVJfVVBEQVRFIiwiVVNFUlNfU0VMRl9SRUFEIiwiVVNFUlNfU0VMRl9VUERBVEUiLCJVU0VSU19PVEhFUl9SRUFEIiwiVVNFUlNfU0VMRl9ERUxFVEUiXSwiaWF0IjoxNzcxMDEyNDM5LCJleHAiOjE3NzEwOTg4Mzl9.__EHi3dO_uG1mtCVhmRqVKTkbTkOzM5Hu-4gMrIfu7I
|
||||
35
API Tests/BGApp/Auth/Login.yml
Normal file
35
API Tests/BGApp/Auth/Login.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
info:
|
||||
name: Login
|
||||
type: http
|
||||
seq: 1
|
||||
|
||||
http:
|
||||
method: POST
|
||||
url: "{{BASE_URL}}/{{SECTOR}}/login"
|
||||
headers:
|
||||
- name: Content-Type
|
||||
value: application/json
|
||||
body:
|
||||
type: json
|
||||
data: |-
|
||||
{
|
||||
"email":"james@dardry.com",
|
||||
"password":"Foobar"
|
||||
}
|
||||
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:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
28
API Tests/BGApp/Auth/Token.yml
Normal file
28
API Tests/BGApp/Auth/Token.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
info:
|
||||
name: Token
|
||||
type: http
|
||||
seq: 2
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{BASE_URL}}/{{SECTOR}}/token"
|
||||
headers:
|
||||
- name: Cookie
|
||||
value: "{{REFRESH_COOKIE}}"
|
||||
auth: inherit
|
||||
|
||||
runtime:
|
||||
scripts:
|
||||
- type: after-response
|
||||
code: |-
|
||||
function onResponse(res) {
|
||||
let data = res.getBody();
|
||||
bru.setEnvVar("BEARER_TOKEN", data.token);
|
||||
}
|
||||
onResponse(res);
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
9
API Tests/BGApp/Auth/folder.yml
Normal file
9
API Tests/BGApp/Auth/folder.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
info:
|
||||
name: Auth
|
||||
type: folder
|
||||
seq: 1
|
||||
|
||||
request:
|
||||
variables:
|
||||
- name: SECTOR
|
||||
value: auth
|
||||
30
API Tests/BGApp/Circle/Create.yml
Normal file
30
API Tests/BGApp/Circle/Create.yml
Normal 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
|
||||
20
API Tests/BGApp/Circle/Delete.yml
Normal file
20
API Tests/BGApp/Circle/Delete.yml
Normal 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
|
||||
20
API Tests/BGApp/Circle/Get.yml
Normal file
20
API Tests/BGApp/Circle/Get.yml
Normal 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
|
||||
24
API Tests/BGApp/Circle/Search.yml
Normal file
24
API Tests/BGApp/Circle/Search.yml
Normal 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
|
||||
28
API Tests/BGApp/Circle/Update.yml
Normal file
28
API Tests/BGApp/Circle/Update.yml
Normal 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
|
||||
10
API Tests/BGApp/Circle/folder.yml
Normal file
10
API Tests/BGApp/Circle/folder.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
info:
|
||||
name: Circle
|
||||
type: folder
|
||||
seq: 1
|
||||
|
||||
request:
|
||||
auth: inherit
|
||||
variables:
|
||||
- name: SECTOR
|
||||
value: circles
|
||||
28
API Tests/BGApp/Collections/Add game.yml
Normal file
28
API Tests/BGApp/Collections/Add game.yml
Normal 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
|
||||
26
API Tests/BGApp/Collections/Create.yml
Normal file
26
API Tests/BGApp/Collections/Create.yml
Normal 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
|
||||
20
API Tests/BGApp/Collections/Delete.yml
Normal file
20
API Tests/BGApp/Collections/Delete.yml
Normal 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
|
||||
20
API Tests/BGApp/Collections/Get.yml
Normal file
20
API Tests/BGApp/Collections/Get.yml
Normal 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
|
||||
22
API Tests/BGApp/Collections/List.yml
Normal file
22
API Tests/BGApp/Collections/List.yml
Normal 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
|
||||
28
API Tests/BGApp/Collections/Remove game.yml
Normal file
28
API Tests/BGApp/Collections/Remove game.yml
Normal 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
|
||||
28
API Tests/BGApp/Collections/Update.yml
Normal file
28
API Tests/BGApp/Collections/Update.yml
Normal 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
|
||||
10
API Tests/BGApp/Collections/folder.yml
Normal file
10
API Tests/BGApp/Collections/folder.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
info:
|
||||
name: Collections
|
||||
type: folder
|
||||
seq: 1
|
||||
|
||||
request:
|
||||
auth: inherit
|
||||
variables:
|
||||
- name: SECTOR
|
||||
value: collections
|
||||
26
API Tests/BGApp/Game/Create.yml
Normal file
26
API Tests/BGApp/Game/Create.yml
Normal 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
|
||||
20
API Tests/BGApp/Game/Delete.yml
Normal file
20
API Tests/BGApp/Game/Delete.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
info:
|
||||
name: Delete
|
||||
type: http
|
||||
seq: 4
|
||||
|
||||
http:
|
||||
method: DELETE
|
||||
url: "{{BASE_URL}}/{{SECTOR}}/{{GameID}}"
|
||||
auth: inherit
|
||||
|
||||
runtime:
|
||||
variables:
|
||||
- name: GameID
|
||||
value: ""
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
20
API Tests/BGApp/Game/Get.yml
Normal file
20
API Tests/BGApp/Game/Get.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
info:
|
||||
name: Get
|
||||
type: http
|
||||
seq: 2
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{BASE_URL}}/{{SECTOR}}/{{GameID}}"
|
||||
auth: inherit
|
||||
|
||||
runtime:
|
||||
variables:
|
||||
- name: GameID
|
||||
value: ""
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
24
API Tests/BGApp/Game/Search.yml
Normal file
24
API Tests/BGApp/Game/Search.yml
Normal 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
|
||||
28
API Tests/BGApp/Game/Update.yml
Normal file
28
API Tests/BGApp/Game/Update.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
info:
|
||||
name: Update
|
||||
type: http
|
||||
seq: 3
|
||||
|
||||
http:
|
||||
method: PATCH
|
||||
url: "{{BASE_URL}}/{{SECTOR}}/{{GameID}}"
|
||||
body:
|
||||
type: json
|
||||
data: |-
|
||||
{
|
||||
"name":"{{Name}}"
|
||||
}
|
||||
auth: inherit
|
||||
|
||||
runtime:
|
||||
variables:
|
||||
- name: GameID
|
||||
value: DM5GMY
|
||||
- name: Name
|
||||
value: Update test
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
10
API Tests/BGApp/Game/folder.yml
Normal file
10
API Tests/BGApp/Game/folder.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
info:
|
||||
name: Game
|
||||
type: folder
|
||||
seq: 1
|
||||
|
||||
request:
|
||||
auth: inherit
|
||||
variables:
|
||||
- name: SECTOR
|
||||
value: games
|
||||
29
API Tests/BGApp/Invites/Accept.yml
Normal file
29
API Tests/BGApp/Invites/Accept.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
info:
|
||||
name: Accept
|
||||
type: http
|
||||
seq: 2
|
||||
|
||||
http:
|
||||
method: POST
|
||||
url: "{{BASE_URL}}/{{SECTOR}}/accept"
|
||||
body:
|
||||
type: json
|
||||
data: |-
|
||||
{
|
||||
"inviteCode": "{{InviteCode}}",
|
||||
"password": "{{Password}}
|
||||
}
|
||||
auth: inherit
|
||||
|
||||
runtime:
|
||||
variables:
|
||||
- name: InviteCode
|
||||
value: ""
|
||||
- name: Password
|
||||
value: ""
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
29
API Tests/BGApp/Invites/Create.yml
Normal file
29
API Tests/BGApp/Invites/Create.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
info:
|
||||
name: Create
|
||||
type: http
|
||||
seq: 5
|
||||
|
||||
http:
|
||||
method: POST
|
||||
url: "{{BASE_URL}}/{{SECTOR}}"
|
||||
body:
|
||||
type: json
|
||||
data: |-
|
||||
{
|
||||
"email": "{{Email}}",
|
||||
"playerId": "{{PlayerID}}"
|
||||
}
|
||||
auth: inherit
|
||||
|
||||
runtime:
|
||||
variables:
|
||||
- name: Email
|
||||
value: ""
|
||||
- name: PlayerID
|
||||
value: ""
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
10
API Tests/BGApp/Invites/folder.yml
Normal file
10
API Tests/BGApp/Invites/folder.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
info:
|
||||
name: Invites
|
||||
type: folder
|
||||
seq: 1
|
||||
|
||||
request:
|
||||
auth: inherit
|
||||
variables:
|
||||
- name: SECTOR
|
||||
value: invites
|
||||
35
API Tests/BGApp/Matches/Create.yml
Normal file
35
API Tests/BGApp/Matches/Create.yml
Normal 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
|
||||
20
API Tests/BGApp/Matches/Delete.yml
Normal file
20
API Tests/BGApp/Matches/Delete.yml
Normal 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
|
||||
20
API Tests/BGApp/Matches/Get.yml
Normal file
20
API Tests/BGApp/Matches/Get.yml
Normal 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
|
||||
23
API Tests/BGApp/Matches/Leave.yml
Normal file
23
API Tests/BGApp/Matches/Leave.yml
Normal 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
|
||||
10
API Tests/BGApp/Matches/folder.yml
Normal file
10
API Tests/BGApp/Matches/folder.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
info:
|
||||
name: Matches
|
||||
type: folder
|
||||
seq: 1
|
||||
|
||||
request:
|
||||
auth: inherit
|
||||
variables:
|
||||
- name: SECTOR
|
||||
value: matches
|
||||
26
API Tests/BGApp/Players/Create.yml
Normal file
26
API Tests/BGApp/Players/Create.yml
Normal 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: Test
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
20
API Tests/BGApp/Players/Delete.yml
Normal file
20
API Tests/BGApp/Players/Delete.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
info:
|
||||
name: Delete
|
||||
type: http
|
||||
seq: 4
|
||||
|
||||
http:
|
||||
method: DELETE
|
||||
url: "{{BASE_URL}}/{{SECTOR}}/{{PlayerID}}"
|
||||
auth: inherit
|
||||
|
||||
runtime:
|
||||
variables:
|
||||
- name: PlayerID
|
||||
value: ""
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
20
API Tests/BGApp/Players/Get.yml
Normal file
20
API Tests/BGApp/Players/Get.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
info:
|
||||
name: Get
|
||||
type: http
|
||||
seq: 2
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{BASE_URL}}/{{SECTOR}}/{{PlayerID}}"
|
||||
auth: inherit
|
||||
|
||||
runtime:
|
||||
variables:
|
||||
- name: PlayerID
|
||||
value: 539DPX
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
22
API Tests/BGApp/Players/List.yml
Normal file
22
API Tests/BGApp/Players/List.yml
Normal 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: "100"
|
||||
- name: Page
|
||||
value: "1"
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
30
API Tests/BGApp/Players/Update.yml
Normal file
30
API Tests/BGApp/Players/Update.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
info:
|
||||
name: Update
|
||||
type: http
|
||||
seq: 3
|
||||
|
||||
http:
|
||||
method: PATCH
|
||||
url: "{{BASE_URL}}/{{SECTOR}}/{{PlayerID}}"
|
||||
body:
|
||||
type: json
|
||||
data: |-
|
||||
{
|
||||
"name": "{{Name}}",
|
||||
"isRatingLocked": false,
|
||||
"canBeMultiple": false
|
||||
}
|
||||
auth: inherit
|
||||
|
||||
runtime:
|
||||
variables:
|
||||
- name: Name
|
||||
value: Foo
|
||||
- name: PlayerID
|
||||
value: ""
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
10
API Tests/BGApp/Players/folder.yml
Normal file
10
API Tests/BGApp/Players/folder.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
info:
|
||||
name: Players
|
||||
type: folder
|
||||
seq: 1
|
||||
|
||||
request:
|
||||
auth: inherit
|
||||
variables:
|
||||
- name: SECTOR
|
||||
value: players
|
||||
32
API Tests/BGApp/User/Create.yml
Normal file
32
API Tests/BGApp/User/Create.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
info:
|
||||
name: Create
|
||||
type: http
|
||||
seq: 1
|
||||
|
||||
http:
|
||||
method: POST
|
||||
url: "{{BASE_URL}}/{{SECTOR}}"
|
||||
body:
|
||||
type: json
|
||||
data: |-
|
||||
{
|
||||
"email": "{{Email}}",
|
||||
"password": "{{Password}}",
|
||||
"playerId": "{{PlayerID}}"
|
||||
}
|
||||
auth: inherit
|
||||
|
||||
runtime:
|
||||
variables:
|
||||
- name: Email
|
||||
value: ""
|
||||
- name: Password
|
||||
value: ""
|
||||
- name: PlayerID
|
||||
value: ""
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
20
API Tests/BGApp/User/Delete.yml
Normal file
20
API Tests/BGApp/User/Delete.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
info:
|
||||
name: Delete
|
||||
type: http
|
||||
seq: 4
|
||||
|
||||
http:
|
||||
method: DELETE
|
||||
url: "{{BASE_URL}}/{{SECTOR}}/{{UserID}}"
|
||||
auth: inherit
|
||||
|
||||
runtime:
|
||||
variables:
|
||||
- name: UserID
|
||||
value: ""
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
20
API Tests/BGApp/User/Get.yml
Normal file
20
API Tests/BGApp/User/Get.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
info:
|
||||
name: Get
|
||||
type: http
|
||||
seq: 2
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{BASE_URL}}/{{SECTOR}}/{{UserID}}"
|
||||
auth: inherit
|
||||
|
||||
runtime:
|
||||
variables:
|
||||
- name: UserID
|
||||
value: ""
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
34
API Tests/BGApp/User/Update.yml
Normal file
34
API Tests/BGApp/User/Update.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
info:
|
||||
name: Update
|
||||
type: http
|
||||
seq: 3
|
||||
|
||||
http:
|
||||
method: PATCH
|
||||
url: "{{BASE_URL}}/{{SECTOR}}/{{UserID}}"
|
||||
body:
|
||||
type: json
|
||||
data: |-
|
||||
{
|
||||
"isActive": {{IsActive}},
|
||||
"isAdmin": {{IsAdmin}},
|
||||
"email": "{{Email}}"
|
||||
}
|
||||
auth: inherit
|
||||
|
||||
runtime:
|
||||
variables:
|
||||
- name: UserID
|
||||
value: ""
|
||||
- name: IsActive
|
||||
value: ""
|
||||
- name: IsAdmin
|
||||
value: ""
|
||||
- name: Email
|
||||
value: ""
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
10
API Tests/BGApp/User/folder.yml
Normal file
10
API Tests/BGApp/User/folder.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
info:
|
||||
name: User
|
||||
type: folder
|
||||
seq: 1
|
||||
|
||||
request:
|
||||
auth: inherit
|
||||
variables:
|
||||
- name: SECTOR
|
||||
value: users
|
||||
9
API Tests/BGApp/environments/BGApp.yml
Normal file
9
API Tests/BGApp/environments/BGApp.yml
Normal 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
|
||||
30
API Tests/BGApp/opencollection.yml
Normal file
30
API Tests/BGApp/opencollection.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
opencollection: 1.0.0
|
||||
|
||||
info:
|
||||
name: BGApp
|
||||
config:
|
||||
proxy:
|
||||
inherit: true
|
||||
config:
|
||||
protocol: http
|
||||
hostname: ""
|
||||
port: ""
|
||||
auth:
|
||||
username: ""
|
||||
password: ""
|
||||
bypassProxy: ""
|
||||
|
||||
request:
|
||||
auth:
|
||||
type: bearer
|
||||
token: "{{BEARER_TOKEN}}"
|
||||
bundled: false
|
||||
extensions:
|
||||
bruno:
|
||||
ignore:
|
||||
- node_modules
|
||||
- .git
|
||||
presets:
|
||||
request:
|
||||
type: http
|
||||
url: http://localhost:3000/api/
|
||||
@@ -1,12 +0,0 @@
|
||||
POST http://localhost:3000/api/user
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxIiwiY2xhaW1zIjpbIlVTRVJTX1NFTEZfUkVBRCIsIkFETUlOIiwiVVNFUlNfQ1JFQVRFIiwiVVNFUlNfT1RIRVJfUkVBRCIsIlVTRVJTX1NFTEZfREVMRVRFIiwiVVNFUlNfT1RIRVJfVVBEQVRFIiwiVVNFUlNfU0VMRl9VUERBVEUiXSwiaWF0IjoxNzcwOTE1NjY4LCJleHAiOjE3NzEwMDIwNjh9.7ZWfIcT9vBIpqYY4PhJspRPrCtyBkqQ5jmSjOrCgzWI
|
||||
|
||||
{
|
||||
"username": "jd8",
|
||||
"password": "Foobar2"
|
||||
}
|
||||
|
||||
###
|
||||
GET http://localhost:3000/api/user/2
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxIiwiY2xhaW1zIjpbIkFETUlOIiwiVVNFUlNfQ1JFQVRFIiwiVVNFUlNfT1RIRVJfVVBEQVRFIiwiVVNFUlNfU0VMRl9SRUFEIiwiVVNFUlNfU0VMRl9VUERBVEUiLCJVU0VSU19PVEhFUl9SRUFEIiwiVVNFUlNfU0VMRl9ERUxFVEUiXSwiaWF0IjoxNzcxMDE5NzMzLCJleHAiOjE3NzExMDYxMzN9.V4La9Sv13M15lubtxWGESUksWldhlyG8AhiIE4zAtWo
|
||||
@@ -24,8 +24,6 @@ COPY ./package.json ./package.json
|
||||
|
||||
# copy production dependencies and source code into final image
|
||||
FROM base AS release
|
||||
ENV DATABASE_URL=postgres://admin:iiyama12@192.168.1.166:5432/bgApp
|
||||
ENV JWT_SECRET_KEY=MySecret
|
||||
COPY --from=install /temp/prod/node_modules node_modules
|
||||
COPY --from=prerelease /usr/src/app/index.ts .
|
||||
COPY --from=prerelease /usr/src/app/utilities/ ./utilities
|
||||
|
||||
@@ -10,11 +10,17 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@react-email/render": "^2.0.4",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash": "^4.17.23",
|
||||
"@types/react": "^19.2.14",
|
||||
"argon2": "^0.44.0",
|
||||
"hashids": "^2.3.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lodash": "^4.17.23"
|
||||
"lodash": "^4.17.23",
|
||||
"react": "^19.2.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"resend": "^6.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.9"
|
||||
|
||||
1355
scripts/dbCreate.sql
1355
scripts/dbCreate.sql
File diff suppressed because it is too large
Load Diff
78
src/emails/invite.tsx
Normal file
78
src/emails/invite.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as React from 'react';
|
||||
import { brandColours } from '../utilities/helpers';
|
||||
|
||||
interface InviteEmailProperties {
|
||||
playerName: string;
|
||||
inviteCode: string;
|
||||
}
|
||||
|
||||
export const InviteEmail = (props: InviteEmailProperties) => (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: brandColours.light,
|
||||
}}
|
||||
>
|
||||
<table
|
||||
width="100%"
|
||||
border={0}
|
||||
cellSpacing={0}
|
||||
cellPadding={0}
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<div
|
||||
style={{
|
||||
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={{
|
||||
marginBottom: '40px',
|
||||
}}
|
||||
>
|
||||
<a
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
@@ -1,31 +1,104 @@
|
||||
import {orm} from "../orm/orm";
|
||||
import jwt from "jsonwebtoken";
|
||||
import {UnwrappedRequest} from "../utilities/guard";
|
||||
import {ErrorResponse} from "../utilities/responseHelper";
|
||||
import {Claims} from "../orm/claims";
|
||||
import {UnauthorizedError} from "../utilities/errors";
|
||||
import { orm } from '../orm/orm';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { UnwrappedRequest } from '../utilities/guard';
|
||||
import { ErrorResponse, OkResponse, UnauthorizedResponse } from '../utilities/responseHelper';
|
||||
import { Claims } from '../orm/claims';
|
||||
import { ChangePasswordRequest, LoginRequest } from '../utilities/requestModels';
|
||||
import { UserId } from '../utilities/secureIds';
|
||||
|
||||
async function login(request: UnwrappedRequest): Promise<Response> {
|
||||
async function login(request: UnwrappedRequest<LoginRequest>): Promise<Response> {
|
||||
try {
|
||||
const requestBody = request.json;
|
||||
console.log(`/api/auth/login: username=${requestBody.username}`);
|
||||
const claims: Claims | null = await orm.users.verify(requestBody.username, requestBody.password);
|
||||
console.log(claims);
|
||||
if (claims) {
|
||||
const token = jwt.sign({...claims}, process.env.JWT_SECRET_KEY as string, {expiresIn: "24h"});
|
||||
return Response.json({token: token, claims: claims}, {status: 200});
|
||||
const verify: {
|
||||
userId: UserId;
|
||||
refreshCount: string;
|
||||
} | null = await orm.users.verifyCredentials(request.body.email, request.body.password);
|
||||
if (!verify) {
|
||||
return new UnauthorizedResponse('Invalid credentials');
|
||||
}
|
||||
|
||||
throw new UnauthorizedError('Invalid credentials');
|
||||
// Build refresh token that expires in 30 days, return as secure HTTP only cookie.
|
||||
const tokenLifeSpanInDays = 30;
|
||||
const token = jwt.sign(
|
||||
{
|
||||
u: verify.userId,
|
||||
r: verify.refreshCount,
|
||||
},
|
||||
process.env.JWT_REFRESH_KEY as string,
|
||||
{ expiresIn: `${tokenLifeSpanInDays * 24}h` },
|
||||
);
|
||||
const cookies = request?.request?.cookies;
|
||||
cookies?.set({
|
||||
name: 'refresh',
|
||||
value: token,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
maxAge: tokenLifeSpanInDays * 24 * 60 * 60,
|
||||
path: '/api/auth/token',
|
||||
});
|
||||
return new OkResponse({ token });
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
async function test(request: UnwrappedRequest) {
|
||||
return Response.json(request.claims, {status: 200});
|
||||
|
||||
async function token(request: UnwrappedRequest): Promise<Response> {
|
||||
try {
|
||||
const cookies = request.request.cookies;
|
||||
const refreshCookie = cookies.get('refresh');
|
||||
if (!refreshCookie) {
|
||||
return new UnauthorizedResponse('No refresh token found');
|
||||
}
|
||||
|
||||
const refreshToken: {
|
||||
u: string;
|
||||
r: string;
|
||||
} = jwt.verify(refreshCookie, process.env.JWT_REFRESH_KEY as string) as { u: string; r: string };
|
||||
|
||||
if (!(await orm.users.verifyRefreshCount(UserId.fromHash(refreshToken.u), refreshToken.r))) {
|
||||
const response = new UnauthorizedResponse('Invalid refresh token');
|
||||
response.headers.set('Clear-Site-Data', '"cookies","cache","storage","executionContexts"');
|
||||
return response;
|
||||
}
|
||||
|
||||
const claims: Claims | null = await orm.claims.getByUserId(UserId.fromHash(refreshToken.u));
|
||||
|
||||
const token = jwt.sign({ ...claims }, process.env.JWT_SECRET_KEY as string, {
|
||||
expiresIn: process.env.JWT_LIFESPAN as any,
|
||||
});
|
||||
return new OkResponse({ token });
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(): Promise<Response> {
|
||||
try {
|
||||
const response = new OkResponse();
|
||||
response.headers.set('Clear-Site-Data', '"cookies","cache","storage","executionContexts"');
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword(request: UnwrappedRequest<ChangePasswordRequest>): Promise<Response> {
|
||||
try {
|
||||
return new OkResponse(
|
||||
await orm.users.changePassword(
|
||||
UserId.fromHash(request.params.id),
|
||||
request.body.oldPassword,
|
||||
request.body.newPassword,
|
||||
request.claims,
|
||||
),
|
||||
);
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
login,
|
||||
test
|
||||
};
|
||||
token,
|
||||
logout,
|
||||
changePassword,
|
||||
};
|
||||
|
||||
74
src/endpoints/circles.ts
Normal file
74
src/endpoints/circles.ts
Normal 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,
|
||||
};
|
||||
85
src/endpoints/collections.ts
Normal file
85
src/endpoints/collections.ts
Normal 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,
|
||||
};
|
||||
62
src/endpoints/games.ts
Normal file
62
src/endpoints/games.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { orm } from '../orm/orm';
|
||||
import { UnwrappedRequest } from '../utilities/guard';
|
||||
import { CreatedResponse, ErrorResponse, OkResponse, PagedResponse } from '../utilities/responseHelper';
|
||||
import { CreateGameRequest, UpdateGameRequest } from '../utilities/requestModels';
|
||||
import { GameId } from '../utilities/secureIds';
|
||||
|
||||
async function create(request: UnwrappedRequest<CreateGameRequest>): Promise<Response> {
|
||||
try {
|
||||
return new CreatedResponse(
|
||||
await orm.games.create(
|
||||
{
|
||||
name: request.body.name,
|
||||
bggId: request.body.bggId,
|
||||
imagePath: request.body.imagePath,
|
||||
},
|
||||
request.claims,
|
||||
),
|
||||
);
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
try {
|
||||
return new OkResponse(await orm.games.drop(GameId.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.games.query(request.params.query));
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
create,
|
||||
get,
|
||||
update,
|
||||
drop,
|
||||
query,
|
||||
};
|
||||
32
src/endpoints/invites.ts
Normal file
32
src/endpoints/invites.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { orm } from '../orm/orm';
|
||||
import { UnwrappedRequest } from '../utilities/guard';
|
||||
import { CreatedResponse, ErrorResponse } from '../utilities/responseHelper';
|
||||
import { AcceptInviteRequest, InviteUserRequest } from '../utilities/requestModels';
|
||||
import { PlayerId, UserId } from '../utilities/secureIds';
|
||||
|
||||
async function create(request: UnwrappedRequest<InviteUserRequest>): Promise<Response> {
|
||||
try {
|
||||
return new CreatedResponse(
|
||||
await orm.invites.create({
|
||||
...request.body,
|
||||
playerId: PlayerId.fromHash(request.body.playerId),
|
||||
invitedByUserId: request.claims.userId as UserId,
|
||||
}),
|
||||
);
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function accept(request: UnwrappedRequest<AcceptInviteRequest>): Promise<Response> {
|
||||
try {
|
||||
return new CreatedResponse(await orm.invites.accept(request.body));
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
create,
|
||||
accept,
|
||||
};
|
||||
55
src/endpoints/matches.ts
Normal file
55
src/endpoints/matches.ts
Normal 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,
|
||||
};
|
||||
55
src/endpoints/players.ts
Normal file
55
src/endpoints/players.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { orm } from '../orm/orm';
|
||||
import { UnwrappedRequest } from '../utilities/guard';
|
||||
import { CreatedResponse, ErrorResponse, OkResponse, PagedResponse } from '../utilities/responseHelper';
|
||||
import { CreatePlayerRequest, UpdatePlayerRequest } from '../utilities/requestModels';
|
||||
import { PlayerId } from '../utilities/secureIds';
|
||||
|
||||
async function create(request: UnwrappedRequest<CreatePlayerRequest>): Promise<Response> {
|
||||
try {
|
||||
return new CreatedResponse(await orm.players.create(request.body));
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function get(request: UnwrappedRequest): Promise<Response> {
|
||||
try {
|
||||
return new OkResponse(await orm.players.get(PlayerId.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.players.list(request.claims));
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function update(request: UnwrappedRequest<UpdatePlayerRequest>): Promise<Response> {
|
||||
try {
|
||||
return new OkResponse(
|
||||
await orm.players.update(PlayerId.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.players.drop(PlayerId.fromHash(request.params.id), request.claims));
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
create,
|
||||
get,
|
||||
list,
|
||||
update,
|
||||
drop,
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import {orm} from "../orm/orm";
|
||||
import {UnwrappedRequest} from "../utilities/guard";
|
||||
import {ErrorResponse} from "../utilities/responseHelper";
|
||||
|
||||
async function create(request: UnwrappedRequest): Promise<Response> {
|
||||
try {
|
||||
const requestBody = request.json;
|
||||
|
||||
const newUser = await orm.users.create(requestBody.username, requestBody.password, request.claims);
|
||||
if(!newUser) {
|
||||
return new Response(null,{status: 201})
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
...newUser
|
||||
},
|
||||
{status: 201}
|
||||
);
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function get(request: UnwrappedRequest): Promise<Response> {
|
||||
try {
|
||||
return Response.json({
|
||||
...(await orm.users.get(request.params.id, request.claims))
|
||||
}, {status: 200});
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
create,
|
||||
get,
|
||||
}
|
||||
49
src/endpoints/users.ts
Normal file
49
src/endpoints/users.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { orm } from '../orm/orm';
|
||||
import { UnwrappedRequest } from '../utilities/guard';
|
||||
import { CreatedResponse, ErrorResponse, OkResponse } from '../utilities/responseHelper';
|
||||
import { CreateUserRequest, UpdateUserRequest } from '../utilities/requestModels';
|
||||
import { PlayerId, UserId } from '../utilities/secureIds';
|
||||
|
||||
async function create(request: UnwrappedRequest<CreateUserRequest>): Promise<Response> {
|
||||
try {
|
||||
return new CreatedResponse(
|
||||
await orm.users.create({
|
||||
...request.body,
|
||||
playerId: PlayerId.fromHash(request.body.playerId),
|
||||
}),
|
||||
);
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function get(request: UnwrappedRequest): Promise<Response> {
|
||||
try {
|
||||
return new OkResponse(await orm.users.get(UserId.fromHash(request.params.id), request.claims));
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function update(request: UnwrappedRequest<UpdateUserRequest>): Promise<Response> {
|
||||
try {
|
||||
return new OkResponse(await orm.users.update(UserId.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.users.drop(UserId.fromHash(request.params.id), request.claims));
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
create,
|
||||
get,
|
||||
update,
|
||||
drop,
|
||||
};
|
||||
45
src/index.ts
45
src/index.ts
@@ -1,27 +1,38 @@
|
||||
import {unwrapMethod, guard} from './utilities/guard';
|
||||
import auth from "./endpoints/auth";
|
||||
import user from "./endpoints/user";
|
||||
import { OkResponse } from './utilities/responseHelper';
|
||||
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({
|
||||
routes: {
|
||||
"/api/auth/login": {
|
||||
POST: unwrapMethod(auth.login),
|
||||
routes: buildRoute({
|
||||
[process.env.API_ROOT_PATH ?? '']: {
|
||||
auth,
|
||||
users,
|
||||
players,
|
||||
games,
|
||||
invites,
|
||||
collections,
|
||||
matches,
|
||||
circles,
|
||||
},
|
||||
"/api/auth/test": {
|
||||
GET: guard(auth.test, ['ADMIN', 'USERS_OTHER_DELETE'])
|
||||
test: {
|
||||
GET: () => {
|
||||
return new OkResponse(MatchId.fromID('2').value);
|
||||
},
|
||||
},
|
||||
"/api/user": {
|
||||
POST: guard(user.create, ['ADMIN', 'USERS_CREATE'])
|
||||
},
|
||||
"/api/user/:id": {
|
||||
GET: guard(user.get, ['ADMIN', 'USERS_OTHERS_READ', 'USERS_SELF_READ'])
|
||||
},
|
||||
},
|
||||
}) as any,
|
||||
|
||||
// (optional) fallback for unmatched routes:
|
||||
fetch(): Response {
|
||||
return Response.json({message: "Not found"}, {status: 404});
|
||||
return Response.json({ message: 'Not found' }, { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Server running at ${server.url}`);
|
||||
console.log(`Server running at ${server.url}`);
|
||||
|
||||
192
src/orm/circles.ts
Normal file
192
src/orm/circles.ts
Normal 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),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,49 @@
|
||||
import {sql} from 'bun';
|
||||
import { sql } from 'bun';
|
||||
import { ClaimDefinition } from '../utilities/claimDefinitions';
|
||||
import { UserId } from '../utilities/secureIds';
|
||||
|
||||
export class Claims {
|
||||
userId: string | undefined;
|
||||
export class Claims extends ClaimDefinition {
|
||||
userId: UserId;
|
||||
claims: string[] = [];
|
||||
|
||||
public static test(userClaims: Claims, guardClaim: string): Boolean {
|
||||
return userClaims.claims.some(x => x === guardClaim);
|
||||
constructor(raw?: { userId?: string | UserId; claims?: string[] }) {
|
||||
super();
|
||||
if (raw?.userId instanceof UserId) {
|
||||
this.userId = raw.userId;
|
||||
} else {
|
||||
this.userId = UserId.fromHash(raw?.userId ?? '');
|
||||
}
|
||||
this.claims = raw?.claims ?? [];
|
||||
}
|
||||
|
||||
test(...guardClaims: string[]): Boolean {
|
||||
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 {
|
||||
async getByUserId(userId: string): Promise<Claims> {
|
||||
const dbResults: any[] = await sql`SELECT c.name
|
||||
async getByUserId(userId: UserId): Promise<Claims> {
|
||||
const records: any[] = await sql`SELECT c.name
|
||||
from user_claims as uc
|
||||
JOIN claims as c on uc.claimid = c.id
|
||||
where uc.userid = ${userId};`;
|
||||
const claims = new Claims();
|
||||
claims.userId = userId;
|
||||
claims.claims = dbResults.map(x => x.name);
|
||||
return claims;
|
||||
JOIN claims as c on uc.claim_id = c.id
|
||||
where uc.user_id = ${userId.raw};`;
|
||||
return new Claims({
|
||||
userId: userId,
|
||||
claims: records.map((x) => x.name),
|
||||
});
|
||||
}
|
||||
|
||||
async getDefaultClaims(): Promise<number[]> {
|
||||
const dbResults: any[] = await sql`SELECT id
|
||||
const records: any[] = await sql`SELECT id
|
||||
FROM claims
|
||||
WHERE is_default = true;`;
|
||||
return dbResults.map(x => x.id);
|
||||
return records.map((x) => x.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
180
src/orm/collections.ts
Normal file
180
src/orm/collections.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
112
src/orm/games.ts
Normal file
112
src/orm/games.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Claims } from './claims';
|
||||
import { sql } from 'bun';
|
||||
import { first } from 'lodash';
|
||||
import { NotFoundError } from '../utilities/errors';
|
||||
import { CreateGameRequest, UpdateGameRequest } from '../utilities/requestModels';
|
||||
import { memo } from '../utilities/helpers';
|
||||
import { GameId } from '../utilities/secureIds';
|
||||
|
||||
export class Game {
|
||||
id: GameId;
|
||||
name: string;
|
||||
imagePath?: string;
|
||||
bggId?: string;
|
||||
|
||||
constructor(input: { id: GameId; name: string; imagePath?: string; bggId?: string }) {
|
||||
this.id = input.id;
|
||||
this.name = input?.name;
|
||||
this.imagePath = input?.imagePath;
|
||||
this.bggId = input?.bggId;
|
||||
}
|
||||
}
|
||||
|
||||
export class GamesOrm {
|
||||
async create(model: CreateGameRequest, claims?: Claims): Promise<Game> {
|
||||
await sql`INSERT INTO games (name, image_path, bgg_id)
|
||||
VALUES (${model.name}, ${claims?.test(Claims.GAMES.MANAGE_IMAGES) ? model.imagePath : null}, ${model.bggId})`;
|
||||
const newRecordId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string;
|
||||
|
||||
return await this.get(GameId.fromID(newRecordId));
|
||||
}
|
||||
|
||||
async get(id: GameId): Promise<Game> {
|
||||
const record: any = first(
|
||||
await sql`SELECT *
|
||||
FROM games
|
||||
WHERE id = ${id.raw}
|
||||
LIMIT 1`,
|
||||
);
|
||||
|
||||
if (!record) {
|
||||
throw new NotFoundError('No matching game exists');
|
||||
}
|
||||
|
||||
return new Game({
|
||||
id: GameId.fromID(record.id),
|
||||
name: record.name,
|
||||
bggId: record.bgg_id,
|
||||
imagePath: record.image_path,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: GameId, patch: UpdateGameRequest, claims?: Claims): Promise<Game> {
|
||||
const game = await this.get(id);
|
||||
game.name = patch.name ?? game.name;
|
||||
game.bggId = patch.bggId ?? game.bggId;
|
||||
|
||||
if (claims?.test(Claims.GAMES.MANAGE_IMAGES)) {
|
||||
game.imagePath = patch.imagePath ?? game.imagePath;
|
||||
}
|
||||
|
||||
await sql`UPDATE games
|
||||
SET name=${game.name},
|
||||
bgg_id=${game.bggId},
|
||||
image_path=${game.imagePath}
|
||||
WHERE id = ${id.raw}`;
|
||||
|
||||
return await this.get(id);
|
||||
}
|
||||
|
||||
async drop(id: GameId): Promise<void> {
|
||||
// Ensure player exists before attempting to delete
|
||||
await this.get(id);
|
||||
await sql.transaction(async (tx) => {
|
||||
await tx`DELETE
|
||||
FROM collection_games
|
||||
WHERE game_id = ${id.raw}`;
|
||||
await tx`DELETE
|
||||
FROM match_players
|
||||
WHERE match_id IN (SELECT id FROM matches WHERE game_id = ${id.raw})`;
|
||||
await tx`DELETE
|
||||
FROM matches
|
||||
WHERE game_id = ${id.raw}`;
|
||||
await tx`DELETE
|
||||
FROM games
|
||||
WHERE id = ${id.raw}`;
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
query: (query: string) => Promise<Game[]> = memo<(query: string) => Promise<Game[]>, Game[]>(this.#query);
|
||||
async #query(query: string): Promise<Game[]> {
|
||||
const records: any = await sql` SELECT
|
||||
id, name
|
||||
FROM (SELECT *, SIMILARITY(${query}, name) as similarity FROM games)
|
||||
WHERE similarity > 0
|
||||
ORDER BY similarity
|
||||
LIMIT 5;`;
|
||||
|
||||
if (!records) {
|
||||
throw new NotFoundError('No matching game exists');
|
||||
}
|
||||
|
||||
return records.map(
|
||||
(x: { id: string; name: string }) =>
|
||||
new Game({
|
||||
id: GameId.fromID(x.id),
|
||||
name: x.name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
133
src/orm/invites.ts
Normal file
133
src/orm/invites.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { sql } from 'bun';
|
||||
import { first } from 'lodash';
|
||||
import { BadRequestError, InternalServerError, NotFoundError, UnauthorizedError } from '../utilities/errors';
|
||||
import { PlayerId, UserId } from '../utilities/secureIds';
|
||||
import { createRandomString } from '../utilities/helpers';
|
||||
import { Resend } from 'resend';
|
||||
import { orm } from './orm';
|
||||
import { InviteEmail } from '../emails/invite';
|
||||
import { User } from './user';
|
||||
import { Claims } from './claims';
|
||||
|
||||
export class InvitesOrm {
|
||||
async create(
|
||||
{
|
||||
email,
|
||||
playerId,
|
||||
invitedByUserId,
|
||||
}: {
|
||||
email: string;
|
||||
playerId: PlayerId;
|
||||
invitedByUserId: UserId;
|
||||
},
|
||||
claims?: Claims,
|
||||
): Promise<void> {
|
||||
if (!claims?.test(Claims.ADMIN)) {
|
||||
const userInviteCount = (
|
||||
first(
|
||||
await sql`SELECT COUNT(*) AS count
|
||||
FROM user_invites
|
||||
WHERE invited_by_user_id = ${invitedByUserId.raw}`,
|
||||
) as { count: number }
|
||||
)?.count;
|
||||
|
||||
if (process.env.MAX_INVITE_ALLOWANCE && userInviteCount >= parseInt(process.env.MAX_INVITE_ALLOWANCE)) {
|
||||
throw new UnauthorizedError('Invite allowance reached.');
|
||||
}
|
||||
|
||||
const doesInviteExist = (
|
||||
first(
|
||||
await sql`SELECT COUNT(*) > 0 AS exists
|
||||
FROM user_invites
|
||||
WHERE player_id = ${playerId.raw}
|
||||
OR email = ${email}`,
|
||||
) as {
|
||||
exists: boolean;
|
||||
}
|
||||
)?.exists;
|
||||
if (doesInviteExist) {
|
||||
throw new BadRequestError('Player has already been invited.');
|
||||
}
|
||||
}
|
||||
|
||||
const isPlayerAssociatedWithUser = (
|
||||
first(
|
||||
await sql`SELECT COUNT(*) > 0 AS exists
|
||||
FROM users
|
||||
WHERE player_id = ${playerId.raw}
|
||||
OR email = ${email}`,
|
||||
) as {
|
||||
exists: boolean;
|
||||
}
|
||||
)?.exists;
|
||||
if (isPlayerAssociatedWithUser) {
|
||||
throw new BadRequestError('User has already been invited.');
|
||||
}
|
||||
|
||||
const player = await orm.players.get(playerId);
|
||||
|
||||
const invitationCode = createRandomString(6);
|
||||
await sql`INSERT INTO user_invites (invite_code, email, player_id, invited_by_user_id)
|
||||
VALUES (${invitationCode}, ${email}, ${playerId.raw}, ${invitedByUserId.raw})`;
|
||||
const newRecordId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string;
|
||||
|
||||
const resend = new Resend(process.env.RESEND_KEY);
|
||||
const resendResponse = await resend.emails.send({
|
||||
from: `${process.env.PRODUCT_NAME} <noreply@mail.jdar.uk>`,
|
||||
to: [email],
|
||||
subject: "You've been invited!",
|
||||
react: InviteEmail({ playerName: player.name, inviteCode: invitationCode }),
|
||||
});
|
||||
|
||||
if (resendResponse.error) {
|
||||
throw new InternalServerError();
|
||||
}
|
||||
|
||||
await sql`UPDATE user_invites SET was_email_sent = true WHERE id=${newRecordId}`;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async accept({ inviteCode, password }: { inviteCode: string; password: string }): Promise<User> {
|
||||
const record: {
|
||||
id: string;
|
||||
email: string;
|
||||
player_id: string;
|
||||
accepted: boolean;
|
||||
} = first(await sql`SELECT * FROM user_invites WHERE invite_code=${inviteCode} LIMIT 1`);
|
||||
|
||||
if (!record) {
|
||||
throw new NotFoundError('Invalid invite code');
|
||||
}
|
||||
|
||||
if (record.accepted) {
|
||||
throw new UnauthorizedError('Invite already accepted');
|
||||
}
|
||||
|
||||
const isPlayerAssociatedWithUser = (
|
||||
first(
|
||||
await sql`SELECT COUNT(*) > 0 AS exists
|
||||
FROM users
|
||||
WHERE player_id = ${record.player_id}
|
||||
OR email = ${record.email}`,
|
||||
) as {
|
||||
exists: boolean;
|
||||
}
|
||||
)?.exists;
|
||||
if (isPlayerAssociatedWithUser) {
|
||||
throw new BadRequestError('User has already been invited.');
|
||||
}
|
||||
|
||||
const user = await orm.users.create({
|
||||
email: record.email,
|
||||
playerId: PlayerId.fromID(record.player_id),
|
||||
password,
|
||||
});
|
||||
|
||||
await sql`UPDATE user_invites
|
||||
SET accepted = true
|
||||
WHERE id = ${record.id}`;
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
205
src/orm/matches.ts
Normal file
205
src/orm/matches.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
import {ClaimsOrm} from "./claims";
|
||||
import {UsersOrm} from "./user";
|
||||
|
||||
import { ClaimsOrm } from './claims';
|
||||
import { UsersOrm } from './user';
|
||||
import { PlayersOrm } from './players';
|
||||
import { GamesOrm } from './games';
|
||||
import { InvitesOrm } from './invites';
|
||||
import { CollectionsOrm } from './collections';
|
||||
import { MatchOrm } from './matches';
|
||||
import { CircleOrm } from './circles';
|
||||
|
||||
class Orm {
|
||||
claims: ClaimsOrm;
|
||||
users: UsersOrm;
|
||||
|
||||
constructor() {
|
||||
this.claims = new ClaimsOrm();
|
||||
this.users = new UsersOrm(this.claims);
|
||||
}
|
||||
|
||||
readonly claims: ClaimsOrm = new ClaimsOrm();
|
||||
readonly users: UsersOrm = new UsersOrm();
|
||||
readonly players: PlayersOrm = new PlayersOrm();
|
||||
readonly games: GamesOrm = new GamesOrm();
|
||||
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();
|
||||
|
||||
162
src/orm/players.ts
Normal file
162
src/orm/players.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Claims } from './claims';
|
||||
import { sql } from 'bun';
|
||||
import { first } from 'lodash';
|
||||
import { NotFoundError, UnauthorizedError } from '../utilities/errors';
|
||||
import { orm } from './orm';
|
||||
import { UpdatePlayerRequest } from '../utilities/requestModels';
|
||||
import { PlayerId } from '../utilities/secureIds';
|
||||
|
||||
export class Player {
|
||||
id: PlayerId;
|
||||
name: string;
|
||||
elo: number;
|
||||
isRatingLocked: boolean;
|
||||
canBeMultiple: boolean;
|
||||
|
||||
constructor(input: {
|
||||
id: PlayerId;
|
||||
name: string;
|
||||
elo?: number;
|
||||
isRatingLocked?: boolean;
|
||||
canBeMultiple?: boolean;
|
||||
}) {
|
||||
this.id = input.id;
|
||||
this.name = input?.name;
|
||||
this.elo = input?.elo ?? 1000;
|
||||
this.isRatingLocked = input?.isRatingLocked ?? false;
|
||||
this.canBeMultiple = input?.canBeMultiple ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
export class PlayersOrm {
|
||||
async create(model: { name: string }): Promise<Player> {
|
||||
await sql`INSERT INTO players (name)
|
||||
VALUES (${model.name})`;
|
||||
const newRecordId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string;
|
||||
|
||||
return await this.get(PlayerId.fromID(newRecordId));
|
||||
}
|
||||
|
||||
async get(id: PlayerId, claims?: Claims): Promise<Player> {
|
||||
if(claims) {
|
||||
const user = await orm.users.get(claims.userId);
|
||||
if(!(claims.test(Claims.ADMIN, Claims.PLAYERS.OTHER.READ) ||
|
||||
claims.test(Claims.PLAYERS.SELF.READ) && id === user.playerId)) {
|
||||
throw new UnauthorizedError();
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
const record: any = first(
|
||||
await sql`SELECT *
|
||||
FROM players
|
||||
WHERE id = ${id.raw}
|
||||
LIMIT 1`,
|
||||
);
|
||||
|
||||
if (!record) {
|
||||
throw new NotFoundError('No matching player exists');
|
||||
}
|
||||
|
||||
return new Player({
|
||||
id: PlayerId.fromID(record.id),
|
||||
name: record.name,
|
||||
elo: parseInt(record.elo),
|
||||
isRatingLocked: record.is_rating_locked,
|
||||
canBeMultiple: record.can_be_multiple,
|
||||
});
|
||||
}
|
||||
|
||||
async list(claims?: Claims): Promise<Player[]> {
|
||||
if (!claims || claims.test(Claims.ADMIN)) {
|
||||
return (await sql`SELECT * FROM players`).map(
|
||||
(x: { id: string; name: string; elo: string; is_rating_locked: boolean; can_be_multiple: boolean }) =>
|
||||
new Player({
|
||||
id: PlayerId.fromID(x.id),
|
||||
name: x.name,
|
||||
elo: parseInt(x.elo),
|
||||
isRatingLocked: x.is_rating_locked,
|
||||
canBeMultiple: x.can_be_multiple,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!claims.test(Claims.PLAYERS.OTHER.READ)) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
return (
|
||||
await sql`SELECT p.*
|
||||
FROM
|
||||
users u
|
||||
JOIN player_circles upc on upc.player_id = u.id
|
||||
JOIN circles c ON c.id = upc.circle_id
|
||||
JOIN player_circles pc ON pc.circle_id = c.id
|
||||
JOIN players p ON p.id = pc.player_id
|
||||
WHERE
|
||||
u.player_id = ${claims.userId?.raw}
|
||||
AND
|
||||
pc.player_id <> u.player_id`
|
||||
).map(
|
||||
(x: { id: string; name: string; elo: string; is_rating_locked: boolean; can_be_multiple: boolean }) =>
|
||||
new Player({
|
||||
id: PlayerId.fromID(x.id),
|
||||
name: x.name,
|
||||
elo: parseInt(x.elo),
|
||||
isRatingLocked: x.is_rating_locked,
|
||||
canBeMultiple: x.can_be_multiple,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async update(id: PlayerId, patch: UpdatePlayerRequest, claims?: Claims): Promise<Player> {
|
||||
if(claims) {
|
||||
const user = await orm.users.get(claims.userId);
|
||||
if(!(claims.test(Claims.ADMIN, Claims.PLAYERS.OTHER.UPDATE) ||
|
||||
(claims.test(Claims.PLAYERS.SELF.UPDATE) && id === user.playerId)
|
||||
)) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
}
|
||||
|
||||
const player = await this.get(id);
|
||||
player.name = patch.name ?? player.name;
|
||||
player.isRatingLocked = patch.isRatingLocked ?? player.isRatingLocked;
|
||||
player.canBeMultiple = patch.canBeMultiple ?? player.canBeMultiple;
|
||||
|
||||
await sql`UPDATE players
|
||||
SET name=${player.name},
|
||||
is_rating_locked=${player.isRatingLocked},
|
||||
can_be_multiple=${player.canBeMultiple}
|
||||
WHERE id = ${id.raw}`;
|
||||
|
||||
return await this.get(id);
|
||||
}
|
||||
|
||||
async drop(id: PlayerId, claims?: Claims): Promise<void> {
|
||||
if(claims) {
|
||||
const user = await orm.users.get(claims.userId);
|
||||
if(!(claims.test(Claims.ADMIN, Claims.PLAYERS.OTHER.DELETE) ||
|
||||
(claims.test(Claims.PLAYERS.SELF.DELETE) && id === user.playerId)
|
||||
)) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure player exists before attempting to delete
|
||||
await this.get(id);
|
||||
await sql.transaction(async (tx) => {
|
||||
await tx`DELETE
|
||||
FROM player_circles
|
||||
WHERE player_id = ${id.raw}`;
|
||||
await tx`DELETE
|
||||
FROM match_players
|
||||
WHERE player_id = ${id.raw}`;
|
||||
await tx`DELETE
|
||||
FROM players
|
||||
WHERE id = ${id.raw}`;
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
296
src/orm/user.ts
296
src/orm/user.ts
@@ -1,97 +1,263 @@
|
||||
import {Claims, ClaimsOrm} from "./claims";
|
||||
import {sql} from "bun";
|
||||
import {first} from "lodash";
|
||||
import argon2 from "argon2";
|
||||
import {BadRequestError, NotFoundError, UnauthorizedError} from "../utilities/errors";
|
||||
import { Claims } from './claims';
|
||||
import { sql } from 'bun';
|
||||
import { first } from 'lodash';
|
||||
import argon2 from 'argon2';
|
||||
import { BadRequestError, NotFoundError, UnauthorizedError } from '../utilities/errors';
|
||||
import { UpdateUserRequest } from '../utilities/requestModels';
|
||||
import { orm } from './orm';
|
||||
import { PlayerId, UserId } from '../utilities/secureIds';
|
||||
|
||||
export class User {
|
||||
id: string;
|
||||
name: string;
|
||||
id: UserId;
|
||||
playerId: PlayerId;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
isActive: boolean;
|
||||
|
||||
constructor(id: string, name: string, isAdmin: boolean = false, isActive: boolean = true) {
|
||||
constructor(id: UserId, playerId: PlayerId, email: string, isAdmin: boolean = false, isActive: boolean = true) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.playerId = playerId;
|
||||
this.email = email;
|
||||
this.isAdmin = isAdmin;
|
||||
this.isActive = isActive;
|
||||
}
|
||||
}
|
||||
|
||||
export class UsersOrm {
|
||||
#claims: ClaimsOrm;
|
||||
|
||||
constructor(claims: ClaimsOrm) {
|
||||
this.#claims = claims;
|
||||
}
|
||||
|
||||
async get(id: string, claims: Claims): Promise<User> {
|
||||
if (!(
|
||||
Claims.test(claims, 'ADMIN') ||
|
||||
Claims.test(claims, 'USERS_OTHER_READ') ||
|
||||
(Claims.test(claims, 'USERS_SELF_READ') && id === claims.userId)
|
||||
)) {
|
||||
throw new
|
||||
UnauthorizedError();
|
||||
async create({
|
||||
email,
|
||||
password,
|
||||
playerId,
|
||||
}: {
|
||||
email: string;
|
||||
password: string;
|
||||
playerId: PlayerId;
|
||||
}): Promise<User> {
|
||||
const existingUser: any = first(
|
||||
await sql`SELECT id
|
||||
FROM users
|
||||
WHERE email = ${email} OR player_id = ${playerId.raw}
|
||||
LIMIT 1`,
|
||||
);
|
||||
if (existingUser) {
|
||||
throw new BadRequestError(`User or player already exists`);
|
||||
}
|
||||
|
||||
const dbResult: any = first(await sql`select *
|
||||
from users
|
||||
where id = ${id}
|
||||
and is_active = true
|
||||
limit 1`);
|
||||
const defaultClaims: number[] = await orm.claims.getDefaultClaims();
|
||||
const passwordHash = await argon2.hash(password);
|
||||
await sql`INSERT INTO users (email, pass_hash, player_id)
|
||||
VALUES (${email}, ${passwordHash}, ${playerId.raw})`;
|
||||
const newRecordId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string;
|
||||
await sql.transaction(async (tx) => {
|
||||
for (let i in defaultClaims) {
|
||||
await tx`INSERT INTO user_claims (user_id, claim_id)
|
||||
VALUES (${newRecordId}, ${defaultClaims[i]})`;
|
||||
}
|
||||
});
|
||||
|
||||
if(!dbResult) {
|
||||
return await this.get(UserId.fromID(newRecordId));
|
||||
}
|
||||
|
||||
async get(id: UserId, claims?: Claims): Promise<User> {
|
||||
if (
|
||||
claims &&
|
||||
!(
|
||||
claims.test(Claims.ADMIN, Claims.USERS.OTHER.READ) ||
|
||||
(claims.test(Claims.USERS.SELF.READ) && id === claims?.userId)
|
||||
)
|
||||
) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
const record: any = first(
|
||||
await sql`SELECT *
|
||||
FROM users
|
||||
WHERE id = ${id.raw}
|
||||
AND is_active = true
|
||||
LIMIT 1`,
|
||||
);
|
||||
|
||||
if (!record) {
|
||||
throw new NotFoundError('No matching user exists');
|
||||
}
|
||||
|
||||
return new User(dbResult.id, dbResult.username, dbResult.is_admin);
|
||||
return new User(
|
||||
UserId.fromID(record.id),
|
||||
PlayerId.fromID(record.player_id),
|
||||
record.email,
|
||||
record.is_admin,
|
||||
);
|
||||
}
|
||||
|
||||
async verify(username: string, password: string): Promise<Claims | null> {
|
||||
try {
|
||||
const dbResult: any = first(await sql`select *
|
||||
from users
|
||||
where username = ${username}
|
||||
limit 1`);
|
||||
if (!await argon2.verify(dbResult.pass_hash, password)) {
|
||||
return null;
|
||||
}
|
||||
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`,
|
||||
);
|
||||
|
||||
return this.#claims.getByUserId(dbResult.id);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
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 create(username: string, password: string, claims: Claims): Promise<User | null> {
|
||||
const existingUser: any = first(await sql`SELECT id
|
||||
FROM users
|
||||
WHERE username = ${username}
|
||||
LIMIT 1`);
|
||||
if (existingUser) {
|
||||
throw new BadRequestError(`User ${username} already exists`);
|
||||
async 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');
|
||||
}
|
||||
|
||||
const defaultClaims: number[] = await this.#claims.getDefaultClaims();
|
||||
const passwordHash = await argon2.hash(password);
|
||||
await sql`INSERT INTO users (username, pass_hash)
|
||||
VALUES (${username}, ${passwordHash})`;
|
||||
const newUserId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string;
|
||||
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
|
||||
SET is_active=${user.isActive},
|
||||
is_admin=${user.isAdmin}
|
||||
WHERE id = ${id.raw}`;
|
||||
|
||||
return await this.get(id);
|
||||
}
|
||||
|
||||
async drop(id: UserId, claims?: Claims): Promise<void> {
|
||||
if (
|
||||
claims &&
|
||||
!(
|
||||
claims.test(Claims.ADMIN, Claims.USERS.OTHER.DELETE) ||
|
||||
(claims.test(Claims.USERS.SELF.DELETE) && id === claims?.userId)
|
||||
)
|
||||
) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
// Ensure user exists before attempting to delete
|
||||
await this.get(id);
|
||||
await sql.transaction(async (tx) => {
|
||||
for (let i in defaultClaims) {
|
||||
await tx`INSERT INTO user_claims (userid, claimid)
|
||||
VALUES (${newUserId}, ${defaultClaims[i]})`;
|
||||
}
|
||||
})
|
||||
await tx`DELETE
|
||||
FROM user_claims
|
||||
WHERE user_id = ${id.raw}`;
|
||||
await tx`DELETE
|
||||
FROM users
|
||||
WHERE id = ${id.raw}`;
|
||||
});
|
||||
|
||||
if (!(
|
||||
Claims.test(claims, 'ADMIN') ||
|
||||
Claims.test(claims, 'USERS_OTHER_READ')
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
async verifyCredentials(email: string, password: string): Promise<{ userId: UserId; refreshCount: string } | null> {
|
||||
const record: any = first(
|
||||
await sql`SELECT *
|
||||
FROM users
|
||||
WHERE email = ${email}
|
||||
AND is_active = true
|
||||
limit 1`,
|
||||
);
|
||||
if (!record) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
if (!(await argon2.verify(record.pass_hash, password))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await this.get(newUserId, claims);
|
||||
return {
|
||||
userId: UserId.fromID(record.id),
|
||||
refreshCount: record.refresh_count,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async verifyRefreshCount(id: UserId, refreshCount: string): Promise<boolean> {
|
||||
const record: any = first(
|
||||
await sql`SELECT *
|
||||
FROM users
|
||||
WHERE id = ${id.raw}
|
||||
LIMIT 1`,
|
||||
);
|
||||
return record.refresh_count === refreshCount;
|
||||
}
|
||||
|
||||
async changePassword(id: UserId, oldPassword: string | null, newPassword: string, claims?: Claims): Promise<void> {
|
||||
const isAdmin = claims?.test(Claims.ADMIN) ?? true;
|
||||
if (!(isAdmin || (claims?.test(Claims.USERS.SELF.UPDATE) && id === claims?.userId))) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
if (!isAdmin && oldPassword === null) {
|
||||
throw new BadRequestError('Password is required');
|
||||
}
|
||||
|
||||
const record: any = first(
|
||||
await sql`SELECT *
|
||||
FROM users
|
||||
WHERE id = ${id.raw}
|
||||
LIMIT 1`,
|
||||
);
|
||||
|
||||
if (!isAdmin && !(await argon2.verify(record.pass_hash, oldPassword as string))) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(newPassword);
|
||||
await sql`UPDATE users
|
||||
SET pass_hash=${passwordHash}
|
||||
WHERE id = ${id.raw}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
20
src/routes/auth.ts
Normal file
20
src/routes/auth.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { guard, unwrapMethod } from '../utilities/guard';
|
||||
import auth from '../endpoints/auth';
|
||||
import { Claims } from '../orm/claims';
|
||||
|
||||
export default {
|
||||
login: {
|
||||
POST: unwrapMethod(auth.login),
|
||||
},
|
||||
token: {
|
||||
GET: unwrapMethod(auth.token),
|
||||
},
|
||||
logout: {
|
||||
POST: unwrapMethod(auth.logout),
|
||||
},
|
||||
changePassword: {
|
||||
':id': {
|
||||
PATCH: guard(auth.changePassword, Claims.ADMIN, Claims.USERS.SELF.UPDATE),
|
||||
},
|
||||
},
|
||||
};
|
||||
32
src/routes/circles.ts
Normal file
32
src/routes/circles.ts
Normal 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
22
src/routes/collections.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
18
src/routes/games.ts
Normal file
18
src/routes/games.ts
Normal 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),
|
||||
},
|
||||
},
|
||||
};
|
||||
10
src/routes/invites.ts
Normal file
10
src/routes/invites.ts
Normal 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
14
src/routes/matches.ts
Normal 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),
|
||||
},
|
||||
},
|
||||
};
|
||||
16
src/routes/players.ts
Normal file
16
src/routes/players.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
12
src/routes/users.ts
Normal file
12
src/routes/users.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
119
src/tests/auth.test.ts
Normal file
119
src/tests/auth.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import auth from '../endpoints/auth';
|
||||
import { UnwrappedRequest } from '../utilities/guard';
|
||||
import { Claims } from '../orm/claims';
|
||||
import { orm } from '../orm/orm';
|
||||
import { User } from '../orm/user';
|
||||
|
||||
test('login', async () => {
|
||||
await orm.users.create('authTest', 'test123');
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
json: {
|
||||
username: 'authTest',
|
||||
password: 'test123',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await auth.login(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("login user that doesn't exist", async () => {
|
||||
const request = new UnwrappedRequest({
|
||||
json: {
|
||||
username: 'thisUserDoesNotExist',
|
||||
password: 'test123',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await auth.login(request);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test('login with invalid password', async () => {
|
||||
const createdUser = (await orm.users.create('authTest2', 'test123')) as User;
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
json: {
|
||||
username: 'authTest2',
|
||||
password: 'wrongPassword',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await auth.login(request);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test('Change password', async () => {
|
||||
const claims = new Claims();
|
||||
claims.claims.push(Claims.USERS.SELF.UPDATE);
|
||||
|
||||
const testUser = (await orm.users.create('authTest3', 'test123')) as User;
|
||||
claims.userId = testUser.id;
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
params: {
|
||||
id: testUser.id,
|
||||
},
|
||||
json: {
|
||||
oldPassword: 'test123',
|
||||
newPassword: 'test1234',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await auth.changePassword(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toBeNull();
|
||||
});
|
||||
|
||||
test('Change password with incorrect old password', async () => {
|
||||
const claims = new Claims();
|
||||
claims.claims.push(Claims.USERS.SELF.UPDATE);
|
||||
|
||||
const testUser = (await orm.users.create('authTest4', 'test123')) as User;
|
||||
claims.userId = testUser.id;
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
params: {
|
||||
id: testUser.id,
|
||||
},
|
||||
json: {
|
||||
oldPassword: 'wrongPassword',
|
||||
newPassword: 'test1234',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await auth.changePassword(request);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test('Change password as admin', async () => {
|
||||
const claims = new Claims();
|
||||
claims.userId = '1';
|
||||
claims.claims.push(Claims.ADMIN);
|
||||
|
||||
const testUser = (await orm.users.create('authTest5', 'test123')) as User;
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
params: {
|
||||
id: testUser.id,
|
||||
},
|
||||
json: {
|
||||
newPassword: 'test1234',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await auth.changePassword(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toBeNull();
|
||||
});
|
||||
@@ -1,2 +1,2 @@
|
||||
import {expect, test, beforeAll} from 'bun:test';
|
||||
import {sql} from "bun";
|
||||
import { expect, test, beforeAll } from 'bun:test';
|
||||
import { sql } from 'bun';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {beforeAll} from 'bun:test';
|
||||
import { beforeAll } from 'bun:test';
|
||||
import Bun from 'bun';
|
||||
import {sql} from "bun";
|
||||
import { sql } from 'bun';
|
||||
|
||||
beforeAll(async () => {
|
||||
console.log(process.env.DATABASE_URL);
|
||||
@@ -24,12 +24,3 @@ beforeAll(async () => {
|
||||
await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_OTHER_READ', true)`;
|
||||
await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_SELF_DELETE', false)`;
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {expect, test} from 'bun:test';
|
||||
import user from '../endpoints/user';
|
||||
import {UnwrappedRequest} from "../utilities/guard";
|
||||
import {Claims} from "../orm/claims";
|
||||
import { expect, test } from 'bun:test';
|
||||
import user from '../endpoints/users';
|
||||
import { UnwrappedRequest } from '../utilities/guard';
|
||||
import { Claims } from '../orm/claims';
|
||||
import { orm } from '../orm/orm';
|
||||
import { User } from '../orm/user';
|
||||
|
||||
test('Create user as admin', async () => {
|
||||
const claims = new Claims();
|
||||
claims.claims.push('ADMIN');
|
||||
claims.claims.push(Claims.ADMIN);
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
@@ -24,7 +26,7 @@ test('Create user as admin', async () => {
|
||||
|
||||
test('Create user without read access', async () => {
|
||||
const claims = new Claims();
|
||||
claims.claims.push('USERS_CREATE');
|
||||
claims.claims.push(Claims.USERS.CREATE);
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
@@ -43,7 +45,7 @@ test('Create user without read access', async () => {
|
||||
|
||||
test('Create user that already exists', async () => {
|
||||
const claims = new Claims();
|
||||
claims.claims.push('USERS_CREATE');
|
||||
claims.claims.push(Claims.USERS.CREATE);
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
@@ -61,13 +63,13 @@ test('Create user that already exists', async () => {
|
||||
|
||||
test('Get user', async () => {
|
||||
const claims = new Claims();
|
||||
claims.claims.push('USERS_OTHER_READ');
|
||||
claims.claims.push(Claims.USERS.OTHER.READ);
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
request: null,
|
||||
params: {
|
||||
id: 1
|
||||
id: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -79,14 +81,14 @@ test('Get user', async () => {
|
||||
|
||||
test('Get user self with only self read permission', async () => {
|
||||
const claims = new Claims();
|
||||
claims.userId = "1";
|
||||
claims.claims.push('USERS_OTHER_READ');
|
||||
claims.userId = '1';
|
||||
claims.claims.push(Claims.USERS.OTHER.READ);
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
request: null,
|
||||
params: {
|
||||
id: 1
|
||||
id: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -98,14 +100,14 @@ test('Get user self with only self read permission', async () => {
|
||||
|
||||
test('Get other user without read permissions', async () => {
|
||||
const claims = new Claims();
|
||||
claims.userId = "2";
|
||||
claims.claims.push('USERS_SELF_READ');
|
||||
claims.userId = '2';
|
||||
claims.claims.push(Claims.USERS.SELF.READ);
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
request: null,
|
||||
params: {
|
||||
id: 1
|
||||
id: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -113,18 +115,170 @@ test('Get other user without read permissions', async () => {
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test('Get user that doesn\'t exist', async () => {
|
||||
test("Get user that doesn't exist", async () => {
|
||||
const claims = new Claims();
|
||||
claims.claims.push('ADMIN');
|
||||
claims.claims.push(Claims.ADMIN);
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
request: null,
|
||||
params: {
|
||||
id: 101
|
||||
id: 101,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await user.get(request);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
test('Update user', async () => {
|
||||
const claims = new Claims();
|
||||
claims.claims.push(Claims.ADMIN);
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
request: null,
|
||||
json: {
|
||||
isAdmin: true,
|
||||
},
|
||||
params: {
|
||||
id: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await user.update(request);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toBeDefined();
|
||||
});
|
||||
|
||||
test('Update user without read access', async () => {
|
||||
const claims = new Claims();
|
||||
claims.userId = '1';
|
||||
claims.claims.push(Claims.USERS.OTHER.UPDATE);
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
request: null,
|
||||
json: {
|
||||
isAdmin: true,
|
||||
},
|
||||
params: {
|
||||
id: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await user.update(request);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toBeNull();
|
||||
});
|
||||
|
||||
test('Update user without permissions', async () => {
|
||||
const claims = new Claims();
|
||||
claims.userId = '1';
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
request: null,
|
||||
json: {
|
||||
isAdmin: true,
|
||||
},
|
||||
params: {
|
||||
id: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await user.update(request);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("Update user that doesn't exist", async () => {
|
||||
const claims = new Claims();
|
||||
claims.userId = '1';
|
||||
claims.claims.push(Claims.ADMIN);
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
request: null,
|
||||
json: {
|
||||
isAdmin: true,
|
||||
},
|
||||
params: {
|
||||
id: 101,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await user.update(request);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
test('Delete user', async () => {
|
||||
const claims = new Claims();
|
||||
claims.claims.push(Claims.ADMIN);
|
||||
|
||||
const createdUser = (await orm.users.create('test3', 'test123')) as User;
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
request: null,
|
||||
params: {
|
||||
id: createdUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await user.drop(request);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test('Delete user without delete permissions', async () => {
|
||||
const claims = new Claims();
|
||||
const createdUser = (await orm.users.create('test4', 'test123')) as User;
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
request: null,
|
||||
params: {
|
||||
id: createdUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await user.drop(request);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test('Delete self user with only self delete permissions', async () => {
|
||||
const claims = new Claims();
|
||||
claims.claims.push(Claims.USERS.SELF.DELETE);
|
||||
|
||||
const createdUser = (await orm.users.create('test5', 'test123')) as User;
|
||||
claims.userId = createdUser.id;
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
request: null,
|
||||
params: {
|
||||
id: createdUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await user.drop(request);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test('Delete other user with only self delete permissions', async () => {
|
||||
const claims = new Claims();
|
||||
claims.userId = '1';
|
||||
claims.claims.push(Claims.USERS.SELF.DELETE);
|
||||
|
||||
const createdUser = (await orm.users.create('test6', 'test123')) as User;
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
request: null,
|
||||
params: {
|
||||
id: createdUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await user.drop(request);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
129
src/utilities/claimDefinitions.ts
Normal file
129
src/utilities/claimDefinitions.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
export class ClaimDefinition {
|
||||
public static readonly ADMIN = 'ADMIN';
|
||||
public static readonly USERS = {
|
||||
CREATE: 'USERS_CREATE',
|
||||
INVITE: 'USERS_INVITE',
|
||||
SELF: {
|
||||
READ: 'USERS_SELF_READ',
|
||||
UPDATE: 'USERS_SELF_UPDATE',
|
||||
DELETE: 'USERS_SELF_DELETE',
|
||||
},
|
||||
OTHER: {
|
||||
READ: 'USERS_OTHER_READ',
|
||||
UPDATE: 'USERS_OTHER_UPDATE',
|
||||
DELETE: 'USERS_OTHER_DELETE',
|
||||
},
|
||||
};
|
||||
public static readonly PLAYERS = {
|
||||
CREATE: 'PLAYERS_CREATE',
|
||||
SELF: {
|
||||
READ: 'PLAYERS_SELF_READ',
|
||||
UPDATE: 'PLAYERS_SELF_UPDATE',
|
||||
DELETE: 'PLAYERS_SELF_DELETE',
|
||||
},
|
||||
OTHER: {
|
||||
READ: 'PLAYERS_OTHER_READ',
|
||||
UPDATE: 'PLAYERS_OTHER_UPDATE',
|
||||
DELETE: 'PLAYERS_OTHER_DELETE',
|
||||
},
|
||||
};
|
||||
public static readonly CIRCLES = {
|
||||
PUBLIC: {
|
||||
READ: 'CIRCLES_OWNED_READ',
|
||||
CREATE: 'CIRCLES_PUBLIC_CREATE',
|
||||
JOIN: 'CIRCLES_PUBLIC_JOIN',
|
||||
COMMENTS: {
|
||||
ADD: 'CIRCLES_PUBLIC_COMMENTS_ADD',
|
||||
},
|
||||
USERS: {
|
||||
INVITE: 'CIRCLES_PUBLIC_USER_INVITE',
|
||||
LIST: 'CIRCLES_PUBLIC_USER_LIST',
|
||||
},
|
||||
},
|
||||
PRIVATE: {
|
||||
READ: 'CIRCLES_PRIVATE_READ',
|
||||
READ_IF_MEMBER: 'CIRCLES_PRIVATE_READ_IF_MEMBER',
|
||||
CREATE: 'CIRCLES_PRIVATE_CREATE',
|
||||
COMMENTS: {
|
||||
ADD: 'CIRCLES_PRIVATE_COMMENTS_ADD',
|
||||
},
|
||||
},
|
||||
OWNED: {
|
||||
READ: 'CIRCLES_OWNED_READ',
|
||||
UPDATE: 'CIRCLES_OWNED_UPDATE',
|
||||
DELETE: 'CIRCLES_OWNED_DELETE',
|
||||
PLAYERS: {
|
||||
ADD: 'CIRCLES_OWNED_USER_ADD',
|
||||
LIST: 'CIRCLES_OWNED_USER_LIST',
|
||||
},
|
||||
USERS: {
|
||||
INVITE: 'CIRCLES_OWNED_USER_INVITE',
|
||||
},
|
||||
COMMENTS: {
|
||||
ADD: 'CIRCLES_OWNED_COMMENTS_ADD',
|
||||
DELETE: 'CIRCLES_OWNED_COMMENTS_DELETE',
|
||||
},
|
||||
},
|
||||
};
|
||||
public static readonly GAMES = {
|
||||
CREATE: 'GAMES_CREATE',
|
||||
READ: 'GAMES_READ',
|
||||
UPDATE: 'GAMES_UPDATE',
|
||||
DELETE: 'GAMES_DELETE',
|
||||
MANAGE_IMAGES: 'GAMES_IMAGES_MANAGE',
|
||||
};
|
||||
public static readonly MATCHES = {
|
||||
CREATE: 'MATCHES_CREATE',
|
||||
COMMENTS: {
|
||||
ADD: 'MATCHES_UNOWNED_COMMENTS_ADD',
|
||||
},
|
||||
OWNED: {
|
||||
READ: 'MATCHES_OWNED_READ',
|
||||
DELETE: 'MATCHES_OWNED_DELETE',
|
||||
COMMENTS: {
|
||||
ADD: 'MATCHES_OWNED_COMMENTS_ADD',
|
||||
DELETE: 'MATCHES_OWNED_COMMENTS_DELETE',
|
||||
},
|
||||
},
|
||||
PARTICIPANT: {
|
||||
READ: 'MATCHES_PARTICIPANT_READ',
|
||||
LEAVE: 'MATCHES_LEAVE',
|
||||
COMMENTS: {
|
||||
ADD: 'MATCHES_PARTICIPANT_COMMENTS_ADD',
|
||||
},
|
||||
}
|
||||
};
|
||||
public static readonly COLLECTIONS = {
|
||||
CREATE: 'COLLECTIONS_CREATE',
|
||||
OWNED: {
|
||||
READ: 'COLLECTIONS_OWNED_READ',
|
||||
UPDATE: 'COLLECTIONS_OWNED_UPDATE',
|
||||
DELETE: 'COLLECTIONS_OWNED_DELETE',
|
||||
LIST: 'COLLECTIONS_OWNED_LIST',
|
||||
GAME: {
|
||||
ADD: 'COLLECTIONS_OWNED_GAME_ADD',
|
||||
REMOVE: 'COLLECTIONS_OWNED_GAME_REMOVE',
|
||||
},
|
||||
COMMENTS: {
|
||||
DELETE: 'COLLECTIONS_OWNED_COMMENTS_DELETE',
|
||||
},
|
||||
},
|
||||
UNOWNED: {
|
||||
READ: 'COLLECTIONS_UNOWNED_READ',
|
||||
UPDATE: 'COLLECTIONS_UNOWNED_UPDATE',
|
||||
DELETE: 'COLLECTIONS_UNOWNED_DELETE',
|
||||
},
|
||||
};
|
||||
public static readonly COMMENTS = {
|
||||
OWNED: {
|
||||
READ: 'COMMENTS_OWNED_READ',
|
||||
UPDATE: 'COMMENTS_OWNED_UPDATE',
|
||||
DELETE: 'COMMENTS_OWNED_DELETE',
|
||||
},
|
||||
UNOWNED: {
|
||||
READ: 'COMMENTS_UNOWNED_READ',
|
||||
UPDATE: 'COMMENTS_UNOWNED_UPDATE',
|
||||
DELETE: 'COMMENTS_UNOWNED_DELETE',
|
||||
},
|
||||
};
|
||||
}
|
||||
39
src/utilities/elo.ts
Normal file
39
src/utilities/elo.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { orderBy } from 'lodash';
|
||||
import { MatchParticipant } from '../orm/matches';
|
||||
|
||||
export function calculateElos(players: MatchParticipant[], provisionalPeriod: number = 1): MatchParticipant[] {
|
||||
const orderedResults = orderBy(players, (x:any) => parseInt(x.standing ?? 0), 'asc');
|
||||
for (let i = 0; i < orderedResults.length - 1; i++) {
|
||||
for (let j = i + 1; j < orderedResults.length; j++) {
|
||||
const challengerResults = calculateEloChange(
|
||||
orderedResults[i].elo,
|
||||
orderedResults[j].elo,
|
||||
orderedResults[i].standing === orderedResults[j].standing,
|
||||
);
|
||||
orderedResults[i].eloChange =
|
||||
(orderedResults[i].eloChange ?? 0) +
|
||||
challengerResults.winnerChange *
|
||||
Math.min(1, ((orderedResults[j].gamesPlayed as number) + 1) / provisionalPeriod);
|
||||
orderedResults[j].eloChange =
|
||||
(orderedResults[j].eloChange ?? 0) +
|
||||
challengerResults.loserChange *
|
||||
Math.min(1, ((orderedResults[i].gamesPlayed as number) + 1) / provisionalPeriod);
|
||||
}
|
||||
}
|
||||
return orderedResults;
|
||||
}
|
||||
|
||||
interface EloResult {
|
||||
winnerChange: number;
|
||||
loserChange: number;
|
||||
}
|
||||
function calculateEloChange(winnerElo: number, loserElo: number, draw: boolean = false): EloResult {
|
||||
const ratingStep = 32;
|
||||
const expectedWinnerResult = 1 / (1 + Math.pow(10, (loserElo - winnerElo) / 400));
|
||||
const expectedLoserResult = 1 / (1 + Math.pow(10, (winnerElo - loserElo) / 400));
|
||||
|
||||
return {
|
||||
winnerChange: ratingStep * ((draw ? 0.5 : 1) - expectedWinnerResult),
|
||||
loserChange: ratingStep * ((draw ? 0.5 : 0) - expectedLoserResult),
|
||||
};
|
||||
}
|
||||
@@ -1,17 +1,28 @@
|
||||
export class BadRequestError extends Error {
|
||||
constructor(message: string | undefined = undefined) {
|
||||
constructor(message?: string | undefined) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalServerError extends Error {
|
||||
constructor(message?: string | undefined) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor(message: string | undefined = undefined) {
|
||||
constructor(message?: string | undefined) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends Error {
|
||||
constructor(message: string | undefined = undefined) {
|
||||
constructor(message?: string | undefined) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
export class NotImplementedError extends Error {
|
||||
constructor(message?: string | undefined) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +1,73 @@
|
||||
import {BunRequest as Request} from 'bun';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import {ErrorResponse} from "./responseHelper";
|
||||
import {UnauthorizedError} from "./errors";
|
||||
import {Claims} from "../orm/claims";
|
||||
import { BunRequest as Request } from 'bun';
|
||||
import jwt, { TokenExpiredError } from 'jsonwebtoken';
|
||||
import { ErrorResponse, UnauthorizedResponse } from './responseHelper';
|
||||
import { Claims } from '../orm/claims';
|
||||
|
||||
export function guardRedirect(method: Function, redirectMethod: Function, guardedClaims: string[] | undefined = undefined) {
|
||||
export function guardRedirect(
|
||||
method: (request: UnwrappedRequest<any>) => Promise<Response> | Response,
|
||||
redirectMethod: Function,
|
||||
...guardedClaims: string[]
|
||||
) {
|
||||
try {
|
||||
return guard(method, guardedClaims);
|
||||
return guard(method, ...guardedClaims);
|
||||
} catch (e) {
|
||||
return redirectMethod();
|
||||
}
|
||||
}
|
||||
|
||||
export function guard(method: Function, guardedClaims: string[] | undefined = undefined): (r: Request) => Promise<Response> {
|
||||
export function guard(
|
||||
method: (request: UnwrappedRequest<any>) => Promise<Response> | Response,
|
||||
...guardedClaims: string[]
|
||||
): (r: Request) => Promise<Response> {
|
||||
return async (request: Request): Promise<Response> => {
|
||||
const authHeader: string | null = request.headers.get('Authorization')?.replace(/^Bearer /, '') as string ?? null;
|
||||
const authHeader: string | null =
|
||||
(request.headers.get('Authorization')?.replace(/^Bearer /, '') as string) ?? null;
|
||||
try {
|
||||
const userClaims: Claims = jwt.verify(authHeader as string, process.env.JWT_SECRET_KEY as string) as Claims;
|
||||
if (guardedClaims !== undefined && !userClaims.claims.some((x: string): boolean => guardedClaims.includes(x))) {
|
||||
throw new UnauthorizedError('Unauthorized');
|
||||
const userClaims: Claims = new Claims(
|
||||
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 method(await unwrap(request, userClaims));
|
||||
} catch (error: any) {
|
||||
console.log(error);
|
||||
if (error instanceof TokenExpiredError) {
|
||||
return new UnauthorizedResponse(error.message);
|
||||
}
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export class UnwrappedRequest {
|
||||
readonly json: any;
|
||||
export class UnwrappedRequest<T = {}> {
|
||||
readonly body: T;
|
||||
readonly request: Request;
|
||||
readonly params: { [x: string]: string };
|
||||
readonly claims: Claims;
|
||||
|
||||
constructor(input: any) {
|
||||
this.json = input.json;
|
||||
this.body = input.body;
|
||||
this.request = input.request;
|
||||
this.claims = input.claims;
|
||||
this.claims = input.claims || new Claims();
|
||||
this.params = input.params;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unwrap(request: Request, claims: Claims | null = null) {
|
||||
return new UnwrappedRequest({
|
||||
export async function unwrap<T = {}>(request: Request, claims?: Claims) {
|
||||
return new UnwrappedRequest<T>({
|
||||
request,
|
||||
claims,
|
||||
json: request.body ? await request.json() : null,
|
||||
body: request.body ? await request.json() : null,
|
||||
params: request.params,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function unwrapMethod(methodToUnwrap: ((r: UnwrappedRequest) => Response) | ((r: UnwrappedRequest) => Promise<Response>)): (r: Request) => Promise<Response> {
|
||||
export function unwrapMethod<T = {}>(
|
||||
methodToUnwrap: ((r: UnwrappedRequest<T>) => Response) | ((r: UnwrappedRequest<T>) => Promise<Response>),
|
||||
): (r: Request) => Promise<Response> {
|
||||
return async (request: Request) => {
|
||||
const unwrappedRequest = await unwrap(request);
|
||||
return await methodToUnwrap(unwrappedRequest);
|
||||
const unwrappedRequest = await unwrap<T>(request);
|
||||
return methodToUnwrap(unwrappedRequest);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
37
src/utilities/helpers.ts
Normal file
37
src/utilities/helpers.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export function memo<T extends (...args: any[]) => {}, S>(
|
||||
func: T,
|
||||
lifespan: number = 5 * 60 * 1000,
|
||||
keyDelegate?: (...args: any[]) => string,
|
||||
): T {
|
||||
const cache: { [key: string]: { value: S; timestamp: number } } = {};
|
||||
return ((...args: any[]): S => {
|
||||
const key: string = (keyDelegate ? keyDelegate(...args) : args?.[0]?.toString()) ?? '';
|
||||
const now = Date.now();
|
||||
|
||||
if (!cache[key] || now - cache[key].timestamp > lifespan) {
|
||||
cache[key] = {
|
||||
value: func(...args) as S,
|
||||
timestamp: now,
|
||||
};
|
||||
}
|
||||
|
||||
return cache[key].value;
|
||||
}) as unknown as T;
|
||||
}
|
||||
|
||||
export function createRandomString(length:number = 6):string {
|
||||
const maxRandStringVal = parseInt(''.padEnd(length, 'z'), 36);
|
||||
return Math.floor(Math.random() * maxRandStringVal).toString(36).toUpperCase();
|
||||
}
|
||||
|
||||
export const brandColours = {
|
||||
dark: '#14111C',
|
||||
mid: '#CBCACB',
|
||||
light: '#FBF8FC',
|
||||
white: '#FFFFFF',
|
||||
black: '#000000',
|
||||
primary: '#CA00E7',
|
||||
secondary: '#FFB527',
|
||||
tertiary: '#6ED500',
|
||||
danger: '#CA3211',
|
||||
};
|
||||
72
src/utilities/requestModels.ts
Normal file
72
src/utilities/requestModels.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
export interface ChangePasswordRequest {
|
||||
oldPassword: string | null;
|
||||
newPassword: string;
|
||||
}
|
||||
export interface CreateUserRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
playerId: string;
|
||||
}
|
||||
export interface UpdateUserRequest {
|
||||
isActive?: boolean;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
export interface InviteUserRequest {
|
||||
email: string;
|
||||
playerId: string;
|
||||
}
|
||||
export interface AcceptInviteRequest {
|
||||
inviteCode: string;
|
||||
password: string;
|
||||
}
|
||||
export interface CreatePlayerRequest {
|
||||
name: string;
|
||||
}
|
||||
export interface UpdatePlayerRequest {
|
||||
name?: string;
|
||||
isRatingLocked?: boolean;
|
||||
canBeMultiple?: boolean;
|
||||
}
|
||||
export interface CreateGameRequest {
|
||||
name: string;
|
||||
imagePath?: string;
|
||||
bggId?: string;
|
||||
}
|
||||
export interface UpdateGameRequest {
|
||||
name: string;
|
||||
imagePath?: string;
|
||||
bggId?: string;
|
||||
}
|
||||
export interface CreateCollectionRequest {
|
||||
name: string;
|
||||
}
|
||||
export interface UpdateCollectionRequest {
|
||||
name?: string;
|
||||
}
|
||||
export interface GameToCollectionRequest {
|
||||
gameId: string;
|
||||
}
|
||||
export interface CreateMatchRequest {
|
||||
gameId: string;
|
||||
participants: { playerId: string; standing: number }[];
|
||||
}
|
||||
export interface CreateCircleRequest {
|
||||
name: string;
|
||||
isPublic: boolean;
|
||||
imagePath?: string;
|
||||
colour: string;
|
||||
}
|
||||
export interface UpdateCircleRequest {
|
||||
name: string;
|
||||
imagePath?: string;
|
||||
colour: string;
|
||||
}
|
||||
export interface InviteToCircleRequest {
|
||||
email?:string;
|
||||
userId?:string;
|
||||
playerId?:string;
|
||||
}
|
||||
@@ -1,18 +1,87 @@
|
||||
import {BadRequestError, NotFoundError, UnauthorizedError} from "./errors";
|
||||
import { BadRequestError, NotFoundError, UnauthorizedError } from './errors';
|
||||
import { clamp, isArray, isObject } from 'lodash';
|
||||
import { UnwrappedRequest } from './guard';
|
||||
|
||||
export class ErrorResponse extends Response {
|
||||
//@ts-ignore
|
||||
constructor(error: Error) {
|
||||
if(error instanceof BadRequestError) {
|
||||
return Response.json({message: error.message}, {status: 400});
|
||||
}
|
||||
else if(error instanceof UnauthorizedError){
|
||||
return Response.json({message: error.message}, {status: 401});
|
||||
}
|
||||
else if(error instanceof NotFoundError){
|
||||
return Response.json({message: error.message}, {status: 404});
|
||||
if (error instanceof BadRequestError) {
|
||||
return new BadRequestResponse(error.message);
|
||||
} else if (error instanceof UnauthorizedError) {
|
||||
return new UnauthorizedResponse(error.message);
|
||||
} else if (error instanceof NotFoundError) {
|
||||
return new NotFoundResponse(error.message);
|
||||
}
|
||||
|
||||
return Response.json({message: error.message}, {status: 500});
|
||||
return Response.json({ message: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class BadRequestResponse extends Response {
|
||||
// @ts-ignore
|
||||
constructor(message?: string) {
|
||||
return Response.json({ message: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedResponse extends Response {
|
||||
// @ts-ignore
|
||||
constructor(message?: string) {
|
||||
return Response.json({ message: message }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundResponse extends Response {
|
||||
// @ts-ignore
|
||||
constructor(message?: string) {
|
||||
return Response.json({ message: message }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
export class 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 {
|
||||
// @ts-ignore
|
||||
constructor(body?: any) {
|
||||
if (body) {
|
||||
return Response.json(
|
||||
isObject(body) && !isArray(body)
|
||||
? {
|
||||
...body,
|
||||
}
|
||||
: body,
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class CreatedResponse extends Response {
|
||||
// @ts-ignore
|
||||
constructor(body?: any) {
|
||||
if (body) {
|
||||
return Response.json({ ...body }, { status: 201 });
|
||||
}
|
||||
|
||||
return new Response(null, { status: 201 });
|
||||
}
|
||||
}
|
||||
|
||||
42
src/utilities/routeBuilder.ts
Normal file
42
src/utilities/routeBuilder.ts
Normal 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
169
src/utilities/secureIds.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user