diff --git a/config/runtime.exs b/config/runtime.exs index 85995218..a4ee7d2b 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -323,6 +323,11 @@ if config_env() == :prod do # Enable billing config :keila, Keila.Billing, enabled: System.get_env("ENABLE_BILLING") in [1, "1", "true", "TRUE"] + # Enable update check + config :keila, + :update_checks_enabled, + System.get_env("DISABLE_UPDATE_CHECKS") not in [nil, "", "0", "false", "FALSE"] + paddle_vendor = System.get_env("PADDLE_VENDOR") diff --git a/lib/keila/application.ex b/lib/keila/application.ex index 79681706..36f3b43d 100644 --- a/lib/keila/application.ex +++ b/lib/keila/application.ex @@ -34,7 +34,11 @@ defmodule Keila.Application do # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: Keila.Supervisor] + Supervisor.start_link(children, opts) + |> tap(fn _ -> + maybe_fetch_updates() + end) end # Tell Phoenix to update the endpoint configuration @@ -53,4 +57,9 @@ defmodule Keila.Application do Keila.ReleaseTasks.init() end end + + defp maybe_fetch_updates() do + Keila.Instance.UpdateCronWorker.new(%{}) + |> Oban.insert() + end end diff --git a/lib/keila/instance.ex b/lib/keila/instance.ex new file mode 100644 index 00000000..8849565b --- /dev/null +++ b/lib/keila/instance.ex @@ -0,0 +1,83 @@ +defmodule Keila.Instance do + alias __MODULE__.Instance + alias __MODULE__.Release + + use Keila.Repo + require Logger + + @moduledoc """ + This module provides easier access to the properties of the current Keila instance. + """ + + @doc """ + Returns the `Instance` struct that is persisted in the database. + If it doesn't exist, creates a new `Instance` and returns it. + """ + @spec get_instance() :: Instance.t() + def get_instance() do + case Repo.one(Instance) do + nil -> Repo.insert!(%Instance{}) + instance -> instance + end + end + + @doc """ + Returns the version of the current Keila instance as a string. + """ + @spec current_version() :: String.t() + def current_version() do + Application.spec(:keila, :vsn) |> to_string() + end + + @doc """ + Returns `true` if the update check has been enabled for the current instance. + """ + @spec update_checks_enabled? :: boolean() + def update_checks_enabled? do + Application.get_env(:keila, :update_checks_enabled, true) + end + + @doc """ + Returns `true` if updates are available. + """ + @spec updates_available? :: boolean() + def updates_available? do + update_checks_enabled?() && + Repo.exists?( + from i in Instance, where: fragment("jsonb_array_length(?) > 0", i.available_updates) + ) + end + + @doc """ + Fetches updates from the Keila GitHub update releases. + """ + @release_url "https://api.github.com/repos/pentacent/keila/releases" + @spec fetch_updates() :: [__MODULE__.Release.t()] + def fetch_updates() do + with {:ok, response} <- HTTPoison.get(@release_url, recv_timeout: 5_000), + {:ok, release_attrs} when is_list(release_attrs) <- Jason.decode(response.body) do + current_version = current_version() |> Version.parse!() + + release_attrs + |> Enum.map(fn %{"tag_name" => version, "published_at" => published_at, "body" => changelog} -> + %{version: version, published_at: published_at, changelog: changelog} + end) + |> Enum.map(&Release.new!/1) + |> Enum.filter(fn %{version: version} -> + version |> Version.parse!() |> Version.compare(current_version) == :gt + end) + else + other -> + Logger.info("Unable to fetch updates. Got: #{inspect(other)}") + [] + end + end + + def get_available_updates() do + if update_checks_enabled?() do + Repo.one(from i in Instance, select: i.available_updates) + else + [] + end + end +end diff --git a/lib/keila/instance/schemas/instance.ex b/lib/keila/instance/schemas/instance.ex new file mode 100644 index 00000000..682d6c04 --- /dev/null +++ b/lib/keila/instance/schemas/instance.ex @@ -0,0 +1,7 @@ +defmodule Keila.Instance.Instance do + use Keila.Schema + + schema "instance" do + embeds_many :available_updates, Keila.Instance.Release, on_replace: :delete + end +end diff --git a/lib/keila/instance/schemas/release.ex b/lib/keila/instance/schemas/release.ex new file mode 100644 index 00000000..f2f7a1f9 --- /dev/null +++ b/lib/keila/instance/schemas/release.ex @@ -0,0 +1,25 @@ +defmodule Keila.Instance.Release do + use Keila.Schema + + embedded_schema do + field :version, :string + field :published_at, :utc_datetime + field :changelog, :string + end + + def changeset(struct \\ %__MODULE__{}, params) do + struct + |> cast(params, [:version, :published_at, :changelog]) + |> validate_required([:version, :published_at, :changelog]) + |> validate_change(:version, fn :version, version -> + case Version.parse(version) do + {:ok, _} -> [] + _ -> [version: "invalid version format"] + end + end) + end + + def new!(params) do + params |> changeset() |> Ecto.Changeset.apply_action!(:insert) + end +end diff --git a/lib/keila/instance/update_cron_worker.ex b/lib/keila/instance/update_cron_worker.ex new file mode 100644 index 00000000..3e0ad2e2 --- /dev/null +++ b/lib/keila/instance/update_cron_worker.ex @@ -0,0 +1,25 @@ +defmodule Keila.Instance.UpdateCronWorker do + use Oban.Worker, + queue: :periodic, + unique: [ + period: :infinity, + states: [:available, :scheduled, :executing] + ] + + use Keila.Repo + alias Keila.Instance + + @impl true + def perform(_job) do + if Instance.update_checks_enabled?() do + releases = Instance.fetch_updates() + + Instance.get_instance() + |> change() + |> put_embed(:available_updates, releases) + |> Repo.update() + else + :ok + end + end +end diff --git a/lib/keila_web/controllers/instance_admin_controller.ex b/lib/keila_web/controllers/instance_admin_controller.ex new file mode 100644 index 00000000..4f80111f --- /dev/null +++ b/lib/keila_web/controllers/instance_admin_controller.ex @@ -0,0 +1,32 @@ +defmodule KeilaWeb.InstanceAdminController do + use KeilaWeb, :controller + + alias Keila.Instance + + plug :authorize + + def show(conn, _) do + update_checks_enabled? = Instance.update_checks_enabled?() + available_updates = Instance.get_available_updates() + current_version = Instance.current_version() + + host = + Routes.project_url(conn, :index) + |> String.replace(~r"https?://", "") + |> String.replace("/", "") + + conn + |> assign(:update_checks_enabled?, update_checks_enabled?) + |> assign(:available_updates, available_updates) + |> assign(:current_version, current_version) + |> assign(:host, host) + |> render("show.html") + end + + defp authorize(conn, _) do + case conn.assigns.is_admin? do + true -> conn + false -> conn |> put_status(404) |> halt() + end + end +end diff --git a/lib/keila_web/helpers/instance_info_plug.ex b/lib/keila_web/helpers/instance_info_plug.ex new file mode 100644 index 00000000..8d2a788f --- /dev/null +++ b/lib/keila_web/helpers/instance_info_plug.ex @@ -0,0 +1,19 @@ +defmodule KeilaWeb.InstanceInfoPlug do + @behaviour Plug + import Plug.Conn + + @impl true + def call(conn, _opts) do + if conn.assigns[:is_admin?] do + updates_available? = Keila.Instance.updates_available?() + + conn + |> assign(:instance_updates_available?, updates_available?) + else + conn + end + end + + @impl true + def init(opts), do: opts +end diff --git a/lib/keila_web/router.ex b/lib/keila_web/router.ex index 78a29b8c..39e6fa08 100644 --- a/lib/keila_web/router.ex +++ b/lib/keila_web/router.ex @@ -11,6 +11,7 @@ defmodule KeilaWeb.Router do plug KeilaWeb.Meta.Plug plug KeilaWeb.AuthSession.Plug plug KeilaWeb.PutLocalePlug + plug KeilaWeb.InstanceInfoPlug end # Non-authenticated Routes @@ -78,6 +79,8 @@ defmodule KeilaWeb.Router do resources "/admin/shared-senders", SharedSenderAdminController get "/admin/shared-senders/:id/delete", SharedSenderAdminController, :delete_confirmation + + get "/admin/instance", InstanceAdminController, :show end # Authenticated Routes within a Project context diff --git a/lib/keila_web/templates/instance_admin/show.html.heex b/lib/keila_web/templates/instance_admin/show.html.heex new file mode 100644 index 00000000..a23bc275 --- /dev/null +++ b/lib/keila_web/templates/instance_admin/show.html.heex @@ -0,0 +1,44 @@ +
+
+

+ <%= dgettext("admin", "You are currently running Keila %{version} on %{host}.", + version: @current_version, + host: @host + ) %> +

+
+
+ +
+ <%= if @update_checks_enabled? do %> + <%= if Enum.any?(@available_updates) do %> +
+
+ <%= render_icon(:newspaper) %> +
+ There are updates available for your Keila instance! +
+

+ <%= for release <- @available_updates do %> +
+

+ <%= local_datetime_tag(release.published_at) %> +

+
+ <%= raw(Earmark.as_html!(release.changelog)) %> +
+
+ <% end %> + <% else %> +
+
+ <%= render_icon(:check_circle) %> +
+ You seem to be running the most recent version of Keila! +
+
+ <% end %> + <% else %> +

Update checks are disabled.

+ <% end %> +
diff --git a/lib/keila_web/templates/layout/_menu.html.heex b/lib/keila_web/templates/layout/_menu.html.heex index 43deab07..300ed1d8 100644 --- a/lib/keila_web/templates/layout/_menu.html.heex +++ b/lib/keila_web/templates/layout/_menu.html.heex @@ -150,6 +150,12 @@ icon: :speakerphone ) %> <% end %> + <%= menu_link( + @conn, + Routes.instance_admin_path(@conn, :show), + dgettext("admin", "System Info"), + icon: :information_circle + ) %> <%= menu_link(@conn, Routes.auth_path(@conn, :logout), gettext("Sign out"), icon: :logout) %> diff --git a/lib/keila_web/templates/layout/app.html.heex b/lib/keila_web/templates/layout/app.html.heex index 42fd4f3e..7c1ec6c7 100644 --- a/lib/keila_web/templates/layout/app.html.heex +++ b/lib/keila_web/templates/layout/app.html.heex @@ -9,5 +9,15 @@

<%= get_flash(@conn, :error) %>

<% end %> + <%= if assigns[:is_admin?] && assigns[:instance_updates_available?] do %> + + <% end %> <%= @inner_content %> diff --git a/lib/keila_web/views/instance_admin_view.ex b/lib/keila_web/views/instance_admin_view.ex new file mode 100644 index 00000000..a8552519 --- /dev/null +++ b/lib/keila_web/views/instance_admin_view.ex @@ -0,0 +1,3 @@ +defmodule KeilaWeb.InstanceAdminView do + use KeilaWeb, :view +end diff --git a/priv/repo/migrations/20201229080000_auth .exs b/priv/repo/migrations/20201229080000_auth .exs index 3e9f7e03..695d35e5 100644 --- a/priv/repo/migrations/20201229080000_auth .exs +++ b/priv/repo/migrations/20201229080000_auth .exs @@ -1,4 +1,4 @@ -defmodule PreluApi.Repo.Migrations.Auth do +defmodule Keila.Repo.Migrations.Auth do use Ecto.Migration def change do diff --git a/priv/repo/migrations/20210630205000_subscription_upserts.exs b/priv/repo/migrations/20210630205000_subscription_upserts.exs index a1bb5705..9074fabe 100644 --- a/priv/repo/migrations/20210630205000_subscription_upserts.exs +++ b/priv/repo/migrations/20210630205000_subscription_upserts.exs @@ -1,4 +1,4 @@ -defmodule PreluApi.Repo.Migrations.SubscriptionUpserts do +defmodule Keila.Repo.Migrations.SubscriptionUpserts do use Ecto.Migration def up do diff --git a/priv/repo/migrations/20241117001430_create_instance.exs b/priv/repo/migrations/20241117001430_create_instance.exs new file mode 100644 index 00000000..cdfe01ca --- /dev/null +++ b/priv/repo/migrations/20241117001430_create_instance.exs @@ -0,0 +1,12 @@ +defmodule Keila.Repo.Migrations.CreateInstance do + use Ecto.Migration + + def change do + create table("instance") do + add :available_updates, :jsonb, default: "[]" + end + + execute "CREATE UNIQUE INDEX instance_singleton ON instance ((true));", "" + execute "INSERT INTO instance DEFAULT VALUES;", "" + end +end