From 3a4773c3c2bd0bbef244eb519b07208da9108e49 Mon Sep 17 00:00:00 2001 From: dcc Date: Sat, 2 Sep 2023 00:52:52 -0700 Subject: First --- lib/mix/pleroma.ex | 139 +++++++ lib/mix/tasks/pleroma/app.ex | 49 +++ lib/mix/tasks/pleroma/benchmark.ex | 113 ++++++ lib/mix/tasks/pleroma/config.ex | 402 ++++++++++++++++++++ lib/mix/tasks/pleroma/count_statuses.ex | 26 ++ lib/mix/tasks/pleroma/database.ex | 260 +++++++++++++ lib/mix/tasks/pleroma/digest.ex | 36 ++ lib/mix/tasks/pleroma/docs.ex | 46 +++ lib/mix/tasks/pleroma/ecto.ex | 50 +++ lib/mix/tasks/pleroma/ecto/migrate.ex | 67 ++++ lib/mix/tasks/pleroma/ecto/rollback.ex | 72 ++++ lib/mix/tasks/pleroma/email.ex | 44 +++ lib/mix/tasks/pleroma/emoji.ex | 275 ++++++++++++++ lib/mix/tasks/pleroma/frontend.ex | 36 ++ lib/mix/tasks/pleroma/instance.ex | 357 ++++++++++++++++++ lib/mix/tasks/pleroma/notification_settings.ex | 87 +++++ lib/mix/tasks/pleroma/openapi_spec.ex | 12 + lib/mix/tasks/pleroma/refresh_counter_cache.ex | 69 ++++ lib/mix/tasks/pleroma/relay.ex | 59 +++ lib/mix/tasks/pleroma/robots_txt.ex | 33 ++ lib/mix/tasks/pleroma/uploads.ex | 100 +++++ lib/mix/tasks/pleroma/user.ex | 490 +++++++++++++++++++++++++ 22 files changed, 2822 insertions(+) create mode 100644 lib/mix/pleroma.ex create mode 100644 lib/mix/tasks/pleroma/app.ex create mode 100644 lib/mix/tasks/pleroma/benchmark.ex create mode 100644 lib/mix/tasks/pleroma/config.ex create mode 100644 lib/mix/tasks/pleroma/count_statuses.ex create mode 100644 lib/mix/tasks/pleroma/database.ex create mode 100644 lib/mix/tasks/pleroma/digest.ex create mode 100644 lib/mix/tasks/pleroma/docs.ex create mode 100644 lib/mix/tasks/pleroma/ecto.ex create mode 100644 lib/mix/tasks/pleroma/ecto/migrate.ex create mode 100644 lib/mix/tasks/pleroma/ecto/rollback.ex create mode 100644 lib/mix/tasks/pleroma/email.ex create mode 100644 lib/mix/tasks/pleroma/emoji.ex create mode 100644 lib/mix/tasks/pleroma/frontend.ex create mode 100644 lib/mix/tasks/pleroma/instance.ex create mode 100644 lib/mix/tasks/pleroma/notification_settings.ex create mode 100644 lib/mix/tasks/pleroma/openapi_spec.ex create mode 100644 lib/mix/tasks/pleroma/refresh_counter_cache.ex create mode 100644 lib/mix/tasks/pleroma/relay.ex create mode 100644 lib/mix/tasks/pleroma/robots_txt.ex create mode 100644 lib/mix/tasks/pleroma/uploads.ex create mode 100644 lib/mix/tasks/pleroma/user.ex (limited to 'lib/mix') diff --git a/lib/mix/pleroma.ex b/lib/mix/pleroma.ex new file mode 100644 index 0000000..2976085 --- /dev/null +++ b/lib/mix/pleroma.ex @@ -0,0 +1,139 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Pleroma do + @apps [ + :restarter, + :ecto, + :ecto_sql, + :postgrex, + :db_connection, + :cachex, + :flake_id, + :swoosh, + :timex, + :fast_html, + :oban + ] + @cachex_children ["object", "user", "scrubber", "web_resp"] + @doc "Common functions to be reused in mix tasks" + def start_pleroma do + Pleroma.Config.Holder.save_default() + Pleroma.Config.Oban.warn() + Pleroma.Application.limiters_setup() + Application.put_env(:phoenix, :serve_endpoints, false, persistent: true) + + unless System.get_env("DEBUG") do + Logger.remove_backend(:console) + end + + adapter = Application.get_env(:tesla, :adapter) + + apps = + if adapter == Tesla.Adapter.Gun do + [:gun | @apps] + else + [:hackney | @apps] + end + + Enum.each(apps, &Application.ensure_all_started/1) + + oban_config = [ + crontab: [], + repo: Pleroma.Repo, + log: false, + queues: [], + plugins: [] + ] + + children = + [ + Pleroma.Repo, + Pleroma.Emoji, + {Pleroma.Config.TransferTask, false}, + Pleroma.Web.Endpoint, + {Oban, oban_config}, + {Majic.Pool, + [name: Pleroma.MajicPool, pool_size: Pleroma.Config.get([:majic_pool, :size], 2)]} + ] ++ + http_children(adapter) + + cachex_children = Enum.map(@cachex_children, &Pleroma.Application.build_cachex(&1, [])) + + Supervisor.start_link(children ++ cachex_children, + strategy: :one_for_one, + name: Pleroma.Supervisor + ) + + if Pleroma.Config.get(:env) not in [:test, :benchmark] do + pleroma_rebooted?() + end + end + + defp pleroma_rebooted? do + if Restarter.Pleroma.rebooted?() do + :ok + else + Process.sleep(10) + pleroma_rebooted?() + end + end + + def load_pleroma do + Application.load(:pleroma) + end + + def get_option(options, opt, prompt, defval \\ nil, defname \\ nil) do + Keyword.get(options, opt) || shell_prompt(prompt, defval, defname) + end + + def shell_prompt(prompt, defval \\ nil, defname \\ nil) do + prompt_message = "#{prompt} [#{defname || defval}] " + + input = + if mix_shell?(), + do: Mix.shell().prompt(prompt_message), + else: :io.get_line(prompt_message) + + case input do + "\n" -> + case defval do + nil -> + shell_prompt(prompt, defval, defname) + + defval -> + defval + end + + input -> + String.trim(input) + end + end + + def shell_info(message) do + if mix_shell?(), + do: Mix.shell().info(message), + else: IO.puts(message) + end + + def shell_error(message) do + if mix_shell?(), + do: Mix.shell().error(message), + else: IO.puts(:stderr, message) + end + + @doc "Performs a safe check whether `Mix.shell/0` is available (does not raise if Mix is not loaded)" + def mix_shell?, do: :erlang.function_exported(Mix, :shell, 0) + + def escape_sh_path(path) do + ~S(') <> String.replace(path, ~S('), ~S(\')) <> ~S(') + end + + defp http_children(Tesla.Adapter.Gun) do + Pleroma.Gun.ConnectionPool.children() ++ + [{Task, &Pleroma.HTTP.AdapterHelper.Gun.limiter_setup/0}] + end + + defp http_children(_), do: [] +end diff --git a/lib/mix/tasks/pleroma/app.ex b/lib/mix/tasks/pleroma/app.ex new file mode 100644 index 0000000..885d071 --- /dev/null +++ b/lib/mix/tasks/pleroma/app.ex @@ -0,0 +1,49 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.App do + @moduledoc File.read!("docs/administration/CLI_tasks/oauth_app.md") + use Mix.Task + + import Mix.Pleroma + + @shortdoc "Creates trusted OAuth App" + + def run(["create" | options]) do + start_pleroma() + + {opts, _} = + OptionParser.parse!(options, + strict: [name: :string, redirect_uri: :string, scopes: :string], + aliases: [n: :name, r: :redirect_uri, s: :scopes] + ) + + scopes = + if opts[:scopes] do + String.split(opts[:scopes], ",") + else + ["read", "write", "follow", "push"] + end + + params = %{ + client_name: opts[:name], + redirect_uris: opts[:redirect_uri], + trusted: true, + scopes: scopes + } + + with {:ok, app} <- Pleroma.Web.OAuth.App.create(params) do + shell_info("#{app.client_name} successfully created:") + shell_info("App client_id: " <> app.client_id) + shell_info("App client_secret: " <> app.client_secret) + else + {:error, changeset} -> + shell_error("Creating failed:") + + Enum.each(Pleroma.Web.OAuth.App.errors(changeset), fn {key, error} -> + shell_error("#{key}: #{error}") + end) + end + end +end diff --git a/lib/mix/tasks/pleroma/benchmark.ex b/lib/mix/tasks/pleroma/benchmark.ex new file mode 100644 index 0000000..f324921 --- /dev/null +++ b/lib/mix/tasks/pleroma/benchmark.ex @@ -0,0 +1,113 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Benchmark do + import Mix.Pleroma + use Mix.Task + + def run(["search"]) do + start_pleroma() + + Benchee.run(%{ + "search" => fn -> + Pleroma.Activity.search(nil, "cofe") + end + }) + end + + def run(["tag"]) do + start_pleroma() + + Benchee.run(%{ + "tag" => fn -> + %{"type" => "Create", "tag" => "cofe"} + |> Pleroma.Web.ActivityPub.ActivityPub.fetch_public_activities() + end + }) + end + + def run(["render_timeline", nickname | _] = args) do + start_pleroma() + user = Pleroma.User.get_by_nickname(nickname) + + activities = + %{} + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("user", user) + |> Map.put("limit", 4096) + |> Pleroma.Web.ActivityPub.ActivityPub.fetch_public_activities() + |> Enum.reverse() + + inputs = %{ + "1 activity" => Enum.take_random(activities, 1), + "10 activities" => Enum.take_random(activities, 10), + "20 activities" => Enum.take_random(activities, 20), + "40 activities" => Enum.take_random(activities, 40), + "80 activities" => Enum.take_random(activities, 80) + } + + inputs = + if Enum.at(args, 2) == "extended" do + Map.merge(inputs, %{ + "200 activities" => Enum.take_random(activities, 200), + "500 activities" => Enum.take_random(activities, 500), + "2000 activities" => Enum.take_random(activities, 2000), + "4096 activities" => Enum.take_random(activities, 4096) + }) + else + inputs + end + + Benchee.run( + %{ + "Standart rendering" => fn activities -> + Pleroma.Web.MastodonAPI.StatusView.render("index.json", %{ + activities: activities, + for: user, + as: :activity + }) + end + }, + inputs: inputs + ) + end + + def run(["adapters"]) do + start_pleroma() + + :ok = + Pleroma.Gun.Conn.open( + "https://httpbin.org/stream-bytes/1500", + :gun_connections + ) + + Process.sleep(1_500) + + Benchee.run( + %{ + "Without conn and without pool" => fn -> + {:ok, %Tesla.Env{}} = + Pleroma.HTTP.get("https://httpbin.org/stream-bytes/1500", [], + pool: :no_pool, + receive_conn: false + ) + end, + "Without conn and with pool" => fn -> + {:ok, %Tesla.Env{}} = + Pleroma.HTTP.get("https://httpbin.org/stream-bytes/1500", [], receive_conn: false) + end, + "With reused conn and without pool" => fn -> + {:ok, %Tesla.Env{}} = + Pleroma.HTTP.get("https://httpbin.org/stream-bytes/1500", [], pool: :no_pool) + end, + "With reused conn and with pool" => fn -> + {:ok, %Tesla.Env{}} = Pleroma.HTTP.get("https://httpbin.org/stream-bytes/1500") + end + }, + parallel: 10 + ) + end +end diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex new file mode 100644 index 0000000..3a2ea44 --- /dev/null +++ b/lib/mix/tasks/pleroma/config.ex @@ -0,0 +1,402 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Config do + use Mix.Task + + import Ecto.Query + import Mix.Pleroma + + alias Pleroma.ConfigDB + alias Pleroma.Repo + + @shortdoc "Manages the location of the config" + @moduledoc File.read!("docs/administration/CLI_tasks/config.md") + + def run(["migrate_to_db"]) do + check_configdb(fn -> + start_pleroma() + migrate_to_db() + end) + end + + def run(["migrate_from_db" | options]) do + check_configdb(fn -> + start_pleroma() + + {opts, _} = + OptionParser.parse!(options, + strict: [env: :string, delete: :boolean, path: :string], + aliases: [d: :delete] + ) + + migrate_from_db(opts) + end) + end + + def run(["dump"]) do + check_configdb(fn -> + start_pleroma() + + header = config_header() + + settings = + ConfigDB + |> Repo.all() + |> Enum.sort() + + unless settings == [] do + shell_info("#{header}") + + Enum.each(settings, &dump(&1)) + else + shell_error("No settings in ConfigDB.") + end + end) + end + + def run(["dump", group, key]) do + check_configdb(fn -> + start_pleroma() + + group = maybe_atomize(group) + key = maybe_atomize(key) + + group + |> ConfigDB.get_by_group_and_key(key) + |> dump() + end) + end + + def run(["dump", group]) do + check_configdb(fn -> + start_pleroma() + + group = maybe_atomize(group) + + dump_group(group) + end) + end + + def run(["groups"]) do + check_configdb(fn -> + start_pleroma() + + groups = + ConfigDB + |> distinct([c], true) + |> select([c], c.group) + |> Repo.all() + + if length(groups) > 0 do + shell_info("The following configuration groups are set in ConfigDB:\r\n") + groups |> Enum.each(fn x -> shell_info("- #{x}") end) + shell_info("\r\n") + end + end) + end + + def run(["reset", "--force"]) do + check_configdb(fn -> + start_pleroma() + truncatedb() + shell_info("The ConfigDB settings have been removed from the database.") + end) + end + + def run(["reset"]) do + check_configdb(fn -> + start_pleroma() + + shell_info("The following settings will be permanently removed:") + + ConfigDB + |> Repo.all() + |> Enum.sort() + |> Enum.each(&dump(&1)) + + shell_error("\nTHIS CANNOT BE UNDONE!") + + if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do + truncatedb() + + shell_info("The ConfigDB settings have been removed from the database.") + else + shell_error("No changes made.") + end + end) + end + + def run(["delete", "--force", group, key]) do + start_pleroma() + + group = maybe_atomize(group) + key = maybe_atomize(key) + + with true <- key_exists?(group, key) do + shell_info("The following settings will be removed from ConfigDB:\n") + + group + |> ConfigDB.get_by_group_and_key(key) + |> dump() + + delete_key(group, key) + else + _ -> + shell_error("No settings in ConfigDB for #{inspect(group)}, #{inspect(key)}. Aborting.") + end + end + + def run(["delete", "--force", group]) do + start_pleroma() + + group = maybe_atomize(group) + + with true <- group_exists?(group) do + shell_info("The following settings will be removed from ConfigDB:\n") + dump_group(group) + delete_group(group) + else + _ -> shell_error("No settings in ConfigDB for #{inspect(group)}. Aborting.") + end + end + + def run(["delete", group, key]) do + start_pleroma() + + group = maybe_atomize(group) + key = maybe_atomize(key) + + with true <- key_exists?(group, key) do + shell_info("The following settings will be removed from ConfigDB:\n") + + group + |> ConfigDB.get_by_group_and_key(key) + |> dump() + + if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do + delete_key(group, key) + else + shell_error("No changes made.") + end + else + _ -> + shell_error("No settings in ConfigDB for #{inspect(group)}, #{inspect(key)}. Aborting.") + end + end + + def run(["delete", group]) do + start_pleroma() + + group = maybe_atomize(group) + + with true <- group_exists?(group) do + shell_info("The following settings will be removed from ConfigDB:\n") + dump_group(group) + + if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do + delete_group(group) + else + shell_error("No changes made.") + end + else + _ -> shell_error("No settings in ConfigDB for #{inspect(group)}. Aborting.") + end + end + + @spec migrate_to_db(Path.t() | nil) :: any() + def migrate_to_db(file_path \\ nil) do + with :ok <- Pleroma.Config.DeprecationWarnings.warn() do + config_file = + if file_path do + file_path + else + if Pleroma.Config.get(:release) do + Pleroma.Config.get(:config_path) + else + "config/#{Pleroma.Config.get(:env)}.secret.exs" + end + end + + do_migrate_to_db(config_file) + else + _ -> + shell_error("Migration is not allowed until all deprecation warnings have been resolved.") + end + end + + defp do_migrate_to_db(config_file) do + if File.exists?(config_file) do + shell_info("Migrating settings from file: #{Path.expand(config_file)}") + truncatedb() + + custom_config = + config_file + |> read_file() + |> elem(0) + + custom_config + |> Keyword.keys() + |> Enum.each(&create(&1, custom_config)) + else + shell_info("To migrate settings, you must define custom settings in #{config_file}.") + end + end + + defp create(group, settings) do + group + |> Pleroma.Config.Loader.filter_group(settings) + |> Enum.each(fn {key, value} -> + {:ok, _} = ConfigDB.update_or_create(%{group: group, key: key, value: value}) + + shell_info("Settings for key #{key} migrated.") + end) + + shell_info("Settings for group #{inspect(group)} migrated.") + end + + defp migrate_from_db(opts) do + env = opts[:env] || Pleroma.Config.get(:env) + + filename = "#{env}.exported_from_db.secret.exs" + + config_path = + cond do + opts[:path] -> + opts[:path] + + Pleroma.Config.get(:release) -> + :config_path + |> Pleroma.Config.get() + |> Path.dirname() + + true -> + "config" + end + |> Path.join(filename) + + with {:ok, file} <- File.open(config_path, [:write, :utf8]) do + write_config(file, config_path, opts) + shell_info("Database configuration settings have been exported to #{config_path}") + else + _ -> + shell_error("Impossible to save settings to this directory #{Path.dirname(config_path)}") + tmp_config_path = Path.join(System.tmp_dir!(), filename) + file = File.open!(tmp_config_path) + + shell_info( + "Saving database configuration settings to #{tmp_config_path}. Copy it to the #{Path.dirname(config_path)} manually." + ) + + write_config(file, tmp_config_path, opts) + end + end + + defp write_config(file, path, opts) do + IO.write(file, config_header()) + + ConfigDB + |> Repo.all() + |> Enum.each(&write_and_delete(&1, file, opts[:delete])) + + :ok = File.close(file) + System.cmd("mix", ["format", path]) + end + + defp config_header, do: "import Config\r\n\r\n" + defp read_file(config_file), do: Config.Reader.read_imports!(config_file) + + defp write_and_delete(config, file, delete?) do + config + |> write(file) + |> delete(delete?) + end + + defp write(config, file) do + value = inspect(config.value, limit: :infinity) + + IO.write(file, "config #{inspect(config.group)}, #{inspect(config.key)}, #{value}\r\n\r\n") + + config + end + + defp delete(config, true) do + {:ok, _} = Repo.delete(config) + + shell_info( + "config #{inspect(config.group)}, #{inspect(config.key)} was deleted from the ConfigDB." + ) + end + + defp delete(_config, _), do: :ok + + defp dump(%ConfigDB{} = config) do + value = inspect(config.value, limit: :infinity) + + shell_info("config #{inspect(config.group)}, #{inspect(config.key)}, #{value}\r\n\r\n") + end + + defp dump(_), do: :noop + + defp dump_group(group) when is_atom(group) do + group + |> ConfigDB.get_all_by_group() + |> Enum.each(&dump/1) + end + + defp group_exists?(group) do + group + |> ConfigDB.get_all_by_group() + |> Enum.any?() + end + + defp key_exists?(group, key) do + group + |> ConfigDB.get_by_group_and_key(key) + |> is_nil + |> Kernel.!() + end + + defp maybe_atomize(arg) when is_atom(arg), do: arg + + defp maybe_atomize(":" <> arg), do: maybe_atomize(arg) + + defp maybe_atomize(arg) when is_binary(arg) do + if ConfigDB.module_name?(arg) do + String.to_existing_atom("Elixir." <> arg) + else + String.to_atom(arg) + end + end + + defp check_configdb(callback) do + with true <- Pleroma.Config.get([:configurable_from_database]) do + callback.() + else + _ -> + shell_error( + "ConfigDB not enabled. Please check the value of :configurable_from_database in your configuration." + ) + end + end + + defp delete_key(group, key) do + check_configdb(fn -> + ConfigDB.delete(%{group: group, key: key}) + end) + end + + defp delete_group(group) do + check_configdb(fn -> + group + |> ConfigDB.get_all_by_group() + |> Enum.each(&ConfigDB.delete/1) + end) + end + + defp truncatedb do + Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;") + Ecto.Adapters.SQL.query!(Repo, "ALTER SEQUENCE config_id_seq RESTART;") + end +end diff --git a/lib/mix/tasks/pleroma/count_statuses.ex b/lib/mix/tasks/pleroma/count_statuses.ex new file mode 100644 index 0000000..c5ab8b7 --- /dev/null +++ b/lib/mix/tasks/pleroma/count_statuses.ex @@ -0,0 +1,26 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.CountStatuses do + @shortdoc "Re-counts statuses for all users" + + use Mix.Task + alias Pleroma.User + import Ecto.Query + + def run([]) do + Mix.Pleroma.start_pleroma() + + stream = + User + |> where(local: true) + |> Pleroma.Repo.stream() + + Pleroma.Repo.transaction(fn -> + Enum.each(stream, &User.update_note_count/1) + end) + + Mix.Pleroma.shell_info("Done") + end +end diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex new file mode 100644 index 0000000..ed560c1 --- /dev/null +++ b/lib/mix/tasks/pleroma/database.ex @@ -0,0 +1,260 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Database do + alias Pleroma.Conversation + alias Pleroma.Maintenance + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User + + require Logger + require Pleroma.Constants + + import Ecto.Query + import Mix.Pleroma + + use Mix.Task + + @shortdoc "A collection of database related tasks" + @moduledoc File.read!("docs/administration/CLI_tasks/database.md") + + def run(["remove_embedded_objects" | args]) do + {options, [], []} = + OptionParser.parse( + args, + strict: [ + vacuum: :boolean + ] + ) + + start_pleroma() + Logger.info("Removing embedded objects") + + Repo.query!( + "update activities set data = safe_jsonb_set(data, '{object}'::text[], data->'object'->'id') where data->'object'->>'id' is not null;", + [], + timeout: :infinity + ) + + if Keyword.get(options, :vacuum) do + Maintenance.vacuum("full") + end + end + + def run(["bump_all_conversations"]) do + start_pleroma() + Conversation.bump_for_all_activities() + end + + def run(["update_users_following_followers_counts"]) do + start_pleroma() + + Repo.transaction( + fn -> + from(u in User, select: u) + |> Repo.stream() + |> Stream.each(&User.update_follower_count/1) + |> Stream.run() + end, + timeout: :infinity + ) + end + + def run(["prune_objects" | args]) do + {options, [], []} = + OptionParser.parse( + args, + strict: [ + vacuum: :boolean + ] + ) + + start_pleroma() + + deadline = Pleroma.Config.get([:instance, :remote_post_retention_days]) + + Logger.info("Pruning objects older than #{deadline} days") + + time_deadline = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(-(deadline * 86_400)) + + from(o in Object, + where: + fragment( + "?->'to' \\? ? OR ?->'cc' \\? ?", + o.data, + ^Pleroma.Constants.as_public(), + o.data, + ^Pleroma.Constants.as_public() + ), + where: o.inserted_at < ^time_deadline, + where: + fragment("split_part(?->>'actor', '/', 3) != ?", o.data, ^Pleroma.Web.Endpoint.host()) + ) + |> Repo.delete_all(timeout: :infinity) + + prune_hashtags_query = """ + DELETE FROM hashtags AS ht + WHERE NOT EXISTS ( + SELECT 1 FROM hashtags_objects hto + WHERE ht.id = hto.hashtag_id) + """ + + Repo.query(prune_hashtags_query) + + if Keyword.get(options, :vacuum) do + Maintenance.vacuum("full") + end + end + + def run(["fix_likes_collections"]) do + start_pleroma() + + from(object in Object, + where: fragment("(?)->>'likes' is not null", object.data), + select: %{id: object.id, likes: fragment("(?)->>'likes'", object.data)} + ) + |> Pleroma.Repo.chunk_stream(100, :batches) + |> Stream.each(fn objects -> + ids = + objects + |> Enum.filter(fn object -> object.likes |> Jason.decode!() |> is_map() end) + |> Enum.map(& &1.id) + + Object + |> where([object], object.id in ^ids) + |> update([object], + set: [ + data: + fragment( + "safe_jsonb_set(?, '{likes}', '[]'::jsonb, true)", + object.data + ) + ] + ) + |> Repo.update_all([], timeout: :infinity) + end) + |> Stream.run() + end + + def run(["vacuum", args]) do + start_pleroma() + + Maintenance.vacuum(args) + end + + def run(["ensure_expiration"]) do + start_pleroma() + days = Pleroma.Config.get([:mrf_activity_expiration, :days], 365) + + Pleroma.Activity + |> join(:inner, [a], o in Object, + on: + fragment( + "(?->>'id') = associated_object_id((?))", + o.data, + a.data + ) + ) + |> where(local: true) + |> where([a], fragment("(? ->> 'type'::text) = 'Create'", a.data)) + |> where([_a, o], fragment("?->>'type' = 'Note'", o.data)) + |> Pleroma.Repo.chunk_stream(100, :batches) + |> Stream.each(fn activities -> + Enum.each(activities, fn activity -> + expires_at = + activity.inserted_at + |> DateTime.from_naive!("Etc/UTC") + |> Timex.shift(days: days) + + Pleroma.Workers.PurgeExpiredActivity.enqueue(%{ + activity_id: activity.id, + expires_at: expires_at + }) + end) + end) + |> Stream.run() + end + + def run(["set_text_search_config", tsconfig]) do + start_pleroma() + %{rows: [[tsc]]} = Ecto.Adapters.SQL.query!(Pleroma.Repo, "SHOW default_text_search_config;") + shell_info("Current default_text_search_config: #{tsc}") + + %{rows: [[db]]} = Ecto.Adapters.SQL.query!(Pleroma.Repo, "SELECT current_database();") + shell_info("Update default_text_search_config: #{tsconfig}") + + %{messages: msg} = + Ecto.Adapters.SQL.query!( + Pleroma.Repo, + "ALTER DATABASE #{db} SET default_text_search_config = '#{tsconfig}';" + ) + + # non-exist config will not raise excpetion but only give >0 messages + if length(msg) > 0 do + shell_info("Error: #{inspect(msg, pretty: true)}") + else + rum_enabled = Pleroma.Config.get([:database, :rum_enabled]) + shell_info("Recreate index, RUM: #{rum_enabled}") + + # Note SQL below needs to be kept up-to-date with latest GIN or RUM index definition in future + if rum_enabled do + Ecto.Adapters.SQL.query!( + Pleroma.Repo, + "CREATE OR REPLACE FUNCTION objects_fts_update() RETURNS trigger AS $$ BEGIN + new.fts_content := to_tsvector(new.data->>'content'); + RETURN new; + END + $$ LANGUAGE plpgsql", + [], + timeout: :infinity + ) + + shell_info("Refresh RUM index") + Ecto.Adapters.SQL.query!(Pleroma.Repo, "UPDATE objects SET updated_at = NOW();") + else + Ecto.Adapters.SQL.query!(Pleroma.Repo, "DROP INDEX IF EXISTS objects_fts;") + + Ecto.Adapters.SQL.query!( + Pleroma.Repo, + "CREATE INDEX CONCURRENTLY objects_fts ON objects USING gin(to_tsvector('#{tsconfig}', data->>'content')); ", + [], + timeout: :infinity + ) + end + + shell_info('Done.') + end + end + + # Rolls back a specific migration (leaving subsequent migrations applied). + # WARNING: imposes a risk of unrecoverable data loss — proceed at your own responsibility. + # Based on https://stackoverflow.com/a/53825840 + def run(["rollback", version]) do + prompt = "SEVERE WARNING: this operation may result in unrecoverable data loss. Continue?" + + if shell_prompt(prompt, "n") in ~w(Yn Y y) do + {_, result, _} = + Ecto.Migrator.with_repo(Pleroma.Repo, fn repo -> + version = String.to_integer(version) + re = ~r/^#{version}_.*\.exs/ + path = Ecto.Migrator.migrations_path(repo) + + with {_, "" <> file} <- {:find, Enum.find(File.ls!(path), &String.match?(&1, re))}, + {_, [{mod, _} | _]} <- {:compile, Code.compile_file(Path.join(path, file))}, + {_, :ok} <- {:rollback, Ecto.Migrator.down(repo, version, mod)} do + {:ok, "Reversed migration: #{file}"} + else + {:find, _} -> {:error, "No migration found with version prefix: #{version}"} + {:compile, e} -> {:error, "Problem compiling migration module: #{inspect(e)}"} + {:rollback, e} -> {:error, "Problem reversing migration: #{inspect(e)}"} + end + end) + + shell_info(inspect(result)) + end + end +end diff --git a/lib/mix/tasks/pleroma/digest.ex b/lib/mix/tasks/pleroma/digest.ex new file mode 100644 index 0000000..aea9c8a --- /dev/null +++ b/lib/mix/tasks/pleroma/digest.ex @@ -0,0 +1,36 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Digest do + use Mix.Task + import Mix.Pleroma + + @shortdoc "Manages digest emails" + @moduledoc File.read!("docs/administration/CLI_tasks/digest.md") + + def run(["test", nickname | opts]) do + Mix.Pleroma.start_pleroma() + + user = Pleroma.User.get_by_nickname(nickname) + + last_digest_emailed_at = + with [date] <- opts, + {:ok, datetime} <- Timex.parse(date, "{YYYY}-{0M}-{0D}") do + datetime + else + _ -> user.inserted_at + end + + patched_user = %{user | last_digest_emailed_at: last_digest_emailed_at} + + with %Swoosh.Email{} = email <- Pleroma.Emails.UserEmail.digest_email(patched_user) do + {:ok, _} = Pleroma.Emails.Mailer.deliver(email) + + shell_info("Digest email have been sent to #{nickname} (#{user.email})") + else + _ -> + shell_info("Cound't find any mentions for #{nickname} since #{last_digest_emailed_at}") + end + end +end diff --git a/lib/mix/tasks/pleroma/docs.ex b/lib/mix/tasks/pleroma/docs.ex new file mode 100644 index 0000000..32c02a5 --- /dev/null +++ b/lib/mix/tasks/pleroma/docs.ex @@ -0,0 +1,46 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Docs do + use Mix.Task + import Mix.Pleroma + + @shortdoc "Generates docs from descriptions.exs" + @moduledoc """ + Generates docs from `descriptions.exs`. + + Supports two formats: `markdown` and `json`. + + ## Generate Markdown docs + + `mix pleroma.docs` + + ## Generate JSON docs + + `mix pleroma.docs json` + """ + + def run(["json"]) do + do_run(Pleroma.Docs.JSON) + end + + def run(_) do + do_run(Pleroma.Docs.Markdown) + end + + defp do_run(implementation) do + start_pleroma() + + with descriptions <- Pleroma.Config.Loader.read("config/description.exs"), + {:ok, file_path} <- + Pleroma.Docs.Generator.process( + implementation, + descriptions[:pleroma][:config_description] + ) do + type = if implementation == Pleroma.Docs.Markdown, do: "Markdown", else: "JSON" + + Mix.shell().info([:green, "#{type} docs successfully generated to #{file_path}."]) + end + end +end diff --git a/lib/mix/tasks/pleroma/ecto.ex b/lib/mix/tasks/pleroma/ecto.ex new file mode 100644 index 0000000..8cf77d1 --- /dev/null +++ b/lib/mix/tasks/pleroma/ecto.ex @@ -0,0 +1,50 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Ecto do + @doc """ + Ensures the given repository's migrations path exists on the file system. + """ + @spec ensure_migrations_path(Ecto.Repo.t(), Keyword.t()) :: String.t() + def ensure_migrations_path(repo, opts) do + path = opts[:migrations_path] || Path.join(source_repo_priv(repo), "migrations") + + path = + case Path.type(path) do + :relative -> + Path.join(Application.app_dir(:pleroma), path) + + :absolute -> + path + end + + if not File.dir?(path) do + raise_missing_migrations(Path.relative_to_cwd(path), repo) + end + + path + end + + @doc """ + Returns the private repository path relative to the source. + """ + def source_repo_priv(repo) do + config = repo.config() + priv = config[:priv] || "priv/#{repo |> Module.split() |> List.last() |> Macro.underscore()}" + Path.join(Application.app_dir(:pleroma), priv) + end + + defp raise_missing_migrations(path, repo) do + raise(""" + Could not find migrations directory #{inspect(path)} + for repo #{inspect(repo)}. + This may be because you are in a new project and the + migration directory has not been created yet. Creating an + empty directory at the path above will fix this error. + If you expected existing migrations to be found, please + make sure your repository has been properly configured + and the configured path exists. + """) + end +end diff --git a/lib/mix/tasks/pleroma/ecto/migrate.ex b/lib/mix/tasks/pleroma/ecto/migrate.ex new file mode 100644 index 0000000..c45c930 --- /dev/null +++ b/lib/mix/tasks/pleroma/ecto/migrate.ex @@ -0,0 +1,67 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Ecto.Migrate do + use Mix.Task + import Mix.Pleroma + require Logger + + @shortdoc "Wrapper on `ecto.migrate` task." + + @aliases [ + n: :step, + v: :to + ] + + @switches [ + all: :boolean, + step: :integer, + to: :integer, + quiet: :boolean, + log_sql: :boolean, + strict_version_order: :boolean, + migrations_path: :string + ] + + @moduledoc """ + Changes `Logger` level to `:info` before start migration. + Changes level back when migration ends. + + ## Start migration + + mix pleroma.ecto.migrate [OPTIONS] + + Options: + - see https://hexdocs.pm/ecto/2.0.0/Mix.Tasks.Ecto.Migrate.html + """ + + @impl true + def run(args \\ []) do + load_pleroma() + {opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) + + if Application.get_env(:pleroma, Pleroma.Repo)[:ssl] do + Application.ensure_all_started(:ssl) + end + + opts = + if opts[:to] || opts[:step] || opts[:all], + do: opts, + else: Keyword.put(opts, :all, true) + + opts = + if opts[:quiet], + do: Keyword.merge(opts, log: false, log_sql: false), + else: opts + + path = Mix.Tasks.Pleroma.Ecto.ensure_migrations_path(Pleroma.Repo, opts) + + level = Logger.level() + Logger.configure(level: :info) + + {:ok, _, _} = Ecto.Migrator.with_repo(Pleroma.Repo, &Ecto.Migrator.run(&1, path, :up, opts)) + + Logger.configure(level: level) + end +end diff --git a/lib/mix/tasks/pleroma/ecto/rollback.ex b/lib/mix/tasks/pleroma/ecto/rollback.ex new file mode 100644 index 0000000..3d78eae --- /dev/null +++ b/lib/mix/tasks/pleroma/ecto/rollback.ex @@ -0,0 +1,72 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Ecto.Rollback do + use Mix.Task + import Mix.Pleroma + require Logger + @shortdoc "Wrapper on `ecto.rollback` task" + + @aliases [ + n: :step, + v: :to + ] + + @switches [ + all: :boolean, + step: :integer, + to: :integer, + start: :boolean, + quiet: :boolean, + log_sql: :boolean, + migrations_path: :string, + env: :string + ] + + @moduledoc """ + Changes `Logger` level to `:info` before start rollback. + Changes level back when rollback ends. + + ## Start rollback + + mix pleroma.ecto.rollback + + Options: + - see https://hexdocs.pm/ecto/2.0.0/Mix.Tasks.Ecto.Rollback.html + """ + + @impl true + def run(args \\ []) do + load_pleroma() + {opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) + + if Application.get_env(:pleroma, Pleroma.Repo)[:ssl] do + Application.ensure_all_started(:ssl) + end + + opts = + if opts[:to] || opts[:step] || opts[:all], + do: opts, + else: Keyword.put(opts, :step, 1) + + opts = + if opts[:quiet], + do: Keyword.merge(opts, log: false, log_sql: false), + else: opts + + path = Mix.Tasks.Pleroma.Ecto.ensure_migrations_path(Pleroma.Repo, opts) + + level = Logger.level() + Logger.configure(level: :info) + + if opts[:env] == "test" do + Logger.info("Rollback succesfully") + else + {:ok, _, _} = + Ecto.Migrator.with_repo(Pleroma.Repo, &Ecto.Migrator.run(&1, path, :down, opts)) + end + + Logger.configure(level: level) + end +end diff --git a/lib/mix/tasks/pleroma/email.ex b/lib/mix/tasks/pleroma/email.ex new file mode 100644 index 0000000..37272c1 --- /dev/null +++ b/lib/mix/tasks/pleroma/email.ex @@ -0,0 +1,44 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Email do + use Mix.Task + import Mix.Pleroma + + @shortdoc "Email administrative tasks" + @moduledoc File.read!("docs/administration/CLI_tasks/email.md") + + def run(["test" | args]) do + start_pleroma() + + {options, [], []} = + OptionParser.parse( + args, + strict: [ + to: :string + ] + ) + + email = Pleroma.Emails.AdminEmail.test_email(options[:to]) + {:ok, _} = Pleroma.Emails.Mailer.deliver(email) + + shell_info("Test email has been sent to #{inspect(email.to)} from #{inspect(email.from)}") + end + + def run(["resend_confirmation_emails"]) do + start_pleroma() + + shell_info("Sending emails to all unconfirmed users") + + Pleroma.User.Query.build(%{ + local: true, + is_active: true, + is_confirmed: false, + invisible: false + }) + |> Pleroma.Repo.chunk_stream(500) + |> Stream.each(&Pleroma.User.maybe_send_confirmation_email(&1)) + |> Stream.run() + end +end diff --git a/lib/mix/tasks/pleroma/emoji.ex b/lib/mix/tasks/pleroma/emoji.ex new file mode 100644 index 0000000..537f071 --- /dev/null +++ b/lib/mix/tasks/pleroma/emoji.ex @@ -0,0 +1,275 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Emoji do + use Mix.Task + import Mix.Pleroma + + @shortdoc "Manages emoji packs" + @moduledoc File.read!("docs/administration/CLI_tasks/emoji.md") + + def run(["ls-packs" | args]) do + start_pleroma() + + {options, [], []} = parse_global_opts(args) + + url_or_path = options[:manifest] || default_manifest() + manifest = fetch_and_decode!(url_or_path) + + Enum.each(manifest, fn {name, info} -> + to_print = [ + {"Name", name}, + {"Homepage", info["homepage"]}, + {"Description", info["description"]}, + {"License", info["license"]}, + {"Source", info["src"]} + ] + + for {param, value} <- to_print do + IO.puts(IO.ANSI.format([:bright, param, :normal, ": ", value])) + end + + # A newline + IO.puts("") + end) + end + + def run(["get-packs" | args]) do + start_pleroma() + + {options, pack_names, []} = parse_global_opts(args) + + url_or_path = options[:manifest] || default_manifest() + + manifest = fetch_and_decode!(url_or_path) + + for pack_name <- pack_names do + if Map.has_key?(manifest, pack_name) do + pack = manifest[pack_name] + src = pack["src"] + + IO.puts( + IO.ANSI.format([ + "Downloading ", + :bright, + pack_name, + :normal, + " from ", + :underline, + src + ]) + ) + + {:ok, binary_archive} = fetch(src) + archive_sha = :crypto.hash(:sha256, binary_archive) |> Base.encode16() + + sha_status_text = ["SHA256 of ", :bright, pack_name, :normal, " source file is ", :bright] + + if archive_sha == String.upcase(pack["src_sha256"]) do + IO.puts(IO.ANSI.format(sha_status_text ++ [:green, "OK"])) + else + IO.puts(IO.ANSI.format(sha_status_text ++ [:red, "BAD"])) + + raise "Bad SHA256 for #{pack_name}" + end + + # The location specified in files should be in the same directory + files_loc = + url_or_path + |> Path.dirname() + |> Path.join(pack["files"]) + + IO.puts( + IO.ANSI.format([ + "Fetching the file list for ", + :bright, + pack_name, + :normal, + " from ", + :underline, + files_loc + ]) + ) + + files = fetch_and_decode!(files_loc) + + IO.puts(IO.ANSI.format(["Unpacking ", :bright, pack_name])) + + pack_path = + Path.join([ + Pleroma.Config.get!([:instance, :static_dir]), + "emoji", + pack_name + ]) + + files_to_unzip = + Enum.map( + files, + fn {_, f} -> to_charlist(f) end + ) + + {:ok, _} = + :zip.unzip(binary_archive, + cwd: pack_path, + file_list: files_to_unzip + ) + + IO.puts(IO.ANSI.format(["Writing pack.json for ", :bright, pack_name])) + + pack_json = %{ + pack: %{ + "license" => pack["license"], + "homepage" => pack["homepage"], + "description" => pack["description"], + "fallback-src" => pack["src"], + "fallback-src-sha256" => pack["src_sha256"], + "share-files" => true + }, + files: files + } + + File.write!(Path.join(pack_path, "pack.json"), Jason.encode!(pack_json, pretty: true)) + else + IO.puts(IO.ANSI.format([:bright, :red, "No pack named \"#{pack_name}\" found"])) + end + end + end + + def run(["gen-pack" | args]) do + start_pleroma() + + {opts, [src], []} = + OptionParser.parse( + args, + strict: [ + name: :string, + license: :string, + homepage: :string, + description: :string, + files: :string, + extensions: :string + ] + ) + + proposed_name = Path.basename(src) |> Path.rootname() + name = get_option(opts, :name, "Pack name:", proposed_name) + license = get_option(opts, :license, "License:") + homepage = get_option(opts, :homepage, "Homepage:") + description = get_option(opts, :description, "Description:") + + proposed_files_name = "#{name}_files.json" + files_name = get_option(opts, :files, "Save file list to:", proposed_files_name) + + default_exts = [".png", ".gif"] + + custom_exts = + get_option( + opts, + :extensions, + "Emoji file extensions (separated with spaces):", + Enum.join(default_exts, " ") + ) + |> String.split(" ", trim: true) + + exts = + if MapSet.equal?(MapSet.new(default_exts), MapSet.new(custom_exts)) do + default_exts + else + custom_exts + end + + IO.puts("Using #{Enum.join(exts, " ")} extensions") + + IO.puts("Downloading the pack and generating SHA256") + + {:ok, %{body: binary_archive}} = Pleroma.HTTP.get(src) + archive_sha = :crypto.hash(:sha256, binary_archive) |> Base.encode16() + + IO.puts("SHA256 is #{archive_sha}") + + pack_json = %{ + name => %{ + license: license, + homepage: homepage, + description: description, + src: src, + src_sha256: archive_sha, + files: files_name + } + } + + tmp_pack_dir = Path.join(System.tmp_dir!(), "emoji-pack-#{name}") + + {:ok, _} = :zip.unzip(binary_archive, cwd: String.to_charlist(tmp_pack_dir)) + + emoji_map = Pleroma.Emoji.Loader.make_shortcode_to_file_map(tmp_pack_dir, exts) + + File.write!(files_name, Jason.encode!(emoji_map, pretty: true)) + + IO.puts(""" + + #{files_name} has been created and contains the list of all found emojis in the pack. + Please review the files in the pack and remove those not needed. + """) + + pack_file = "#{name}.json" + + if File.exists?(pack_file) do + existing_data = File.read!(pack_file) |> Jason.decode!() + + File.write!( + pack_file, + Jason.encode!( + Map.merge( + existing_data, + pack_json + ), + pretty: true + ) + ) + + IO.puts("#{pack_file} has been updated with the #{name} pack") + else + File.write!(pack_file, Jason.encode!(pack_json, pretty: true)) + + IO.puts("#{pack_file} has been created with the #{name} pack") + end + end + + def run(["reload"]) do + start_pleroma() + Pleroma.Emoji.reload() + IO.puts("Emoji packs have been reloaded.") + end + + defp fetch_and_decode!(from) do + with {:ok, json} <- fetch(from) do + Jason.decode!(json) + else + {:error, error} -> raise "#{from} cannot be fetched. Error: #{error} occur." + end + end + + defp fetch("http" <> _ = from) do + with {:ok, %{body: body}} <- Pleroma.HTTP.get(from) do + {:ok, body} + end + end + + defp fetch(path), do: File.read(path) + + defp parse_global_opts(args) do + OptionParser.parse( + args, + strict: [ + manifest: :string + ], + aliases: [ + m: :manifest + ] + ) + end + + defp default_manifest, do: Pleroma.Config.get!([:emoji, :default_manifest]) +end diff --git a/lib/mix/tasks/pleroma/frontend.ex b/lib/mix/tasks/pleroma/frontend.ex new file mode 100644 index 0000000..3c71801 --- /dev/null +++ b/lib/mix/tasks/pleroma/frontend.ex @@ -0,0 +1,36 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Frontend do + use Mix.Task + + import Mix.Pleroma + + @shortdoc "Manages bundled Pleroma frontends" + + @moduledoc File.read!("docs/administration/CLI_tasks/frontend.md") + + def run(["install", "none" | _args]) do + shell_info("Skipping frontend installation because none was requested") + "none" + end + + def run(["install", frontend | args]) do + start_pleroma() + + {options, [], []} = + OptionParser.parse( + args, + strict: [ + ref: :string, + static_dir: :string, + build_url: :string, + build_dir: :string, + file: :string + ] + ) + + Pleroma.Frontend.install(frontend, options) + end +end diff --git a/lib/mix/tasks/pleroma/instance.ex b/lib/mix/tasks/pleroma/instance.ex new file mode 100644 index 0000000..5d8b254 --- /dev/null +++ b/lib/mix/tasks/pleroma/instance.ex @@ -0,0 +1,357 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Instance do + use Mix.Task + import Mix.Pleroma + + alias Pleroma.Config + + @shortdoc "Manages Pleroma instance" + @moduledoc File.read!("docs/administration/CLI_tasks/instance.md") + + def run(["gen" | rest]) do + {options, [], []} = + OptionParser.parse( + rest, + strict: [ + force: :boolean, + output: :string, + output_psql: :string, + domain: :string, + instance_name: :string, + admin_email: :string, + notify_email: :string, + dbhost: :string, + dbname: :string, + dbuser: :string, + dbpass: :string, + rum: :string, + indexable: :string, + db_configurable: :string, + uploads_dir: :string, + static_dir: :string, + listen_ip: :string, + listen_port: :string, + strip_uploads_location: :string, + read_uploads_description: :string, + anonymize_uploads: :string, + dedupe_uploads: :string + ], + aliases: [ + o: :output, + f: :force + ] + ) + + paths = + [config_path, psql_path] = [ + Keyword.get(options, :output, "config/generated_config.exs"), + Keyword.get(options, :output_psql, "config/setup_db.psql") + ] + + will_overwrite = Enum.filter(paths, &File.exists?/1) + proceed? = Enum.empty?(will_overwrite) or Keyword.get(options, :force, false) + + if proceed? do + [domain, port | _] = + String.split( + get_option( + options, + :domain, + "What domain will your instance use? (e.g pleroma.soykaf.com)" + ), + ":" + ) ++ [443] + + name = + get_option( + options, + :instance_name, + "What is the name of your instance? (e.g. The Corndog Emporium)", + domain + ) + + email = get_option(options, :admin_email, "What is your admin email address?") + + notify_email = + get_option( + options, + :notify_email, + "What email address do you want to use for sending email notifications?", + email + ) + + indexable = + get_option( + options, + :indexable, + "Do you want search engines to index your site? (y/n)", + "y" + ) === "y" + + db_configurable? = + get_option( + options, + :db_configurable, + "Do you want to store the configuration in the database (allows controlling it from admin-fe)? (y/n)", + "n" + ) === "y" + + dbhost = get_option(options, :dbhost, "What is the hostname of your database?", "localhost") + + dbname = get_option(options, :dbname, "What is the name of your database?", "pleroma") + + dbuser = + get_option( + options, + :dbuser, + "What is the user used to connect to your database?", + "pleroma" + ) + + dbpass = + get_option( + options, + :dbpass, + "What is the password used to connect to your database?", + :crypto.strong_rand_bytes(64) |> Base.encode64() |> binary_part(0, 64), + "autogenerated" + ) + + rum_enabled = + get_option( + options, + :rum, + "Would you like to use RUM indices?", + "n" + ) === "y" + + listen_port = + get_option( + options, + :listen_port, + "What port will the app listen to (leave it if you are using the default setup with nginx)?", + 4000 + ) + + listen_ip = + get_option( + options, + :listen_ip, + "What ip will the app listen to (leave it if you are using the default setup with nginx)?", + "127.0.0.1" + ) + + uploads_dir = + get_option( + options, + :uploads_dir, + "What directory should media uploads go in (when using the local uploader)?", + Config.get([Pleroma.Uploaders.Local, :uploads]) + ) + |> Path.expand() + + static_dir = + get_option( + options, + :static_dir, + "What directory should custom public files be read from (custom emojis, frontend bundle overrides, robots.txt, etc.)?", + Config.get([:instance, :static_dir]) + ) + |> Path.expand() + + {strip_uploads_location_message, strip_uploads_location_default} = + if Pleroma.Utils.command_available?("exiftool") do + {"Do you want to strip location (GPS) data from uploaded images? This requires exiftool, it was detected as installed. (y/n)", + "y"} + else + {"Do you want to strip location (GPS) data from uploaded images? This requires exiftool, it was detected as not installed, please install it if you answer yes. (y/n)", + "n"} + end + + strip_uploads_location = + get_option( + options, + :strip_uploads_location, + strip_uploads_location_message, + strip_uploads_location_default + ) === "y" + + {read_uploads_description_message, read_uploads_description_default} = + if Pleroma.Utils.command_available?("exiftool") do + {"Do you want to read data from uploaded files so clients can use it to prefill fields like image description? This requires exiftool, it was detected as installed. (y/n)", + "y"} + else + {"Do you want to read data from uploaded files so clients can use it to prefill fields like image description? This requires exiftool, it was detected as not installed, please install it if you answer yes. (y/n)", + "n"} + end + + read_uploads_description = + get_option( + options, + :read_uploads_description, + read_uploads_description_message, + read_uploads_description_default + ) === "y" + + anonymize_uploads = + get_option( + options, + :anonymize_uploads, + "Do you want to anonymize the filenames of uploads? (y/n)", + "n" + ) === "y" + + dedupe_uploads = + get_option( + options, + :dedupe_uploads, + "Do you want to deduplicate uploaded files? (y/n)", + "n" + ) === "y" + + Config.put([:instance, :static_dir], static_dir) + + secret = :crypto.strong_rand_bytes(64) |> Base.encode64() |> binary_part(0, 64) + jwt_secret = :crypto.strong_rand_bytes(64) |> Base.encode64() |> binary_part(0, 64) + signing_salt = :crypto.strong_rand_bytes(8) |> Base.encode64() |> binary_part(0, 8) + lv_signing_salt = :crypto.strong_rand_bytes(8) |> Base.encode64() |> binary_part(0, 8) + {web_push_public_key, web_push_private_key} = :crypto.generate_key(:ecdh, :prime256v1) + template_dir = Application.app_dir(:pleroma, "priv") <> "/templates" + + result_config = + EEx.eval_file( + template_dir <> "/sample_config.eex", + domain: domain, + port: port, + email: email, + notify_email: notify_email, + name: name, + dbhost: dbhost, + dbname: dbname, + dbuser: dbuser, + dbpass: dbpass, + secret: secret, + jwt_secret: jwt_secret, + signing_salt: signing_salt, + lv_signing_salt: lv_signing_salt, + web_push_public_key: Base.url_encode64(web_push_public_key, padding: false), + web_push_private_key: Base.url_encode64(web_push_private_key, padding: false), + db_configurable?: db_configurable?, + static_dir: static_dir, + uploads_dir: uploads_dir, + rum_enabled: rum_enabled, + listen_ip: listen_ip, + listen_port: listen_port, + upload_filters: + upload_filters(%{ + strip_location: strip_uploads_location, + read_description: read_uploads_description, + anonymize: anonymize_uploads, + dedupe: dedupe_uploads + }) + ) + + result_psql = + EEx.eval_file( + template_dir <> "/sample_psql.eex", + dbname: dbname, + dbuser: dbuser, + dbpass: dbpass, + rum_enabled: rum_enabled + ) + + config_dir = Path.dirname(config_path) + psql_dir = Path.dirname(psql_path) + + # Note: Distros requiring group read (0o750) on those directories should + # pre-create the directories. + [config_dir, psql_dir, static_dir, uploads_dir] + |> Enum.reject(&File.exists?/1) + |> Enum.each(fn dir -> + File.mkdir_p!(dir) + File.chmod!(dir, 0o700) + end) + + shell_info("Writing config to #{config_path}.") + + # Sadly no fchmod(2) equivalent in Elixir… + File.touch!(config_path) + File.chmod!(config_path, 0o640) + File.write(config_path, result_config) + shell_info("Writing the postgres script to #{psql_path}.") + File.write(psql_path, result_psql) + + write_robots_txt(static_dir, indexable, template_dir) + + shell_info( + "\n All files successfully written! Refer to the installation instructions for your platform for next steps." + ) + + if db_configurable? do + shell_info( + " Please transfer your config to the database after running database migrations. Refer to \"Transfering the config to/from the database\" section of the docs for more information." + ) + end + else + shell_error( + "The task would have overwritten the following files:\n" <> + Enum.map_join(will_overwrite, &"- #{&1}\n") <> "Rerun with `--force` to overwrite them." + ) + end + end + + defp write_robots_txt(static_dir, indexable, template_dir) do + robots_txt = + EEx.eval_file( + template_dir <> "/robots_txt.eex", + indexable: indexable + ) + + robots_txt_path = Path.join(static_dir, "robots.txt") + + if File.exists?(robots_txt_path) do + File.cp!(robots_txt_path, "#{robots_txt_path}.bak") + shell_info("Backing up existing robots.txt to #{robots_txt_path}.bak") + end + + File.write(robots_txt_path, robots_txt) + shell_info("Writing #{robots_txt_path}.") + end + + defp upload_filters(filters) when is_map(filters) do + enabled_filters = + if filters.strip_location do + [Pleroma.Upload.Filter.Exiftool.StripLocation] + else + [] + end + + enabled_filters = + if filters.read_description do + enabled_filters ++ [Pleroma.Upload.Filter.Exiftool.ReadDescription] + else + enabled_filters + end + + enabled_filters = + if filters.anonymize do + enabled_filters ++ [Pleroma.Upload.Filter.AnonymizeFilename] + else + enabled_filters + end + + enabled_filters = + if filters.dedupe do + enabled_filters ++ [Pleroma.Upload.Filter.Dedupe] + else + enabled_filters + end + + enabled_filters + end + + defp upload_filters(_), do: [] +end diff --git a/lib/mix/tasks/pleroma/notification_settings.ex b/lib/mix/tasks/pleroma/notification_settings.ex new file mode 100644 index 0000000..f0a7cc4 --- /dev/null +++ b/lib/mix/tasks/pleroma/notification_settings.ex @@ -0,0 +1,87 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.NotificationSettings do + @shortdoc "Enable&Disable privacy option for push notifications" + @moduledoc """ + Example: + + > mix pleroma.notification_settings --hide-notification-contents=false --nickname-users="parallel588" # set false only for parallel588 user + > mix pleroma.notification_settings --hide-notification-contents=true # set true for all users + + """ + + use Mix.Task + import Mix.Pleroma + import Ecto.Query + + def run(args) do + start_pleroma() + + {options, _, _} = + OptionParser.parse( + args, + strict: [ + hide_notification_contents: :boolean, + email_users: :string, + nickname_users: :string + ] + ) + + hide_notification_contents = Keyword.get(options, :hide_notification_contents) + + if not is_nil(hide_notification_contents) do + hide_notification_contents + |> build_query(options) + |> Pleroma.Repo.update_all([]) + end + + shell_info("Done") + end + + defp build_query(hide_notification_contents, options) do + query = + from(u in Pleroma.User, + update: [ + set: [ + notification_settings: + fragment( + "jsonb_set(notification_settings, '{hide_notification_contents}', ?)", + ^hide_notification_contents + ) + ] + ] + ) + + user_emails = + options + |> Keyword.get(:email_users, "") + |> String.split(",") + |> Enum.map(&String.trim(&1)) + |> Enum.reject(&(&1 == "")) + + query = + if length(user_emails) > 0 do + where(query, [u], u.email in ^user_emails) + else + query + end + + user_nicknames = + options + |> Keyword.get(:nickname_users, "") + |> String.split(",") + |> Enum.map(&String.trim(&1)) + |> Enum.reject(&(&1 == "")) + + query = + if length(user_nicknames) > 0 do + where(query, [u], u.nickname in ^user_nicknames) + else + query + end + + query + end +end diff --git a/lib/mix/tasks/pleroma/openapi_spec.ex b/lib/mix/tasks/pleroma/openapi_spec.ex new file mode 100644 index 0000000..884f931 --- /dev/null +++ b/lib/mix/tasks/pleroma/openapi_spec.ex @@ -0,0 +1,12 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.OpenapiSpec do + def run([path]) do + # Load Pleroma application to get version info + Application.load(:pleroma) + spec = Pleroma.Web.ApiSpec.spec(server_specific: false) |> Jason.encode!() + File.write(path, spec) + end +end diff --git a/lib/mix/tasks/pleroma/refresh_counter_cache.ex b/lib/mix/tasks/pleroma/refresh_counter_cache.ex new file mode 100644 index 0000000..ad37cd2 --- /dev/null +++ b/lib/mix/tasks/pleroma/refresh_counter_cache.ex @@ -0,0 +1,69 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.RefreshCounterCache do + @shortdoc "Refreshes counter cache" + + use Mix.Task + + alias Pleroma.Activity + alias Pleroma.CounterCache + alias Pleroma.Repo + + require Logger + import Ecto.Query + + def run([]) do + Mix.Pleroma.start_pleroma() + + instances = + Activity + |> distinct([a], true) + |> select([a], fragment("split_part(?, '/', 3)", a.actor)) + |> Repo.all() + + instances + |> Enum.with_index(1) + |> Enum.each(fn {instance, i} -> + counters = instance_counters(instance) + CounterCache.set(instance, counters) + + Mix.Pleroma.shell_info( + "[#{i}/#{length(instances)}] Setting #{instance} counters: #{inspect(counters)}" + ) + end) + + Mix.Pleroma.shell_info("Done") + end + + defp instance_counters(instance) do + counters = %{"public" => 0, "unlisted" => 0, "private" => 0, "direct" => 0} + + Activity + |> where([a], fragment("(? ->> 'type'::text) = 'Create'", a.data)) + |> where([a], fragment("split_part(?, '/', 3) = ?", a.actor, ^instance)) + |> select( + [a], + {fragment( + "activity_visibility(?, ?, ?)", + a.actor, + a.recipients, + a.data + ), count(a.id)} + ) + |> group_by( + [a], + fragment( + "activity_visibility(?, ?, ?)", + a.actor, + a.recipients, + a.data + ) + ) + |> Repo.all(timeout: :timer.minutes(30)) + |> Enum.reduce(counters, fn {visibility, count}, acc -> + Map.put(acc, visibility, count) + end) + end +end diff --git a/lib/mix/tasks/pleroma/relay.ex b/lib/mix/tasks/pleroma/relay.ex new file mode 100644 index 0000000..29cc666 --- /dev/null +++ b/lib/mix/tasks/pleroma/relay.ex @@ -0,0 +1,59 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Relay do + use Mix.Task + import Mix.Pleroma + alias Pleroma.Web.ActivityPub.Relay + + @shortdoc "Manages remote relays" + @moduledoc File.read!("docs/administration/CLI_tasks/relay.md") + + def run(["follow", target]) do + start_pleroma() + + with {:ok, _activity} <- Relay.follow(target) do + # put this task to sleep to allow the genserver to push out the messages + :timer.sleep(500) + else + {:error, e} -> shell_error("Error while following #{target}: #{inspect(e)}") + end + end + + def run(["unfollow", target | rest]) do + start_pleroma() + + {options, [], []} = + OptionParser.parse( + rest, + strict: [force: :boolean], + aliases: [f: :force] + ) + + force = Keyword.get(options, :force, false) + + with {:ok, _activity} <- Relay.unfollow(target, %{force: force}) do + # put this task to sleep to allow the genserver to push out the messages + :timer.sleep(500) + else + {:error, e} -> shell_error("Error while following #{target}: #{inspect(e)}") + end + end + + def run(["list"]) do + start_pleroma() + + with {:ok, list} <- Relay.list() do + Enum.each(list, &print_relay_url/1) + else + {:error, e} -> shell_error("Error while fetching relay subscription list: #{inspect(e)}") + end + end + + defp print_relay_url(%{followed_back: false} = relay) do + shell_info("#{relay.actor} - no Accept received (relay didn't follow back)") + end + + defp print_relay_url(relay), do: shell_info(relay.actor) +end diff --git a/lib/mix/tasks/pleroma/robots_txt.ex b/lib/mix/tasks/pleroma/robots_txt.ex new file mode 100644 index 0000000..5124c7c --- /dev/null +++ b/lib/mix/tasks/pleroma/robots_txt.ex @@ -0,0 +1,33 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.RobotsTxt do + use Mix.Task + + @shortdoc "Generate robots.txt" + @moduledoc """ + Generates robots.txt + + ## Overwrite robots.txt to disallow all + + mix pleroma.robots_txt disallow_all + + This will write a robots.txt that will hide all paths on your instance + from search engines and other robots that obey robots.txt + + """ + def run(["disallow_all"]) do + Mix.Pleroma.start_pleroma() + static_dir = Pleroma.Config.get([:instance, :static_dir], "instance/static/") + + if !File.exists?(static_dir) do + File.mkdir_p!(static_dir) + end + + robots_txt_path = Path.join(static_dir, "robots.txt") + robots_txt_content = "User-Agent: *\nDisallow: /\n" + + File.write!(robots_txt_path, robots_txt_content, [:write]) + end +end diff --git a/lib/mix/tasks/pleroma/uploads.ex b/lib/mix/tasks/pleroma/uploads.ex new file mode 100644 index 0000000..bf02912 --- /dev/null +++ b/lib/mix/tasks/pleroma/uploads.ex @@ -0,0 +1,100 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Uploads do + use Mix.Task + import Mix.Pleroma + alias Pleroma.Upload + alias Pleroma.Uploaders.Local + require Logger + + @log_every 50 + + @shortdoc "Migrates uploads from local to remote storage" + @moduledoc File.read!("docs/administration/CLI_tasks/uploads.md") + + def run(["migrate_local", target_uploader | args]) do + delete? = Enum.member?(args, "--delete") + start_pleroma() + local_path = Pleroma.Config.get!([Local, :uploads]) + uploader = Module.concat(Pleroma.Uploaders, target_uploader) + + unless Code.ensure_loaded?(uploader) do + raise("The uploader #{inspect(uploader)} is not an existing/loaded module.") + end + + target_enabled? = Pleroma.Config.get([Upload, :uploader]) == uploader + + unless target_enabled? do + Pleroma.Config.put([Upload, :uploader], uploader) + end + + shell_info("Migrating files from local #{local_path} to #{to_string(uploader)}") + + if delete? do + shell_info( + "Attention: uploaded files will be deleted, hope you have backups! (--delete ; cancel with ^C)" + ) + + :timer.sleep(:timer.seconds(5)) + end + + uploads = + File.ls!(local_path) + |> Enum.map(fn id -> + root_path = Path.join(local_path, id) + + cond do + File.dir?(root_path) -> + files = for file <- File.ls!(root_path), do: {id, file, Path.join([root_path, file])} + + case List.first(files) do + {id, file, path} -> + {%Pleroma.Upload{id: id, name: file, path: id <> "/" <> file, tempfile: path}, + root_path} + + _ -> + nil + end + + File.exists?(root_path) -> + file = Path.basename(id) + hash = Path.rootname(id) + {%Pleroma.Upload{id: hash, name: file, path: file, tempfile: root_path}, root_path} + + true -> + nil + end + end) + |> Enum.filter(& &1) + + total_count = length(uploads) + shell_info("Found #{total_count} uploads") + + uploads + |> Task.async_stream( + fn {upload, root_path} -> + case Upload.store(upload, uploader: uploader, filters: [], size_limit: nil) do + {:ok, _} -> + if delete?, do: File.rm_rf!(root_path) + Logger.debug("uploaded: #{inspect(upload.path)} #{inspect(upload)}") + :ok + + error -> + shell_error("failed to upload #{inspect(upload.path)}: #{inspect(error)}") + end + end, + timeout: 150_000 + ) + |> Stream.chunk_every(@log_every) + # credo:disable-for-next-line Credo.Check.Warning.UnusedEnumOperation + |> Enum.reduce(0, fn done, count -> + count = count + length(done) + shell_info("Uploaded #{count}/#{total_count} files") + count + end) + + shell_info("Done!") + end +end diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex new file mode 100644 index 0000000..929fa17 --- /dev/null +++ b/lib/mix/tasks/pleroma/user.ex @@ -0,0 +1,490 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.User do + use Mix.Task + import Mix.Pleroma + alias Ecto.Changeset + alias Pleroma.User + alias Pleroma.UserInviteToken + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Pipeline + + @shortdoc "Manages Pleroma users" + @moduledoc File.read!("docs/administration/CLI_tasks/user.md") + + def run(["new", nickname, email | rest]) do + {options, [], []} = + OptionParser.parse( + rest, + strict: [ + name: :string, + bio: :string, + password: :string, + moderator: :boolean, + admin: :boolean, + assume_yes: :boolean + ], + aliases: [ + y: :assume_yes + ] + ) + + name = Keyword.get(options, :name, nickname) + bio = Keyword.get(options, :bio, "") + + {password, generated_password?} = + case Keyword.get(options, :password) do + nil -> + {:crypto.strong_rand_bytes(16) |> Base.encode64(), true} + + password -> + {password, false} + end + + moderator? = Keyword.get(options, :moderator, false) + admin? = Keyword.get(options, :admin, false) + assume_yes? = Keyword.get(options, :assume_yes, false) + + shell_info(""" + A user will be created with the following information: + - nickname: #{nickname} + - email: #{email} + - password: #{if(generated_password?, do: "[generated; a reset link will be created]", else: password)} + - name: #{name} + - bio: #{bio} + - moderator: #{if(moderator?, do: "true", else: "false")} + - admin: #{if(admin?, do: "true", else: "false")} + """) + + proceed? = assume_yes? or shell_prompt("Continue?", "n") in ~w(Yn Y y) + + if proceed? do + start_pleroma() + + params = %{ + nickname: nickname, + email: email, + password: password, + password_confirmation: password, + name: name, + bio: bio + } + + changeset = User.register_changeset(%User{}, params, is_confirmed: true) + {:ok, _user} = User.register(changeset) + + shell_info("User #{nickname} created") + + if moderator? do + run(["set", nickname, "--moderator"]) + end + + if admin? do + run(["set", nickname, "--admin"]) + end + + if generated_password? do + run(["reset_password", nickname]) + end + else + shell_info("User will not be created.") + end + end + + def run(["rm", nickname]) do + start_pleroma() + + with %User{local: true} = user <- User.get_cached_by_nickname(nickname), + {:ok, delete_data, _} <- Builder.delete(user, user.ap_id), + {:ok, _delete, _} <- Pipeline.common_pipeline(delete_data, local: true) do + shell_info("User #{nickname} deleted.") + else + _ -> shell_error("No local user #{nickname}") + end + end + + def run(["reset_password", nickname]) do + start_pleroma() + + with %User{local: true} = user <- User.get_cached_by_nickname(nickname), + {:ok, token} <- Pleroma.PasswordResetToken.create_token(user) do + shell_info("Generated password reset token for #{user.nickname}") + + url = + Pleroma.Web.Router.Helpers.reset_password_url(Pleroma.Web.Endpoint, :reset, token.token) + + IO.puts("URL: #{url}") + else + _ -> + shell_error("No local user #{nickname}") + end + end + + def run(["reset_mfa", nickname]) do + start_pleroma() + + with %User{local: true} = user <- User.get_cached_by_nickname(nickname), + {:ok, _token} <- Pleroma.MFA.disable(user) do + shell_info("Multi-Factor Authentication disabled for #{user.nickname}") + else + _ -> + shell_error("No local user #{nickname}") + end + end + + def run(["activate", nickname]) do + start_pleroma() + + with %User{} = user <- User.get_cached_by_nickname(nickname), + false <- user.is_active do + User.set_activation(user, true) + :timer.sleep(500) + + shell_info("Successfully activated #{nickname}") + else + true -> + shell_info("User #{nickname} already activated") + + _ -> + shell_error("No user #{nickname}") + end + end + + def run(["deactivate", nickname]) do + start_pleroma() + + with %User{} = user <- User.get_cached_by_nickname(nickname), + true <- user.is_active do + User.set_activation(user, false) + :timer.sleep(500) + + user = User.get_cached_by_id(user.id) + + if Enum.empty?(Enum.filter(User.get_friends(user), & &1.local)) do + shell_info("Successfully deactivated #{nickname} and unsubscribed all local followers") + end + else + false -> + shell_info("User #{nickname} already deactivated") + + _ -> + shell_error("No user #{nickname}") + end + end + + def run(["deactivate_all_from_instance", instance]) do + start_pleroma() + + Pleroma.User.Query.build(%{nickname: "@#{instance}"}) + |> Pleroma.Repo.chunk_stream(500, :batches) + |> Stream.each(fn users -> + users + |> Enum.each(fn user -> + run(["deactivate", user.nickname]) + end) + end) + |> Stream.run() + end + + def run(["set", nickname | rest]) do + start_pleroma() + + {options, [], []} = + OptionParser.parse( + rest, + strict: [ + admin: :boolean, + confirmed: :boolean, + locked: :boolean, + moderator: :boolean + ] + ) + + with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do + user = + case Keyword.get(options, :admin) do + nil -> user + value -> set_admin(user, value) + end + + user = + case Keyword.get(options, :confirmed) do + nil -> user + value -> set_confirmation(user, value) + end + + user = + case Keyword.get(options, :locked) do + nil -> user + value -> set_locked(user, value) + end + + _user = + case Keyword.get(options, :moderator) do + nil -> user + value -> set_moderator(user, value) + end + else + _ -> + shell_error("No local user #{nickname}") + end + end + + def run(["tag", nickname | tags]) do + start_pleroma() + + with %User{} = user <- User.get_cached_by_nickname(nickname) do + user = user |> User.tag(tags) + + shell_info("Tags of #{user.nickname}: #{inspect(user.tags)}") + else + _ -> + shell_error("Could not change user tags for #{nickname}") + end + end + + def run(["untag", nickname | tags]) do + start_pleroma() + + with %User{} = user <- User.get_cached_by_nickname(nickname) do + user = user |> User.untag(tags) + + shell_info("Tags of #{user.nickname}: #{inspect(user.tags)}") + else + _ -> + shell_error("Could not change user tags for #{nickname}") + end + end + + def run(["invite" | rest]) do + {options, [], []} = + OptionParser.parse(rest, + strict: [ + expires_at: :string, + max_use: :integer + ] + ) + + options = + options + |> Keyword.update(:expires_at, {:ok, nil}, fn + nil -> {:ok, nil} + val -> Date.from_iso8601(val) + end) + |> Enum.into(%{}) + + start_pleroma() + + with {:ok, val} <- options[:expires_at], + options = Map.put(options, :expires_at, val), + {:ok, invite} <- UserInviteToken.create_invite(options) do + shell_info("Generated user invite token " <> String.replace(invite.invite_type, "_", " ")) + + url = + Pleroma.Web.Router.Helpers.redirect_url( + Pleroma.Web.Endpoint, + :registration_page, + invite.token + ) + + IO.puts(url) + else + error -> + shell_error("Could not create invite token: #{inspect(error)}") + end + end + + def run(["invites"]) do + start_pleroma() + + shell_info("Invites list:") + + UserInviteToken.list_invites() + |> Enum.each(fn invite -> + expire_info = + with expires_at when not is_nil(expires_at) <- invite.expires_at do + " | Expires at: #{Date.to_string(expires_at)}" + end + + using_info = + with max_use when not is_nil(max_use) <- invite.max_use do + " | Max use: #{max_use} Left use: #{max_use - invite.uses}" + end + + shell_info( + "ID: #{invite.id} | Token: #{invite.token} | Token type: #{invite.invite_type} | Used: #{invite.used}#{expire_info}#{using_info}" + ) + end) + end + + def run(["revoke_invite", token]) do + start_pleroma() + + with {:ok, invite} <- UserInviteToken.find_by_token(token), + {:ok, _} <- UserInviteToken.update_invite(invite, %{used: true}) do + shell_info("Invite for token #{token} was revoked.") + else + _ -> shell_error("No invite found with token #{token}") + end + end + + def run(["delete_activities", nickname]) do + start_pleroma() + + with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do + User.delete_user_activities(user) + shell_info("User #{nickname} statuses deleted.") + else + _ -> + shell_error("No local user #{nickname}") + end + end + + def run(["confirm", nickname]) do + start_pleroma() + + with %User{} = user <- User.get_cached_by_nickname(nickname) do + {:ok, user} = User.confirm(user) + + message = if !user.is_confirmed, do: "needs", else: "doesn't need" + + shell_info("#{nickname} #{message} confirmation.") + else + _ -> + shell_error("No local user #{nickname}") + end + end + + def run(["confirm_all"]) do + start_pleroma() + + Pleroma.User.Query.build(%{ + local: true, + is_active: true, + is_moderator: false, + is_admin: false, + invisible: false + }) + |> Pleroma.Repo.chunk_stream(500, :batches) + |> Stream.each(fn users -> + users + |> Enum.each(fn user -> User.set_confirmation(user, true) end) + end) + |> Stream.run() + end + + def run(["unconfirm_all"]) do + start_pleroma() + + Pleroma.User.Query.build(%{ + local: true, + is_active: true, + is_moderator: false, + is_admin: false, + invisible: false + }) + |> Pleroma.Repo.chunk_stream(500, :batches) + |> Stream.each(fn users -> + users + |> Enum.each(fn user -> User.set_confirmation(user, false) end) + end) + |> Stream.run() + end + + def run(["sign_out", nickname]) do + start_pleroma() + + with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do + User.global_sign_out(user) + + shell_info("#{nickname} signed out from all apps.") + else + _ -> + shell_error("No local user #{nickname}") + end + end + + def run(["list"]) do + start_pleroma() + + Pleroma.User.Query.build(%{local: true}) + |> Pleroma.Repo.chunk_stream(500, :batches) + |> Stream.each(fn users -> + users + |> Enum.each(fn user -> + shell_info( + "#{user.nickname} moderator: #{user.is_moderator}, admin: #{user.is_admin}, locked: #{user.is_locked}, is_active: #{user.is_active}" + ) + end) + end) + |> Stream.run() + end + + def run(["fix_follow_state", local_user, remote_user]) do + start_pleroma() + + with {:local, %User{} = local} <- {:local, User.get_by_nickname(local_user)}, + {:remote, %User{} = remote} <- {:remote, User.get_by_nickname(remote_user)}, + {:follow_data, %{data: %{"state" => request_state}}} <- + {:follow_data, Pleroma.Web.ActivityPub.Utils.fetch_latest_follow(local, remote)} do + calculated_state = User.following?(local, remote) + + shell_info( + "Request state is #{request_state}, vs calculated state of following=#{calculated_state}" + ) + + if calculated_state == false && request_state == "accept" do + shell_info("Discrepancy found, fixing") + Pleroma.Web.CommonAPI.reject_follow_request(local, remote) + shell_info("Relationship fixed") + else + shell_info("No discrepancy found") + end + else + {:local, _} -> + shell_error("No local user #{local_user}") + + {:remote, _} -> + shell_error("No remote user #{remote_user}") + + {:follow_data, _} -> + shell_error("No follow data for #{local_user} and #{remote_user}") + end + end + + defp set_moderator(user, value) do + {:ok, user} = + user + |> Changeset.change(%{is_moderator: value}) + |> User.update_and_set_cache() + + shell_info("Moderator status of #{user.nickname}: #{user.is_moderator}") + user + end + + defp set_admin(user, value) do + {:ok, user} = User.admin_api_update(user, %{is_admin: value}) + + shell_info("Admin status of #{user.nickname}: #{user.is_admin}") + user + end + + defp set_locked(user, value) do + {:ok, user} = + user + |> Changeset.change(%{is_locked: value}) + |> User.update_and_set_cache() + + shell_info("Locked status of #{user.nickname}: #{user.is_locked}") + user + end + + defp set_confirmation(user, value) do + {:ok, user} = User.set_confirmation(user, value) + + shell_info("Confirmation status of #{user.nickname}: #{user.is_confirmed}") + user + end +end -- cgit v1.2.3