├── api ├── src │ ├── sql │ │ ├── mod.rs │ │ └── account.rs │ ├── helpers │ │ ├── mod.rs │ │ ├── cache.rs │ │ └── problem.rs │ ├── graphql │ │ ├── query │ │ │ ├── mod.rs │ │ │ └── accounts.rs │ │ ├── mutation │ │ │ ├── mod.rs │ │ │ ├── account.rs │ │ │ └── auth.rs │ │ ├── mod.rs │ │ └── context.rs │ ├── model │ │ ├── mod.rs │ │ ├── auth.rs │ │ ├── account.rs │ │ ├── session.rs │ │ └── redacted.rs │ ├── environment │ │ ├── argon.rs │ │ ├── jwt.rs │ │ └── mod.rs │ ├── session.rs │ ├── auth.rs │ └── main.rs ├── scripts │ └── run_dev.sh ├── Dockerfile.dev └── Cargo.toml ├── frontend ├── src │ ├── react-app-env.d.ts │ ├── lib.ts │ ├── setupTests.ts │ ├── App.test.tsx │ ├── redux │ │ ├── auth │ │ │ ├── actions.ts │ │ │ ├── types.ts │ │ │ └── reducers.ts │ │ ├── index.ts │ │ └── range │ │ │ ├── actions.ts │ │ │ ├── types.ts │ │ │ ├── reducers.ts │ │ │ └── handrange.ts │ ├── pages │ │ ├── Landing.tsx │ │ ├── Home.tsx │ │ ├── Login.tsx │ │ └── Register.tsx │ ├── components │ │ ├── Footer.tsx │ │ ├── CreateRangeForm.tsx │ │ ├── TextArea.tsx │ │ ├── Input.tsx │ │ ├── Button.tsx │ │ ├── Nav.tsx │ │ ├── Modal.tsx │ │ ├── RangeFilter.tsx │ │ ├── RangeTable.tsx │ │ ├── Matrix.tsx │ │ └── BoardSelector.tsx │ ├── apollo.ts │ ├── index.tsx │ ├── App.tsx │ ├── styles.ts │ ├── serviceWorker.ts │ └── containers │ │ └── RangeSelector.tsx ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── index.html ├── .prettierrc.js ├── Dockerfile.dev ├── tsconfig.json ├── .eslintrc.js └── package.json ├── screenshots └── 2.png ├── workspace.code-workspace ├── .env.sample ├── .dockerignore ├── migrations └── up │ ├── create_accounts.sql │ └── create_sessions.sql ├── .gitignore ├── LICENSE ├── docker-compose.dev.yml └── README.md /api/src/sql/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod account; 2 | -------------------------------------------------------------------------------- /api/src/helpers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod cache; 2 | pub mod problem; 3 | -------------------------------------------------------------------------------- /api/src/graphql/query/mod.rs: -------------------------------------------------------------------------------- 1 | mod accounts; 2 | pub struct Query; 3 | -------------------------------------------------------------------------------- /frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /screenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kmurf1999/HoldemSolver/HEAD/screenshots/2.png -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kmurf1999/HoldemSolver/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kmurf1999/HoldemSolver/HEAD/frontend/public/logo192.png -------------------------------------------------------------------------------- /frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kmurf1999/HoldemSolver/HEAD/frontend/public/logo512.png -------------------------------------------------------------------------------- /workspace.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": {} 8 | } -------------------------------------------------------------------------------- /frontend/src/lib.ts: -------------------------------------------------------------------------------- 1 | export enum UIState { 2 | INACTIVE, 3 | ACTIVE, 4 | UNAVAILABLE, 5 | PARTIAL, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: "all", 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2 7 | }; -------------------------------------------------------------------------------- /frontend/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM node 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN yarn install 8 | 9 | COPY . . 10 | 11 | CMD "yarn" "start" 12 | -------------------------------------------------------------------------------- /api/src/model/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod account; 2 | mod redacted; 3 | pub mod session; 4 | pub mod auth; 5 | 6 | pub use account::Account; 7 | pub use session::Session; 8 | pub use auth::Auth; 9 | -------------------------------------------------------------------------------- /api/src/model/auth.rs: -------------------------------------------------------------------------------- 1 | use juniper::GraphQLObject; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Clone, Serialize, Deserialize, GraphQLObject, Debug)] 5 | pub struct Auth { 6 | pub csrf: String, 7 | pub jwt: String 8 | } -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | POSTGRES_USER=postgres 2 | POSTGRES_PASSWORD=postgres 3 | POSTGRES_DB=postgres 4 | DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:5432/${POSTGRES_DB}" 5 | REDIS_URL="redis://127.0.0.1:6379/" 6 | JWT_SECRET="ITS A SECRET" 7 | ARGON_SECRET="ITS ANOTHER SECRET" 8 | -------------------------------------------------------------------------------- /frontend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules/ 5 | .pnp/ 6 | .pnp.js 7 | 8 | # testing 9 | coverage/ 10 | 11 | # production 12 | build/ 13 | target/ 14 | 15 | # misc 16 | .DS_Store 17 | 18 | npm-debug.log* 19 | yarn-debug.log* -------------------------------------------------------------------------------- /frontend/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /api/scripts/run_dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # postgres is just starting up, may not accept connections right away 4 | sleep 5 5 | # Run database migration fixes 6 | # movine init 7 | # movine status 8 | # movine fix 9 | 10 | # Run server with cargo watch and systemfd for autoreload 11 | systemfd --no-pid -s http::0.0.0.0:3535 -- cargo watch -x run 12 | 13 | -------------------------------------------------------------------------------- /migrations/up/create_accounts.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE accounts 2 | ( 3 | id uuid NOT NULL, 4 | email varchar(100) NOT NULL, 5 | password varchar(150) NOT NULL, 6 | created_at timestamp WITHOUT TIME ZONE DEFAULT (NOW() AT TIME ZONE 'UTC') NOT NULL, 7 | updated_at timestamp WITHOUT TIME ZONE NULL, 8 | PRIMARY KEY (id), 9 | UNIQUE (email) 10 | ); 11 | 12 | -------------------------------------------------------------------------------- /frontend/src/redux/auth/actions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AuthActionTypes, 3 | LOGIN, 4 | LOGOUT 5 | } from './types'; 6 | 7 | export function login(jwt: string, csrf: string): AuthActionTypes { 8 | return { 9 | type: LOGIN, 10 | payload: { 11 | jwt, csrf 12 | } 13 | } 14 | } 15 | 16 | export function logout(): AuthActionTypes { 17 | return { 18 | type: LOGOUT 19 | } 20 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules/ 5 | .pnp/ 6 | .pnp.js 7 | 8 | # testing 9 | coverage/ 10 | 11 | # production 12 | build/ 13 | target/ 14 | data/ 15 | 16 | # misc 17 | .DS_Store 18 | .env.* 19 | # include sample 20 | !.env.sample 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # IDEs 27 | .idea/ 28 | -------------------------------------------------------------------------------- /api/src/graphql/mutation/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::graphql::Context; 2 | 3 | mod account; 4 | mod auth; 5 | 6 | use account::AccountMutation; 7 | use auth::AuthMutation; 8 | 9 | pub struct Mutation; 10 | 11 | #[juniper::graphql_object(Context = Context)] 12 | impl Mutation { 13 | fn auth() -> AuthMutation { 14 | AuthMutation 15 | } 16 | fn account() -> AccountMutation { 17 | AccountMutation 18 | } 19 | } 20 | 21 | 22 | -------------------------------------------------------------------------------- /migrations/up/create_sessions.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE sessions 2 | ( 3 | key varchar(100) NOT NULL, 4 | csrf varchar(100) NOT NULL, 5 | account uuid NOT NULL, 6 | identity json NOT NULL, 7 | expiry timestamp, 8 | invalidated bool NOT NULL DEFAULT FALSE, 9 | created_at timestamp WITHOUT TIME ZONE DEFAULT (NOW() AT TIME ZONE 'UTC') NOT NULL, 10 | updated_at timestamp WITHOUT TIME ZONE NULL, 11 | PRIMARY KEY (key), 12 | FOREIGN KEY (account) REFERENCES accounts (id) 13 | ); 14 | 15 | -------------------------------------------------------------------------------- /api/src/model/account.rs: -------------------------------------------------------------------------------- 1 | use super::redacted::Redacted; 2 | use chrono::{DateTime, Utc}; 3 | use juniper::GraphQLObject; 4 | use serde::{Deserialize, Serialize}; 5 | use uuid::Uuid; 6 | 7 | #[derive(Clone, Serialize, Deserialize, GraphQLObject, Debug)] 8 | pub struct Account { 9 | pub id: Uuid, 10 | pub email: String, 11 | 12 | #[graphql(skip)] 13 | #[serde(skip_serializing)] 14 | pub password: Redacted, 15 | 16 | pub created_at: DateTime, 17 | pub updated_at: Option>, 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/redux/auth/types.ts: -------------------------------------------------------------------------------- 1 | export const LOGIN = 'LOGIN'; 2 | export const LOGOUT = 'LOGOUT'; 3 | 4 | export type AuthState = { 5 | isLoggedIn: boolean; 6 | jwt: string | undefined; 7 | csrf: string | undefined; 8 | }; 9 | 10 | interface loginAction { 11 | type: typeof LOGIN; 12 | payload: { 13 | jwt: string, 14 | csrf: string 15 | } 16 | } 17 | 18 | interface logoutAction { 19 | type: typeof LOGOUT; 20 | } 21 | 22 | export type AuthActionTypes = 23 | | loginAction 24 | | logoutAction; -------------------------------------------------------------------------------- /api/src/graphql/query/accounts.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | auth, 3 | graphql::{context::Context, query::Query}, 4 | model, 5 | }; 6 | use juniper::FieldResult; 7 | 8 | #[juniper::graphql_object(Context = Context)] 9 | impl Query { 10 | pub async fn accounts(ctx: &Context) -> FieldResult> { 11 | if ctx.is_authenticated() { 12 | Ok(crate::sql::account::get_all_accounts(ctx.database()).await?) 13 | } else { 14 | Err(auth::AuthError::InvalidCredentials.into()) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/pages/Landing.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { Link } from 'react-router-dom'; 5 | 6 | const LandingStyle = styled.div` 7 | display: flex; 8 | flex-direction: column; 9 | 10 | `; 11 | 12 | function Landing(): React.ReactElement { 13 | return ( 14 | 15 | Home Page 16 | Login 17 | Register 18 | 19 | ); 20 | } 21 | 22 | export default Landing; 23 | -------------------------------------------------------------------------------- /frontend/src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const FooterStyle = styled.div` 5 | font-family: 'Open Sans', 'sans-serif'; 6 | text-align: center; 7 | color: rgba(0, 0, 0, 0.45); 8 | font-size: 0.8em; 9 | padding: 1em 0; 10 | `; 11 | 12 | const Footer: React.FC = () => ( 13 | 14 |

15 | Holdem Solver is a free, open source project 16 |

17 |
18 | ); 19 | 20 | export default Footer; 21 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/redux/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from 'redux'; 2 | import { combineReducers } from 'redux'; 3 | // import { composeWithDevTools } from 'redux-devtools-extension'; 4 | import rangeReducer from './range/reducers'; 5 | import authReducer from './auth/reducers'; 6 | 7 | import { RangeState } from './range/types'; 8 | import { AuthState } from './auth/types'; 9 | 10 | export type RootState = { 11 | range: RangeState; 12 | auth: AuthState; 13 | }; 14 | 15 | const rootReducer = combineReducers({ 16 | range: rangeReducer, 17 | auth: authReducer 18 | }); 19 | 20 | const store = createStore( 21 | rootReducer 22 | ); 23 | 24 | export default store; 25 | -------------------------------------------------------------------------------- /api/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # Stage 1 - build 2 | FROM rust:1.45.2-slim as build-deps 3 | 4 | LABEL maintainer="aslamplr@gmail.com" 5 | LABEL version=1.0 6 | 7 | WORKDIR /api 8 | 9 | RUN apt-get update && apt-get install -y build-essential libssl-dev pkg-config 10 | # required for auto-reload in development only. 11 | RUN cargo install systemfd cargo-watch 12 | # clang, llvm required for argonautica dependency. 13 | RUN apt-get install -y clang llvm-dev libclang-dev 14 | 15 | # install movine for database migrations 16 | RUN apt-get install -y libsqlite3-dev wait-for-it 17 | RUN cargo install movine 18 | 19 | ENTRYPOINT ["wait-for-it", "db:5432", "--", "./scripts/run_dev.sh"] 20 | 21 | -------------------------------------------------------------------------------- /api/src/model/session.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use serde::{Deserialize, Serialize}; 3 | use sqlx::{types::json::Json, FromRow}; 4 | use std::net::IpAddr; 5 | use uuid::Uuid; 6 | 7 | #[derive(Clone, Serialize, Deserialize, FromRow, Debug)] 8 | pub struct Session { 9 | pub key: String, 10 | pub csrf: String, 11 | pub account: Uuid, 12 | pub identity: Json, 13 | pub expiry: DateTime, 14 | pub invalidated: bool, 15 | pub created_at: DateTime, 16 | pub updated_at: Option>, 17 | } 18 | 19 | #[derive(Clone, Serialize, Deserialize, Default, Debug)] 20 | pub struct Identity { 21 | pub fingerprint: Option, 22 | pub ip: Option, 23 | } 24 | -------------------------------------------------------------------------------- /api/src/graphql/mod.rs: -------------------------------------------------------------------------------- 1 | mod context; 2 | mod mutation; 3 | mod query; 4 | 5 | pub use context::Context; 6 | use futures::channel::mpsc::channel; 7 | use juniper::FieldResult; 8 | use mutation::Mutation; 9 | use query::Query; 10 | 11 | pub type Schema = juniper::RootNode<'static, Query, Mutation, Subscription>; 12 | pub fn schema() -> Schema { 13 | Schema::new(Query, Mutation, Subscription) 14 | } 15 | 16 | pub struct Subscription; 17 | 18 | type CallsStream = std::pin::Pin> + Send>>; 19 | 20 | #[juniper::graphql_subscription(Context = Context)] 21 | impl Subscription { 22 | pub async fn calls(ctx: &Context) -> CallsStream { 23 | let (tx, rx) = channel(16); 24 | Box::pin(rx) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/apollo.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient, createHttpLink, InMemoryCache } from '@apollo/client'; 2 | import { setContext } from '@apollo/client/link/context'; 3 | 4 | const httpLink = createHttpLink({ 5 | uri: `http://localhost:3535/graphql/query${localStorage.getItem('csrf') ? '?csrf=' + localStorage.getItem('csrf') : ''}` 6 | }); 7 | 8 | const authLink = setContext((_, { headers }) => { 9 | // get the authentication token from local storage if it exists 10 | const token = localStorage.getItem('jwt'); 11 | if (token) { 12 | return { 13 | headers: { 14 | ...headers, 15 | authorization: token 16 | } 17 | }; 18 | } 19 | return headers; 20 | }); 21 | 22 | const client = new ApolloClient({ 23 | link: authLink.concat(httpLink), 24 | cache: new InMemoryCache() 25 | }); 26 | 27 | export default client; 28 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import store from './redux'; 5 | import App from './App'; 6 | import { GlobalStyle } from './styles'; 7 | import { ApolloProvider } from '@apollo/client'; 8 | 9 | import client from './apollo'; 10 | import { login } from './redux/auth/actions'; 11 | 12 | if (localStorage.getItem('jwt') && localStorage.getItem('csrf')) { 13 | const jwt = localStorage.getItem('jwt') as string; 14 | const csrf = localStorage.getItem('csrf') as string; 15 | store.dispatch(login(jwt, csrf)); 16 | } 17 | 18 | ReactDOM.render( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | , 27 | document.getElementById('root'), 28 | ); 29 | -------------------------------------------------------------------------------- /api/src/graphql/context.rs: -------------------------------------------------------------------------------- 1 | use crate::{environment::Environment, session::Session}; 2 | use shrinkwraprs::Shrinkwrap; 3 | 4 | #[derive(Shrinkwrap, Clone)] 5 | pub struct Context { 6 | session: Option, 7 | #[shrinkwrap(main_field)] 8 | env: Environment, 9 | } 10 | 11 | impl Context { 12 | pub async fn new(env: Environment, auth: Option<(String, String)>) -> anyhow::Result { 13 | if let Some((jwt, csrf)) = auth { 14 | let session = Some(Session::new(env.clone(), &jwt, &csrf).await?); 15 | Ok(Self { env, session }) 16 | } else { 17 | Ok(Self { env, session: None }) 18 | } 19 | } 20 | 21 | pub fn session(&self) -> Option<&Session> { 22 | self.session.as_ref() 23 | } 24 | 25 | pub fn is_authenticated(&self) -> bool { 26 | self.session.is_some() 27 | } 28 | } 29 | 30 | impl juniper::Context for Context {} 31 | -------------------------------------------------------------------------------- /frontend/src/redux/auth/reducers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LOGIN, 3 | LOGOUT, 4 | AuthActionTypes, 5 | AuthState 6 | } from './types'; 7 | 8 | const defaultState = { 9 | jwt: undefined, 10 | csrf: undefined, 11 | isLoggedIn: false 12 | }; 13 | 14 | function authReducer(state = defaultState, action: AuthActionTypes): AuthState { 15 | switch(action.type) { 16 | case LOGIN: 17 | const { jwt, csrf } = action.payload; 18 | localStorage.setItem('jwt', jwt); 19 | localStorage.setItem('csrf', csrf); 20 | return { 21 | jwt, 22 | csrf, 23 | isLoggedIn: true 24 | }; 25 | case LOGOUT: 26 | localStorage.removeItem('jwt'); 27 | localStorage.removeItem('csrf'); 28 | return { 29 | jwt: undefined, 30 | csrf: undefined, 31 | isLoggedIn: false 32 | }; 33 | default: 34 | return state; 35 | } 36 | } 37 | 38 | export default authReducer; 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kyle Murphy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | frontend: # React.js frontend 4 | build: 5 | context: ./frontend 6 | dockerfile: Dockerfile.dev 7 | volumes: 8 | - ./frontend:/app 9 | - "/app/node_modules" 10 | env_file: 11 | - ./.env.dev 12 | ports: 13 | - 3000:3000 14 | stdin_open: true 15 | 16 | api: # rust graphql server 17 | build: 18 | context: ./api 19 | dockerfile: Dockerfile.dev 20 | volumes: 21 | - ./api:/api 22 | env_file: 23 | - ./.env.dev 24 | environment: 25 | RUST_LOG: info 26 | DATABASE_URL: postgres://postgres:postgres@db:5432/postgres 27 | ports: 28 | - 3535:3535 29 | depends_on: 30 | - db 31 | - cache 32 | restart: always 33 | 34 | db: # postgresql database 35 | image: postgres 36 | ports: 37 | - 5432:5432 38 | volumes: 39 | - ./data:/var/lib/postgresql/data 40 | - ./migrations/up:/docker-entrypoint-initdb.d/ 41 | env_file: 42 | - ./.env.dev 43 | restart: always 44 | 45 | cache: 46 | image: redis:latest 47 | ports: 48 | - 6379:6379 49 | restart: always 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HoldemSolver 2 | 3 | Holdem Solver is an free to use, open source web-application similar to FlopZilla or Equilab with future plans to integrate a cloud-based poker solver. 4 | 5 | ## Features 6 | 7 | ![UI so far](/screenshots/2.png?raw=true "UISo Far") 8 | 9 | - Fast, range vs range equity calculation 10 | - Range equity heat maps 11 | - Hand class breakdown (like flopzilla) 12 | - Easy to use interface 13 | - Ability to save range vs range analysis 14 | - Free to use 15 | 16 | ## **Built With** 17 | 18 | - [React.js][1] 19 | - [Typescript][2] 20 | - [Rust][3] 21 | - [Graphql][4] 22 | 23 | [1]: https://reactjs.org/ 24 | [2]: https://www.typescriptlang.org/ 25 | [3]: https://www.rust-lang.org/ 26 | [4]: https://graphql.org/ 27 | 28 | ## Getting Started 29 | 30 | 1. Clone the repository 31 | 32 | `git clone https://github.com/kmurf1999/HoldemSolver` 33 | 34 | 2. Setup environment variables 35 | 36 | `mv .env.sample .env.dev` 37 | 38 | 3. Start the development server 39 | 40 | `docker-compose -f docker-compose.dev.yml up` 41 | 42 | ## Progress 43 | 44 | Currently working on completing UI and dockerizing the application. 45 | 46 | Contribution is welcome and appreciated. 47 | 48 | ## License 49 | 50 | This project is MIT Licensed 51 | 52 | Copyright (c) 2020 Kyle Murphy 53 | -------------------------------------------------------------------------------- /api/src/helpers/cache.rs: -------------------------------------------------------------------------------- 1 | use redis::aio::MultiplexedConnection; 2 | use redis::AsyncCommands; 3 | use serde::{de::DeserializeOwned, Serialize}; 4 | 5 | pub async fn get_or_create<'a, K, T, F, P>( 6 | con: &mut MultiplexedConnection, 7 | key: K, 8 | create_fn: F, 9 | ) -> anyhow::Result 10 | where 11 | K: redis::ToRedisArgs + Clone + Send + Sync + 'a, 12 | T: Serialize + DeserializeOwned, 13 | F: Fn() -> P, 14 | P: core::future::Future>, 15 | { 16 | if let Ok(item) = get(con, key.clone()).await { 17 | Ok(item) 18 | } else { 19 | let (item, expiry) = create_fn().await?; 20 | set_ex(con, key, &item, expiry).await?; 21 | Ok(item) 22 | } 23 | } 24 | 25 | pub async fn get<'a, K, T>(con: &mut MultiplexedConnection, key: K) -> anyhow::Result 26 | where 27 | K: redis::ToRedisArgs + Send + Sync + 'a, 28 | T: DeserializeOwned, 29 | { 30 | let bytes: Vec = con.get(key).await?; 31 | Ok(bincode::deserialize(&bytes)?) 32 | } 33 | 34 | pub async fn set_ex<'a, K, T>( 35 | con: &mut MultiplexedConnection, 36 | key: K, 37 | value: &T, 38 | seconds: usize, 39 | ) -> anyhow::Result<()> 40 | where 41 | K: redis::ToRedisArgs + Send + Sync + 'a, 42 | T: Serialize, 43 | { 44 | con.set_ex(key, bincode::serialize(value)?, seconds).await?; 45 | 46 | Ok(()) 47 | } 48 | -------------------------------------------------------------------------------- /api/src/model/redacted.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize, Serializer}; 2 | use shrinkwraprs::Shrinkwrap; 3 | use std::{fmt, iter}; 4 | use unicode_width::UnicodeWidthStr; 5 | 6 | #[derive( 7 | Shrinkwrap, Deserialize, sqlx::Type, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Default, 8 | )] 9 | #[sqlx(transparent)] 10 | pub struct Redacted(T); 11 | 12 | impl Redacted { 13 | pub fn _new(value: T) -> Self { 14 | Self(value) 15 | } 16 | } 17 | 18 | impl Serialize for Redacted { 19 | fn serialize(&self, ser: S) -> Result { 20 | ser.serialize_none() 21 | } 22 | } 23 | 24 | impl fmt::Debug for Redacted { 25 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 26 | write!( 27 | f, 28 | "{}", 29 | iter::repeat("█") 30 | .take(UnicodeWidthStr::width(format!("{:?}", self.0).as_str())) 31 | .collect::() 32 | ) 33 | } 34 | } 35 | 36 | impl fmt::Display for Redacted { 37 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 38 | write!( 39 | f, 40 | "{}", 41 | iter::repeat("█") 42 | .take(UnicodeWidthStr::width(format!("{}", self.0).as_str())) 43 | .collect::() 44 | ) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /api/src/environment/argon.rs: -------------------------------------------------------------------------------- 1 | use crate::Args; 2 | 3 | #[derive(Clone, Debug)] 4 | pub struct Argon { 5 | secret: String, 6 | memory_size: Option, 7 | iterations: Option, 8 | } 9 | 10 | impl Argon { 11 | pub fn new(args: &Args) -> Self { 12 | let Args { 13 | argon_secret, 14 | argon_memory_size, 15 | argon_iterations, 16 | .. 17 | } = args; 18 | Self { 19 | secret: argon_secret.to_owned(), 20 | memory_size: argon_memory_size.to_owned(), 21 | iterations: argon_iterations.to_owned(), 22 | } 23 | } 24 | 25 | pub fn hasher(&self) -> argonautica::Hasher<'static> { 26 | let mut hasher = argonautica::Hasher::default(); 27 | let mut hasher = hasher.with_secret_key(&self.secret); 28 | if let Some(memory_size) = self.memory_size { 29 | hasher = hasher.configure_memory_size(memory_size); 30 | } 31 | if let Some(iterations) = self.iterations { 32 | hasher = hasher.configure_iterations(iterations); 33 | } 34 | hasher.to_owned() 35 | } 36 | 37 | pub fn verifier(&self) -> argonautica::Verifier<'static> { 38 | let mut verifier = argonautica::Verifier::default(); 39 | let verifier = verifier.with_secret_key(&self.secret); 40 | verifier.to_owned() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", // Specifies the ESLint parser 3 | parserOptions: { 4 | ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features 5 | sourceType: "module", // Allows for the use of imports 6 | ecmaFeatures: { 7 | jsx: true // Allows for the parsing of JSX 8 | } 9 | }, 10 | settings: { 11 | react: { 12 | version: "detect" // Tells eslint-plugin-react to automatically detect the version of React to use 13 | } 14 | }, 15 | extends: [ 16 | "plugin:react/recommended", // Uses the recommended rules from @eslint-plugin-react 17 | "plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin 18 | "prettier/@typescript-eslint", // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 19 | "plugin:prettier/recommended" // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 20 | ], 21 | rules: { 22 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs 23 | // e.g. "@typescript-eslint/explicit-function-return-type": "off", 24 | }, 25 | }; -------------------------------------------------------------------------------- /frontend/src/components/CreateRangeForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, ChangeEvent } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import Input from './Input'; 5 | 6 | const CreateRangeFormStyle = styled.div``; 7 | 8 | function CreateRangeForm(): React.ReactElement { 9 | const [game, setGame] = useState(''); 10 | const [position, setPosition] = useState(''); 11 | const [name, setName] = useState(''); 12 | function onNameChange(e: ChangeEvent) { 13 | e.preventDefault(); 14 | const value = (e.target as HTMLInputElement).value; 15 | setName(value); 16 | } 17 | function onPositionChange(e: ChangeEvent) { 18 | e.preventDefault(); 19 | const value = (e.target as HTMLInputElement).value; 20 | setPosition(value); 21 | } 22 | function onGameChange(e: ChangeEvent) { 23 | e.preventDefault(); 24 | const value = (e.target as HTMLInputElement).value; 25 | setGame(value); 26 | } 27 | return ( 28 | 29 |
30 | 31 | 32 | 33 |
34 |
35 | ); 36 | } 37 | 38 | export default CreateRangeForm; 39 | -------------------------------------------------------------------------------- /api/src/graphql/mutation/account.rs: -------------------------------------------------------------------------------- 1 | use crate::graphql::Context; 2 | use crate::{auth, model}; 3 | use juniper::FieldResult; 4 | use uuid::Uuid; 5 | 6 | #[derive(juniper::GraphQLInputObject, Debug)] 7 | pub struct CreateAccountInput { 8 | email: String, 9 | password: String, 10 | } 11 | 12 | pub struct AccountMutation; 13 | 14 | #[derive(juniper::GraphQLInputObject, Debug)] 15 | pub struct AccountInput { 16 | email: String, 17 | } 18 | 19 | #[juniper::graphql_object(Context = Context)] 20 | impl AccountMutation { 21 | async fn create(ctx: &Context, input: CreateAccountInput) -> FieldResult { 22 | let argon = ctx.argon(); 23 | 24 | let CreateAccountInput { email, password } = input; 25 | let password = argon.hasher().with_password(password).hash()?; 26 | let id = Uuid::new_v4(); 27 | 28 | crate::sql::account::create_account(ctx.database(), id, &email, &password).await?; 29 | 30 | Ok(crate::sql::account::get_account(ctx.database(), &email).await?) 31 | } 32 | 33 | async fn update(ctx: &Context, id: Uuid, input: AccountInput) -> FieldResult { 34 | let acc = ctx 35 | .session() 36 | .ok_or(auth::AuthError::InvalidCredentials)? 37 | .account() 38 | .await?; 39 | if acc.id != id { 40 | return Err(auth::AuthError::InvalidCredentials.into()); 41 | } 42 | 43 | Ok(crate::sql::account::update_email(ctx.database(), id, &input.email).await?) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "holdemsolver-api" 3 | version = "0.1.0" 4 | authors = ["Kyle Murphy "] 5 | edition = "2018" 6 | 7 | # [[bin]] 8 | # name = "api" 9 | # path = "dummy.rs" 10 | 11 | [dependencies] 12 | anyhow = "1.0.34" 13 | thiserror = "1.0.20" 14 | clap = "3.0.0-beta.2" 15 | dotenv = "0.15.0" 16 | tracing = "0.1.16" 17 | tracing-subscriber = "0.2.15" 18 | serde = "1.0.114" 19 | serde_json = "1.0.59" 20 | bincode = "1.3.1" 21 | shrinkwraprs = "0.3.0" 22 | chrono = { version = "0.4.19", features = ["serde"] } 23 | uuid = { version = "0.8.1", features = ["serde", "v4"] } 24 | unicode-width = "0.1.8" 25 | rand = "0.7.3" 26 | futures = "0.3.5" 27 | tokio = { version = "0.2.21", features = ["full"] } 28 | warp = "0.2.5" 29 | http-api-problem = { version = "0.17.0", features = ["with-warp"] } 30 | biscuit = "0.5.0-beta2" 31 | argonautica = "0.2.0" 32 | sqlx = { version = "0.3.5", default-features = false, features = [ "runtime-tokio", "macros", "postgres", "uuid", "chrono", "json" ] } 33 | redis = { version = "0.17.0", default-features = false, features = [ "tokio-rt-core" ]} 34 | juniper = { git = "https://github.com/graphql-rust/juniper.git", rev = "4d77a1a9b9b0e60cbeb200527289bd45afec4141" } 35 | juniper_subscriptions = { git = "https://github.com/graphql-rust/juniper.git", rev = "4d77a1a9b9b0e60cbeb200527289bd45afec4141" } 36 | juniper_warp = { git = "https://github.com/graphql-rust/juniper.git", features = ["subscriptions"], rev = "4d77a1a9b9b0e60cbeb200527289bd45afec4141" } 37 | hyper = "0.13.6" 38 | listenfd = "0.3.3" 39 | -------------------------------------------------------------------------------- /api/src/environment/jwt.rs: -------------------------------------------------------------------------------- 1 | use crate::auth; 2 | 3 | type DateTimeUtc = chrono::DateTime; 4 | #[derive(Clone, Debug)] 5 | pub struct Jwt { 6 | secret: String, 7 | } 8 | 9 | impl Jwt { 10 | pub fn new(secret: &str) -> Self { 11 | Self { 12 | secret: secret.to_owned(), 13 | } 14 | } 15 | 16 | pub fn encode(&self, claims: auth::Claims, _expiry: DateTimeUtc) -> anyhow::Result { 17 | let registered = biscuit::RegisteredClaims::default(); 18 | let private = claims; 19 | let claims = biscuit::ClaimsSet:: { 20 | registered, 21 | private, 22 | }; 23 | 24 | let jwt = biscuit::JWT::new_decoded( 25 | From::from(biscuit::jws::RegisteredHeader { 26 | algorithm: biscuit::jwa::SignatureAlgorithm::HS256, 27 | ..Default::default() 28 | }), 29 | claims, 30 | ); 31 | 32 | let secret = biscuit::jws::Secret::bytes_from_str(&self.secret); 33 | 34 | jwt.into_encoded(&secret) 35 | .map(|t| t.unwrap_encoded().to_string()) 36 | .map_err(|e| e.into()) 37 | } 38 | 39 | pub fn decode(&self, token: &str) -> anyhow::Result { 40 | let token = biscuit::JWT::::new_encoded(&token); 41 | let secret = biscuit::jws::Secret::bytes_from_str(&self.secret); 42 | let token = token.into_decoded(&secret, biscuit::jwa::SignatureAlgorithm::HS256)?; 43 | let payload = token.payload()?.private.to_owned(); 44 | Ok(payload) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /api/src/environment/mod.rs: -------------------------------------------------------------------------------- 1 | mod argon; 2 | mod jwt; 3 | 4 | use crate::Args; 5 | use argon::Argon; 6 | use jwt::Jwt; 7 | use sqlx::postgres::PgPool; 8 | 9 | #[derive(Clone, Debug)] 10 | pub struct Environment { 11 | db_pool: PgPool, 12 | redis: redis::Client, 13 | argon: Argon, 14 | jwt: Jwt, 15 | session_lifetime: Option 16 | } 17 | 18 | impl Environment { 19 | pub async fn new(args: &Args) -> anyhow::Result { 20 | let Args { 21 | database_url, 22 | redis_url, 23 | session_lifetime, 24 | jwt_secret, 25 | .. 26 | } = &args; 27 | let db_pool = PgPool::builder().max_size(5).build(database_url).await?; 28 | let redis = redis::Client::open(redis_url.as_str())?; 29 | let argon = Argon::new(&args); 30 | let jwt = Jwt::new(&jwt_secret); 31 | Ok(Self { 32 | db_pool, 33 | redis, 34 | argon, 35 | jwt, 36 | session_lifetime: session_lifetime.to_owned(), 37 | }) 38 | } 39 | 40 | pub fn database(&self) -> &PgPool { 41 | &self.db_pool 42 | } 43 | 44 | pub fn argon(&self) -> &Argon { 45 | &self.argon 46 | } 47 | 48 | pub async fn redis(&self) -> anyhow::Result { 49 | self.redis 50 | .get_multiplexed_tokio_connection() 51 | .await 52 | .map_err(|e| e.into()) 53 | } 54 | 55 | pub fn jwt(&self) -> &Jwt { 56 | &self.jwt 57 | } 58 | 59 | pub fn session_lifetime(&self, req_lifetime: Option) -> i64 { 60 | req_lifetime.or(self.session_lifetime).unwrap_or(86400i64) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect, ConnectedProps } from 'react-redux'; 3 | import { 4 | BrowserRouter as Router, 5 | Switch, 6 | Route, 7 | Redirect 8 | } from "react-router-dom"; 9 | import { RootState } from './redux'; 10 | 11 | import Landing from './pages/Landing'; 12 | import Home from './pages/Home'; 13 | import Login from './pages/Login'; 14 | import Register from './pages/Register'; 15 | 16 | import Footer from './components/Footer'; 17 | import Nav from './components/Nav'; 18 | 19 | function ProtectedRoute({ isLoggedIn, component: Component, ...rest }: any): React.ReactElement { 20 | return ( 21 | { 22 | return isLoggedIn 23 | ? 24 | : 25 | }}/> 26 | ); 27 | } 28 | 29 | function mapStateToProps(state: RootState) { 30 | return { 31 | isLoggedIn: state.auth.isLoggedIn 32 | }; 33 | } 34 | 35 | const connector = connect(mapStateToProps, null); 36 | 37 | type PropsFromRedux = ConnectedProps; 38 | 39 | type Props = PropsFromRedux; 40 | 41 | function App(props: Props): React.ReactElement { 42 | const { isLoggedIn } = props; 43 | return ( 44 | 45 |