lib/activity_pub_client/client.ex

defmodule ActivityPubClient.Client do
  @moduledoc """
  Interface for handling `%OAuth2.Client{}` structs (see `OAuth2.Client`).
  """

  # @TODO
  # set config :oauth2, debug: true
  # for dev and test env

  alias ActivityPubClient.App
  alias ActivityPubClient.User

  @repo Application.fetch_env!(:activity_pub_client, :ecto_repos) |> List.first()
  @default_scopes "read write follow push"

  @doc """
  Gets a valid `%OAuth2.Client{}` struct.

  Expects the URL of the server and an ActivityPub ID (most of the time it
  corresponds to the user profile URL).
  """
  def client(site, ap_id) do
    with {:ok, client} <- ensure_app_registered(site),
         {:ok, client} <- ensure_access_token(client, ap_id) do
      {:ok, client}
    end
  end

  @doc """
  Sets the OAuth2 code as returned to the user after authorizing the application.
  """
  def set_code(ap_id, code) do
    #   %User{ap_id: ap_id, code: code}
    @repo.insert!(
      %User{ap_id: ap_id, code: code},
      on_conflict: [set: [code: code]],
      conflict_target: :ap_id
    )
  end

  defp ensure_app_registered(site) do
    case get_app(site) do
      nil -> register_app(site)
      %App{} = app -> {:ok, new_client(app)}
    end
  end

  defp register_app(site) do
    url = site <> "/api/v1/apps"
    headers = [{"content-type", "application/x-www-form-urlencoded"}]

    body =
      %{
        client_name: "Kazarma",
        redirect_uris: "urn:ietf:wg:oauth:2.0:oob",
        scopes: @default_scopes,
        website: "https://gitlab.com/technostructures/kazarma/kazarma"
      }
      |> URI.encode_query()

    req_opts = [with_body: true]

    with {:ok, 200, _headers, body} <-
           :hackney.post(url, headers, body, req_opts),
         {:ok, resp} <- Jason.decode(body),
         {:ok, app} <- create_app(site, resp) do
      {:ok, new_client(app)}
    else
      {:ok, 400, _headers, body} ->
        {:error, Jason.decode!(body) |> Map.fetch!("error")}

      {:ok, 422, _headers, body} ->
        {:error, Jason.decode!(body) |> Map.fetch!("error")}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp ensure_access_token(client, ap_id) do
    case get_user(ap_id) do
      nil ->
        {:needs_authorization, client, OAuth2.Client.authorize_url!(client)}

      %User{code: nil} ->
        {:needs_authorization, client, OAuth2.Client.authorize_url!(client)}

      %User{code: code, token: nil} = user ->
        client = OAuth2.Client.get_token!(client, code: code)
        update_user_token(user, client.token)
        {:ok, client}

      %User{code: _code, token: token} = user ->
        token = %OAuth2.AccessToken{
          access_token: token.access_token,
          refresh_token: token.refresh_token,
          expires_at: token.expires_at
        }

        client = %{client | token: token}

        if OAuth2.AccessToken.expired?(token) do
          client = OAuth2.Client.refresh_token!(client)
          update_user_token(user, client.token)
          {:ok, client}
        else
          {:ok, client}
        end
    end
  end

  defp new_client(app) do
    OAuth2.Client.new(
      strategy: OAuth2.Strategy.AuthCode,
      client_id: app.client_id,
      client_secret: app.client_secret,
      site: app.site,
      redirect_uri: "urn:ietf:wg:oauth:2.0:oob"
    )
    |> OAuth2.Client.put_serializer("application/json", Jason)
  end

  defp create_app(site, attrs) do
    attrs = Map.put(attrs, "site", site)

    %App{}
    |> App.changeset(attrs)
    |> @repo.insert()
  end

  defp get_app(site) do
    @repo.get_by(App, site: site)
  end

  defp get_user(ap_id) do
    @repo.get_by(User, ap_id: ap_id)
  end

  defp update_user_token(user, token) do
    user
    |> User.changeset(%{"token" => Map.from_struct(token)})
    |> @repo.update()
  end
end