Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,35 @@ If you get an :eacces error that mentions `$PROJECT_DIR/_build/tailwind-linux-x6


## API
### Developer API

The versioned Developer API exposes the public aggregate data used by the Meta,
Archetype, and Decks pages. Sign in with Battle.net and create a key from
`/profile/settings`, then read the complete documentation at
`/api-docs`.

```text
Authorization: Bearer hsg_live_<prefix>.<secret>
```

The API also accepts the key in `X-API-Key`. Plaintext keys are shown once and
are never stored. Each key is independently rate limited; the default is 60
requests per minute.

Available endpoints:

- `GET /api/v1/meta`
- `GET /api/v1/archetypes`
- `GET /api/v1/archetypes/:archetype`
- `GET /api/v1/decks`
- `GET /api/v1/streamers`
- `GET /api/v1/streamers/:twitch_login/decks`
- `GET /api/v1/streamer-decks`
- `GET /api/v1/streams/live`

Only filters backed by public aggregate tables are available. Personal games,
`region`, and `force_fresh` remain outside the public v1 contract.

### Resources
#### Deck Info
`archetype`: the deck archetype, without runes or XL
Expand Down
4 changes: 4 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ config :backend, Backend.UserManager.Guardian,
ttl: {60 * 60 * 24 * 365, :seconds},
secret_key: "CyjJAVTbtJgJwS+NbkbTpVTPDJeMKqcn+GakxrO4E5j/kB3SgcgF3CqfsxpxzQKM"

config :backend, :developer_api,
rate_limit: 60,
window_ms: :timer.minutes(1)

config :kaffy,
otp_app: :backend,
ecto_repo: Backend.Repo,
Expand Down
117 changes: 117 additions & 0 deletions lib/backend/api.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ defmodule Backend.Api do

import Ecto.Query, warn: false
alias Backend.Repo
alias Ecto.Multi
import Torch.Helpers, only: [sort: 1, paginate: 4]
import Filtrex.Type.Config

alias Backend.Api.ApiUser
alias Backend.Api.DeveloperApiKey
alias Backend.UserManager.User

@pagination [page_size: 15]
@pagination_distance 5
Expand Down Expand Up @@ -177,4 +180,118 @@ defmodule Backend.Api do
_ -> {:error, :unknown_error}
end
end

@doc """
Creates a new developer API key and revokes the user's previous key.

The plaintext token is returned once and is never persisted.
"""
@spec create_developer_api_key(User.t()) ::
{:ok, %{api_key: DeveloperApiKey.t(), token: String.t()}} | {:error, term()}
def create_developer_api_key(%User{id: user_id}) do
token_prefix = "hsg_live_" <> random_token(9)
secret = random_token(32)
token = token_prefix <> "." <> secret
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)

attrs = %{
user_id: user_id,
token_prefix: token_prefix,
token_digest: token_digest(secret)
}

Multi.new()
|> Multi.run(:user, fn repo, _changes ->
case repo.one(from u in User, where: u.id == ^user_id, lock: "FOR UPDATE") do
%User{} = user -> {:ok, user}
nil -> {:error, :user_not_found}
end
end)
|> Multi.update_all(
:revoked_keys,
active_developer_api_keys_query(user_id),
set: [revoked_at: now, updated_at: now]
)
|> Multi.insert(:api_key, DeveloperApiKey.changeset(%DeveloperApiKey{}, attrs))
|> Repo.transaction()
|> case do
{:ok, %{api_key: api_key}} -> {:ok, %{api_key: api_key, token: token}}
{:error, _operation, reason, _changes} -> {:error, reason}
end
end

@doc "Returns the user's active developer API key, if one exists."
@spec get_active_developer_api_key(User.t()) :: DeveloperApiKey.t() | nil
def get_active_developer_api_key(%User{id: user_id}) do
user_id
|> active_developer_api_keys_query()
|> Repo.one()
end

@doc "Revokes the developer API key owned by the given user."
@spec revoke_developer_api_key(User.t()) :: :ok | {:error, term()}
def revoke_developer_api_key(%User{id: user_id}) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)

Multi.new()
|> Multi.run(:user, fn repo, _changes ->
case repo.one(from u in User, where: u.id == ^user_id, lock: "FOR UPDATE") do
%User{} = user -> {:ok, user}
nil -> {:error, :user_not_found}
end
end)
|> Multi.update_all(
:revoked_keys,
active_developer_api_keys_query(user_id),
set: [revoked_at: now, updated_at: now]
)
|> Repo.transaction()
|> case do
{:ok, _changes} -> :ok
{:error, _operation, reason, _changes} -> {:error, reason}
end
end

@doc "Verifies an active developer API key and loads its owner."
@spec verify_developer_api_key(String.t()) ::
{:ok, DeveloperApiKey.t()} | {:error, :invalid_api_key}
def verify_developer_api_key(token) when is_binary(token) do
with {:ok, token_prefix, secret} <- parse_developer_api_key(token),
%DeveloperApiKey{} = api_key <- developer_api_key_by_prefix(token_prefix),
true <- Plug.Crypto.secure_compare(api_key.token_digest, token_digest(secret)) do
{:ok, api_key}
else
_ -> {:error, :invalid_api_key}
end
end

def verify_developer_api_key(_), do: {:error, :invalid_api_key}

defp active_developer_api_keys_query(user_id) do
from key in DeveloperApiKey,
where: key.user_id == ^user_id and is_nil(key.revoked_at)
end

defp developer_api_key_by_prefix(token_prefix) do
from(key in DeveloperApiKey,
join: user in assoc(key, :user),
where: key.token_prefix == ^token_prefix and is_nil(key.revoked_at),
preload: [user: user]
)
|> Repo.one()
end

defp parse_developer_api_key("hsg_live_" <> _ = token) do
case String.split(token, ".", parts: 2) do
[token_prefix, secret] when secret != "" -> {:ok, token_prefix, secret}
_ -> {:error, :invalid_api_key}
end
end

defp parse_developer_api_key(_), do: {:error, :invalid_api_key}

defp random_token(bytes),
do: bytes |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false)

defp token_digest(secret), do: :crypto.hash(:sha256, secret)
end
Loading