diff options
author | lain <lain@soykaf.club> | 2020-05-04 10:43:20 +0200 |
---|---|---|
committer | lain <lain@soykaf.club> | 2020-05-04 10:43:20 +0200 |
commit | c40afe5ba040964834657ef18f92bf9f1a1770d1 (patch) | |
tree | 842774fee81293fc9f65a23beaa0a312acf7ab92 /lib | |
parent | 9249742f13445f47167d4b352751c49caf48aa8f (diff) | |
parent | 095635453ac58b9e01a32ad226c0b61466c16da0 (diff) | |
download | pleroma-c40afe5ba040964834657ef18f92bf9f1a1770d1.tar.gz |
Merge branch 'develop' of git.pleroma.social:pleroma/pleroma into remake-remodel-dms
Diffstat (limited to 'lib')
32 files changed, 1411 insertions, 789 deletions
diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index a00938c04..308d8cffa 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -73,7 +73,6 @@ defmodule Pleroma.Application do Pleroma.Repo, Config.TransferTask, Pleroma.Emoji, - Pleroma.Captcha, Pleroma.Plugs.RateLimiter.Supervisor ] ++ cachex_children() ++ diff --git a/lib/pleroma/captcha/captcha.ex b/lib/pleroma/captcha/captcha.ex index cf75c3adc..6ab754b6f 100644 --- a/lib/pleroma/captcha/captcha.ex +++ b/lib/pleroma/captcha/captcha.ex @@ -3,53 +3,22 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Captcha do - import Pleroma.Web.Gettext - alias Calendar.DateTime alias Plug.Crypto.KeyGenerator alias Plug.Crypto.MessageEncryptor - use GenServer - - @doc false - def start_link(_) do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - @doc false - def init(_) do - {:ok, nil} - end - @doc """ Ask the configured captcha service for a new captcha """ def new do - GenServer.call(__MODULE__, :new) - end - - @doc """ - Ask the configured captcha service to validate the captcha - """ - def validate(token, captcha, answer_data) do - GenServer.call(__MODULE__, {:validate, token, captcha, answer_data}) - end - - @doc false - def handle_call(:new, _from, state) do - enabled = Pleroma.Config.get([__MODULE__, :enabled]) - - if !enabled do - {:reply, %{type: :none}, state} + if not enabled?() do + %{type: :none} else new_captcha = method().new() - secret_key_base = Pleroma.Config.get!([Pleroma.Web.Endpoint, :secret_key_base]) - # This make salt a little different for two keys - token = new_captcha[:token] - secret = KeyGenerator.generate(secret_key_base, token <> "_encrypt") - sign_secret = KeyGenerator.generate(secret_key_base, token <> "_sign") + {secret, sign_secret} = secret_pair(new_captcha[:token]) + # Basically copy what Phoenix.Token does here, add the time to # the actual data and make it a binary to then encrypt it encrypted_captcha_answer = @@ -60,55 +29,73 @@ defmodule Pleroma.Captcha do |> :erlang.term_to_binary() |> MessageEncryptor.encrypt(secret, sign_secret) - { - :reply, - # Replace the answer with the encrypted answer - %{new_captcha | answer_data: encrypted_captcha_answer}, - state - } + # Replace the answer with the encrypted answer + %{new_captcha | answer_data: encrypted_captcha_answer} end end - @doc false - def handle_call({:validate, token, captcha, answer_data}, _from, state) do + @doc """ + Ask the configured captcha service to validate the captcha + """ + def validate(token, captcha, answer_data) do + with {:ok, %{at: at, answer_data: answer_md5}} <- validate_answer_data(token, answer_data), + :ok <- validate_expiration(at), + :ok <- validate_usage(token), + :ok <- method().validate(token, captcha, answer_md5), + {:ok, _} <- mark_captcha_as_used(token) do + :ok + end + end + + def enabled?, do: Pleroma.Config.get([__MODULE__, :enabled], false) + + defp seconds_valid, do: Pleroma.Config.get!([__MODULE__, :seconds_valid]) + + defp secret_pair(token) do secret_key_base = Pleroma.Config.get!([Pleroma.Web.Endpoint, :secret_key_base]) secret = KeyGenerator.generate(secret_key_base, token <> "_encrypt") sign_secret = KeyGenerator.generate(secret_key_base, token <> "_sign") + {secret, sign_secret} + end + + defp validate_answer_data(token, answer_data) do + {secret, sign_secret} = secret_pair(token) + + with false <- is_nil(answer_data), + {:ok, data} <- MessageEncryptor.decrypt(answer_data, secret, sign_secret), + %{at: at, answer_data: answer_md5} <- :erlang.binary_to_term(data) do + {:ok, %{at: at, answer_data: answer_md5}} + else + _ -> {:error, :invalid_answer_data} + end + end + + defp validate_expiration(created_at) do # If the time found is less than (current_time-seconds_valid) then the time has already passed # Later we check that the time found is more than the presumed invalidatation time, that means # that the data is still valid and the captcha can be checked - seconds_valid = Pleroma.Config.get!([Pleroma.Captcha, :seconds_valid]) - valid_if_after = DateTime.subtract!(DateTime.now_utc(), seconds_valid) - - result = - with false <- is_nil(answer_data), - {:ok, data} <- MessageEncryptor.decrypt(answer_data, secret, sign_secret), - %{at: at, answer_data: answer_md5} <- :erlang.binary_to_term(data) do - try do - if DateTime.before?(at, valid_if_after), - do: throw({:error, dgettext("errors", "CAPTCHA expired")}) - - if not is_nil(Cachex.get!(:used_captcha_cache, token)), - do: throw({:error, dgettext("errors", "CAPTCHA already used")}) - - res = method().validate(token, captcha, answer_md5) - # Throw if an error occurs - if res != :ok, do: throw(res) - - # Mark this captcha as used - {:ok, _} = - Cachex.put(:used_captcha_cache, token, true, ttl: :timer.seconds(seconds_valid)) - - :ok - catch - :throw, e -> e - end - else - _ -> {:error, dgettext("errors", "Invalid answer data")} - end - - {:reply, result, state} + + valid_if_after = DateTime.subtract!(DateTime.now_utc(), seconds_valid()) + + if DateTime.before?(created_at, valid_if_after) do + {:error, :expired} + else + :ok + end + end + + defp validate_usage(token) do + if is_nil(Cachex.get!(:used_captcha_cache, token)) do + :ok + else + {:error, :already_used} + end + end + + defp mark_captcha_as_used(token) do + ttl = seconds_valid() |> :timer.seconds() + Cachex.put(:used_captcha_cache, token, true, ttl: ttl) end defp method, do: Pleroma.Config.get!([__MODULE__, :method]) diff --git a/lib/pleroma/captcha/kocaptcha.ex b/lib/pleroma/captcha/kocaptcha.ex index 06ceb20b6..6bc2fa158 100644 --- a/lib/pleroma/captcha/kocaptcha.ex +++ b/lib/pleroma/captcha/kocaptcha.ex @@ -3,7 +3,6 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Captcha.Kocaptcha do - import Pleroma.Web.Gettext alias Pleroma.Captcha.Service @behaviour Service @@ -13,7 +12,7 @@ defmodule Pleroma.Captcha.Kocaptcha do case Tesla.get(endpoint <> "/new") do {:error, _} -> - %{error: dgettext("errors", "Kocaptcha service unavailable")} + %{error: :kocaptcha_service_unavailable} {:ok, res} -> json_resp = Jason.decode!(res.body) @@ -33,6 +32,6 @@ defmodule Pleroma.Captcha.Kocaptcha do if not is_nil(captcha) and :crypto.hash(:md5, captcha) |> Base.encode16() == String.upcase(answer_data), do: :ok, - else: {:error, dgettext("errors", "Invalid CAPTCHA")} + else: {:error, :invalid} end end diff --git a/lib/pleroma/captcha/native.ex b/lib/pleroma/captcha/native.ex index 06c479ca9..a90631d61 100644 --- a/lib/pleroma/captcha/native.ex +++ b/lib/pleroma/captcha/native.ex @@ -3,7 +3,6 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Captcha.Native do - import Pleroma.Web.Gettext alias Pleroma.Captcha.Service @behaviour Service @@ -11,7 +10,7 @@ defmodule Pleroma.Captcha.Native do def new do case Captcha.get() do :error -> - %{error: dgettext("errors", "Captcha error")} + %{error: :captcha_error} {:ok, answer_data, img_binary} -> %{ @@ -25,7 +24,7 @@ defmodule Pleroma.Captcha.Native do @impl Service def validate(_token, captcha, captcha) when not is_nil(captcha), do: :ok - def validate(_token, _captcha, _answer), do: {:error, dgettext("errors", "Invalid CAPTCHA")} + def validate(_token, _captcha, _answer), do: {:error, :invalid} defp token do 10 diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index 4ba39b53f..3a9eec5ea 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -20,4 +20,9 @@ defmodule Pleroma.Constants do "deleted_activity_id" ] ) + + const(static_only_files, + do: + ~w(index.html robots.txt static static-fe finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc) + ) end diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex new file mode 100644 index 000000000..242344374 --- /dev/null +++ b/lib/pleroma/emoji/pack.ex @@ -0,0 +1,507 @@ +defmodule Pleroma.Emoji.Pack do + @derive {Jason.Encoder, only: [:files, :pack]} + defstruct files: %{}, + pack_file: nil, + path: nil, + pack: %{}, + name: nil + + @type t() :: %__MODULE__{ + files: %{String.t() => Path.t()}, + pack_file: Path.t(), + path: Path.t(), + pack: map(), + name: String.t() + } + + alias Pleroma.Emoji + + @spec emoji_path() :: Path.t() + def emoji_path do + static = Pleroma.Config.get!([:instance, :static_dir]) + Path.join(static, "emoji") + end + + @spec create(String.t()) :: :ok | {:error, File.posix()} | {:error, :empty_values} + def create(name) when byte_size(name) > 0 do + dir = Path.join(emoji_path(), name) + + with :ok <- File.mkdir(dir) do + %__MODULE__{ + pack_file: Path.join(dir, "pack.json") + } + |> save_pack() + end + end + + def create(_), do: {:error, :empty_values} + + @spec show(String.t()) :: {:ok, t()} | {:loaded, nil} | {:error, :empty_values} + def show(name) when byte_size(name) > 0 do + with {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)}, + {_, pack} <- validate_pack(pack) do + {:ok, pack} + end + end + + def show(_), do: {:error, :empty_values} + + @spec delete(String.t()) :: + {:ok, [binary()]} | {:error, File.posix(), binary()} | {:error, :empty_values} + def delete(name) when byte_size(name) > 0 do + emoji_path() + |> Path.join(name) + |> File.rm_rf() + end + + def delete(_), do: {:error, :empty_values} + + @spec add_file(String.t(), String.t(), Path.t(), Plug.Upload.t() | String.t()) :: + {:ok, t()} | {:error, File.posix()} | {:error, :empty_values} + def add_file(name, shortcode, filename, file) + when byte_size(name) > 0 and byte_size(shortcode) > 0 and byte_size(filename) > 0 do + with {_, nil} <- {:exists, Emoji.get(shortcode)}, + {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)} do + file_path = Path.join(pack.path, filename) + + create_subdirs(file_path) + + case file do + %Plug.Upload{path: upload_path} -> + # Copy the uploaded file from the temporary directory + File.copy!(upload_path, file_path) + + url when is_binary(url) -> + # Download and write the file + file_contents = Tesla.get!(url).body + File.write!(file_path, file_contents) + end + + files = Map.put(pack.files, shortcode, filename) + + updated_pack = %{pack | files: files} + + case save_pack(updated_pack) do + :ok -> + Emoji.reload() + {:ok, updated_pack} + + e -> + e + end + end + end + + def add_file(_, _, _, _), do: {:error, :empty_values} + + defp create_subdirs(file_path) do + if String.contains?(file_path, "/") do + file_path + |> Path.dirname() + |> File.mkdir_p!() + end + end + + @spec delete_file(String.t(), String.t()) :: + {:ok, t()} | {:error, File.posix()} | {:error, :empty_values} + def delete_file(name, shortcode) when byte_size(name) > 0 and byte_size(shortcode) > 0 do + with {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)}, + {_, {filename, files}} when not is_nil(filename) <- + {:exists, Map.pop(pack.files, shortcode)}, + emoji <- Path.join(pack.path, filename), + {_, true} <- {:exists, File.exists?(emoji)} do + emoji_dir = Path.dirname(emoji) + + File.rm!(emoji) + + if String.contains?(filename, "/") and File.ls!(emoji_dir) == [] do + File.rmdir!(emoji_dir) + end + + updated_pack = %{pack | files: files} + + case save_pack(updated_pack) do + :ok -> + Emoji.reload() + {:ok, updated_pack} + + e -> + e + end + end + end + + def delete_file(_, _), do: {:error, :empty_values} + + @spec update_file(String.t(), String.t(), String.t(), String.t(), boolean()) :: + {:ok, t()} | {:error, File.posix()} | {:error, :empty_values} + def update_file(name, shortcode, new_shortcode, new_filename, force) + when byte_size(name) > 0 and byte_size(shortcode) > 0 and byte_size(new_shortcode) > 0 and + byte_size(new_filename) > 0 do + with {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)}, + {_, {filename, files}} when not is_nil(filename) <- + {:exists, Map.pop(pack.files, shortcode)}, + {_, true} <- {:not_used, force or is_nil(Emoji.get(new_shortcode))} do + old_path = Path.join(pack.path, filename) + old_dir = Path.dirname(old_path) + new_path = Path.join(pack.path, new_filename) + + create_subdirs(new_path) + + :ok = File.rename(old_path, new_path) + + if String.contains?(filename, "/") and File.ls!(old_dir) == [] do + File.rmdir!(old_dir) + end + + files = Map.put(files, new_shortcode, new_filename) + + updated_pack = %{pack | files: files} + + case save_pack(updated_pack) do + :ok -> + Emoji.reload() + {:ok, updated_pack} + + e -> + e + end + end + end + + def update_file(_, _, _, _, _), do: {:error, :empty_values} + + @spec import_from_filesystem() :: {:ok, [String.t()]} | {:error, atom()} + def import_from_filesystem do + emoji_path = emoji_path() + + with {:ok, %{access: :read_write}} <- File.stat(emoji_path), + {:ok, results} <- File.ls(emoji_path) do + names = + results + |> Enum.map(&Path.join(emoji_path, &1)) + |> Enum.reject(fn path -> + File.dir?(path) and File.exists?(Path.join(path, "pack.json")) + end) + |> Enum.map(&write_pack_contents/1) + |> Enum.filter(& &1) + + {:ok, names} + else + {:ok, %{access: _}} -> {:error, :no_read_write} + e -> e + end + end + + defp write_pack_contents(path) do + pack = %__MODULE__{ + files: files_from_path(path), + path: path, + pack_file: Path.join(path, "pack.json") + } + + case save_pack(pack) do + :ok -> Path.basename(path) + _ -> nil + end + end + + defp files_from_path(path) do + txt_path = Path.join(path, "emoji.txt") + + if File.exists?(txt_path) do + # There's an emoji.txt file, it's likely from a pack installed by the pack manager. + # Make a pack.json file from the contents of that emoji.txt file + + # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2 + + # Create a map of shortcodes to filenames from emoji.txt + File.read!(txt_path) + |> String.split("\n") + |> Enum.map(&String.trim/1) + |> Enum.map(fn line -> + case String.split(line, ~r/,\s*/) do + # This matches both strings with and without tags + # and we don't care about tags here + [name, file | _] -> + file_dir_name = Path.dirname(file) + + file = + if String.ends_with?(path, file_dir_name) do + Path.basename(file) + else + file + end + + {name, file} + + _ -> + nil + end + end) + |> Enum.filter(& &1) + |> Enum.into(%{}) + else + # If there's no emoji.txt, assume all files + # that are of certain extensions from the config are emojis and import them all + pack_extensions = Pleroma.Config.get!([:emoji, :pack_extensions]) + Emoji.Loader.make_shortcode_to_file_map(path, pack_extensions) + end + end + + @spec list_remote(String.t()) :: {:ok, map()} + def list_remote(url) do + uri = + url + |> String.trim() + |> URI.parse() + + with {_, true} <- {:shareable, shareable_packs_available?(uri)} do + packs = + uri + |> URI.merge("/api/pleroma/emoji/packs") + |> to_string() + |> Tesla.get!() + |> Map.get(:body) + |> Jason.decode!() + + {:ok, packs} + end + end + + @spec list_local() :: {:ok, map()} + def list_local do + emoji_path = emoji_path() + + # Create the directory first if it does not exist. This is probably the first request made + # with the API so it should be sufficient + with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(emoji_path)}, + {:ls, {:ok, results}} <- {:ls, File.ls(emoji_path)} do + packs = + results + |> Enum.map(&load_pack/1) + |> Enum.filter(& &1) + |> Enum.map(&validate_pack/1) + |> Map.new() + + {:ok, packs} + end + end + + defp validate_pack(pack) do + if downloadable?(pack) do + archive = fetch_archive(pack) + archive_sha = :crypto.hash(:sha256, archive) |> Base.encode16() + + info = + pack.pack + |> Map.put("can-download", true) + |> Map.put("download-sha256", archive_sha) + + {pack.name, Map.put(pack, :pack, info)} + else + info = Map.put(pack.pack, "can-download", false) + {pack.name, Map.put(pack, :pack, info)} + end + end + + defp downloadable?(pack) do + # If the pack is set as shared, check if it can be downloaded + # That means that when asked, the pack can be packed and sent to the remote + # Otherwise, they'd have to download it from external-src + pack.pack["share-files"] && + Enum.all?(pack.files, fn {_, file} -> + File.exists?(Path.join(pack.path, file)) + end) + end + + @spec get_archive(String.t()) :: {:ok, binary()} + def get_archive(name) do + with {_, %__MODULE__{} = pack} <- {:exists?, load_pack(name)}, + {_, true} <- {:can_download?, downloadable?(pack)} do + {:ok, fetch_archive(pack)} + end + end + + defp fetch_archive(pack) do + hash = :crypto.hash(:md5, File.read!(pack.pack_file)) + + case Cachex.get!(:emoji_packs_cache, pack.name) do + %{hash: ^hash, pack_data: archive} -> + archive + + _ -> + create_archive_and_cache(pack, hash) + end + end + + defp create_archive_and_cache(pack, hash) do + files = ['pack.json' | Enum.map(pack.files, fn {_, file} -> to_charlist(file) end)] + + {:ok, {_, result}} = + :zip.zip('#{pack.name}.zip', files, [:memory, cwd: to_charlist(pack.path)]) + + ttl_per_file = Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file]) + overall_ttl = :timer.seconds(ttl_per_file * Enum.count(files)) + + Cachex.put!( + :emoji_packs_cache, + pack.name, + # if pack.json MD5 changes, the cache is not valid anymore + %{hash: hash, pack_data: result}, + # Add a minute to cache time for every file in the pack + ttl: overall_ttl + ) + + result + end + + @spec download(String.t(), String.t(), String.t()) :: :ok + def download(name, url, as) do + uri = + url + |> String.trim() + |> URI.parse() + + with {_, true} <- {:shareable, shareable_packs_available?(uri)} do + remote_pack = + uri + |> URI.merge("/api/pleroma/emoji/packs/#{name}") + |> to_string() + |> Tesla.get!() + |> Map.get(:body) + |> Jason.decode!() + + result = + case remote_pack["pack"] do + %{"share-files" => true, "can-download" => true, "download-sha256" => sha} -> + {:ok, + %{ + sha: sha, + url: URI.merge(uri, "/api/pleroma/emoji/packs/#{name}/archive") |> to_string() + }} + + %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) -> + {:ok, + %{ + sha: sha, + url: src, + fallback: true + }} + + _ -> + {:error, + "The pack was not set as shared and there is no fallback src to download from"} + end + + with {:ok, %{sha: sha, url: url} = pinfo} <- result, + %{body: archive} <- Tesla.get!(url), + {_, true} <- {:checksum, Base.decode16!(sha) == :crypto.hash(:sha256, archive)} do + local_name = as || name + + path = Path.join(emoji_path(), local_name) + + pack = %__MODULE__{ + name: local_name, + path: path, + files: remote_pack["files"], + pack_file: Path.join(path, "pack.json") + } + + File.mkdir_p!(pack.path) + + files = Enum.map(remote_pack["files"], fn {_, path} -> to_charlist(path) end) + # Fallback cannot contain a pack.json file + files = if pinfo[:fallback], do: files, else: ['pack.json' | files] + + {:ok, _} = :zip.unzip(archive, cwd: to_charlist(pack.path), file_list: files) + + # Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256 + # in it to depend on itself + if pinfo[:fallback] do + save_pack(pack) + end + + :ok + end + end + end + + defp save_pack(pack), do: File.write(pack.pack_file, Jason.encode!(pack, pretty: true)) + + @spec save_metadata(map(), t()) :: {:ok, t()} | {:error, File.posix()} + def save_metadata(metadata, %__MODULE__{} = pack) do + pack = Map.put(pack, :pack, metadata) + + with :ok <- save_pack(pack) do + {:ok, pack} + end + end + + @spec update_metadata(String.t(), map()) :: {:ok, t()} | {:error, File.posix()} + def update_metadata(name, data) do + pack = load_pack(name) + + fb_sha_changed? = + not is_nil(data["fallback-src"]) and data["fallback-src"] != pack.pack["fallback-src"] + + with {_, true} <- {:update?, fb_sha_changed?}, + {:ok, %{body: zip}} <- Tesla.get(data["fallback-src"]), + {:ok, f_list} <- :zip.unzip(zip, [:memory]), + {_, true} <- {:has_all_files?, has_all_files?(pack.files, f_list)} do + fallback_sha = :crypto.hash(:sha256, zip) |> Base.encode16() + + data + |> Map.put("fallback-src-sha256", fallback_sha) + |> save_metadata(pack) + else + {:update?, _} -> save_metadata(data, pack) + e -> e + end + end + + # Check if all files from the pack.json are in the archive + defp has_all_files?(files, f_list) do + Enum.all?(files, fn {_, from_manifest} -> + List.keyfind(f_list, to_charlist(from_manifest), 0) + end) + end + + @spec load_pack(String.t()) :: t() | nil + def load_pack(name) do + pack_file = Path.join([emoji_path(), name, "pack.json"]) + + if File.exists?(pack_file) do + pack_file + |> File.read!() + |> from_json() + |> Map.put(:pack_file, pack_file) + |> Map.put(:path, Path.dirname(pack_file)) + |> Map.put(:name, name) + end + end + + defp from_json(json) do + map = Jason.decode!(json) + + struct(__MODULE__, %{files: map["files"], pack: map["pack"]}) + end + + defp shareable_packs_available?(uri) do + uri + |> URI.merge("/.well-known/nodeinfo") + |> to_string() + |> Tesla.get!() + |> Map.get(:body) + |> Jason.decode!() + |> Map.get("links") + |> List.last() + |> Map.get("href") + # Get the actual nodeinfo address and fetch it + |> Tesla.get!() + |> Map.get(:body) + |> Jason.decode!() + |> get_in(["metadata", "features"]) + |> Enum.member?("shareable_emoji_packs") + end +end diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index 9ccf40495..3a3082e72 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -10,11 +10,12 @@ defmodule Pleroma.FollowingRelationship do alias Ecto.Changeset alias FlakeId.Ecto.CompatType + alias Pleroma.FollowingRelationship.State alias Pleroma.Repo alias Pleroma.User schema "following_relationships" do - field(:state, Pleroma.FollowingRelationship.State, default: :follow_pending) + field(:state, State, default: :follow_pending) belongs_to(:follower, User, type: CompatType) belongs_to(:following, User, type: CompatType) @@ -22,6 +23,11 @@ defmodule Pleroma.FollowingRelationship do timestamps() end + @doc "Returns underlying integer code for state atom" + def state_int_code(state_atom), do: State.__enum_map__() |> Keyword.fetch!(state_atom) + + def accept_state_code, do: state_int_code(:follow_accept) + def changeset(%__MODULE__{} = following_relationship, attrs) do following_relationship |> cast(attrs, [:state]) @@ -82,6 +88,29 @@ defmodule Pleroma.FollowingRelationship do |> Repo.aggregate(:count, :id) end + def followers_query(%User{} = user) do + __MODULE__ + |> join(:inner, [r], u in User, on: r.follower_id == u.id) + |> where([r], r.following_id == ^user.id) + |> where([r], r.state == ^:follow_accept) + end + + def followers_ap_ids(%User{} = user, from_ap_ids \\ nil) do + query = + user + |> followers_query() + |> select([r, u], u.ap_id) + + query = + if from_ap_ids do + where(query, [r, u], u.ap_id in ^from_ap_ids) + else + query + end + + Repo.all(query) + end + def following_count(%User{id: nil}), do: 0 def following_count(%User{} = user) do @@ -105,12 +134,16 @@ defmodule Pleroma.FollowingRelationship do |> Repo.exists?() end + def following_query(%User{} = user) do + __MODULE__ + |> join(:inner, [r], u in User, on: r.following_id == u.id) + |> where([r], r.follower_id == ^user.id) + |> where([r], r.state == ^:follow_accept) + end + def following(%User{} = user) do following = - __MODULE__ - |> join(:inner, [r], u in User, on: r.following_id == u.id) - |> where([r], r.follower_id == ^user.id) - |> where([r], r.state == ^:follow_accept) + following_query(user) |> select([r, u], u.follower_address) |> Repo.all() @@ -171,6 +204,30 @@ defmodule Pleroma.FollowingRelationship do end) end + @doc """ + For a query with joined activity, + keeps rows where activity's actor is followed by user -or- is NOT domain-blocked by user. + """ + def keep_following_or_not_domain_blocked(query, user) do + where( + query, + [_, activity], + fragment( + # "(actor's domain NOT in domain_blocks) OR (actor IS in followed AP IDs)" + """ + NOT (substring(? from '.*://([^/]*)') = ANY(?)) OR + ? = ANY(SELECT ap_id FROM users AS u INNER JOIN following_relationships AS fr + ON u.id = fr.following_id WHERE fr.follower_id = ? AND fr.state = ?) + """, + activity.actor, + ^user.domain_blocks, + activity.actor, + ^User.binary_id(user.id), + ^accept_state_code() + ) + ) + end + defp validate_not_self_relationship(%Changeset{} = changeset) do changeset |> validate_follower_id_following_id_inequality() diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index d96c12440..a2c870ded 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Notification do use Ecto.Schema alias Pleroma.Activity + alias Pleroma.FollowingRelationship alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Pagination @@ -81,15 +82,13 @@ defmodule Pleroma.Notification do |> exclude_visibility(opts) end + # Excludes blocked users and non-followed domain-blocked users defp exclude_blocked(query, user, opts) do blocked_ap_ids = opts[:blocked_users_ap_ids] || User.blocked_users_ap_ids(user) query |> where([n, a], a.actor not in ^blocked_ap_ids) - |> where( - [n, a], - fragment("substring(? from '.*://([^/]*)')", a.actor) not in ^user.domain_blocks - ) + |> FollowingRelationship.keep_following_or_not_domain_blocked(user) end defp exclude_notification_muted(query, _, %{@include_muted_option => true}) do @@ -262,6 +261,16 @@ defmodule Pleroma.Notification do |> Repo.delete_all() end + def dismiss(%Pleroma.Activity{} = activity) do + Notification + |> where([n], n.activity_id == ^activity.id) + |> Repo.delete_all() + |> case do + {_, notifications} -> {:ok, notifications} + _ -> {:error, "Cannot dismiss notification"} + end + end + def dismiss(%{id: user_id} = _user, id) do notification = Repo.get(Notification, id) @@ -284,17 +293,8 @@ defmodule Pleroma.Notification do end end - def create_notifications(%Activity{data: %{"type" => "Follow"}} = activity) do - if Pleroma.Config.get([:notifications, :enable_follow_request_notifications]) || - Activity.follow_accepted?(activity) do - do_create_notifications(activity) - else - {:ok, []} - end - end - def create_notifications(%Activity{data: %{"type" => type}} = activity) - when type in ["Like", "Announce", "Move", "EmojiReact"] do + when type in ["Follow", "Like", "Announce", "Move", "EmojiReact"] do do_create_notifications(activity) end @@ -330,10 +330,11 @@ defmodule Pleroma.Notification do @doc """ Returns a tuple with 2 elements: - {enabled notification receivers, currently disabled receivers (blocking / [thread] muting)} + {notification-enabled receivers, currently disabled receivers (blocking / [thread] muting)} NOTE: might be called for FAKE Activities, see ActivityPub.Utils.get_notified_from_object/1 """ + @spec get_notified_from_activity(Activity.t(), boolean()) :: {list(User.t()), list(User.t())} def get_notified_from_activity(activity, local_only \\ true) def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, local_only) @@ -346,17 +347,14 @@ defmodule Pleroma.Notification do |> Utils.maybe_notify_followers(activity) |> Enum.uniq() - # Since even subscribers and followers can mute / thread-mute, filtering all above AP IDs + potential_receivers = User.get_users_from_set(potential_receiver_ap_ids, local_only) + notification_enabled_ap_ids = potential_receiver_ap_ids + |> exclude_domain_blocker_ap_ids(activity, potential_receivers) |> exclude_relationship_restricted_ap_ids(activity) |> exclude_thread_muter_ap_ids(activity) - potential_receivers = - potential_receiver_ap_ids - |> Enum.uniq() - |> User.get_users_from_set(local_only) - notification_enabled_users = Enum.filter(potential_receivers, fn u -> u.ap_id in notification_enabled_ap_ids end) @@ -365,6 +363,38 @@ defmodule Pleroma.Notification do def get_notified_from_activity(_, _local_only), do: {[], []} + @doc "Filters out AP IDs domain-blocking and not following the activity's actor" + def exclude_domain_blocker_ap_ids(ap_ids, activity, preloaded_users \\ []) + + def exclude_domain_blocker_ap_ids([], _activity, _preloaded_users), do: [] + + def exclude_domain_blocker_ap_ids(ap_ids, %Activity{} = activity, preloaded_users) do + activity_actor_domain = activity.actor && URI.parse(activity.actor).host + + users = + ap_ids + |> Enum.map(fn ap_id -> + Enum.find(preloaded_users, &(&1.ap_id == ap_id)) || + User.get_cached_by_ap_id(ap_id) + end) + |> Enum.filter(& &1) + + domain_blocker_ap_ids = for u <- users, activity_actor_domain in u.domain_blocks, do: u.ap_id + + domain_blocker_follower_ap_ids = + if Enum.any?(domain_blocker_ap_ids) do + activity + |> Activity.user_actor() + |> FollowingRelationship.followers_ap_ids(domain_blocker_ap_ids) + else + [] + end + + ap_ids + |> Kernel.--(domain_blocker_ap_ids) + |> Kernel.++(domain_blocker_follower_ap_ids) + end + @doc "Filters out AP IDs of users basing on their relationships with activity actor user" def exclude_relationship_restricted_ap_ids([], _activity), do: [] diff --git a/lib/pleroma/plugs/instance_static.ex b/lib/pleroma/plugs/instance_static.ex index 927fa2663..7516f75c3 100644 --- a/lib/pleroma/plugs/instance_static.ex +++ b/lib/pleroma/plugs/instance_static.ex @@ -3,6 +3,8 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.InstanceStatic do + require Pleroma.Constants + @moduledoc """ This is a shim to call `Plug.Static` but with runtime `from` configuration. @@ -21,9 +23,6 @@ defmodule Pleroma.Plugs.InstanceStatic do end end - @only ~w(index.html robots.txt static emoji packs sounds images instance favicon.png sw.js - sw-pleroma.js) - def init(opts) do opts |> Keyword.put(:from, "__unconfigured_instance_static_plug") @@ -31,7 +30,7 @@ defmodule Pleroma.Plugs.InstanceStatic do |> Plug.Static.init() end - for only <- @only do + for only <- Pleroma.Constants.static_only_files() do at = Plug.Router.Utils.split("/") def call(%{request_path: "/" <> unquote(only) <> _} = conn, opts) do diff --git a/lib/pleroma/plugs/mapped_signature_to_identity_plug.ex b/lib/pleroma/plugs/mapped_signature_to_identity_plug.ex index 84b7c5d83..f44d4dee5 100644 --- a/lib/pleroma/plugs/mapped_signature_to_identity_plug.ex +++ b/lib/pleroma/plugs/mapped_signature_to_identity_plug.ex @@ -13,8 +13,9 @@ defmodule Pleroma.Web.Plugs.MappedSignatureToIdentityPlug do def init(options), do: options defp key_id_from_conn(conn) do - with %{"keyId" => key_id} <- HTTPSignatures.signature_for_conn(conn) do - Signature.key_id_to_actor_id(key_id) + with %{"keyId" => key_id} <- HTTPSignatures.signature_for_conn(conn), + {:ok, ap_id} <- Signature.key_id_to_actor_id(key_id) do + ap_id else _ -> nil diff --git a/lib/pleroma/signature.ex b/lib/pleroma/signature.ex index 6b0b2c969..d01728361 100644 --- a/lib/pleroma/signature.ex +++ b/lib/pleroma/signature.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Signature do alias Pleroma.Keys alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.ObjectValidators.Types def key_id_to_actor_id(key_id) do uri = @@ -21,12 +22,23 @@ defmodule Pleroma.Signature do uri end - URI.to_string(uri) + maybe_ap_id = URI.to_string(uri) + + case Types.ObjectID.cast(maybe_ap_id) do + {:ok, ap_id} -> + {:ok, ap_id} + + _ -> + case Pleroma.Web.WebFinger.finger(maybe_ap_id) do + %{"ap_id" => ap_id} -> {:ok, ap_id} + _ -> {:error, maybe_ap_id} + end + end end def fetch_public_key(conn) do with %{"keyId" => kid} <- HTTPSignatures.signature_for_conn(conn), - actor_id <- key_id_to_actor_id(kid), + {:ok, actor_id} <- key_id_to_actor_id(kid), {:ok, public_key} <- User.get_public_key_for_ap_id(actor_id) do {:ok, public_key} else @@ -37,7 +49,7 @@ defmodule Pleroma.Signature do def refetch_public_key(conn) do with %{"keyId" => kid} <- HTTPSignatures.signature_for_conn(conn), - actor_id <- key_id_to_actor_id(kid), + {:ok, actor_id} <- key_id_to_actor_id(kid), {:ok, _user} <- ActivityPub.make_user_from_ap_id(actor_id), {:ok, public_key} <- User.get_public_key_for_ap_id(actor_id) do {:ok, public_key} diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index b451202b2..99358ddaf 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1445,8 +1445,15 @@ defmodule Pleroma.User do end) delete_user_activities(user) - invalidate_cache(user) - Repo.delete(user) + + if user.local do + user + |> change(%{deactivated: true, email: nil}) + |> update_and_set_cache() + else + invalidate_cache(user) + Repo.delete(user) + end end def perform(:deactivate_async, user, status), do: deactivate(user, status) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index d625530ec..f607931ab 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -37,9 +37,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do [unless_func: &FederatingPlug.federating?/0] when action not in @federating_only_actions ) + # Note: :following and :followers must be served even without authentication (as via :api) plug( EnsureAuthenticatedPlug - when action in [:read_inbox, :update_outbox, :whoami, :upload_media, :following, :followers] + when action in [:read_inbox, :update_outbox, :whoami, :upload_media] ) plug( diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index d3a2e0362..7021d3f24 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do A module to handle coding from internal to wire ActivityPub and back. """ alias Pleroma.Activity + alias Pleroma.EarmarkRenderer alias Pleroma.FollowingRelationship alias Pleroma.Object alias Pleroma.Object.Containment @@ -43,6 +44,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do |> fix_addressing |> fix_summary |> fix_type(options) + |> fix_content end def fix_summary(%{"summary" => nil} = object) do @@ -357,6 +359,18 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def fix_type(object, _), do: object + defp fix_content(%{"mediaType" => "text/markdown", "content" => content} = object) + when is_binary(content) do + html_content = + content + |> Earmark.as_html!(%Earmark.Options{renderer: EarmarkRenderer}) + |> Pleroma.HTML.filter_tags() + + Map.merge(object, %{"content" => html_content, "mediaType" => "text/html"}) + end + + defp fix_content(object), do: object + defp mastodon_follow_hack(%{"id" => id, "actor" => follower_id}, followed) do with true <- id =~ "follows", %User{local: true} = follower <- User.get_cached_by_ap_id(follower_id), @@ -1217,18 +1231,24 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def prepare_attachments(object) do attachments = - (object["attachment"] || []) + object + |> Map.get("attachment", []) |> Enum.map(fn data -> [%{"mediaType" => media_type, "href" => href} | _] = data["url"] - %{"url" => href, "mediaType" => media_type, "name" => data["name"], "type" => "Document"} + + %{ + "url" => href, + "mediaType" => media_type, + "name" => data["name"], + "type" => "Document" + } end) Map.put(object, "attachment", attachments) end def strip_internal_fields(object) do - object - |> Map.drop(Pleroma.Constants.object_internal_fields()) + Map.drop(object, Pleroma.Constants.object_internal_fields()) end defp strip_internal_tags(%{"tag" => tags} = object) do diff --git a/lib/pleroma/web/api_spec/helpers.ex b/lib/pleroma/web/api_spec/helpers.ex index ce40fb9e8..183df43ee 100644 --- a/lib/pleroma/web/api_spec/helpers.ex +++ b/lib/pleroma/web/api_spec/helpers.ex @@ -41,9 +41,17 @@ defmodule Pleroma.Web.ApiSpec.Helpers do Operation.parameter( :limit, :query, - %Schema{type: :integer, default: 20, maximum: 40}, - "Limit" + %Schema{type: :integer, default: 20}, + "Maximum number of items to return. Will be ignored if it's more than 40" ) ] end + + def empty_object_response do + Operation.response("Empty object", "application/json", %Schema{type: :object, example: %{}}) + end + + def empty_array_response do + Operation.response("Empty array", "application/json", %Schema{type: :array, example: []}) + end end diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 64e2e43c4..fe9548b1b 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -131,6 +131,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do "Include statuses from muted acccounts." ), Operation.parameter(:exclude_reblogs, :query, BooleanLike, "Exclude reblogs"), + Operation.parameter(:exclude_replies, :query, BooleanLike, "Exclude replies"), Operation.parameter( :exclude_visibilities, :query, @@ -343,7 +344,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do description: "Not implemented", security: [%{"oAuth" => ["read:accounts"]}], responses: %{ - 200 => Operation.response("Empry array", "application/json", %Schema{type: :array}) + 200 => empty_array_response() } } end @@ -355,7 +356,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do operationId: "AccountController.identity_proofs", description: "Not implemented", responses: %{ - 200 => Operation.response("Empry array", "application/json", %Schema{type: :array}) + 200 => empty_array_response() } } end diff --git a/lib/pleroma/web/api_spec/operations/domain_block_operation.ex b/lib/pleroma/web/api_spec/operations/domain_block_operation.ex index 3b7f51ceb..049bcf931 100644 --- a/lib/pleroma/web/api_spec/operations/domain_block_operation.ex +++ b/lib/pleroma/web/api_spec/operations/domain_block_operation.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.ApiSpec.DomainBlockOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema - alias Pleroma.Web.ApiSpec.Helpers + import Pleroma.Web.ApiSpec.Helpers def open_api_operation(action) do operation = String.to_existing_atom("#{action}_operation") @@ -46,9 +46,7 @@ defmodule Pleroma.Web.ApiSpec.DomainBlockOperation do operationId: "DomainBlockController.create", requestBody: domain_block_request(), security: [%{"oAuth" => ["follow", "write:blocks"]}], - responses: %{ - 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}) - } + responses: %{200 => empty_object_response()} } end @@ -67,7 +65,7 @@ defmodule Pleroma.Web.ApiSpec.DomainBlockOperation do end defp domain_block_request do - Helpers.request_body( + request_body( "Parameters", %Schema{ type: :object, diff --git a/lib/pleroma/web/api_spec/operations/notification_operation.ex b/lib/pleroma/web/api_spec/operations/notification_operation.ex new file mode 100644 index 000000000..64adc5319 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/notification_operation.ex @@ -0,0 +1,211 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.NotificationOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Notifications"], + summary: "Get all notifications", + description: + "Notifications concerning the user. This API returns Link headers containing links to the next/previous page. However, the links can also be constructed dynamically using query params and `id` values.", + operationId: "NotificationController.index", + security: [%{"oAuth" => ["read:notifications"]}], + parameters: + [ + Operation.parameter( + :exclude_types, + :query, + %Schema{type: :array, items: notification_type()}, + "Array of types to exclude" + ), + Operation.parameter( + :account_id, + :query, + %Schema{type: :string}, + "Return only notifications received from this account" + ), + Operation.parameter( + :exclude_visibilities, + :query, + %Schema{type: :array, items: VisibilityScope}, + "Exclude the notifications for activities with the given visibilities" + ), + Operation.parameter( + :include_types, + :query, + %Schema{type: :array, items: notification_type()}, + "Include the notifications for activities with the given types" + ), + Operation.parameter( + :with_muted, + :query, + BooleanLike, + "Include the notifications from muted users" + ) + ] ++ pagination_params(), + responses: %{ + 200 => + Operation.response("Array of notifications", "application/json", %Schema{ + type: :array, + items: notification() + }), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Notifications"], + summary: "Get a single notification", + description: "View information about a notification with a given ID.", + operationId: "NotificationController.show", + security: [%{"oAuth" => ["read:notifications"]}], + parameters: [id_param()], + responses: %{ + 200 => Operation.response("Notification", "application/json", notification()) + } + } + end + + def clear_operation do + %Operation{ + tags: ["Notifications"], + summary: "Dismiss all notifications", + description: "Clear all notifications from the server.", + operationId: "NotificationController.clear", + security: [%{"oAuth" => ["write:notifications"]}], + responses: %{200 => empty_object_response()} + } + end + + def dismiss_operation do + %Operation{ + tags: ["Notifications"], + summary: "Dismiss a single notification", + description: "Clear a single notification from the server.", + operationId: "NotificationController.dismiss", + parameters: [id_param()], + security: [%{"oAuth" => ["write:notifications"]}], + responses: %{200 => empty_object_response()} + } + end + + def dismiss_via_body_operation do + %Operation{ + tags: ["Notifications"], + summary: "Dismiss a single notification", + deprecated: true, + description: "Clear a single notification from the server.", + operationId: "NotificationController.dismiss_via_body", + requestBody: + request_body( + "Parameters", + %Schema{type: :object, properties: %{id: %Schema{type: :string}}}, + required: true + ), + security: [%{"oAuth" => ["write:notifications"]}], + responses: %{200 => empty_object_response()} + } + end + + def destroy_multiple_operation do + %Operation{ + tags: ["Notifications"], + summary: "Dismiss multiple notifications", + operationId: "NotificationController.destroy_multiple", + security: [%{"oAuth" => ["write:notifications"]}], + parameters: [ + Operation.parameter( + :ids, + :query, + %Schema{type: :array, items: %Schema{type: :string}}, + "Array of notification IDs to dismiss", + required: true + ) + ], + responses: %{200 => empty_object_response()} + } + end + + defp notification do + %Schema{ + title: "Notification", + description: "Response schema for a notification", + type: :object, + properties: %{ + id: %Schema{type: :string}, + type: notification_type(), + created_at: %Schema{type: :string, format: :"date-time"}, + account: %Schema{ + allOf: [Account], + description: "The account that performed the action that generated the notification." + }, + status: %Schema{ + allOf: [Status], + description: + "Status that was the object of the notification, e.g. in mentions, reblogs, favourites, or polls.", + nullable: true + } + }, + example: %{ + "id" => "34975861", + "type" => "mention", + "created_at" => "2019-11-23T07:49:02.064Z", + "account" => Account.schema().example, + "status" => Status.schema().example + } + } + end + + defp notification_type do + %Schema{ + type: :string, + enum: [ + "follow", + "favourite", + "reblog", + "mention", + "poll", + "pleroma:emoji_reaction", + "move", + "follow_request" + ], + description: """ + The type of event that resulted in the notification. + + - `follow` - Someone followed you + - `mention` - Someone mentioned you in their status + - `reblog` - Someone boosted one of your statuses + - `favourite` - Someone favourited one of your statuses + - `poll` - A poll you have voted in or created has ended + - `move` - Someone moved their account + - `pleroma:emoji_reaction` - Someone reacted with emoji to your status + """ + } + end + + defp id_param do + Operation.parameter(:id, :path, :string, "Notification ID", + example: "123", + required: true + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/report_operation.ex b/lib/pleroma/web/api_spec/operations/report_operation.ex new file mode 100644 index 000000000..da4d50703 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/report_operation.ex @@ -0,0 +1,78 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.ReportOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def create_operation do + %Operation{ + tags: ["reports"], + summary: "File a report", + description: "Report problematic users to your moderators", + operationId: "ReportController.create", + security: [%{"oAuth" => ["follow", "write:reports"]}], + requestBody: Helpers.request_body("Parameters", create_request(), required: true), + responses: %{ + 200 => Operation.response("Report", "application/json", create_response()), + 400 => Operation.response("Report", "application/json", ApiError) + } + } + end + + defp create_request do + %Schema{ + title: "ReportCreateRequest", + description: "POST body for creating a report", + type: :object, + properties: %{ + account_id: %Schema{type: :string, description: "ID of the account to report"}, + status_ids: %Schema{ + type: :array, + items: %Schema{type: :string}, + description: "Array of Statuses to attach to the report, for context" + }, + comment: %Schema{ + type: :string, + description: "Reason for the report" + }, + forward: %Schema{ + type: :boolean, + default: false, + description: + "If the account is remote, should the report be forwarded to the remote admin?" + } + }, + required: [:account_id], + example: %{ + "account_id" => "123", + "status_ids" => ["1337"], + "comment" => "bad status!", + "forward" => "false" + } + } + end + + defp create_response do + %Schema{ + title: "ReportResponse", + type: :object, + properties: %{ + id: %Schema{type: :string, description: "Report ID"}, + action_taken: %Schema{type: :boolean, description: "Is action taken?"} + }, + example: %{ + "id" => "123", + "action_taken" => false + } + } + end +end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 359045f48..1eda0b2f2 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.CommonAPI do alias Pleroma.Conversation.Participation alias Pleroma.FollowingRelationship alias Pleroma.Formatter + alias Pleroma.Notification alias Pleroma.Object alias Pleroma.ThreadMute alias Pleroma.User @@ -68,8 +69,8 @@ defmodule Pleroma.Web.CommonAPI do end def accept_follow_request(follower, followed) do - with {:ok, follower} <- User.follow(follower, followed), - %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), + with %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), + {:ok, follower} <- User.follow(follower, followed), {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "accept"), {:ok, _relationship} <- FollowingRelationship.update(follower, followed, :follow_accept), {:ok, _activity} <- @@ -87,6 +88,7 @@ defmodule Pleroma.Web.CommonAPI do with %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "reject"), {:ok, _relationship} <- FollowingRelationship.update(follower, followed, :follow_reject), + {:ok, _notifications} <- Notification.dismiss(follow_activity), {:ok, _activity} <- ActivityPub.reject(%{ to: [follower.ap_id], @@ -406,9 +408,9 @@ defmodule Pleroma.Web.CommonAPI do ThreadMute.exists?(user.id, activity.data["context"]) end - def report(user, %{"account_id" => account_id} = data) do - with {:ok, account} <- get_reported_account(account_id), - {:ok, {content_html, _, _}} <- make_report_content_html(data["comment"]), + def report(user, data) do + with {:ok, account} <- get_reported_account(data.account_id), + {:ok, {content_html, _, _}} <- make_report_content_html(data[:comment]), {:ok, statuses} <- get_report_statuses(account, data) do ActivityPub.flag(%{ context: Utils.generate_context_id(), @@ -416,13 +418,11 @@ defmodule Pleroma.Web.CommonAPI do account: account, statuses: statuses, content: content_html, - forward: data["forward"] || false + forward: Map.get(data, :forward, false) }) end end - def report(_user, _params), do: {:error, dgettext("errors", "Valid `account_id` required")} - defp get_reported_account(account_id) do case User.get_cached_by_id(account_id) do %User{} = account -> {:ok, account} diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 4afdf80af..b0b1bd559 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -504,7 +504,8 @@ defmodule Pleroma.Web.CommonAPI.Utils do end end - def get_report_statuses(%User{ap_id: actor}, %{"status_ids" => status_ids}) do + def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids}) + when is_list(status_ids) do {:ok, Activity.all_by_actor_and_id(actor, status_ids)} end diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index 72cb3ee27..226d42c2c 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -5,6 +5,8 @@ defmodule Pleroma.Web.Endpoint do use Phoenix.Endpoint, otp_app: :pleroma + require Pleroma.Constants + socket("/socket", Pleroma.Web.UserSocket) plug(Pleroma.Plugs.SetLocalePlug) @@ -34,8 +36,7 @@ defmodule Pleroma.Web.Endpoint do Plug.Static, at: "/", from: :pleroma, - only: - ~w(index.html robots.txt static finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc), + only: Pleroma.Constants.static_only_files(), # credo:disable-for-previous-line Credo.Check.Readability.MaxLineLength gzip: true, cache_control_for_etags: @static_cache_control, diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 1eedf02d6..61b0e2f63 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -94,24 +94,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do @doc "POST /api/v1/accounts" def create(%{assigns: %{app: app}, body_params: params} = conn, _params) do - params = - params - |> Map.take([ - :email, - :bio, - :captcha_solution, - :captcha_token, - :captcha_answer_data, - :token, - :password, - :fullname - ]) - |> Map.put(:nickname, params.username) - |> Map.put(:fullname, Map.get(params, :fullname, params.username)) - |> Map.put(:confirm, params.password) - |> Map.put(:trusted_app, app.trusted) - with :ok <- validate_email_param(params), + :ok <- TwitterAPI.validate_captcha(app, params), {:ok, user} <- TwitterAPI.register_user(params, need_confirmation: true), {:ok, token} <- Token.create_token(app, user, %{scopes: app.scopes}) do json(conn, %{ @@ -121,7 +105,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do created_at: Token.Utils.format_created_at(token) }) else - {:error, errors} -> json_response(conn, :bad_request, errors) + {:error, error} -> json_response(conn, :bad_request, %{error: error}) end end @@ -133,11 +117,11 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do render_error(conn, :forbidden, "Invalid credentials") end - defp validate_email_param(%{:email => email}) when not is_nil(email), do: :ok + defp validate_email_param(%{email: email}) when not is_nil(email), do: :ok defp validate_email_param(_) do case Pleroma.Config.get([:instance, :account_activation_required]) do - true -> {:error, %{"error" => "Missing parameters"}} + true -> {:error, dgettext("errors", "Missing parameter: %{name}", name: "email")} _ -> :ok end end diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex index 311405277..a14c86893 100644 --- a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex @@ -13,6 +13,8 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do @oauth_read_actions [:show, :index] + plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError) + plug( OAuthScopesPlug, %{scopes: ["read:notifications"]} when action in @oauth_read_actions @@ -20,14 +22,16 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action not in @oauth_read_actions) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.NotificationOperation + # GET /api/v1/notifications - def index(conn, %{"account_id" => account_id} = params) do + def index(conn, %{account_id: account_id} = params) do case Pleroma.User.get_cached_by_id(account_id) do %{ap_id: account_ap_id} -> params = params - |> Map.delete("account_id") - |> Map.put("account_ap_id", account_ap_id) + |> Map.delete(:account_id) + |> Map.put(:account_ap_id, account_ap_id) index(conn, params) @@ -39,6 +43,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do end def index(%{assigns: %{user: user}} = conn, params) do + params = Map.new(params, fn {k, v} -> {to_string(k), v} end) notifications = MastodonAPI.get_notifications(user, params) conn @@ -51,7 +56,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do end # GET /api/v1/notifications/:id - def show(%{assigns: %{user: user}} = conn, %{"id" => id}) do + def show(%{assigns: %{user: user}} = conn, %{id: id}) do with {:ok, notification} <- Notification.get(user, id) do render(conn, "show.json", notification: notification, for: user) else @@ -69,8 +74,8 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do end # POST /api/v1/notifications/:id/dismiss - # POST /api/v1/notifications/dismiss (deprecated) - def dismiss(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do + + def dismiss(%{assigns: %{user: user}} = conn, %{id: id} = _params) do with {:ok, _notif} <- Notification.dismiss(user, id) do json(conn, %{}) else @@ -81,8 +86,13 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do end end + # POST /api/v1/notifications/dismiss (deprecated) + def dismiss_via_body(%{body_params: params} = conn, _) do + dismiss(conn, params) + end + # DELETE /api/v1/notifications/destroy_multiple - def destroy_multiple(%{assigns: %{user: user}} = conn, %{"ids" => ids} = _params) do + def destroy_multiple(%{assigns: %{user: user}} = conn, %{ids: ids} = _params) do Notification.destroy_multiple(user, ids) json(conn, %{}) end diff --git a/lib/pleroma/web/mastodon_api/controllers/report_controller.ex b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex index 9fbaa7bd1..f65c5c62b 100644 --- a/lib/pleroma/web/mastodon_api/controllers/report_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex @@ -9,10 +9,13 @@ defmodule Pleroma.Web.MastodonAPI.ReportController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError) plug(OAuthScopesPlug, %{scopes: ["write:reports"]} when action == :create) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ReportOperation + @doc "POST /api/v1/reports" - def create(%{assigns: %{user: user}} = conn, params) do + def create(%{assigns: %{user: user}, body_params: params} = conn, _) do with {:ok, activity} <- Pleroma.Web.CommonAPI.report(user, params) do render(conn, "show.json", activity: activity) end diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 67214dbea..a329ffc28 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -5,10 +5,13 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do use Pleroma.Web, :view + alias Pleroma.Config + alias Pleroma.Web.ActivityPub.MRF + @mastodon_api_level "2.7.2" def render("show.json", _) do - instance = Pleroma.Config.get(:instance) + instance = Config.get(:instance) %{ uri: Pleroma.Web.base_url(), @@ -29,7 +32,58 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do upload_limit: Keyword.get(instance, :upload_limit), avatar_upload_limit: Keyword.get(instance, :avatar_upload_limit), background_upload_limit: Keyword.get(instance, :background_upload_limit), - banner_upload_limit: Keyword.get(instance, :banner_upload_limit) + banner_upload_limit: Keyword.get(instance, :banner_upload_limit), + pleroma: %{ + metadata: %{ + features: features(), + federation: federation() + }, + vapid_public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) + } } end + + def features do + [ + "pleroma_api", + "mastodon_api", + "mastodon_api_streaming", + "polls", + "pleroma_explicit_addressing", + "shareable_emoji_packs", + "multifetch", + "pleroma:api/v1/notifications:include_types_filter", + if Config.get([:media_proxy, :enabled]) do + "media_proxy" + end, + if Config.get([:gopher, :enabled]) do + "gopher" + end, + if Config.get([:chat, :enabled]) do + "chat" + end, + if Config.get([:instance, :allow_relay]) do + "relay" + end, + if Config.get([:instance, :safe_dm_mentions]) do + "safe_dm_mentions" + end, + "pleroma_emoji_reactions" + ] + |> Enum.filter(& &1) + end + + def federation do + quarantined = Config.get([:instance, :quarantined_instances], []) + + if Config.get([:instance, :mrf_transparency]) do + {:ok, data} = MRF.describe() + + data + |> Map.merge(%{quarantined_instances: quarantined}) + else + %{} + end + |> Map.put(:enabled, Config.get([:instance, :federating])) + end end diff --git a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex index f9a5ddcc0..721b599d4 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex @@ -9,8 +9,8 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do alias Pleroma.Stats alias Pleroma.User alias Pleroma.Web - alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.Federator.Publisher + alias Pleroma.Web.MastodonAPI.InstanceView def schemas(conn, _params) do response = %{ @@ -34,51 +34,12 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do def raw_nodeinfo do stats = Stats.get_stats() - quarantined = Config.get([:instance, :quarantined_instances], []) - staff_accounts = User.all_superusers() |> Enum.map(fn u -> u.ap_id end) - federation_response = - if Config.get([:instance, :mrf_transparency]) do - {:ok, data} = MRF.describe() - - data - |> Map.merge(%{quarantined_instances: quarantined}) - else - %{} - end - |> Map.put(:enabled, Config.get([:instance, :federating])) - - features = - [ - "pleroma_api", - "mastodon_api", - "mastodon_api_streaming", - "polls", - "pleroma_explicit_addressing", - "shareable_emoji_packs", - "multifetch", - "pleroma:api/v1/notifications:include_types_filter", - if Config.get([:media_proxy, :enabled]) do - "media_proxy" - end, - if Config.get([:gopher, :enabled]) do - "gopher" - end, - if Config.get([:chat, :enabled]) do - "chat" - end, - if Config.get([:instance, :allow_relay]) do - "relay" - end, - if Config.get([:instance, :safe_dm_mentions]) do - "safe_dm_mentions" - end, - "pleroma_emoji_reactions" - ] - |> Enum.filter(& &1) + features = InstanceView.features() + federation = InstanceView.federation() %{ version: "2.0", @@ -106,7 +67,7 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do enabled: false }, staffAccounts: staff_accounts, - federation: federation_response, + federation: federation, pollLimits: Config.get([:instance, :poll_limits]), postFormats: Config.get([:instance, :allowed_post_formats]), uploadLimits: %{ diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex index e01825b48..d276b96a4 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex @@ -1,195 +1,93 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do use Pleroma.Web, :controller - alias Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug - alias Pleroma.Plugs.OAuthScopesPlug - - require Logger + alias Pleroma.Emoji.Pack plug( - OAuthScopesPlug, + Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["write"], admin: true} when action in [ + :import_from_filesystem, + :remote, + :download, :create, + :update, :delete, - :save_from, - :import_from_fs, + :add_file, :update_file, - :update_metadata + :delete_file ] ) plug( :skip_plug, - [OAuthScopesPlug, ExpectPublicOrAuthenticatedCheckPlug] - when action in [:download_shared, :list_packs, :list_from] + [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug] + when action in [:archive, :show, :list] ) - defp emoji_dir_path do - Path.join( - Pleroma.Config.get!([:instance, :static_dir]), - "emoji" - ) - end - - @doc """ - Lists packs from the remote instance. - - Since JS cannot ask remote instances for their packs due to CPS, it has to - be done by the server - """ - def list_from(conn, %{"instance_address" => address}) do - address = String.trim(address) - - if shareable_packs_available(address) do - list_resp = - "#{address}/api/pleroma/emoji/packs" |> Tesla.get!() |> Map.get(:body) |> Jason.decode!() - - json(conn, list_resp) + def remote(conn, %{"url" => url}) do + with {:ok, packs} <- Pack.list_remote(url) do + json(conn, packs) else - conn - |> put_status(:internal_server_error) - |> json(%{error: "The requested instance does not support sharing emoji packs"}) + {:shareable, _} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "The requested instance does not support sharing emoji packs"}) end end - @doc """ - Lists the packs available on the instance as JSON. - - The information is public and does not require authentication. The format is - a map of "pack directory name" to pack.json contents. - """ - def list_packs(conn, _params) do - # Create the directory first if it does not exist. This is probably the first request made - # with the API so it should be sufficient - with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(emoji_dir_path())}, - {:ls, {:ok, results}} <- {:ls, File.ls(emoji_dir_path())} do - pack_infos = - results - |> Enum.filter(&has_pack_json?/1) - |> Enum.map(&load_pack/1) - # Check if all the files are in place and can be sent - |> Enum.map(&validate_pack/1) - # Transform into a map of pack-name => pack-data - |> Enum.into(%{}) - - json(conn, pack_infos) + def list(conn, _params) do + emoji_path = + Path.join( + Pleroma.Config.get!([:instance, :static_dir]), + "emoji" + ) + + with {:ok, packs} <- Pack.list_local() do + json(conn, packs) else {:create_dir, {:error, e}} -> conn |> put_status(:internal_server_error) - |> json(%{error: "Failed to create the emoji pack directory at #{emoji_dir_path()}: #{e}"}) + |> json(%{error: "Failed to create the emoji pack directory at #{emoji_path}: #{e}"}) {:ls, {:error, e}} -> conn |> put_status(:internal_server_error) |> json(%{ - error: - "Failed to get the contents of the emoji pack directory at #{emoji_dir_path()}: #{e}" + error: "Failed to get the contents of the emoji pack directory at #{emoji_path}: #{e}" }) end end - defp has_pack_json?(file) do - dir_path = Path.join(emoji_dir_path(), file) - # Filter to only use the pack.json packs - File.dir?(dir_path) and File.exists?(Path.join(dir_path, "pack.json")) - end - - defp load_pack(pack_name) do - pack_path = Path.join(emoji_dir_path(), pack_name) - pack_file = Path.join(pack_path, "pack.json") - - {pack_name, Jason.decode!(File.read!(pack_file))} - end - - defp validate_pack({name, pack}) do - pack_path = Path.join(emoji_dir_path(), name) - - if can_download?(pack, pack_path) do - archive_for_sha = make_archive(name, pack, pack_path) - archive_sha = :crypto.hash(:sha256, archive_for_sha) |> Base.encode16() + def show(conn, %{"name" => name}) do + name = String.trim(name) - pack = - pack - |> put_in(["pack", "can-download"], true) - |> put_in(["pack", "download-sha256"], archive_sha) - - {name, pack} + with {:ok, pack} <- Pack.show(name) do + json(conn, pack) else - {name, put_in(pack, ["pack", "can-download"], false)} - end - end - - defp can_download?(pack, pack_path) do - # If the pack is set as shared, check if it can be downloaded - # That means that when asked, the pack can be packed and sent to the remote - # Otherwise, they'd have to download it from external-src - pack["pack"]["share-files"] && - Enum.all?(pack["files"], fn {_, path} -> - File.exists?(Path.join(pack_path, path)) - end) - end - - defp create_archive_and_cache(name, pack, pack_dir, md5) do - files = - ['pack.json'] ++ - (pack["files"] |> Enum.map(fn {_, path} -> to_charlist(path) end)) - - {:ok, {_, zip_result}} = :zip.zip('#{name}.zip', files, [:memory, cwd: to_charlist(pack_dir)]) - - cache_seconds_per_file = Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file]) - cache_ms = :timer.seconds(cache_seconds_per_file * Enum.count(files)) - - Cachex.put!( - :emoji_packs_cache, - name, - # if pack.json MD5 changes, the cache is not valid anymore - %{pack_json_md5: md5, pack_data: zip_result}, - # Add a minute to cache time for every file in the pack - ttl: cache_ms - ) - - Logger.debug("Created an archive for the '#{name}' emoji pack, \ -keeping it in cache for #{div(cache_ms, 1000)}s") - - zip_result - end - - defp make_archive(name, pack, pack_dir) do - # Having a different pack.json md5 invalidates cache - pack_file_md5 = :crypto.hash(:md5, File.read!(Path.join(pack_dir, "pack.json"))) - - case Cachex.get!(:emoji_packs_cache, name) do - %{pack_file_md5: ^pack_file_md5, pack_data: zip_result} -> - Logger.debug("Using cache for the '#{name}' shared emoji pack") - zip_result + {:loaded, _} -> + conn + |> put_status(:not_found) + |> json(%{error: "Pack #{name} does not exist"}) - _ -> - create_archive_and_cache(name, pack, pack_dir, pack_file_md5) + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack name cannot be empty"}) end end - @doc """ - An endpoint for other instances (via admin UI) or users (via browser) - to download packs that the instance shares. - """ - def download_shared(conn, %{"name" => name}) do - pack_dir = Path.join(emoji_dir_path(), name) - pack_file = Path.join(pack_dir, "pack.json") - - with {_, true} <- {:exists?, File.exists?(pack_file)}, - pack = Jason.decode!(File.read!(pack_file)), - {_, true} <- {:can_download?, can_download?(pack, pack_dir)} do - zip_result = make_archive(name, pack, pack_dir) - send_download(conn, {:binary, zip_result}, filename: "#{name}.zip") + def archive(conn, %{"name" => name}) do + with {:ok, archive} <- Pack.get_archive(name) do + send_download(conn, {:binary, archive}, filename: "#{name}.zip") else {:can_download?, _} -> conn |> put_status(:forbidden) |> json(%{ - error: "Pack #{name} cannot be downloaded from this instance, either pack sharing\ - was disabled for this pack or some files are missing" + error: + "Pack #{name} cannot be downloaded from this instance, either pack sharing was disabled for this pack or some files are missing" }) {:exists?, _} -> @@ -199,133 +97,67 @@ keeping it in cache for #{div(cache_ms, 1000)}s") end end - defp shareable_packs_available(address) do - "#{address}/.well-known/nodeinfo" - |> Tesla.get!() - |> Map.get(:body) - |> Jason.decode!() - |> Map.get("links") - |> List.last() - |> Map.get("href") - # Get the actual nodeinfo address and fetch it - |> Tesla.get!() - |> Map.get(:body) - |> Jason.decode!() - |> get_in(["metadata", "features"]) - |> Enum.member?("shareable_emoji_packs") - end - - @doc """ - An admin endpoint to request downloading and storing a pack named `pack_name` from the instance - `instance_address`. - - If the requested instance's admin chose to share the pack, it will be downloaded - from that instance, otherwise it will be downloaded from the fallback source, if there is one. - """ - def save_from(conn, %{"instance_address" => address, "pack_name" => name} = data) do - address = String.trim(address) - - if shareable_packs_available(address) do - full_pack = - "#{address}/api/pleroma/emoji/packs/list" - |> Tesla.get!() - |> Map.get(:body) - |> Jason.decode!() - |> Map.get(name) - - pack_info_res = - case full_pack["pack"] do - %{"share-files" => true, "can-download" => true, "download-sha256" => sha} -> - {:ok, - %{ - sha: sha, - uri: "#{address}/api/pleroma/emoji/packs/download_shared/#{name}" - }} - - %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) -> - {:ok, - %{ - sha: sha, - uri: src, - fallback: true - }} - - _ -> - {:error, - "The pack was not set as shared and there is no fallback src to download from"} - end - - with {:ok, %{sha: sha, uri: uri} = pinfo} <- pack_info_res, - %{body: emoji_archive} <- Tesla.get!(uri), - {_, true} <- {:checksum, Base.decode16!(sha) == :crypto.hash(:sha256, emoji_archive)} do - local_name = data["as"] || name - pack_dir = Path.join(emoji_dir_path(), local_name) - File.mkdir_p!(pack_dir) - - files = Enum.map(full_pack["files"], fn {_, path} -> to_charlist(path) end) - # Fallback cannot contain a pack.json file - files = if pinfo[:fallback], do: files, else: ['pack.json'] ++ files - - {:ok, _} = :zip.unzip(emoji_archive, cwd: to_charlist(pack_dir), file_list: files) - - # Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256 - # in it to depend on itself - if pinfo[:fallback] do - pack_file_path = Path.join(pack_dir, "pack.json") - - File.write!(pack_file_path, Jason.encode!(full_pack, pretty: true)) - end - - json(conn, "ok") - else - {:error, e} -> - conn |> put_status(:internal_server_error) |> json(%{error: e}) - - {:checksum, _} -> - conn - |> put_status(:internal_server_error) - |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"}) - end + def download(conn, %{"url" => url, "name" => name} = params) do + with :ok <- Pack.download(name, url, params["as"]) do + json(conn, "ok") else - conn - |> put_status(:internal_server_error) - |> json(%{error: "The requested instance does not support sharing emoji packs"}) + {:shareable, _} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "The requested instance does not support sharing emoji packs"}) + + {:checksum, _} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"}) + + {:error, e} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: e}) end end - @doc """ - Creates an empty pack named `name` which then can be updated via the admin UI. - """ def create(conn, %{"name" => name}) do - pack_dir = Path.join(emoji_dir_path(), name) + name = String.trim(name) - if not File.exists?(pack_dir) do - File.mkdir_p!(pack_dir) - - pack_file_p = Path.join(pack_dir, "pack.json") + with :ok <- Pack.create(name) do + json(conn, "ok") + else + {:error, :eexist} -> + conn + |> put_status(:conflict) + |> json(%{error: "A pack named \"#{name}\" already exists"}) - File.write!( - pack_file_p, - Jason.encode!(%{pack: %{}, files: %{}}, pretty: true) - ) + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack name cannot be empty"}) - conn |> json("ok") - else - conn - |> put_status(:conflict) - |> json(%{error: "A pack named \"#{name}\" already exists"}) + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while creating pack." + ) end end - @doc """ - Deletes the pack `name` and all it's files. - """ def delete(conn, %{"name" => name}) do - pack_dir = Path.join(emoji_dir_path(), name) + name = String.trim(name) - case File.rm_rf(pack_dir) do - {:ok, _} -> - conn |> json("ok") + with {:ok, deleted} when deleted != [] <- Pack.delete(name) do + json(conn, "ok") + else + {:ok, []} -> + conn + |> put_status(:not_found) + |> json(%{error: "Pack #{name} does not exist"}) + + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack name cannot be empty"}) {:error, _, _} -> conn @@ -334,265 +166,128 @@ keeping it in cache for #{div(cache_ms, 1000)}s") end end - @doc """ - An endpoint to update `pack_names`'s metadata. - - `new_data` is the new metadata for the pack, that will replace the old metadata. - """ - def update_metadata(conn, %{"pack_name" => name, "new_data" => new_data}) do - pack_file_p = Path.join([emoji_dir_path(), name, "pack.json"]) - - full_pack = Jason.decode!(File.read!(pack_file_p)) - - # The new fallback-src is in the new data and it's not the same as it was in the old data - should_update_fb_sha = - not is_nil(new_data["fallback-src"]) and - new_data["fallback-src"] != full_pack["pack"]["fallback-src"] - - with {_, true} <- {:should_update?, should_update_fb_sha}, - %{body: pack_arch} <- Tesla.get!(new_data["fallback-src"]), - {:ok, flist} <- :zip.unzip(pack_arch, [:memory]), - {_, true} <- {:has_all_files?, has_all_files?(full_pack, flist)} do - fallback_sha = :crypto.hash(:sha256, pack_arch) |> Base.encode16() - - new_data = Map.put(new_data, "fallback-src-sha256", fallback_sha) - update_metadata_and_send(conn, full_pack, new_data, pack_file_p) + def update(conn, %{"name" => name, "metadata" => metadata}) do + with {:ok, pack} <- Pack.update_metadata(name, metadata) do + json(conn, pack.pack) else - {:should_update?, _} -> - update_metadata_and_send(conn, full_pack, new_data, pack_file_p) - {:has_all_files?, _} -> conn |> put_status(:bad_request) |> json(%{error: "The fallback archive does not have all files specified in pack.json"}) - end - end - - # Check if all files from the pack.json are in the archive - defp has_all_files?(%{"files" => files}, flist) do - Enum.all?(files, fn {_, from_manifest} -> - Enum.find(flist, fn {from_archive, _} -> - to_string(from_archive) == from_manifest - end) - end) - end - - defp update_metadata_and_send(conn, full_pack, new_data, pack_file_p) do - full_pack = Map.put(full_pack, "pack", new_data) - File.write!(pack_file_p, Jason.encode!(full_pack, pretty: true)) - # Send new data back with fallback sha filled - json(conn, new_data) - end - - defp get_filename(%{"filename" => filename}), do: filename - - defp get_filename(%{"file" => file}) do - case file do - %Plug.Upload{filename: filename} -> filename - url when is_binary(url) -> Path.basename(url) + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while updating pack metadata." + ) end end - defp empty?(str), do: String.trim(str) == "" - - defp update_file_and_send(conn, updated_full_pack, pack_file_p) do - # Write the emoji pack file - File.write!(pack_file_p, Jason.encode!(updated_full_pack, pretty: true)) - - # Return the modified file list - json(conn, updated_full_pack["files"]) - end + def add_file(conn, %{"name" => name} = params) do + filename = params["filename"] || get_filename(params["file"]) + shortcode = params["shortcode"] || Path.basename(filename, Path.extname(filename)) - @doc """ - Updates a file in a pack. - - Updating can mean three things: - - - `add` adds an emoji named `shortcode` to the pack `pack_name`, - that means that the emoji file needs to be uploaded with the request - (thus requiring it to be a multipart request) and be named `file`. - There can also be an optional `filename` that will be the new emoji file name - (if it's not there, the name will be taken from the uploaded file). - - `update` changes emoji shortcode (from `shortcode` to `new_shortcode` or moves the file - (from the current filename to `new_filename`) - - `remove` removes the emoji named `shortcode` and it's associated file - """ - - # Add - def update_file( - conn, - %{"pack_name" => pack_name, "action" => "add", "shortcode" => shortcode} = params - ) do - pack_dir = Path.join(emoji_dir_path(), pack_name) - pack_file_p = Path.join(pack_dir, "pack.json") - - full_pack = Jason.decode!(File.read!(pack_file_p)) - - with {_, false} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)}, - filename <- get_filename(params), - false <- empty?(shortcode), - false <- empty?(filename) do - file_path = Path.join(pack_dir, filename) - - # If the name contains directories, create them - if String.contains?(file_path, "/") do - File.mkdir_p!(Path.dirname(file_path)) - end - - case params["file"] do - %Plug.Upload{path: upload_path} -> - # Copy the uploaded file from the temporary directory - File.copy!(upload_path, file_path) - - url when is_binary(url) -> - # Download and write the file - file_contents = Tesla.get!(url).body - File.write!(file_path, file_contents) - end - - updated_full_pack = put_in(full_pack, ["files", shortcode], filename) - update_file_and_send(conn, updated_full_pack, pack_file_p) + with {:ok, pack} <- Pack.add_file(name, shortcode, filename, params["file"]) do + json(conn, pack.files) else - {:has_shortcode, _} -> + {:exists, _} -> conn |> put_status(:conflict) |> json(%{error: "An emoji with the \"#{shortcode}\" shortcode already exists"}) - true -> + {:loaded, _} -> conn |> put_status(:bad_request) - |> json(%{error: "shortcode or filename cannot be empty"}) - end - end + |> json(%{error: "pack \"#{name}\" is not found"}) - # Remove - def update_file(conn, %{ - "pack_name" => pack_name, - "action" => "remove", - "shortcode" => shortcode - }) do - pack_dir = Path.join(emoji_dir_path(), pack_name) - pack_file_p = Path.join(pack_dir, "pack.json") + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack name, shortcode or filename cannot be empty"}) - full_pack = Jason.decode!(File.read!(pack_file_p)) + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while adding file to pack." + ) + end + end - if Map.has_key?(full_pack["files"], shortcode) do - {emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode]) + def update_file(conn, %{"name" => name, "shortcode" => shortcode} = params) do + new_shortcode = params["new_shortcode"] + new_filename = params["new_filename"] + force = params["force"] == true - emoji_file_path = Path.join(pack_dir, emoji_file_path) + with {:ok, pack} <- Pack.update_file(name, shortcode, new_shortcode, new_filename, force) do + json(conn, pack.files) + else + {:exists, _} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) - # Delete the emoji file - File.rm!(emoji_file_path) + {:not_used, _} -> + conn + |> put_status(:conflict) + |> json(%{ + error: + "New shortcode \"#{new_shortcode}\" is already used. If you want to override emoji use 'force' option" + }) - # If the old directory has no more files, remove it - if String.contains?(emoji_file_path, "/") do - dir = Path.dirname(emoji_file_path) + {:loaded, _} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack \"#{name}\" is not found"}) - if Enum.empty?(File.ls!(dir)) do - File.rmdir!(dir) - end - end + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "new_shortcode or new_filename cannot be empty"}) - update_file_and_send(conn, updated_full_pack, pack_file_p) - else - conn - |> put_status(:bad_request) - |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while updating file in pack." + ) end end - # Update - def update_file( - conn, - %{"pack_name" => pack_name, "action" => "update", "shortcode" => shortcode} = params - ) do - pack_dir = Path.join(emoji_dir_path(), pack_name) - pack_file_p = Path.join(pack_dir, "pack.json") - - full_pack = Jason.decode!(File.read!(pack_file_p)) - - with {_, true} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)}, - %{"new_shortcode" => new_shortcode, "new_filename" => new_filename} <- params, - false <- empty?(new_shortcode), - false <- empty?(new_filename) do - # First, remove the old shortcode, saving the old path - {old_emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode]) - old_emoji_file_path = Path.join(pack_dir, old_emoji_file_path) - new_emoji_file_path = Path.join(pack_dir, new_filename) - - # If the name contains directories, create them - if String.contains?(new_emoji_file_path, "/") do - File.mkdir_p!(Path.dirname(new_emoji_file_path)) - end - - # Move/Rename the old filename to a new filename - # These are probably on the same filesystem, so just rename should work - :ok = File.rename(old_emoji_file_path, new_emoji_file_path) - - # If the old directory has no more files, remove it - if String.contains?(old_emoji_file_path, "/") do - dir = Path.dirname(old_emoji_file_path) - - if Enum.empty?(File.ls!(dir)) do - File.rmdir!(dir) - end - end - - # Then, put in the new shortcode with the new path - updated_full_pack = put_in(updated_full_pack, ["files", new_shortcode], new_filename) - update_file_and_send(conn, updated_full_pack, pack_file_p) + def delete_file(conn, %{"name" => name, "shortcode" => shortcode}) do + with {:ok, pack} <- Pack.delete_file(name, shortcode) do + json(conn, pack.files) else - {:has_shortcode, _} -> + {:exists, _} -> conn |> put_status(:bad_request) |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) - true -> + {:loaded, _} -> conn |> put_status(:bad_request) - |> json(%{error: "new_shortcode or new_filename cannot be empty"}) + |> json(%{error: "pack \"#{name}\" is not found"}) - _ -> + {:error, :empty_values} -> conn |> put_status(:bad_request) - |> json(%{error: "new_shortcode or new_file were not specified"}) - end - end + |> json(%{error: "pack name or shortcode cannot be empty"}) - def update_file(conn, %{"action" => action}) do - conn - |> put_status(:bad_request) - |> json(%{error: "Unknown action: #{action}"}) + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while removing file from pack." + ) + end end - @doc """ - Imports emoji from the filesystem. - - Importing means checking all the directories in the - `$instance_static/emoji/` for directories which do not have - `pack.json`. If one has an emoji.txt file, that file will be used - to create a `pack.json` file with it's contents. If the directory has - neither, all the files with specific configured extenstions will be - assumed to be emojis and stored in the new `pack.json` file. - """ - def import_from_fs(conn, _params) do - emoji_path = emoji_dir_path() - - with {:ok, %{access: :read_write}} <- File.stat(emoji_path), - {:ok, results} <- File.ls(emoji_path) do - imported_pack_names = - results - |> Enum.filter(fn file -> - dir_path = Path.join(emoji_path, file) - # Find the directories that do NOT have pack.json - File.dir?(dir_path) and not File.exists?(Path.join(dir_path, "pack.json")) - end) - |> Enum.map(&write_pack_json_contents/1) - - json(conn, imported_pack_names) + def import_from_filesystem(conn, _params) do + with {:ok, names} <- Pack.import_from_filesystem() do + json(conn, names) else - {:ok, %{access: _}} -> + {:error, :no_read_write} -> conn |> put_status(:internal_server_error) |> json(%{error: "Error: emoji pack directory must be writable"}) @@ -604,44 +299,6 @@ keeping it in cache for #{div(cache_ms, 1000)}s") end end - defp write_pack_json_contents(dir) do - dir_path = Path.join(emoji_dir_path(), dir) - emoji_txt_path = Path.join(dir_path, "emoji.txt") - - files_for_pack = files_for_pack(emoji_txt_path, dir_path) - pack_json_contents = Jason.encode!(%{pack: %{}, files: files_for_pack}) - - File.write!(Path.join(dir_path, "pack.json"), pack_json_contents) - - dir - end - - defp files_for_pack(emoji_txt_path, dir_path) do - if File.exists?(emoji_txt_path) do - # There's an emoji.txt file, it's likely from a pack installed by the pack manager. - # Make a pack.json file from the contents of that emoji.txt fileh - - # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2 - - # Create a map of shortcodes to filenames from emoji.txt - File.read!(emoji_txt_path) - |> String.split("\n") - |> Enum.map(&String.trim/1) - |> Enum.map(fn line -> - case String.split(line, ~r/,\s*/) do - # This matches both strings with and without tags - # and we don't care about tags here - [name, file | _] -> {name, file} - _ -> nil - end - end) - |> Enum.filter(fn x -> not is_nil(x) end) - |> Enum.into(%{}) - else - # If there's no emoji.txt, assume all files - # that are of certain extensions from the config are emojis and import them all - pack_extensions = Pleroma.Config.get!([:emoji, :pack_extensions]) - Pleroma.Emoji.Loader.make_shortcode_to_file_map(dir_path, pack_extensions) - end - end + defp get_filename(%Plug.Upload{filename: filename}), do: filename + defp get_filename(url) when is_binary(url), do: Path.basename(url) end diff --git a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index 2c1874051..1bdb3aa4d 100644 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -61,7 +61,10 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do else users = Enum.map(user_ap_ids, &User.get_cached_by_ap_id/1) - |> Enum.filter(& &1) + |> Enum.filter(fn + %{deactivated: false} -> true + _ -> false + end) %{ name: emoji, diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index e826bff6f..3a5063d4a 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -214,24 +214,24 @@ defmodule Pleroma.Web.Router do scope "/packs" do pipe_through(:admin_api) - post("/import_from_fs", EmojiAPIController, :import_from_fs) - post("/:pack_name/update_file", EmojiAPIController, :update_file) - post("/:pack_name/update_metadata", EmojiAPIController, :update_metadata) - put("/:name", EmojiAPIController, :create) + get("/import", EmojiAPIController, :import_from_filesystem) + get("/remote", EmojiAPIController, :remote) + post("/download", EmojiAPIController, :download) + + post("/:name", EmojiAPIController, :create) + patch("/:name", EmojiAPIController, :update) delete("/:name", EmojiAPIController, :delete) - # Note: /download_from downloads and saves to instance, not to requester - post("/download_from", EmojiAPIController, :save_from) + post("/:name/files", EmojiAPIController, :add_file) + patch("/:name/files", EmojiAPIController, :update_file) + delete("/:name/files", EmojiAPIController, :delete_file) end # Pack info / downloading scope "/packs" do - get("/", EmojiAPIController, :list_packs) - get("/:name/download_shared/", EmojiAPIController, :download_shared) - get("/list_from", EmojiAPIController, :list_from) - - # Deprecated: POST /api/pleroma/emoji/packs/list_from (use GET instead) - post("/list_from", EmojiAPIController, :list_from) + get("/", EmojiAPIController, :list) + get("/:name", EmojiAPIController, :show) + get("/:name/archive", EmojiAPIController, :archive) end end @@ -405,7 +405,7 @@ defmodule Pleroma.Web.Router do post("/notifications/clear", NotificationController, :clear) delete("/notifications/destroy_multiple", NotificationController, :destroy_multiple) # Deprecated: was removed in Mastodon v3, use `/notifications/:id/dismiss` instead - post("/notifications/dismiss", NotificationController, :dismiss) + post("/notifications/dismiss", NotificationController, :dismiss_via_body) post("/polls/:id/votes", PollController, :vote) @@ -594,6 +594,7 @@ defmodule Pleroma.Web.Router do post("/users/:nickname/outbox", ActivityPubController, :update_outbox) post("/api/ap/upload_media", ActivityPubController, :upload_media) + # The following two are S2S as well, see `ActivityPub.fetch_follow_information_for_user/1`: get("/users/:nickname/followers", ActivityPubController, :followers) get("/users/:nickname/following", ActivityPubController, :following) end diff --git a/lib/pleroma/web/templates/layout/static_fe.html.eex b/lib/pleroma/web/templates/layout/static_fe.html.eex index 819632cec..dc0ee2a5c 100644 --- a/lib/pleroma/web/templates/layout/static_fe.html.eex +++ b/lib/pleroma/web/templates/layout/static_fe.html.eex @@ -5,7 +5,7 @@ <meta name="viewport" content="width=device-width,initial-scale=1,minimal-ui" /> <title><%= Pleroma.Config.get([:instance, :name]) %></title> <%= Phoenix.HTML.raw(assigns[:meta] || "") %> - <link rel="stylesheet" href="/static/static-fe.css"> + <link rel="stylesheet" href="/static-fe/static-fe.css"> </head> <body> <div class="container"> diff --git a/lib/pleroma/web/twitter_api/twitter_api.ex b/lib/pleroma/web/twitter_api/twitter_api.ex index cf1d9c74c..5cfb385ac 100644 --- a/lib/pleroma/web/twitter_api/twitter_api.ex +++ b/lib/pleroma/web/twitter_api/twitter_api.ex @@ -3,54 +3,27 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.TwitterAPI.TwitterAPI do + import Pleroma.Web.Gettext + alias Pleroma.Emails.Mailer alias Pleroma.Emails.UserEmail alias Pleroma.Repo alias Pleroma.User alias Pleroma.UserInviteToken - require Pleroma.Constants - def register_user(params, opts \\ []) do params = params - |> Map.take([ - :nickname, - :password, - :captcha_solution, - :captcha_token, - :captcha_answer_data, - :token, - :email, - :trusted_app - ]) - |> Map.put(:bio, User.parse_bio(params[:bio] || "")) - |> Map.put(:name, params.fullname) - |> Map.put(:password_confirmation, params[:confirm]) - - case validate_captcha(params) do - :ok -> - if Pleroma.Config.get([:instance, :registrations_open]) do - create_user(params, opts) - else - create_user_with_invite(params, opts) - end + |> Map.take([:email, :token, :password]) + |> Map.put(:bio, params |> Map.get(:bio, "") |> User.parse_bio()) + |> Map.put(:nickname, params[:username]) + |> Map.put(:name, Map.get(params, :fullname, params[:username])) + |> Map.put(:password_confirmation, params[:password]) - {:error, error} -> - # I have no idea how this error handling works - {:error, %{error: Jason.encode!(%{captcha: [error]})}} - end - end - - defp validate_captcha(params) do - if params[:trusted_app] || not Pleroma.Config.get([Pleroma.Captcha, :enabled]) do - :ok + if Pleroma.Config.get([:instance, :registrations_open]) do + create_user(params, opts) else - Pleroma.Captcha.validate( - params.captcha_token, - params.captcha_solution, - params.captcha_answer_data - ) + create_user_with_invite(params, opts) end end @@ -75,16 +48,17 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do {:error, changeset} -> errors = - Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end) + changeset + |> Ecto.Changeset.traverse_errors(fn {msg, _opts} -> msg end) |> Jason.encode!() - {:error, %{error: errors}} + {:error, errors} end end def password_reset(nickname_or_email) do with true <- is_binary(nickname_or_email), - %User{local: true, email: email} = user when not is_nil(email) <- + %User{local: true, email: email} = user when is_binary(email) <- User.get_by_nickname_or_email(nickname_or_email), {:ok, token_record} <- Pleroma.PasswordResetToken.create_token(user) do user @@ -106,4 +80,58 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do {:error, "unknown user"} end end + + def validate_captcha(app, params) do + if app.trusted || not Pleroma.Captcha.enabled?() do + :ok + else + do_validate_captcha(params) + end + end + + defp do_validate_captcha(params) do + with :ok <- validate_captcha_presence(params), + :ok <- + Pleroma.Captcha.validate( + params[:captcha_token], + params[:captcha_solution], + params[:captcha_answer_data] + ) do + :ok + else + {:error, :captcha_error} -> + captcha_error(dgettext("errors", "CAPTCHA Error")) + + {:error, :invalid} -> + captcha_error(dgettext("errors", "Invalid CAPTCHA")) + + {:error, :kocaptcha_service_unavailable} -> + captcha_error(dgettext("errors", "Kocaptcha service unavailable")) + + {:error, :expired} -> + captcha_error(dgettext("errors", "CAPTCHA expired")) + + {:error, :already_used} -> + captcha_error(dgettext("errors", "CAPTCHA already used")) + + {:error, :invalid_answer_data} -> + captcha_error(dgettext("errors", "Invalid answer data")) + + {:error, error} -> + captcha_error(error) + end + end + + defp validate_captcha_presence(params) do + [:captcha_solution, :captcha_token, :captcha_answer_data] + |> Enum.find_value(:ok, fn key -> + unless is_binary(params[key]) do + error = dgettext("errors", "Invalid CAPTCHA (Missing parameter: %{name})", name: key) + {:error, error} + end + end) + end + + # For some reason FE expects error message to be a serialized JSON + defp captcha_error(error), do: {:error, Jason.encode!(%{captcha: [error]})} end |