lib/aino/session.ex

defmodule Aino.Session do
  @moduledoc """
  Session storage
  """

  alias Aino.Session.Storage

  @doc """
  Put a session configuration into the token

  Used for `decode/1` and `encode/1`. The configuration should be an implementation
  of `Aino.Session.Storage`.

  The following keys will be added to the token `[:session_config]`

      iex> config = %Session.Cookie{key: "key", salt: "salt"}
      iex> token = %{}
      iex> token = Session.config(token, config)
      iex> token.session_config == config
      true
  """
  def config(token, config) do
    Map.put(token, :session_config, config)
  end

  @doc """
  Decode session data from the token

  Can only be used with `Aino.Session.config/2` having run before.

  The following keys will be added to the token `[:session]`
  """
  def decode(token) do
    Storage.decode(token.session_config, token)
  end

  @doc """
  Encode session data from the token

  Can only be used with `Aino.Middleware.cookies/1` and `Aino.Session.config/2` having run before.
  """
  def encode(%{session_updated: true} = token) do
    Storage.encode(token.session_config, token)
  end

  def encode(token), do: token
end

defmodule Aino.Session.Token do
  @moduledoc """
  Token functions related only to session

  Session data _must_ be decoded before using these functions
  """

  @doc """
  Puts a new key/value session data

  Values _must_ be serializable via JSON.

  Session data _must_ be decoded before using putting a new key
  """
  def put(%{session: session} = token, key, value) do
    session = Map.put(session, key, value)

    token
    |> Map.put(:session, session)
    |> Map.put(:session_updated, true)
  end

  def put(_token, _key, _value) do
    raise """
    Make sure to decode session data before trying to put values in it

    See `Aino.Session.decode/1`
    """
  end

  @doc """
  Delete a key from the session

  While keeping the rest of the session in tact
  """
  def delete(%{session: session} = token, key) do
    session = Map.delete(session, key)

    token
    |> Map.put(:session, session)
    |> Map.put(:session_updated, true)
  end

  def delete(_token, _key, _value) do
    raise """
    Make sure to decode session data before trying to remove values from it

    See `Aino.Session.decode/1`
    """
  end

  @doc """
  Clear a session, resetting to an empty map
  """
  def clear(token) do
    token
    |> Map.put(:session, %{})
    |> Map.put(:session_updated, true)
  end
end

defprotocol Aino.Session.Storage do
  @moduledoc """
  Encode and decode session data in a pluggable backend
  """

  @doc """
  Parse session data on the token

  The following keys should be added to the token `[:session]`
  """
  def decode(config, token)

  @doc """
  Set session data from the token
  """
  def encode(config, token)
end

defmodule Aino.Session.Cookie do
  @moduledoc """
  Session implementation using cookies as the storage
  """

  alias Aino.Token

  defstruct [:key, :salt]

  @doc false
  def signature(config, data) do
    Base.encode64(:crypto.mac(:hmac, :sha256, config.key, data <> config.salt))
  end

  @doc """
  Parse session data from cookies

  Verifies the signature and if valid, parses session JSON data.

  Can only be used with `Aino.Middleware.cookies/1` and `Aino.Session.config/2` having run before.

  Adds the following keys to the token `[:session]`
  """
  def decode(config, token) do
    case token.cookies["_aino_session"] do
      data when is_binary(data) ->
        expected_signature = token.cookies["_aino_session_signature"]
        signature = signature(config, data)

        case expected_signature == signature do
          true ->
            parse_session(token, data)

          false ->
            Map.put(token, :session, %{})
        end

      _ ->
        Map.put(token, :session, %{})
    end
  end

  defp parse_session(token, data) do
    case Jason.decode(data) do
      {:ok, session} ->
        Map.put(token, :session, session)

      {:error, _} ->
        Map.put(token, :session, %{})
    end
  end

  @doc """
  Response will be returned with two new `Set-Cookie` headers, a signature
  of the session data and the session data itself as JSON.
  """
  def encode(config, token) do
    case is_map(token.session) do
      true ->
        session = Map.put(token.session, "t", DateTime.utc_now())

        case Jason.encode(session) do
          {:ok, data} ->
            signature = signature(config, data)
            signature = "_aino_session_signature=#{signature}; HttpOnly; Path=/"

            token
            |> Token.response_header("Set-Cookie", "_aino_session=#{data}; HttpOnly; Path=/")
            |> Token.response_header("Set-Cookie", signature)

          :error ->
            token
        end

      false ->
        token
    end
  end

  defimpl Aino.Session.Storage do
    alias Aino.Session.Cookie

    def decode(config, token) do
      Cookie.decode(config, token)
    end

    def encode(config, token) do
      Cookie.encode(config, token)
    end
  end
end

defmodule Aino.Session.Flash do
  @moduledoc """
  A temporary storage for strings

  Primarily to display notices on the next page load, e.g. "Success!" after a POST
  """

  alias Aino.Session

  @doc """
  Set a temporary flash message

  _Must_ be after `Aino.Session.decode/1`
  """
  def put(%{session: session} = token, key, value) when is_binary(value) do
    flash = Map.get(session, "aino_flash", %{})
    flash = Map.put(flash, to_string(key), value)
    Session.Token.put(token, "aino_flash", flash)
  end

  def put(_token, _key, _value) do
    raise """
    Make sure to decode session data before trying to set flash messages

    See `Aino.Session.decode/1`
    """
  end

  @doc """
  Fetch a key from the loaded flash message

  Can only be used with `Aino.Session.Flash.load/1`
  """
  def get(%{flash: flash} = _token, key) do
    Map.get(flash, to_string(key))
  end

  def get(_token, _key) do
    raise """
    Make sure to load flash data before trying to fetch flash messages

    See `Aino.Session.Flash.load/1`
    """
  end

  @doc """
  Load flash messages from session storage

  Flash messages in the session are deleted after being loaded.

  _Must_ be after `Aino.Session.decode/1`
  """
  def load(token) do
    case Map.has_key?(token.session, "aino_flash") do
      true ->
        token
        |> Session.Token.delete("aino_flash")
        |> Map.put(:flash, token.session["aino_flash"])

      false ->
        Map.put(token, :flash, %{})
    end
  end
end