Compare commits

...

5 Commits

52 changed files with 1583 additions and 174 deletions

View File

@@ -1,2 +1,3 @@
DATABASE_URL=postgres://ApiUser:2<KtJ=*5`;19@192.168.1.166:5432/bgApp
JWT_SECRET_KEY=MySecret
DATABASE_URL=
JWT_SECRET_KEY=
RESEND_KEY=

View File

@@ -1,2 +1,3 @@
DATABASE_URL=postgres://admin:iiyama12@192.168.1.166:5432/bgAppTest
JWT_SECRET_KEY=MySecret
DATABASE_URL=
JWT_SECRET_KEY=
RESEND_KEY=

2
.gitignore vendored
View File

@@ -4,3 +4,5 @@ bun.lock
package-lock.json
.dockerignore
bgapp
.env.dev
.env.test

View File

@@ -0,0 +1,25 @@
info:
name: Login
type: http
seq: 1
http:
method: POST
url: http://localhost:3000/api/auth/login
headers:
- name: Content-Type
value: application/json
body:
type: json
data: |-
{
"email":"james@dardry.com",
"password":"Foobar"
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,18 @@
info:
name: Token
type: http
seq: 2
http:
method: GET
url: http://localhost:3000/api/auth/token
headers:
- name: Cookie
value: refresh=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1IjoiMSIsInIiOiIxIiwiaWF0IjoxNzcxNTk3NjQ2LCJleHAiOjE3NzQxODk2NDZ9.07ViS5Nie3Bi2OgnlHyybDNZ9bdXPRRiqO-RFLhjoKo; Path=/; Max-Age=2592000; Secure; HttpOnly; SameSite=Lax
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,4 @@
info:
name: Auth
type: folder
seq: 1

View File

@@ -0,0 +1,21 @@
info:
name: Create
type: http
seq: 1
http:
method: POST
url: http://localhost:3000/api/game
body:
type: json
data: |-
{
"name": "Test Game3"
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,19 @@
info:
name: Delete
type: http
seq: 4
http:
method: DELETE
url: http://localhost:3000/api/game/:id
params:
- name: id
value: bk5e
type: path
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,19 @@
info:
name: Get
type: http
seq: 2
http:
method: GET
url: http://localhost:3000/api/game/:id
params:
- name: id
value: bk5e
type: path
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,19 @@
info:
name: Search
type: http
seq: 5
http:
method: GET
url: http://localhost:3000/api/game/search/:query
params:
- name: query
value: game
type: path
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,25 @@
info:
name: Update
type: http
seq: 3
http:
method: PATCH
url: http://localhost:3000/api/game/:id
params:
- name: id
value: el5a
type: path
body:
type: json
data: |-
{
"name":"Updated game"
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,7 @@
info:
name: Game
type: folder
seq: 4
request:
auth: inherit

View File

@@ -0,0 +1,22 @@
info:
name: Accept
type: http
seq: 2
http:
method: POST
url: http://localhost:3000/api/invite/accept
body:
type: json
data: |-
{
"inviteCode": "3ST6N8",
"password": "test123"
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,22 @@
info:
name: Create
type: http
seq: 5
http:
method: POST
url: http://localhost:3000/api/invite
body:
type: json
data: |-
{
"email": "james+test2@dardry.com",
"playerId": "boja"
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,7 @@
info:
name: Invites
type: folder
seq: 5
request:
auth: inherit

View File

@@ -0,0 +1,21 @@
info:
name: Create
type: http
seq: 1
http:
method: POST
url: http://localhost:3000/api/player
body:
type: json
data: |-
{
"name": "Invited player2"
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,19 @@
info:
name: Delete
type: http
seq: 4
http:
method: DELETE
url: http://localhost:3000/api/player/:id
params:
- name: id
value: bmOe
type: path
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,19 @@
info:
name: Get
type: http
seq: 2
http:
method: GET
url: http://localhost:3000/api/player/:id
params:
- name: id
value: ejRe
type: path
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,15 @@
info:
name: List
type: http
seq: 5
http:
method: GET
url: http://localhost:3000/api/player/list
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,27 @@
info:
name: Update
type: http
seq: 3
http:
method: PATCH
url: http://localhost:3000/api/player/:id
params:
- name: id
value: bmOe
type: path
body:
type: json
data: |-
{
"name": "Test Player",
"isRatingLocked": true,
"canBeMultiple": false
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,7 @@
info:
name: Players
type: folder
seq: 2
request:
auth: inherit

View File

@@ -0,0 +1,23 @@
info:
name: Create
type: http
seq: 1
http:
method: POST
url: http://localhost:3000/api/user
body:
type: json
data: |-
{
"email": "Test User",
"password": "Test123",
"playerId": "enRe"
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,19 @@
info:
name: Delete
type: http
seq: 4
http:
method: DELETE
url: http://localhost:3000/api/user/:id
params:
- name: id
value: ""
type: path
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,19 @@
info:
name: Get
type: http
seq: 2
http:
method: GET
url: http://localhost:3000/api/user/:id
params:
- name: id
value: ejRe
type: path
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,26 @@
info:
name: Update
type: http
seq: 3
http:
method: PATCH
url: http://localhost:3000/api/user/:id
params:
- name: id
value: ""
type: path
body:
type: json
data: |-
{
"isActive": true,
"isAdmin": false
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,7 @@
info:
name: User
type: folder
seq: 3
request:
auth: inherit

View File

@@ -0,0 +1,40 @@
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: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJlalJlIiwiY2xhaW1zIjpbIkFETUlOIiwiVVNFUlNfQ1JFQVRFIiwiVVNFUlNfU0VMRl9SRUFEIiwiVVNFUlNfU0VMRl9VUERBVEUiLCJVU0VSU19TRUxGX0RFTEVURSIsIlVTRVJTX09USEVSX1JFQUQiLCJVU0VSU19PVEhFUl9VUERBVEUiXSwiaWF0IjoxNzcxNjE4NTQzLCJleHAiOjE4MDMxNTQ1NDN9.R-3Qb5CEcLJBSt7DnsO9b0IGRVYDIZuFfH1m9TikVXU
actions:
- type: set-variable
phase: after-response
selector:
expression: ${token}
method: jsonq
variable:
name: Token
scope: runtime
disabled: true
bundled: false
extensions:
bruno:
ignore:
- node_modules
- .git
presets:
request:
type: http
url: http://localhost:3000/api/

View File

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

View File

@@ -10,13 +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",
"reflect-metadata": "^0.2.2"
"react": "^19.2.4",
"reflect-metadata": "^0.2.2",
"resend": "^6.9.2"
},
"devDependencies": {
"@types/bun": "^1.3.9"

File diff suppressed because it is too large Load Diff

77
src/emails/invite.tsx Normal file
View File

@@ -0,0 +1,77 @@
import * as React from 'react';
import { brandColours } from '../utilities/helpers';
import { size } from 'lodash';
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}
>
<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>
</table>
</div>
);

View File

@@ -10,7 +10,7 @@ async function login(request: UnwrappedRequest<LoginRequest>): Promise<Response>
const verify: {
userId: SecureId;
refreshCount: string;
} | null = await orm.users.verifyCredentials(request.body.username, request.body.password);
} | null = await orm.users.verifyCredentials(request.body.email, request.body.password);
if (!verify) {
return new UnauthorizedResponse('Invalid credentials');
}
@@ -32,6 +32,7 @@ async function login(request: UnwrappedRequest<LoginRequest>): Promise<Response>
httpOnly: true,
secure: true,
maxAge: tokenLifeSpanInDays * 24 * 60 * 60,
path: '/api/auth/token'
});
return new OkResponse();
} catch (error: any) {
@@ -60,7 +61,9 @@ async function token(request: UnwrappedRequest): Promise<Response> {
const claims: Claims | null = await orm.claims.getByUserId(refreshToken.u);
const token = jwt.sign({ ...claims }, process.env.JWT_SECRET_KEY as string, { expiresIn: '1h' });
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);

37
src/endpoints/invite.ts Normal file
View File

@@ -0,0 +1,37 @@
import { orm } from '../orm/orm';
import { UnwrappedRequest } from '../utilities/guard';
import { CreatedResponse, ErrorResponse } from '../utilities/responseHelper';
import {
AcceptInviteRequest,
InviteUserRequest,
SecureId,
} from '../utilities/requestModels';
async function create(request: UnwrappedRequest<InviteUserRequest>): Promise<Response> {
try {
const newUser = await orm.invites.create(
{
...request.body,
playerId: SecureId.fromHash(request.body.playerId),
invitedByUserId: request.claims.userId as SecureId,
},
);
return new CreatedResponse(newUser);
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function accept(request: UnwrappedRequest<AcceptInviteRequest>): Promise<Response> {
try {
const newUser = await orm.invites.accept(request.body);
return new CreatedResponse(newUser);
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
export default {
create,
accept,
};

View File

@@ -20,6 +20,14 @@ async function get(request: UnwrappedRequest): Promise<Response> {
}
}
async function list(request: UnwrappedRequest): Promise<Response> {
try {
return new OkResponse(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(
@@ -41,6 +49,7 @@ async function drop(request: UnwrappedRequest): Promise<Response> {
export default {
create,
get,
list,
update,
drop,
};

View File

@@ -29,11 +29,7 @@ async function get(request: UnwrappedRequest): Promise<Response> {
async function update(request: UnwrappedRequest<UpdateUserRequest>): Promise<Response> {
try {
return new OkResponse(
await orm.users.update(
SecureId.fromHash(request.params.id),
request.body,
request.claims,
),
await orm.users.update(SecureId.fromHash(request.params.id), request.body, request.claims),
);
} catch (error: any) {
return new ErrorResponse(error as Error);

View File

@@ -3,6 +3,7 @@ import user from './routes/user';
import player from './routes/player';
import game from './routes/game';
import { OkResponse } from './utilities/responseHelper';
import invite from './routes/invite';
const server = Bun.serve({
routes: {
@@ -10,6 +11,7 @@ const server = Bun.serve({
...user,
...player,
...game,
...invite,
'/test': {
GET: () => {
return new OkResponse();

View File

@@ -1,10 +1,17 @@
import { sql } from 'bun';
import { ClaimDefinition } from '../utilities/claimDefinitions';
import { SecureId } from '../utilities/requestModels';
export class Claims extends ClaimDefinition {
userId?: string;
userId?: SecureId;
claims: string[] = [];
constructor(raw?:{userId?:string, claims?: string[]}) {
super();
this.userId = raw?.userId ? SecureId.fromHash(raw.userId) : undefined;
this.claims = raw?.claims ?? [];
}
public static test(guardClaim: string, userClaims?: Claims): Boolean {
return userClaims === undefined || userClaims.claims.some((x) => x === guardClaim);
}
@@ -17,7 +24,7 @@ export class ClaimsOrm {
JOIN claims as c on uc.claim_id = c.id
where uc.user_id = ${userId};`;
const claims = new Claims();
claims.userId = userId;
claims.userId = SecureId.fromID(userId);
claims.claims = dbResults.map((x) => x.name);
return claims;
}

View File

@@ -20,19 +20,12 @@ export class Game {
}
export class GamesOrm {
async create(model: CreateGameRequest, claims?: Claims): Promise<Game | null> {
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, claims) ? model.imagePath : null}, ${model.bggId})`;
const newGameId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string;
try {
return await this.get(SecureId.fromID(newGameId));
} catch (error) {
if (error instanceof UnauthorizedError) {
return null;
}
throw error;
}
return await this.get(SecureId.fromID(newGameId));
}
async get(id: SecureId): Promise<Game> {
@@ -55,7 +48,7 @@ export class GamesOrm {
});
}
async update(id: SecureId, patch: UpdateGameRequest, claims?: Claims): Promise<Game | null> {
async update(id: SecureId, patch: UpdateGameRequest, claims?: Claims): Promise<Game> {
const gameToUpdate = await this.get(id);
gameToUpdate.name = patch.name ?? gameToUpdate.name;
gameToUpdate.bggId = patch.bggId ?? gameToUpdate.bggId;
@@ -70,17 +63,10 @@ export class GamesOrm {
image_path=${gameToUpdate.imagePath}
WHERE id = ${id.raw}`;
try {
return await this.get(id);
} catch (error) {
if (error instanceof UnauthorizedError) {
return null;
}
throw error;
}
return await this.get(id);
}
async drop(id: SecureId): Promise<undefined> {
async drop(id: SecureId): Promise<void> {
// Ensure player exists before attempting to delete
await this.get(id);
await sql.transaction(async (tx) => {

133
src/orm/invites.ts Normal file
View File

@@ -0,0 +1,133 @@
import { sql } from 'bun';
import { first } from 'lodash';
import { BadRequestError, InternalServerError, NotFoundError, UnauthorizedError } from '../utilities/errors';
import { SecureId } from '../utilities/requestModels';
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: SecureId;
invitedByUserId: SecureId;
},
claims?: Claims,
): Promise<void> {
if (!Claims.test(Claims.ADMIN, claims)) {
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 inviteExists = (
first(
await sql`SELECT COUNT(*) > 0 AS exists
FROM user_invites
WHERE player_id = ${playerId.raw}
OR email = ${email}`,
) as {
exists: boolean;
}
)?.exists;
if (inviteExists) {
throw new BadRequestError('Player has already been invited.');
}
}
const playerHasUser = (
first(
await sql`SELECT COUNT(*) > 0 AS exists
FROM users
WHERE player_id = ${playerId.raw}
OR email = ${email}`,
) as {
exists: boolean;
}
)?.exists;
if (playerHasUser) {
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 newInviteId: 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=${newInviteId}`;
return;
}
async accept({ inviteCode, password }: { inviteCode: string; password: string }): Promise<User> {
const invite: {
id: string;
email: string;
player_id: string;
accepted: boolean;
} = first(await sql`SELECT * FROM user_invites WHERE invite_code=${inviteCode} LIMIT 1`);
if (!invite) {
throw new NotFoundError('Invalid invite code');
}
if (invite.accepted) {
throw new UnauthorizedError('Invite already accepted');
}
const playerHasUser = (
first(
await sql`SELECT COUNT(*) > 0 AS exists
FROM users
WHERE player_id = ${invite.player_id}
OR email = ${invite.email}`,
) as {
exists: boolean;
}
)?.exists;
if (playerHasUser) {
throw new BadRequestError('User has already been invited.');
}
const createdUser = await orm.users.create({
email: invite.email,
playerId: SecureId.fromID(invite.player_id),
password,
});
await sql`UPDATE user_invites
SET accepted = true
WHERE id = ${invite.id}`;
return createdUser;
}
}

View File

@@ -2,12 +2,14 @@ import { ClaimsOrm } from './claims';
import { UsersOrm } from './user';
import { PlayersOrm } from './players';
import { GamesOrm } from './games';
import { InvitesOrm } from './invites';
class Orm {
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();
}
export const orm = new Orm();

View File

@@ -28,26 +28,19 @@ export class Player {
}
export class PlayersOrm {
async create(model: { name: string }, claims?: Claims): Promise<Player | null> {
async create(model: { name: string }, claims?: Claims): Promise<Player> {
await sql`INSERT INTO players (name)
VALUES (${model.name})`;
const newPlayerId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string;
try {
return await this.get(SecureId.fromID(newPlayerId), claims);
} catch (error) {
if (error instanceof UnauthorizedError) {
return null;
}
throw error;
}
return await this.get(SecureId.fromID(newPlayerId));
}
async get(id: SecureId, claims?: Claims): Promise<Player> {
if (!(Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.PLAYERS.OTHER.READ, claims))) {
throw new UnauthorizedError();
} else if (Claims.test(Claims.PLAYERS.SELF.READ, claims) && claims?.userId) {
const user = await orm.users.get(SecureId.fromHash(claims.userId));
const user = await orm.users.get(claims.userId);
if (id.raw !== user.playerId.raw) {
throw new UnauthorizedError();
}
@@ -66,21 +59,59 @@ export class PlayersOrm {
return new Player({
id: SecureId.fromID(dbResult.id),
name: dbResult.name,
elo: dbResult.elo,
elo: parseInt(dbResult.elo),
isRatingLocked: dbResult.is_rating_locked,
canBeMultiple: dbResult.can_be_multiple,
});
}
async update(
id: SecureId,
patch: UpdatePlayerRequest,
claims?: Claims,
): Promise<Player | null> {
async list(claims?: Claims): Promise<Player[]> {
if (!claims || Claims.test(Claims.ADMIN, claims)) {
return (await sql`SELECT * FROM players`).map(
(x: { id: string; name: string; elo: string; is_rating_locked: boolean; can_be_multiple: boolean }) =>
new Player({
id: SecureId.fromID(x.id),
name: x.name,
elo: parseInt(x.elo),
isRatingLocked: x.is_rating_locked,
canBeMultiple: x.can_be_multiple,
}),
);
}
if (!Claims.test(Claims.PLAYERS.OTHER.READ, claims)) {
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: SecureId.fromID(x.id),
name: x.name,
elo: parseInt(x.elo),
isRatingLocked: x.is_rating_locked,
canBeMultiple: x.can_be_multiple,
}),
);
}
async update(id: SecureId, patch: UpdatePlayerRequest, claims?: Claims): Promise<Player> {
if (!(Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.PLAYERS.OTHER.UPDATE, claims))) {
throw new UnauthorizedError();
} else if (Claims.test(Claims.PLAYERS.SELF.UPDATE, claims) && claims?.userId) {
const user = await orm.users.get(SecureId.fromHash(claims.userId));
const user = await orm.users.get(claims.userId);
if (id.raw !== user.playerId.raw) {
throw new UnauthorizedError();
}
@@ -97,21 +128,14 @@ export class PlayersOrm {
can_be_multiple=${playerToUpdate.canBeMultiple}
WHERE id = ${id.raw}`;
try {
return await this.get(id, claims);
} catch (error) {
if (error instanceof UnauthorizedError) {
return null;
}
throw error;
}
return await this.get(id);
}
async drop(id: SecureId, claims?: Claims): Promise<undefined> {
async drop(id: SecureId, claims?: Claims): Promise<void> {
if (!(Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.PLAYERS.OTHER.DELETE, claims))) {
throw new UnauthorizedError();
} else if (Claims.test(Claims.PLAYERS.SELF.DELETE, claims) && claims?.userId) {
const user = await orm.users.get(SecureId.fromHash(claims.userId));
const user = await orm.users.get(claims.userId);
if (id.raw !== user.playerId.raw) {
throw new UnauthorizedError();
}

View File

@@ -9,14 +9,14 @@ import { orm } from './orm';
export class User {
id: SecureId;
playerId: SecureId;
name: string;
email: string;
isAdmin: boolean;
isActive: boolean;
constructor(id: SecureId, playerId: SecureId, name: string, isAdmin: boolean = false, isActive: boolean = true) {
constructor(id: SecureId, playerId: SecureId, email: string, isAdmin: boolean = false, isActive: boolean = true) {
this.id = id;
this.playerId = playerId;
this.name = name;
this.email = email;
this.isAdmin = isAdmin;
this.isActive = isActive;
}
@@ -24,23 +24,23 @@ export class User {
export class UsersOrm {
async create(
model: { username: string; password: string; playerId: SecureId },
{ email, password, playerId }: { email: string; password: string; playerId: SecureId },
claims?: Claims,
): Promise<User | null> {
): Promise<User> {
const existingUser: any = first(
await sql`SELECT id
FROM users
WHERE username = ${model.username}
WHERE email = ${email} OR player_id = ${playerId.raw}
LIMIT 1`,
);
if (existingUser) {
throw new BadRequestError(`User ${model.username} already exists`);
throw new BadRequestError(`User or player already exists`);
}
const defaultClaims: number[] = await orm.claims.getDefaultClaims();
const passwordHash = await argon2.hash(model.password);
await sql`INSERT INTO users (username, pass_hash, player_id)
VALUES (${model.username}, ${passwordHash}, ${model.playerId.raw})`;
const passwordHash = await argon2.hash(password);
await sql`INSERT INTO users (email, pass_hash, player_id)
VALUES (${email}, ${passwordHash}, ${playerId.raw})`;
const newUserId: SecureId = SecureId.fromID((first(await sql`SELECT lastval();`) as any)?.lastval as string);
await sql.transaction(async (tx) => {
for (let i in defaultClaims) {
@@ -49,14 +49,7 @@ export class UsersOrm {
}
});
try {
return await this.get(newUserId, claims);
} catch (error) {
if (error instanceof UnauthorizedError) {
return null;
}
throw error;
}
return await this.get(newUserId);
}
async get(id: SecureId, claims?: Claims): Promise<User> {
@@ -64,7 +57,7 @@ export class UsersOrm {
!(
Claims.test(Claims.ADMIN, claims) ||
Claims.test(Claims.USERS.OTHER.READ, claims) ||
(Claims.test(Claims.USERS.SELF.READ, claims) && id.raw === claims?.userId)
(Claims.test(Claims.USERS.SELF.READ, claims) && id === claims?.userId)
)
) {
throw new UnauthorizedError();
@@ -85,21 +78,17 @@ export class UsersOrm {
return new User(
SecureId.fromID(dbResult.id),
SecureId.fromID(dbResult.player_id),
dbResult.username,
dbResult.email,
dbResult.is_admin,
);
}
async update(
id: SecureId,
patch: UpdateUserRequest,
claims?: Claims,
): Promise<User | null> {
async update(id: SecureId, patch: UpdateUserRequest, claims?: Claims): Promise<User> {
if (
!(
Claims.test(Claims.ADMIN, claims) ||
Claims.test(Claims.USERS.OTHER.UPDATE, claims) ||
(Claims.test(Claims.USERS.SELF.UPDATE, claims) && id.raw === claims?.userId)
(Claims.test(Claims.USERS.SELF.UPDATE, claims) && id === claims?.userId)
)
) {
throw new UnauthorizedError();
@@ -116,22 +105,15 @@ export class UsersOrm {
is_admin=${userToUpdate.isAdmin}
WHERE id = ${id.raw}`;
try {
return await this.get(id, claims);
} catch (error) {
if (error instanceof UnauthorizedError) {
return null;
}
throw error;
}
return await this.get(id);
}
async drop(id: SecureId, claims?: Claims): Promise<User | null> {
async drop(id: SecureId, claims?: Claims): Promise<void> {
if (
!(
Claims.test(Claims.ADMIN, claims) ||
Claims.test(Claims.USERS.OTHER.DELETE, claims) ||
(Claims.test(Claims.USERS.SELF.DELETE, claims) && id.raw === claims?.userId)
(Claims.test(Claims.USERS.SELF.DELETE, claims) && id === claims?.userId)
)
) {
throw new UnauthorizedError();
@@ -148,17 +130,17 @@ export class UsersOrm {
WHERE id = ${id.raw}`;
});
return null;
return;
}
async verifyCredentials(
username: string,
email: string,
password: string,
): Promise<{ userId: SecureId; refreshCount: string } | null> {
const dbResult: any = first(
await sql`SELECT *
FROM users
WHERE username = ${username}
WHERE email = ${email}
AND is_active = true
limit 1`,
);
@@ -183,7 +165,6 @@ export class UsersOrm {
WHERE id = ${id.raw}
LIMIT 1`,
);
console.log(dbResult.refresh_count, refreshCount);
return dbResult.refresh_count === refreshCount;
}
@@ -192,9 +173,9 @@ export class UsersOrm {
oldPassword: string | null,
newPassword: string,
claims?: Claims,
): Promise<undefined> {
): Promise<void> {
const isAdmin = Claims.test(Claims.ADMIN, claims);
if (!(isAdmin || (Claims.test(Claims.USERS.SELF.UPDATE, claims) && id.raw === claims?.userId))) {
if (!(isAdmin || (Claims.test(Claims.USERS.SELF.UPDATE, claims) && id === claims?.userId))) {
throw new UnauthorizedError();
}

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

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

View File

@@ -11,4 +11,7 @@ export default {
PATCH: guard(player.update, [Claims.ADMIN, Claims.PLAYERS.OTHER.UPDATE, Claims.PLAYERS.SELF.UPDATE]),
DELETE: guard(player.drop, [Claims.ADMIN, Claims.PLAYERS.OTHER.DELETE, Claims.PLAYERS.SELF.DELETE]),
},
'/api/player/list': {
GET: guard(player.list, [Claims.ADMIN, Claims.PLAYERS.OTHER.READ]),
},
};

View File

@@ -1,4 +1,4 @@
import { guard } from '../utilities/guard';
import { guard, unwrap, unwrapMethod } from '../utilities/guard';
import user from '../endpoints/user';
import { Claims } from '../orm/claims';
@@ -6,6 +6,12 @@ export default {
'/api/user': {
POST: guard(user.create, [Claims.ADMIN, Claims.USERS.CREATE]),
},
'/api/user/invite': {
POST: guard(user.invite, [Claims.ADMIN, Claims.USERS.INVITE]),
},
'/api/user/invite/accept': {
POST: unwrapMethod(user.acceptInvite),
},
'/api/user/:id': {
GET: guard(user.get, [Claims.ADMIN, Claims.USERS.OTHER.READ, Claims.USERS.SELF.READ]),
PATCH: guard(user.update, [Claims.ADMIN, Claims.USERS.OTHER.UPDATE, Claims.USERS.SELF.UPDATE]),

View File

@@ -2,6 +2,7 @@ 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',

View File

@@ -4,6 +4,12 @@ export class BadRequestError extends Error {
}
}
export class InternalServerError extends Error {
constructor(message?: string | undefined) {
super(message);
}
}
export class UnauthorizedError extends Error {
constructor(message?: string | undefined) {
super(message);

View File

@@ -26,12 +26,13 @@ export function guard(
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;
const userClaims: Claims = new Claims(jwt.verify(authHeader as string, process.env.JWT_SECRET_KEY as string) as any);
if (!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);
}

View File

@@ -18,3 +18,20 @@ export function memo<T extends (...args: any[]) => {}, S>(
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',
};

View File

@@ -1,7 +1,7 @@
import { hashIds } from './guard';
export interface LoginRequest {
username: string;
email: string;
password: string;
}
export interface ChangePasswordRequest {
@@ -9,7 +9,7 @@ export interface ChangePasswordRequest {
newPassword: string;
}
export interface CreateUserRequest {
username: string;
email: string;
password: string;
playerId: string;
}
@@ -17,13 +17,21 @@ 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;
isRatingLocked?: boolean;
canBeMultiple?: boolean;
}
export interface CreateGameRequest {
name: string;
@@ -65,6 +73,10 @@ export class SecureId {
return this.#hashedValue;
}
valueOf(): string | undefined {
return this.#secureValue;
}
public static fromHash(hash: string) {
return new SecureId({ public: hash });
}

View File

@@ -1,4 +1,5 @@
import { BadRequestError, NotFoundError, UnauthorizedError } from './errors';
import { isArray } from 'lodash';
export class ErrorResponse extends Response {
//@ts-ignore
@@ -41,9 +42,11 @@ export class OkResponse extends Response {
constructor(body?: Model | null) {
if (body) {
return Response.json(
{
...body,
},
isArray(body)
? body
: {
...body,
},
{
status: 200,
headers: {

View File

@@ -6,5 +6,6 @@
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"jsx": "react"
}
}