Compare commits

...

3 Commits

32 changed files with 2086 additions and 268 deletions

View File

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

12
.prettierrc.json Normal file
View 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
}

View File

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

View File

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

View File

@@ -13,8 +13,10 @@
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/lodash": "^4.17.23", "@types/lodash": "^4.17.23",
"argon2": "^0.44.0", "argon2": "^0.44.0",
"hashids": "^2.3.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"lodash": "^4.17.23" "lodash": "^4.17.23",
"reflect-metadata": "^0.2.2"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.9" "@types/bun": "^1.3.9"

View File

@@ -5,7 +5,7 @@
-- Dumped from database version 18.1 (Debian 18.1-1.pgdg13+2) -- Dumped from database version 18.1 (Debian 18.1-1.pgdg13+2)
-- Dumped by pg_dump version 18.1 -- Dumped by pg_dump version 18.1
-- Started on 2026-02-13 18:14:42 GMT -- Started on 2026-02-18 20:07:03 GMT
SET statement_timeout = 0; SET statement_timeout = 0;
SET lock_timeout = 0; SET lock_timeout = 0;
@@ -19,17 +19,88 @@ SET xmloption = content;
SET client_min_messages = warning; SET client_min_messages = warning;
SET row_security = off; SET row_security = off;
--
-- TOC entry 2 (class 3079 OID 20739)
-- Name: pg_trgm; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;
--
-- TOC entry 3606 (class 0 OID 0)
-- Dependencies: 2
-- Name: EXTENSION pg_trgm; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION pg_trgm IS 'text similarity measurement and index searching based on trigrams';
SET default_tablespace = ''; SET default_tablespace = '';
SET default_table_access_method = heap; SET default_table_access_method = heap;
-- --
-- TOC entry 224 (class 1259 OID 16431) -- TOC entry 243 (class 1259 OID 19974)
-- Name: circle_comments; Type: TABLE; Schema: public; Owner: admin
--
CREATE TABLE public.circle_comments (
circle_id bigint NOT NULL,
comment_id bigint NOT NULL
);
ALTER TABLE public.circle_comments OWNER TO admin;
--
-- TOC entry 230 (class 1259 OID 19837)
-- Name: circles; Type: TABLE; Schema: public; Owner: admin
--
CREATE TABLE public.circles (
id bigint NOT NULL,
name character varying(60) NOT NULL,
is_public boolean DEFAULT false NOT NULL,
owning_user_id bigint NOT NULL,
image_path text,
colour character(7) DEFAULT '#71677C'::bpchar NOT NULL
);
ALTER TABLE public.circles OWNER TO admin;
--
-- TOC entry 229 (class 1259 OID 19836)
-- Name: circles_id_seq; Type: SEQUENCE; Schema: public; Owner: admin
--
CREATE SEQUENCE public.circles_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.circles_id_seq OWNER TO admin;
--
-- TOC entry 3607 (class 0 OID 0)
-- Dependencies: 229
-- Name: circles_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin
--
ALTER SEQUENCE public.circles_id_seq OWNED BY public.circles.id;
--
-- TOC entry 225 (class 1259 OID 16431)
-- Name: claims; Type: TABLE; Schema: public; Owner: admin -- Name: claims; Type: TABLE; Schema: public; Owner: admin
-- --
CREATE TABLE public.claims ( CREATE TABLE public.claims (
id integer NOT NULL, id bigint NOT NULL,
name character varying(60) NOT NULL, name character varying(60) NOT NULL,
is_default boolean DEFAULT false NOT NULL is_default boolean DEFAULT false NOT NULL
); );
@@ -38,7 +109,7 @@ CREATE TABLE public.claims (
ALTER TABLE public.claims OWNER TO admin; ALTER TABLE public.claims OWNER TO admin;
-- --
-- TOC entry 223 (class 1259 OID 16430) -- TOC entry 224 (class 1259 OID 16430)
-- Name: claims_id_seq; Type: SEQUENCE; Schema: public; Owner: admin -- Name: claims_id_seq; Type: SEQUENCE; Schema: public; Owner: admin
-- --
@@ -54,8 +125,8 @@ CREATE SEQUENCE public.claims_id_seq
ALTER SEQUENCE public.claims_id_seq OWNER TO admin; ALTER SEQUENCE public.claims_id_seq OWNER TO admin;
-- --
-- TOC entry 3465 (class 0 OID 0) -- TOC entry 3608 (class 0 OID 0)
-- Dependencies: 223 -- Dependencies: 224
-- Name: claims_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin -- Name: claims_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin
-- --
@@ -63,20 +134,268 @@ ALTER SEQUENCE public.claims_id_seq OWNED BY public.claims.id;
-- --
-- TOC entry 225 (class 1259 OID 16439) -- TOC entry 239 (class 1259 OID 19919)
-- Name: collection_games; Type: TABLE; Schema: public; Owner: admin
--
CREATE TABLE public.collection_games (
collection_id bigint NOT NULL,
game_id bigint NOT NULL
);
ALTER TABLE public.collection_games OWNER TO admin;
--
-- TOC entry 238 (class 1259 OID 19905)
-- Name: collections; Type: TABLE; Schema: public; Owner: admin
--
CREATE TABLE public.collections (
id bigint NOT NULL,
user_id bigint NOT NULL,
name character varying(60) NOT NULL
);
ALTER TABLE public.collections OWNER TO admin;
--
-- TOC entry 237 (class 1259 OID 19904)
-- Name: collections_id_seq; Type: SEQUENCE; Schema: public; Owner: admin
--
CREATE SEQUENCE public.collections_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.collections_id_seq OWNER TO admin;
--
-- TOC entry 3609 (class 0 OID 0)
-- Dependencies: 237
-- Name: collections_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin
--
ALTER SEQUENCE public.collections_id_seq OWNED BY public.collections.id;
--
-- TOC entry 241 (class 1259 OID 19940)
-- Name: comments; Type: TABLE; Schema: public; Owner: admin
--
CREATE TABLE public.comments (
id bigint NOT NULL,
created_by_user_id bigint NOT NULL,
created_at timestamp(6) with time zone DEFAULT now() NOT NULL,
content text NOT NULL
);
ALTER TABLE public.comments OWNER TO admin;
--
-- TOC entry 240 (class 1259 OID 19939)
-- Name: comments_id_seq; Type: SEQUENCE; Schema: public; Owner: admin
--
CREATE SEQUENCE public.comments_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.comments_id_seq OWNER TO admin;
--
-- TOC entry 3610 (class 0 OID 0)
-- Dependencies: 240
-- Name: comments_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin
--
ALTER SEQUENCE public.comments_id_seq OWNED BY public.comments.id;
--
-- TOC entry 232 (class 1259 OID 19848)
-- Name: games; Type: TABLE; Schema: public; Owner: admin
--
CREATE TABLE public.games (
id bigint NOT NULL,
name character varying(60) CONSTRAINT games_names_not_null NOT NULL,
image_path text,
bgg_id bigint
);
ALTER TABLE public.games OWNER TO admin;
--
-- TOC entry 231 (class 1259 OID 19847)
-- Name: games_id_seq; Type: SEQUENCE; Schema: public; Owner: admin
--
CREATE SEQUENCE public.games_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.games_id_seq OWNER TO admin;
--
-- TOC entry 3611 (class 0 OID 0)
-- Dependencies: 231
-- Name: games_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin
--
ALTER SEQUENCE public.games_id_seq OWNED BY public.games.id;
--
-- TOC entry 242 (class 1259 OID 19959)
-- Name: match_comments; Type: TABLE; Schema: public; Owner: admin
--
CREATE TABLE public.match_comments (
match_id bigint NOT NULL,
comment_id bigint NOT NULL
);
ALTER TABLE public.match_comments OWNER TO admin;
--
-- TOC entry 235 (class 1259 OID 19872)
-- Name: match_players; Type: TABLE; Schema: public; Owner: admin
--
CREATE TABLE public.match_players (
match_id bigint NOT NULL,
player_id bigint NOT NULL,
standing integer NOT NULL,
elo_change integer NOT NULL
);
ALTER TABLE public.match_players OWNER TO admin;
--
-- TOC entry 234 (class 1259 OID 19859)
-- Name: matches; Type: TABLE; Schema: public; Owner: admin
--
CREATE TABLE public.matches (
id bigint NOT NULL,
game_id bigint NOT NULL,
owning_user_id bigint NOT NULL
);
ALTER TABLE public.matches OWNER TO admin;
--
-- TOC entry 233 (class 1259 OID 19858)
-- Name: matches_id_seq; Type: SEQUENCE; Schema: public; Owner: admin
--
CREATE SEQUENCE public.matches_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.matches_id_seq OWNER TO admin;
--
-- TOC entry 3612 (class 0 OID 0)
-- Dependencies: 233
-- Name: matches_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin
--
ALTER SEQUENCE public.matches_id_seq OWNED BY public.matches.id;
--
-- TOC entry 236 (class 1259 OID 19889)
-- Name: player_circles; Type: TABLE; Schema: public; Owner: admin
--
CREATE TABLE public.player_circles (
player_id bigint NOT NULL,
circle_id bigint NOT NULL
);
ALTER TABLE public.player_circles OWNER TO admin;
--
-- TOC entry 228 (class 1259 OID 19809)
-- Name: players; Type: TABLE; Schema: public; Owner: admin
--
CREATE TABLE public.players (
id bigint NOT NULL,
name text NOT NULL,
elo integer DEFAULT 1000 NOT NULL,
is_rating_locked boolean DEFAULT false NOT NULL,
can_be_multiple boolean DEFAULT false NOT NULL
);
ALTER TABLE public.players OWNER TO admin;
--
-- TOC entry 227 (class 1259 OID 19808)
-- Name: players_id_seq; Type: SEQUENCE; Schema: public; Owner: admin
--
CREATE SEQUENCE public.players_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.players_id_seq OWNER TO admin;
--
-- TOC entry 3613 (class 0 OID 0)
-- Dependencies: 227
-- Name: players_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin
--
ALTER SEQUENCE public.players_id_seq OWNED BY public.players.id;
--
-- TOC entry 226 (class 1259 OID 16439)
-- Name: user_claims; Type: TABLE; Schema: public; Owner: admin -- Name: user_claims; Type: TABLE; Schema: public; Owner: admin
-- --
CREATE TABLE public.user_claims ( CREATE TABLE public.user_claims (
userid bigint, user_id bigint,
claimid integer claim_id integer
); );
ALTER TABLE public.user_claims OWNER TO admin; ALTER TABLE public.user_claims OWNER TO admin;
-- --
-- TOC entry 222 (class 1259 OID 16392) -- TOC entry 223 (class 1259 OID 16392)
-- Name: users; Type: TABLE; Schema: public; Owner: admin -- Name: users; Type: TABLE; Schema: public; Owner: admin
-- --
@@ -85,14 +404,19 @@ CREATE TABLE public.users (
username character varying(20) NOT NULL, username character varying(20) NOT NULL,
pass_hash text NOT NULL, pass_hash text NOT NULL,
is_active boolean DEFAULT true CONSTRAINT users_active_not_null NOT NULL, is_active boolean DEFAULT true CONSTRAINT users_active_not_null NOT NULL,
is_admin boolean DEFAULT false NOT NULL is_admin boolean DEFAULT false NOT NULL,
player_id bigint NOT NULL,
failed_attempts integer DEFAULT 0 NOT NULL,
last_login_attempt date,
require_new_password boolean DEFAULT false NOT NULL,
refresh_count bigint DEFAULT 0 NOT NULL
); );
ALTER TABLE public.users OWNER TO admin; ALTER TABLE public.users OWNER TO admin;
-- --
-- TOC entry 219 (class 1259 OID 16389) -- TOC entry 220 (class 1259 OID 16389)
-- Name: users_id_seq; Type: SEQUENCE; Schema: public; Owner: admin -- Name: users_id_seq; Type: SEQUENCE; Schema: public; Owner: admin
-- --
@@ -107,8 +431,8 @@ CREATE SEQUENCE public.users_id_seq
ALTER SEQUENCE public.users_id_seq OWNER TO admin; ALTER SEQUENCE public.users_id_seq OWNER TO admin;
-- --
-- TOC entry 3466 (class 0 OID 0) -- TOC entry 3614 (class 0 OID 0)
-- Dependencies: 219 -- Dependencies: 220
-- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin -- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin
-- --
@@ -116,7 +440,7 @@ ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id;
-- --
-- TOC entry 221 (class 1259 OID 16391) -- TOC entry 222 (class 1259 OID 16391)
-- Name: users_pass_hash_seq; Type: SEQUENCE; Schema: public; Owner: admin -- Name: users_pass_hash_seq; Type: SEQUENCE; Schema: public; Owner: admin
-- --
@@ -131,8 +455,8 @@ CREATE SEQUENCE public.users_pass_hash_seq
ALTER SEQUENCE public.users_pass_hash_seq OWNER TO admin; ALTER SEQUENCE public.users_pass_hash_seq OWNER TO admin;
-- --
-- TOC entry 3467 (class 0 OID 0) -- TOC entry 3615 (class 0 OID 0)
-- Dependencies: 221 -- Dependencies: 222
-- Name: users_pass_hash_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin -- Name: users_pass_hash_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin
-- --
@@ -140,7 +464,7 @@ ALTER SEQUENCE public.users_pass_hash_seq OWNED BY public.users.pass_hash;
-- --
-- TOC entry 220 (class 1259 OID 16390) -- TOC entry 221 (class 1259 OID 16390)
-- Name: users_username_seq; Type: SEQUENCE; Schema: public; Owner: admin -- Name: users_username_seq; Type: SEQUENCE; Schema: public; Owner: admin
-- --
@@ -155,8 +479,8 @@ CREATE SEQUENCE public.users_username_seq
ALTER SEQUENCE public.users_username_seq OWNER TO admin; ALTER SEQUENCE public.users_username_seq OWNER TO admin;
-- --
-- TOC entry 3468 (class 0 OID 0) -- TOC entry 3616 (class 0 OID 0)
-- Dependencies: 220 -- Dependencies: 221
-- Name: users_username_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin -- Name: users_username_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin
-- --
@@ -164,7 +488,15 @@ ALTER SEQUENCE public.users_username_seq OWNED BY public.users.username;
-- --
-- TOC entry 3305 (class 2604 OID 16434) -- TOC entry 3412 (class 2604 OID 19840)
-- Name: circles id; Type: DEFAULT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.circles ALTER COLUMN id SET DEFAULT nextval('public.circles_id_seq'::regclass);
--
-- TOC entry 3406 (class 2604 OID 20726)
-- Name: claims id; Type: DEFAULT; Schema: public; Owner: admin -- Name: claims id; Type: DEFAULT; Schema: public; Owner: admin
-- --
@@ -172,7 +504,47 @@ ALTER TABLE ONLY public.claims ALTER COLUMN id SET DEFAULT nextval('public.claim
-- --
-- TOC entry 3300 (class 2604 OID 16395) -- TOC entry 3417 (class 2604 OID 19908)
-- Name: collections id; Type: DEFAULT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.collections ALTER COLUMN id SET DEFAULT nextval('public.collections_id_seq'::regclass);
--
-- TOC entry 3418 (class 2604 OID 19943)
-- Name: comments id; Type: DEFAULT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.comments ALTER COLUMN id SET DEFAULT nextval('public.comments_id_seq'::regclass);
--
-- TOC entry 3415 (class 2604 OID 19851)
-- Name: games id; Type: DEFAULT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.games ALTER COLUMN id SET DEFAULT nextval('public.games_id_seq'::regclass);
--
-- TOC entry 3416 (class 2604 OID 19862)
-- Name: matches id; Type: DEFAULT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.matches ALTER COLUMN id SET DEFAULT nextval('public.matches_id_seq'::regclass);
--
-- TOC entry 3408 (class 2604 OID 19812)
-- Name: players id; Type: DEFAULT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.players ALTER COLUMN id SET DEFAULT nextval('public.players_id_seq'::regclass);
--
-- TOC entry 3398 (class 2604 OID 16395)
-- Name: users id; Type: DEFAULT; Schema: public; Owner: admin -- Name: users id; Type: DEFAULT; Schema: public; Owner: admin
-- --
@@ -180,7 +552,7 @@ ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_
-- --
-- TOC entry 3301 (class 2604 OID 16405) -- TOC entry 3399 (class 2604 OID 16405)
-- Name: users username; Type: DEFAULT; Schema: public; Owner: admin -- Name: users username; Type: DEFAULT; Schema: public; Owner: admin
-- --
@@ -188,7 +560,7 @@ ALTER TABLE ONLY public.users ALTER COLUMN username SET DEFAULT nextval('public.
-- --
-- TOC entry 3302 (class 2604 OID 16411) -- TOC entry 3400 (class 2604 OID 16411)
-- Name: users pass_hash; Type: DEFAULT; Schema: public; Owner: admin -- Name: users pass_hash; Type: DEFAULT; Schema: public; Owner: admin
-- --
@@ -196,7 +568,16 @@ ALTER TABLE ONLY public.users ALTER COLUMN pass_hash SET DEFAULT nextval('public
-- --
-- TOC entry 3310 (class 2606 OID 16438) -- TOC entry 3427 (class 2606 OID 19846)
-- Name: circles circles_pkey; Type: CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.circles
ADD CONSTRAINT circles_pkey PRIMARY KEY (id);
--
-- TOC entry 3423 (class 2606 OID 20728)
-- Name: claims claims_pkey; Type: CONSTRAINT; Schema: public; Owner: admin -- Name: claims claims_pkey; Type: CONSTRAINT; Schema: public; Owner: admin
-- --
@@ -205,7 +586,52 @@ ALTER TABLE ONLY public.claims
-- --
-- TOC entry 3308 (class 2606 OID 16403) -- TOC entry 3433 (class 2606 OID 19913)
-- Name: collections collections_pkey; Type: CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.collections
ADD CONSTRAINT collections_pkey PRIMARY KEY (id);
--
-- TOC entry 3435 (class 2606 OID 19952)
-- Name: comments comments_pkey; Type: CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.comments
ADD CONSTRAINT comments_pkey PRIMARY KEY (id);
--
-- TOC entry 3429 (class 2606 OID 19857)
-- Name: games games_pkey; Type: CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.games
ADD CONSTRAINT games_pkey PRIMARY KEY (id);
--
-- TOC entry 3431 (class 2606 OID 19866)
-- Name: matches matches_pkey; Type: CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.matches
ADD CONSTRAINT matches_pkey PRIMARY KEY (id);
--
-- TOC entry 3425 (class 2606 OID 19824)
-- Name: players players_pkey; Type: CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.players
ADD CONSTRAINT players_pkey PRIMARY KEY (id);
--
-- TOC entry 3421 (class 2606 OID 16403)
-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: admin -- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: admin
-- --
@@ -214,26 +640,169 @@ ALTER TABLE ONLY public.users
-- --
-- TOC entry 3311 (class 2606 OID 16447) -- TOC entry 3452 (class 2606 OID 19979)
-- Name: circle_comments circle_comments_circles_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.circle_comments
ADD CONSTRAINT circle_comments_circles_fkey FOREIGN KEY (circle_id) REFERENCES public.circles(id);
--
-- TOC entry 3453 (class 2606 OID 19984)
-- Name: circle_comments circle_comments_comment_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.circle_comments
ADD CONSTRAINT circle_comments_comment_fkey FOREIGN KEY (comment_id) REFERENCES public.comments(id);
--
-- TOC entry 3439 (class 2606 OID 20708)
-- Name: circles circles_users_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.circles
ADD CONSTRAINT circles_users_fkey FOREIGN KEY (owning_user_id) REFERENCES public.users(id) NOT VALID;
--
-- TOC entry 3447 (class 2606 OID 19924)
-- Name: collection_games collection_games_collections_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.collection_games
ADD CONSTRAINT collection_games_collections_fkey FOREIGN KEY (collection_id) REFERENCES public.collections(id);
--
-- TOC entry 3448 (class 2606 OID 19929)
-- Name: collection_games collection_games_games_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.collection_games
ADD CONSTRAINT collection_games_games_fkey FOREIGN KEY (game_id) REFERENCES public.games(id);
--
-- TOC entry 3446 (class 2606 OID 19914)
-- Name: collections collections_users_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.collections
ADD CONSTRAINT collections_users_fkey FOREIGN KEY (user_id) REFERENCES public.users(id);
--
-- TOC entry 3449 (class 2606 OID 19953)
-- Name: comments comments_users_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.comments
ADD CONSTRAINT comments_users_fkey FOREIGN KEY (created_by_user_id) REFERENCES public.users(id);
--
-- TOC entry 3450 (class 2606 OID 19969)
-- Name: match_comments match_comments_comments_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.match_comments
ADD CONSTRAINT match_comments_comments_fkey FOREIGN KEY (comment_id) REFERENCES public.comments(id);
--
-- TOC entry 3451 (class 2606 OID 19964)
-- Name: match_comments match_comments_matches_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.match_comments
ADD CONSTRAINT match_comments_matches_fkey FOREIGN KEY (match_id) REFERENCES public.matches(id);
--
-- TOC entry 3442 (class 2606 OID 19884)
-- Name: match_players match_players_matches_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.match_players
ADD CONSTRAINT match_players_matches_fkey FOREIGN KEY (match_id) REFERENCES public.matches(id);
--
-- TOC entry 3443 (class 2606 OID 19879)
-- Name: match_players match_players_players_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.match_players
ADD CONSTRAINT match_players_players_fkey FOREIGN KEY (player_id) REFERENCES public.players(id);
--
-- TOC entry 3440 (class 2606 OID 19867)
-- Name: matches matches_games_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.matches
ADD CONSTRAINT matches_games_fkey FOREIGN KEY (game_id) REFERENCES public.games(id);
--
-- TOC entry 3441 (class 2606 OID 20721)
-- Name: matches matches_users_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.matches
ADD CONSTRAINT matches_users_fkey FOREIGN KEY (owning_user_id) REFERENCES public.users(id) NOT VALID;
--
-- TOC entry 3444 (class 2606 OID 19899)
-- Name: player_circles player_circles_circles_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.player_circles
ADD CONSTRAINT player_circles_circles_fkey FOREIGN KEY (circle_id) REFERENCES public.circles(id);
--
-- TOC entry 3445 (class 2606 OID 19894)
-- Name: player_circles player_circles_players_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.player_circles
ADD CONSTRAINT player_circles_players_fkey FOREIGN KEY (player_id) REFERENCES public.players(id);
--
-- TOC entry 3437 (class 2606 OID 20730)
-- Name: user_claims user_claims_claimid_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin -- Name: user_claims user_claims_claimid_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin
-- --
ALTER TABLE ONLY public.user_claims ALTER TABLE ONLY public.user_claims
ADD CONSTRAINT user_claims_claimid_fkey FOREIGN KEY (claimid) REFERENCES public.claims(id); ADD CONSTRAINT user_claims_claimid_fkey FOREIGN KEY (claim_id) REFERENCES public.claims(id);
-- --
-- TOC entry 3312 (class 2606 OID 16442) -- TOC entry 3438 (class 2606 OID 16442)
-- Name: user_claims user_claims_userid_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin -- Name: user_claims user_claims_userid_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin
-- --
ALTER TABLE ONLY public.user_claims ALTER TABLE ONLY public.user_claims
ADD CONSTRAINT user_claims_userid_fkey FOREIGN KEY (userid) REFERENCES public.users(id); ADD CONSTRAINT user_claims_userid_fkey FOREIGN KEY (user_id) REFERENCES public.users(id);
-- Completed on 2026-02-13 18:14:43 GMT --
-- TOC entry 3436 (class 2606 OID 19830)
-- Name: users users_players_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin
--
ALTER TABLE ONLY public.users
ADD CONSTRAINT users_players_fkey FOREIGN KEY (player_id) REFERENCES public.players(id) NOT VALID;
-- Completed on 2026-02-18 20:07:03 GMT
-- --
-- PostgreSQL database dump complete -- PostgreSQL database dump complete
-- --

View File

@@ -1,31 +1,100 @@
import {orm} from "../orm/orm"; import { orm } from '../orm/orm';
import jwt from "jsonwebtoken"; import jwt from 'jsonwebtoken';
import {UnwrappedRequest} from "../utilities/guard"; import { UnwrappedRequest } from '../utilities/guard';
import {ErrorResponse} from "../utilities/responseHelper"; import { ErrorResponse, OkResponse, UnauthorizedResponse } from '../utilities/responseHelper';
import {Claims} from "../orm/claims"; import { Claims } from '../orm/claims';
import {UnauthorizedError} from "../utilities/errors"; import { ChangePasswordRequest, LoginRequest, SecureId } from '../utilities/requestModels';
async function login(request: UnwrappedRequest): Promise<Response> { async function login(request: UnwrappedRequest<LoginRequest>): Promise<Response> {
try { try {
const requestBody = request.json; const verify: {
console.log(`/api/auth/login: username=${requestBody.username}`); userId: SecureId;
const claims: Claims | null = await orm.users.verify(requestBody.username, requestBody.password); refreshCount: string;
console.log(claims); } | null = await orm.users.verifyCredentials(request.body.username, request.body.password);
if (claims) { if (!verify) {
const token = jwt.sign({...claims}, process.env.JWT_SECRET_KEY as string, {expiresIn: "24h"}); return new UnauthorizedResponse('Invalid credentials');
return Response.json({token: token, claims: claims}, {status: 200});
} }
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.raw,
r: verify.refreshCount,
},
process.env.JWT_SECRET_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,
});
return new OkResponse();
} catch (error: any) { } catch (error: any) {
return new ErrorResponse(error as Error); 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_SECRET_KEY as string) as { u: string; r: string };
if (!(await orm.users.verifyRefreshCount(SecureId.fromID(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(refreshToken.u);
const token = jwt.sign({ ...claims }, process.env.JWT_SECRET_KEY as string, { expiresIn: '1h' });
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(
SecureId.fromHash(request.params.id),
request.body.oldPassword,
request.body.newPassword,
request.claims,
),
);
} catch (error: any) {
return new ErrorResponse(error as Error);
}
} }
export default { export default {
login, login,
test token,
}; logout,
changePassword,
};

70
src/endpoints/games.ts Normal file
View File

@@ -0,0 +1,70 @@
import { orm } from '../orm/orm';
import { UnwrappedRequest } from '../utilities/guard';
import { CreatedResponse, ErrorResponse, OkResponse } from '../utilities/responseHelper';
import {
CreateGameRequest,
SecureId,
UpdateGameRequest,
} from '../utilities/requestModels';
async function create(request: UnwrappedRequest<CreateGameRequest>): Promise<Response> {
try {
const newUser = await orm.games.create(
{
name: request.body.name,
bggId: request.body.bggId,
imagePath: request.body.imagePath,
},
request.claims,
);
return new CreatedResponse(newUser);
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function get(request: UnwrappedRequest): Promise<Response> {
try {
return new OkResponse(await orm.games.get(SecureId.fromHash(request.params.id)));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function update(request: UnwrappedRequest<UpdateGameRequest>): Promise<Response> {
try {
return new OkResponse(
await orm.games.update(
SecureId.fromHash(request.params.id),
request.body,
request.claims,
),
);
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function drop(request: UnwrappedRequest): Promise<Response> {
try {
return new OkResponse(await orm.games.drop(SecureId.fromHash(request.params.id)));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function query(request: UnwrappedRequest): Promise<Response> {
try {
return new OkResponse(await orm.games.query(request.params.query));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
export default {
create,
get,
update,
drop,
query,
};

46
src/endpoints/player.ts Normal file
View File

@@ -0,0 +1,46 @@
import { orm } from '../orm/orm';
import { UnwrappedRequest } from '../utilities/guard';
import { CreatedResponse, ErrorResponse, OkResponse } from '../utilities/responseHelper';
import { CreatePlayerRequest, SecureId, UpdatePlayerRequest } from '../utilities/requestModels';
async function create(request: UnwrappedRequest<CreatePlayerRequest>): Promise<Response> {
try {
const newPlayer = await orm.players.create(request.body, request.claims);
return new CreatedResponse(newPlayer);
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function get(request: UnwrappedRequest): Promise<Response> {
try {
return new OkResponse(await orm.players.get(SecureId.fromHash(request.params.id), 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(SecureId.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(SecureId.fromHash(request.params.id), request.claims));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
export default {
create,
get,
update,
drop,
};

View File

@@ -1,22 +1,18 @@
import {orm} from "../orm/orm"; import { orm } from '../orm/orm';
import {UnwrappedRequest} from "../utilities/guard"; import { UnwrappedRequest } from '../utilities/guard';
import {ErrorResponse} from "../utilities/responseHelper"; import { CreatedResponse, ErrorResponse, OkResponse } from '../utilities/responseHelper';
import { CreateUserRequest, SecureId, UpdateUserRequest } from '../utilities/requestModels';
async function create(request: UnwrappedRequest): Promise<Response> { async function create(request: UnwrappedRequest<CreateUserRequest>): Promise<Response> {
try { try {
const requestBody = request.json; const newUser = await orm.users.create(
const newUser = await orm.users.create(requestBody.username, requestBody.password, request.claims);
if(!newUser) {
return new Response(null,{status: 201})
}
return Response.json(
{ {
...newUser ...request.body,
playerId: SecureId.fromHash(request.body.playerId),
}, },
{status: 201} request.claims,
); );
return new CreatedResponse(newUser);
} catch (error: any) { } catch (error: any) {
return new ErrorResponse(error as Error); return new ErrorResponse(error as Error);
} }
@@ -24,9 +20,29 @@ async function create(request: UnwrappedRequest): Promise<Response> {
async function get(request: UnwrappedRequest): Promise<Response> { async function get(request: UnwrappedRequest): Promise<Response> {
try { try {
return Response.json({ return new OkResponse(await orm.users.get(SecureId.fromHash(request.params.id), request.claims));
...(await orm.users.get(request.params.id, request.claims)) } catch (error: any) {
}, {status: 200}); return new ErrorResponse(error as Error);
}
}
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,
),
);
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function drop(request: UnwrappedRequest): Promise<Response> {
try {
return new OkResponse(await orm.users.drop(SecureId.fromHash(request.params.id), request.claims));
} catch (error: any) { } catch (error: any) {
return new ErrorResponse(error as Error); return new ErrorResponse(error as Error);
} }
@@ -35,4 +51,6 @@ async function get(request: UnwrappedRequest): Promise<Response> {
export default { export default {
create, create,
get, get,
} update,
drop,
};

View File

@@ -1,27 +1,26 @@
import {unwrapMethod, guard} from './utilities/guard'; import auth from './routes/auth';
import auth from "./endpoints/auth"; import user from './routes/user';
import user from "./endpoints/user"; import player from './routes/player';
import game from './routes/game';
import { OkResponse } from './utilities/responseHelper';
const server = Bun.serve({ const server = Bun.serve({
routes: { routes: {
"/api/auth/login": { ...auth,
POST: unwrapMethod(auth.login), ...user,
}, ...player,
"/api/auth/test": { ...game,
GET: guard(auth.test, ['ADMIN', 'USERS_OTHER_DELETE']) '/test': {
}, GET: () => {
"/api/user": { return new OkResponse();
POST: guard(user.create, ['ADMIN', 'USERS_CREATE']) },
},
"/api/user/:id": {
GET: guard(user.get, ['ADMIN', 'USERS_OTHERS_READ', 'USERS_SELF_READ'])
}, },
}, },
// (optional) fallback for unmatched routes: // (optional) fallback for unmatched routes:
fetch(): Response { 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}`);

View File

@@ -1,11 +1,12 @@
import {sql} from 'bun'; import { sql } from 'bun';
import { ClaimDefinition } from '../utilities/claimDefinitions';
export class Claims { export class Claims extends ClaimDefinition {
userId: string | undefined; userId?: string;
claims: string[] = []; claims: string[] = [];
public static test(userClaims: Claims, guardClaim: string): Boolean { public static test(guardClaim: string, userClaims?: Claims): Boolean {
return userClaims.claims.some(x => x === guardClaim); return userClaims === undefined || userClaims.claims.some((x) => x === guardClaim);
} }
} }
@@ -13,11 +14,11 @@ export class ClaimsOrm {
async getByUserId(userId: string): Promise<Claims> { async getByUserId(userId: string): Promise<Claims> {
const dbResults: any[] = await sql`SELECT c.name const dbResults: any[] = await sql`SELECT c.name
from user_claims as uc from user_claims as uc
JOIN claims as c on uc.claimid = c.id JOIN claims as c on uc.claim_id = c.id
where uc.userid = ${userId};`; where uc.user_id = ${userId};`;
const claims = new Claims(); const claims = new Claims();
claims.userId = userId; claims.userId = userId;
claims.claims = dbResults.map(x => x.name); claims.claims = dbResults.map((x) => x.name);
return claims; return claims;
} }
@@ -25,6 +26,6 @@ export class ClaimsOrm {
const dbResults: any[] = await sql`SELECT id const dbResults: any[] = await sql`SELECT id
FROM claims FROM claims
WHERE is_default = true;`; WHERE is_default = true;`;
return dbResults.map(x => x.id); return dbResults.map((x) => x.id);
} }
} }

125
src/orm/games.ts Normal file
View File

@@ -0,0 +1,125 @@
import { Claims } from './claims';
import { sql } from 'bun';
import { first } from 'lodash';
import { NotFoundError, UnauthorizedError } from '../utilities/errors';
import { CreateGameRequest, SecureId, UpdateGameRequest } from '../utilities/requestModels';
import { memo } from '../utilities/helpers';
export class Game {
id: SecureId;
name: string;
imagePath?: string;
bggId?: string;
constructor(input: { id: SecureId; 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 | null> {
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;
}
}
async get(id: SecureId): Promise<Game> {
const dbResult: any = first(
await sql`SELECT *
FROM games
WHERE id = ${id.raw}
LIMIT 1`,
);
if (!dbResult) {
throw new NotFoundError('No matching game exists');
}
return new Game({
id: SecureId.fromID(dbResult.id),
name: dbResult.name,
bggId: dbResult.bgg_id,
imagePath: dbResult.image_path,
});
}
async update(id: SecureId, patch: UpdateGameRequest, claims?: Claims): Promise<Game | null> {
const gameToUpdate = await this.get(id);
gameToUpdate.name = patch.name ?? gameToUpdate.name;
gameToUpdate.bggId = patch.bggId ?? gameToUpdate.bggId;
if (Claims.test(Claims.GAMES.MANAGE_IMAGES, claims)) {
gameToUpdate.imagePath = patch.imagePath ?? gameToUpdate.imagePath;
}
await sql`UPDATE games
SET name=${gameToUpdate.name},
bgg_id=${gameToUpdate.bggId},
image_path=${gameToUpdate.imagePath}
WHERE id = ${id.raw}`;
try {
return await this.get(id);
} catch (error) {
if (error instanceof UnauthorizedError) {
return null;
}
throw error;
}
}
async drop(id: SecureId): Promise<undefined> {
// 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 dbResult: any = await sql` SELECT
id, name
FROM (SELECT *, SIMILARITY(${query}, name) as similarity FROM games)
WHERE similarity > 0
ORDER BY similarity
LIMIT 5;`;
if (!dbResult) {
throw new NotFoundError('No matching game exists');
}
return dbResult.map(
(x: { id: string; name: string }) =>
new Game({
id: SecureId.fromID(x.id),
name: x.name,
}),
);
}
}

View File

@@ -1,16 +1,13 @@
import {ClaimsOrm} from "./claims"; import { ClaimsOrm } from './claims';
import {UsersOrm} from "./user"; import { UsersOrm } from './user';
import { PlayersOrm } from './players';
import { GamesOrm } from './games';
class Orm { class Orm {
claims: ClaimsOrm; readonly claims: ClaimsOrm = new ClaimsOrm();
users: UsersOrm; readonly users: UsersOrm = new UsersOrm();
readonly players: PlayersOrm = new PlayersOrm();
constructor() { readonly games: GamesOrm = new GamesOrm();
this.claims = new ClaimsOrm();
this.users = new UsersOrm(this.claims);
}
} }
export const orm = new Orm(); export const orm = new Orm();

136
src/orm/players.ts Normal file
View File

@@ -0,0 +1,136 @@
import { Claims } from './claims';
import { sql } from 'bun';
import { first } from 'lodash';
import { NotFoundError, UnauthorizedError } from '../utilities/errors';
import { orm } from './orm';
import { SecureId, UpdatePlayerRequest } from '../utilities/requestModels';
export class Player {
id: SecureId;
name: string;
elo: number;
isRatingLocked: boolean;
canBeMultiple: boolean;
constructor(input: {
id: SecureId;
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 }, claims?: Claims): Promise<Player | null> {
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;
}
}
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));
if (id.raw !== user.playerId.raw) {
throw new UnauthorizedError();
}
}
const dbResult: any = first(
await sql`SELECT *
FROM players
WHERE id = ${id.raw}
LIMIT 1`,
);
if (!dbResult) {
throw new NotFoundError('No matching player exists');
}
return new Player({
id: SecureId.fromID(dbResult.id),
name: dbResult.name,
elo: dbResult.elo,
isRatingLocked: dbResult.is_rating_locked,
canBeMultiple: dbResult.can_be_multiple,
});
}
async update(
id: SecureId,
patch: UpdatePlayerRequest,
claims?: Claims,
): Promise<Player | null> {
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));
if (id.raw !== user.playerId.raw) {
throw new UnauthorizedError();
}
}
const playerToUpdate = await this.get(id);
playerToUpdate.name = patch.name ?? playerToUpdate.name;
playerToUpdate.isRatingLocked = patch.isRatingLocked ?? playerToUpdate.isRatingLocked;
playerToUpdate.canBeMultiple = patch.canBeMultiple ?? playerToUpdate.canBeMultiple;
await sql`UPDATE players
SET name=${playerToUpdate.name},
is_rating_locked=${playerToUpdate.isRatingLocked},
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;
}
}
async drop(id: SecureId, claims?: Claims): Promise<undefined> {
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));
if (id.raw !== user.playerId.raw) {
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;
}
}

View File

@@ -1,17 +1,21 @@
import {Claims, ClaimsOrm} from "./claims"; import { Claims } from './claims';
import {sql} from "bun"; import { sql } from 'bun';
import {first} from "lodash"; import { first } from 'lodash';
import argon2 from "argon2"; import argon2 from 'argon2';
import {BadRequestError, NotFoundError, UnauthorizedError} from "../utilities/errors"; import { BadRequestError, NotFoundError, UnauthorizedError } from '../utilities/errors';
import { SecureId, UpdateUserRequest } from '../utilities/requestModels';
import { orm } from './orm';
export class User { export class User {
id: string; id: SecureId;
playerId: SecureId;
name: string; name: string;
isAdmin: boolean; isAdmin: boolean;
isActive: boolean; isActive: boolean;
constructor(id: string, name: string, isAdmin: boolean = false, isActive: boolean = true) { constructor(id: SecureId, playerId: SecureId, name: string, isAdmin: boolean = false, isActive: boolean = true) {
this.id = id; this.id = id;
this.playerId = playerId;
this.name = name; this.name = name;
this.isAdmin = isAdmin; this.isAdmin = isAdmin;
this.isActive = isActive; this.isActive = isActive;
@@ -19,79 +23,200 @@ export class User {
} }
export class UsersOrm { export class UsersOrm {
#claims: ClaimsOrm; async create(
model: { username: string; password: string; playerId: SecureId },
constructor(claims: ClaimsOrm) { claims?: Claims,
this.#claims = claims; ): Promise<User | null> {
} const existingUser: any = first(
await sql`SELECT id
async get(id: string, claims: Claims): Promise<User> { FROM users
if (!( WHERE username = ${model.username}
Claims.test(claims, 'ADMIN') || LIMIT 1`,
Claims.test(claims, 'USERS_OTHER_READ') || );
(Claims.test(claims, 'USERS_SELF_READ') && id === claims.userId) if (existingUser) {
)) { throw new BadRequestError(`User ${model.username} already exists`);
throw new
UnauthorizedError();
} }
const dbResult: any = first(await sql`select * const defaultClaims: number[] = await orm.claims.getDefaultClaims();
from users const passwordHash = await argon2.hash(model.password);
where id = ${id} await sql`INSERT INTO users (username, pass_hash, player_id)
and is_active = true VALUES (${model.username}, ${passwordHash}, ${model.playerId.raw})`;
limit 1`); 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) {
await tx`INSERT INTO user_claims (user_id, claim_id)
VALUES (${newUserId.raw}, ${defaultClaims[i]})`;
}
});
if(!dbResult) {
throw new NotFoundError('No matching user exists');
}
return new User(dbResult.id, dbResult.username, dbResult.is_admin);
}
async verify(username: string, password: string): Promise<Claims | null> {
try { try {
const dbResult: any = first(await sql`select * return await this.get(newUserId, claims);
from users } catch (error) {
where username = ${username} if (error instanceof UnauthorizedError) {
limit 1`);
if (!await argon2.verify(dbResult.pass_hash, password)) {
return null; return null;
} }
return this.#claims.getByUserId(dbResult.id);
} catch (error) {
throw error; throw error;
} }
} }
async create(username: string, password: string, claims: Claims): Promise<User | null> { async get(id: SecureId, claims?: Claims): Promise<User> {
const existingUser: any = first(await sql`SELECT id if (
FROM users !(
WHERE username = ${username} Claims.test(Claims.ADMIN, claims) ||
LIMIT 1`); Claims.test(Claims.USERS.OTHER.READ, claims) ||
if (existingUser) { (Claims.test(Claims.USERS.SELF.READ, claims) && id.raw === claims?.userId)
throw new BadRequestError(`User ${username} already exists`); )
) {
throw new UnauthorizedError();
} }
const defaultClaims: number[] = await this.#claims.getDefaultClaims(); const dbResult: any = first(
const passwordHash = await argon2.hash(password); await sql`SELECT *
await sql`INSERT INTO users (username, pass_hash) FROM users
VALUES (${username}, ${passwordHash})`; WHERE id = ${id.raw}
const newUserId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string; AND is_active = true
await sql.transaction(async (tx) => { LIMIT 1`,
for (let i in defaultClaims) { );
await tx`INSERT INTO user_claims (userid, claimid)
VALUES (${newUserId}, ${defaultClaims[i]})`;
}
})
if (!( if (!dbResult) {
Claims.test(claims, 'ADMIN') || throw new NotFoundError('No matching user exists');
Claims.test(claims, 'USERS_OTHER_READ') }
)) {
return new User(
SecureId.fromID(dbResult.id),
SecureId.fromID(dbResult.player_id),
dbResult.username,
dbResult.is_admin,
);
}
async update(
id: SecureId,
patch: UpdateUserRequest,
claims?: Claims,
): Promise<User | null> {
if (
!(
Claims.test(Claims.ADMIN, claims) ||
Claims.test(Claims.USERS.OTHER.UPDATE, claims) ||
(Claims.test(Claims.USERS.SELF.UPDATE, claims) && id.raw === claims?.userId)
)
) {
throw new UnauthorizedError();
}
const userToUpdate = await this.get(id);
if (Claims.test(Claims.ADMIN, claims)) {
userToUpdate.isActive = patch.isActive ?? userToUpdate.isActive;
userToUpdate.isAdmin = patch.isAdmin ?? userToUpdate.isAdmin;
}
await sql`UPDATE users
SET is_active=${userToUpdate.isActive},
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;
}
}
async drop(id: SecureId, claims?: Claims): Promise<User | null> {
if (
!(
Claims.test(Claims.ADMIN, claims) ||
Claims.test(Claims.USERS.OTHER.DELETE, claims) ||
(Claims.test(Claims.USERS.SELF.DELETE, claims) && id.raw === claims?.userId)
)
) {
throw new UnauthorizedError();
}
// Ensure user exists before attempting to delete
await this.get(id);
await sql.transaction(async (tx) => {
await tx`DELETE
FROM user_claims
WHERE user_id = ${id.raw}`;
await tx`DELETE
FROM users
WHERE id = ${id.raw}`;
});
return null;
}
async verifyCredentials(
username: string,
password: string,
): Promise<{ userId: SecureId; refreshCount: string } | null> {
const dbResult: any = first(
await sql`SELECT *
FROM users
WHERE username = ${username}
AND is_active = true
limit 1`,
);
if (!dbResult) {
throw new UnauthorizedError();
}
if (!(await argon2.verify(dbResult.pass_hash, password))) {
return null; return null;
} }
return await this.get(newUserId, claims); return {
userId: SecureId.fromID(dbResult.id),
refreshCount: dbResult.refresh_count,
};
} }
}
async verifyRefreshCount(id: SecureId, refreshCount: string): Promise<boolean> {
const dbResult: any = first(
await sql`SELECT *
FROM users
WHERE id = ${id.raw}
LIMIT 1`,
);
console.log(dbResult.refresh_count, refreshCount);
return dbResult.refresh_count === refreshCount;
}
async changePassword(
id: SecureId,
oldPassword: string | null,
newPassword: string,
claims?: Claims,
): Promise<undefined> {
const isAdmin = Claims.test(Claims.ADMIN, claims);
if (!(isAdmin || (Claims.test(Claims.USERS.SELF.UPDATE, claims) && id.raw === claims?.userId))) {
throw new UnauthorizedError();
}
if (!isAdmin && oldPassword === null) {
throw new BadRequestError('Password is required');
}
const dbUser: any = first(
await sql`SELECT *
FROM users
WHERE id = ${id.raw}
LIMIT 1`,
);
if (!isAdmin && !(await argon2.verify(dbUser.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;
}
}

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

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

17
src/routes/game.ts Normal file
View File

@@ -0,0 +1,17 @@
import { guard } from '../utilities/guard';
import { Claims } from '../orm/claims';
import games from '../endpoints/games';
export default {
'/api/game': {
POST: guard(games.create, [Claims.ADMIN, Claims.GAMES.CREATE]),
},
'/api/game/:id': {
GET: guard(games.get, [Claims.ADMIN, Claims.GAMES.READ]),
PATCH: guard(games.update, [Claims.ADMIN, Claims.GAMES.UPDATE]),
DELETE: guard(games.drop, [Claims.ADMIN, Claims.GAMES.DELETE]),
},
'/api/game/search/:query': {
GET: guard(games.query, [Claims.ADMIN, Claims.GAMES.READ]),
},
};

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

@@ -0,0 +1,14 @@
import { guard } from '../utilities/guard';
import { Claims } from '../orm/claims';
import player from '../endpoints/player';
export default {
'/api/player': {
POST: guard(player.create, [Claims.ADMIN, Claims.PLAYERS.CREATE]),
},
'/api/player/:id': {
GET: guard(player.get, [Claims.ADMIN, Claims.PLAYERS.OTHER.READ, Claims.PLAYERS.SELF.READ]),
PATCH: guard(player.update, [Claims.ADMIN, Claims.PLAYERS.OTHER.UPDATE, Claims.PLAYERS.SELF.UPDATE]),
DELETE: guard(player.drop, [Claims.ADMIN, Claims.PLAYERS.OTHER.DELETE, Claims.PLAYERS.SELF.DELETE]),
},
};

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

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

View File

@@ -1,2 +1,2 @@
import {expect, test, beforeAll} from 'bun:test'; import { expect, test, beforeAll } from 'bun:test';
import {sql} from "bun"; import { sql } from 'bun';

View File

@@ -1,6 +1,6 @@
import {beforeAll} from 'bun:test'; import { beforeAll } from 'bun:test';
import Bun from 'bun'; import Bun from 'bun';
import {sql} from "bun"; import { sql } from 'bun';
beforeAll(async () => { beforeAll(async () => {
console.log(process.env.DATABASE_URL); 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_OTHER_READ', true)`;
await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_SELF_DELETE', false)`; await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_SELF_DELETE', false)`;
}); });

View File

@@ -1,11 +1,13 @@
import {expect, test} from 'bun:test'; import { expect, test } from 'bun:test';
import user from '../endpoints/user'; import user from '../endpoints/user';
import {UnwrappedRequest} from "../utilities/guard"; import { UnwrappedRequest } from '../utilities/guard';
import {Claims} from "../orm/claims"; import { Claims } from '../orm/claims';
import { orm } from '../orm/orm';
import { User } from '../orm/user';
test('Create user as admin', async () => { test('Create user as admin', async () => {
const claims = new Claims(); const claims = new Claims();
claims.claims.push('ADMIN'); claims.claims.push(Claims.ADMIN);
const request = new UnwrappedRequest({ const request = new UnwrappedRequest({
claims, claims,
@@ -24,7 +26,7 @@ test('Create user as admin', async () => {
test('Create user without read access', async () => { test('Create user without read access', async () => {
const claims = new Claims(); const claims = new Claims();
claims.claims.push('USERS_CREATE'); claims.claims.push(Claims.USERS.CREATE);
const request = new UnwrappedRequest({ const request = new UnwrappedRequest({
claims, claims,
@@ -43,7 +45,7 @@ test('Create user without read access', async () => {
test('Create user that already exists', async () => { test('Create user that already exists', async () => {
const claims = new Claims(); const claims = new Claims();
claims.claims.push('USERS_CREATE'); claims.claims.push(Claims.USERS.CREATE);
const request = new UnwrappedRequest({ const request = new UnwrappedRequest({
claims, claims,
@@ -61,13 +63,13 @@ test('Create user that already exists', async () => {
test('Get user', async () => { test('Get user', async () => {
const claims = new Claims(); const claims = new Claims();
claims.claims.push('USERS_OTHER_READ'); claims.claims.push(Claims.USERS.OTHER.READ);
const request = new UnwrappedRequest({ const request = new UnwrappedRequest({
claims, claims,
request: null, request: null,
params: { params: {
id: 1 id: 1,
}, },
}); });
@@ -79,14 +81,14 @@ test('Get user', async () => {
test('Get user self with only self read permission', async () => { test('Get user self with only self read permission', async () => {
const claims = new Claims(); const claims = new Claims();
claims.userId = "1"; claims.userId = '1';
claims.claims.push('USERS_OTHER_READ'); claims.claims.push(Claims.USERS.OTHER.READ);
const request = new UnwrappedRequest({ const request = new UnwrappedRequest({
claims, claims,
request: null, request: null,
params: { 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 () => { test('Get other user without read permissions', async () => {
const claims = new Claims(); const claims = new Claims();
claims.userId = "2"; claims.userId = '2';
claims.claims.push('USERS_SELF_READ'); claims.claims.push(Claims.USERS.SELF.READ);
const request = new UnwrappedRequest({ const request = new UnwrappedRequest({
claims, claims,
request: null, request: null,
params: { params: {
id: 1 id: 1,
}, },
}); });
@@ -113,18 +115,170 @@ test('Get other user without read permissions', async () => {
expect(response.status).toBe(401); 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(); const claims = new Claims();
claims.claims.push('ADMIN'); claims.claims.push(Claims.ADMIN);
const request = new UnwrappedRequest({ const request = new UnwrappedRequest({
claims, claims,
request: null, request: null,
params: { params: {
id: 101 id: 101,
}, },
}); });
const response = await user.get(request); const response = await user.get(request);
expect(response.status).toBe(404); 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);
});

View File

@@ -0,0 +1,131 @@
export class ClaimDefinition {
public static readonly ADMIN = 'ADMIN';
public static readonly USERS = {
CREATE: 'USERS_CREATE',
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: {
CREATE: 'CIRCLES_PUBLIC_CREATE',
JOIN: 'CIRCLES_PUBLIC_JOIN',
USERS: {
ADD: 'CIRCLES_PUBLIC_USER_ADD',
LIST: 'CIRCLES_PUBLIC_USER_LIST',
INVITE: 'CIRCLES_PUBLIC_USER_INVITE',
},
COMMENTS: {
ADD: 'CIRCLES_PUBLIC_COMMENTS_ADD',
DELETE: 'CIRCLES_PUBLIC_COMMENTS_DELETE',
},
},
PRIVATE: {
CREATE: 'CIRCLES_PRIVATE_CREATE',
USERS: {
INVITE: 'CIRCLES_PRIVATE_USER_INVITE',
},
},
OWNED: {
READ: 'CIRCLES_OWNED_READ',
UPDATE: 'CIRCLES_OWNED_UPDATE',
DELETE: 'CIRCLES_OWNED_DELETE',
USERS: {
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',
},
},
UNOWNED: {
READ: 'CIRCLES_UNOWNED_READ',
UPDATE: 'CIRCLES_UNOWNED_UPDATE',
DELETE: 'CIRCLES_UNOWNED_DELETE',
COMMENTS: {
ADD: 'CIRCLES_UNOWNED_COMMENTS_ADD',
DELETE: 'CIRCLES_UNOWNED_COMMENTS_DELETE',
},
},
};
public static readonly GAMES = {
CREATE: 'GAMES_CREATE',
READ: 'GAMES_READ',
UPDATE: 'GAMES_UPDATE',
DELETE: 'GAMES_DELETE',
MANAGE_IMAGES: 'GAMES_IMAGES_MANAGE',
};
public static readonly MATCHES = {
CREATE: 'MATCHES_CREATE',
OWNED: {
READ: 'MATCHES_OWNED_READ',
UPDATE: 'MATCHES_OWNED_UPDATE',
DELETE: 'MATCHES_OWNED_DELETE',
COMMENTS: {
ADD: 'MATCHES_OWNED_COMMENTS_ADD',
DELETE: 'MATCHES_OWNED_COMMENTS_DELETE',
},
},
UNOWNED: {
READ: 'MATCHES_UNOWNED_READ',
UPDATE: 'MATCHES_UNOWNED_UPDATE',
DELETE: 'MATCHES_UNOWNED_DELETE',
COMMENTS: {
ADD: 'MATCHES_UNOWNED_COMMENTS_ADD',
DELETE: 'MATCHES_UNOWNED_COMMENTS_DELETE',
},
},
};
public static readonly COLLECTIONS = {
CREATE: 'COLLECTIONS_CREATE',
OWNED: {
READ: 'COLLECTIONS_OWNED_READ',
UPDATE: 'COLLECTIONS_OWNED_UPDATE',
DELETE: 'COLLECTIONS_OWNED_DELETE',
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',
},
};
}

45
src/utilities/elo.ts Normal file
View File

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

View File

@@ -1,17 +1,17 @@
export class BadRequestError extends Error { export class BadRequestError extends Error {
constructor(message: string | undefined = undefined) { constructor(message?: string | undefined) {
super(message); super(message);
} }
} }
export class UnauthorizedError extends Error { export class UnauthorizedError extends Error {
constructor(message: string | undefined = undefined) { constructor(message?: string | undefined) {
super(message); super(message);
} }
} }
export class NotFoundError extends Error { export class NotFoundError extends Error {
constructor(message: string | undefined = undefined) { constructor(message?: string | undefined) {
super(message); super(message);
} }
} }

View File

@@ -1,10 +1,16 @@
import {BunRequest as Request} from 'bun'; import { BunRequest as Request } from 'bun';
import jwt from 'jsonwebtoken'; import jwt, { TokenExpiredError } from 'jsonwebtoken';
import {ErrorResponse} from "./responseHelper"; import { ErrorResponse, UnauthorizedResponse } from './responseHelper';
import {UnauthorizedError} from "./errors"; import { Claims } from '../orm/claims';
import {Claims} from "../orm/claims"; import HashIds from 'hashids';
export function guardRedirect(method: Function, redirectMethod: Function, guardedClaims: string[] | undefined = undefined) { export const hashIds = new HashIds(process.env.JWT_SECRET, 4);
export function guardRedirect(
method: (request: UnwrappedRequest<any>) => Promise<Response> | Response,
redirectMethod: Function,
guardedClaims: string[],
) {
try { try {
return guard(method, guardedClaims); return guard(method, guardedClaims);
} catch (e) { } catch (e) {
@@ -12,47 +18,56 @@ export function guardRedirect(method: Function, redirectMethod: Function, guarde
} }
} }
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> => { 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 { try {
const userClaims: Claims = jwt.verify(authHeader as string, process.env.JWT_SECRET_KEY as string) as Claims; 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))) { if (!userClaims.claims.some((x: string): boolean => guardedClaims.includes(x))) {
throw new UnauthorizedError('Unauthorized'); return new UnauthorizedResponse('Unauthorized');
} }
return method(await unwrap(request, userClaims)); return method(await unwrap(request, userClaims));
} catch (error: any) { } catch (error: any) {
if (error instanceof TokenExpiredError) {
return new UnauthorizedResponse(error.message);
}
return new ErrorResponse(error as Error); return new ErrorResponse(error as Error);
} }
} };
} }
export class UnwrappedRequest { export class UnwrappedRequest<T = {}> {
readonly json: any; readonly body: T;
readonly request: Request; readonly request: Request;
readonly params: { [x: string]: string }; readonly params: { [x: string]: string };
readonly claims: Claims; readonly claims: Claims;
constructor(input: any) { constructor(input: any) {
this.json = input.json; this.body = input.body;
this.request = input.request; this.request = input.request;
this.claims = input.claims; this.claims = input.claims || new Claims();
this.params = input.params; this.params = input.params;
} }
} }
export async function unwrap(request: Request, claims: Claims | null = null) { export async function unwrap<T = {}>(request: Request, claims?: Claims) {
return new UnwrappedRequest({ return new UnwrappedRequest<T>({
request, request,
claims, claims,
json: request.body ? await request.json() : null, body: request.body ? await request.json() : null,
params: request.params, 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) => { return async (request: Request) => {
const unwrappedRequest = await unwrap(request); const unwrappedRequest = await unwrap<T>(request);
return await methodToUnwrap(unwrappedRequest); return await methodToUnwrap(unwrappedRequest);
}; };
} }

20
src/utilities/helpers.ts Normal file
View File

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

View File

@@ -0,0 +1,75 @@
import { hashIds } from './guard';
export interface LoginRequest {
username: string;
password: string;
}
export interface ChangePasswordRequest {
oldPassword: string | null;
newPassword: string;
}
export interface CreateUserRequest {
username: string;
password: string;
playerId: string;
}
export interface UpdateUserRequest {
isActive?: boolean;
isAdmin?: boolean;
}
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 class SecureId {
#hashedValue?: string;
#secureValue?: string;
get value(): string | undefined {
return this.#hashedValue;
}
set value(value: string) {
this.#hashedValue = value;
this.#secureValue = hashIds.decode(value)?.toString();
}
get raw(): string | undefined {
return this.#secureValue;
}
set raw(value: string) {
this.#hashedValue = hashIds.encode(value);
this.#secureValue = value;
}
constructor(id: { public?: string; secure?: string }) {
if (id.public) {
this.value = id.public;
} else if (id.secure) {
this.raw = id.secure;
}
}
toJSON(): string | undefined {
return this.#hashedValue;
}
public static fromHash(hash: string) {
return new SecureId({ public: hash });
}
public static fromID(id: string) {
return new SecureId({ secure: id });
}
}

View File

@@ -1,18 +1,74 @@
import {BadRequestError, NotFoundError, UnauthorizedError} from "./errors"; import { BadRequestError, NotFoundError, UnauthorizedError } from './errors';
export class ErrorResponse extends Response { export class ErrorResponse extends Response {
//@ts-ignore //@ts-ignore
constructor(error: Error) { constructor(error: Error) {
if(error instanceof BadRequestError) { if (error instanceof BadRequestError) {
return Response.json({message: error.message}, {status: 400}); return new BadRequestResponse(error.message);
} } else if (error instanceof UnauthorizedError) {
else if(error instanceof UnauthorizedError){ return new UnauthorizedResponse(error.message);
return Response.json({message: error.message}, {status: 401}); } else if (error instanceof NotFoundError) {
} return new NotFoundResponse(error.message);
else if(error instanceof NotFoundError){
return Response.json({message: error.message}, {status: 404});
} }
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 OkResponse extends Response {
// @ts-ignore
constructor(body?: Model | null) {
if (body) {
return Response.json(
{
...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 });
}
}

View File

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