Merge branch 'refactor/mix-tasks' into 'develop'
Rebased !243 See merge request pleroma/pleroma!509
This commit is contained in:
commit
220de24002
|
@ -37,7 +37,7 @@ While we don't provide docker files, other people have written very good ones. T
|
||||||
|
|
||||||
* Run `mix deps.get` to install elixir dependencies.
|
* Run `mix deps.get` to install elixir dependencies.
|
||||||
|
|
||||||
* Run `mix generate_config`. This will ask you a few questions about your instance and generate a configuration file in `config/generated_config.exs`. Check that and copy it to either `config/dev.secret.exs` or `config/prod.secret.exs`. It will also create a `config/setup_db.psql`; you may want to double-check this file in case you wanted a different username, or database name than the default. Then you need to run the script as PostgreSQL superuser (i.e. `sudo su postgres -c "psql -f config/setup_db.psql"`). It will create a pleroma db user, database and will setup needed extensions that need to be set up. Postgresql super-user privileges are only needed for this step.
|
* Run `mix pleroma.instance gen`. This will ask you questions about your instance and generate a configuration file in `config/generated_config.exs`. Check that and copy it to either `config/dev.secret.exs` or `config/prod.secret.exs`. It will also create a `config/setup_db.psql`, which you should run as the PostgreSQL superuser (i.e., `sudo -u postgres psql -f config/setup_db.psql`). It will create the database, user, and password you gave `mix pleroma.gen.instance` earlier, as well as set up the necessary extensions in the database. PostgreSQL superuser privileges are only needed for this step.
|
||||||
|
|
||||||
* For these next steps, the default will be to run pleroma using the dev configuration file, `config/dev.secret.exs`. To run them using the prod config file, prefix each command at the shell with `MIX_ENV=prod`. For example: `MIX_ENV=prod mix phx.server`. Documentation for the config can be found at [``config/config.md``](config/config.md)
|
* For these next steps, the default will be to run pleroma using the dev configuration file, `config/dev.secret.exs`. To run them using the prod config file, prefix each command at the shell with `MIX_ENV=prod`. For example: `MIX_ENV=prod mix phx.server`. Documentation for the config can be found at [``config/config.md``](config/config.md)
|
||||||
|
|
||||||
|
@ -45,8 +45,7 @@ While we don't provide docker files, other people have written very good ones. T
|
||||||
|
|
||||||
* You can check if your instance is configured correctly by running it with `mix phx.server` and checking the instance info endpoint at `/api/v1/instance`. If it shows your uri, name and email correctly, you are configured correctly. If it shows something like `localhost:4000`, your configuration is probably wrong, unless you are running a local development setup.
|
* You can check if your instance is configured correctly by running it with `mix phx.server` and checking the instance info endpoint at `/api/v1/instance`. If it shows your uri, name and email correctly, you are configured correctly. If it shows something like `localhost:4000`, your configuration is probably wrong, unless you are running a local development setup.
|
||||||
|
|
||||||
* The common and convenient way for adding HTTPS is by using Nginx as a reverse proxy. You can look at example Nginx configuration in `installation/pleroma.nginx`. If you need TLS/SSL certificates for HTTPS, you can look get some for free with letsencrypt: https://letsencrypt.org/
|
* The common and convenient way for adding HTTPS is by using Nginx as a reverse proxy. You can look at example Nginx configuration in `installation/pleroma.nginx`. If you need TLS/SSL certificates for HTTPS, you can look get some for free with letsencrypt: <https://letsencrypt.org/>. The simplest way to obtain and install a certificate is to use [Certbot.](https://certbot.eff.org) Depending on your specific setup, certbot may be able to get a certificate and configure your web server automatically.
|
||||||
The simplest way to obtain and install a certificate is to use [Certbot.](https://certbot.eff.org) Depending on your specific setup, certbot may be able to get a certificate and configure your web server automatically.
|
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
|
|
|
@ -1,19 +0,0 @@
|
||||||
defmodule Mix.Tasks.DeactivateUser do
|
|
||||||
use Mix.Task
|
|
||||||
alias Pleroma.User
|
|
||||||
|
|
||||||
@moduledoc """
|
|
||||||
Deactivates a user (local or remote)
|
|
||||||
|
|
||||||
Usage: ``mix deactivate_user <nickname>``
|
|
||||||
|
|
||||||
Example: ``mix deactivate_user lain``
|
|
||||||
"""
|
|
||||||
def run([nickname]) do
|
|
||||||
Mix.Task.run("app.start")
|
|
||||||
|
|
||||||
with user <- User.get_by_nickname(nickname) do
|
|
||||||
User.deactivate(user)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,51 +0,0 @@
|
||||||
defmodule Mix.Tasks.GenerateConfig do
|
|
||||||
use Mix.Task
|
|
||||||
|
|
||||||
@moduledoc """
|
|
||||||
Generate a new config
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
``mix generate_config``
|
|
||||||
|
|
||||||
This mix task is interactive, and will overwrite the config present at ``config/generated_config.exs``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def run(_) do
|
|
||||||
IO.puts("Answer a few questions to generate a new config\n")
|
|
||||||
IO.puts("--- THIS WILL OVERWRITE YOUR config/generated_config.exs! ---\n")
|
|
||||||
domain = IO.gets("What is your domain name? (e.g. pleroma.soykaf.com): ") |> String.trim()
|
|
||||||
name = IO.gets("What is the name of your instance? (e.g. Pleroma/Soykaf): ") |> String.trim()
|
|
||||||
email = IO.gets("What's your admin email address: ") |> String.trim()
|
|
||||||
|
|
||||||
secret = :crypto.strong_rand_bytes(64) |> Base.encode64() |> binary_part(0, 64)
|
|
||||||
dbpass = :crypto.strong_rand_bytes(64) |> Base.encode64() |> binary_part(0, 64)
|
|
||||||
|
|
||||||
resultSql = EEx.eval_file("lib/mix/tasks/sample_psql.eex", dbpass: dbpass)
|
|
||||||
|
|
||||||
{web_push_public_key, web_push_private_key} = :crypto.generate_key(:ecdh, :prime256v1)
|
|
||||||
|
|
||||||
result =
|
|
||||||
EEx.eval_file(
|
|
||||||
"lib/mix/tasks/sample_config.eex",
|
|
||||||
domain: domain,
|
|
||||||
email: email,
|
|
||||||
name: name,
|
|
||||||
secret: secret,
|
|
||||||
dbpass: dbpass,
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
|
|
||||||
IO.puts(
|
|
||||||
"\nWriting config to config/generated_config.exs.\n\nCheck it and configure your database, then copy it to either config/dev.secret.exs or config/prod.secret.exs"
|
|
||||||
)
|
|
||||||
|
|
||||||
File.write("config/generated_config.exs", result)
|
|
||||||
|
|
||||||
IO.puts(
|
|
||||||
"\nWriting setup_db.psql, please run it as postgre superuser, i.e.: sudo su postgres -c 'psql -f config/setup_db.psql'"
|
|
||||||
)
|
|
||||||
|
|
||||||
File.write("config/setup_db.psql", resultSql)
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,32 +0,0 @@
|
||||||
defmodule Mix.Tasks.GenerateInviteToken do
|
|
||||||
use Mix.Task
|
|
||||||
|
|
||||||
@moduledoc """
|
|
||||||
Generates invite token
|
|
||||||
|
|
||||||
This is in the form of a URL to be used by the Invited user to register themselves.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
``mix generate_invite_token``
|
|
||||||
"""
|
|
||||||
def run([]) do
|
|
||||||
Mix.Task.run("app.start")
|
|
||||||
|
|
||||||
with {:ok, token} <- Pleroma.UserInviteToken.create_token() do
|
|
||||||
IO.puts("Generated user invite token")
|
|
||||||
|
|
||||||
IO.puts(
|
|
||||||
"Url: #{
|
|
||||||
Pleroma.Web.Router.Helpers.redirect_url(
|
|
||||||
Pleroma.Web.Endpoint,
|
|
||||||
:registration_page,
|
|
||||||
token.token
|
|
||||||
)
|
|
||||||
}"
|
|
||||||
)
|
|
||||||
else
|
|
||||||
_ ->
|
|
||||||
IO.puts("Error creating token")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,33 +0,0 @@
|
||||||
defmodule Mix.Tasks.GeneratePasswordReset do
|
|
||||||
use Mix.Task
|
|
||||||
alias Pleroma.User
|
|
||||||
|
|
||||||
@moduledoc """
|
|
||||||
Generate password reset link for user
|
|
||||||
|
|
||||||
Usage: ``mix generate_password_reset <nickname>``
|
|
||||||
|
|
||||||
Example: ``mix generate_password_reset lain``
|
|
||||||
"""
|
|
||||||
def run([nickname]) do
|
|
||||||
Mix.Task.run("app.start")
|
|
||||||
|
|
||||||
with %User{local: true} = user <- User.get_by_nickname(nickname),
|
|
||||||
{:ok, token} <- Pleroma.PasswordResetToken.create_token(user) do
|
|
||||||
IO.puts("Generated password reset token for #{user.nickname}")
|
|
||||||
|
|
||||||
IO.puts(
|
|
||||||
"Url: #{
|
|
||||||
Pleroma.Web.Router.Helpers.util_url(
|
|
||||||
Pleroma.Web.Endpoint,
|
|
||||||
:show_password_reset,
|
|
||||||
token.token
|
|
||||||
)
|
|
||||||
}"
|
|
||||||
)
|
|
||||||
else
|
|
||||||
_ ->
|
|
||||||
IO.puts("No local user #{nickname}")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,38 +0,0 @@
|
||||||
defmodule Mix.Tasks.SetModerator do
|
|
||||||
@moduledoc """
|
|
||||||
Set moderator to a local user
|
|
||||||
|
|
||||||
Usage: ``mix set_moderator <nickname>``
|
|
||||||
|
|
||||||
Example: ``mix set_moderator lain``
|
|
||||||
"""
|
|
||||||
|
|
||||||
use Mix.Task
|
|
||||||
import Ecto.Changeset
|
|
||||||
alias Pleroma.{Repo, User}
|
|
||||||
|
|
||||||
def run([nickname | rest]) do
|
|
||||||
Application.ensure_all_started(:pleroma)
|
|
||||||
|
|
||||||
moderator =
|
|
||||||
case rest do
|
|
||||||
[moderator] -> moderator == "true"
|
|
||||||
_ -> true
|
|
||||||
end
|
|
||||||
|
|
||||||
with %User{local: true} = user <- User.get_by_nickname(nickname) do
|
|
||||||
info_cng = User.Info.admin_api_update(user.info, %{is_moderator: !!moderator})
|
|
||||||
|
|
||||||
user_cng =
|
|
||||||
Ecto.Changeset.change(user)
|
|
||||||
|> put_embed(:info, info_cng)
|
|
||||||
|
|
||||||
{:ok, user} = User.update_and_set_cache(user_cng)
|
|
||||||
|
|
||||||
IO.puts("Moderator status of #{nickname}: #{user.info.is_moderator}")
|
|
||||||
else
|
|
||||||
_ ->
|
|
||||||
IO.puts("No local user #{nickname}")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,97 +0,0 @@
|
||||||
defmodule Mix.Tasks.MigrateLocalUploads do
|
|
||||||
use Mix.Task
|
|
||||||
import Mix.Ecto
|
|
||||||
alias Pleroma.{Upload, Uploaders.Local, Uploaders.S3}
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
@log_every 50
|
|
||||||
@shortdoc "Migrate uploads from local to remote storage"
|
|
||||||
|
|
||||||
def run([target_uploader | args]) do
|
|
||||||
delete? = Enum.member?(args, "--delete")
|
|
||||||
Application.ensure_all_started(: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
|
|
||||||
|
|
||||||
Logger.info("Migrating files from local #{local_path} to #{to_string(uploader)}")
|
|
||||||
|
|
||||||
if delete? do
|
|
||||||
Logger.warn(
|
|
||||||
"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, ext] = String.split(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)
|
|
||||||
Logger.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 ->
|
|
||||||
Logger.error("failed to upload #{inspect(upload.path)}: #{inspect(error)}")
|
|
||||||
end
|
|
||||||
end,
|
|
||||||
timeout: 150_000
|
|
||||||
)
|
|
||||||
|> Stream.chunk_every(@log_every)
|
|
||||||
|> Enum.reduce(0, fn done, count ->
|
|
||||||
count = count + length(done)
|
|
||||||
Logger.info("Uploaded #{count}/#{total_count} files")
|
|
||||||
count
|
|
||||||
end)
|
|
||||||
|
|
||||||
Logger.info("Done!")
|
|
||||||
end
|
|
||||||
|
|
||||||
def run(_) do
|
|
||||||
Logger.error("Usage: migrate_local_uploads S3|Swift [--delete]")
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -0,0 +1,157 @@
|
||||||
|
defmodule Mix.Tasks.Pleroma.Instance do
|
||||||
|
use Mix.Task
|
||||||
|
alias Pleroma.{Repo, User}
|
||||||
|
alias Mix.Tasks.Pleroma.Common
|
||||||
|
|
||||||
|
@shortdoc "Manages Pleroma instance"
|
||||||
|
@moduledoc """
|
||||||
|
Manages Pleroma instance.
|
||||||
|
|
||||||
|
## Generate a new instance config.
|
||||||
|
|
||||||
|
mix pleroma.instance gen [OPTION...]
|
||||||
|
|
||||||
|
If any options are left unspecified, you will be prompted interactively
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
- `-f`, `--force` - overwrite any output files
|
||||||
|
- `-o PATH`, `--output PATH` - the output file for the generated configuration
|
||||||
|
- `--output-psql PATH` - the output file for the generated PostgreSQL setup
|
||||||
|
- `--domain DOMAIN` - the domain of your instance
|
||||||
|
- `--instance-name INSTANCE_NAME` - the name of your instance
|
||||||
|
- `--admin-email ADMIN_EMAIL` - the email address of the instance admin
|
||||||
|
- `--dbhost HOSTNAME` - the hostname of the PostgreSQL database to use
|
||||||
|
- `--dbname DBNAME` - the name of the database to use
|
||||||
|
- `--dbuser DBUSER` - the user (aka role) to use for the database connection
|
||||||
|
- `--dbpass DBPASS` - the password to use for the database connection
|
||||||
|
"""
|
||||||
|
|
||||||
|
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,
|
||||||
|
dbhost: :string,
|
||||||
|
dbname: :string,
|
||||||
|
dbuser: :string,
|
||||||
|
dbpass: :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)
|
||||||
|
|
||||||
|
unless not proceed? do
|
||||||
|
domain =
|
||||||
|
Common.get_option(
|
||||||
|
options,
|
||||||
|
:domain,
|
||||||
|
"What domain will your instance use? (e.g pleroma.soykaf.com)"
|
||||||
|
)
|
||||||
|
|
||||||
|
name =
|
||||||
|
Common.get_option(
|
||||||
|
options,
|
||||||
|
:name,
|
||||||
|
"What is the name of your instance? (e.g. Pleroma/Soykaf)"
|
||||||
|
)
|
||||||
|
|
||||||
|
email = Common.get_option(options, :admin_email, "What is your admin email address?")
|
||||||
|
|
||||||
|
dbhost =
|
||||||
|
Common.get_option(options, :dbhost, "What is the hostname of your database?", "localhost")
|
||||||
|
|
||||||
|
dbname =
|
||||||
|
Common.get_option(options, :dbname, "What is the name of your database?", "pleroma_dev")
|
||||||
|
|
||||||
|
dbuser =
|
||||||
|
Common.get_option(
|
||||||
|
options,
|
||||||
|
:dbuser,
|
||||||
|
"What is the user used to connect to your database?",
|
||||||
|
"pleroma"
|
||||||
|
)
|
||||||
|
|
||||||
|
dbpass =
|
||||||
|
Common.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"
|
||||||
|
)
|
||||||
|
|
||||||
|
secret = :crypto.strong_rand_bytes(64) |> Base.encode64() |> binary_part(0, 64)
|
||||||
|
{web_push_public_key, web_push_private_key} = :crypto.generate_key(:ecdh, :prime256v1)
|
||||||
|
|
||||||
|
result_config =
|
||||||
|
EEx.eval_file(
|
||||||
|
"sample_config.eex" |> Path.expand(__DIR__),
|
||||||
|
domain: domain,
|
||||||
|
email: email,
|
||||||
|
name: name,
|
||||||
|
dbhost: dbhost,
|
||||||
|
dbname: dbname,
|
||||||
|
dbuser: dbuser,
|
||||||
|
dbpass: dbpass,
|
||||||
|
version: Pleroma.Mixfile.project() |> Keyword.get(:version),
|
||||||
|
secret: secret,
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
|
||||||
|
result_psql =
|
||||||
|
EEx.eval_file(
|
||||||
|
"sample_psql.eex" |> Path.expand(__DIR__),
|
||||||
|
dbname: dbname,
|
||||||
|
dbuser: dbuser,
|
||||||
|
dbpass: dbpass
|
||||||
|
)
|
||||||
|
|
||||||
|
Mix.shell().info(
|
||||||
|
"Writing config to #{config_path}. You should rename it to config/prod.secret.exs or config/dev.secret.exs."
|
||||||
|
)
|
||||||
|
|
||||||
|
File.write(config_path, result_config)
|
||||||
|
Mix.shell().info("Writing #{psql_path}.")
|
||||||
|
File.write(psql_path, result_psql)
|
||||||
|
|
||||||
|
Mix.shell().info(
|
||||||
|
"\n" <>
|
||||||
|
"""
|
||||||
|
To get started:
|
||||||
|
1. Verify the contents of the generated files.
|
||||||
|
2. Run `sudo -u postgres psql -f #{Common.escape_sh_path(psql_path)}`.
|
||||||
|
""" <>
|
||||||
|
if config_path in ["config/dev.secret.exs", "config/prod.secret.exs"] do
|
||||||
|
""
|
||||||
|
else
|
||||||
|
"3. Run `mv #{Common.escape_sh_path(config_path)} 'config/prod.secret.exs'`."
|
||||||
|
end
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Mix.shell().error(
|
||||||
|
"The task would have overwritten the following files:\n" <>
|
||||||
|
(Enum.map(paths, &"- #{&1}\n") |> Enum.join("")) <>
|
||||||
|
"Rerun with `--force` to overwrite them."
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,43 @@
|
||||||
|
defmodule Mix.Tasks.Pleroma.Relay do
|
||||||
|
use Mix.Task
|
||||||
|
alias Pleroma.Web.ActivityPub.Relay
|
||||||
|
alias Mix.Tasks.Pleroma.Common
|
||||||
|
|
||||||
|
@shortdoc "Manages remote relays"
|
||||||
|
@moduledoc """
|
||||||
|
Manages remote relays
|
||||||
|
|
||||||
|
## Follow a remote relay
|
||||||
|
|
||||||
|
``mix pleroma.relay unfollow <relay_url>``
|
||||||
|
|
||||||
|
Example: ``mix pleroma.relay follow https://example.org/relay``
|
||||||
|
|
||||||
|
## Unfollow a remote relay
|
||||||
|
|
||||||
|
``mix pleroma.relay unfollow <relay_url>``
|
||||||
|
|
||||||
|
Example: ``mix pleroma.relay unfollow https://example.org/relay``
|
||||||
|
"""
|
||||||
|
def run(["follow", target]) do
|
||||||
|
Common.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} -> Mix.shell().error("Error while following #{target}: #{inspect(e)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def run(["unfollow", target]) do
|
||||||
|
Common.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} -> Mix.shell().error("Error while following #{target}: #{inspect(e)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,3 +1,8 @@
|
||||||
|
# Pleroma instance configuration
|
||||||
|
|
||||||
|
# NOTE: This file should not be committed to a repo or otherwise made public
|
||||||
|
# without removing sensitive information.
|
||||||
|
|
||||||
use Mix.Config
|
use Mix.Config
|
||||||
|
|
||||||
config :pleroma, Pleroma.Web.Endpoint,
|
config :pleroma, Pleroma.Web.Endpoint,
|
||||||
|
@ -16,13 +21,12 @@ config :pleroma, :media_proxy,
|
||||||
redirect_on_failure: true
|
redirect_on_failure: true
|
||||||
#base_url: "https://cache.pleroma.social"
|
#base_url: "https://cache.pleroma.social"
|
||||||
|
|
||||||
# Configure your database
|
|
||||||
config :pleroma, Pleroma.Repo,
|
config :pleroma, Pleroma.Repo,
|
||||||
adapter: Ecto.Adapters.Postgres,
|
adapter: Ecto.Adapters.Postgres,
|
||||||
username: "pleroma",
|
username: "<%= dbuser %>",
|
||||||
password: "<%= dbpass %>",
|
password: "<%= dbpass %>",
|
||||||
database: "pleroma_dev",
|
database: "<%= dbname %>",
|
||||||
hostname: "localhost",
|
hostname: "<%= dbhost %>",
|
||||||
pool_size: 10
|
pool_size: 10
|
||||||
|
|
||||||
# Configure web push notifications
|
# Configure web push notifications
|
|
@ -0,0 +1,289 @@
|
||||||
|
defmodule Mix.Tasks.Pleroma.User do
|
||||||
|
use Mix.Task
|
||||||
|
import Ecto.Changeset
|
||||||
|
alias Pleroma.{Repo, User}
|
||||||
|
alias Mix.Tasks.Pleroma.Common
|
||||||
|
|
||||||
|
@shortdoc "Manages Pleroma users"
|
||||||
|
@moduledoc """
|
||||||
|
Manages Pleroma users.
|
||||||
|
|
||||||
|
## Create a new user.
|
||||||
|
|
||||||
|
mix pleroma.user new NICKNAME EMAIL [OPTION...]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--name NAME` - the user's name (i.e., "Lain Iwakura")
|
||||||
|
- `--bio BIO` - the user's bio
|
||||||
|
- `--password PASSWORD` - the user's password
|
||||||
|
- `--moderator`/`--no-moderator` - whether the user is a moderator
|
||||||
|
- `--admin`/`--no-admin` - whether the user is an admin
|
||||||
|
|
||||||
|
## Generate an invite link.
|
||||||
|
|
||||||
|
mix pleroma.user invite
|
||||||
|
|
||||||
|
## Delete the user's account.
|
||||||
|
|
||||||
|
mix pleroma.user rm NICKNAME
|
||||||
|
|
||||||
|
## Deactivate or activate the user's account.
|
||||||
|
|
||||||
|
mix pleroma.user toggle_activated NICKNAME
|
||||||
|
|
||||||
|
## Create a password reset link.
|
||||||
|
|
||||||
|
mix pleroma.user reset_password NICKNAME
|
||||||
|
|
||||||
|
## Set the value of the given user's settings.
|
||||||
|
|
||||||
|
mix pleroma.user set NICKNAME [OPTION...]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--locked`/`--no-locked` - whether the user's account is locked
|
||||||
|
- `--moderator`/`--no-moderator` - whether the user is a moderator
|
||||||
|
- `--admin`/`--no-admin` - whether the user is an admin
|
||||||
|
"""
|
||||||
|
def run(["new", nickname, email | rest]) do
|
||||||
|
{options, [], []} =
|
||||||
|
OptionParser.parse(
|
||||||
|
rest,
|
||||||
|
strict: [
|
||||||
|
name: :string,
|
||||||
|
bio: :string,
|
||||||
|
password: :string,
|
||||||
|
moderator: :boolean,
|
||||||
|
admin: :boolean
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
Mix.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? = Mix.shell().yes?("Continue?")
|
||||||
|
|
||||||
|
unless not proceed? do
|
||||||
|
Common.start_pleroma()
|
||||||
|
|
||||||
|
params =
|
||||||
|
%{
|
||||||
|
nickname: nickname,
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
password_confirmation: password,
|
||||||
|
name: name,
|
||||||
|
bio: bio
|
||||||
|
}
|
||||||
|
|> IO.inspect()
|
||||||
|
|
||||||
|
user = User.register_changeset(%User{}, params)
|
||||||
|
Repo.insert!(user)
|
||||||
|
|
||||||
|
Mix.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
|
||||||
|
Mix.shell().info("User will not be created.")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def run(["rm", nickname]) do
|
||||||
|
Common.start_pleroma()
|
||||||
|
|
||||||
|
with %User{local: true} = user <- User.get_by_nickname(nickname) do
|
||||||
|
User.delete(user)
|
||||||
|
Mix.shell().info("User #{nickname} deleted.")
|
||||||
|
else
|
||||||
|
_ ->
|
||||||
|
Mix.shell().error("No local user #{nickname}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def run(["toggle_activated", nickname]) do
|
||||||
|
Common.start_pleroma()
|
||||||
|
|
||||||
|
with %User{} = user <- User.get_by_nickname(nickname) do
|
||||||
|
User.deactivate(user, !user.info["deactivated"])
|
||||||
|
Mix.shell().info("Activation status of #{nickname}: #{user.info["deactivated"]}")
|
||||||
|
else
|
||||||
|
_ ->
|
||||||
|
Mix.shell().error("No user #{nickname}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def run(["reset_password", nickname]) do
|
||||||
|
Common.start_pleroma()
|
||||||
|
|
||||||
|
with %User{local: true} = user <- User.get_by_nickname(nickname),
|
||||||
|
{:ok, token} <- Pleroma.PasswordResetToken.create_token(user) do
|
||||||
|
Mix.shell().info("Generated password reset token for #{user.nickname}")
|
||||||
|
|
||||||
|
IO.puts(
|
||||||
|
"URL: #{
|
||||||
|
Pleroma.Web.Router.Helpers.util_url(
|
||||||
|
Pleroma.Web.Endpoint,
|
||||||
|
:show_password_reset,
|
||||||
|
token.token
|
||||||
|
)
|
||||||
|
}"
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_ ->
|
||||||
|
Mix.shell().error("No local user #{nickname}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def run(["unsubscribe", nickname]) do
|
||||||
|
Common.start_pleroma()
|
||||||
|
|
||||||
|
with %User{} = user <- User.get_by_nickname(nickname) do
|
||||||
|
Mix.shell().info("Deactivating #{user.nickname}")
|
||||||
|
User.deactivate(user)
|
||||||
|
|
||||||
|
{:ok, friends} = User.get_friends(user)
|
||||||
|
|
||||||
|
Enum.each(friends, fn friend ->
|
||||||
|
user = Repo.get(User, user.id)
|
||||||
|
|
||||||
|
Mix.shell().info("Unsubscribing #{friend.nickname} from #{user.nickname}")
|
||||||
|
User.unfollow(user, friend)
|
||||||
|
end)
|
||||||
|
|
||||||
|
:timer.sleep(500)
|
||||||
|
|
||||||
|
user = Repo.get(User, user.id)
|
||||||
|
|
||||||
|
if length(user.following) == 0 do
|
||||||
|
Mix.shell().info("Successfully unsubscribed all followers from #{user.nickname}")
|
||||||
|
end
|
||||||
|
else
|
||||||
|
_ ->
|
||||||
|
Mix.shell().error("No user #{nickname}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def run(["set", nickname | rest]) do
|
||||||
|
Common.start_pleroma()
|
||||||
|
|
||||||
|
{options, [], []} =
|
||||||
|
OptionParser.parse(
|
||||||
|
rest,
|
||||||
|
strict: [
|
||||||
|
moderator: :boolean,
|
||||||
|
admin: :boolean,
|
||||||
|
locked: :boolean
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
with %User{local: true} = user <- User.get_by_nickname(nickname) do
|
||||||
|
case Keyword.get(options, :moderator) do
|
||||||
|
nil -> nil
|
||||||
|
value -> set_moderator(user, value)
|
||||||
|
end
|
||||||
|
|
||||||
|
case Keyword.get(options, :locked) do
|
||||||
|
nil -> nil
|
||||||
|
value -> set_locked(user, value)
|
||||||
|
end
|
||||||
|
|
||||||
|
case Keyword.get(options, :admin) do
|
||||||
|
nil -> nil
|
||||||
|
value -> set_admin(user, value)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
_ ->
|
||||||
|
Mix.shell().error("No local user #{nickname}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp set_moderator(user, value) do
|
||||||
|
info_cng = User.Info.admin_api_update(user.info, %{is_moderator: value})
|
||||||
|
|
||||||
|
user_cng =
|
||||||
|
Ecto.Changeset.change(user)
|
||||||
|
|> put_embed(:info, info_cng)
|
||||||
|
|
||||||
|
{:ok, user} = User.update_and_set_cache(user_cng)
|
||||||
|
|
||||||
|
Mix.shell().info("Moderator status of #{user.nickname}: #{user.info.is_moderator}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp set_admin(user, value) do
|
||||||
|
info_cng = User.Info.admin_api_update(user.info, %{is_admin: value})
|
||||||
|
|
||||||
|
user_cng =
|
||||||
|
Ecto.Changeset.change(user)
|
||||||
|
|> put_embed(:info, info_cng)
|
||||||
|
|
||||||
|
{:ok, user} = User.update_and_set_cache(user_cng)
|
||||||
|
|
||||||
|
Mix.shell().info("Admin status of #{user.nickname}: #{user.info.is_moderator}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp set_locked(user, value) do
|
||||||
|
info_cng = User.Info.user_upgrade(user.info, %{locked: value})
|
||||||
|
|
||||||
|
user_cng =
|
||||||
|
Ecto.Changeset.change(user)
|
||||||
|
|> put_embed(:info, info_cng)
|
||||||
|
|
||||||
|
{:ok, user} = User.update_and_set_cache(user_cng)
|
||||||
|
|
||||||
|
Mix.shell().info("Locked status of #{user.nickname}: #{user.info.locked}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def run(["invite"]) do
|
||||||
|
Common.start_pleroma()
|
||||||
|
|
||||||
|
with {:ok, token} <- Pleroma.UserInviteToken.create_token() do
|
||||||
|
Mix.shell().info("Generated user invite token")
|
||||||
|
|
||||||
|
url =
|
||||||
|
Pleroma.Web.Router.Helpers.redirect_url(
|
||||||
|
Pleroma.Web.Endpoint,
|
||||||
|
:registration_page,
|
||||||
|
token.token
|
||||||
|
)
|
||||||
|
|
||||||
|
IO.puts(url)
|
||||||
|
else
|
||||||
|
_ ->
|
||||||
|
Mix.shell().error("Could not create invite token.")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,19 +0,0 @@
|
||||||
defmodule Mix.Tasks.ReactivateUser do
|
|
||||||
use Mix.Task
|
|
||||||
alias Pleroma.User
|
|
||||||
|
|
||||||
@moduledoc """
|
|
||||||
Reactivate a user
|
|
||||||
|
|
||||||
Usage: ``mix reactivate_user <nickname>``
|
|
||||||
|
|
||||||
Example: ``mix reactivate_user lain``
|
|
||||||
"""
|
|
||||||
def run([nickname]) do
|
|
||||||
Mix.Task.run("app.start")
|
|
||||||
|
|
||||||
with user <- User.get_by_nickname(nickname) do
|
|
||||||
User.deactivate(user, false)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,30 +0,0 @@
|
||||||
defmodule Mix.Tasks.RegisterUser do
|
|
||||||
@moduledoc """
|
|
||||||
Manually register a local user
|
|
||||||
|
|
||||||
Usage: ``mix register_user <name> <nickname> <email> <bio> <password>``
|
|
||||||
|
|
||||||
Example: ``mix register_user 仮面の告白 lain lain@example.org "blushy-crushy fediverse idol + pleroma dev" pleaseDontHeckLain``
|
|
||||||
"""
|
|
||||||
|
|
||||||
use Mix.Task
|
|
||||||
alias Pleroma.{Repo, User}
|
|
||||||
|
|
||||||
@shortdoc "Register user"
|
|
||||||
def run([name, nickname, email, bio, password]) do
|
|
||||||
Mix.Task.run("app.start")
|
|
||||||
|
|
||||||
params = %{
|
|
||||||
name: name,
|
|
||||||
nickname: nickname,
|
|
||||||
email: email,
|
|
||||||
password: password,
|
|
||||||
password_confirmation: password,
|
|
||||||
bio: bio
|
|
||||||
}
|
|
||||||
|
|
||||||
user = User.register_changeset(%User{}, params)
|
|
||||||
|
|
||||||
Repo.insert!(user)
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,24 +0,0 @@
|
||||||
defmodule Mix.Tasks.RelayFollow do
|
|
||||||
use Mix.Task
|
|
||||||
require Logger
|
|
||||||
alias Pleroma.Web.ActivityPub.Relay
|
|
||||||
|
|
||||||
@shortdoc "Follows a remote relay"
|
|
||||||
@moduledoc """
|
|
||||||
Follows a remote relay
|
|
||||||
|
|
||||||
Usage: ``mix relay_follow <relay_url>``
|
|
||||||
|
|
||||||
Example: ``mix relay_follow https://example.org/relay``
|
|
||||||
"""
|
|
||||||
def run([target]) do
|
|
||||||
Mix.Task.run("app.start")
|
|
||||||
|
|
||||||
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} -> Mix.shell().error("Error while following #{target}: #{inspect(e)}")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,23 +0,0 @@
|
||||||
defmodule Mix.Tasks.RelayUnfollow do
|
|
||||||
use Mix.Task
|
|
||||||
require Logger
|
|
||||||
alias Pleroma.Web.ActivityPub.Relay
|
|
||||||
|
|
||||||
@moduledoc """
|
|
||||||
Unfollows a remote relay
|
|
||||||
|
|
||||||
Usage: ``mix relay_follow <relay_url>``
|
|
||||||
|
|
||||||
Example: ``mix relay_follow https://example.org/relay``
|
|
||||||
"""
|
|
||||||
def run([target]) do
|
|
||||||
Mix.Task.run("app.start")
|
|
||||||
|
|
||||||
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} -> Mix.shell().error("Error while following #{target}: #{inspect(e)}")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,19 +0,0 @@
|
||||||
defmodule Mix.Tasks.RmUser do
|
|
||||||
use Mix.Task
|
|
||||||
alias Pleroma.User
|
|
||||||
|
|
||||||
@moduledoc """
|
|
||||||
Permanently deletes a user
|
|
||||||
|
|
||||||
Usage: ``mix rm_user [nickname]``
|
|
||||||
|
|
||||||
Example: ``mix rm_user lain``
|
|
||||||
"""
|
|
||||||
def run([nickname]) do
|
|
||||||
Mix.Task.run("app.start")
|
|
||||||
|
|
||||||
with %User{local: true} = user <- User.get_by_nickname(nickname) do
|
|
||||||
{:ok, _} = User.delete(user)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,34 +0,0 @@
|
||||||
defmodule Mix.Tasks.SetAdmin do
|
|
||||||
use Mix.Task
|
|
||||||
import Ecto.Changeset
|
|
||||||
alias Pleroma.User
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Sets admin status
|
|
||||||
Usage: set_admin nickname [true|false]
|
|
||||||
"""
|
|
||||||
def run([nickname | rest]) do
|
|
||||||
Application.ensure_all_started(:pleroma)
|
|
||||||
|
|
||||||
admin =
|
|
||||||
case rest do
|
|
||||||
[admin] -> admin == "true"
|
|
||||||
_ -> true
|
|
||||||
end
|
|
||||||
|
|
||||||
with %User{local: true} = user <- User.get_by_nickname(nickname) do
|
|
||||||
info_cng = User.Info.admin_api_update(user.info, %{is_admin: !!admin})
|
|
||||||
|
|
||||||
user_cng =
|
|
||||||
Ecto.Changeset.change(user)
|
|
||||||
|> put_embed(:info, info_cng)
|
|
||||||
|
|
||||||
{:ok, user} = User.update_and_set_cache(user_cng)
|
|
||||||
|
|
||||||
IO.puts("Admin status of #{nickname}: #{user.info.is_admin}")
|
|
||||||
else
|
|
||||||
_ ->
|
|
||||||
IO.puts("No local user #{nickname}")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,40 +0,0 @@
|
||||||
defmodule Mix.Tasks.SetLocked do
|
|
||||||
@moduledoc """
|
|
||||||
Lock a local user
|
|
||||||
|
|
||||||
The local user will then have to manually accept/reject followers. This can also be done by the user into their settings.
|
|
||||||
|
|
||||||
Usage: ``mix set_locked <username>``
|
|
||||||
|
|
||||||
Example: ``mix set_locked lain``
|
|
||||||
"""
|
|
||||||
|
|
||||||
use Mix.Task
|
|
||||||
import Ecto.Changeset
|
|
||||||
alias Pleroma.{Repo, User}
|
|
||||||
|
|
||||||
def run([nickname | rest]) do
|
|
||||||
Application.ensure_all_started(:pleroma)
|
|
||||||
|
|
||||||
locked =
|
|
||||||
case rest do
|
|
||||||
[locked] -> locked == "true"
|
|
||||||
_ -> true
|
|
||||||
end
|
|
||||||
|
|
||||||
with %User{local: true} = user <- User.get_by_nickname(nickname) do
|
|
||||||
info_cng = User.Info.profile_update(user.info, %{locked: !!locked})
|
|
||||||
|
|
||||||
user_cng =
|
|
||||||
Ecto.Changeset.change(user)
|
|
||||||
|> put_embed(:info, info_cng)
|
|
||||||
|
|
||||||
{:ok, user} = User.update_and_set_cache(user_cng)
|
|
||||||
|
|
||||||
IO.puts("Locked status of #{nickname}: #{user.info.locked}")
|
|
||||||
else
|
|
||||||
_ ->
|
|
||||||
IO.puts("No local user #{nickname}")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,38 +0,0 @@
|
||||||
defmodule Mix.Tasks.UnsubscribeUser do
|
|
||||||
use Mix.Task
|
|
||||||
alias Pleroma.{User, Repo}
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
@moduledoc """
|
|
||||||
Deactivate and Unsubscribe local users from a user
|
|
||||||
|
|
||||||
Usage: ``mix unsubscribe_user <nickname>``
|
|
||||||
|
|
||||||
Example: ``mix unsubscribe_user lain``
|
|
||||||
"""
|
|
||||||
def run([nickname]) do
|
|
||||||
Mix.Task.run("app.start")
|
|
||||||
|
|
||||||
with %User{} = user <- User.get_by_nickname(nickname) do
|
|
||||||
Logger.info("Deactivating #{user.nickname}")
|
|
||||||
User.deactivate(user)
|
|
||||||
|
|
||||||
{:ok, friends} = User.get_friends(user)
|
|
||||||
|
|
||||||
Enum.each(friends, fn friend ->
|
|
||||||
user = Repo.get(User, user.id)
|
|
||||||
|
|
||||||
Logger.info("Unsubscribing #{friend.nickname} from #{user.nickname}")
|
|
||||||
User.unfollow(user, friend)
|
|
||||||
end)
|
|
||||||
|
|
||||||
:timer.sleep(500)
|
|
||||||
|
|
||||||
user = Repo.get(User, user.id)
|
|
||||||
|
|
||||||
if length(user.following) == 0 do
|
|
||||||
Logger.info("Successfully unsubscribed all followers from #{user.nickname}")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
Loading…
Reference in New Issue