diff options
Diffstat (limited to 'lib/pleroma/config')
-rw-r--r-- | lib/pleroma/config/converter.ex | 195 | ||||
-rw-r--r-- | lib/pleroma/config/deprecation_warnings.ex | 46 | ||||
-rw-r--r-- | lib/pleroma/config/loader.ex | 70 | ||||
-rw-r--r-- | lib/pleroma/config/oban.ex | 38 | ||||
-rw-r--r-- | lib/pleroma/config/transfer_task.ex | 201 | ||||
-rw-r--r-- | lib/pleroma/config/version.ex | 25 | ||||
-rw-r--r-- | lib/pleroma/config/versioning.ex | 292 |
7 files changed, 597 insertions, 270 deletions
diff --git a/lib/pleroma/config/converter.ex b/lib/pleroma/config/converter.ex new file mode 100644 index 000000000..86d7ea8e2 --- /dev/null +++ b/lib/pleroma/config/converter.ex @@ -0,0 +1,195 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Config.Converter do + @moduledoc """ + Converts json structures into elixir structures and types and vice versa. + """ + @spec to_elixir_types(boolean() | String.t() | map() | list()) :: term() + def to_elixir_types(%{"tuple" => [":args", args]}) when is_list(args) do + arguments = + Enum.map(args, fn arg -> + if String.contains?(arg, ["{", "}"]) do + {elem, []} = Code.eval_string(arg) + elem + else + to_elixir_types(arg) + end + end) + + {:args, arguments} + end + + def to_elixir_types(%{"tuple" => [":proxy_url", %{"tuple" => [type, host, port]}]}) do + {:proxy_url, {string_to_elixir_types!(type), parse_host(host), port}} + end + + def to_elixir_types(%{"tuple" => [":partial_chain", entity]}) do + {partial_chain, []} = + entity + |> String.replace(~r/[^\w|^{:,[|^,|^[|^\]^}|^\/|^\.|^"]^\s/, "") + |> Code.eval_string() + + {:partial_chain, partial_chain} + end + + def to_elixir_types(%{"tuple" => entity}) do + Enum.reduce(entity, {}, &Tuple.append(&2, to_elixir_types(&1))) + end + + def to_elixir_types(entity) when is_map(entity) do + Map.new(entity, fn {k, v} -> {to_elixir_types(k), to_elixir_types(v)} end) + end + + def to_elixir_types(entity) when is_list(entity) do + Enum.map(entity, &to_elixir_types/1) + end + + def to_elixir_types(entity) when is_binary(entity) do + entity + |> String.trim() + |> string_to_elixir_types!() + end + + def to_elixir_types(entity), do: entity + + defp parse_host("localhost"), do: :localhost + + defp parse_host(host) do + charlist = to_charlist(host) + + case :inet.parse_address(charlist) do + {:error, :einval} -> + charlist + + {:ok, ip} -> + ip + end + end + + @spec string_to_elixir_types!(String.t()) :: + atom() | Regex.t() | module() | String.t() | no_return() + def string_to_elixir_types!("~r" <> _pattern = regex) do + pattern = + ~r/^~r(?'delimiter'[\/|"'([{<]{1})(?'pattern'.+)[\/|"')\]}>]{1}(?'modifier'[uismxfU]*)/u + + delimiters = ["/", "|", "\"", "'", {"(", ")"}, {"[", "]"}, {"{", "}"}, {"<", ">"}] + + with %{"modifier" => modifier, "pattern" => pattern, "delimiter" => regex_delimiter} <- + Regex.named_captures(pattern, regex), + {:ok, {leading, closing}} <- find_valid_delimiter(delimiters, pattern, regex_delimiter), + {result, _} <- Code.eval_string("~r#{leading}#{pattern}#{closing}#{modifier}") do + result + end + end + + def string_to_elixir_types!(":" <> atom), do: String.to_atom(atom) + + def string_to_elixir_types!(value) do + if module_name?(value) do + String.to_existing_atom("Elixir." <> value) + else + value + end + end + + defp find_valid_delimiter([], _string, _) do + raise(ArgumentError, message: "valid delimiter for Regex expression not found") + end + + defp find_valid_delimiter([{leading, closing} = delimiter | others], pattern, regex_delimiter) + when is_tuple(delimiter) do + if String.contains?(pattern, closing) do + find_valid_delimiter(others, pattern, regex_delimiter) + else + {:ok, {leading, closing}} + end + end + + defp find_valid_delimiter([delimiter | others], pattern, regex_delimiter) do + if String.contains?(pattern, delimiter) do + find_valid_delimiter(others, pattern, regex_delimiter) + else + {:ok, {delimiter, delimiter}} + end + end + + @spec module_name?(String.t()) :: boolean() + def module_name?(string) do + Regex.match?(~r/^(Pleroma|Phoenix|Tesla|Quack|Ueberauth|Swoosh)\./, string) or + string in ["Oban", "Ueberauth", "ExSyslogger", "ConcurrentLimiter"] + end + + @spec to_json_types(term()) :: map() | list() | boolean() | String.t() | integer() + def to_json_types(entity) when is_list(entity) do + Enum.map(entity, &to_json_types/1) + end + + def to_json_types(%Regex{} = entity), do: inspect(entity) + + def to_json_types(entity) when is_map(entity) do + Map.new(entity, fn {k, v} -> {to_json_types(k), to_json_types(v)} end) + end + + def to_json_types({:args, args}) when is_list(args) do + arguments = + Enum.map(args, fn + arg when is_tuple(arg) -> inspect(arg) + arg -> to_json_types(arg) + end) + + %{"tuple" => [":args", arguments]} + end + + def to_json_types({:proxy_url, {type, :localhost, port}}) do + %{"tuple" => [":proxy_url", %{"tuple" => [to_json_types(type), "localhost", port]}]} + end + + def to_json_types({:proxy_url, {type, host, port}}) when is_tuple(host) do + ip = + host + |> :inet_parse.ntoa() + |> to_string() + + %{ + "tuple" => [ + ":proxy_url", + %{"tuple" => [to_json_types(type), ip, port]} + ] + } + end + + def to_json_types({:proxy_url, {type, host, port}}) do + %{ + "tuple" => [ + ":proxy_url", + %{"tuple" => [to_json_types(type), to_string(host), port]} + ] + } + end + + def to_json_types({:partial_chain, entity}), + do: %{"tuple" => [":partial_chain", inspect(entity)]} + + def to_json_types(entity) when is_tuple(entity) do + value = + entity + |> Tuple.to_list() + |> to_json_types() + + %{"tuple" => value} + end + + def to_json_types(entity) when is_binary(entity), do: entity + + def to_json_types(entity) when is_boolean(entity) or is_number(entity) or is_nil(entity) do + entity + end + + def to_json_types(entity) when entity in [:"tlsv1.1", :"tlsv1.2", :"tlsv1.3"] do + ":#{entity}" + end + + def to_json_types(entity) when is_atom(entity), do: inspect(entity) +end diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index 24aa5993b..19868d174 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -41,7 +41,8 @@ defmodule Pleroma.Config.DeprecationWarnings do :ok <- check_gun_pool_options(), :ok <- check_activity_expiration_config(), :ok <- check_remote_ip_plug_name(), - :ok <- check_uploders_s3_public_endpoint() do + :ok <- check_uploders_s3_public_endpoint(), + :ok <- check_oban_config() do :ok else _ -> @@ -79,7 +80,7 @@ defmodule Pleroma.Config.DeprecationWarnings do move_namespace_and_warn(@mrf_config_map, warning_preface) end - @spec move_namespace_and_warn([config_map()], String.t()) :: :ok | nil + @spec move_namespace_and_warn([config_map()], String.t()) :: :ok | :error def move_namespace_and_warn(config_map, warning_preface) do warning = Enum.reduce(config_map, "", fn @@ -102,7 +103,7 @@ defmodule Pleroma.Config.DeprecationWarnings do end end - @spec check_media_proxy_whitelist_config() :: :ok | nil + @spec check_media_proxy_whitelist_config() :: :ok | :error def check_media_proxy_whitelist_config do whitelist = Config.get([:media_proxy, :whitelist]) @@ -163,7 +164,7 @@ defmodule Pleroma.Config.DeprecationWarnings do end end - @spec check_activity_expiration_config() :: :ok | nil + @spec check_activity_expiration_config() :: :ok | :error def check_activity_expiration_config do warning_preface = """ !!!DEPRECATION WARNING!!! @@ -215,4 +216,41 @@ defmodule Pleroma.Config.DeprecationWarnings do :ok end end + + @spec check_oban_config() :: :ok | :error + def check_oban_config do + oban_config = Config.get(Oban) + + {crontab, changed?} = + [ + Pleroma.Workers.Cron.StatsWorker, + Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker, + Pleroma.Workers.Cron.ClearOauthTokenWorker + ] + |> Enum.reduce({oban_config[:crontab], false}, fn removed_worker, {acc, changed?} -> + with acc when is_list(acc) <- acc, + setting when is_tuple(setting) <- + Enum.find(acc, fn {_, worker} -> worker == removed_worker end) do + """ + !!!OBAN CONFIG WARNING!!! + You are using old workers in Oban crontab settings, which were removed. + Please, remove setting from crontab in your config file (prod.secret.exs): #{ + inspect(setting) + } + """ + |> Logger.warn() + + {List.delete(acc, setting), true} + else + _ -> {acc, changed?} + end + end) + + if changed? do + Config.put(Oban, Keyword.put(oban_config, :crontab, crontab)) + :error + else + :ok + end + end end diff --git a/lib/pleroma/config/loader.ex b/lib/pleroma/config/loader.ex index b64d06707..69fd458c0 100644 --- a/lib/pleroma/config/loader.ex +++ b/lib/pleroma/config/loader.ex @@ -3,57 +3,73 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Config.Loader do + @reject_groups [ + :postgrex, + :tesla, + :phoenix, + :tzdata, + :http_signatures, + :web_push_encryption, + :floki, + :pbkdf2_elixir + ] + @reject_keys [ Pleroma.Repo, Pleroma.Web.Endpoint, :env, :configurable_from_database, :database, - :swarm - ] - - @reject_groups [ - :postgrex, - :tesla + :ecto_repos, + Pleroma.Gun, + Pleroma.ReverseProxy.Client, + Pleroma.Web.Auth.Authenticator ] if Code.ensure_loaded?(Config.Reader) do @reader Config.Reader - - def read(path), do: @reader.read!(path) + @config_header "import Config\r\n\r\n" else # support for Elixir less than 1.9 @reader Mix.Config - def read(path) do - path - |> @reader.eval!() - |> elem(0) - end + @config_header "use Mix.Config\r\n\r\n" end - @spec read(Path.t()) :: keyword() + @spec read!(Path.t()) :: keyword() + def read!(path), do: @reader.read!(path) @spec merge(keyword(), keyword()) :: keyword() def merge(c1, c2), do: @reader.merge(c1, c2) + @spec config_header() :: String.t() + def config_header, do: @config_header + @spec default_config() :: keyword() def default_config do - "config/config.exs" - |> read() - |> filter() - end + config = + "config/config.exs" + |> read!() + |> filter() + + logger_config = + :logger + |> Application.get_all_env() + |> Enum.filter(fn {key, _} -> key in [:backends, :console, :ex_syslogger] end) - defp filter(configs) do - configs - |> Keyword.keys() - |> Enum.reduce([], &Keyword.put(&2, &1, filter_group(&1, configs))) + merge(config, logger: logger_config) end - @spec filter_group(atom(), keyword()) :: keyword() - def filter_group(group, configs) do - Enum.reject(configs[group], fn {key, _v} -> - key in @reject_keys or group in @reject_groups or - (group == :phoenix and key == :serve_endpoints) + @spec filter(keyword()) :: keyword() + def filter(configs) do + Enum.reduce(configs, [], fn + {group, _settings}, group_acc when group in @reject_groups -> + group_acc + + {group, settings}, group_acc -> + Enum.reduce(settings, group_acc, fn + {key, _value}, acc when key in @reject_keys -> acc + setting, acc -> Keyword.update(acc, group, [setting], &Keyword.merge(&1, [setting])) + end) end) end end diff --git a/lib/pleroma/config/oban.ex b/lib/pleroma/config/oban.ex deleted file mode 100644 index 3e63bca40..000000000 --- a/lib/pleroma/config/oban.ex +++ /dev/null @@ -1,38 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Config.Oban do - require Logger - - def warn do - oban_config = Pleroma.Config.get(Oban) - - crontab = - [ - Pleroma.Workers.Cron.StatsWorker, - Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker, - Pleroma.Workers.Cron.ClearOauthTokenWorker - ] - |> Enum.reduce(oban_config[:crontab], fn removed_worker, acc -> - with acc when is_list(acc) <- acc, - setting when is_tuple(setting) <- - Enum.find(acc, fn {_, worker} -> worker == removed_worker end) do - """ - !!!OBAN CONFIG WARNING!!! - You are using old workers in Oban crontab settings, which were removed. - Please, remove setting from crontab in your config file (prod.secret.exs): #{ - inspect(setting) - } - """ - |> Logger.warn() - - List.delete(acc, setting) - else - _ -> acc - end - end) - - Pleroma.Config.put(Oban, Keyword.put(oban_config, :crontab, crontab)) - end -end diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex deleted file mode 100644 index aad45aab8..000000000 --- a/lib/pleroma/config/transfer_task.ex +++ /dev/null @@ -1,201 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Config.TransferTask do - use Task - - alias Pleroma.Config - alias Pleroma.ConfigDB - alias Pleroma.Repo - - require Logger - - @type env() :: :test | :benchmark | :dev | :prod - - @reboot_time_keys [ - {:pleroma, :hackney_pools}, - {:pleroma, :chat}, - {:pleroma, Oban}, - {:pleroma, :rate_limit}, - {:pleroma, :markup}, - {:pleroma, :streamer}, - {:pleroma, :pools}, - {:pleroma, :connections_pool} - ] - - @reboot_time_subkeys [ - {:pleroma, Pleroma.Captcha, [:seconds_valid]}, - {:pleroma, Pleroma.Upload, [:proxy_remote]}, - {:pleroma, :instance, [:upload_limit]}, - {:pleroma, :gopher, [:enabled]} - ] - - def start_link(restart_pleroma? \\ true) do - load_and_update_env([], restart_pleroma?) - if Config.get(:env) == :test, do: Ecto.Adapters.SQL.Sandbox.checkin(Repo) - :ignore - end - - @spec load_and_update_env([ConfigDB.t()], boolean()) :: :ok - def load_and_update_env(deleted_settings \\ [], restart_pleroma? \\ true) do - with {_, true} <- {:configurable, Config.get(:configurable_from_database)} do - # We need to restart applications for loaded settings take effect - - {logger, other} = - (Repo.all(ConfigDB) ++ deleted_settings) - |> Enum.map(&merge_with_default/1) - |> Enum.split_with(fn {group, _, _, _} -> group in [:logger, :quack] end) - - logger - |> Enum.sort() - |> Enum.each(&configure/1) - - started_applications = Application.started_applications() - - # TODO: some problem with prometheus after restart! - reject = [nil, :prometheus, :postgrex] - - reject = - if restart_pleroma? do - reject - else - [:pleroma | reject] - end - - other - |> Enum.map(&update/1) - |> Enum.uniq() - |> Enum.reject(&(&1 in reject)) - |> maybe_set_pleroma_last() - |> Enum.each(&restart(started_applications, &1, Config.get(:env))) - - :ok - else - {:configurable, false} -> Restarter.Pleroma.rebooted() - end - end - - defp maybe_set_pleroma_last(apps) do - # to be ensured that pleroma will be restarted last - if :pleroma in apps do - apps - |> List.delete(:pleroma) - |> List.insert_at(-1, :pleroma) - else - Restarter.Pleroma.rebooted() - apps - end - end - - defp merge_with_default(%{group: group, key: key, value: value} = setting) do - default = Config.Holder.default_config(group, key) - - merged = - cond do - Ecto.get_meta(setting, :state) == :deleted -> default - can_be_merged?(default, value) -> ConfigDB.merge_group(group, key, default, value) - true -> value - end - - {group, key, value, merged} - end - - # change logger configuration in runtime, without restart - defp configure({:quack, key, _, merged}) do - Logger.configure_backend(Quack.Logger, [{key, merged}]) - :ok = update_env(:quack, key, merged) - end - - defp configure({_, :backends, _, merged}) do - # removing current backends - Enum.each(Application.get_env(:logger, :backends), &Logger.remove_backend/1) - - Enum.each(merged, &Logger.add_backend/1) - - :ok = update_env(:logger, :backends, merged) - end - - defp configure({_, key, _, merged}) when key in [:console, :ex_syslogger] do - merged = - if key == :console do - put_in(merged[:format], merged[:format] <> "\n") - else - merged - end - - backend = - if key == :ex_syslogger, - do: {ExSyslogger, :ex_syslogger}, - else: key - - Logger.configure_backend(backend, merged) - :ok = update_env(:logger, key, merged) - end - - defp configure({_, key, _, merged}) do - Logger.configure([{key, merged}]) - :ok = update_env(:logger, key, merged) - end - - defp update({group, key, value, merged}) do - try do - :ok = update_env(group, key, merged) - - if group != :pleroma or pleroma_need_restart?(group, key, value), do: group - rescue - error -> - error_msg = - "updating env causes error, group: #{inspect(group)}, key: #{inspect(key)}, value: #{ - inspect(value) - } error: #{inspect(error)}" - - Logger.warn(error_msg) - - nil - end - end - - defp update_env(group, key, nil), do: Application.delete_env(group, key) - defp update_env(group, key, value), do: Application.put_env(group, key, value) - - @spec pleroma_need_restart?(atom(), atom(), any()) :: boolean() - def pleroma_need_restart?(group, key, value) do - group_and_key_need_reboot?(group, key) or group_and_subkey_need_reboot?(group, key, value) - end - - defp group_and_key_need_reboot?(group, key) do - Enum.any?(@reboot_time_keys, fn {g, k} -> g == group and k == key end) - end - - defp group_and_subkey_need_reboot?(group, key, value) do - Keyword.keyword?(value) and - Enum.any?(@reboot_time_subkeys, fn {g, k, subkeys} -> - g == group and k == key and - Enum.any?(Keyword.keys(value), &(&1 in subkeys)) - end) - end - - defp restart(_, :pleroma, env), do: Restarter.Pleroma.restart_after_boot(env) - - defp restart(started_applications, app, _) do - with {^app, _, _} <- List.keyfind(started_applications, app, 0), - :ok <- Application.stop(app) do - :ok = Application.start(app) - else - nil -> - Logger.warn("#{app} is not started.") - - error -> - error - |> inspect() - |> Logger.warn() - end - end - - defp can_be_merged?(val1, val2) when is_list(val1) and is_list(val2) do - Keyword.keyword?(val1) and Keyword.keyword?(val2) - end - - defp can_be_merged?(_val1, _val2), do: false -end diff --git a/lib/pleroma/config/version.ex b/lib/pleroma/config/version.ex new file mode 100644 index 000000000..2f66cc039 --- /dev/null +++ b/lib/pleroma/config/version.ex @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Config.Version do + @moduledoc """ + IMPORTANT!!! + Before modifying records in the database directly, please read "Config versioning" in `docs/development/config_versioning.md`. + """ + + use Ecto.Schema + + import Ecto.Query, only: [from: 2] + + schema "config_versions" do + field(:backup, Pleroma.EctoType.Config.BinaryValue) + field(:current, :boolean, default: true) + + timestamps() + end + + def all do + from(v in __MODULE__, order_by: [desc: v.id]) |> Pleroma.Repo.all() + end +end diff --git a/lib/pleroma/config/versioning.ex b/lib/pleroma/config/versioning.ex new file mode 100644 index 000000000..b997da1db --- /dev/null +++ b/lib/pleroma/config/versioning.ex @@ -0,0 +1,292 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Config.Versioning do + @moduledoc """ + Module that manages versions of database configs. + """ + + import Ecto.Query, only: [from: 2] + + alias Ecto.Multi + alias Pleroma.Config.Version + alias Pleroma.ConfigDB + alias Pleroma.Repo + + @type change :: %{ + optional(:delete) => boolean(), + optional(:value) => any(), + group: atom(), + key: atom() | nil + } + + @doc """ + Creates new config version: + - convert changes to elixir types + - splits changes by type and processes them in `config` table + - sets all pointers to false + - gets all rows from `config` table and inserts them as keyword in `backup` field + """ + @spec new_version([change()] | change()) :: + {:ok, map()} | {:error, :no_changes} | {:error, atom() | tuple(), any(), any()} + def new_version([]), do: {:error, :empty_changes} + def new_version(change) when is_map(change), do: new_version([change]) + + def new_version(changes) when is_list(changes) do + changes + |> Enum.reduce(Multi.new(), fn + %{delete: true} = deletion, acc -> + Multi.run(acc, {:delete_or_update, deletion[:group], deletion[:key]}, fn _, _ -> + ConfigDB.delete_or_update(deletion) + end) + + operation, acc -> + {name, fun} = + if Keyword.keyword?(operation[:value]) or + (operation[:group] == :pleroma and + operation[:key] in ConfigDB.pleroma_not_keyword_values()) do + {:insert_or_update, + fn _, _ -> + ConfigDB.update_or_create(operation) + end} + else + {:error, + fn _, _ -> + {:error, {:value_must_be_keyword, operation}} + end} + end + + Multi.run(acc, {name, operation[:group], operation[:key]}, fun) + end) + |> set_current_flag_false_for_all_versions() + |> insert_new_version() + |> Repo.transaction() + end + + def new_version(_), do: {:error, :bad_format} + + defp set_current_flag_false_for_all_versions(multi) do + Multi.update_all(multi, :update_all_versions, Version, set: [current: false]) + end + + defp insert_new_version(multi) do + Multi.run(multi, :insert_version, fn repo, _ -> + %Version{ + backup: ConfigDB.all_as_keyword() + } + |> repo.insert() + end) + end + + @doc """ + Rollbacks config version by N steps: + - checks possibility for rollback + - truncates config table and restarts pk + - inserts config settings from backup + - sets all pointers to false + - sets current pointer to true for rollback version + - deletes versions after current + """ + @spec rollback(pos_integer()) :: + {:ok, map()} + | {:error, atom() | tuple(), any(), any()} + | {:error, :steps_format} + | {:error, :no_current_version} + | {:error, :rollback_not_possible} + def rollback(steps \\ 1) + + def rollback(steps) when is_integer(steps) and steps > 0 do + with version_id when is_integer(version_id) <- get_current_version_id(), + %Version{} = version <- get_version_by_steps(steps) do + do_rollback(version) + end + end + + def rollback(_), do: {:error, :steps_format} + + @doc """ + Same as `rollback/1`, but rollbacks for a given version id. + """ + @spec rollback_by_id(pos_integer()) :: + {:ok, map()} + | {:error, atom() | tuple(), any(), any()} + | {:error, :not_found} + | {:error, :version_is_already_current} + def rollback_by_id(id) when is_integer(id) do + with %Version{current: false} = version <- get_version_by_id(id) do + do_rollback(version) + else + %Version{current: true} -> {:error, :version_is_already_current} + error -> error + end + end + + defp get_current_version_id do + query = from(v in Version, where: v.current == true) + + with nil <- Repo.aggregate(query, :max, :id) do + {:error, :no_current_version} + end + end + + defp get_version_by_id(id) do + with nil <- Repo.get(Version, id) do + {:error, :not_found} + end + end + + defp get_version_by_steps(steps) do + query = from(v in Version, order_by: [desc: v.id], limit: 1, offset: ^steps) + + with nil <- Repo.one(query) do + {:error, :rollback_not_possible} + end + end + + defp do_rollback(version) do + multi = + truncate_config_table() + |> reset_pk_in_config_table() + + version.backup + |> ConfigDB.from_keyword_to_maps() + |> add_insert_commands(multi) + |> set_current_flag_false_for_all_versions() + |> Multi.update(:move_current_pointer, Ecto.Changeset.change(version, current: true)) + |> Multi.delete_all( + :delete_next_versions, + from(v in Version, where: v.id > ^version.id) + ) + |> Repo.transaction() + end + + defp truncate_config_table(multi \\ Multi.new()) do + Multi.run(multi, :truncate_config_table, fn repo, _ -> + repo.query("TRUNCATE config;") + end) + end + + defp reset_pk_in_config_table(multi) do + Multi.run(multi, :reset_pk, fn repo, _ -> + repo.query("ALTER SEQUENCE config_id_seq RESTART;") + end) + end + + defp add_insert_commands(changes, multi) do + Enum.reduce(changes, multi, fn change, acc -> + Multi.run(acc, {:insert, change[:group], change[:key]}, fn _, _ -> + ConfigDB.update_or_create(change) + end) + end) + end + + @doc """ + Resets config table and creates new empty version. + """ + @spec reset() :: {:ok, map()} | {:error, atom() | tuple(), any(), any()} + def reset do + truncate_config_table() + |> reset_pk_in_config_table() + |> set_current_flag_false_for_all_versions() + |> insert_new_version() + |> Repo.transaction() + end + + @doc """ + Migrates settings from config file into database: + - truncates config table and restarts pk + - inserts settings from config file + - sets all pointers to false + - gets all rows from `config` table and inserts them as keyword in `backup` field + """ + @spec migrate(Path.t()) :: {:ok, map()} | {:error, atom() | tuple(), any(), any()} + def migrate(config_path) do + multi = + truncate_config_table() + |> reset_pk_in_config_table() + + config_path + |> Pleroma.Config.Loader.read!() + |> Pleroma.Config.Loader.filter() + |> ConfigDB.from_keyword_to_maps() + |> add_insert_commands(multi) + |> set_current_flag_false_for_all_versions() + |> insert_new_version() + |> Repo.transaction() + end + + @doc """ + Common function to migrate old config namespace to the new one keeping the old value. + """ + @spec migrate_namespace({atom(), atom()}, {atom(), atom()}) :: + {:ok, map()} | {:error, atom() | tuple(), any(), any()} + def migrate_namespace({o_group, o_key}, {n_group, n_key}) do + config = ConfigDB.get_by_params(%{group: o_group, key: o_key}) + + configs_changes_fun = + if config do + fn -> + config + |> Ecto.Changeset.change(group: n_group, key: n_key) + |> Repo.update() + end + else + fn -> {:ok, nil} end + end + + versions_changes_fun = fn %{backup: backup} = version -> + with {value, rest} when not is_nil(value) <- pop_in(backup[o_group][o_key]) do + rest = + if rest[o_group] == [] do + Keyword.delete(rest, o_group) + else + rest + end + + updated_backup = + if Keyword.has_key?(rest, n_group) do + put_in(rest[n_group][n_key], value) + else + Keyword.put(rest, n_group, [{n_key, value}]) + end + + version + |> Ecto.Changeset.change(backup: updated_backup) + |> Repo.update() + else + _ -> {:ok, nil} + end + end + + migrate_configs_and_versions(configs_changes_fun, versions_changes_fun) + end + + @doc """ + Abstract function for config migrations to keep changes in config table and changes in versions backups in transaction. + Accepts two functions: + - first function makes changes to the configs + - second function makes changes to the backups in versions + """ + @spec migrate_configs_and_versions(function(), function()) :: + {:ok, map()} | {:error, atom() | tuple(), any(), any()} + def migrate_configs_and_versions(configs_changes_fun, version_change_fun) + when is_function(configs_changes_fun, 0) and + is_function(version_change_fun, 1) do + versions = Repo.all(Version) + + multi = + Multi.new() + |> Multi.run(:configs_changes, fn _, _ -> + configs_changes_fun.() + end) + + versions + |> Enum.reduce(multi, fn version, acc -> + Multi.run(acc, {:version_change, version.id}, fn _, _ -> + version_change_fun.(version) + end) + end) + |> Repo.transaction() + end +end |