Web Client #11

Merged
Simponic merged 19 commits from web into main 2023-01-19 16:04:10 -05:00
12 changed files with 327 additions and 142 deletions
Showing only changes of commit 2db07410df - Show all commits

View File

@ -15,7 +15,8 @@ config :chessh, RateLimits,
jail_attempt_threshold: 15,
max_concurrent_user_sessions: 5,
player_session_message_burst_ms: 500,
player_session_message_burst_rate: 8
player_session_message_burst_rate: 8,
player_public_keys: 15
config :chessh, Web,
port: 8080,

View File

@ -15,6 +15,7 @@
"react": "^18.2.0",
"react-code-blocks": "^0.0.9-0",
"react-dom": "^18.2.0",
"react-modal": "^3.16.1",
"react-router-dom": "^6.6.2",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
@ -7836,6 +7837,11 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/exenv": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz",
"integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw=="
},
"node_modules/exit": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
@ -14533,6 +14539,29 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
},
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
},
"node_modules/react-modal": {
"version": "3.16.1",
"resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.1.tgz",
"integrity": "sha512-VStHgI3BVcGo7OXczvnJN7yT2TWHJPDXZWyI/a0ssFNhGZWsPmB8cF0z33ewDXq4VfYMO1vXgiv/g8Nj9NDyWg==",
"dependencies": {
"exenv": "^1.2.0",
"prop-types": "^15.7.2",
"react-lifecycles-compat": "^3.0.0",
"warning": "^4.0.3"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"react": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18",
"react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18"
}
},
"node_modules/react-refresh": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
@ -16620,6 +16649,14 @@
"makeerror": "1.0.12"
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/watchpack": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
@ -23111,6 +23148,11 @@
"strip-final-newline": "^2.0.0"
}
},
"exenv": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz",
"integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw=="
},
"exit": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
@ -27768,6 +27810,22 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
},
"react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
},
"react-modal": {
"version": "3.16.1",
"resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.1.tgz",
"integrity": "sha512-VStHgI3BVcGo7OXczvnJN7yT2TWHJPDXZWyI/a0ssFNhGZWsPmB8cF0z33ewDXq4VfYMO1vXgiv/g8Nj9NDyWg==",
"requires": {
"exenv": "^1.2.0",
"prop-types": "^15.7.2",
"react-lifecycles-compat": "^3.0.0",
"warning": "^4.0.3"
}
},
"react-refresh": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
@ -29304,6 +29362,14 @@
"makeerror": "1.0.12"
}
},
"warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"requires": {
"loose-envify": "^1.0.0"
}
},
"watchpack": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",

View File

@ -10,6 +10,7 @@
"react": "^18.2.0",
"react-code-blocks": "^0.0.9-0",
"react-dom": "^18.2.0",
"react-modal": "^3.16.1",
"react-router-dom": "^6.6.2",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"

View File

@ -1,4 +1,4 @@
import React, { useContext, useState, createContext, useEffect } from "react";
import React, { useContext, useState, createContext } from "react";
export const DEFAULT_EXPIRY_TIME_MS = 12 * 60 * 60 * 1000;
@ -21,63 +21,6 @@ export const AuthProvider = ({ children }) => {
const [userId, setUserId] = useState(null);
const [username, setUsername] = useState(null);
useEffect(() => {
if (userId) {
localStorage.setItem("userId", userId.toString());
}
}, [userId]);
useEffect(() => {
if (username) {
localStorage.setItem("username", username);
}
}, [username]);
useEffect(() => {
let expiry = localStorage.getItem("expiry");
if (expiry) {
expiry = new Date(expiry);
if (Date.now() < expiry.getTime()) {
setSignedIn(true);
setSessionOver(expiry);
// We don't have access to the JWT token as it is an HTTP only cookie -
// so we store user info in local storage
((username) => {
if (username) {
setUsername(username);
}
})(localStorage.getItem("username"));
((id) => {
if (id) {
setUserId(parseInt(id, 10));
}
})(localStorage.getItem("userId"));
}
}
}, []);
useEffect(() => {
localStorage.setItem("expiry", sessionOver.toISOString());
setTimeout(() => {
setSessionOver((sessionOver) => {
if (Date.now() >= sessionOver.getTime()) {
setSignedIn((signedIn) => {
if (signedIn) {
alert(
"Session expired. Any further privileged requests will fail until signed in again."
);
["userId", "userName"].map((x) => localStorage.removeItem(x));
return false;
}
return signedIn;
});
}
return sessionOver;
});
}, sessionOver.getTime() - Date.now());
}, [sessionOver]);
return (
<AuthContext.Provider
value={{

View File

@ -1,3 +1,9 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
:root {
--main-bg-color: #282828;
--primary-text-color: #ebdbb2;
@ -66,6 +72,7 @@ body {
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
border-radius: 12px;
padding: 0.5rem;
padding-left: 2rem;
@ -103,6 +110,13 @@ a:hover {
gap: 2rem;
}
.flex-end-row {
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 1rem;
}
.container {
padding-top: 1rem;
max-width: 1200px;
@ -138,10 +152,48 @@ a:hover {
justify-content: space-around;
flex-direction: row;
align-items: center;
padding: 2rem;
padding-left: 1rem;
padding-right: 1rem;
border-radius: 12px;
border: solid 1px var(--gold-color);
margin-top: 12px;
gap: 0.5rem;
}
.key-card-collection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
input,
textarea {
font-family: "DM Mono";
color: var(--primary-text-color);
background-color: rgba(0, 0, 0, 0.2);
border-radius: 4px;
border: 1px solid var(--primary-text-color);
}
input,textarea: focus {
border: 1px solid var(--gold-color);
}
.modal {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 3rem;
top: 50%;
left: 50%;
-moz-transform: translateX(-50%) translateY(-50%);
-webkit-transform: translateX(-50%) translateY(-50%);
transform: translateX(-50%) translateY(-50%);
position: absolute;
border-radius: 12px;
border: solid 1px var(--purple-color);
background-color: var(--main-bg-color);
}
@media screen and (max-width: 680px) {
@ -151,4 +203,22 @@ a:hover {
.navbar {
flex-direction: column;
}
.key-card {
flex-direction: column;
justify-content: start;
gap: 0;
align-items: start;
padding-bottom: 1rem;
}
.flex-row-around {
flex-direction: column;
gap: 0;
}
}
@media screen and (max-width: 1200px) {
.key-card-collection {
display: flex;
flex-direction: column;
}
}

View File

@ -2,7 +2,7 @@ import { Link, Outlet } from "react-router-dom";
import logo from "./assets/chessh_sm.svg";
import { useAuthContext, DEFAULT_EXPIRY_TIME_MS } from "./context/auth_context";
import { useAuthContext } from "./context/auth_context";
export const Root = () => {
const { signedIn, setUserId, setSignedIn, setSessionOver } = useAuthContext();
@ -45,15 +45,7 @@ export const Root = () => {
</>
) : (
<>
<a
onClick={() =>
setSessionOver(
new Date(Date.now() + DEFAULT_EXPIRY_TIME_MS)
)
}
href={process.env.REACT_APP_GITHUB_OAUTH}
className="button"
>
<a href={process.env.REACT_APP_GITHUB_OAUTH} className="button">
🐙 Login w/ GitHub 🐙
</a>
</>

View File

@ -1,29 +1,34 @@
import { useEffect, useCallback } from "react";
import { Link } from "react-router-dom";
import { useAuthContext } from "../context/auth_context";
import {
useAuthContext,
DEFAULT_EXPIRY_TIME_MS,
} from "../context/auth_context";
export const AuthSuccessful = () => {
const { username, signedIn, setSignedIn, setUserId, setUsername } =
useAuthContext();
const fetchMyself = useCallback(
() =>
fetch("/api/player/me", {
credentials: "same-origin",
})
.then((r) => r.json())
.then((player) => {
setSignedIn(!!player);
setUserId(player.id);
setUsername(player.username);
}),
[setSignedIn, setUserId, setUsername]
);
const {
username,
userId,
sessionOver,
signedIn,
setSignedIn,
setUserId,
setUsername,
setSessionOver,
} = useAuthContext();
useEffect(() => {
fetchMyself();
}, [fetchMyself]);
fetch("/api/player/me", {
credentials: "same-origin",
})
.then((r) => r.json())
.then((player) => {
setSignedIn(!!player);
setUserId(player.id);
setUsername(player.username);
});
}, []);
if (signedIn) {
return (

View File

@ -35,8 +35,8 @@ export const Demo = () => {
</h1>
<div className="flex-row-around">
<p>
CheSSH is a multiplayer, scalable, free, open source, and potentially
passwordless game of Chess over the SSH protocol.
CheSSH is a multiplayer, scalable, free, open source, and (optionally)
passwordless game of Chess over the SSH protocol, written in Elixir.
</p>
<a
className="button gold"
@ -44,7 +44,7 @@ export const Demo = () => {
target="_blank"
rel="noreferrer"
>
🌟 Star 🌟
🌟 Star Repo 🌟
</a>
</div>
<hr />
@ -53,7 +53,7 @@ export const Demo = () => {
<div className="flex-row-around">
<h3>Would you like to play a game?</h3>
<Link className="button" to="/home">
Yes, Joshua
Yes, Falken
</Link>
</div>
</div>

View File

@ -9,8 +9,8 @@ export const Home = () => {
if (signedIn) {
const sshConfig = `Host chessh
Hostname ${process.env.REACT_APP_SSH_SERVER}
User ${username}
Port ${process.env.REACT_APP_SSH_PORT}
User ${username}
PubkeyAuthentication yes`;
return (
<>
@ -37,10 +37,10 @@ export const Home = () => {
/>
<div>
<li>And connect with:</li>
<li>Then, connect with:</li>
<CopyBlock
theme={dracula}
text={`ssh -t chessh`}
text={"ssh -t chessh"}
language={"shell"}
showLineNumbers={false}
codeBlock
@ -54,6 +54,9 @@ export const Home = () => {
return (
<div>
<h1>CheSSH</h1>
<p>Hello!</p>
<p>Looks like you're not signed in 👀. </p>
<p>Please link your GitHub account above!</p>
</div>
);
};

View File

@ -1,6 +1,9 @@
import { useEffect, useState } from "react";
import Modal from "react-modal";
import { useEffect, useState, useCallback } from "react";
import { useAuthContext } from "../context/auth_context";
Modal.setAppElement("#root");
const MINIMIZE_KEY_LEN = 40;
const minimizeKey = (key) => {
const n = key.length;
@ -11,7 +14,7 @@ const minimizeKey = (key) => {
return key;
};
const KeyCard = ({ props }) => {
const KeyCard = ({ onDelete, props }) => {
const { id, name, key } = props;
const deleteThisKey = () => {
@ -20,25 +23,41 @@ const KeyCard = ({ props }) => {
method: "DELETE",
})
.then((r) => r.json())
.then((d) => d.success); //&& onDelete());
.then((d) => d.success && onDelete && onDelete());
};
return (
<div className="key-card">
<h4>{name}</h4>
<p>{minimizeKey(key)}</p>
<h4 style={{ flex: 1 }}>{name}</h4>
<p style={{ flex: 4 }}>{minimizeKey(key)}</p>
<button className="button red" onClick={deleteThisKey}>
<button
style={{ flex: 0 }}
className="button red"
onClick={deleteThisKey}
>
Delete
</button>
</div>
);
};
const AddKey = () => {
const [key, setKey] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState("");
const AddKeyButton = ({ onSave }) => {
const [open, setOpen] = useState(false);
const [name, setName] = useState({ value: "", error: "" });
const [key, setKey] = useState({ value: "", error: "" });
const [errors, setErrors] = useState(null);
const setDefaults = () => {
setName({ value: "", error: "" });
setKey({ value: "", error: "" });
setErrors(null);
};
const close = () => {
setDefaults();
setOpen(false);
};
const createKey = () => {
fetch(`/api/player/keys`, {
@ -48,28 +67,92 @@ const AddKey = () => {
"Content-Type": "application/json",
},
body: JSON.stringify({
key,
name,
key: key.value,
name: name.value,
}),
})
.then((r) => r.json())
.then((d) => {
if (d.success) {
setName("");
setKey("");
} else {
setError(d.errors);
if (onSave) {
onSave();
}
setDefaults();
close();
} else if (d.errors) {
if (typeof d.errors === "object") {
setErrors(
Object.keys(d.errors).map(
(field) => `${field}: ${d.errors[field].join(",")}`
)
);
} else {
setErrors([d.errors]);
}
}
});
};
return (
<div className="key-card">
<input onChange={(e) => setName(e.target.value)} />
<textarea onChange={(e) => setKey(e.target.value)} />
<button className="button gold" onClick={createKey}>
Add
<div>
<button className="button" onClick={() => setOpen(true)}>
+ Add Key
</button>
<Modal
isOpen={open}
onRequestClose={close}
className="modal"
contentLabel="Add Key"
>
<div>
<h3>Add SSH Key</h3>
<p>
Not sure about this? Check{" "}
<a
href="https://www.ssh.com/academy/ssh/keygen"
target="_blank"
rel="noreferrer"
>
here
</a>{" "}
for help!
</p>
<hr />
<p>Key Name *</p>
<input
value={name.value}
onChange={(e) => setName({ ...name, value: e.target.value })}
required
/>
</div>
<div>
<p>SSH Key *</p>
<textarea
cols={40}
rows={5}
value={key.value}
onChange={(e) => setKey({ ...key, value: e.target.value })}
required
/>
</div>
<div>
{errors && (
<div style={{ color: "red" }}>
{errors.map((error, i) => (
<p key={i}>{error}</p>
))}
</div>
)}
</div>
<div className="flex-end-row">
<button className="button" onClick={createKey}>
Add
</button>
<button className="button red" onClick={close}>
Cancel
</button>
</div>
</Modal>
</div>
);
};
@ -78,13 +161,19 @@ export const Keys = () => {
const { userId } = useAuthContext();
const [keys, setKeys] = useState(null);
useEffect(() => {
if (userId) {
const refreshKeys = useCallback(
() =>
fetch(`/api/player/${userId}/keys`)
.then((r) => r.json())
.then((keys) => setKeys(keys));
.then((keys) => setKeys(keys)),
[userId]
);
useEffect(() => {
if (userId) {
refreshKeys();
}
}, [userId]);
}, [userId, refreshKeys]);
if (!keys) {
return <p>Loading...</p>;
@ -92,12 +181,17 @@ export const Keys = () => {
if (Array.isArray(keys)) {
return (
<>
<AddKey />
{keys.length ? (
keys.map((key) => <KeyCard key={key.id} props={key} />)
) : (
<p>No keys</p>
)}
<h2>My Keys</h2>
<AddKeyButton onSave={refreshKeys} />
<div className="key-card-collection">
{keys.length ? (
keys.map((key) => (
<KeyCard key={key.id} onDelete={refreshKeys} props={key} />
))
) : (
<p>Looks like you've got no keys, try adding some!</p>
)}
</div>
</>
);
}

View File

@ -22,7 +22,7 @@ defmodule Chessh.Key do
|> cast(update_encode_key(attrs, :key), [:key, :player_id])
|> cast(attrs, [:name])
|> validate_required([:key, :name])
|> validate_format(:key, ~r/[\-\w\d]+ [^ ]+$/, message: "invalid public ssh key")
|> validate_format(:key, ~r/^[\-\w\d]+ [^ ]+$/, message: "invalid public ssh key")
|> validate_format(:key, ~r/^(?!ssh-dss).+/, message: "DSA keys are not supported")
|> unique_constraint([:player_id, :key], message: "Player already has that key")
end

View File

@ -3,6 +3,7 @@ defmodule Chessh.Web.Endpoint do
alias Chessh.Web.Token
use Plug.Router
require Logger
import Ecto.Query
plug(Plug.Logger)
plug(:match)
@ -127,26 +128,35 @@ defmodule Chessh.Web.Endpoint do
post "/player/keys" do
player = get_player_from_jwt(conn)
player_key_count =
Repo.aggregate(from(k in Key, where: k.player_id == ^player.id), :count, :id)
max_key_count = Application.get_env(:chessh, RateLimits)[:player_public_keys]
{status, body} =
case conn.body_params do
%{"key" => key, "name" => name} ->
case Key.changeset(%Key{player_id: player.id}, %{key: key, name: name})
|> Repo.insert() do
{:ok, _new_key} ->
{
200,
%{
success: true
if player_key_count > max_key_count do
{400, %{errors: "Player has reached threshold of #{max_key_count} keys."}}
else
case Key.changeset(%Key{player_id: player.id}, %{key: key, name: name})
|> Repo.insert() do
{:ok, _new_key} ->
{
200,
%{
success: true
}
}
}
{:error, %{valid?: false} = changeset} ->
{
400,
%{
errors: format_errors(changeset)
{:error, %{valid?: false} = changeset} ->
{
400,
%{
errors: format_errors(changeset)
}
}
}
end
end
_ ->