summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/chessh.ex5
-rw-r--r--lib/chessh/application.ex11
-rw-r--r--lib/chessh/release.ex18
-rw-r--r--lib/chessh/schema/key.ex11
-rw-r--r--lib/chessh/schema/player.ex13
-rw-r--r--lib/chessh/schema/player_session.ex8
-rw-r--r--lib/chessh/ssh/client/client.ex13
-rw-r--r--lib/chessh/ssh/client/game/game.ex37
-rw-r--r--lib/chessh/ssh/client/menu.ex10
-rw-r--r--lib/chessh/ssh/daemon.ex8
-rw-r--r--lib/chessh/web/token.ex5
-rw-r--r--lib/chessh/web/web.ex289
12 files changed, 391 insertions, 37 deletions
diff --git a/lib/chessh.ex b/lib/chessh.ex
deleted file mode 100644
index 82d0cfc..0000000
--- a/lib/chessh.ex
+++ /dev/null
@@ -1,5 +0,0 @@
-defmodule Chessh do
- def hello() do
- :world
- end
-end
diff --git a/lib/chessh/application.ex b/lib/chessh/application.ex
index 4b03169..5538e39 100644
--- a/lib/chessh/application.ex
+++ b/lib/chessh/application.ex
@@ -15,7 +15,16 @@ defmodule Chessh.Application do
end
def start(_, _) do
- children = [Chessh.Repo, Chessh.SSH.Daemon]
+ children = [
+ Chessh.Repo,
+ Chessh.SSH.Daemon,
+ Plug.Cowboy.child_spec(
+ scheme: :http,
+ plug: Chessh.Web.Endpoint,
+ options: [port: Application.get_env(:chessh, Web)[:port]]
+ )
+ ]
+
opts = [strategy: :one_for_one, name: Chessh.Supervisor]
with {:ok, pid} <- Supervisor.start_link(children, opts) do
diff --git a/lib/chessh/release.ex b/lib/chessh/release.ex
new file mode 100644
index 0000000..82f1807
--- /dev/null
+++ b/lib/chessh/release.ex
@@ -0,0 +1,18 @@
+defmodule Chessh.Release do
+ @app :chessh
+
+ def migrate do
+ for repo <- repos() do
+ {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
+ end
+ end
+
+ def rollback(repo, version) do
+ {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
+ end
+
+ defp repos do
+ Application.load(@app)
+ Application.fetch_env!(@app, :ecto_repos)
+ end
+end
diff --git a/lib/chessh/schema/key.ex b/lib/chessh/schema/key.ex
index df790e2..856aa8e 100644
--- a/lib/chessh/schema/key.ex
+++ b/lib/chessh/schema/key.ex
@@ -11,13 +11,20 @@ defmodule Chessh.Key do
timestamps()
end
+ defimpl Jason.Encoder, for: Chessh.Key do
+ def encode(value, opts) do
+ Jason.Encode.map(Map.take(value, [:id, :key, :name]), opts)
+ end
+ end
+
def changeset(key, attrs) do
key
- |> cast(update_encode_key(attrs, :key), [:key])
+ |> 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
def encode_key(key) do
diff --git a/lib/chessh/schema/player.ex b/lib/chessh/schema/player.ex
index 074ea4e..f12ad9e 100644
--- a/lib/chessh/schema/player.ex
+++ b/lib/chessh/schema/player.ex
@@ -5,6 +5,8 @@ defmodule Chessh.Player do
@derive {Inspect, except: [:password]}
schema "players" do
+ field(:github_id, :integer)
+
field(:username, :string)
field(:password, :string, virtual: true)
@@ -19,6 +21,15 @@ defmodule Chessh.Player do
timestamps()
end
+ defimpl Jason.Encoder, for: Chessh.Player do
+ def encode(value, opts) do
+ Jason.Encode.map(
+ Map.take(value, [:id, :github_id, :username, :created_at, :updated_at]),
+ opts
+ )
+ end
+ end
+
def authentications_changeset(player, attrs) do
player
|> cast(attrs, [:authentications])
@@ -26,7 +37,7 @@ defmodule Chessh.Player do
def registration_changeset(player, attrs, opts \\ []) do
player
- |> cast(attrs, [:username, :password])
+ |> cast(attrs, [:username, :password, :github_id])
|> validate_username()
|> validate_password(opts)
end
diff --git a/lib/chessh/schema/player_session.ex b/lib/chessh/schema/player_session.ex
index b16519f..f12387a 100644
--- a/lib/chessh/schema/player_session.ex
+++ b/lib/chessh/schema/player_session.ex
@@ -108,4 +108,12 @@ defmodule Chessh.PlayerSession do
3_000 -> false
end
end
+
+ def close_all_player_sessions(player) do
+ player_sessions = Repo.all(from(p in PlayerSession, where: p.player_id == ^player.id))
+
+ Enum.map(player_sessions, fn session ->
+ :syn.publish(:player_sessions, {:session, session.id}, :session_closed)
+ end)
+ end
end
diff --git a/lib/chessh/ssh/client/client.ex b/lib/chessh/ssh/client/client.ex
index 72bbb66..3aaed07 100644
--- a/lib/chessh/ssh/client/client.ex
+++ b/lib/chessh/ssh/client/client.ex
@@ -11,8 +11,6 @@ defmodule Chessh.SSH.Client do
@min_terminal_width 64
@min_terminal_height 38
- @max_terminal_width 220
- @max_terminal_height 100
defmodule State do
defstruct tui_pid: nil,
@@ -163,13 +161,10 @@ defmodule Chessh.SSH.Client do
end
defp get_terminal_dim_msg(width, height) do
- case {height > @max_terminal_height, height < @min_terminal_height,
- width > @max_terminal_width, width < @min_terminal_width} do
- {true, _, _, _} -> {true, @clear_codes ++ ["The terminal height is too large."]}
- {_, true, _, _} -> {true, @clear_codes ++ ["The terminal height is too small."]}
- {_, _, true, _} -> {true, @clear_codes ++ ["The terminal width is too large"]}
- {_, _, _, true} -> {true, @clear_codes ++ ["The terminal width is too small."]}
- {false, false, false, false} -> {false, nil}
+ case {height < @min_terminal_height, width < @min_terminal_width} do
+ {true, _} -> {true, @clear_codes ++ ["The terminal height is too small."]}
+ {_, true} -> {true, @clear_codes ++ ["The terminal width is too small."]}
+ {false, false} -> {false, nil}
end
end
diff --git a/lib/chessh/ssh/client/game/game.ex b/lib/chessh/ssh/client/game/game.ex
index 2ee6dca..65b9d10 100644
--- a/lib/chessh/ssh/client/game/game.ex
+++ b/lib/chessh/ssh/client/game/game.ex
@@ -35,16 +35,23 @@ defmodule Chessh.SSH.Client.Game do
def init([
%State{
color: color,
- game: %Game{dark_player_id: dark_player_id, light_player_id: light_player_id}
+ game: %Game{dark_player_id: dark_player_id, light_player_id: light_player_id},
+ player_session: %{player_id: player_id}
} = state
| tail
])
when is_nil(color) do
+ {is_dark, is_light} = {player_id == dark_player_id, player_id == light_player_id}
+
new_state =
- case {is_nil(dark_player_id), is_nil(light_player_id)} do
- {true, false} -> %State{state | color: :dark}
- {false, true} -> %State{state | color: :light}
- {_, _} -> %State{state | color: Enum.random([:light, :dark])}
+ if is_dark || is_light do
+ %State{state | color: if(is_light, do: :light, else: :dark)}
+ else
+ case {is_nil(dark_player_id), is_nil(light_player_id)} do
+ {true, false} -> %State{state | color: :dark}
+ {false, true} -> %State{state | color: :light}
+ {_, _} -> %State{state | color: :light}
+ end
end
init([new_state | tail])
@@ -89,18 +96,22 @@ defmodule Chessh.SSH.Client.Game do
end
binbo_pid = initialize_game(game_id, fen)
- send(client_pid, {:send_to_ssh, Utils.clear_codes()})
new_game = Repo.get(Game, game_id) |> Repo.preload([:light_player, :dark_player])
- new_state = %State{
- state
- | binbo_pid: binbo_pid,
- color: if(new_game.light_player_id == player_session.player_id, do: :light, else: :dark),
- game: new_game
- }
+ player_color =
+ if(new_game.light_player_id == player_session.player_id, do: :light, else: :dark)
- {:ok, new_state}
+ send(client_pid, {:send_to_ssh, Utils.clear_codes()})
+
+ {:ok,
+ %State{
+ state
+ | binbo_pid: binbo_pid,
+ color: player_color,
+ game: new_game,
+ flipped: player_color == :dark
+ }}
end
def init([
diff --git a/lib/chessh/ssh/client/menu.ex b/lib/chessh/ssh/client/menu.ex
index 6c96cc2..b69340c 100644
--- a/lib/chessh/ssh/client/menu.ex
+++ b/lib/chessh/ssh/client/menu.ex
@@ -63,7 +63,6 @@ defmodule Chessh.SSH.Client.Menu do
%Chessh.SSH.Client.Game.State{player_session: player_session, game: game}}}
end) ++
[
- {"Settings", {}},
{"Help", {}}
]
end
@@ -130,7 +129,14 @@ defmodule Chessh.SSH.Client.Menu do
fn {i, {option, _}} ->
[
ANSI.cursor(y + length(logo_lines) + i + 1, x),
- if(i == selected, do: ANSI.format([:bright, :light_cyan, "+ #{option}"]), else: option)
+ if(i == selected,
+ do:
+ ANSI.format_fragment(
+ [:light_cyan, :bright, "> #{option} <", :reset],
+ true
+ ),
+ else: option
+ )
]
end
) ++ [ANSI.home()]
diff --git a/lib/chessh/ssh/daemon.ex b/lib/chessh/ssh/daemon.ex
index e122f9a..6be6732 100644
--- a/lib/chessh/ssh/daemon.ex
+++ b/lib/chessh/ssh/daemon.ex
@@ -47,12 +47,12 @@ defmodule Chessh.SSH.Daemon do
:disconnect
end
- x ->
+ authed_or_disconnect ->
PlayerSession.update_sessions_and_player_satisfies(username, fn _player ->
- x
+ authed_or_disconnect
end)
- x
+ authed_or_disconnect
end
end
@@ -92,7 +92,7 @@ defmodule Chessh.SSH.Daemon do
def handle_info(_, state), do: {:noreply, state}
defp on_disconnect(_reason) do
- Logger.debug("#{inspect(self())} disconnected")
+ Logger.info("#{inspect(self())} disconnected")
Repo.delete_all(
from(p in PlayerSession,
diff --git a/lib/chessh/web/token.ex b/lib/chessh/web/token.ex
new file mode 100644
index 0000000..c0ac740
--- /dev/null
+++ b/lib/chessh/web/token.ex
@@ -0,0 +1,5 @@
+defmodule Chessh.Web.Token do
+ use Joken.Config
+
+ def token_config, do: default_claims(default_exp: 12 * 60 * 60)
+end
diff --git a/lib/chessh/web/web.ex b/lib/chessh/web/web.ex
new file mode 100644
index 0000000..8c0929a
--- /dev/null
+++ b/lib/chessh/web/web.ex
@@ -0,0 +1,289 @@
+defmodule Chessh.Web.Endpoint do
+ alias Chessh.{Player, Repo, Key, PlayerSession}
+ alias Chessh.Web.Token
+ use Plug.Router
+ require Logger
+ import Ecto.Query
+
+ plug(Plug.Logger)
+ plug(:match)
+
+ plug(Plug.Parsers,
+ parsers: [:json],
+ pass: ["application/json"],
+ json_decoder: Jason
+ )
+
+ plug(:dispatch)
+
+ get "/oauth/redirect" do
+ [github_login_url, client_id, client_secret, github_user_api_url, github_user_agent] =
+ get_github_configs()
+
+ resp =
+ case conn.params do
+ %{"code" => req_token} ->
+ case :httpc.request(
+ :post,
+ {String.to_charlist(
+ "#{github_login_url}?client_id=#{client_id}&client_secret=#{client_secret}&code=#{req_token}"
+ ), [], 'application/json', ''},
+ [],
+ []
+ ) do
+ {:ok, {{_, 200, 'OK'}, _, resp}} ->
+ URI.decode_query(String.Chars.to_string(resp))
+ end
+ end
+
+ {status, body} =
+ create_player_from_github_response(resp, github_user_api_url, github_user_agent)
+
+ conn
+ |> assign_jwt_and_redirect_or_encode(status, body)
+ end
+
+ delete "/player/token/password" do
+ player = get_player_from_jwt(conn)
+ PlayerSession.close_all_player_sessions(player)
+
+ {status, body} =
+ case Repo.update(Ecto.Changeset.change(player, %{hashed_password: nil})) do
+ {:ok, _new_player} ->
+ {200, %{success: true}}
+
+ {:error, _} ->
+ {400, %{success: false}}
+ end
+
+ conn
+ |> put_resp_content_type("application/json")
+ |> send_resp(status, Jason.encode!(body))
+ end
+
+ put "/player/token/password" do
+ player = get_player_from_jwt(conn)
+ PlayerSession.close_all_player_sessions(player)
+
+ {status, body} =
+ case conn.body_params do
+ %{"password" => password, "password_confirmation" => password_confirmation} ->
+ case Player.password_changeset(player, %{
+ password: password,
+ password_confirmation: password_confirmation
+ })
+ |> Repo.update() do
+ {:ok, player} ->
+ {200, %{success: true, id: player.id}}
+
+ {:error, %{valid?: false} = changeset} ->
+ {400, %{errors: format_errors(changeset)}}
+ end
+ end
+
+ conn
+ |> put_resp_content_type("application/json")
+ |> send_resp(status, Jason.encode!(body))
+ end
+
+ get "/player/logout" do
+ conn
+ |> delete_resp_cookie("jwt")
+ |> send_resp(200, Jason.encode!(%{success: true}))
+ end
+
+ 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} ->
+ 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)
+ }
+ }
+ end
+ end
+
+ _ ->
+ {
+ 400,
+ %{errors: "Must define key and name"}
+ }
+ end
+
+ conn
+ |> put_resp_content_type("application/json")
+ |> send_resp(status, Jason.encode!(body))
+ end
+
+ get "/player/token/me" do
+ {:ok, jwt} = Token.verify_and_validate(get_jwt(conn))
+
+ %{"uid" => player_id, "exp" => expiration} = jwt
+ player = Repo.get(Player, player_id)
+
+ conn
+ |> put_resp_content_type("application/json")
+ |> send_resp(200, Jason.encode!(%{player: player, expiration: expiration * 1000}))
+ end
+
+ get "/player/:id/keys" do
+ %{"id" => player_id} = conn.path_params
+
+ keys = (Repo.get(Player, player_id) |> Repo.preload([:keys])).keys
+
+ conn
+ |> put_resp_content_type("application/json")
+ |> send_resp(200, Jason.encode!(keys))
+ end
+
+ delete "/keys/:id" do
+ player = get_player_from_jwt(conn)
+ PlayerSession.close_all_player_sessions(player)
+
+ %{"id" => key_id} = conn.path_params
+ key = Repo.get(Key, key_id)
+
+ {status, body} =
+ if key && player.id == key.player_id do
+ case Repo.delete(key) do
+ {:ok, _} ->
+ {200, %{success: true}}
+
+ {:error, changeset} ->
+ {400, %{errors: format_errors(changeset)}}
+ end
+ else
+ if !key do
+ {404, %{errors: "Key not found"}}
+ else
+ {401, %{errors: "You cannot delete that key"}}
+ end
+ end
+
+ conn
+ |> put_resp_content_type("application/json")
+ |> send_resp(status, Jason.encode!(body))
+ end
+
+ match _ do
+ send_resp(conn, 404, "Route undefined")
+ end
+
+ defp format_errors(changeset) do
+ Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
+ Enum.reduce(opts, msg, fn {key, value}, acc ->
+ String.replace(acc, "%{#{key}}", to_string(value))
+ end)
+ end)
+ end
+
+ defp get_github_configs() do
+ Enum.map(
+ [
+ :github_oauth_login_url,
+ :github_client_id,
+ :github_client_secret,
+ :github_user_api_url,
+ :github_user_agent
+ ],
+ fn key -> Application.get_env(:chessh, Web)[key] end
+ )
+ end
+
+ defp get_jwt(conn) do
+ auth_header =
+ Enum.find_value(conn.req_headers, fn {header, value} ->
+ if header === "authorization", do: value
+ end)
+
+ if auth_header, do: auth_header, else: Map.get(fetch_cookies(conn).cookies, "jwt")
+ end
+
+ defp get_player_from_jwt(conn) do
+ {:ok, %{"uid" => uid}} = Token.verify_and_validate(get_jwt(conn))
+
+ Repo.get(Player, uid)
+ end
+
+ defp assign_jwt_and_redirect_or_encode(conn, status, body) do
+ case body do
+ %{jwt: token} ->
+ client_redirect_location =
+ Application.get_env(:chessh, Web)[:client_redirect_after_successful_sign_in]
+
+ conn
+ |> put_resp_cookie("jwt", token)
+ |> put_resp_header("location", client_redirect_location)
+ |> send_resp(301, '')
+
+ _ ->
+ conn
+ |> put_resp_content_type("application/json")
+ |> send_resp(status, Jason.encode!(body))
+ end
+ end
+
+ defp create_player_from_github_response(resp, github_user_api_url, github_user_agent) do
+ case resp do
+ %{"access_token" => access_token} ->
+ case :httpc.request(
+ :get,
+ {String.to_charlist(github_user_api_url),
+ [
+ {'Authorization', String.to_charlist("Bearer #{access_token}")},
+ {'User-Agent', github_user_agent}
+ ]},
+ [],
+ []
+ ) do
+ {:ok, {{_, 200, 'OK'}, _, user_details}} ->
+ %{"login" => username, "id" => github_id} =
+ Jason.decode!(String.Chars.to_string(user_details))
+
+ %Player{id: id} =
+ Repo.insert!(%Player{github_id: github_id, username: username},
+ on_conflict: [set: [github_id: github_id]],
+ conflict_target: :github_id
+ )
+
+ {200,
+ %{
+ success: true,
+ jwt:
+ Token.generate_and_sign!(%{
+ "uid" => id
+ })
+ }}
+
+ _ ->
+ {400, %{errors: "Access token was incorrect. Try again."}}
+ end
+
+ _ ->
+ {400, %{errors: "Failed to retrieve token from GitHub. Try again."}}
+ end
+ end
+end