Skip to content

Commit

Permalink
feat: api token
Browse files Browse the repository at this point in the history
  • Loading branch information
sorax committed Dec 2, 2023
1 parent cf6b6dc commit 393c169
Show file tree
Hide file tree
Showing 9 changed files with 265 additions and 10 deletions.
53 changes: 53 additions & 0 deletions lib/radiator/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -363,4 +363,57 @@ defmodule Radiator.Accounts do
{:error, :user, changeset, _} -> {:error, changeset}
end
end

## API

@doc """
Generates an api token.
"""
def generate_user_api_token(user) do
existing_token = get_api_token_by_user(user)

if is_nil(existing_token) do
{token, user_token} = UserToken.build_api_token(user)
Repo.insert!(user_token)
token
else
existing_token
end
end

@doc """
Gets the api token for the given user.
"""
def get_api_token_by_user(user) do
with query <- UserToken.by_user_and_contexts_query(user, ["api"]),
%UserToken{token: token} <- Repo.one(query) do
token
else
_ -> nil
end
end

@doc """
Refresh api token.
"""
def refresh_user_api_token(user) do
Repo.delete_all(UserToken.by_user_and_contexts_query(user, ["api"]))
generate_user_api_token(user)
end

@doc """
Gets the user with the given api token.
"""
def get_user_by_api_token(token) do
{:ok, query} = UserToken.verify_api_token_query(token)
Repo.one(query)
end

@doc """
Deletes the api token.
"""
def delete_user_api_token(token) do
Repo.delete_all(UserToken.by_token_and_context_query(token, "api"))
:ok
end
end
14 changes: 14 additions & 0 deletions lib/radiator/accounts/user_token.ex
Original file line number Diff line number Diff line change
Expand Up @@ -176,4 +176,18 @@ defmodule Radiator.Accounts.UserToken do
def by_user_and_contexts_query(user, [_ | _] = contexts) do
from t in UserToken, where: t.user_id == ^user.id and t.context in ^contexts
end

def build_api_token(user) do
token = :crypto.strong_rand_bytes(@rand_size)
{token, %UserToken{token: token, context: "api", user_id: user.id}}
end

def verify_api_token_query(token) do
query =
from token in by_token_and_context_query(token, "api"),
join: user in assoc(token, :user),
select: user

{:ok, query}
end
end
23 changes: 20 additions & 3 deletions lib/radiator_web/controllers/api/outline_controller.ex
Original file line number Diff line number Diff line change
@@ -1,19 +1,36 @@
defmodule RadiatorWeb.Api.OutlineController do
use RadiatorWeb, :controller

alias Radiator.Accounts
alias Radiator.Outline

def create(conn, %{"content" => content}) do
{:ok, node} = Outline.create_node(%{"content" => content})
def create(conn, %{"content" => content, "token" => token}) do
{status_code, body} =
token
|> decode_token()
|> get_user_by_token()
|> create_node(content)
|> get_response()

conn
|> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(%{uuid: node.uuid}))
|> send_resp(status_code, Jason.encode!(body))
end

def create(conn, _params) do
conn
|> put_resp_content_type("application/json")
|> send_resp(400, Jason.encode!(%{error: "missing params"}))
end

defp decode_token(token), do: Base.url_decode64(token, padding: false)

defp get_user_by_token({:ok, token}), do: Accounts.get_user_by_api_token(token)
defp get_user_by_token(:error), do: {:error, :token}

defp create_node(nil, _), do: {:error, :user}
defp create_node(user, content), do: Outline.create_node(%{"content" => content}, user)

defp get_response({:ok, node}), do: {200, %{uuid: node.uuid}}
defp get_response({:error, _}), do: {400, %{error: "params"}}
end
29 changes: 29 additions & 0 deletions lib/radiator_web/live/accounts_live/index.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,33 @@ defmodule RadiatorWeb.AccountsLive.Index do
|> stream(:users, Accounts.list_users())
|> reply(:ok)
end

@impl true
def handle_event("refresh_token", %{"id" => id}, socket) do
id
|> Accounts.get_user!()
|> Accounts.refresh_user_api_token()

socket
|> stream(:users, Accounts.list_users())
|> reply(:noreply)
end

def handle_event("delete_token", %{"id" => id}, socket) do
id
|> Accounts.get_user!()
|> Accounts.get_api_token_by_user()
|> Accounts.delete_user_api_token()

socket
|> stream(:users, Accounts.list_users())
|> reply(:noreply)
end

defp has_api_token(user) do
case Accounts.get_api_token_by_user(user) do
nil -> false
_ -> true
end
end
end
23 changes: 23 additions & 0 deletions lib/radiator_web/live/accounts_live/index.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,28 @@
<.table id="accounts" rows={@streams.users}>
<:col :let={{_id, user}} label="Id"><%= user.id %></:col>
<:col :let={{_id, user}} label="Email"><%= user.email %></:col>
<:col :let={{_id, user}} label="API-Token">
<%= if has_api_token(user) do %>
<.icon name="hero-check-circle" class="w-5 h-5" />
<% else %>
<.icon name="hero-no-symbol" class="w-5 h-5" />
<% end %>
</:col>
<:col :let={{_id, user}} label="Actions">
<button
phx-click={JS.push("refresh_token", value: %{id: user.id})}
data-confirm="Refresh API-Token?"
>
<.icon name="hero-arrow-path" class="w-5 h-5" />
</button>
<%= if has_api_token(user) do %>
<button
phx-click={JS.push("delete_token", value: %{id: user.id})}
data-confirm="Delete API-Token?"
>
<.icon name="hero-no-symbol" class="w-5 h-5" />
</button>
<% end %>
</:col>
</.table>
</section>
12 changes: 9 additions & 3 deletions lib/radiator_web/live/outline_live/index.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
defmodule RadiatorWeb.OutlineLive.Index do
use RadiatorWeb, :live_view

alias Radiator.Accounts
alias Radiator.Outline
alias Radiator.Outline.Node

Expand All @@ -19,7 +20,7 @@ defmodule RadiatorWeb.OutlineLive.Index do

socket
|> assign(:page_title, "Outline")
|> assign(:bookmarklet, get_bookmarklet(Endpoint.url() <> "/api/v1/outline"))
|> assign(:bookmarklet, get_bookmarklet(Endpoint.url() <> "/api/v1/outline", socket))
|> assign(:node, node)
|> assign(:form, to_form(changeset))
|> stream_configure(:nodes, dom_id: &"node-#{&1.uuid}")
Expand Down Expand Up @@ -70,15 +71,20 @@ defmodule RadiatorWeb.OutlineLive.Index do
|> reply(:noreply)
end

defp get_bookmarklet(api_uri) do
defp get_bookmarklet(api_uri, socket) do
token =
socket.assigns.current_user
|> Accounts.generate_user_api_token()
|> Base.url_encode64(padding: false)

"""
javascript:(function(){
s=window.getSelection().toString();
c=s!=""?s:window.location.href;
xhr=new XMLHttpRequest();
xhr.open('POST','#{api_uri}',true);
xhr.setRequestHeader('Content-Type','application/x-www-form-urlencoded');
xhr.send('content='+encodeURIComponent(c));
xhr.send('content='+encodeURIComponent(c)+'&token=#{token}');
})()
"""
|> String.replace(["\n", " "], "")
Expand Down
2 changes: 1 addition & 1 deletion lib/radiator_web/live/outline_live/index.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<p>
Drag & Drop this link in your Browser-Bookmarks:
<.icon name="hero-bookmark" class="w-5 h-5" />
<a href={@bookmarklet} class="underline">Save in Radiator</a>
<a href={@bookmarklet} class="underline">Save in Radiator (v2)</a>
</p>
</section>

Expand Down
92 changes: 92 additions & 0 deletions test/radiator/accounts_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -512,4 +512,96 @@ defmodule Radiator.AccountsTest do
refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
end
end

describe "generate_user_api_token/1" do
setup do
%{user: user_fixture()}
end

test "generates a token", %{user: user} do
token = Accounts.generate_user_api_token(user)

assert user_token = Repo.get_by(UserToken, token: token)
assert user_token.context == "api"

# Creating the same token for another user should fail
assert_raise Ecto.ConstraintError, fn ->
Repo.insert!(%UserToken{
token: user_token.token,
user_id: user_fixture().id,
context: "api"
})
end
end

test "returns existing token if token already exists", %{user: user} do
token = Accounts.generate_user_api_token(user)

assert Accounts.generate_user_api_token(user) == token
end
end

describe "get_api_token_by_user/1" do
setup do
%{user: user_fixture()}
end

test "returns nil if user has no api token", %{user: user} do
refute Accounts.get_api_token_by_user(user)
end

test "returns api token by user", %{user: user} do
token = Accounts.generate_user_api_token(user)

assert Accounts.get_api_token_by_user(user) == token
end
end

describe "get_user_by_api_token/1" do
setup do
user = user_fixture()
token = Accounts.generate_user_api_token(user)
%{user: user, token: token}
end

test "does not return user for invalid token" do
refute Accounts.get_user_by_api_token("oops")
end

test "returns user by token", %{user: user, token: token} do
assert api_user = Accounts.get_user_by_api_token(token)
assert api_user.id == user.id
end

test "token won't expire", %{user: user, token: token} do
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])

assert api_user = Accounts.get_user_by_api_token(token)
assert api_user.id == user.id
end
end

describe "refresh_user_api_token/1" do
setup do
user = user_fixture()
token = Accounts.generate_user_api_token(user)
%{user: user, token: token}
end

test "deletes the current token and sets a new token", %{user: user, token: token} do
refute Accounts.refresh_user_api_token(user) == token
end
end

describe "delete_user_api_token/1" do
setup do
%{user: user_fixture()}
end

test "deletes the token", %{user: user} do
token = Accounts.generate_user_api_token(user)
assert Accounts.delete_user_api_token(token) == :ok
refute Accounts.get_api_token_by_user(user)
end
end
end
27 changes: 24 additions & 3 deletions test/radiator_web/controllers/api/outline_controller_test.exs
Original file line number Diff line number Diff line change
@@ -1,22 +1,43 @@
defmodule RadiatorWeb.Api.OutlineControllerTest do
use RadiatorWeb.ConnCase, async: true

import Radiator.AccountsFixtures

alias Radiator.Accounts
alias Radiator.Outline

describe "POST /api/v1/outline" do
test "creates a node if content is present", %{conn: conn} do
body = %{"content" => "new node content"}
setup %{conn: conn} do
user = user_fixture()

token =
user
|> Accounts.generate_user_api_token()
|> Base.url_encode64()

%{conn: conn, user: user, token: token}
end

test "creates a node if content is present", %{conn: conn, user: %{id: user_id}, token: token} do
body = %{"content" => "new node content", "token" => token}
conn = post(conn, ~p"/api/v1/outline", body)

%{"uuid" => uuid} = json_response(conn, 200)

assert %{content: "new node content"} = Outline.get_node!(uuid)
assert %{content: "new node content", creator_id: ^user_id} = Outline.get_node!(uuid)
end

test "can't create node when content is missing", %{conn: conn} do
conn = post(conn, ~p"/api/v1/outline", nil)

assert %{"error" => "missing params"} = json_response(conn, 400)
end

test "can't create node when token is wrong", %{conn: conn} do
body = %{"content" => "new node content", "token" => "invalid"}
conn = post(conn, ~p"/api/v1/outline", body)

assert %{"error" => "params"} = json_response(conn, 400)
end
end
end

0 comments on commit 393c169

Please sign in to comment.