defmodule Plausible.Plugins.API.Token do @moduledoc """ Ecto schema for Plugins API Tokens. Tokens are stored hashed and require a description. Tokens are considered secret, although the Plugins API by nature will expose very little, if any, destructive/insecure operations. The raw token version is meant to be presented to the user upon creation. It is prefixed with a plain text identifier allowing source scanning for leaked secrets. """ use Plausible use Ecto.Schema import Ecto.Changeset alias Plausible.Site @type t() :: %__MODULE__{} @primary_key {:id, :binary_id, autogenerate: true} schema "plugins_api_tokens" do timestamps() field(:token_hash, :binary) field(:description, :string) field(:hint, :string) field(:last_used_at, :naive_datetime) belongs_to(:site, Site) end @spec generate(String.t()) :: map() def generate(random_bytes \\ random_bytes()) do raw = prefixed(random_bytes) hash = hash(raw) %{ raw: raw, hash: hash } end @spec hash(String.t()) :: binary() def hash(raw) do :crypto.hash(:sha256, raw) end @fields [:description, :site_id] @required_fields [:description, :site, :token_hash, :hint] @spec insert_changeset(Site.t(), map(), map()) :: Ecto.Changeset.t() def insert_changeset(site, %{hash: hash, raw: raw}, attrs \\ %{}) do %__MODULE__{} |> cast(attrs, @fields) |> put_change(:token_hash, hash) |> put_change(:hint, String.slice(raw, -4, 4)) |> put_assoc(:site, site) |> validate_required(@required_fields) end @doc """ Raw tokens are prefixed so that tools like https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning can scan repositories for accidental secret commits. """ def prefix() do on_ee do env = Application.get_env(:plausible, :environment) case env do "prod" -> "plausible-plugin" env -> "plausible-plugin-#{env}" end else "plausible-plugin-selfhost" end end @spec last_used_humanize(t()) :: String.t() def last_used_humanize(token) do diff = if token.last_used_at do now = NaiveDateTime.utc_now() NaiveDateTime.diff(now, token.last_used_at, :minute) end cond do is_nil(diff) -> "Not yet" diff < 5 -> "Just recently" diff < 30 -> "Several minutes ago" diff < 70 -> "An hour ago" diff < 24 * 60 -> "Hours ago" diff < 24 * 60 * 2 -> "Yesterday" diff < 24 * 60 * 7 -> "Sometime this week" true -> "Long time ago" end end defp prefixed(random_bytes) do Enum.join([prefix(), random_bytes], "-") end defp random_bytes() do 30 |> :crypto.strong_rand_bytes() |> Base.encode64() end end