From: rinpatch Date: Wed, 17 Apr 2019 09:22:32 +0000 (+0300) Subject: Merge branch 'develop' into feature/database-compaction X-Git-Url: http://git.squeep.com/?a=commitdiff_plain;h=627e5a0a4992cc19fc65a7e93a09c470c8e2bf33;hp=-c;p=akkoma Merge branch 'develop' into feature/database-compaction --- 627e5a0a4992cc19fc65a7e93a09c470c8e2bf33 diff --combined lib/mix/tasks/compact_database.ex index 7de50812a,000000000..17b9721f7 mode 100644,000000..100644 --- a/lib/mix/tasks/compact_database.ex +++ b/lib/mix/tasks/compact_database.ex @@@ -1,57 -1,0 +1,57 @@@ +defmodule Mix.Tasks.CompactDatabase do + @moduledoc """ + Compact the database by flattening the object graph. + """ + + require Logger + + use Mix.Task - import Mix.Ecto + import Ecto.Query - alias Pleroma.{Repo, Object, Activity} ++ alias Pleroma.Activity ++ alias Pleroma.Repo + + defp maybe_compact(%Activity{data: %{"object" => %{"id" => object_id}}} = activity) do + data = + activity.data + |> Map.put("object", object_id) + + {:ok, activity} = + Activity.change(activity, %{data: data}) + |> Repo.update() + + {:ok, activity} + end + + defp maybe_compact(%Activity{} = activity), do: {:ok, activity} + + defp activity_query(min_id, max_id) do + from( + a in Activity, + where: fragment("?->>'type' = 'Create'", a.data), + where: a.id >= ^min_id, + where: a.id < ^max_id + ) + end + - def run(args) do ++ def run(_args) do + Application.ensure_all_started(:pleroma) + + max = Repo.aggregate(Activity, :max, :id) + Logger.info("Considering #{max} activities") + + chunks = 0..round(max / 100) + + Enum.each(chunks, fn i -> + min = i * 100 + max = min + 100 + + activity_query(min, max) + |> Repo.all() + |> Enum.each(&maybe_compact/1) + + IO.write(".") + end) + + Logger.info("Finished.") + end +end diff --combined lib/pleroma/activity.ex index e3aa4eb97,e6507e5ca..99cc9c077 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@@ -1,18 -1,75 +1,76 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2019 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Activity do use Ecto.Schema - alias Pleroma.{Repo, Activity, Notification, Object} - import Ecto.{Query, Changeset} + + alias Pleroma.Activity + alias Pleroma.Notification + alias Pleroma.Object + alias Pleroma.Repo + ++ import Ecto.Changeset + import Ecto.Query + + @type t :: %__MODULE__{} + @primary_key {:id, Pleroma.FlakeId, autogenerate: true} + + # https://github.com/tootsuite/mastodon/blob/master/app/models/notification.rb#L19 + @mastodon_notification_types %{ + "Create" => "mention", + "Follow" => "follow", + "Announce" => "reblog", + "Like" => "favourite" + } + + @mastodon_to_ap_notification_types for {k, v} <- @mastodon_notification_types, + into: %{}, + do: {v, k} schema "activities" do field(:data, :map) field(:local, :boolean, default: true) field(:actor, :string) - field(:recipients, {:array, :string}) + field(:recipients, {:array, :string}, default: []) has_many(:notifications, Notification, on_delete: :delete_all) + # Attention: this is a fake relation, don't try to preload it blindly and expect it to work! + # The foreign key is embedded in a jsonb field. + # + # To use it, you probably want to do an inner join and a preload: + # + # ``` + # |> join(:inner, [activity], o in Object, + # on: fragment("(?->>'id') = COALESCE((?)->'object'->> 'id', (?)->>'object')", + # o.data, activity.data, activity.data)) + # |> preload([activity, object], [object: object]) + # ``` + # + # As a convenience, Activity.with_preloaded_object() sets up an inner join and preload for the + # typical case. + has_one(:object, Object, on_delete: :nothing, foreign_key: :id) + timestamps() end + def with_preloaded_object(query) do + query + |> join( + :inner, + [activity], + o in Object, + on: + fragment( + "(?->>'id') = COALESCE(?->'object'->>'id', ?->>'object')", + o.data, + activity.data, + activity.data + ) + ) + |> preload([activity, object], object: object) + end + def get_by_ap_id(ap_id) do Repo.one( from( @@@ -22,17 -79,45 +80,52 @@@ ) end + def change(struct, params \\ %{}) do + struct + |> cast(params, [:data]) + |> validate_required([:data]) + |> unique_constraint(:ap_id, name: :activities_unique_apid_index) + end + - # TODO: - # Go through these and fix them everywhere. - # Wrong name, only returns create activities - def all_by_object_ap_id_q(ap_id) do + def get_by_ap_id_with_object(ap_id) do + Repo.one( + from( + activity in Activity, + where: fragment("(?)->>'id' = ?", activity.data, ^to_string(ap_id)), + left_join: o in Object, + on: + fragment( + "(?->>'id') = COALESCE(?->'object'->>'id', ?->>'object')", + o.data, + activity.data, + activity.data + ), + preload: [object: o] + ) + ) + end + + def get_by_id(id) do + Repo.get(Activity, id) + end + + def get_by_id_with_object(id) do + from(activity in Activity, + where: activity.id == ^id, + inner_join: o in Object, + on: + fragment( + "(?->>'id') = COALESCE(?->'object'->>'id', ?->>'object')", + o.data, + activity.data, + activity.data + ), + preload: [object: o] + ) + |> Repo.one() + end + + def by_object_ap_id(ap_id) do from( activity in Activity, where: @@@ -41,13 -126,25 +134,25 @@@ activity.data, activity.data, ^to_string(ap_id) + ) + ) + end + + def create_by_object_ap_id(ap_ids) when is_list(ap_ids) do + from( + activity in Activity, + where: + fragment( + "coalesce((?)->'object'->>'id', (?)->>'object') = ANY(?)", + activity.data, + activity.data, + ^ap_ids ), where: fragment("(?)->>'type' = 'Create'", activity.data) ) end - # Wrong name, returns all. - def all_non_create_by_object_ap_id_q(ap_id) do + def create_by_object_ap_id(ap_id) when is_binary(ap_id) do from( activity in Activity, where: @@@ -56,47 -153,146 +161,150 @@@ activity.data, activity.data, ^to_string(ap_id) - ) + ), + where: fragment("(?)->>'type' = 'Create'", activity.data) ) end - # Wrong name plz fix thx - def all_by_object_ap_id(ap_id) do - Repo.all(all_by_object_ap_id_q(ap_id)) + def create_by_object_ap_id(_), do: nil + + def get_all_create_by_object_ap_id(ap_id) do + Repo.all(create_by_object_ap_id(ap_id)) end - def create_activity_by_object_id_query(ap_ids) do + def get_create_by_object_ap_id(ap_id) when is_binary(ap_id) do + create_by_object_ap_id(ap_id) + |> Repo.one() + end + + def get_create_by_object_ap_id(_), do: nil + + def create_by_object_ap_id_with_object(ap_id) when is_binary(ap_id) do from( activity in Activity, where: fragment( - "coalesce((?)->'object'->>'id', (?)->>'object') = ANY(?)", + "coalesce((?)->'object'->>'id', (?)->>'object') = ?", activity.data, activity.data, - ^ap_ids + ^to_string(ap_id) ), - where: fragment("(?)->>'type' = 'Create'", activity.data) + where: fragment("(?)->>'type' = 'Create'", activity.data), + inner_join: o in Object, + on: + fragment( + "(?->>'id') = COALESCE(?->'object'->>'id', ?->>'object')", + o.data, + activity.data, + activity.data + ), + preload: [object: o] ) end - def get_create_activity_by_object_ap_id(ap_id) when is_binary(ap_id) do - create_activity_by_object_id_query([ap_id]) + def create_by_object_ap_id_with_object(_), do: nil + + def get_create_by_object_ap_id_with_object(ap_id) do + ap_id + |> create_by_object_ap_id_with_object() |> Repo.one() end - def get_create_activity_by_object_ap_id(_), do: nil - - def normalize(obj) when is_map(obj), do: normalize(obj["id"]) - def normalize(ap_id) when is_binary(ap_id), do: get_by_ap_id(ap_id) - def normalize(obj) when is_map(obj), do: get_by_ap_id_with_object(obj["id"]) - def normalize(ap_id) when is_binary(ap_id), do: get_by_ap_id_with_object(ap_id) -- def normalize(_), do: nil - + defp get_in_reply_to_activity_from_object(%Object{data: %{"inReplyTo" => ap_id}}) do - get_create_activity_by_object_ap_id(ap_id) ++ get_create_by_object_ap_id_with_object(ap_id) + end + + defp get_in_reply_to_activity_from_object(_), do: nil - def get_in_reply_to_activity(%Activity{data: %{"object" => %{"inReplyTo" => ap_id}}}) do - get_create_by_object_ap_id(ap_id) + def get_in_reply_to_activity(%Activity{data: %{"object" => object}}) do + get_in_reply_to_activity_from_object(Object.normalize(object)) end + - def get_in_reply_to_activity(_), do: nil ++ def normalize(obj) when is_map(obj), do: get_by_ap_id_with_object(obj["id"]) ++ def normalize(ap_id) when is_binary(ap_id), do: get_by_ap_id_with_object(ap_id) ++ def normalize(_), do: nil + + def delete_by_ap_id(id) when is_binary(id) do + by_object_ap_id(id) + |> select([u], u) + |> Repo.delete_all() + |> elem(1) + |> Enum.find(fn + %{data: %{"type" => "Create", "object" => %{"id" => ap_id}}} -> ap_id == id + _ -> nil + end) + end + + def delete_by_ap_id(_), do: nil + + for {ap_type, type} <- @mastodon_notification_types do + def mastodon_notification_type(%Activity{data: %{"type" => unquote(ap_type)}}), + do: unquote(type) + end + + def mastodon_notification_type(%Activity{}), do: nil + + def from_mastodon_notification_type(type) do + Map.get(@mastodon_to_ap_notification_types, type) + end + + def all_by_actor_and_id(actor, status_ids \\ []) + def all_by_actor_and_id(_actor, []), do: [] + + def all_by_actor_and_id(actor, status_ids) do + Activity + |> where([s], s.id in ^status_ids) + |> where([s], s.actor == ^actor) + |> Repo.all() + end + + def increase_replies_count(nil), do: nil + + def increase_replies_count(object_ap_id) do + from(a in create_by_object_ap_id(object_ap_id), + update: [ + set: [ + data: + fragment( + """ + jsonb_set(?, '{object, repliesCount}', + (coalesce((?->'object'->>'repliesCount')::int, 0) + 1)::varchar::jsonb, true) + """, + a.data, + a.data + ) + ] + ] + ) + |> Repo.update_all([]) + |> case do + {1, [activity]} -> activity + _ -> {:error, "Not found"} + end + end + + def decrease_replies_count(nil), do: nil + + def decrease_replies_count(object_ap_id) do + from(a in create_by_object_ap_id(object_ap_id), + update: [ + set: [ + data: + fragment( + """ + jsonb_set(?, '{object, repliesCount}', + (greatest(0, (?->'object'->>'repliesCount')::int - 1))::varchar::jsonb, true) + """, + a.data, + a.data + ) + ] + ] + ) + |> Repo.update_all([]) + |> case do + {1, [activity]} -> activity + _ -> {:error, "Not found"} + end + end end diff --combined lib/pleroma/gopher/server.ex index 1ab15611c,6a56a6f67..2ebc5d5f7 --- a/lib/pleroma/gopher/server.ex +++ b/lib/pleroma/gopher/server.ex @@@ -1,8 -1,12 +1,12 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2019 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Gopher.Server do use GenServer require Logger - def start_link() do + def start_link do config = Pleroma.Config.get(:gopher, []) ip = Keyword.get(config, :ip, {0, 0, 0, 0}) port = Keyword.get(config, :port, 1234) @@@ -22,7 -26,7 +26,7 @@@ :gopher, 100, :ranch_tcp, - [port: port], + [ip: ip, port: port], __MODULE__.ProtocolHandler, [] ) @@@ -32,19 -36,18 +36,19 @@@ end defmodule Pleroma.Gopher.Server.ProtocolHandler do - alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.User alias Pleroma.Activity - alias Pleroma.Repo alias Pleroma.HTML + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Visibility def start_link(ref, socket, transport, opts) do pid = spawn_link(__MODULE__, :init, [ref, socket, transport, opts]) {:ok, pid} end - def init(ref, socket, transport, _Opts = []) do + def init(ref, socket, transport, [] = _Opts) do :ok = :ranch.accept_ack(ref) loop(socket, transport) end @@@ -62,7 -65,8 +66,8 @@@ def link(name, selector, type \\ 1) do address = Pleroma.Web.Endpoint.host() port = Pleroma.Config.get([:gopher, :port], 1234) - "#{type}#{name}\t#{selector}\t#{address}\t#{port}\r\n" + dstport = Pleroma.Config.get([:gopher, :dstport], port) + "#{type}#{name}\t#{selector}\t#{address}\t#{dstport}\r\n" end def render_activities(activities) do @@@ -71,14 -75,14 +76,14 @@@ |> Enum.map(fn activity -> user = User.get_cached_by_ap_id(activity.data["actor"]) - object = activity.data["object"] + object = Object.normalize(activity.data["object"]) like_count = object["like_count"] || 0 announcement_count = object["announcement_count"] || 0 link("Post ##{activity.id} by #{user.nickname}", "/notices/#{activity.id}") <> info("#{like_count} likes, #{announcement_count} repeats") <> "i\tfake\t(NULL)\t0\r\n" <> - info(HTML.strip_tags(String.replace(activity.data["object"]["content"], "
", "\r"))) + info(HTML.strip_tags(String.replace(object["content"], "
", "\r"))) end) |> Enum.join("i\tfake\t(NULL)\t0\r\n") end @@@ -106,8 -110,8 +111,8 @@@ end def response("/notices/" <> id) do - with %Activity{} = activity <- Repo.get(Activity, id), - true <- ActivityPub.is_public?(activity) do + with %Activity{} = activity <- Activity.get_by_id(id), + true <- Visibility.is_public?(activity) do activities = ActivityPub.fetch_activities_for_context(activity.data["context"]) |> render_activities diff --combined lib/pleroma/html.ex index 1b920d7fd,7f1dbe28c..4b42d8c9b --- a/lib/pleroma/html.ex +++ b/lib/pleroma/html.ex @@@ -1,3 -1,7 +1,7 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2019 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.HTML do alias HtmlSanitizeEx.Scrubber @@@ -5,26 -9,82 +9,83 @@@ defp get_scrubbers(scrubbers) when is_list(scrubbers), do: scrubbers defp get_scrubbers(_), do: [Pleroma.HTML.Scrubber.Default] - def get_scrubbers() do + def get_scrubbers do Pleroma.Config.get([:markup, :scrub_policy]) |> get_scrubbers end def filter_tags(html, nil) do - get_scrubbers() - |> Enum.reduce(html, fn scrubber, html -> + filter_tags(html, get_scrubbers()) + end + + def filter_tags(html, scrubbers) when is_list(scrubbers) do + Enum.reduce(scrubbers, html, fn scrubber, html -> filter_tags(html, scrubber) end) end - def filter_tags(html, scrubber) do - html |> Scrubber.scrub(scrubber) + def filter_tags(html, scrubber), do: Scrubber.scrub(html, scrubber) + def filter_tags(html), do: filter_tags(html, nil) + def strip_tags(html), do: Scrubber.scrub(html, Scrubber.StripTags) + + def get_cached_scrubbed_html_for_activity(content, scrubbers, activity, key \\ "") do + key = "#{key}#{generate_scrubber_signature(scrubbers)}|#{activity.id}" + + Cachex.fetch!(:scrubber_cache, key, fn _key -> - ensure_scrubbed_html(content, scrubbers, activity.data["object"]["fake"] || false) ++ object = Pleroma.Object.normalize(activity) ++ ensure_scrubbed_html(content, scrubbers, object.data["fake"] || false) + end) + end + + def get_cached_stripped_html_for_activity(content, activity, key) do + get_cached_scrubbed_html_for_activity( + content, + HtmlSanitizeEx.Scrubber.StripTags, + activity, + key + ) end - def filter_tags(html), do: filter_tags(html, nil) + def ensure_scrubbed_html( + content, + scrubbers, + false = _fake + ) do + {:commit, filter_tags(content, scrubbers)} + end + + def ensure_scrubbed_html( + content, + scrubbers, + true = _fake + ) do + {:ignore, filter_tags(content, scrubbers)} + end + + defp generate_scrubber_signature(scrubber) when is_atom(scrubber) do + generate_scrubber_signature([scrubber]) + end + + defp generate_scrubber_signature(scrubbers) do + Enum.reduce(scrubbers, "", fn scrubber, signature -> + "#{signature}#{to_string(scrubber)}" + end) + end - def strip_tags(html) do - html |> Scrubber.scrub(Scrubber.StripTags) + def extract_first_external_url(_, nil), do: {:error, "No content"} + + def extract_first_external_url(object, content) do + key = "URL|#{object.id}" + + Cachex.fetch!(:scrubber_cache, key, fn _key -> + result = + content + |> Floki.filter_out("a.mention") + |> Floki.attribute("a", "href") + |> Enum.at(0) + + {:commit, {:ok, result}} + end) end end @@@ -35,8 -95,7 +96,7 @@@ defmodule Pleroma.HTML.Scrubber.Twitter """ @markup Application.get_env(:pleroma, :markup) - @uri_schemes Application.get_env(:pleroma, :uri_schemes, []) - @valid_schemes Keyword.get(@uri_schemes, :valid_schemes, []) + @valid_schemes Pleroma.Config.get([:uri_schemes, :valid_schemes], []) require HtmlSanitizeEx.Scrubber.Meta alias HtmlSanitizeEx.Scrubber.Meta @@@ -45,15 -104,22 +105,22 @@@ Meta.strip_comments() # links - Meta.allow_tag_with_uri_attributes("a", ["href"], @valid_schemes) - Meta.allow_tag_with_these_attributes("a", ["name", "title"]) + Meta.allow_tag_with_uri_attributes("a", ["href", "data-user", "data-tag"], @valid_schemes) + Meta.allow_tag_with_these_attributes("a", ["name", "title", "class"]) + + Meta.allow_tag_with_this_attribute_values("a", "rel", [ + "tag", + "nofollow", + "noopener", + "noreferrer" + ]) # paragraphs and linebreaks Meta.allow_tag_with_these_attributes("br", []) Meta.allow_tag_with_these_attributes("p", []) # microformats - Meta.allow_tag_with_these_attributes("span", []) + Meta.allow_tag_with_these_attributes("span", ["class"]) # allow inline images for custom emoji @allow_inline_images Keyword.get(@markup, :allow_inline_images) @@@ -78,16 -144,24 +145,24 @@@ defmodule Pleroma.HTML.Scrubber.Defaul require HtmlSanitizeEx.Scrubber.Meta alias HtmlSanitizeEx.Scrubber.Meta + # credo:disable-for-previous-line + # No idea how to fix this one… @markup Application.get_env(:pleroma, :markup) - @uri_schemes Application.get_env(:pleroma, :uri_schemes, []) - @valid_schemes Keyword.get(@uri_schemes, :valid_schemes, []) + @valid_schemes Pleroma.Config.get([:uri_schemes, :valid_schemes], []) Meta.remove_cdata_sections_before_scrub() Meta.strip_comments() - Meta.allow_tag_with_uri_attributes("a", ["href"], @valid_schemes) - Meta.allow_tag_with_these_attributes("a", ["name", "title"]) + Meta.allow_tag_with_uri_attributes("a", ["href", "data-user", "data-tag"], @valid_schemes) + Meta.allow_tag_with_these_attributes("a", ["name", "title", "class"]) + + Meta.allow_tag_with_this_attribute_values("a", "rel", [ + "tag", + "nofollow", + "noopener", + "noreferrer" + ]) Meta.allow_tag_with_these_attributes("abbr", ["title"]) @@@ -102,7 -176,7 +177,7 @@@ Meta.allow_tag_with_these_attributes("ol", []) Meta.allow_tag_with_these_attributes("p", []) Meta.allow_tag_with_these_attributes("pre", []) - Meta.allow_tag_with_these_attributes("span", []) + Meta.allow_tag_with_these_attributes("span", ["class"]) Meta.allow_tag_with_these_attributes("strong", []) Meta.allow_tag_with_these_attributes("u", []) Meta.allow_tag_with_these_attributes("ul", []) @@@ -166,7 -240,7 +241,7 @@@ defmodule Pleroma.HTML.Transform.MediaP {"src", media_url} end - def scrub_attribute(tag, attribute), do: attribute + def scrub_attribute(_tag, attribute), do: attribute def scrub({"img", attributes, children}) do attributes = @@@ -177,9 -251,9 +252,9 @@@ {"img", attributes, children} end - def scrub({:comment, children}), do: "" + def scrub({:comment, _children}), do: "" def scrub({tag, attributes, children}), do: {tag, attributes, children} - def scrub({tag, children}), do: children + def scrub({_tag, children}), do: children def scrub(text), do: text end diff --combined lib/pleroma/object.ex index 0e9aefb63,013d62157..3f1d0fea1 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@@ -1,8 -1,20 +1,21 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2019 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Object do use Ecto.Schema - alias Pleroma.{Repo, Object, Activity} + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.Object.Fetcher - import Ecto.{Query, Changeset} + alias Pleroma.ObjectTombstone + alias Pleroma.Repo + alias Pleroma.User + + import Ecto.Query + import Ecto.Changeset + + require Logger schema "objects" do field(:data, :map) @@@ -28,42 -40,148 +41,150 @@@ Repo.one(from(object in Object, where: fragment("(?)->>'id' = ?", object.data, ^ap_id))) end + def normalize(_, fetch_remote \\ true) + # If we pass an Activity to Object.normalize(), we can try to use the preloaded object. + # Use this whenever possible, especially when walking graphs in an O(N) loop! - def normalize(%Activity{object: %Object{} = object}), do: object ++ def normalize(%Activity{object: %Object{} = object}, _), do: object - def normalize(obj, fetch_remote) when is_map(obj), do: normalize(obj["id"], fetch_remote) - def normalize(ap_id, true) when is_binary(ap_id), do: Fetcher.fetch_object_from_id!(ap_id) + # A hack for fake activities - def normalize(%Activity{data: %{"object" => %{"fake" => true} = data}}) do ++ def normalize(%Activity{data: %{"object" => %{"fake" => true} = data}}, _) do + %Object{id: "pleroma:fake_object_id", data: data} + end + + # Catch and log Object.normalize() calls where the Activity's child object is not + # preloaded. - def normalize(%Activity{data: %{"object" => %{"id" => ap_id}}}) do ++ def normalize(%Activity{data: %{"object" => %{"id" => ap_id}}}, fetch_remote) do + Logger.debug( + "Object.normalize() called without preloaded object (#{ap_id}). Consider preloading the object!" + ) + + Logger.debug("Backtrace: #{inspect(Process.info(:erlang.self(), :current_stacktrace))}") + - normalize(ap_id) ++ normalize(ap_id, fetch_remote) + end + - def normalize(%Activity{data: %{"object" => ap_id}}) do ++ def normalize(%Activity{data: %{"object" => ap_id}}, fetch_remote) do + Logger.debug( + "Object.normalize() called without preloaded object (#{ap_id}). Consider preloading the object!" + ) + + Logger.debug("Backtrace: #{inspect(Process.info(:erlang.self(), :current_stacktrace))}") + - normalize(ap_id) ++ normalize(ap_id, fetch_remote) + end + + # Old way, try fetching the object through cache. - def normalize(%{"id" => ap_id}), do: normalize(ap_id) - def normalize(ap_id) when is_binary(ap_id), do: get_cached_by_ap_id(ap_id) - def normalize(_), do: nil ++ def normalize(%{"id" => ap_id}, fetch_remote), do: normalize(ap_id, fetch_remote) + def normalize(ap_id, false) when is_binary(ap_id), do: get_cached_by_ap_id(ap_id) - def normalize(obj, _), do: nil ++ def normalize(ap_id, true) when is_binary(ap_id), do: Fetcher.fetch_object_from_id!(ap_id) ++ def normalize(_, _), do: nil - if Mix.env() == :test do - def get_cached_by_ap_id(ap_id) do - get_by_ap_id(ap_id) - end - else - def get_cached_by_ap_id(ap_id) do - key = "object:#{ap_id}" - - Cachex.fetch!(:object_cache, key, fn _ -> - object = get_by_ap_id(ap_id) - - if object do - {:commit, object} - else - {:ignore, object} - end - end) - end + # Owned objects can only be mutated by their owner + def authorize_mutation(%Object{data: %{"actor" => actor}}, %User{ap_id: ap_id}), + do: actor == ap_id + + # Legacy objects can be mutated by anybody + def authorize_mutation(%Object{}, %User{}), do: true + + def get_cached_by_ap_id(ap_id) do + key = "object:#{ap_id}" + + Cachex.fetch!(:object_cache, key, fn _ -> + object = get_by_ap_id(ap_id) + + if object do + {:commit, object} + else + {:ignore, object} + end + end) end def context_mapping(context) do Object.change(%Object{}, %{data: %{"id" => context}}) end + def make_tombstone(%Object{data: %{"id" => id, "type" => type}}, deleted \\ DateTime.utc_now()) do + %ObjectTombstone{ + id: id, + formerType: type, + deleted: deleted + } + |> Map.from_struct() + end + + def swap_object_with_tombstone(object) do + tombstone = make_tombstone(object) + + object + |> Object.change(%{data: tombstone}) + |> Repo.update() + end + def delete(%Object{data: %{"id" => id}} = object) do - with Repo.delete(object), - Repo.delete_all(Activity.all_non_create_by_object_ap_id_q(id)), + with {:ok, _obj} = swap_object_with_tombstone(object), + deleted_activity = Activity.delete_by_ap_id(id), {:ok, true} <- Cachex.del(:object_cache, "object:#{id}") do - {:ok, object} + {:ok, object, deleted_activity} + end + end + + def set_cache(%Object{data: %{"id" => ap_id}} = object) do + Cachex.put(:object_cache, "object:#{ap_id}", object) + {:ok, object} + end + + def update_and_set_cache(changeset) do + with {:ok, object} <- Repo.update(changeset) do + set_cache(object) + else + e -> e + end + end + + def increase_replies_count(ap_id) do + Object + |> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id))) + |> update([o], + set: [ + data: + fragment( + """ + jsonb_set(?, '{repliesCount}', + (coalesce((?->>'repliesCount')::int, 0) + 1)::varchar::jsonb, true) + """, + o.data, + o.data + ) + ] + ) + |> Repo.update_all([]) + |> case do + {1, [object]} -> set_cache(object) + _ -> {:error, "Not found"} + end + end + + def decrease_replies_count(ap_id) do + Object + |> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id))) + |> update([o], + set: [ + data: + fragment( + """ + jsonb_set(?, '{repliesCount}', + (greatest(0, (?->>'repliesCount')::int - 1))::varchar::jsonb, true) + """, + o.data, + o.data + ) + ] + ) + |> Repo.update_all([]) + |> case do + {1, [object]} -> set_cache(object) + _ -> {:error, "Not found"} end end end diff --combined lib/pleroma/object/containment.ex index 010b768bd,000000000..27e89d87f mode 100644,000000..100644 --- a/lib/pleroma/object/containment.ex +++ b/lib/pleroma/object/containment.ex @@@ -1,64 -1,0 +1,64 @@@ +defmodule Pleroma.Object.Containment do + @moduledoc """ + # Object Containment + + This module contains some useful functions for containing objects to specific + origins and determining those origins. They previously lived in the + ActivityPub `Transmogrifier` module. + + Object containment is an important step in validating remote objects to prevent + spoofing, therefore removal of object containment functions is NOT recommended. + """ + + require Logger + + def get_actor(%{"actor" => actor}) when is_binary(actor) do + actor + end + + def get_actor(%{"actor" => actor}) when is_list(actor) do + if is_binary(Enum.at(actor, 0)) do + Enum.at(actor, 0) + else + Enum.find(actor, fn %{"type" => type} -> type in ["Person", "Service", "Application"] end) + |> Map.get("id") + end + end + + def get_actor(%{"actor" => %{"id" => id}}) when is_bitstring(id) do + id + end + + def get_actor(%{"actor" => nil, "attributedTo" => actor}) when not is_nil(actor) do + get_actor(%{"actor" => actor}) + end + + @doc """ + Checks that an imported AP object's actor matches the domain it came from. + """ - def contain_origin(id, %{"actor" => nil}), do: :error ++ def contain_origin(_id, %{"actor" => nil}), do: :error + - def contain_origin(id, %{"actor" => actor} = params) do ++ def contain_origin(id, %{"actor" => _actor} = params) do + id_uri = URI.parse(id) + actor_uri = URI.parse(get_actor(params)) + + if id_uri.host == actor_uri.host do + :ok + else + :error + end + end + - def contain_origin_from_id(id, %{"id" => nil}), do: :error ++ def contain_origin_from_id(_id, %{"id" => nil}), do: :error + - def contain_origin_from_id(id, %{"id" => other_id} = params) do ++ def contain_origin_from_id(id, %{"id" => other_id} = _params) do + id_uri = URI.parse(id) + other_uri = URI.parse(other_id) + + if id_uri.host == other_uri.host do + :ok + else + :error + end + end +end diff --combined lib/pleroma/object/fetcher.ex index c98722f39,000000000..19d9c51af mode 100644,000000..100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@@ -1,78 -1,0 +1,78 @@@ +defmodule Pleroma.Object.Fetcher do - alias Pleroma.{Object, Repo} ++ alias Pleroma.Object + alias Pleroma.Object.Containment + alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.OStatus + + require Logger + + @httpoison Application.get_env(:pleroma, :httpoison) + + # TODO: + # This will create a Create activity, which we need internally at the moment. + def fetch_object_from_id(id) do + if object = Object.get_cached_by_ap_id(id) do + {:ok, object} + else + Logger.info("Fetching #{id} via AP") + + with {:ok, data} <- fetch_and_contain_remote_object_from_id(id), + nil <- Object.normalize(data, false), + params <- %{ + "type" => "Create", + "to" => data["to"], + "cc" => data["cc"], + "actor" => data["actor"] || data["attributedTo"], + "object" => data + }, + :ok <- Containment.contain_origin(id, params), + {:ok, activity} <- Transmogrifier.handle_incoming(params) do + {:ok, Object.normalize(activity.data["object"], false)} + else + {:error, {:reject, nil}} -> + {:reject, nil} + + object = %Object{} -> + {:ok, object} + + _e -> + Logger.info("Couldn't get object via AP, trying out OStatus fetching...") + + case OStatus.fetch_activity_from_url(id) do + {:ok, [activity | _]} -> {:ok, Object.normalize(activity.data["object"], false)} + e -> e + end + end + end + end + + def fetch_object_from_id!(id) do + with {:ok, object} <- fetch_object_from_id(id) do + object + else + _e -> + nil + end + end + + def fetch_and_contain_remote_object_from_id(id) do + Logger.info("Fetching #{id} via AP") + + with true <- String.starts_with?(id, "http"), + {:ok, %{body: body, status_code: code}} when code in 200..299 <- + @httpoison.get( + id, + [Accept: "application/activity+json"], + follow_redirect: true, + timeout: 10000, + recv_timeout: 20000 + ), + {:ok, data} <- Jason.decode(body), + :ok <- Containment.contain_origin_from_id(id, data) do + {:ok, data} + else + e -> + {:error, e} + end + end +end diff --combined lib/pleroma/web/activity_pub/activity_pub.ex index fefefc320,54dd4097c..1a3b47cb3 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@@ -1,12 -1,26 +1,26 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2019 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.ActivityPub.ActivityPub do - alias Pleroma.{Activity, Repo, Object, Upload, User, Notification} + alias Pleroma.Activity + alias Pleroma.Instances + alias Pleroma.Notification + alias Pleroma.Object + alias Pleroma.Object.Fetcher - alias Pleroma.Web.ActivityPub.{Transmogrifier, MRF} - alias Pleroma.Web.WebFinger + alias Pleroma.Pagination + alias Pleroma.Repo + alias Pleroma.Upload + alias Pleroma.User + alias Pleroma.Web.ActivityPub.MRF + alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.Federator -- alias Pleroma.Web.OStatus + alias Pleroma.Web.WebFinger + import Ecto.Query import Pleroma.Web.ActivityPub.Utils + import Pleroma.Web.ActivityPub.Visibility + require Logger @httpoison Application.get_env(:pleroma, :httpoison) @@@ -16,20 -30,28 +30,28 @@@ defp get_recipients(%{"type" => "Announce"} = data) do to = data["to"] || [] cc = data["cc"] || [] - recipients = to ++ cc actor = User.get_cached_by_ap_id(data["actor"]) - recipients - |> Enum.filter(fn recipient -> - case User.get_cached_by_ap_id(recipient) do - nil -> - true + recipients = + (to ++ cc) + |> Enum.filter(fn recipient -> + case User.get_cached_by_ap_id(recipient) do + nil -> + true + + user -> + User.following?(user, actor) + end + end) - user -> - User.following?(user, actor) - end - end) + {recipients, to, cc} + end + defp get_recipients(%{"type" => "Create"} = data) do + to = data["to"] || [] + cc = data["cc"] || [] + actor = data["actor"] || [] + recipients = (to ++ cc ++ [actor]) |> Enum.uniq() {recipients, to, cc} end @@@ -53,14 -75,53 +75,53 @@@ end end - def insert(map, local \\ true) when is_map(map) do + defp check_remote_limit(%{"object" => %{"content" => content}}) when not is_nil(content) do + limit = Pleroma.Config.get([:instance, :remote_limit]) + String.length(content) <= limit + end + + defp check_remote_limit(_), do: true + + def increase_note_count_if_public(actor, object) do + if is_public?(object), do: User.increase_note_count(actor), else: {:ok, actor} + end + + def decrease_note_count_if_public(actor, object) do + if is_public?(object), do: User.decrease_note_count(actor), else: {:ok, actor} + end + + def increase_replies_count_if_reply(%{ + "object" => %{"inReplyTo" => reply_ap_id} = object, + "type" => "Create" + }) do + if is_public?(object) do + Activity.increase_replies_count(reply_ap_id) + Object.increase_replies_count(reply_ap_id) + end + end + + def increase_replies_count_if_reply(_create_data), do: :noop + + def decrease_replies_count_if_reply(%Object{ + data: %{"inReplyTo" => reply_ap_id} = object + }) do + if is_public?(object) do + Activity.decrease_replies_count(reply_ap_id) + Object.decrease_replies_count(reply_ap_id) + end + end + + def decrease_replies_count_if_reply(_object), do: :noop + + def insert(map, local \\ true, fake \\ false) when is_map(map) do with nil <- Activity.normalize(map), - map <- lazy_put_activity_defaults(map), + map <- lazy_put_activity_defaults(map, fake), :ok <- check_actor_is_active(map["actor"]), + {_, true} <- {:remote_limit_error, check_remote_limit(map)}, {:ok, map} <- MRF.filter(map), - {:ok, map} <- insert_full_object(map) do - {recipients, _, _} = get_recipients(map) - + {recipients, _, _} = get_recipients(map), + {:fake, false, map, recipients} <- {:fake, fake, map, recipients}, - {:ok, object} <- insert_full_object(map) do ++ {:ok, map, object} <- insert_full_object(map) do {:ok, activity} = Repo.insert(%Activity{ data: map, @@@ -69,21 -130,46 +130,47 @@@ recipients: recipients }) + # Splice in the child object if we have one. + activity = + if !is_nil(object) do + Map.put(activity, :object, object) + else + activity + end + + Task.start(fn -> + Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) + end) + Notification.create_notifications(activity) stream_out(activity) {:ok, activity} else - %Activity{} = activity -> {:ok, activity} - error -> {:error, error} + %Activity{} = activity -> + {:ok, activity} + + {:fake, true, map, recipients} -> + activity = %Activity{ + data: map, + local: local, + actor: map["actor"], + recipients: recipients, + id: "pleroma:fakeid" + } + + Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) + {:ok, activity} + + error -> + {:error, error} end end def stream_out(activity) do public = "https://www.w3.org/ns/activitystreams#Public" - if activity.data["type"] in ["Create", "Announce", "Delete"] do + if activity.data["type"] in ["Create", "Announce"] do + object = Object.normalize(activity.data["object"]) - Pleroma.Web.Streamer.stream("user", activity) Pleroma.Web.Streamer.stream("list", activity) @@@ -94,16 -180,18 +181,18 @@@ Pleroma.Web.Streamer.stream("public:local", activity) end - object.data - |> Map.get("tag", []) - |> Enum.filter(fn tag -> is_bitstring(tag) end) - |> Enum.map(fn tag -> Pleroma.Web.Streamer.stream("hashtag:" <> tag, activity) end) + if activity.data["type"] in ["Create"] do - activity.data["object"] ++ object.data + |> Map.get("tag", []) + |> Enum.filter(fn tag -> is_bitstring(tag) end) + |> Enum.each(fn tag -> Pleroma.Web.Streamer.stream("hashtag:" <> tag, activity) end) - if object.data["attachment"] != [] do - Pleroma.Web.Streamer.stream("public:media", activity) - if activity.data["object"]["attachment"] != [] do ++ if object.data["attachment"] != [] do + Pleroma.Web.Streamer.stream("public:media", activity) - if activity.local do - Pleroma.Web.Streamer.stream("public:local:media", activity) + if activity.local do + Pleroma.Web.Streamer.stream("public:local:media", activity) + end end end else @@@ -117,7 -205,7 +206,7 @@@ end end - def create(%{to: to, actor: actor, context: context, object: object} = params) do + def create(%{to: to, actor: actor, context: context, object: object} = params, fake \\ false) do additional = params[:additional] || %{} # only accept false as false value local = !(params[:local] == false) @@@ -128,10 -216,17 +217,17 @@@ %{to: to, actor: actor, published: published, context: context, object: object}, additional ), - {:ok, activity} <- insert(create_data, local), - :ok <- maybe_federate(activity), - {:ok, _actor} <- User.increase_note_count(actor) do + {:ok, activity} <- insert(create_data, local, fake), + {:fake, false, activity} <- {:fake, fake, activity}, + _ <- increase_replies_count_if_reply(create_data), + # Changing note count prior to enqueuing federation task in order to avoid + # race conditions on updating user.info + {:ok, _actor} <- increase_note_count_if_public(actor, activity), + :ok <- maybe_federate(activity) do {:ok, activity} + else + {:fake, true, activity} -> + {:ok, activity} end end @@@ -139,7 -234,7 +235,7 @@@ # only accept false as false value local = !(params[:local] == false) - with data <- %{"to" => to, "type" => "Accept", "actor" => actor, "object" => object}, + with data <- %{"to" => to, "type" => "Accept", "actor" => actor.ap_id, "object" => object}, {:ok, activity} <- insert(data, local), :ok <- maybe_federate(activity) do {:ok, activity} @@@ -150,7 -245,7 +246,7 @@@ # only accept false as false value local = !(params[:local] == false) - with data <- %{"to" => to, "type" => "Reject", "actor" => actor, "object" => object}, + with data <- %{"to" => to, "type" => "Reject", "actor" => actor.ap_id, "object" => object}, {:ok, activity} <- insert(data, local), :ok <- maybe_federate(activity) do {:ok, activity} @@@ -215,10 -310,11 +311,11 @@@ %User{ap_id: _} = user, %Object{data: %{"id" => _}} = object, activity_id \\ nil, - local \\ true + local \\ true, + public \\ true ) do with true <- is_public?(object), - announce_data <- make_announce_data(user, object, activity_id), + announce_data <- make_announce_data(user, object, activity_id, public), {:ok, activity} <- insert(announce_data, local), {:ok, object} <- add_announce_to_object(activity, object), :ok <- maybe_federate(activity) do @@@ -266,18 -362,22 +363,22 @@@ def delete(%Object{data: %{"id" => id, "actor" => actor}} = object, local \\ true) do user = User.get_cached_by_ap_id(actor) + to = (object.data["to"] || []) ++ (object.data["cc"] || []) - data = %{ - "type" => "Delete", - "actor" => actor, - "object" => id, - "to" => [user.follower_address, "https://www.w3.org/ns/activitystreams#Public"] - } - - with {:ok, _} <- Object.delete(object), + with {:ok, object, activity} <- Object.delete(object), + data <- %{ + "type" => "Delete", + "actor" => actor, + "object" => id, + "to" => to, + "deleted_activity_id" => activity && activity.id + }, {:ok, activity} <- insert(data, local), - :ok <- maybe_federate(activity), - {:ok, _actor} <- User.decrease_note_count(user) do + _ <- decrease_replies_count_if_reply(object), + # Changing note count prior to enqueuing federation task in order to avoid + # race conditions on updating user.info + {:ok, _actor} <- decrease_note_count_if_public(user, object), + :ok <- maybe_federate(activity) do {:ok, activity} end end @@@ -314,6 -414,49 +415,49 @@@ end end + def flag( + %{ + actor: actor, + context: context, + account: account, + statuses: statuses, + content: content + } = params + ) do + # only accept false as false value + local = !(params[:local] == false) + forward = !(params[:forward] == false) + + additional = params[:additional] || %{} + + params = %{ + actor: actor, + context: context, + account: account, + statuses: statuses, + content: content + } + + additional = + if forward do + Map.merge(additional, %{"to" => [], "cc" => [account.ap_id]}) + else + Map.merge(additional, %{"to" => [], "cc" => []}) + end + + with flag_data <- make_flag_data(params, additional), + {:ok, activity} <- insert(flag_data, local), + :ok <- maybe_federate(activity) do + Enum.each(User.all_superusers(), fn superuser -> + superuser + |> Pleroma.Emails.AdminEmail.report(actor, account, statuses, content) + |> Pleroma.Emails.Mailer.deliver_async() + end) + + {:ok, activity} + end + end + def fetch_activities_for_context(context, opts \\ %{}) do public = ["https://www.w3.org/ns/activitystreams#Public"] @@@ -340,6 -483,7 +484,7 @@@ ), order_by: [desc: :id] ) + |> Activity.with_preloaded_object() Repo.all(query) end @@@ -349,27 -493,48 +494,48 @@@ q |> restrict_unlisted() - |> Repo.all() + |> Pagination.fetch_paginated(opts) |> Enum.reverse() end @valid_visibilities ~w[direct unlisted public private] - defp restrict_visibility(query, %{visibility: "direct"}) do - public = "https://www.w3.org/ns/activitystreams#Public" - - from( - activity in query, - join: sender in User, - on: sender.ap_id == activity.actor, - # Are non-direct statuses with no to/cc possible? - where: - fragment( - "not (? && ?)", - [^public, sender.follower_address], - activity.recipients + defp restrict_visibility(query, %{visibility: visibility}) + when is_list(visibility) do + if Enum.all?(visibility, &(&1 in @valid_visibilities)) do + query = + from( + a in query, + where: + fragment( + "activity_visibility(?, ?, ?) = ANY (?)", + a.actor, + a.recipients, + a.data, + ^visibility + ) ) - ) + + Ecto.Adapters.SQL.to_sql(:all, Repo, query) + + query + else + Logger.error("Could not restrict visibility to #{visibility}") + end + end + + defp restrict_visibility(query, %{visibility: visibility}) + when visibility in @valid_visibilities do + query = + from( + a in query, + where: + fragment("activity_visibility(?, ?, ?) = ?", a.actor, a.recipients, a.data, ^visibility) + ) + + Ecto.Adapters.SQL.to_sql(:all, Repo, query) + + query end defp restrict_visibility(_query, %{visibility: visibility}) @@@ -385,6 -550,7 +551,7 @@@ |> Map.put("type", ["Create", "Announce"]) |> Map.put("actor_id", user.ap_id) |> Map.put("whole_db", true) + |> Map.put("pinned_activity_ids", user.info.pinned_activities) recipients = if reading_user do @@@ -398,16 -564,45 +565,45 @@@ |> Enum.reverse() end + defp restrict_since(query, %{"since_id" => ""}), do: query + defp restrict_since(query, %{"since_id" => since_id}) do from(activity in query, where: activity.id > ^since_id) end defp restrict_since(query, _), do: query - defp restrict_tag(query, %{"tag" => tag}) do + defp restrict_tag_reject(query, %{"tag_reject" => tag_reject}) + when is_list(tag_reject) and tag_reject != [] do + from( + activity in query, + where: fragment(~s(\(not \(? #> '{"object","tag"}'\) \\?| ?\)), activity.data, ^tag_reject) + ) + end + + defp restrict_tag_reject(query, _), do: query + + defp restrict_tag_all(query, %{"tag_all" => tag_all}) + when is_list(tag_all) and tag_all != [] do from( activity in query, - where: fragment("? <@ (? #> '{\"object\",\"tag\"}')", ^tag, activity.data) + where: fragment(~s(\(? #> '{"object","tag"}'\) \\?& ?), activity.data, ^tag_all) + ) + end + + defp restrict_tag_all(query, _), do: query + + defp restrict_tag(query, %{"tag" => tag}) when is_list(tag) do + from( + activity in query, + where: fragment(~s(\(? #> '{"object","tag"}'\) \\?| ?), activity.data, ^tag) + ) + end + + defp restrict_tag(query, %{"tag" => tag}) when is_binary(tag) do + from( + activity in query, + where: fragment(~s(? <@ (? #> '{"object","tag"}'\)), ^tag, activity.data) ) end @@@ -441,24 -636,12 +637,12 @@@ ) end - defp restrict_limit(query, %{"limit" => limit}) do - from(activity in query, limit: ^limit) - end - - defp restrict_limit(query, _), do: query - defp restrict_local(query, %{"local_only" => true}) do from(activity in query, where: activity.local == true) end defp restrict_local(query, _), do: query - defp restrict_max(query, %{"max_id" => max_id}) do - from(activity in query, where: activity.id < ^max_id) - end - - defp restrict_max(query, _), do: query - defp restrict_actor(query, %{"actor_id" => actor_id}) do from(activity in query, where: activity.actor == ^actor_id) end @@@ -466,7 -649,7 +650,7 @@@ defp restrict_actor(query, _), do: query defp restrict_type(query, %{"type" => type}) when is_binary(type) do - restrict_type(query, %{"type" => [type]}) + from(activity in query, where: fragment("?->>'type' = ?", activity.data, ^type)) end defp restrict_type(query, %{"type" => type}) do @@@ -478,7 -661,7 +662,7 @@@ defp restrict_favorited_by(query, %{"favorited_by" => ap_id}) do from( activity in query, - where: fragment("? <@ (? #> '{\"object\",\"likes\"}')", ^ap_id, activity.data) + where: fragment(~s(? <@ (? #> '{"object","likes"}'\)), ^ap_id, activity.data) ) end @@@ -487,7 -670,7 +671,7 @@@ defp restrict_media(query, %{"only_media" => val}) when val == "true" or val == "1" do from( activity in query, - where: fragment("not (? #> '{\"object\",\"attachment\"}' = ?)", activity.data, ^[]) + where: fragment(~s(not (? #> '{"object","attachment"}' = ?\)), activity.data, ^[]) ) end @@@ -502,15 -685,26 +686,26 @@@ defp restrict_replies(query, _), do: query - # Only search through last 100_000 activities by default - defp restrict_recent(query, %{"whole_db" => true}), do: query + defp restrict_reblogs(query, %{"exclude_reblogs" => val}) when val == "true" or val == "1" do + from(activity in query, where: fragment("?->>'type' != 'Announce'", activity.data)) + end + + defp restrict_reblogs(query, _), do: query + + defp restrict_muted(query, %{"with_muted" => val}) when val in [true, "true", "1"], do: query - defp restrict_recent(query, _) do - since = (Repo.aggregate(Activity, :max, :id) || 0) - 100_000 + defp restrict_muted(query, %{"muting_user" => %User{info: info}}) do + mutes = info.mutes - from(activity in query, where: activity.id > ^since) + from( + activity in query, + where: fragment("not (? = ANY(?))", activity.actor, ^mutes), + where: fragment("not (?->'to' \\?| ?)", activity.data, ^mutes) + ) end + defp restrict_muted(query, _), do: query + defp restrict_blocked(query, %{"blocking_user" => %User{info: info}}) do blocks = info.blocks || [] domain_blocks = info.domain_blocks || [] @@@ -537,47 -731,83 +732,83 @@@ ) end + defp restrict_pinned(query, %{"pinned" => "true", "pinned_activity_ids" => ids}) do + from(activity in query, where: activity.id in ^ids) + end + + defp restrict_pinned(query, _), do: query + + defp restrict_muted_reblogs(query, %{"muting_user" => %User{info: info}}) do + muted_reblogs = info.muted_reblogs || [] + + from( + activity in query, + where: + fragment( + "not ( ?->>'type' = 'Announce' and ? = ANY(?))", + activity.data, + activity.actor, + ^muted_reblogs + ) + ) + end + + defp restrict_muted_reblogs(query, _), do: query + + defp maybe_preload_objects(query, %{"skip_preload" => true}), do: query + + defp maybe_preload_objects(query, _) do + query + |> Activity.with_preloaded_object() + end + def fetch_activities_query(recipients, opts \\ %{}) do - base_query = - from( - activity in Activity, - limit: 20, - order_by: [fragment("? desc nulls last", activity.id)] - ) + base_query = from(activity in Activity) base_query + |> maybe_preload_objects(opts) |> restrict_recipients(recipients, opts["user"]) |> restrict_tag(opts) + |> restrict_tag_reject(opts) + |> restrict_tag_all(opts) |> restrict_since(opts) |> restrict_local(opts) - |> restrict_limit(opts) - |> restrict_max(opts) |> restrict_actor(opts) |> restrict_type(opts) |> restrict_favorited_by(opts) - |> restrict_recent(opts) |> restrict_blocked(opts) + |> restrict_muted(opts) |> restrict_media(opts) |> restrict_visibility(opts) |> restrict_replies(opts) + |> restrict_reblogs(opts) + |> restrict_pinned(opts) + |> restrict_muted_reblogs(opts) end def fetch_activities(recipients, opts \\ %{}) do fetch_activities_query(recipients, opts) - |> Repo.all() + |> Pagination.fetch_paginated(opts) |> Enum.reverse() end def fetch_activities_bounded(recipients_to, recipients_cc, opts \\ %{}) do fetch_activities_query([], opts) |> restrict_to_cc(recipients_to, recipients_cc) - |> Repo.all() + |> Pagination.fetch_paginated(opts) |> Enum.reverse() end def upload(file, opts \\ []) do with {:ok, data} <- Upload.store(file, opts) do - Repo.insert(%Object{data: data}) + obj_data = + if opts[:actor] do + Map.put(data, "actor", opts[:actor]) + else + data + end + + Repo.insert(%Object{data: obj_data}) end end @@@ -629,7 -859,7 +860,7 @@@ end def fetch_and_prepare_user_from_ap_id(ap_id) do - with {:ok, data} <- fetch_and_contain_remote_object_from_id(ap_id) do + with {:ok, data} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id) do user_data_from_user_object(data) else e -> Logger.error("Could not decode user at fetch #{ap_id}, #{inspect(e)}") @@@ -666,7 -896,7 +897,7 @@@ end def publish(actor, activity) do - followers = + remote_followers = if actor.follower_address in activity.recipients do {:ok, followers} = User.get_followers(actor) followers |> Enum.filter(&(!&1.local)) @@@ -676,87 -906,123 +907,69 @@@ public = is_public?(activity) - remote_inboxes = - (Pleroma.Web.Salmon.remote_users(activity) ++ followers) - |> Enum.filter(fn user -> User.ap_enabled?(user) end) - |> Enum.map(fn %{info: %{source_data: data}} -> - (is_map(data["endpoints"]) && Map.get(data["endpoints"], "sharedInbox")) || data["inbox"] - end) - |> Enum.uniq() - |> Enum.filter(fn inbox -> should_federate?(inbox, public) end) - {:ok, data} = Transmogrifier.prepare_outgoing(activity.data) json = Jason.encode!(data) - Enum.each(remote_inboxes, fn inbox -> - Federator.enqueue(:publish_single_ap, %{ + (Pleroma.Web.Salmon.remote_users(activity) ++ remote_followers) + |> Enum.filter(fn user -> User.ap_enabled?(user) end) + |> Enum.map(fn %{info: %{source_data: data}} -> + (is_map(data["endpoints"]) && Map.get(data["endpoints"], "sharedInbox")) || data["inbox"] + end) + |> Enum.uniq() + |> Enum.filter(fn inbox -> should_federate?(inbox, public) end) + |> Instances.filter_reachable() + |> Enum.each(fn {inbox, unreachable_since} -> + Federator.publish_single_ap(%{ inbox: inbox, json: json, actor: actor, - id: activity.data["id"] + id: activity.data["id"], + unreachable_since: unreachable_since }) end) end - def publish_one(%{inbox: inbox, json: json, actor: actor, id: id}) do + def publish_one(%{inbox: inbox, json: json, actor: actor, id: id} = params) do Logger.info("Federating #{id} to #{inbox}") host = URI.parse(inbox).host digest = "SHA-256=" <> (:crypto.hash(:sha256, json) |> Base.encode64()) + date = + NaiveDateTime.utc_now() + |> Timex.format!("{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT") + signature = Pleroma.Web.HTTPSignatures.sign(actor, %{ host: host, "content-length": byte_size(json), - digest: digest + digest: digest, + date: date }) - @httpoison.post( - inbox, - json, - [ - {"Content-Type", "application/activity+json"}, - {"signature", signature}, - {"digest", digest} - ], - hackney: [pool: :default] - ) - end - - def is_public?(activity) do - "https://www.w3.org/ns/activitystreams#Public" in (activity.data["to"] ++ - (activity.data["cc"] || [])) - end - - def visible_for_user?(activity, nil) do - is_public?(activity) - end - - def visible_for_user?(activity, user) do - x = [user.ap_id | user.following] - y = activity.data["to"] ++ (activity.data["cc"] || []) - visible_for_user?(activity, nil) || Enum.any?(x, &(&1 in y)) - end - - # guard - def entire_thread_visible_for_user?(nil, user), do: false - - # child / root - def entire_thread_visible_for_user?( - %Activity{data: %{"object" => object_id}} = tail, - user - ) do - parent = Activity.get_in_reply_to_activity(tail) - - cond do - !is_nil(parent) -> - visible_for_user?(tail, user) && entire_thread_visible_for_user?(parent, user) - - true -> - visible_for_user?(tail, user) + with {:ok, %{status: code}} when code in 200..299 <- + result = + @httpoison.post( + inbox, + json, + [ + {"Content-Type", "application/activity+json"}, + {"Date", date}, + {"signature", signature}, + {"digest", digest} + ] + ) do + if !Map.has_key?(params, :unreachable_since) || params[:unreachable_since], + do: Instances.set_reachable(inbox) + + result + else + {_post_result, response} -> + unless params[:unreachable_since], do: Instances.set_unreachable(inbox) + {:error, response} end end - # TODO: - # This will create a Create activity, which we need internally at the moment. - def fetch_object_from_id(id) do - if object = Object.get_cached_by_ap_id(id) do - {:ok, object} - else - with {:ok, data} <- fetch_and_contain_remote_object_from_id(id), - nil <- Object.normalize(data), - params <- %{ - "type" => "Create", - "to" => data["to"], - "cc" => data["cc"], - "actor" => data["actor"] || data["attributedTo"], - "object" => data - }, - :ok <- Transmogrifier.contain_origin(id, params), - {:ok, activity} <- Transmogrifier.handle_incoming(params) do - {:ok, Object.normalize(activity)} - else - {:error, {:reject, nil}} -> - {:reject, nil} - - object = %Object{} -> - {:ok, object} - - _e -> - Logger.info("Couldn't get object via AP, trying out OStatus fetching...") - - case OStatus.fetch_activity_from_url(id) do - {:ok, [activity | _]} -> {:ok, Object.normalize(activity)} - e -> e - end - end - end - end - - def fetch_and_contain_remote_object_from_id(id) do - Logger.info("Fetching object #{id} via AP") - - with true <- String.starts_with?(id, "http"), - {:ok, %{body: body, status: code}} when code in 200..299 <- - @httpoison.get( - id, - [{:Accept, "application/activity+json"}] - ), - {:ok, data} <- Jason.decode(body), - :ok <- Transmogrifier.contain_origin_from_id(id, data) do - {:ok, data} - else - e -> - {:error, e} - end - end - # filter out broken threads def contain_broken_threads(%Activity{} = activity, %User{} = user) do entire_thread_visible_for_user?(activity, user) diff --combined lib/pleroma/web/activity_pub/activity_pub_controller.ex index 7b7c0e090,3331ebebd..0b80566bf --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@@ -1,11 -1,20 +1,21 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2019 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.ActivityPub.ActivityPubController do use Pleroma.Web, :controller - alias Pleroma.{User, Object} + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.Object.Fetcher - alias Pleroma.Web.ActivityPub.{ObjectView, UserView} + alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.ObjectView alias Pleroma.Web.ActivityPub.Relay + alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.ActivityPub.UserView alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.Federator require Logger @@@ -13,6 -22,7 +23,7 @@@ action_fallback(:errors) plug(Pleroma.Web.FederatingPlug when action in [:inbox, :relay]) + plug(:set_requester_reachable when action in [:inbox]) plug(:relay_active? when action in [:relay]) def relay_active?(conn, _) do @@@ -40,7 -50,7 +51,7 @@@ def object(conn, %{"uuid" => uuid}) do with ap_id <- o_status_url(conn, :object, uuid), %Object{} = object <- Object.get_cached_by_ap_id(ap_id), - {_, true} <- {:public?, ActivityPub.is_public?(object)} do + {_, true} <- {:public?, Visibility.is_public?(object)} do conn |> put_resp_header("content-type", "application/activity+json") |> json(ObjectView.render("object.json", %{object: object})) @@@ -50,6 -60,49 +61,49 @@@ end end + def object_likes(conn, %{"uuid" => uuid, "page" => page}) do + with ap_id <- o_status_url(conn, :object, uuid), + %Object{} = object <- Object.get_cached_by_ap_id(ap_id), + {_, true} <- {:public?, Visibility.is_public?(object)}, + likes <- Utils.get_object_likes(object) do + {page, _} = Integer.parse(page) + + conn + |> put_resp_header("content-type", "application/activity+json") + |> json(ObjectView.render("likes.json", ap_id, likes, page)) + else + {:public?, false} -> + {:error, :not_found} + end + end + + def object_likes(conn, %{"uuid" => uuid}) do + with ap_id <- o_status_url(conn, :object, uuid), + %Object{} = object <- Object.get_cached_by_ap_id(ap_id), + {_, true} <- {:public?, Visibility.is_public?(object)}, + likes <- Utils.get_object_likes(object) do + conn + |> put_resp_header("content-type", "application/activity+json") + |> json(ObjectView.render("likes.json", ap_id, likes)) + else + {:public?, false} -> + {:error, :not_found} + end + end + + def activity(conn, %{"uuid" => uuid}) do + with ap_id <- o_status_url(conn, :activity, uuid), + %Activity{} = activity <- Activity.normalize(ap_id), + {_, true} <- {:public?, Visibility.is_public?(activity)} do + conn + |> put_resp_header("content-type", "application/activity+json") + |> json(ObjectView.render("object.json", %{object: activity})) + else + {:public?, false} -> + {:error, :not_found} + end + end + def following(conn, %{"nickname" => nickname, "page" => page}) do with %User{} = user <- User.get_cached_by_nickname(nickname), {:ok, user} <- Pleroma.Web.WebFinger.ensure_keys_present(user) do @@@ -90,30 -143,27 +144,27 @@@ end end - def outbox(conn, %{"nickname" => nickname, "max_id" => max_id}) do + def outbox(conn, %{"nickname" => nickname} = params) do with %User{} = user <- User.get_cached_by_nickname(nickname), {:ok, user} <- Pleroma.Web.WebFinger.ensure_keys_present(user) do conn |> put_resp_header("content-type", "application/activity+json") - |> json(UserView.render("outbox.json", %{user: user, max_id: max_id})) + |> json(UserView.render("outbox.json", %{user: user, max_id: params["max_id"]})) end end - def outbox(conn, %{"nickname" => nickname}) do - outbox(conn, %{"nickname" => nickname, "max_id" => nil}) - end - def inbox(%{assigns: %{valid_signature: true}} = conn, %{"nickname" => nickname} = params) do - with %User{} = user <- User.get_cached_by_nickname(nickname), - true <- Utils.recipient_in_message(user.ap_id, params), - params <- Utils.maybe_splice_recipient(user.ap_id, params) do - Federator.enqueue(:incoming_ap_doc, params) + with %User{} = recipient <- User.get_cached_by_nickname(nickname), + %User{} = actor <- User.get_or_fetch_by_ap_id(params["actor"]), + true <- Utils.recipient_in_message(recipient, actor, params), + params <- Utils.maybe_splice_recipient(recipient.ap_id, params) do + Federator.incoming_ap_doc(params) json(conn, "ok") end end def inbox(%{assigns: %{valid_signature: true}} = conn, params) do - Federator.enqueue(:incoming_ap_doc, params) + Federator.incoming_ap_doc(params) json(conn, "ok") end @@@ -123,7 -173,7 +174,7 @@@ "Signature missing or not from author, relayed Create message, fetching object from source" ) - ActivityPub.fetch_object_from_id(params["object"]["id"]) + Fetcher.fetch_object_from_id(params["object"]["id"]) json(conn, "ok") end @@@ -142,7 -192,7 +193,7 @@@ json(conn, "error") end - def relay(conn, params) do + def relay(conn, _params) do with %User{} = user <- Relay.get_actor(), {:ok, user} <- Pleroma.Web.WebFinger.ensure_keys_present(user) do conn @@@ -153,6 -203,96 +204,96 @@@ end end + def whoami(%{assigns: %{user: %User{} = user}} = conn, _params) do + conn + |> put_resp_header("content-type", "application/activity+json") + |> json(UserView.render("user.json", %{user: user})) + end + + def whoami(_conn, _params), do: {:error, :not_found} + + def read_inbox(%{assigns: %{user: user}} = conn, %{"nickname" => nickname} = params) do + if nickname == user.nickname do + conn + |> put_resp_header("content-type", "application/activity+json") + |> json(UserView.render("inbox.json", %{user: user, max_id: params["max_id"]})) + else + conn + |> put_status(:forbidden) + |> json("can't read inbox of #{nickname} as #{user.nickname}") + end + end + + def handle_user_activity(user, %{"type" => "Create"} = params) do + object = + params["object"] + |> Map.merge(Map.take(params, ["to", "cc"])) + |> Map.put("attributedTo", user.ap_id()) + |> Transmogrifier.fix_object() + + ActivityPub.create(%{ + to: params["to"], + actor: user, + context: object["context"], + object: object, + additional: Map.take(params, ["cc"]) + }) + end + + def handle_user_activity(user, %{"type" => "Delete"} = params) do + with %Object{} = object <- Object.normalize(params["object"]), + true <- user.info.is_moderator || user.ap_id == object.data["actor"], + {:ok, delete} <- ActivityPub.delete(object) do + {:ok, delete} + else + _ -> {:error, "Can't delete object"} + end + end + + def handle_user_activity(user, %{"type" => "Like"} = params) do + with %Object{} = object <- Object.normalize(params["object"]), + {:ok, activity, _object} <- ActivityPub.like(user, object) do + {:ok, activity} + else + _ -> {:error, "Can't like object"} + end + end + + def handle_user_activity(_, _) do + {:error, "Unhandled activity type"} + end + + def update_outbox( + %{assigns: %{user: user}} = conn, + %{"nickname" => nickname} = params + ) do + if nickname == user.nickname do + actor = user.ap_id() + + params = + params + |> Map.drop(["id"]) + |> Map.put("actor", actor) + |> Transmogrifier.fix_addressing() + + with {:ok, %Activity{} = activity} <- handle_user_activity(user, params) do + conn + |> put_status(:created) + |> put_resp_header("location", activity.data["id"]) + |> json(activity.data) + else + {:error, message} -> + conn + |> put_status(:bad_request) + |> json(message) + end + else + conn + |> put_status(:forbidden) + |> json("can't update outbox of #{nickname} as #{user.nickname}") + end + end + def errors(conn, {:error, :not_found}) do conn |> put_status(404) @@@ -164,4 -304,13 +305,13 @@@ |> put_status(500) |> json("error") end + + defp set_requester_reachable(%Plug.Conn{} = conn, _) do + with actor <- conn.params["actor"], + true <- is_binary(actor) do + Pleroma.Instances.set_reachable(actor) + end + + conn + end end diff --combined lib/pleroma/web/activity_pub/transmogrifier.ex index c4567193f,39cd31921..0637b18dc --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@@ -1,27 -1,81 +1,34 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2019 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.ActivityPub.Transmogrifier do @moduledoc """ A module to handle coding from internal to wire ActivityPub and back. """ + alias Pleroma.User + alias Pleroma.Object - alias Pleroma.Object.{Containment, Fetcher} ++ alias Pleroma.Object.Containment alias Pleroma.Activity + alias Pleroma.Object alias Pleroma.Repo + alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.ActivityPub.Visibility import Ecto.Query require Logger - def get_actor(%{"actor" => actor}) when is_binary(actor) do - actor - end - - def get_actor(%{"actor" => actor}) when is_list(actor) do - if is_binary(Enum.at(actor, 0)) do - Enum.at(actor, 0) - else - Enum.find(actor, fn %{"type" => type} -> type in ["Person", "Service", "Application"] end) - |> Map.get("id") - end - end - - def get_actor(%{"actor" => %{"id" => id}}) when is_bitstring(id) do - id - end - - def get_actor(%{"actor" => nil, "attributedTo" => actor}) when not is_nil(actor) do - get_actor(%{"actor" => actor}) - end - - @doc """ - Checks that an imported AP object's actor matches the domain it came from. - """ - def contain_origin(_id, %{"actor" => nil}), do: :error - - def contain_origin(id, %{"actor" => _actor} = params) do - id_uri = URI.parse(id) - actor_uri = URI.parse(get_actor(params)) - - if id_uri.host == actor_uri.host do - :ok - else - :error - end - end - - def contain_origin_from_id(_id, %{"id" => nil}), do: :error - - def contain_origin_from_id(id, %{"id" => other_id} = _params) do - id_uri = URI.parse(id) - other_uri = URI.parse(other_id) - - if id_uri.host == other_uri.host do - :ok - else - :error - end - end - @doc """ Modifies an incoming AP object (mastodon format) to our internal format. """ def fix_object(object) do object |> fix_actor - |> fix_attachments |> fix_url + |> fix_attachments |> fix_context |> fix_in_reply_to |> fix_emoji @@@ -29,35 -83,119 +36,119 @@@ |> fix_content_map |> fix_likes |> fix_addressing + |> fix_summary + end + + def fix_summary(%{"summary" => nil} = object) do + object + |> Map.put("summary", "") + end + + def fix_summary(%{"summary" => _} = object) do + # summary is present, nothing to do + object + end + + def fix_summary(object) do + object + |> Map.put("summary", "") end def fix_addressing_list(map, field) do - if is_binary(map[field]) do - map - |> Map.put(field, [map[field]]) + cond do + is_binary(map[field]) -> + Map.put(map, field, [map[field]]) + + is_nil(map[field]) -> + Map.put(map, field, []) + + true -> + map + end + end + + def fix_explicit_addressing(%{"to" => to, "cc" => cc} = object, explicit_mentions) do + explicit_to = + to + |> Enum.filter(fn x -> x in explicit_mentions end) + + explicit_cc = + to + |> Enum.filter(fn x -> x not in explicit_mentions end) + + final_cc = + (cc ++ explicit_cc) + |> Enum.uniq() + + object + |> Map.put("to", explicit_to) + |> Map.put("cc", final_cc) + end + + def fix_explicit_addressing(object, _explicit_mentions), do: object + + # if directMessage flag is set to true, leave the addressing alone + def fix_explicit_addressing(%{"directMessage" => true} = object), do: object + + def fix_explicit_addressing(object) do + explicit_mentions = + object + |> Utils.determine_explicit_mentions() + + explicit_mentions = explicit_mentions ++ ["https://www.w3.org/ns/activitystreams#Public"] + + object + |> fix_explicit_addressing(explicit_mentions) + end + + # if as:Public is addressed, then make sure the followers collection is also addressed + # so that the activities will be delivered to local users. + def fix_implicit_addressing(%{"to" => to, "cc" => cc} = object, followers_collection) do + recipients = to ++ cc + + if followers_collection not in recipients do + cond do + "https://www.w3.org/ns/activitystreams#Public" in cc -> + to = to ++ [followers_collection] + Map.put(object, "to", to) + + "https://www.w3.org/ns/activitystreams#Public" in to -> + cc = cc ++ [followers_collection] + Map.put(object, "cc", cc) + + true -> + object + end else - map + object end end - def fix_addressing(map) do - map + def fix_implicit_addressing(object, _), do: object + + def fix_addressing(object) do + %User{} = user = User.get_or_fetch_by_ap_id(object["actor"]) + followers_collection = User.ap_followers(user) + + object |> fix_addressing_list("to") |> fix_addressing_list("cc") |> fix_addressing_list("bto") |> fix_addressing_list("bcc") + |> fix_explicit_addressing + |> fix_implicit_addressing(followers_collection) end def fix_actor(%{"attributedTo" => actor} = object) do object - |> Map.put("actor", get_actor(%{"actor" => actor})) + |> Map.put("actor", Containment.get_actor(%{"actor" => actor})) end - def fix_likes(%{"likes" => likes} = object) - when is_bitstring(likes) do - # Check for standardisation - # This is what Peertube does - # curl -H 'Accept: application/activity+json' $likes | jq .totalItems + # Check for standardisation + # This is what Peertube does + # curl -H 'Accept: application/activity+json' $likes | jq .totalItems + # Prismo returns only an integer (count) as "likes" + def fix_likes(%{"likes" => likes} = object) when not is_map(likes) do object |> Map.put("likes", []) |> Map.put("like_count", 0) @@@ -85,14 -223,13 +176,13 @@@ "" end - case fetch_obj_helper(in_reply_to_id) do + case get_obj_helper(in_reply_to_id) do {:ok, replied_object} -> - with %Activity{} = activity <- - Activity.get_create_activity_by_object_ap_id(replied_object.data["id"]) do + with %Activity{} = _activity <- + Activity.get_create_by_object_ap_id(replied_object.data["id"]) do object |> Map.put("inReplyTo", replied_object.data["id"]) |> Map.put("inReplyToAtomUri", object["inReplyToAtomUri"] || in_reply_to_id) - |> Map.put("inReplyToStatusId", activity.id) |> Map.put("conversation", replied_object.data["context"] || object["conversation"]) |> Map.put("context", replied_object.data["context"] || object["conversation"]) else @@@ -121,8 -258,14 +211,14 @@@ attachments = attachment |> Enum.map(fn data -> - url = [%{"type" => "Link", "mediaType" => data["mediaType"], "href" => data["url"]}] - Map.put(data, "url", url) + media_type = data["mediaType"] || data["mimeType"] + href = data["url"] || data["href"] + + url = [%{"type" => "Link", "mediaType" => media_type, "href" => href}] + + data + |> Map.put("mediaType", media_type) + |> Map.put("url", url) end) object @@@ -141,7 -284,22 +237,22 @@@ |> Map.put("url", url["href"]) end - def fix_url(%{"url" => url} = object) when is_list(url) do + def fix_url(%{"type" => "Video", "url" => url} = object) when is_list(url) do + first_element = Enum.at(url, 0) + + link_element = + url + |> Enum.filter(fn x -> is_map(x) end) + |> Enum.filter(fn x -> x["mimeType"] == "text/html" end) + |> Enum.at(0) + + object + |> Map.put("attachment", [first_element]) + |> Map.put("url", link_element["href"]) + end + + def fix_url(%{"type" => object_type, "url" => url} = object) + when object_type != "Video" and is_list(url) do first_element = Enum.at(url, 0) url_string = @@@ -204,6 -362,8 +315,8 @@@ |> Map.put("tag", combined) end + def fix_tag(%{"tag" => %{} = tag} = object), do: Map.put(object, "tag", [tag]) + def fix_tag(object), do: object # content map usually only has one language so this will do for now. @@@ -217,6 -377,66 +330,66 @@@ def fix_content_map(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), + %Activity{} = activity <- Utils.fetch_latest_follow(follower, followed) do + {:ok, activity} + else + _ -> {:error, nil} + end + end + + defp mastodon_follow_hack(_, _), do: {:error, nil} + + defp get_follow_activity(follow_object, followed) do + with object_id when not is_nil(object_id) <- Utils.get_ap_id(follow_object), + {_, %Activity{} = activity} <- {:activity, Activity.get_by_ap_id(object_id)} do + {:ok, activity} + else + # Can't find the activity. This might a Mastodon 2.3 "Accept" + {:activity, nil} -> + mastodon_follow_hack(follow_object, followed) + + _ -> + {:error, nil} + end + end + + # Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them + # with nil ID. + def handle_incoming(%{"type" => "Flag", "object" => objects, "actor" => actor} = data) do + with context <- data["context"] || Utils.generate_context_id(), + content <- data["content"] || "", + %User{} = actor <- User.get_cached_by_ap_id(actor), + + # Reduce the object list to find the reported user. + %User{} = account <- + Enum.reduce_while(objects, nil, fn ap_id, _ -> + with %User{} = user <- User.get_cached_by_ap_id(ap_id) do + {:halt, user} + else + _ -> {:cont, nil} + end + end), + + # Remove the reported user from the object list. + statuses <- Enum.filter(objects, fn ap_id -> ap_id != account.ap_id end) do + params = %{ + actor: actor, + context: context, + account: account, + statuses: statuses, + content: content, + additional: %{ + "cc" => [account.ap_id] + } + } + + ActivityPub.flag(params) + end + end + # disallow objects with bogus IDs def handle_incoming(%{"id" => nil}), do: :error def handle_incoming(%{"id" => ""}), do: :error @@@ -228,13 -448,13 +401,13 @@@ # - emoji def handle_incoming(%{"type" => "Create", "object" => %{"type" => objtype} = object} = data) when objtype in ["Article", "Note", "Video", "Page"] do - actor = get_actor(data) + actor = Containment.get_actor(data) data = Map.put(data, "actor", actor) |> fix_addressing - with nil <- Activity.get_create_activity_by_object_ap_id(object["id"]), + with nil <- Activity.get_create_by_object_ap_id(object["id"]), %User{} = user <- User.get_or_fetch_by_ap_id(data["actor"]) do object = fix_object(data["object"]) @@@ -248,6 -468,7 +421,7 @@@ additional: Map.take(data, [ "cc", + "directMessage", "id" ]) } @@@ -268,7 -489,7 +442,7 @@@ if not User.locked?(followed) do ActivityPub.accept(%{ to: [follower.ap_id], - actor: followed.ap_id, + actor: followed, object: data, local: true }) @@@ -282,36 -503,10 +456,10 @@@ end end - 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), - %Activity{} = activity <- Utils.fetch_latest_follow(follower, followed) do - {:ok, activity} - else - _ -> {:error, nil} - end - end - - defp mastodon_follow_hack(_), do: {:error, nil} - - defp get_follow_activity(follow_object, followed) do - with object_id when not is_nil(object_id) <- Utils.get_ap_id(follow_object), - {_, %Activity{} = activity} <- {:activity, Activity.get_by_ap_id(object_id)} do - {:ok, activity} - else - # Can't find the activity. This might a Mastodon 2.3 "Accept" - {:activity, nil} -> - mastodon_follow_hack(follow_object, followed) - - _ -> - {:error, nil} - end - end - def handle_incoming( - %{"type" => "Accept", "object" => follow_object, "actor" => actor, "id" => id} = data + %{"type" => "Accept", "object" => follow_object, "actor" => _actor, "id" => _id} = data ) do - with actor <- get_actor(data), + with actor <- Containment.get_actor(data), %User{} = followed <- User.get_or_fetch_by_ap_id(actor), {:ok, follow_activity} <- get_follow_activity(follow_object, followed), {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "accept"), @@@ -320,12 -515,12 +468,12 @@@ ActivityPub.accept(%{ to: follow_activity.data["to"], type: "Accept", - actor: followed.ap_id, + actor: followed, object: follow_activity.data["id"], local: false }) do if not User.following?(follower, followed) do - {:ok, follower} = User.follow(follower, followed) + {:ok, _follower} = User.follow(follower, followed) end {:ok, activity} @@@ -335,18 -530,18 +483,18 @@@ end def handle_incoming( - %{"type" => "Reject", "object" => follow_object, "actor" => actor, "id" => id} = data + %{"type" => "Reject", "object" => follow_object, "actor" => _actor, "id" => _id} = data ) do - with actor <- get_actor(data), + with actor <- Containment.get_actor(data), %User{} = followed <- User.get_or_fetch_by_ap_id(actor), {:ok, follow_activity} <- get_follow_activity(follow_object, followed), {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "reject"), %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]), {:ok, activity} <- - ActivityPub.accept(%{ + ActivityPub.reject(%{ to: follow_activity.data["to"], - type: "Accept", - actor: followed.ap_id, + type: "Reject", + actor: followed, object: follow_activity.data["id"], local: false }) do @@@ -359,11 -554,11 +507,11 @@@ end def handle_incoming( - %{"type" => "Like", "object" => object_id, "actor" => actor, "id" => id} = data + %{"type" => "Like", "object" => object_id, "actor" => _actor, "id" => id} = data ) do - with actor <- get_actor(data), + with actor <- Containment.get_actor(data), %User{} = actor <- User.get_or_fetch_by_ap_id(actor), - {:ok, object} <- get_obj_helper(object_id) || fetch_obj_helper(object_id), + {:ok, object} <- get_obj_helper(object_id), {:ok, activity, _object} <- ActivityPub.like(actor, object, id, false) do {:ok, activity} else @@@ -372,12 -567,13 +520,13 @@@ end def handle_incoming( - %{"type" => "Announce", "object" => object_id, "actor" => actor, "id" => id} = data + %{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => id} = data ) do - with actor <- get_actor(data), + with actor <- Containment.get_actor(data), %User{} = actor <- User.get_or_fetch_by_ap_id(actor), - {:ok, object} <- get_obj_helper(object_id) || fetch_obj_helper(object_id), + {:ok, object} <- get_obj_helper(object_id), - {:ok, activity, _object} <- ActivityPub.announce(actor, object, id, false) do + public <- Visibility.is_public?(data), + {:ok, activity, _object} <- ActivityPub.announce(actor, object, id, false, public) do {:ok, activity} else _e -> :error @@@ -428,10 -624,10 +577,10 @@@ ) do object_id = Utils.get_ap_id(object_id) - with actor <- get_actor(data), + with actor <- Containment.get_actor(data), %User{} = actor <- User.get_or_fetch_by_ap_id(actor), - {:ok, object} <- get_obj_helper(object_id) || fetch_obj_helper(object_id), - :ok <- contain_origin(actor.ap_id, object.data), + {:ok, object} <- get_obj_helper(object_id), + :ok <- Containment.contain_origin(actor.ap_id, object.data), {:ok, activity} <- ActivityPub.delete(object, false) do {:ok, activity} else @@@ -443,13 -639,13 +592,13 @@@ %{ "type" => "Undo", "object" => %{"type" => "Announce", "object" => object_id}, - "actor" => actor, + "actor" => _actor, "id" => id } = data ) do - with actor <- get_actor(data), + with actor <- Containment.get_actor(data), %User{} = actor <- User.get_or_fetch_by_ap_id(actor), - {:ok, object} <- get_obj_helper(object_id) || fetch_obj_helper(object_id), + {:ok, object} <- get_obj_helper(object_id), {:ok, activity, _} <- ActivityPub.unannounce(actor, object, id, false) do {:ok, activity} else @@@ -471,7 -667,7 +620,7 @@@ User.unfollow(follower, followed) {:ok, activity} else - e -> :error + _e -> :error end end @@@ -490,12 -686,12 +639,12 @@@ User.unblock(blocker, blocked) {:ok, activity} else - e -> :error + _e -> :error end end def handle_incoming( - %{"type" => "Block", "object" => blocked, "actor" => blocker, "id" => id} = data + %{"type" => "Block", "object" => blocked, "actor" => blocker, "id" => id} = _data ) do with true <- Pleroma.Config.get([:activitypub, :accept_blocks]), %User{local: true} = blocked = User.get_cached_by_ap_id(blocked), @@@ -505,7 -701,7 +654,7 @@@ User.block(blocker, blocked) {:ok, activity} else - e -> :error + _e -> :error end end @@@ -513,13 -709,13 +662,13 @@@ %{ "type" => "Undo", "object" => %{"type" => "Like", "object" => object_id}, - "actor" => actor, + "actor" => _actor, "id" => id } = data ) do - with actor <- get_actor(data), + with actor <- Containment.get_actor(data), %User{} = actor <- User.get_or_fetch_by_ap_id(actor), - {:ok, object} <- get_obj_helper(object_id) || fetch_obj_helper(object_id), + {:ok, object} <- get_obj_helper(object_id), {:ok, activity, _, _} <- ActivityPub.unlike(actor, object, id, false) do {:ok, activity} else @@@ -529,14 -725,17 +678,14 @@@ def handle_incoming(_), do: :error - def fetch_obj_helper(id) when is_bitstring(id), do: ActivityPub.fetch_object_from_id(id) - def fetch_obj_helper(obj) when is_map(obj), do: ActivityPub.fetch_object_from_id(obj["id"]) - def get_obj_helper(id) do if object = Object.normalize(id), do: {:ok, object}, else: nil end - def set_reply_to_uri(%{"inReplyTo" => inReplyTo} = object) do - with false <- String.starts_with?(inReplyTo, "http"), - {:ok, %{data: replied_to_object}} <- get_obj_helper(inReplyTo) do - Map.put(object, "inReplyTo", replied_to_object["external_url"] || inReplyTo) + def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) when is_binary(in_reply_to) do + with false <- String.starts_with?(in_reply_to, "http"), + {:ok, %{data: replied_to_object}} <- get_obj_helper(in_reply_to) do + Map.put(object, "inReplyTo", replied_to_object["external_url"] || in_reply_to) else _e -> object end @@@ -552,6 -751,7 +701,7 @@@ |> add_mention_tags |> add_emoji_tags |> add_attributed_to + |> add_likes |> prepare_attachments |> set_conversation |> set_reply_to_uri @@@ -564,9 -764,9 +714,9 @@@ # internal -> Mastodon # """ - def prepare_outgoing(%{"type" => "Create", "object" => object} = data) do + def prepare_outgoing(%{"type" => "Create", "object" => object_id} = data) do object = - object + Object.normalize(object_id).data |> prepare_object data = @@@ -618,6 -818,7 +768,7 @@@ def prepare_outgoing(%{"type" => _type} = data) do data = data + |> strip_internal_fields |> maybe_fix_object_url |> Map.merge(Utils.make_json_ld_header()) @@@ -626,7 -827,7 +777,7 @@@ def maybe_fix_object_url(data) do if is_binary(data["object"]) and not String.starts_with?(data["object"], "http") do - case fetch_obj_helper(data["object"]) do + case get_obj_helper(data["object"]) do {:ok, relative_object} -> if relative_object.data["external_url"] do _data = @@@ -648,12 -849,18 +799,18 @@@ def add_hashtags(object) do tags = (object["tag"] || []) - |> Enum.map(fn tag -> - %{ - "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}", - "name" => "##{tag}", - "type" => "Hashtag" - } + |> Enum.map(fn + # Expand internal representation tags into AS2 tags. + tag when is_binary(tag) -> + %{ + "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}", + "name" => "##{tag}", + "type" => "Hashtag" + } + + # Do not process tags which are already AS2 tag objects. + tag when is_map(tag) -> + tag end) object @@@ -705,10 -912,26 +862,26 @@@ end def add_attributed_to(object) do - attributedTo = object["attributedTo"] || object["actor"] + attributed_to = object["attributedTo"] || object["actor"] + + object + |> Map.put("attributedTo", attributed_to) + end + + def add_likes(%{"id" => id, "like_count" => likes} = object) do + likes = %{ + "id" => "#{id}/likes", + "first" => "#{id}/likes?page=1", + "type" => "OrderedCollection", + "totalItems" => likes + } + + object + |> Map.put("likes", likes) + end + def add_likes(object) do object - |> Map.put("attributedTo", attributedTo) end def prepare_attachments(object) do @@@ -726,12 -949,12 +899,12 @@@ defp strip_internal_fields(object) do object |> Map.drop([ - "likes", "like_count", "announcements", "announcement_count", "emoji", - "context_id" + "context_id", + "deleted_activity_id" ]) end @@@ -746,8 -969,9 +919,9 @@@ defp strip_internal_tags(object), do: object - defp user_upgrade_task(user) do - old_follower_address = User.ap_followers(user) + def perform(:user_upgrade, user) do + # we pass a fake user so that the followers collection is stripped away + old_follower_address = User.ap_followers(%User{nickname: user.nickname}) q = from( @@@ -770,15 -994,10 +944,10 @@@ maybe_retire_websub(user.ap_id) - # Only do this for recent activties, don't go through the whole db. - # Only look at the last 1000 activities. - since = (Repo.aggregate(Activity, :max, :id) || 0) - 1_000 - q = from( a in Activity, where: ^old_follower_address in a.recipients, - where: a.id > ^since, update: [ set: [ recipients: @@@ -795,28 -1014,18 +964,18 @@@ Repo.update_all(q, []) end - def upgrade_user_from_ap_id(ap_id, async \\ true) do + def upgrade_user_from_ap_id(ap_id) do with %User{local: false} = user <- User.get_by_ap_id(ap_id), - {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id) do - already_ap = User.ap_enabled?(user) - - {:ok, user} = - User.upgrade_changeset(user, data) - |> Repo.update() - - if !already_ap do - # This could potentially take a long time, do it in the background - if async do - Task.start(fn -> - user_upgrade_task(user) - end) - else - user_upgrade_task(user) - end + {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id), + already_ap <- User.ap_enabled?(user), + {:ok, user} <- user |> User.upgrade_changeset(data) |> User.update_and_set_cache() do + unless already_ap do + PleromaJobQueue.enqueue(:transmogrifier, __MODULE__, [:user_upgrade, user]) end {:ok, user} else + %User{} = user -> {:ok, user} e -> e end end diff --combined lib/pleroma/web/activity_pub/utils.ex index bc5b98f1a,ccc9da7c6..581b9d1ab --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@@ -1,9 -1,22 +1,22 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2019 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.ActivityPub.Utils do - alias Pleroma.{Repo, Web, Object, Activity, User, Notification} - alias Pleroma.Web.Router.Helpers + alias Ecto.Changeset + alias Ecto.UUID + alias Pleroma.Activity + alias Pleroma.Notification + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web + alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.Endpoint - alias Ecto.{Changeset, UUID} + alias Pleroma.Web.Router.Helpers + import Ecto.Query + require Logger @supported_object_types ["Article", "Note", "Video", "Page"] @@@ -21,11 -34,25 +34,25 @@@ Map.put(params, "actor", get_ap_id(params["actor"])) end + def determine_explicit_mentions(%{"tag" => tag} = _object) when is_list(tag) do + tag + |> Enum.filter(fn x -> is_map(x) end) + |> Enum.filter(fn x -> x["type"] == "Mention" end) + |> Enum.map(fn x -> x["href"] end) + end + + def determine_explicit_mentions(%{"tag" => tag} = object) when is_map(tag) do + Map.put(object, "tag", [tag]) + |> determine_explicit_mentions() + end + + def determine_explicit_mentions(_), do: [] + defp recipient_in_collection(ap_id, coll) when is_binary(coll), do: ap_id == coll defp recipient_in_collection(ap_id, coll) when is_list(coll), do: ap_id in coll defp recipient_in_collection(_, _), do: false - def recipient_in_message(ap_id, params) do + def recipient_in_message(%User{ap_id: ap_id} = recipient, %User{} = actor, params) do cond do recipient_in_collection(ap_id, params["to"]) -> true @@@ -44,6 -71,11 +71,11 @@@ !params["to"] && !params["cc"] && !params["bto"] && !params["bcc"] -> true + # if the message is sent from somebody the user is following, then assume it + # is addressed to the recipient + User.following?(recipient, actor) -> + true + true -> false end @@@ -72,7 -104,10 +104,10 @@@ %{ "@context" => [ "https://www.w3.org/ns/activitystreams", - "#{Web.base_url()}/schemas/litepub-0.1.jsonld" + "#{Web.base_url()}/schemas/litepub-0.1.jsonld", + %{ + "@language" => "und" + } ] } end @@@ -138,7 -173,7 +173,7 @@@ _ -> 5 end - Pleroma.Web.Federator.enqueue(:publish, activity, priority) + Pleroma.Web.Federator.publish(activity, priority) :ok end @@@ -148,18 -183,26 +183,26 @@@ Adds an id and a published data if they aren't there, also adds it to an included object """ - def lazy_put_activity_defaults(map) do - %{data: %{"id" => context}, id: context_id} = create_context(map["context"]) - + def lazy_put_activity_defaults(map, fake \\ false) do map = - map - |> Map.put_new_lazy("id", &generate_activity_id/0) - |> Map.put_new_lazy("published", &make_date/0) - |> Map.put_new("context", context) - |> Map.put_new("context_id", context_id) + unless fake do + %{data: %{"id" => context}, id: context_id} = create_context(map["context"]) + + map + |> Map.put_new_lazy("id", &generate_activity_id/0) + |> Map.put_new_lazy("published", &make_date/0) + |> Map.put_new("context", context) + |> Map.put_new("context_id", context_id) + else + map + |> Map.put_new("id", "pleroma:fakeid") + |> Map.put_new_lazy("published", &make_date/0) + |> Map.put_new("context", "pleroma:fakecontext") + |> Map.put_new("context_id", -1) + end if is_map(map["object"]) do - object = lazy_put_object_defaults(map["object"], map) + object = lazy_put_object_defaults(map["object"], map, fake) %{map | "object" => object} else map @@@ -169,7 -212,18 +212,18 @@@ @doc """ Adds an id and published date if they aren't there. """ - def lazy_put_object_defaults(map, activity \\ %{}) do + def lazy_put_object_defaults(map, activity \\ %{}, fake) + + def lazy_put_object_defaults(map, activity, true = _fake) do + map + |> Map.put_new_lazy("published", &make_date/0) + |> Map.put_new("id", "pleroma:fake_object_id") + |> Map.put_new("context", activity["context"]) + |> Map.put_new("fake", true) + |> Map.put_new("context_id", activity["context_id"]) + end + + def lazy_put_object_defaults(map, activity, _fake) do map |> Map.put_new_lazy("id", &generate_object_id/0) |> Map.put_new_lazy("published", &make_date/0) @@@ -180,25 -234,21 +234,25 @@@ @doc """ Inserts a full object if it is contained in an activity. """ - def insert_full_object(%{"object" => %{"type" => type} = object_data}) + def insert_full_object(%{"object" => %{"type" => type} = object_data} = map) when is_map(object_data) and type in @supported_object_types do with {:ok, object} <- Object.create(object_data) do - {:ok, object} + map = + map + |> Map.put("object", object.data["id"]) + - {:ok, map} ++ {:ok, map, object} end end - def insert_full_object(map), do: {:ok, map} - def insert_full_object(_), do: {:ok, nil} ++ def insert_full_object(map), do: {:ok, map, nil} def update_object_in_activities(%{data: %{"id" => id}} = object) do # TODO # Update activities that already had this. Could be done in a seperate process. # Alternatively, just don't do this and fetch the current object each time. Most # could probably be taken from cache. - relevant_activities = Activity.all_by_object_ap_id(id) + relevant_activities = Activity.get_all_create_by_object_ap_id(id) Enum.map(relevant_activities, fn activity -> new_activity_data = activity.data |> Map.put("object", object.data) @@@ -231,13 -281,52 +285,52 @@@ Repo.one(query) end - def make_like_data(%User{ap_id: ap_id} = actor, %{data: %{"id" => id}} = object, activity_id) do + @doc """ + Returns like activities targeting an object + """ + def get_object_likes(%{data: %{"id" => id}}) do + query = + from( + activity in Activity, + # this is to use the index + where: + fragment( + "coalesce((?)->'object'->>'id', (?)->>'object') = ?", + activity.data, + activity.data, + ^id + ), + where: fragment("(?)->>'type' = 'Like'", activity.data) + ) + + Repo.all(query) + end + + def make_like_data( + %User{ap_id: ap_id} = actor, + %{data: %{"actor" => object_actor_id, "id" => id}} = object, + activity_id + ) do + object_actor = User.get_cached_by_ap_id(object_actor_id) + + to = + if Visibility.is_public?(object) do + [actor.follower_address, object.data["actor"]] + else + [object.data["actor"]] + end + + cc = + (object.data["to"] ++ (object.data["cc"] || [])) + |> List.delete(actor.ap_id) + |> List.delete(object_actor.follower_address) + data = %{ "type" => "Like", "actor" => ap_id, "object" => id, - "to" => [actor.follower_address, object.data["actor"]], - "cc" => ["https://www.w3.org/ns/activitystreams#Public"], + "to" => to, + "cc" => cc, "context" => object.data["context"] } @@@ -250,7 -339,7 +343,7 @@@ |> Map.put("#{property}_count", length(element)) |> Map.put("#{property}s", element), changeset <- Changeset.change(object, data: new_data), - {:ok, object} <- Repo.update(changeset), + {:ok, object} <- Object.update_and_set_cache(changeset), _ <- update_object_in_activities(object) do {:ok, object} end @@@ -281,6 -370,25 +374,25 @@@ @doc """ Updates a follow activity's state (for locked accounts). """ + def update_follow_state( + %Activity{data: %{"actor" => actor, "object" => object, "state" => "pending"}} = activity, + state + ) do + try do + Ecto.Adapters.SQL.query!( + Repo, + "UPDATE activities SET data = jsonb_set(data, '{state}', $1) WHERE data->>'type' = 'Follow' AND data->>'actor' = $2 AND data->>'object' = $3 AND data->>'state' = 'pending'", + [state, actor, object] + ) + + activity = Activity.get_by_id(activity.id) + {:ok, activity} + rescue + e -> + {:error, e} + end + end + def update_follow_state(%Activity{} = activity, state) do with new_data <- activity.data @@@ -296,7 -404,7 +408,7 @@@ """ def make_follow_data( %User{ap_id: follower_id}, - %User{ap_id: followed_id} = followed, + %User{ap_id: followed_id} = _followed, activity_id ) do data = %{ @@@ -323,13 -431,15 +435,15 @@@ activity.data ), where: activity.actor == ^follower_id, + # this is to use the index where: fragment( - "? @> ?", + "coalesce((?)->'object'->>'id', (?)->>'object') = ?", + activity.data, activity.data, - ^%{object: followed_id} + ^followed_id ), - order_by: [desc: :id], + order_by: [fragment("? desc nulls last", activity.id)], limit: 1 ) @@@ -365,9 -475,10 +479,10 @@@ """ # for relayed messages, we only want to send to subscribers def make_announce_data( - %User{ap_id: ap_id, nickname: nil} = user, + %User{ap_id: ap_id} = user, %Object{data: %{"id" => id}} = object, - activity_id + activity_id, + false ) do data = %{ "type" => "Announce", @@@ -384,7 -495,8 +499,8 @@@ def make_announce_data( %User{ap_id: ap_id} = user, %Object{data: %{"id" => id}} = object, - activity_id + activity_id, + true ) do data = %{ "type" => "Announce", @@@ -484,13 -596,15 +600,15 @@@ activity.data ), where: activity.actor == ^blocker_id, + # this is to use the index where: fragment( - "? @> ?", + "coalesce((?)->'object'->>'id', (?)->>'object') = ?", + activity.data, activity.data, - ^%{object: blocked_id} + ^blocked_id ), - order_by: [desc: :id], + order_by: [fragment("? desc nulls last", activity.id)], limit: 1 ) @@@ -534,4 -648,65 +652,65 @@@ } |> Map.merge(additional) end + + #### Flag-related helpers + + def make_flag_data(params, additional) do + status_ap_ids = + Enum.map(params.statuses || [], fn + %Activity{} = act -> act.data["id"] + act when is_map(act) -> act["id"] + act when is_binary(act) -> act + end) + + object = [params.account.ap_id] ++ status_ap_ids + + %{ + "type" => "Flag", + "actor" => params.actor.ap_id, + "content" => params.content, + "object" => object, + "context" => params.context + } + |> Map.merge(additional) + end + + @doc """ + Fetches the OrderedCollection/OrderedCollectionPage from `from`, limiting the amount of pages fetched after + the first one to `pages_left` pages. + If the amount of pages is higher than the collection has, it returns whatever was there. + """ + def fetch_ordered_collection(from, pages_left, acc \\ []) do + with {:ok, response} <- Tesla.get(from), + {:ok, collection} <- Poison.decode(response.body) do + case collection["type"] do + "OrderedCollection" -> + # If we've encountered the OrderedCollection and not the page, + # just call the same function on the page address + fetch_ordered_collection(collection["first"], pages_left) + + "OrderedCollectionPage" -> + if pages_left > 0 do + # There are still more pages + if Map.has_key?(collection, "next") do + # There are still more pages, go deeper saving what we have into the accumulator + fetch_ordered_collection( + collection["next"], + pages_left - 1, + acc ++ collection["orderedItems"] + ) + else + # No more pages left, just return whatever we already have + acc ++ collection["orderedItems"] + end + else + # Got the amount of pages needed, add them all to the accumulator + acc ++ collection["orderedItems"] + end + + _ -> + {:error, "Not an OrderedCollection or OrderedCollectionPage"} + end + end + end end diff --combined lib/pleroma/web/common_api/common_api.ex index c83f8a6a9,74babdf14..9c3daac2c --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@@ -1,14 -1,73 +1,73 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2019 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.CommonAPI do - alias Pleroma.{User, Repo, Activity, Object} - alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Activity alias Pleroma.Formatter + alias Pleroma.Object + alias Pleroma.ThreadMute + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Utils import Pleroma.Web.CommonAPI.Utils + def follow(follower, followed) do + with {:ok, follower} <- User.maybe_direct_follow(follower, followed), + {:ok, activity} <- ActivityPub.follow(follower, followed), + {:ok, follower, followed} <- + User.wait_and_refresh( + Pleroma.Config.get([:activitypub, :follow_handshake_timeout]), + follower, + followed + ) do + {:ok, follower, followed, activity} + end + end + + def unfollow(follower, unfollowed) do + with {:ok, follower, _follow_activity} <- User.unfollow(follower, unfollowed), + {:ok, _activity} <- ActivityPub.unfollow(follower, unfollowed) do + {:ok, follower} + end + end + + def accept_follow_request(follower, followed) do + with {:ok, follower} <- User.maybe_follow(follower, followed), + %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), + {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "accept"), + {:ok, _activity} <- + ActivityPub.accept(%{ + to: [follower.ap_id], + actor: followed, + object: follow_activity.data["id"], + type: "Accept" + }) do + {:ok, follower} + end + end + + def reject_follow_request(follower, followed) do + with %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), + {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "reject"), + {:ok, _activity} <- + ActivityPub.reject(%{ + to: [follower.ap_id], + actor: followed, + object: follow_activity.data["id"], + type: "Reject" + }) do + {:ok, follower} + end + end + def delete(activity_id, user) do - with %Activity{data: %{"object" => object_id}} <- Repo.get(Activity, activity_id), - %Object{} = object <- Object.normalize(object_id), - true <- user.info.is_moderator || user.ap_id == object.data["actor"], + with %Activity{data: %{"object" => _}} = activity <- + Activity.get_by_id_with_object(activity_id), + %Object{} = object <- Object.normalize(activity), + true <- User.superuser?(user) || user.ap_id == object.data["actor"], + {:ok, _} <- unpin(activity_id, user), {:ok, delete} <- ActivityPub.delete(object) do {:ok, delete} end @@@ -16,7 -75,8 +75,8 @@@ def repeat(id_or_ap_id, user) do with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id), - object <- Object.normalize(activity.data["object"]) do + object <- Object.normalize(activity), + nil <- Utils.get_existing_announce(user.ap_id, object) do ActivityPub.announce(user, object) else _ -> @@@ -26,7 -86,7 +86,7 @@@ def unrepeat(id_or_ap_id, user) do with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id), - object <- Object.normalize(activity.data["object"]) do + object <- Object.normalize(activity) do ActivityPub.unannounce(user, object) else _ -> @@@ -36,7 -96,8 +96,8 @@@ def favorite(id_or_ap_id, user) do with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id), - object <- Object.normalize(activity.data["object"]) do + object <- Object.normalize(activity), + nil <- Utils.get_existing_like(user.ap_id, object) do ActivityPub.like(user, object) else _ -> @@@ -46,7 -107,7 +107,7 @@@ def unfavorite(id_or_ap_id, user) do with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id), - object <- Object.normalize(activity.data["object"]) do + object <- Object.normalize(activity) do ActivityPub.unlike(user, object) else _ -> @@@ -63,44 -124,29 +124,32 @@@ nil -> "public" - inReplyTo -> + in_reply_to -> - Pleroma.Web.MastodonAPI.StatusView.get_visibility(in_reply_to.data["object"]) + # XXX: these heuristics should be moved out of MastodonAPI. - with %Object{} = object <- Object.normalize(inReplyTo.data["object"]) do ++ with %Object{} = object <- Object.normalize(in_reply_to) do + Pleroma.Web.MastodonAPI.StatusView.get_visibility(object.data) + end end end def get_visibility(_), do: "public" - defp get_content_type(content_type) do - if Enum.member?(Pleroma.Config.get([:instance, :allowed_post_formats]), content_type) do - content_type - else - "text/plain" - end - end - def post(user, %{"status" => status} = data) do visibility = get_visibility(data) limit = Pleroma.Config.get([:instance, :limit]) with status <- String.trim(status), - attachments <- attachments_from_ids(data["media_ids"]), - mentions <- Formatter.parse_mentions(status), - inReplyTo <- get_replied_to_activity(data["in_reply_to_status_id"]), - {to, cc} <- to_for_user_and_mentions(user, mentions, inReplyTo, visibility), - tags <- Formatter.parse_tags(status, data), - content_html <- + attachments <- attachments_from_ids(data), + in_reply_to <- get_replied_to_activity(data["in_reply_to_status_id"]), + {content_html, mentions, tags} <- make_content_html( status, - mentions, attachments, - tags, - get_content_type(data["content_type"]), - data["no_attachment_links"] + data, + visibility ), - context <- make_context(inReplyTo), + {to, cc} <- to_for_user_and_mentions(user, mentions, in_reply_to, visibility), + context <- make_context(in_reply_to), cw <- data["spoiler_text"], full_payload <- String.trim(status <> (data["spoiler_text"] || "")), length when length in 1..limit <- String.length(full_payload), @@@ -111,7 -157,7 +160,7 @@@ context, content_html, attachments, - inReplyTo, + in_reply_to, tags, cw, cc @@@ -120,19 -166,22 +169,22 @@@ Map.put( object, "emoji", - Formatter.get_emoji(status) - |> Enum.reduce(%{}, fn {name, file}, acc -> + (Formatter.get_emoji(status) ++ Formatter.get_emoji(data["spoiler_text"])) + |> Enum.reduce(%{}, fn {name, file, _}, acc -> Map.put(acc, name, "#{Pleroma.Web.Endpoint.static_url()}#{file}") end) ) do res = - ActivityPub.create(%{ - to: to, - actor: user, - context: context, - object: object, - additional: %{"cc" => cc} - }) + ActivityPub.create( + %{ + to: to, + actor: user, + context: context, + object: object, + additional: %{"cc" => cc, "directMessage" => visibility == "direct"} + }, + Pleroma.Web.ControllerHelper.truthy_param?(data["preview"]) || false + ) res end @@@ -160,4 -209,113 +212,113 @@@ object: Pleroma.Web.ActivityPub.UserView.render("user.json", %{user: user}) }) end + + def pin(id_or_ap_id, %{ap_id: user_ap_id} = user) do + with %Activity{ + actor: ^user_ap_id, + data: %{ + "type" => "Create", + "object" => %{ + "to" => object_to, + "type" => "Note" + } + } + } = activity <- get_by_id_or_ap_id(id_or_ap_id), + true <- Enum.member?(object_to, "https://www.w3.org/ns/activitystreams#Public"), + %{valid?: true} = info_changeset <- + Pleroma.User.Info.add_pinnned_activity(user.info, activity), + changeset <- + Ecto.Changeset.change(user) |> Ecto.Changeset.put_embed(:info, info_changeset), + {:ok, _user} <- User.update_and_set_cache(changeset) do + {:ok, activity} + else + %{errors: [pinned_activities: {err, _}]} -> + {:error, err} + + _ -> + {:error, "Could not pin"} + end + end + + def unpin(id_or_ap_id, user) do + with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id), + %{valid?: true} = info_changeset <- + Pleroma.User.Info.remove_pinnned_activity(user.info, activity), + changeset <- + Ecto.Changeset.change(user) |> Ecto.Changeset.put_embed(:info, info_changeset), + {:ok, _user} <- User.update_and_set_cache(changeset) do + {:ok, activity} + else + %{errors: [pinned_activities: {err, _}]} -> + {:error, err} + + _ -> + {:error, "Could not unpin"} + end + end + + def add_mute(user, activity) do + with {:ok, _} <- ThreadMute.add_mute(user.id, activity.data["context"]) do + {:ok, activity} + else + {:error, _} -> {:error, "conversation is already muted"} + end + end + + def remove_mute(user, activity) do + ThreadMute.remove_mute(user.id, activity.data["context"]) + {:ok, activity} + end + + def thread_muted?(%{id: nil} = _user, _activity), do: false + + def thread_muted?(user, activity) do + with [] <- ThreadMute.check_muted(user.id, activity.data["context"]) do + false + else + _ -> true + end + end + + def report(user, data) do + with {:account_id, %{"account_id" => account_id}} <- {:account_id, data}, + {:account, %User{} = account} <- {:account, User.get_by_id(account_id)}, + {:ok, {content_html, _, _}} <- make_report_content_html(data["comment"]), + {:ok, statuses} <- get_report_statuses(account, data), + {:ok, activity} <- + ActivityPub.flag(%{ + context: Utils.generate_context_id(), + actor: user, + account: account, + statuses: statuses, + content: content_html, + forward: data["forward"] || false + }) do + {:ok, activity} + else + {:error, err} -> {:error, err} + {:account_id, %{}} -> {:error, "Valid `account_id` required"} + {:account, nil} -> {:error, "Account not found"} + end + end + + def hide_reblogs(user, muted) do + ap_id = muted.ap_id + + if ap_id not in user.info.muted_reblogs do + info_changeset = User.Info.add_reblog_mute(user.info, ap_id) + changeset = Ecto.Changeset.change(user) |> Ecto.Changeset.put_embed(:info, info_changeset) + User.update_and_set_cache(changeset) + end + end + + def show_reblogs(user, muted) do + ap_id = muted.ap_id + + if ap_id in user.info.muted_reblogs do + info_changeset = User.Info.remove_reblog_mute(user.info, ap_id) + changeset = Ecto.Changeset.change(user) |> Ecto.Changeset.put_embed(:info, info_changeset) + User.update_and_set_cache(changeset) + end + end end diff --combined lib/pleroma/web/common_api/utils.ex index ec66452c2,185292878..7781f1635 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@@ -1,38 -1,66 +1,66 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2019 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.CommonAPI.Utils do - alias Pleroma.{Repo, Object, Formatter, Activity} + alias Calendar.Strftime + alias Comeonin.Pbkdf2 + alias Pleroma.Activity + alias Pleroma.Config + alias Pleroma.Formatter + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.Endpoint alias Pleroma.Web.MediaProxy - alias Pleroma.User - alias Calendar.Strftime - alias Comeonin.Pbkdf2 + + require Logger # This is a hack for twidere. def get_by_id_or_ap_id(id) do - activity = Repo.get(Activity, id) || Activity.get_create_activity_by_object_ap_id(id) + activity = + Activity.get_by_id_with_object(id) || Activity.get_create_by_object_ap_id_with_object(id) activity && if activity.data["type"] == "Create" do activity else - Activity.get_create_activity_by_object_ap_id(activity.data["object"]) + Activity.get_create_by_object_ap_id_with_object(activity.data["object"]) end end def get_replied_to_activity(""), do: nil def get_replied_to_activity(id) when not is_nil(id) do - Repo.get(Activity, id) + Activity.get_by_id(id) end def get_replied_to_activity(_), do: nil - def attachments_from_ids(ids) do + def attachments_from_ids(data) do + if Map.has_key?(data, "descriptions") do + attachments_from_ids_descs(data["media_ids"], data["descriptions"]) + else + attachments_from_ids_no_descs(data["media_ids"]) + end + end + + def attachments_from_ids_no_descs(ids) do Enum.map(ids || [], fn media_id -> Repo.get(Object, media_id).data end) end + def attachments_from_ids_descs(ids, descs_str) do + {_, descs} = Jason.decode(descs_str) + + Enum.map(ids || [], fn media_id -> + Map.put(Repo.get(Object, media_id).data, "name", descs[media_id]) + end) + end + def to_for_user_and_mentions(user, mentions, inReplyTo, "public") do mentioned_users = Enum.map(mentions, fn {_, %{ap_id: ap_id}} -> ap_id end) @@@ -76,24 -104,53 +104,53 @@@ def make_content_html( status, - mentions, attachments, - tags, - content_type, - no_attachment_links \\ false + data, + visibility ) do + no_attachment_links = + data + |> Map.get("no_attachment_links", Config.get([:instance, :no_attachment_links])) + |> Kernel.in([true, "true"]) + + content_type = get_content_type(data["content_type"]) + + options = + if visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do + [safe_mention: true] + else + [] + end + status - |> format_input(mentions, tags, content_type) + |> format_input(content_type, options) |> maybe_add_attachments(attachments, no_attachment_links) + |> maybe_add_nsfw_tag(data) + end + + defp get_content_type(content_type) do + if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do + content_type + else + "text/plain" + end end + defp maybe_add_nsfw_tag({text, mentions, tags}, %{"sensitive" => sensitive}) + when sensitive in [true, "True", "true", "1"] do + {text, mentions, [{"#nsfw", "nsfw"} | tags]} + end + + defp maybe_add_nsfw_tag(data, _), do: data + def make_context(%Activity{data: %{"context" => context}}), do: context def make_context(_), do: Utils.generate_context_id() - def maybe_add_attachments(text, _attachments, _no_links = true), do: text + def maybe_add_attachments(parsed, _attachments, true = _no_links), do: parsed - def maybe_add_attachments(text, attachments, _no_links) do - add_attachments(text, attachments) + def maybe_add_attachments({text, mentions, tags}, attachments, _no_links) do + text = add_attachments(text, attachments) + {text, mentions, tags} end def add_attachments(text, attachments) do @@@ -111,46 -168,38 +168,38 @@@ Enum.join([text | attachment_text], "
") end - def format_input(text, mentions, tags, "text/plain") do + def format_input(text, format, options \\ []) + + @doc """ + Formatting text to plain text. + """ + def format_input(text, "text/plain", options) do text |> Formatter.html_escape("text/plain") - |> String.replace(~r/\r?\n/, "
") - |> (&{[], &1}).() - |> Formatter.add_links() - |> Formatter.add_user_links(mentions) - |> Formatter.add_hashtag_links(tags) - |> Formatter.finalize() + |> Formatter.linkify(options) + |> (fn {text, mentions, tags} -> + {String.replace(text, ~r/\r?\n/, "
"), mentions, tags} + end).() end - def format_input(text, mentions, tags, "text/html") do + @doc """ + Formatting text to html. + """ + def format_input(text, "text/html", options) do text |> Formatter.html_escape("text/html") - |> String.replace(~r/\r?\n/, "
") - |> (&{[], &1}).() - |> Formatter.add_user_links(mentions) - |> Formatter.finalize() + |> Formatter.linkify(options) end - def format_input(text, mentions, tags, "text/markdown") do + @doc """ + Formatting text to markdown. + """ + def format_input(text, "text/markdown", options) do text + |> Formatter.mentions_escape(options) |> Earmark.as_html!() + |> Formatter.linkify(options) |> Formatter.html_escape("text/html") - |> String.replace(~r/\r?\n/, "") - |> (&{[], &1}).() - |> Formatter.add_user_links(mentions) - |> Formatter.add_hashtag_links(tags) - |> Formatter.finalize() - end - - def add_tag_links(text, tags) do - tags = - tags - |> Enum.sort_by(fn {tag, _} -> -String.length(tag) end) - - Enum.reduce(tags, text, fn {full, tag}, text -> - url = "" - String.replace(text, full, url) - end) end def make_note_data( @@@ -177,11 -226,8 +226,10 @@@ } if inReplyTo do + inReplyToObject = Object.normalize(inReplyTo.data["object"]) + object - |> Map.put("inReplyTo", inReplyTo.data["object"]["id"]) + |> Map.put("inReplyTo", inReplyToObject.data["id"]) - |> Map.put("inReplyToStatusId", inReplyTo.id) else object end @@@ -195,15 -241,21 +243,21 @@@ Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y") end - def date_to_asctime(date) do - with {:ok, date, _offset} <- date |> DateTime.from_iso8601() do + def date_to_asctime(date) when is_binary(date) do + with {:ok, date, _offset} <- DateTime.from_iso8601(date) do format_asctime(date) else _e -> + Logger.warn("Date #{date} in wrong format, must be ISO 8601") "" end end + def date_to_asctime(date) do + Logger.warn("Date #{date} in wrong format, must be ISO 8601") + "" + end + def to_masto_date(%NaiveDateTime{} = date) do date |> NaiveDateTime.to_iso8601() @@@ -230,7 -282,7 +284,7 @@@ end def confirm_current_password(user, password) do - with %User{local: true} = db_user <- Repo.get(User, user.id), + with %User{local: true} = db_user <- User.get_by_id(user.id), true <- Pbkdf2.checkpw(password, db_user.password_hash) do {:ok, db_user} else @@@ -238,9 -290,9 +292,9 @@@ end end - def emoji_from_profile(%{info: info} = user) do + def emoji_from_profile(%{info: _info} = user) do (Formatter.get_emoji(user.bio) ++ Formatter.get_emoji(user.name)) - |> Enum.map(fn {shortcode, url} -> + |> Enum.map(fn {shortcode, url, _} -> %{ "type" => "Emoji", "icon" => %{"type" => "Image", "url" => "#{Endpoint.url()}#{url}"}, @@@ -248,4 -300,111 +302,111 @@@ } end) end + + def maybe_notify_to_recipients( + recipients, + %Activity{data: %{"to" => to, "type" => _type}} = _activity + ) do + recipients ++ to + end + + def maybe_notify_mentioned_recipients( + recipients, + %Activity{data: %{"to" => _to, "type" => type} = data} = activity + ) + when type == "Create" do + object = Object.normalize(activity) + + object_data = + cond do + !is_nil(object) -> + object.data + + is_map(data["object"]) -> + data["object"] + + true -> + %{} + end + + tagged_mentions = maybe_extract_mentions(object_data) + + recipients ++ tagged_mentions + end + + def maybe_notify_mentioned_recipients(recipients, _), do: recipients + + def maybe_notify_subscribers( + recipients, + %Activity{data: %{"actor" => actor, "type" => type}} = activity + ) + when type == "Create" do + with %User{} = user <- User.get_cached_by_ap_id(actor) do + subscriber_ids = + user + |> User.subscribers() + |> Enum.filter(&Visibility.visible_for_user?(activity, &1)) + |> Enum.map(& &1.ap_id) + + recipients ++ subscriber_ids + end + end + + def maybe_notify_subscribers(recipients, _), do: recipients + + def maybe_extract_mentions(%{"tag" => tag}) do + tag + |> Enum.filter(fn x -> is_map(x) end) + |> Enum.filter(fn x -> x["type"] == "Mention" end) + |> Enum.map(fn x -> x["href"] end) + end + + def maybe_extract_mentions(_), do: [] + + def make_report_content_html(nil), do: {:ok, {nil, [], []}} + + def make_report_content_html(comment) do + max_size = Pleroma.Config.get([:instance, :max_report_comment_size], 1000) + + if String.length(comment) <= max_size do + {:ok, format_input(comment, "text/plain")} + else + {:error, "Comment must be up to #{max_size} characters"} + end + end + + def get_report_statuses(%User{ap_id: actor}, %{"status_ids" => status_ids}) do + {:ok, Activity.all_by_actor_and_id(actor, status_ids)} + end + + def get_report_statuses(_, _), do: {:ok, nil} + + # DEPRECATED mostly, context objects are now created at insertion time. + def context_to_conversation_id(context) do + with %Object{id: id} <- Object.get_cached_by_ap_id(context) do + id + else + _e -> + changeset = Object.context_mapping(context) + + case Repo.insert(changeset) do + {:ok, %{id: id}} -> + id + + # This should be solved by an upsert, but it seems ecto + # has problems accessing the constraint inside the jsonb. + {:error, _} -> + Object.get_cached_by_ap_id(context).id + end + end + end + + def conversation_id_to_context(id) do + with %Object{data: %{"id" => context}} <- Repo.get(Object, id) do + context + else + _e -> + {:error, "No such conversation"} + end + end end diff --combined lib/pleroma/web/federator/federator.ex index 0644f8d0a,c47328e13..a1f6373a4 --- a/lib/pleroma/web/federator/federator.ex +++ b/lib/pleroma/web/federator/federator.ex @@@ -1,55 -1,84 +1,85 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2019 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.Federator do - use GenServer - alias Pleroma.User alias Pleroma.Activity - alias Pleroma.Object.Containment - alias Pleroma.Web.{WebFinger, Websub} - alias Pleroma.Web.Federator.RetryQueue + alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.Federator.RetryQueue alias Pleroma.Web.OStatus ++ alias Pleroma.Object.Containment + alias Pleroma.Web.Salmon + alias Pleroma.Web.WebFinger + alias Pleroma.Web.Websub + require Logger @websub Application.get_env(:pleroma, :websub) @ostatus Application.get_env(:pleroma, :ostatus) - @httpoison Application.get_env(:pleroma, :httpoison) - @max_jobs 20 - def init(args) do - {:ok, args} + def init do + # 1 minute + Process.sleep(1000 * 60) + refresh_subscriptions() end - def start_link do - spawn(fn -> - # 1 minute - Process.sleep(1000 * 60 * 1) - enqueue(:refresh_subscriptions, nil) - end) + # Client API + + def incoming_doc(doc) do + PleromaJobQueue.enqueue(:federator_incoming, __MODULE__, [:incoming_doc, doc]) + end + + def incoming_ap_doc(params) do + PleromaJobQueue.enqueue(:federator_incoming, __MODULE__, [:incoming_ap_doc, params]) + end + + def publish(activity, priority \\ 1) do + PleromaJobQueue.enqueue(:federator_outgoing, __MODULE__, [:publish, activity], priority) + end + + def publish_single_ap(params) do + PleromaJobQueue.enqueue(:federator_outgoing, __MODULE__, [:publish_single_ap, params]) + end + + def publish_single_websub(websub) do + PleromaJobQueue.enqueue(:federator_outgoing, __MODULE__, [:publish_single_websub, websub]) + end + + def verify_websub(websub) do + PleromaJobQueue.enqueue(:federator_outgoing, __MODULE__, [:verify_websub, websub]) + end + + def request_subscription(sub) do + PleromaJobQueue.enqueue(:federator_outgoing, __MODULE__, [:request_subscription, sub]) + end + + def refresh_subscriptions do + PleromaJobQueue.enqueue(:federator_outgoing, __MODULE__, [:refresh_subscriptions]) + end - GenServer.start_link( - __MODULE__, - %{ - in: {:sets.new(), []}, - out: {:sets.new(), []} - }, - name: __MODULE__ - ) + def publish_single_salmon(params) do + PleromaJobQueue.enqueue(:federator_outgoing, __MODULE__, [:publish_single_salmon, params]) end - def handle(:refresh_subscriptions, _) do + # Job Worker Callbacks + + def perform(:refresh_subscriptions) do Logger.debug("Federator running refresh subscriptions") Websub.refresh_subscriptions() spawn(fn -> # 6 hours Process.sleep(1000 * 60 * 60 * 6) - enqueue(:refresh_subscriptions, nil) + refresh_subscriptions() end) end - def handle(:request_subscription, websub) do + def perform(:request_subscription, websub) do Logger.debug("Refreshing #{websub.topic}") with {:ok, websub} <- Websub.request_subscription(websub) do @@@ -59,13 -88,13 +89,13 @@@ end end - def handle(:publish, activity) do + def perform(:publish, activity) do Logger.debug(fn -> "Running publish for #{activity.data["id"]}" end) with actor when not is_nil(actor) <- User.get_cached_by_ap_id(activity.data["actor"]) do {:ok, actor} = WebFinger.ensure_keys_present(actor) - if ActivityPub.is_public?(activity) do + if Visibility.is_public?(activity) do if OStatus.is_representable?(activity) do Logger.info(fn -> "Sending #{activity.data["id"]} out via WebSub" end) Websub.publish(Pleroma.Web.OStatus.feed_path(actor), actor, activity) @@@ -85,7 -114,7 +115,7 @@@ end end - def handle(:verify_websub, websub) do + def perform(:verify_websub, websub) do Logger.debug(fn -> "Running WebSub verification for #{websub.id} (#{websub.topic}, #{websub.callback})" end) @@@ -93,12 -122,12 +123,12 @@@ @websub.verify(websub) end - def handle(:incoming_doc, doc) do + def perform(:incoming_doc, doc) do Logger.info("Got document, trying to parse") @ostatus.handle_incoming(doc) end - def handle(:incoming_ap_doc, params) do + def perform(:incoming_ap_doc, params) do Logger.info("Handling incoming AP activity") params = Utils.normalize_params(params) @@@ -107,7 -136,7 +137,7 @@@ # actor shouldn't be acting on objects outside their own AP server. with {:ok, _user} <- ap_enabled_actor(params["actor"]), nil <- Activity.normalize(params["id"]), - :ok <- Transmogrifier.contain_origin_from_id(params["actor"], params), + :ok <- Containment.contain_origin_from_id(params["actor"], params), {:ok, activity} <- Transmogrifier.handle_incoming(params) do {:ok, activity} else @@@ -123,7 -152,11 +153,11 @@@ end end - def handle(:publish_single_ap, params) do + def perform(:publish_single_salmon, params) do + Salmon.send_to_user(params) + end + + def perform(:publish_single_ap, params) do case ActivityPub.publish_one(params) do {:ok, _} -> :ok @@@ -133,9 -166,9 +167,9 @@@ end end - def handle( + def perform( :publish_single_websub, - %{xml: xml, topic: topic, callback: callback, secret: secret} = params + %{xml: _xml, topic: _topic, callback: _callback, secret: _secret} = params ) do case Websub.publish_one(params) do {:ok, _} -> @@@ -146,75 -179,11 +180,11 @@@ end end - def handle(type, _) do + def perform(type, _) do Logger.debug(fn -> "Unknown task: #{type}" end) {:error, "Don't know what to do with this"} end - if Mix.env() == :test do - def enqueue(type, payload, priority \\ 1) do - if Pleroma.Config.get([:instance, :federating]) do - handle(type, payload) - end - end - else - def enqueue(type, payload, priority \\ 1) do - if Pleroma.Config.get([:instance, :federating]) do - GenServer.cast(__MODULE__, {:enqueue, type, payload, priority}) - end - end - end - - def maybe_start_job(running_jobs, queue) do - if :sets.size(running_jobs) < @max_jobs && queue != [] do - {{type, payload}, queue} = queue_pop(queue) - {:ok, pid} = Task.start(fn -> handle(type, payload) end) - mref = Process.monitor(pid) - {:sets.add_element(mref, running_jobs), queue} - else - {running_jobs, queue} - end - end - - def handle_cast({:enqueue, type, payload, _priority}, state) - when type in [:incoming_doc, :incoming_ap_doc] do - %{in: {i_running_jobs, i_queue}, out: {o_running_jobs, o_queue}} = state - i_queue = enqueue_sorted(i_queue, {type, payload}, 1) - {i_running_jobs, i_queue} = maybe_start_job(i_running_jobs, i_queue) - {:noreply, %{in: {i_running_jobs, i_queue}, out: {o_running_jobs, o_queue}}} - end - - def handle_cast({:enqueue, type, payload, _priority}, state) do - %{in: {i_running_jobs, i_queue}, out: {o_running_jobs, o_queue}} = state - o_queue = enqueue_sorted(o_queue, {type, payload}, 1) - {o_running_jobs, o_queue} = maybe_start_job(o_running_jobs, o_queue) - {:noreply, %{in: {i_running_jobs, i_queue}, out: {o_running_jobs, o_queue}}} - end - - def handle_cast(m, state) do - IO.inspect("Unknown: #{inspect(m)}, #{inspect(state)}") - {:noreply, state} - end - - def handle_info({:DOWN, ref, :process, _pid, _reason}, state) do - %{in: {i_running_jobs, i_queue}, out: {o_running_jobs, o_queue}} = state - i_running_jobs = :sets.del_element(ref, i_running_jobs) - o_running_jobs = :sets.del_element(ref, o_running_jobs) - {i_running_jobs, i_queue} = maybe_start_job(i_running_jobs, i_queue) - {o_running_jobs, o_queue} = maybe_start_job(o_running_jobs, o_queue) - - {:noreply, %{in: {i_running_jobs, i_queue}, out: {o_running_jobs, o_queue}}} - end - - def enqueue_sorted(queue, element, priority) do - [%{item: element, priority: priority} | queue] - |> Enum.sort_by(fn %{priority: priority} -> priority end) - end - - def queue_pop([%{item: element} | queue]) do - {element, queue} - end - def ap_enabled_actor(id) do user = User.get_by_ap_id(id) diff --combined lib/pleroma/web/mastodon_api/mastodon_api_controller.ex index 71390be0d,63fadce38..24a2d4cb9 --- a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex @@@ -1,35 -1,64 +1,64 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2019 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do use Pleroma.Web, :controller - alias Pleroma.{Repo, Object, Activity, User, Notification, Stats} - + alias Pleroma.Object.Fetcher + alias Ecto.Changeset + alias Pleroma.Activity + alias Pleroma.Config + alias Pleroma.Filter + alias Pleroma.Notification + alias Pleroma.Object + alias Pleroma.Pagination + alias Pleroma.Repo + alias Pleroma.ScheduledActivity + alias Pleroma.Stats + alias Pleroma.User alias Pleroma.Web - alias Pleroma.Web.MastodonAPI.{StatusView, AccountView, MastodonView, ListView, FilterView} alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.CommonAPI - alias Pleroma.Web.OAuth.{Authorization, Token, App} + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.MastodonAPI.AppView + alias Pleroma.Web.MastodonAPI.FilterView + alias Pleroma.Web.MastodonAPI.ListView + alias Pleroma.Web.MastodonAPI.MastodonAPI + alias Pleroma.Web.MastodonAPI.MastodonView + alias Pleroma.Web.MastodonAPI.NotificationView + alias Pleroma.Web.MastodonAPI.ReportView + alias Pleroma.Web.MastodonAPI.ScheduledActivityView + alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MediaProxy - alias Comeonin.Pbkdf2 + alias Pleroma.Web.OAuth.App + alias Pleroma.Web.OAuth.Authorization + alias Pleroma.Web.OAuth.Token + + import Pleroma.Web.ControllerHelper, only: [oauth_scopes: 2] import Ecto.Query + require Logger @httpoison Application.get_env(:pleroma, :httpoison) + @local_mastodon_name "Mastodon-Local" action_fallback(:errors) def create_app(conn, params) do - with cs <- App.register_changeset(%App{}, params) |> IO.inspect(), - {:ok, app} <- Repo.insert(cs) |> IO.inspect() do - res = %{ - id: app.id |> to_string, - name: app.client_name, - client_id: app.client_id, - client_secret: app.client_secret, - redirect_uri: app.redirect_uris, - website: app.website - } + scopes = oauth_scopes(params, ["read"]) - json(conn, res) + app_attrs = + params + |> Map.drop(["scope", "scopes"]) + |> Map.put("scopes", scopes) + + with cs <- App.register_changeset(%App{}, app_attrs), + false <- cs.changes[:client_name] == @local_mastodon_name, + {:ok, app} <- Repo.insert(cs) do + conn + |> put_view(AppView) + |> render("show.json", %{app: app}) end end @@@ -101,8 -130,17 +130,17 @@@ json(conn, account) end - def user(%{assigns: %{user: for_user}} = conn, %{"id" => id}) do - with %User{} = user <- Repo.get(User, id) do + def verify_app_credentials(%{assigns: %{user: _user, token: token}} = conn, _) do + with %Token{app: %App{} = app} <- Repo.preload(token, :app) do + conn + |> put_view(AppView) + |> render("short.json", %{app: app}) + end + end + + def user(%{assigns: %{user: for_user}} = conn, %{"id" => nickname_or_id}) do + with %User{} = user <- User.get_cached_by_nickname_or_id(nickname_or_id), + true <- User.auth_active?(user) || user.id == for_user.id || User.superuser?(for_user) do account = AccountView.render("account.json", %{user: user, for: for_user}) json(conn, account) else @@@ -116,7 -154,7 +154,7 @@@ @mastodon_api_level "2.5.0" def masto_instance(conn, _params) do - instance = Pleroma.Config.get(:instance) + instance = Config.get(:instance) response = %{ uri: Web.base_url(), @@@ -125,10 -163,13 +163,13 @@@ version: "#{@mastodon_api_level} (compatible; #{Pleroma.Application.named_version()})", email: Keyword.get(instance, :email), urls: %{ - streaming_api: String.replace(Pleroma.Web.Endpoint.static_url(), "http", "ws") + streaming_api: Pleroma.Web.Endpoint.websocket_url() }, stats: Stats.get_stats(), thumbnail: Web.base_url() <> "/instance/thumbnail.jpeg", + languages: ["en"], + registrations: Pleroma.Config.get([:instance, :registrations_open]), + # Extra (not present in Mastodon): max_toot_chars: Keyword.get(instance, :limit) } @@@ -141,14 -182,15 +182,15 @@@ defp mastodonized_emoji do Pleroma.Emoji.get_all() - |> Enum.map(fn {shortcode, relative_url} -> + |> Enum.map(fn {shortcode, relative_url, tags} -> url = to_string(URI.merge(Web.base_url(), relative_url)) %{ "shortcode" => shortcode, "static_url" => url, "visible_in_picker" => true, - "url" => url + "url" => url, + "tags" => String.split(tags, ",") } end) end @@@ -159,12 -201,31 +201,31 @@@ end defp add_link_headers(conn, method, activities, param \\ nil, params \\ %{}) do + params = + conn.params + |> Map.drop(["since_id", "max_id", "min_id"]) + |> Map.merge(params) + last = List.last(activities) - first = List.first(activities) if last do - min = last.id - max = first.id + max_id = last.id + + limit = + params + |> Map.get("limit", "20") + |> String.to_integer() + + min_id = + if length(activities) <= limit do + activities + |> List.first() + |> Map.get(:id) + else + activities + |> Enum.at(limit * -1) + |> Map.get(:id) + end {next_url, prev_url} = if param do @@@ -173,13 -234,13 +234,13 @@@ Pleroma.Web.Endpoint, method, param, - Map.merge(params, %{max_id: min}) + Map.merge(params, %{max_id: max_id}) ), mastodon_api_url( Pleroma.Web.Endpoint, method, param, - Map.merge(params, %{since_id: max}) + Map.merge(params, %{min_id: min_id}) ) } else @@@ -187,12 -248,12 +248,12 @@@ mastodon_api_url( Pleroma.Web.Endpoint, method, - Map.merge(params, %{max_id: min}) + Map.merge(params, %{max_id: max_id}) ), mastodon_api_url( Pleroma.Web.Endpoint, method, - Map.merge(params, %{since_id: max}) + Map.merge(params, %{min_id: min_id}) ) } end @@@ -209,49 -270,47 +270,47 @@@ params |> Map.put("type", ["Create", "Announce"]) |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) |> Map.put("user", user) activities = - ActivityPub.fetch_activities([user.ap_id | user.following], params) + [user.ap_id | user.following] + |> ActivityPub.fetch_activities(params) |> ActivityPub.contain_timeline(user) |> Enum.reverse() conn |> add_link_headers(:home_timeline, activities) - |> render(StatusView, "index.json", %{activities: activities, for: user, as: :activity}) + |> put_view(StatusView) + |> render("index.json", %{activities: activities, for: user, as: :activity}) end def public_timeline(%{assigns: %{user: user}} = conn, params) do local_only = params["local"] in [true, "True", "true", "1"] - params = + activities = params |> Map.put("type", ["Create", "Announce"]) |> Map.put("local_only", local_only) |> Map.put("blocking_user", user) - - activities = - ActivityPub.fetch_public_activities(params) + |> Map.put("muting_user", user) + |> ActivityPub.fetch_public_activities() |> Enum.reverse() conn |> add_link_headers(:public_timeline, activities, false, %{"local" => local_only}) - |> render(StatusView, "index.json", %{activities: activities, for: user, as: :activity}) + |> put_view(StatusView) + |> render("index.json", %{activities: activities, for: user, as: :activity}) end def user_statuses(%{assigns: %{user: reading_user}} = conn, params) do - with %User{} = user <- Repo.get(User, params["id"]) do - # Since Pleroma has no "pinned" posts feature, we'll just set an empty list here - activities = - if params["pinned"] == "true" do - [] - else - ActivityPub.fetch_user_activities(user, reading_user, params) - end + with %User{} = user <- User.get_by_id(params["id"]) do + activities = ActivityPub.fetch_user_activities(user, reading_user, params) conn |> add_link_headers(:user_statuses, activities, params["id"]) - |> render(StatusView, "index.json", %{ + |> put_view(StatusView) + |> render("index.json", %{ activities: activities, for: reading_user, as: :activity @@@ -260,28 -319,35 +319,35 @@@ end def dm_timeline(%{assigns: %{user: user}} = conn, params) do - query = - ActivityPub.fetch_activities_query( - [user.ap_id], - Map.merge(params, %{"type" => "Create", visibility: "direct"}) - ) + params = + params + |> Map.put("type", "Create") + |> Map.put("blocking_user", user) + |> Map.put("user", user) + |> Map.put(:visibility, "direct") - activities = Repo.all(query) + activities = + [user.ap_id] + |> ActivityPub.fetch_activities_query(params) + |> Pagination.fetch_paginated(params) conn |> add_link_headers(:dm_timeline, activities) - |> render(StatusView, "index.json", %{activities: activities, for: user, as: :activity}) + |> put_view(StatusView) + |> render("index.json", %{activities: activities, for: user, as: :activity}) end def get_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with %Activity{} = activity <- Repo.get(Activity, id), - true <- ActivityPub.visible_for_user?(activity, user) do - try_render(conn, StatusView, "status.json", %{activity: activity, for: user}) + with %Activity{} = activity <- Activity.get_by_id(id), + true <- Visibility.visible_for_user?(activity, user) do + conn + |> put_view(StatusView) + |> try_render("status.json", %{activity: activity, for: user}) end end def get_context(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with %Activity{} = activity <- Repo.get(Activity, id), + with %Activity{} = activity <- Activity.get_by_id(id), activities <- ActivityPub.fetch_activities_for_context(activity.data["context"], %{ "blocking_user" => user, @@@ -301,6 -367,7 +367,7 @@@ as: :activity ) |> Enum.reverse(), + # credo:disable-for-previous-line Credo.Check.Refactor.PipeChainStart descendants: StatusView.render( "index.json", @@@ -309,12 -376,62 +376,62 @@@ as: :activity ) |> Enum.reverse() + # credo:disable-for-previous-line Credo.Check.Refactor.PipeChainStart } json(conn, result) end end + def scheduled_statuses(%{assigns: %{user: user}} = conn, params) do + with scheduled_activities <- MastodonAPI.get_scheduled_activities(user, params) do + conn + |> add_link_headers(:scheduled_statuses, scheduled_activities) + |> put_view(ScheduledActivityView) + |> render("index.json", %{scheduled_activities: scheduled_activities}) + end + end + + def show_scheduled_status(%{assigns: %{user: user}} = conn, %{"id" => scheduled_activity_id}) do + with %ScheduledActivity{} = scheduled_activity <- + ScheduledActivity.get(user, scheduled_activity_id) do + conn + |> put_view(ScheduledActivityView) + |> render("show.json", %{scheduled_activity: scheduled_activity}) + else + _ -> {:error, :not_found} + end + end + + def update_scheduled_status( + %{assigns: %{user: user}} = conn, + %{"id" => scheduled_activity_id} = params + ) do + with %ScheduledActivity{} = scheduled_activity <- + ScheduledActivity.get(user, scheduled_activity_id), + {:ok, scheduled_activity} <- ScheduledActivity.update(scheduled_activity, params) do + conn + |> put_view(ScheduledActivityView) + |> render("show.json", %{scheduled_activity: scheduled_activity}) + else + nil -> {:error, :not_found} + error -> error + end + end + + def delete_scheduled_status(%{assigns: %{user: user}} = conn, %{"id" => scheduled_activity_id}) do + with %ScheduledActivity{} = scheduled_activity <- + ScheduledActivity.get(user, scheduled_activity_id), + {:ok, scheduled_activity} <- ScheduledActivity.delete(scheduled_activity) do + conn + |> put_view(ScheduledActivityView) + |> render("show.json", %{scheduled_activity: scheduled_activity}) + else + nil -> {:error, :not_found} + error -> error + end + end + def post_status(conn, %{"status" => "", "media_ids" => media_ids} = params) when length(media_ids) > 0 do params = @@@ -328,7 -445,6 +445,6 @@@ params = params |> Map.put("in_reply_to_status_id", params["in_reply_to_id"]) - |> Map.put("no_attachment_links", true) idempotency_key = case get_req_header(conn, "idempotency-key") do @@@ -336,10 -452,27 +452,27 @@@ _ -> Ecto.UUID.generate() end - {:ok, activity} = - Cachex.fetch!(:idempotency_cache, idempotency_key, fn _ -> CommonAPI.post(user, params) end) + scheduled_at = params["scheduled_at"] - try_render(conn, StatusView, "status.json", %{activity: activity, for: user, as: :activity}) + if scheduled_at && ScheduledActivity.far_enough?(scheduled_at) do + with {:ok, scheduled_activity} <- + ScheduledActivity.create(user, %{"params" => params, "scheduled_at" => scheduled_at}) do + conn + |> put_view(ScheduledActivityView) + |> render("show.json", %{scheduled_activity: scheduled_activity}) + end + else + params = Map.drop(params, ["scheduled_at"]) + + {:ok, activity} = + Cachex.fetch!(:idempotency_cache, idempotency_key, fn _ -> + CommonAPI.post(user, params) + end) + + conn + |> put_view(StatusView) + |> try_render("status.json", %{activity: activity, for: user, as: :activity}) + end end def delete_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do @@@ -355,48 -488,121 +488,121 @@@ def reblog_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do with {:ok, announce, _activity} <- CommonAPI.repeat(ap_id_or_id, user) do - try_render(conn, StatusView, "status.json", %{activity: announce, for: user, as: :activity}) + conn + |> put_view(StatusView) + |> try_render("status.json", %{activity: announce, for: user, as: :activity}) end end def unreblog_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do with {:ok, _unannounce, %{data: %{"id" => id}}} <- CommonAPI.unrepeat(ap_id_or_id, user), - %Activity{} = activity <- Activity.get_create_activity_by_object_ap_id(id) do - try_render(conn, StatusView, "status.json", %{activity: activity, for: user, as: :activity}) + %Activity{} = activity <- Activity.get_create_by_object_ap_id(id) do + conn + |> put_view(StatusView) + |> try_render("status.json", %{activity: activity, for: user, as: :activity}) end end def fav_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do with {:ok, _fav, %{data: %{"id" => id}}} <- CommonAPI.favorite(ap_id_or_id, user), - %Activity{} = activity <- Activity.get_create_activity_by_object_ap_id(id) do - try_render(conn, StatusView, "status.json", %{activity: activity, for: user, as: :activity}) + %Activity{} = activity <- Activity.get_create_by_object_ap_id(id) do + conn + |> put_view(StatusView) + |> try_render("status.json", %{activity: activity, for: user, as: :activity}) end end def unfav_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do with {:ok, _, _, %{data: %{"id" => id}}} <- CommonAPI.unfavorite(ap_id_or_id, user), - %Activity{} = activity <- Activity.get_create_activity_by_object_ap_id(id) do - try_render(conn, StatusView, "status.json", %{activity: activity, for: user, as: :activity}) + %Activity{} = activity <- Activity.get_create_by_object_ap_id(id) do + conn + |> put_view(StatusView) + |> try_render("status.json", %{activity: activity, for: user, as: :activity}) end end - def notifications(%{assigns: %{user: user}} = conn, params) do - notifications = Notification.for_user(user, params) + def pin_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do + with {:ok, activity} <- CommonAPI.pin(ap_id_or_id, user) do + conn + |> put_view(StatusView) + |> try_render("status.json", %{activity: activity, for: user, as: :activity}) + else + {:error, reason} -> + conn + |> put_resp_content_type("application/json") + |> send_resp(:bad_request, Jason.encode!(%{"error" => reason})) + end + end - result = - Enum.map(notifications, fn x -> - render_notification(user, x) - end) - |> Enum.filter(& &1) + def unpin_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do + with {:ok, activity} <- CommonAPI.unpin(ap_id_or_id, user) do + conn + |> put_view(StatusView) + |> try_render("status.json", %{activity: activity, for: user, as: :activity}) + end + end + + def bookmark_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do + with %Activity{} = activity <- Activity.get_by_id(id), + %User{} = user <- User.get_by_nickname(user.nickname), + true <- Visibility.visible_for_user?(activity, user), + {:ok, user} <- User.bookmark(user, activity.data["object"]["id"]) do + conn + |> put_view(StatusView) + |> try_render("status.json", %{activity: activity, for: user, as: :activity}) + end + end + + def unbookmark_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do + with %Activity{} = activity <- Activity.get_by_id(id), + %User{} = user <- User.get_by_nickname(user.nickname), + true <- Visibility.visible_for_user?(activity, user), + {:ok, user} <- User.unbookmark(user, activity.data["object"]["id"]) do + conn + |> put_view(StatusView) + |> try_render("status.json", %{activity: activity, for: user, as: :activity}) + end + end + + def mute_conversation(%{assigns: %{user: user}} = conn, %{"id" => id}) do + activity = Activity.get_by_id(id) + + with {:ok, activity} <- CommonAPI.add_mute(user, activity) do + conn + |> put_view(StatusView) + |> try_render("status.json", %{activity: activity, for: user, as: :activity}) + else + {:error, reason} -> + conn + |> put_resp_content_type("application/json") + |> send_resp(:bad_request, Jason.encode!(%{"error" => reason})) + end + end + + def unmute_conversation(%{assigns: %{user: user}} = conn, %{"id" => id}) do + activity = Activity.get_by_id(id) + + with {:ok, activity} <- CommonAPI.remove_mute(user, activity) do + conn + |> put_view(StatusView) + |> try_render("status.json", %{activity: activity, for: user, as: :activity}) + end + end + + def notifications(%{assigns: %{user: user}} = conn, params) do + notifications = MastodonAPI.get_notifications(user, params) conn |> add_link_headers(:notifications, notifications) - |> json(result) + |> put_view(NotificationView) + |> render("index.json", %{notifications: notifications, for: user}) end def get_notification(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do with {:ok, notification} <- Notification.get(user, id) do - json(conn, render_notification(user, notification)) + conn + |> put_view(NotificationView) + |> render("show.json", %{notification: notification, for: user}) else {:error, reason} -> conn @@@ -421,66 -627,80 +627,82 @@@ end end + def destroy_multiple(%{assigns: %{user: user}} = conn, %{"ids" => ids} = _params) do + Notification.destroy_multiple(user, ids) + json(conn, %{}) + end + def relationships(%{assigns: %{user: user}} = conn, %{"id" => id}) do id = List.wrap(id) q = from(u in User, where: u.id in ^id) targets = Repo.all(q) - render(conn, AccountView, "relationships.json", %{user: user, targets: targets}) - end - # Instead of returning a 400 when no "id" params is present, Mastodon returns an empty array. - def relationships(%{assigns: %{user: user}} = conn, _) do conn - |> json([]) + |> put_view(AccountView) + |> render("relationships.json", %{user: user, targets: targets}) end - def update_media(%{assigns: %{user: _}} = conn, data) do + # Instead of returning a 400 when no "id" params is present, Mastodon returns an empty array. + def relationships(%{assigns: %{user: _user}} = conn, _), do: json(conn, []) + + def update_media(%{assigns: %{user: user}} = conn, data) do with %Object{} = object <- Repo.get(Object, data["id"]), + true <- Object.authorize_mutation(object, user), true <- is_binary(data["description"]), description <- data["description"] do new_data = %{object.data | "name" => description} - change = Object.change(object, %{data: new_data}) - {:ok, _} = Repo.update(change) + {:ok, _} = + object + |> Object.change(%{data: new_data}) + |> Repo.update() - data = - new_data - |> Map.put("id", object.id) + attachment_data = Map.put(new_data, "id", object.id) - render(conn, StatusView, "attachment.json", %{attachment: data}) + conn + |> put_view(StatusView) + |> render("attachment.json", %{attachment: attachment_data}) end end - def upload(%{assigns: %{user: _}} = conn, %{"file" => file} = data) do - with {:ok, object} <- ActivityPub.upload(file, description: Map.get(data, "description")) do - change = Object.change(object, %{data: object.data}) - {:ok, object} = Repo.update(change) - - objdata = - object.data - |> Map.put("id", object.id) + def upload(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do + with {:ok, object} <- + ActivityPub.upload( + file, + actor: User.ap_id(user), + description: Map.get(data, "description") + ) do + attachment_data = Map.put(object.data, "id", object.id) - render(conn, StatusView, "attachment.json", %{attachment: objdata}) + conn + |> put_view(StatusView) + |> render("attachment.json", %{attachment: attachment_data}) end end def favourited_by(conn, %{"id" => id}) do - with %Activity{data: %{"object" => %{"likes" => likes}}} <- Activity.get_by_id(id) do + with %Activity{data: %{"object" => object}} <- Repo.get(Activity, id), + %Object{data: %{"likes" => likes}} <- Object.normalize(object) do q = from(u in User, where: u.ap_id in ^likes) users = Repo.all(q) - render(conn, AccountView, "accounts.json", %{users: users, as: :user}) + + conn + |> put_view(AccountView) + |> render(AccountView, "accounts.json", %{users: users, as: :user}) else _ -> json(conn, []) end end def reblogged_by(conn, %{"id" => id}) do - with %Activity{data: %{"object" => %{"announcements" => announces}}} <- Activity.get_by_id(id) do + with %Activity{data: %{"object" => object}} <- Repo.get(Activity, id), + %Object{data: %{"announcements" => announces}} <- Object.normalize(object) do q = from(u in User, where: u.ap_id in ^announces) users = Repo.all(q) - render(conn, AccountView, "accounts.json", %{users: users, as: :user}) + + conn + |> put_view(AccountView) + |> render("accounts.json", %{users: users, as: :user}) else _ -> json(conn, []) end @@@ -489,56 -709,89 +711,89 @@@ def hashtag_timeline(%{assigns: %{user: user}} = conn, params) do local_only = params["local"] in [true, "True", "true", "1"] - params = + tags = + [params["tag"], params["any"]] + |> List.flatten() + |> Enum.uniq() + |> Enum.filter(& &1) + |> Enum.map(&String.downcase(&1)) + + tag_all = + params["all"] || + [] + |> Enum.map(&String.downcase(&1)) + + tag_reject = + params["none"] || + [] + |> Enum.map(&String.downcase(&1)) + + activities = params |> Map.put("type", "Create") |> Map.put("local_only", local_only) |> Map.put("blocking_user", user) - |> Map.put("tag", String.downcase(params["tag"])) - - activities = - ActivityPub.fetch_public_activities(params) + |> Map.put("muting_user", user) + |> Map.put("tag", tags) + |> Map.put("tag_all", tag_all) + |> Map.put("tag_reject", tag_reject) + |> ActivityPub.fetch_public_activities() |> Enum.reverse() conn |> add_link_headers(:hashtag_timeline, activities, params["tag"], %{"local" => local_only}) - |> render(StatusView, "index.json", %{activities: activities, for: user, as: :activity}) - end + |> put_view(StatusView) + |> render("index.json", %{activities: activities, for: user, as: :activity}) + end + + def followers(%{assigns: %{user: for_user}} = conn, %{"id" => id} = params) do + with %User{} = user <- User.get_by_id(id), + followers <- MastodonAPI.get_followers(user, params) do + followers = + cond do + for_user && user.id == for_user.id -> followers + user.info.hide_followers -> [] + true -> followers + end - # TODO: Pagination - def followers(conn, %{"id" => id}) do - with %User{} = user <- Repo.get(User, id), - {:ok, followers} <- User.get_followers(user) do - render(conn, AccountView, "accounts.json", %{users: followers, as: :user}) + conn + |> add_link_headers(:followers, followers, user) + |> put_view(AccountView) + |> render("accounts.json", %{users: followers, as: :user}) end end - def following(conn, %{"id" => id}) do - with %User{} = user <- Repo.get(User, id), - {:ok, followers} <- User.get_friends(user) do - render(conn, AccountView, "accounts.json", %{users: followers, as: :user}) + def following(%{assigns: %{user: for_user}} = conn, %{"id" => id} = params) do + with %User{} = user <- User.get_by_id(id), + followers <- MastodonAPI.get_friends(user, params) do + followers = + cond do + for_user && user.id == for_user.id -> followers + user.info.hide_follows -> [] + true -> followers + end + + conn + |> add_link_headers(:following, followers, user) + |> put_view(AccountView) + |> render("accounts.json", %{users: followers, as: :user}) end end def follow_requests(%{assigns: %{user: followed}} = conn, _params) do with {:ok, follow_requests} <- User.get_follow_requests(followed) do - render(conn, AccountView, "accounts.json", %{users: follow_requests, as: :user}) + conn + |> put_view(AccountView) + |> render("accounts.json", %{users: follow_requests, as: :user}) end end def authorize_follow_request(%{assigns: %{user: followed}} = conn, %{"id" => id}) do - with %User{} = follower <- Repo.get(User, id), - {:ok, follower} <- User.maybe_follow(follower, followed), - %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), - {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "accept"), - {:ok, _activity} <- - ActivityPub.accept(%{ - to: [follower.ap_id], - actor: followed.ap_id, - object: follow_activity.data["id"], - type: "Accept" - }) do - render(conn, AccountView, "relationship.json", %{user: followed, target: follower}) + with %User{} = follower <- User.get_by_id(id), + {:ok, follower} <- CommonAPI.accept_follow_request(follower, followed) do + conn + |> put_view(AccountView) + |> render("relationship.json", %{user: followed, target: follower}) else {:error, message} -> conn @@@ -548,17 -801,11 +803,11 @@@ end def reject_follow_request(%{assigns: %{user: followed}} = conn, %{"id" => id}) do - with %User{} = follower <- Repo.get(User, id), - %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), - {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "reject"), - {:ok, _activity} <- - ActivityPub.reject(%{ - to: [follower.ap_id], - actor: followed.ap_id, - object: follow_activity.data["id"], - type: "Reject" - }) do - render(conn, AccountView, "relationship.json", %{user: followed, target: follower}) + with %User{} = follower <- User.get_by_id(id), + {:ok, follower} <- CommonAPI.reject_follow_request(follower, followed) do + conn + |> put_view(AccountView) + |> render("relationship.json", %{user: followed, target: follower}) else {:error, message} -> conn @@@ -568,17 -815,30 +817,30 @@@ end def follow(%{assigns: %{user: follower}} = conn, %{"id" => id}) do - with %User{} = followed <- Repo.get(User, id), - {:ok, follower} <- User.maybe_direct_follow(follower, followed), - {:ok, _activity} <- ActivityPub.follow(follower, followed), - {:ok, follower, followed} <- - User.wait_and_refresh( - Pleroma.Config.get([:activitypub, :follow_handshake_timeout]), - follower, - followed - ) do - render(conn, AccountView, "relationship.json", %{user: follower, target: followed}) + with {_, %User{} = followed} <- {:followed, User.get_cached_by_id(id)}, + {_, true} <- {:followed, follower.id != followed.id}, + false <- User.following?(follower, followed), + {:ok, follower, followed, _} <- CommonAPI.follow(follower, followed) do + conn + |> put_view(AccountView) + |> render("relationship.json", %{user: follower, target: followed}) else + {:followed, _} -> + {:error, :not_found} + + true -> + followed = User.get_cached_by_id(id) + + {:ok, follower} = + case conn.params["reblogs"] do + true -> CommonAPI.show_reblogs(follower, followed) + false -> CommonAPI.hide_reblogs(follower, followed) + end + + conn + |> put_view(AccountView) + |> render("relationship.json", %{user: follower, target: followed}) + {:error, message} -> conn |> put_resp_content_type("application/json") @@@ -587,11 -847,16 +849,16 @@@ end def follow(%{assigns: %{user: follower}} = conn, %{"uri" => uri}) do - with %User{} = followed <- Repo.get_by(User, nickname: uri), - {:ok, follower} <- User.maybe_direct_follow(follower, followed), - {:ok, _activity} <- ActivityPub.follow(follower, followed) do - render(conn, AccountView, "account.json", %{user: followed, for: follower}) + with {_, %User{} = followed} <- {:followed, User.get_cached_by_nickname(uri)}, + {_, true} <- {:followed, follower.id != followed.id}, + {:ok, follower, followed, _} <- CommonAPI.follow(follower, followed) do + conn + |> put_view(AccountView) + |> render("account.json", %{user: followed, for: follower}) else + {:followed, _} -> + {:error, :not_found} + {:error, message} -> conn |> put_resp_content_type("application/json") @@@ -600,18 -865,63 +867,63 @@@ end def unfollow(%{assigns: %{user: follower}} = conn, %{"id" => id}) do - with %User{} = followed <- Repo.get(User, id), - {:ok, _activity} <- ActivityPub.unfollow(follower, followed), - {:ok, follower, _} <- User.unfollow(follower, followed) do - render(conn, AccountView, "relationship.json", %{user: follower, target: followed}) + with {_, %User{} = followed} <- {:followed, User.get_cached_by_id(id)}, + {_, true} <- {:followed, follower.id != followed.id}, + {:ok, follower} <- CommonAPI.unfollow(follower, followed) do + conn + |> put_view(AccountView) + |> render("relationship.json", %{user: follower, target: followed}) + else + {:followed, _} -> + {:error, :not_found} + + error -> + error + end + end + + def mute(%{assigns: %{user: muter}} = conn, %{"id" => id}) do + with %User{} = muted <- User.get_by_id(id), + {:ok, muter} <- User.mute(muter, muted) do + conn + |> put_view(AccountView) + |> render("relationship.json", %{user: muter, target: muted}) + else + {:error, message} -> + conn + |> put_resp_content_type("application/json") + |> send_resp(403, Jason.encode!(%{"error" => message})) + end + end + + def unmute(%{assigns: %{user: muter}} = conn, %{"id" => id}) do + with %User{} = muted <- User.get_by_id(id), + {:ok, muter} <- User.unmute(muter, muted) do + conn + |> put_view(AccountView) + |> render("relationship.json", %{user: muter, target: muted}) + else + {:error, message} -> + conn + |> put_resp_content_type("application/json") + |> send_resp(403, Jason.encode!(%{"error" => message})) + end + end + + def mutes(%{assigns: %{user: user}} = conn, _) do + with muted_accounts <- User.muted_users(user) do + res = AccountView.render("accounts.json", users: muted_accounts, for: user, as: :user) + json(conn, res) end end def block(%{assigns: %{user: blocker}} = conn, %{"id" => id}) do - with %User{} = blocked <- Repo.get(User, id), + with %User{} = blocked <- User.get_by_id(id), {:ok, blocker} <- User.block(blocker, blocked), {:ok, _activity} <- ActivityPub.block(blocker, blocked) do - render(conn, AccountView, "relationship.json", %{user: blocker, target: blocked}) + conn + |> put_view(AccountView) + |> render("relationship.json", %{user: blocker, target: blocked}) else {:error, message} -> conn @@@ -621,10 -931,12 +933,12 @@@ end def unblock(%{assigns: %{user: blocker}} = conn, %{"id" => id}) do - with %User{} = blocked <- Repo.get(User, id), + with %User{} = blocked <- User.get_by_id(id), {:ok, blocker} <- User.unblock(blocker, blocked), {:ok, _activity} <- ActivityPub.unblock(blocker, blocked) do - render(conn, AccountView, "relationship.json", %{user: blocker, target: blocked}) + conn + |> put_view(AccountView) + |> render("relationship.json", %{user: blocker, target: blocked}) else {:error, message} -> conn @@@ -633,11 -945,9 +947,9 @@@ end end - # TODO: Use proper query def blocks(%{assigns: %{user: user}} = conn, _) do - with blocked_users <- user.info.blocks || [], - accounts <- Enum.map(blocked_users, fn ap_id -> User.get_cached_by_ap_id(ap_id) end) do - res = AccountView.render("accounts.json", users: accounts, for: user, as: :user) + with blocked_accounts <- User.blocked_users(user) do + res = AccountView.render("accounts.json", users: blocked_accounts, for: user, as: :user) json(conn, res) end end @@@ -656,11 -966,41 +968,41 @@@ json(conn, %{}) end - def status_search(query) do + def subscribe(%{assigns: %{user: user}} = conn, %{"id" => id}) do + with %User{} = subscription_target <- User.get_cached_by_id(id), + {:ok, subscription_target} = User.subscribe(user, subscription_target) do + conn + |> put_view(AccountView) + |> render("relationship.json", %{user: user, target: subscription_target}) + else + {:error, message} -> + conn + |> put_resp_content_type("application/json") + |> send_resp(403, Jason.encode!(%{"error" => message})) + end + end + + def unsubscribe(%{assigns: %{user: user}} = conn, %{"id" => id}) do + with %User{} = subscription_target <- User.get_cached_by_id(id), + {:ok, subscription_target} = User.unsubscribe(user, subscription_target) do + conn + |> put_view(AccountView) + |> render("relationship.json", %{user: user, target: subscription_target}) + else + {:error, message} -> + conn + |> put_resp_content_type("application/json") + |> send_resp(403, Jason.encode!(%{"error" => message})) + end + end + + def status_search(user, query) do fetched = if Regex.match?(~r/https?:/, query) do - with {:ok, object} <- Fetcher.fetch_object_from_id(query) do - [Activity.get_create_activity_by_object_ap_id(object.data["id"])] - with {:ok, object} <- ActivityPub.fetch_object_from_id(query), ++ with {:ok, object} <- Fetcher.fetch_object_from_id(query), + %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), + true <- Visibility.visible_for_user?(activity, user) do + [activity] else _e -> [] end @@@ -685,14 -1025,15 +1027,15 @@@ end def search2(%{assigns: %{user: user}} = conn, %{"q" => query} = params) do - accounts = User.search(query, params["resolve"] == "true") + accounts = User.search(query, resolve: params["resolve"] == "true", for_user: user) - statuses = status_search(query) + statuses = status_search(user, query) tags_path = Web.base_url() <> "/tag/" tags = - String.split(query) + query + |> String.split() |> Enum.uniq() |> Enum.filter(fn tag -> String.starts_with?(tag, "#") end) |> Enum.map(fn tag -> String.slice(tag, 1..-1) end) @@@ -709,12 -1050,13 +1052,13 @@@ end def search(%{assigns: %{user: user}} = conn, %{"q" => query} = params) do - accounts = User.search(query, params["resolve"] == "true") + accounts = User.search(query, resolve: params["resolve"] == "true", for_user: user) - statuses = status_search(query) + statuses = status_search(user, query) tags = - String.split(query) + query + |> String.split() |> Enum.uniq() |> Enum.filter(fn tag -> String.starts_with?(tag, "#") end) |> Enum.map(fn tag -> String.slice(tag, 1..-1) end) @@@ -730,26 -1072,41 +1074,41 @@@ end def account_search(%{assigns: %{user: user}} = conn, %{"q" => query} = params) do - accounts = User.search(query, params["resolve"] == "true") + accounts = User.search(query, resolve: params["resolve"] == "true", for_user: user) res = AccountView.render("accounts.json", users: accounts, for: user, as: :user) json(conn, res) end - def favourites(%{assigns: %{user: user}} = conn, _) do + def favourites(%{assigns: %{user: user}} = conn, params) do params = - %{} + params |> Map.put("type", "Create") |> Map.put("favorited_by", user.ap_id) |> Map.put("blocking_user", user) activities = - ActivityPub.fetch_public_activities(params) + ActivityPub.fetch_activities([], params) + |> Enum.reverse() + + conn + |> add_link_headers(:favourites, activities) + |> put_view(StatusView) + |> render("index.json", %{activities: activities, for: user, as: :activity}) + end + + def bookmarks(%{assigns: %{user: user}} = conn, _) do + user = User.get_by_id(user.id) + + activities = + user.bookmarks + |> Enum.map(fn id -> Activity.get_create_by_object_ap_id(id) end) |> Enum.reverse() conn - |> render(StatusView, "index.json", %{activities: activities, for: user, as: :activity}) + |> put_view(StatusView) + |> render("index.json", %{activities: activities, for: user, as: :activity}) end def get_lists(%{assigns: %{user: user}} = conn, opts) do @@@ -763,7 -1120,10 +1122,10 @@@ res = ListView.render("list.json", list: list) json(conn, res) else - _e -> json(conn, "error") + _e -> + conn + |> put_status(404) + |> json(%{error: "Record not found"}) end end @@@ -794,7 -1154,7 +1156,7 @@@ accounts |> Enum.each(fn account_id -> with %Pleroma.List{} = list <- Pleroma.List.get(id, user), - %User{} = followed <- Repo.get(User, account_id) do + %User{} = followed <- User.get_by_id(account_id) do Pleroma.List.follow(list, followed) end end) @@@ -806,7 -1166,7 +1168,7 @@@ accounts |> Enum.each(fn account_id -> with %Pleroma.List{} = list <- Pleroma.List.get(id, user), - %User{} = followed <- Repo.get(Pleroma.User, account_id) do + %User{} = followed <- Pleroma.User.get_by_id(account_id) do Pleroma.List.unfollow(list, followed) end end) @@@ -817,7 -1177,9 +1179,9 @@@ def list_accounts(%{assigns: %{user: user}} = conn, %{"id" => id}) do with %Pleroma.List{} = list <- Pleroma.List.get(id, user), {:ok, users} = Pleroma.List.get_following(list) do - render(conn, AccountView, "accounts.json", %{users: users, as: :user}) + conn + |> put_view(AccountView) + |> render("accounts.json", %{users: users, as: :user}) end end @@@ -833,24 -1195,24 +1197,24 @@@ end def list_timeline(%{assigns: %{user: user}} = conn, %{"list_id" => id} = params) do - with %Pleroma.List{title: title, following: following} <- Pleroma.List.get(id, user) do + with %Pleroma.List{title: _title, following: following} <- Pleroma.List.get(id, user) do params = params |> Map.put("type", "Create") |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) # we must filter the following list for the user to avoid leaking statuses the user # does not actually have permission to see (for more info, peruse security issue #270). - following_to = + activities = following |> Enum.filter(fn x -> x in user.following end) - - activities = - ActivityPub.fetch_activities_bounded(following_to, following, params) + |> ActivityPub.fetch_activities_bounded(following, params) |> Enum.reverse() conn - |> render(StatusView, "index.json", %{activities: activities, for: user, as: :activity}) + |> put_view(StatusView) + |> render("index.json", %{activities: activities, for: user, as: :activity}) else _e -> conn @@@ -860,18 -1222,18 +1224,18 @@@ end def index(%{assigns: %{user: user}} = conn, _params) do - token = - conn - |> get_session(:oauth_token) + token = get_session(conn, :oauth_token) if user && token do mastodon_emoji = mastodonized_emoji() - limit = Pleroma.Config.get([:instance, :limit]) + limit = Config.get([:instance, :limit]) accounts = Map.put(%{}, user.id, AccountView.render("account.json", %{user: user, for: user})) + flavour = get_user_flavour(user) + initial_state = %{ meta: %{ @@@ -888,15 -1250,18 +1252,18 @@@ auto_play_gif: false, display_sensitive_media: false, reduce_motion: false, - max_toot_chars: limit + max_toot_chars: limit, + mascot: "/images/pleroma-fox-tan-smol.png" }, rights: %{ - delete_others_notice: !!user.info.is_moderator + delete_others_notice: present?(user.info.is_moderator), + admin: present?(user.info.is_admin) }, compose: %{ me: "#{user.id}", default_privacy: user.info.default_scope, - default_sensitive: false + default_sensitive: false, + allow_content_types: Config.get([:instance, :allowed_post_formats]) }, media_attachments: %{ accept_content_types: [ @@@ -915,7 -1280,7 +1282,7 @@@ ] }, settings: - Map.get(user.info, :settings) || + user.info.settings || %{ onboarded: true, home: %{ @@@ -954,36 -1319,83 +1321,83 @@@ conn |> put_layout(false) - |> render(MastodonView, "index.html", %{initial_state: initial_state}) + |> put_view(MastodonView) + |> render("index.html", %{initial_state: initial_state, flavour: flavour}) else conn + |> put_session(:return_to, conn.request_path) |> redirect(to: "/web/login") end end def put_settings(%{assigns: %{user: user}} = conn, %{"data" => settings} = _params) do - with new_info <- Map.put(user.info, "settings", settings), - change <- User.info_changeset(user, %{info: new_info}), - {:ok, _user} <- User.update_and_set_cache(change) do - conn - |> json(%{}) + info_cng = User.Info.mastodon_settings_update(user.info, settings) + + with changeset <- Ecto.Changeset.change(user), + changeset <- Ecto.Changeset.put_embed(changeset, :info, info_cng), + {:ok, _user} <- User.update_and_set_cache(changeset) do + json(conn, %{}) + else + e -> + conn + |> put_resp_content_type("application/json") + |> send_resp(500, Jason.encode!(%{"error" => inspect(e)})) + end + end + + @supported_flavours ["glitch", "vanilla"] + + def set_flavour(%{assigns: %{user: user}} = conn, %{"flavour" => flavour} = _params) + when flavour in @supported_flavours do + flavour_cng = User.Info.mastodon_flavour_update(user.info, flavour) + + with changeset <- Ecto.Changeset.change(user), + changeset <- Ecto.Changeset.put_embed(changeset, :info, flavour_cng), + {:ok, user} <- User.update_and_set_cache(changeset), + flavour <- user.info.flavour do + json(conn, flavour) else e -> conn - |> json(%{error: inspect(e)}) + |> put_resp_content_type("application/json") + |> send_resp(500, Jason.encode!(%{"error" => inspect(e)})) end end - def login(conn, %{"code" => code}) do + def set_flavour(conn, _params) do + conn + |> put_status(400) + |> json(%{error: "Unsupported flavour"}) + end + + def get_flavour(%{assigns: %{user: user}} = conn, _params) do + json(conn, get_user_flavour(user)) + end + + defp get_user_flavour(%User{info: %{flavour: flavour}}) when flavour in @supported_flavours do + flavour + end + + defp get_user_flavour(_) do + "glitch" + end + + def login(%{assigns: %{user: %User{}}} = conn, _params) do + redirect(conn, to: local_mastodon_root_path(conn)) + end + + @doc "Local Mastodon FE login init action" + def login(conn, %{"code" => auth_token}) do with {:ok, app} <- get_or_make_app(), - %Authorization{} = auth <- Repo.get_by(Authorization, token: code, app_id: app.id), + %Authorization{} = auth <- Repo.get_by(Authorization, token: auth_token, app_id: app.id), {:ok, token} <- Token.exchange_token(app, auth) do conn |> put_session(:oauth_token, token.token) - |> redirect(to: "/web/getting-started") + |> redirect(to: local_mastodon_root_path(conn)) end end + @doc "Local Mastodon FE callback action" def login(conn, _) do with {:ok, app} <- get_or_make_app() do path = @@@ -993,25 -1405,46 +1407,46 @@@ response_type: "code", client_id: app.client_id, redirect_uri: ".", - scope: app.scopes + scope: Enum.join(app.scopes, " ") ) - conn - |> redirect(to: path) + redirect(conn, to: path) end end - defp get_or_make_app() do - with %App{} = app <- Repo.get_by(App, client_name: "Mastodon-Local") do + defp local_mastodon_root_path(conn) do + case get_session(conn, :return_to) do + nil -> + mastodon_api_path(conn, :index, ["getting-started"]) + + return_to -> + delete_session(conn, :return_to) + return_to + end + end + + defp get_or_make_app do + find_attrs = %{client_name: @local_mastodon_name, redirect_uris: "."} + scopes = ["read", "write", "follow", "push"] + + with %App{} = app <- Repo.get_by(App, find_attrs) do + {:ok, app} = + if app.scopes == scopes do + {:ok, app} + else + app + |> Ecto.Changeset.change(%{scopes: scopes}) + |> Repo.update() + end + {:ok, app} else _e -> cs = - App.register_changeset(%App{}, %{ - client_name: "Mastodon-Local", - redirect_uris: ".", - scopes: "read,write,follow" - }) + App.register_changeset( + %App{}, + Map.put(find_attrs, :scopes, scopes) + ) Repo.insert(cs) end @@@ -1026,8 -1459,10 +1461,10 @@@ def relationship_noop(%{assigns: %{user: user}} = conn, %{"id" => id}) do Logger.debug("Unimplemented, returning unmodified relationship") - with %User{} = target <- Repo.get(User, id) do - render(conn, AccountView, "relationship.json", %{user: user, target: target}) + with %User{} = target <- User.get_by_id(id) do + conn + |> put_view(AccountView) + |> render("relationship.json", %{user: user, target: target}) end end @@@ -1041,62 -1476,8 +1478,8 @@@ json(conn, %{}) end - def render_notification(user, %{id: id, activity: activity, inserted_at: created_at} = _params) do - actor = User.get_cached_by_ap_id(activity.data["actor"]) - - created_at = - NaiveDateTime.to_iso8601(created_at) - |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false) - - id = id |> to_string - - case activity.data["type"] do - "Create" -> - %{ - id: id, - type: "mention", - created_at: created_at, - account: AccountView.render("account.json", %{user: actor, for: user}), - status: StatusView.render("status.json", %{activity: activity, for: user}) - } - - "Like" -> - liked_activity = Activity.get_create_activity_by_object_ap_id(activity.data["object"]) - - %{ - id: id, - type: "favourite", - created_at: created_at, - account: AccountView.render("account.json", %{user: actor, for: user}), - status: StatusView.render("status.json", %{activity: liked_activity, for: user}) - } - - "Announce" -> - announced_activity = Activity.get_create_activity_by_object_ap_id(activity.data["object"]) - - %{ - id: id, - type: "reblog", - created_at: created_at, - account: AccountView.render("account.json", %{user: actor, for: user}), - status: StatusView.render("status.json", %{activity: announced_activity, for: user}) - } - - "Follow" -> - %{ - id: id, - type: "follow", - created_at: created_at, - account: AccountView.render("account.json", %{user: actor, for: user}) - } - - _ -> - nil - end - end - def get_filters(%{assigns: %{user: user}} = conn, _) do - filters = Pleroma.Filter.get_filters(user) + filters = Filter.get_filters(user) res = FilterView.render("filters.json", filters: filters) json(conn, res) end @@@ -1105,7 -1486,7 +1488,7 @@@ %{assigns: %{user: user}} = conn, %{"phrase" => phrase, "context" => context} = params ) do - query = %Pleroma.Filter{ + query = %Filter{ user_id: user.id, phrase: phrase, context: context, @@@ -1114,13 -1495,13 +1497,13 @@@ # expires_at } - {:ok, response} = Pleroma.Filter.create(query) + {:ok, response} = Filter.create(query) res = FilterView.render("filter.json", filter: response) json(conn, res) end def get_filter(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do - filter = Pleroma.Filter.get(filter_id, user) + filter = Filter.get(filter_id, user) res = FilterView.render("filter.json", filter: filter) json(conn, res) end @@@ -1129,7 -1510,7 +1512,7 @@@ %{assigns: %{user: user}} = conn, %{"phrase" => phrase, "context" => context, "id" => filter_id} = params ) do - query = %Pleroma.Filter{ + query = %Filter{ user_id: user.id, filter_id: filter_id, phrase: phrase, @@@ -1139,21 -1520,40 +1522,40 @@@ # expires_at } - {:ok, response} = Pleroma.Filter.update(query) + {:ok, response} = Filter.update(query) res = FilterView.render("filter.json", filter: response) json(conn, res) end def delete_filter(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do - query = %Pleroma.Filter{ + query = %Filter{ user_id: user.id, filter_id: filter_id } - {:ok, _} = Pleroma.Filter.delete(query) + {:ok, _} = Filter.delete(query) json(conn, %{}) end + # fallback action + # + def errors(conn, {:error, %Changeset{} = changeset}) do + error_message = + changeset + |> Changeset.traverse_errors(fn {message, _opt} -> message end) + |> Enum.map_join(", ", fn {_k, v} -> v end) + + conn + |> put_status(422) + |> json(%{error: error_message}) + end + + def errors(conn, {:error, :not_found}) do + conn + |> put_status(404) + |> json(%{error: "Record not found"}) + end + def errors(conn, _) do conn |> put_status(500) @@@ -1161,23 -1561,35 +1563,35 @@@ end def suggestions(%{assigns: %{user: user}} = conn, _) do - suggestions = Pleroma.Config.get(:suggestions) + suggestions = Config.get(:suggestions) if Keyword.get(suggestions, :enabled, false) do api = Keyword.get(suggestions, :third_party_engine, "") timeout = Keyword.get(suggestions, :timeout, 5000) limit = Keyword.get(suggestions, :limit, 23) - host = Pleroma.Config.get([Pleroma.Web.Endpoint, :url, :host]) + host = Config.get([Pleroma.Web.Endpoint, :url, :host]) user = user.nickname - url = String.replace(api, "{{host}}", host) |> String.replace("{{user}}", user) - with {:ok, %{status_code: 200, body: body}} <- - @httpoison.get(url, [], timeout: timeout, recv_timeout: timeout), + url = + api + |> String.replace("{{host}}", host) + |> String.replace("{{user}}", user) + + with {:ok, %{status: 200, body: body}} <- + @httpoison.get( + url, + [], + adapter: [ + recv_timeout: timeout, + pool: :default + ] + ), {:ok, data} <- Jason.decode(body) do - data2 = - Enum.slice(data, 0, limit) + data = + data + |> Enum.slice(0, limit) |> Enum.map(fn x -> Map.put( x, @@@ -1196,7 -1608,7 +1610,7 @@@ end) conn - |> json(data2) + |> json(data) else e -> Logger.error("Could not retrieve suggestions at fetch #{url}, #{inspect(e)}") end @@@ -1205,9 -1617,39 +1619,39 @@@ end end - def try_render(conn, renderer, target, params) + def status_card(%{assigns: %{user: user}} = conn, %{"id" => status_id}) do + with %Activity{} = activity <- Activity.get_by_id(status_id), + true <- Visibility.visible_for_user?(activity, user) do + data = + StatusView.render( + "card.json", + Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) + ) + + json(conn, data) + else + _e -> + %{} + end + end + + def reports(%{assigns: %{user: user}} = conn, params) do + case CommonAPI.report(user, params) do + {:ok, activity} -> + conn + |> put_view(ReportView) + |> try_render("report.json", %{activity: activity}) + + {:error, err} -> + conn + |> put_status(:bad_request) + |> json(%{error: err}) + end + end + + def try_render(conn, target, params) when is_binary(target) do - res = render(conn, renderer, target, params) + res = render(conn, target, params) if res == nil do conn @@@ -1218,9 -1660,13 +1662,13 @@@ end end - def try_render(conn, _, _, _) do + def try_render(conn, _, _) do conn |> put_status(501) |> json(%{error: "Can't display this activity"}) end + + defp present?(nil), do: false + defp present?(false), do: false + defp present?(_), do: true end diff --combined lib/pleroma/web/mastodon_api/views/status_view.ex index 31f4675c3,a9f607aa5..e4de5ecfb --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@@ -1,53 -1,84 +1,87 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2019 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.MastodonAPI.StatusView do use Pleroma.Web, :view - alias Pleroma.Web.MastodonAPI.{AccountView, StatusView} - alias Pleroma.{User, Activity, Object} + + alias Pleroma.Activity + alias Pleroma.HTML + alias Pleroma.Repo ++ alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MediaProxy - alias Pleroma.Repo - alias Pleroma.HTML # TODO: Add cached version. defp get_replied_to_activities(activities) do activities |> Enum.map(fn - %{data: %{"type" => "Create", "object" => %{"inReplyTo" => in_reply_to}}} -> - in_reply_to != "" && in_reply_to + %{data: %{"type" => "Create", "object" => object}} -> + object = Object.normalize(object) + object.data["inReplyTo"] != "" && object.data["inReplyTo"] _ -> nil end) |> Enum.filter(& &1) - |> Activity.create_activity_by_object_id_query() + |> Activity.create_by_object_ap_id() |> Repo.all() |> Enum.reduce(%{}, fn activity, acc -> - Map.put(acc, activity.data["object"]["id"], activity) + object = Object.normalize(activity.data["object"]) + Map.put(acc, object.data["id"], activity) end) end + defp get_user(ap_id) do + cond do + user = User.get_cached_by_ap_id(ap_id) -> + user + + user = User.get_by_guessed_nickname(ap_id) -> + user + + true -> + User.error_user(ap_id) + end + end + + defp get_context_id(%{data: %{"context_id" => context_id}}) when not is_nil(context_id), + do: context_id + + defp get_context_id(%{data: %{"context" => context}}) when is_binary(context), + do: Utils.context_to_conversation_id(context) + + defp get_context_id(_), do: nil + + defp reblogged?(activity, user) do - object = activity.data["object"] || %{} - present?(user && user.ap_id in (object["announcements"] || [])) ++ object = Object.normalize(activity) || %{} ++ present?(user && user.ap_id in (object.data["announcements"] || [])) + end + def render("index.json", opts) do replied_to_activities = get_replied_to_activities(opts.activities) - render_many( - opts.activities, + opts.activities + |> safe_render_many( StatusView, "status.json", Map.put(opts, :replied_to_activities, replied_to_activities) ) - |> Enum.filter(fn x -> not is_nil(x) end) end def render( "status.json", %{activity: %{data: %{"type" => "Announce", "object" => object}} = activity} = opts ) do - user = User.get_cached_by_ap_id(activity.data["actor"]) + user = get_user(activity.data["actor"]) created_at = Utils.to_masto_date(activity.data["published"]) - reblogged = Activity.get_create_activity_by_object_ap_id(object) - reblogged = render("status.json", Map.put(opts, :activity, reblogged)) + reblogged_activity = Activity.get_create_by_object_ap_id(object) + reblogged = render("status.json", Map.put(opts, :activity, reblogged_activity)) mentions = activity.recipients @@@ -68,34 -99,37 +102,39 @@@ reblogs_count: 0, replies_count: 0, favourites_count: 0, - reblogged: false, + reblogged: reblogged?(reblogged_activity, opts[:for]), favourited: false, + bookmarked: false, muted: false, + pinned: pinned?(activity, user), sensitive: false, spoiler_text: "", visibility: "public", - media_attachments: [], + media_attachments: reblogged[:media_attachments] || [], mentions: mentions, - tags: [], + tags: reblogged[:tags] || [], application: %{ name: "Web", website: nil }, language: nil, - emojis: [] + emojis: [], + pleroma: %{ + local: activity.local + } } end def render("status.json", %{activity: %{data: %{"object" => object}} = activity} = opts) do + object = Object.normalize(object) + - user = User.get_cached_by_ap_id(activity.data["actor"]) + user = get_user(activity.data["actor"]) - like_count = object["like_count"] || 0 - announcement_count = object["announcement_count"] || 0 + like_count = object.data["like_count"] || 0 + announcement_count = object.data["announcement_count"] || 0 - tags = object["tag"] || [] - sensitive = object["sensitive"] || Enum.member?(tags, "nsfw") + tags = object.data["tag"] || [] + sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw") mentions = activity.recipients @@@ -103,63 -137,99 +142,101 @@@ |> Enum.filter(& &1) |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end) - repeated = opts[:for] && opts[:for].ap_id in (object.data["announcements"] || []) - favorited = opts[:for] && opts[:for].ap_id in (object["likes"] || []) - bookmarked = opts[:for] && object["id"] in opts[:for].bookmarks + favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || []) + ++ bookmarked = opts[:for] && object.data["id"] in opts[:for].bookmarks + - attachment_data = object["attachment"] || [] + attachment_data = object.data["attachment"] || [] - attachment_data = attachment_data ++ if object.data["type"] == "Video", do: [object], else: [] attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment) - created_at = Utils.to_masto_date(object["published"]) + created_at = Utils.to_masto_date(object.data["published"]) reply_to = get_reply_to(activity, opts) - reply_to_user = reply_to && User.get_cached_by_ap_id(reply_to.data["actor"]) + - emojis = - (object.data["emoji"] || []) - |> Enum.map(fn {name, url} -> - name = HTML.strip_tags(name) - - url = - HTML.strip_tags(url) - |> MediaProxy.url() - - %{shortcode: name, url: url, static_url: url, visible_in_picker: false} - end) + reply_to_user = reply_to && get_user(reply_to.data["actor"]) content = - render_content(object.data) - |> HTML.filter_tags(User.html_filter_policy(opts[:for])) + object + |> render_content() + + content_html = + content + |> HTML.get_cached_scrubbed_html_for_activity( + User.html_filter_policy(opts[:for]), + activity, + "mastoapi:content" + ) + + content_plaintext = + content + |> HTML.get_cached_stripped_html_for_activity( + activity, + "mastoapi:content" + ) + - summary = object["summary"] || "" ++ summary = object.data["summary"] || "" + + summary_html = + summary + |> HTML.get_cached_scrubbed_html_for_activity( + User.html_filter_policy(opts[:for]), + activity, + "mastoapi:summary" + ) + + summary_plaintext = + summary + |> HTML.get_cached_stripped_html_for_activity( + activity, + "mastoapi:summary" + ) + + card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity)) + + url = + if user.local do + Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity) + else - object["external_url"] || object["id"] ++ object.data["external_url"] || object.data["id"] + end %{ id: to_string(activity.id), - uri: object["id"], + uri: object.data["id"], - url: object.data["external_url"] || object.data["id"], + url: url, account: AccountView.render("account.json", %{user: user}), in_reply_to_id: reply_to && to_string(reply_to.id), in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id), reblog: nil, - content: content, + card: card, + content: content_html, created_at: created_at, reblogs_count: announcement_count, - replies_count: 0, - replies_count: object["repliesCount"] || 0, ++ replies_count: object.data["repliesCount"] || 0, favourites_count: like_count, - reblogged: !!repeated, - favourited: !!favorited, - muted: false, + reblogged: reblogged?(activity, opts[:for]), + favourited: present?(favorited), + bookmarked: present?(bookmarked), + muted: CommonAPI.thread_muted?(user, activity) || User.mutes?(opts[:for], user), + pinned: pinned?(activity, user), sensitive: sensitive, - spoiler_text: object.data["summary"] || "", - visibility: get_visibility(object.data), - media_attachments: attachments |> Enum.take(4), + spoiler_text: summary_html, + visibility: get_visibility(object), + media_attachments: attachments, mentions: mentions, - # fix, - tags: [], + tags: build_tags(tags), application: %{ name: "Web", website: nil }, language: nil, - emojis: emojis - emojis: build_emojis(activity.data["object"]["emoji"]), ++ emojis: build_emojis(object.data["emoji"]), + pleroma: %{ + local: activity.local, + conversation_id: get_context_id(activity), + content: %{"text/plain" => content_plaintext}, + spoiler_text: %{"text/plain" => summary_plaintext} + } } end @@@ -167,6 -237,46 +244,46 @@@ nil end + def render("card.json", %{rich_media: rich_media, page_url: page_url}) do + page_url_data = URI.parse(page_url) + + page_url_data = + if rich_media[:url] != nil do + URI.merge(page_url_data, URI.parse(rich_media[:url])) + else + page_url_data + end + + page_url = page_url_data |> to_string + + image_url = + if rich_media[:image] != nil do + URI.merge(page_url_data, URI.parse(rich_media[:image])) + |> to_string + else + nil + end + + site_name = rich_media[:site_name] || page_url_data.host + + %{ + type: "link", + provider_name: site_name, + provider_url: page_url_data.scheme <> "://" <> page_url_data.host, + url: page_url, + image: image_url |> MediaProxy.url(), + title: rich_media[:title], + description: rich_media[:description], + pleroma: %{ + opengraph: rich_media + } + } + end + + def render("card.json", _) do + nil + end + def render("attachment.json", %{attachment: attachment}) do [attachment_url | _] = attachment["url"] media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image" @@@ -189,20 -299,21 +306,25 @@@ preview_url: href, text_url: href, type: type, - description: attachment["name"] + description: attachment["name"], + pleroma: %{mime_type: media_type} } end def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do - with nil <- replied_to_activities[activity.data["object"]["inReplyTo"]] do + object = Object.normalize(activity.data["object"]) - replied_to_activities[object.data["inReplyTo"]] ++ ++ with nil <- replied_to_activities[object.data["inReplyTo"]] do + # If user didn't participate in the thread + Activity.get_in_reply_to_activity(activity) + end end def get_reply_to(%{data: %{"object" => object}}, _) do - if object["inReplyTo"] && object["inReplyTo"] != "" do - Activity.get_create_by_object_ap_id(object["inReplyTo"]) + object = Object.normalize(object) + + if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do - Activity.get_create_activity_by_object_ap_id(object.data["inReplyTo"]) ++ Activity.get_create_by_object_ap_id(object.data["inReplyTo"]) else nil end @@@ -210,8 -321,8 +332,8 @@@ def get_visibility(object) do public = "https://www.w3.org/ns/activitystreams#Public" -- to = object["to"] || [] -- cc = object["cc"] || [] ++ to = object.data["to"] || [] ++ cc = object.data["cc"] || [] cond do public in to -> @@@ -224,36 -335,89 +346,89 @@@ Enum.any?(to, &String.contains?(&1, "/followers")) -> "private" + length(cc) > 0 -> + "private" + true -> "direct" end end -- def render_content(%{"type" => "Video"} = object) do - name = object["name"] - with name when not is_nil(name) and name != "" <- object["name"] do - "

#{name}

#{object["content"]}" ++ def render_content(%{data: %{"type" => "Video"}} = object) do ++ with name when not is_nil(name) and name != "" <- object.data["name"] do ++ "

#{name}

#{object.data["content"]}" + else - _ -> object["content"] || "" ++ _ -> object.data["content"] || "" + end + end - content = - if !!name and name != "" do - "

#{name}

#{object["content"]}" - else - object["content"] || "" - end - def render_content(%{"type" => object_type} = object) ++ def render_content(%{data: %{"type" => object_type}} = object) + when object_type in ["Article", "Page"] do - with summary when not is_nil(summary) and summary != "" <- object["name"], - url when is_bitstring(url) <- object["url"] do - "

#{summary}

#{object["content"]}" ++ with summary when not is_nil(summary) and summary != "" <- object.data["name"], ++ url when is_bitstring(url) <- object.data["url"] do ++ "

#{summary}

#{object.data["content"]}" + else - _ -> object["content"] || "" ++ _ -> object.data["content"] || "" + end + end - content - def render_content(object), do: object["content"] || "" ++ def render_content(object), do: object.data["content"] || "" + + @doc """ + Builds a dictionary tags. + + ## Examples + + iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"]) + [{"name": "fediverse", "url": "/tag/fediverse"}, + {"name": "nextcloud", "url": "/tag/nextcloud"}] + + """ + @spec build_tags(list(any())) :: list(map()) + def build_tags(object_tags) when is_list(object_tags) do + object_tags = for tag when is_binary(tag) <- object_tags, do: tag + + Enum.reduce(object_tags, [], fn tag, tags -> + tags ++ [%{name: tag, url: "/tag/#{tag}"}] + end) end - def render_content(%{"type" => object_type} = object) when object_type in ["Article", "Page"] do - summary = object["name"] + def build_tags(_), do: [] - content = - if !!summary and summary != "" and is_bitstring(object["url"]) do - "

#{summary}

#{object["content"]}" - else - object["content"] || "" - end + @doc """ + Builds list emojis. - content + Arguments: `nil` or list tuple of name and url. + + Returns list emojis. + + ## Examples + + iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}]) + [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}] + + """ + @spec build_emojis(nil | list(tuple())) :: list(map()) + def build_emojis(nil), do: [] + + def build_emojis(emojis) do + emojis + |> Enum.map(fn {name, url} -> + name = HTML.strip_tags(name) + + url = + url + |> HTML.strip_tags() + |> MediaProxy.url() + + %{shortcode: name, url: url, static_url: url, visible_in_picker: false} + end) end - def render_content(object), do: object["content"] || "" + defp present?(nil), do: false + defp present?(false), do: false + defp present?(_), do: true + + defp pinned?(%Activity{id: id}, %User{info: %{pinned_activities: pinned_activities}}), + do: id in pinned_activities end diff --combined lib/pleroma/web/ostatus/activity_representer.ex index fefd9459a,1a1b74bb0..b11a2b5ce --- a/lib/pleroma/web/ostatus/activity_representer.ex +++ b/lib/pleroma/web/ostatus/activity_representer.ex @@@ -1,6 -1,13 +1,13 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2019 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.OStatus.ActivityRepresenter do - alias Pleroma.{Activity, User, Object} + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.User alias Pleroma.Web.OStatus.UserRepresenter + require Logger defp get_href(id) do @@@ -47,16 -54,23 +54,16 @@@ end) end - defp get_links(%{local: true, data: data}) do + defp get_links(%{local: true}, %{"id" => object_id}) do h = fn str -> [to_charlist(str)] end [ - {:link, [type: ['application/atom+xml'], href: h.(data["object"]["id"]), rel: 'self'], []}, - {:link, [type: ['text/html'], href: h.(data["object"]["id"]), rel: 'alternate'], []} + {:link, [type: ['application/atom+xml'], href: h.(object_id), rel: 'self'], []}, + {:link, [type: ['text/html'], href: h.(object_id), rel: 'alternate'], []} ] end - defp get_links(%{ - local: false, - data: %{ - "object" => %{ - "external_url" => external_url - } - } - }) do + defp get_links(%{local: false}, %{"external_url" => external_url}) do h = fn str -> [to_charlist(str)] end [ @@@ -64,7 -78,7 +71,7 @@@ ] end - defp get_links(_activity), do: [] + defp get_links(_activity, _object_data), do: [] defp get_emoji_links(emojis) do Enum.map(emojis, fn {emoji, file} -> @@@ -74,16 -88,14 +81,16 @@@ def to_simple_form(activity, user, with_author \\ false) - def to_simple_form(%{data: %{"object" => %{"type" => "Note"}}} = activity, user, with_author) do + def to_simple_form(%{data: %{"type" => "Create"}} = activity, user, with_author) do h = fn str -> [to_charlist(str)] end - updated_at = activity.data["object"]["published"] - inserted_at = activity.data["object"]["published"] + object = Object.normalize(activity.data["object"]) + + updated_at = object.data["published"] + inserted_at = object.data["published"] attachments = - Enum.map(activity.data["object"]["attachment"] || [], fn attachment -> + Enum.map(object.data["attachment"] || [], fn attachment -> url = hd(attachment["url"]) {:link, @@@ -96,7 -108,7 +103,7 @@@ mentions = activity.recipients |> get_mentions categories = - (activity.data["object"]["tag"] || []) + (object.data["tag"] || []) |> Enum.map(fn tag -> if is_binary(tag) do {:category, [term: to_charlist(tag)], []} @@@ -106,11 -118,11 +113,11 @@@ end) |> Enum.filter(& &1) - emoji_links = get_emoji_links(activity.data["object"]["emoji"] || %{}) + emoji_links = get_emoji_links(object.data["emoji"] || %{}) summary = - if activity.data["object"]["summary"] do - [{:summary, [], h.(activity.data["object"]["summary"])}] + if object.data["summary"] do + [{:summary, [], h.(object.data["summary"])}] else [] end @@@ -119,9 -131,10 +126,9 @@@ {:"activity:object-type", ['http://activitystrea.ms/schema/1.0/note']}, {:"activity:verb", ['http://activitystrea.ms/schema/1.0/post']}, # For notes, federate the object id. - {:id, h.(activity.data["object"]["id"])}, + {:id, h.(object.data["id"])}, {:title, ['New note by #{user.nickname}']}, - {:content, [type: 'html'], - h.(activity.data["object"]["content"] |> String.replace(~r/[\n\r]/, ""))}, + {:content, [type: 'html'], h.(object.data["content"] |> String.replace(~r/[\n\r]/, ""))}, {:published, h.(inserted_at)}, {:updated, h.(updated_at)}, {:"ostatus:conversation", [ref: h.(activity.data["context"])], @@@ -129,7 -142,7 +136,7 @@@ {:link, [ref: h.(activity.data["context"]), rel: 'ostatus:conversation'], []} ] ++ summary ++ - get_links(activity) ++ + get_links(activity, object.data) ++ categories ++ attachments ++ in_reply_to ++ author ++ mentions ++ emoji_links end @@@ -173,7 -186,7 +180,7 @@@ _in_reply_to = get_in_reply_to(activity.data) author = if with_author, do: [{:author, UserRepresenter.to_simple_form(user)}], else: [] - retweeted_activity = Activity.get_create_activity_by_object_ap_id(activity.data["object"]) + retweeted_activity = Activity.get_create_by_object_ap_id(activity.data["object"]) retweeted_user = User.get_cached_by_ap_id(retweeted_activity.data["actor"]) retweeted_xml = to_simple_form(retweeted_activity, retweeted_user, true) diff --combined lib/pleroma/web/ostatus/handlers/note_handler.ex index ba232b0ec,db995ec77..ec6e5cfaf --- a/lib/pleroma/web/ostatus/handlers/note_handler.ex +++ b/lib/pleroma/web/ostatus/handlers/note_handler.ex @@@ -1,10 -1,17 +1,17 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2019 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.OStatus.NoteHandler do require Logger - alias Pleroma.Web.{XML, OStatus} - alias Pleroma.{Object, Activity} + + alias Pleroma.Activity + alias Pleroma.Object alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI + alias Pleroma.Web.OStatus + alias Pleroma.Web.XML @doc """ Get the context for this note. Uses this: @@@ -12,13 -19,13 +19,13 @@@ 2. The conversation reference in the ostatus xml 3. A newly generated context id. """ - def get_context(entry, inReplyTo) do + def get_context(entry, in_reply_to) do context = (XML.string_from_xpath("//ostatus:conversation[1]", entry) || XML.string_from_xpath("//ostatus:conversation[1]/@ref", entry) || "") |> String.trim() - with %{data: %{"context" => context}} <- Object.get_cached_by_ap_id(inReplyTo) do + with %{data: %{"context" => context}} <- Object.get_cached_by_ap_id(in_reply_to) do context else _e -> @@@ -81,14 -88,14 +88,14 @@@ Map.put(note, "external_url", url) end - def fetch_replied_to_activity(entry, inReplyTo) do - with %Activity{} = activity <- Activity.get_create_activity_by_object_ap_id(inReplyTo) do + def fetch_replied_to_activity(entry, in_reply_to) do + with %Activity{} = activity <- Activity.get_create_by_object_ap_id(in_reply_to) do activity else _e -> - with inReplyToHref when not is_nil(inReplyToHref) <- + with in_reply_to_href when not is_nil(in_reply_to_href) <- XML.string_from_xpath("//thr:in-reply-to[1]/@href", entry), - {:ok, [activity | _]} <- OStatus.fetch_activity_from_url(inReplyToHref) do + {:ok, [activity | _]} <- OStatus.fetch_activity_from_url(in_reply_to_href) do activity else _e -> nil @@@ -99,18 -106,17 +106,18 @@@ # TODO: Clean this up a bit. def handle_note(entry, doc \\ nil) do with id <- XML.string_from_xpath("//id", entry), - activity when is_nil(activity) <- Activity.get_create_activity_by_object_ap_id(id), + activity when is_nil(activity) <- Activity.get_create_by_object_ap_id_with_object(id), [author] <- :xmerl_xpath.string('//author[1]', doc), {:ok, actor} <- OStatus.find_make_or_update_user(author), content_html <- OStatus.get_content(entry), cw <- OStatus.get_cw(entry), - inReplyTo <- XML.string_from_xpath("//thr:in-reply-to[1]/@ref", entry), - inReplyToActivity <- fetch_replied_to_activity(entry, inReplyTo), - inReplyToObject <- - (inReplyToActivity && Object.normalize(inReplyToActivity.data["object"])) || nil, - inReplyTo <- (inReplyToObject && inReplyToObject.data["id"]) || inReplyTo, + in_reply_to <- XML.string_from_xpath("//thr:in-reply-to[1]/@ref", entry), + in_reply_to_activity <- fetch_replied_to_activity(entry, in_reply_to), - in_reply_to <- - (in_reply_to_activity && in_reply_to_activity.data["object"]["id"]) || in_reply_to, ++ in_reply_to_object <- ++ (in_reply_to_activity && Object.normalize(in_reply_to_activity)) || nil, ++ in_reply_to <- (in_reply_to_object && in_reply_to_object.data["id"]) || in_reply_to, attachments <- OStatus.get_attachments(entry), - context <- get_context(entry, inReplyTo), + context <- get_context(entry, in_reply_to), tags <- OStatus.get_tags(entry), mentions <- get_mentions(entry), to <- make_to_list(actor, mentions), @@@ -124,7 -130,7 +131,7 @@@ context, content_html, attachments, - inReplyToActivity, + in_reply_to_activity, [], cw ), @@@ -136,8 -142,8 +143,8 @@@ # TODO: Handle this case in make_note_data note <- if( - inReplyTo && !inReplyToActivity, - do: note |> Map.put("inReplyTo", inReplyTo), + in_reply_to && !in_reply_to_activity, + do: note |> Map.put("inReplyTo", in_reply_to), else: note ) do ActivityPub.create(%{ diff --combined lib/pleroma/web/twitter_api/controllers/util_controller.ex index b0ed8387e,ed45ca735..9441984c7 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@@ -1,18 -1,28 +1,28 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2019 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.TwitterAPI.UtilController do use Pleroma.Web, :controller + require Logger + + alias Comeonin.Pbkdf2 + alias Pleroma.Activity + alias Pleroma.Emoji + alias Pleroma.Notification + alias Pleroma.PasswordResetToken + alias Pleroma.Repo + alias Pleroma.User alias Pleroma.Web + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI alias Pleroma.Web.OStatus alias Pleroma.Web.WebFinger - alias Pleroma.Web.CommonAPI - alias Comeonin.Pbkdf2 - alias Pleroma.{Formatter, Emoji} - alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.{Repo, PasswordResetToken, User} def show_password_reset(conn, %{"token" => token}) do with %{used: false} = token <- Repo.get_by(PasswordResetToken, %{token: token}), - %User{} = user <- Repo.get(User, token.user_id) do + %User{} = user <- User.get_by_id(token.user_id) do render(conn, "password_reset.html", %{ token: token, user: user @@@ -64,36 -74,52 +74,52 @@@ end def remote_follow(%{assigns: %{user: user}} = conn, %{"acct" => acct}) do - {err, followee} = OStatus.find_or_make_user(acct) - avatar = User.avatar_url(followee) - name = followee.nickname - id = followee.id - - if !!user do - conn - |> render("follow.html", %{error: err, acct: acct, avatar: avatar, name: name, id: id}) + if is_status?(acct) do - {:ok, object} = ActivityPub.fetch_object_from_id(acct) ++ {:ok, object} = Pleroma.Object.Fetcher.fetch_object_from_id(acct) + %Activity{id: activity_id} = Activity.get_create_by_object_ap_id(object.data["id"]) + redirect(conn, to: "/notice/#{activity_id}") else - conn - |> render("follow_login.html", %{ - error: false, - acct: acct, - avatar: avatar, - name: name, - id: id - }) + {err, followee} = OStatus.find_or_make_user(acct) + avatar = User.avatar_url(followee) + name = followee.nickname + id = followee.id + + if !!user do + conn + |> render("follow.html", %{error: err, acct: acct, avatar: avatar, name: name, id: id}) + else + conn + |> render("follow_login.html", %{ + error: false, + acct: acct, + avatar: avatar, + name: name, + id: id + }) + end + end + end + + defp is_status?(acct) do - case ActivityPub.fetch_and_contain_remote_object_from_id(acct) do ++ case Pleroma.Object.Fetcher.fetch_and_contain_remote_object_from_id(acct) do + {:ok, %{"type" => type}} when type in ["Article", "Note", "Video", "Page", "Question"] -> + true + + _ -> + false end end def do_remote_follow(conn, %{ "authorization" => %{"name" => username, "password" => password, "id" => id} }) do - followee = Repo.get(User, id) + followee = User.get_by_id(id) avatar = User.avatar_url(followee) name = followee.nickname with %User{} = user <- User.get_cached_by_nickname(username), true <- Pbkdf2.checkpw(password, user.password_hash), - %User{} = _followed <- Repo.get(User, id), + %User{} = _followed <- User.get_by_id(id), {:ok, follower} <- User.follow(user, followee), {:ok, _activity} <- ActivityPub.follow(follower, followee) do conn @@@ -115,7 -141,7 +141,7 @@@ end def do_remote_follow(%{assigns: %{user: user}} = conn, %{"user" => %{"id" => id}}) do - with %User{} = followee <- Repo.get(User, id), + with %User{} = followee <- User.get_by_id(id), {:ok, follower} <- User.follow(user, followee), {:ok, _activity} <- ActivityPub.follow(follower, followee) do conn @@@ -134,6 -160,17 +160,17 @@@ end end + def notifications_read(%{assigns: %{user: user}} = conn, %{"id" => notification_id}) do + with {:ok, _} <- Notification.read_one(user, notification_id) do + json(conn, %{status: "success"}) + else + {:error, message} -> + conn + |> put_resp_content_type("application/json") + |> send_resp(403, Jason.encode!(%{"error" => message})) + end + end + def config(conn, _params) do instance = Pleroma.Config.get(:instance) instance_fe = Pleroma.Config.get(:fe) @@@ -157,31 -194,56 +194,56 @@@ |> send_resp(200, response) _ -> + vapid_public_key = Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) + + uploadlimit = %{ + uploadlimit: to_string(Keyword.get(instance, :upload_limit)), + avatarlimit: to_string(Keyword.get(instance, :avatar_upload_limit)), + backgroundlimit: to_string(Keyword.get(instance, :background_upload_limit)), + bannerlimit: to_string(Keyword.get(instance, :banner_upload_limit)) + } + data = %{ name: Keyword.get(instance, :name), description: Keyword.get(instance, :description), server: Web.base_url(), textlimit: to_string(Keyword.get(instance, :limit)), + uploadlimit: uploadlimit, closed: if(Keyword.get(instance, :registrations_open), do: "0", else: "1"), - private: if(Keyword.get(instance, :public, true), do: "0", else: "1") + private: if(Keyword.get(instance, :public, true), do: "0", else: "1"), + vapidPublicKey: vapid_public_key, + accountActivationRequired: + if(Keyword.get(instance, :account_activation_required, false), do: "1", else: "0"), + invitesEnabled: if(Keyword.get(instance, :invites_enabled, false), do: "1", else: "0"), + safeDMMentionsEnabled: + if(Pleroma.Config.get([:instance, :safe_dm_mentions]), do: "1", else: "0") } - pleroma_fe = %{ - theme: Keyword.get(instance_fe, :theme), - background: Keyword.get(instance_fe, :background), - logo: Keyword.get(instance_fe, :logo), - logoMask: Keyword.get(instance_fe, :logo_mask), - logoMargin: Keyword.get(instance_fe, :logo_margin), - redirectRootNoLogin: Keyword.get(instance_fe, :redirect_root_no_login), - redirectRootLogin: Keyword.get(instance_fe, :redirect_root_login), - chatDisabled: !Keyword.get(instance_chat, :enabled), - showInstanceSpecificPanel: Keyword.get(instance_fe, :show_instance_panel), - scopeOptionsEnabled: Keyword.get(instance_fe, :scope_options_enabled), - formattingOptionsEnabled: Keyword.get(instance_fe, :formatting_options_enabled), - collapseMessageWithSubject: Keyword.get(instance_fe, :collapse_message_with_subject), - hidePostStats: Keyword.get(instance_fe, :hide_post_stats), - hideUserStats: Keyword.get(instance_fe, :hide_user_stats) - } + pleroma_fe = + if instance_fe do + %{ + theme: Keyword.get(instance_fe, :theme), + background: Keyword.get(instance_fe, :background), + logo: Keyword.get(instance_fe, :logo), + logoMask: Keyword.get(instance_fe, :logo_mask), + logoMargin: Keyword.get(instance_fe, :logo_margin), + redirectRootNoLogin: Keyword.get(instance_fe, :redirect_root_no_login), + redirectRootLogin: Keyword.get(instance_fe, :redirect_root_login), + chatDisabled: !Keyword.get(instance_chat, :enabled), + showInstanceSpecificPanel: Keyword.get(instance_fe, :show_instance_panel), + scopeOptionsEnabled: Keyword.get(instance_fe, :scope_options_enabled), + formattingOptionsEnabled: Keyword.get(instance_fe, :formatting_options_enabled), + collapseMessageWithSubject: + Keyword.get(instance_fe, :collapse_message_with_subject), + hidePostStats: Keyword.get(instance_fe, :hide_post_stats), + hideUserStats: Keyword.get(instance_fe, :hide_user_stats), + scopeCopy: Keyword.get(instance_fe, :scope_copy), + subjectLineBehavior: Keyword.get(instance_fe, :subject_line_behavior), + alwaysShowSubjectInput: Keyword.get(instance_fe, :always_show_subject_input) + } + else + Pleroma.Config.get([:frontend_configurations, :pleroma_fe]) + end managed_config = Keyword.get(instance, :managed_config) @@@ -196,6 -258,14 +258,14 @@@ end end + def frontend_configurations(conn, _params) do + config = + Pleroma.Config.get(:frontend_configurations, %{}) + |> Enum.into(%{}) + + json(conn, config) + end + def version(conn, _params) do version = Pleroma.Application.named_version() @@@ -213,28 -283,47 +283,47 @@@ end def emoji(conn, _params) do - json(conn, Enum.into(Emoji.get_all(), %{})) + emoji = + Emoji.get_all() + |> Enum.map(fn {short_code, path, tags} -> + {short_code, %{image_url: path, tags: String.split(tags, ",")}} + end) + |> Enum.into(%{}) + + json(conn, emoji) + end + + def update_notificaton_settings(%{assigns: %{user: user}} = conn, params) do + with {:ok, _} <- User.update_notification_settings(user, params) do + json(conn, %{status: "success"}) + end end def follow_import(conn, %{"list" => %Plug.Upload{} = listfile}) do follow_import(conn, %{"list" => File.read!(listfile.path)}) end - def follow_import(%{assigns: %{user: user}} = conn, %{"list" => list}) do - Task.start(fn -> - String.split(list) - |> Enum.map(fn account -> - with %User{} = follower <- User.get_cached_by_ap_id(user.ap_id), - %User{} = followed <- User.get_or_fetch(account), - {:ok, follower} <- User.maybe_direct_follow(follower, followed) do - ActivityPub.follow(follower, followed) - else - err -> Logger.debug("follow_import: following #{account} failed with #{inspect(err)}") - end - end) - end) + def follow_import(%{assigns: %{user: follower}} = conn, %{"list" => list}) do + with lines <- String.split(list, "\n"), + followed_identifiers <- + Enum.map(lines, fn line -> + String.split(line, ",") |> List.first() + end) + |> List.delete("Account address"), + {:ok, _} = Task.start(fn -> User.follow_import(follower, followed_identifiers) end) do + json(conn, "job started") + end + end + + def blocks_import(conn, %{"list" => %Plug.Upload{} = listfile}) do + blocks_import(conn, %{"list" => File.read!(listfile.path)}) + end - json(conn, "job started") + def blocks_import(%{assigns: %{user: blocker}} = conn, %{"list" => list}) do + with blocked_identifiers <- String.split(list), + {:ok, _} = Task.start(fn -> User.blocks_import(blocker, blocked_identifiers) end) do + json(conn, "job started") + end end def change_password(%{assigns: %{user: user}} = conn, params) do @@@ -270,4 -359,8 +359,8 @@@ json(conn, %{error: msg}) end end + + def captcha(conn, _params) do + json(conn, Pleroma.Captcha.new()) + end end diff --combined lib/pleroma/web/twitter_api/views/activity_view.ex index 18b2ebb0b,ecb2b437b..c64152da8 --- a/lib/pleroma/web/twitter_api/views/activity_view.ex +++ b/lib/pleroma/web/twitter_api/views/activity_view.ex @@@ -1,19 -1,24 +1,24 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2019 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.TwitterAPI.ActivityView do use Pleroma.Web, :view - alias Pleroma.Web.CommonAPI.Utils - alias Pleroma.User - alias Pleroma.Web.TwitterAPI.UserView - alias Pleroma.Web.TwitterAPI.ActivityView - alias Pleroma.Web.TwitterAPI.TwitterAPI - alias Pleroma.Web.TwitterAPI.Representers.ObjectRepresenter alias Pleroma.Activity - alias Pleroma.Object - alias Pleroma.User - alias Pleroma.Repo alias Pleroma.Formatter alias Pleroma.HTML + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.TwitterAPI.ActivityView + alias Pleroma.Web.TwitterAPI.Representers.ObjectRepresenter + alias Pleroma.Web.TwitterAPI.UserView import Ecto.Query + require Logger defp query_context_ids([]), do: [] @@@ -72,7 -77,7 +77,7 @@@ defp get_context_id(%{data: %{"context" => context}}, options) do cond do id = options[:context_ids][context] -> id - true -> TwitterAPI.context_to_conversation_id(context) + true -> Utils.context_to_conversation_id(context) end end @@@ -89,8 -94,14 +94,14 @@@ ap_id == "https://www.w3.org/ns/activitystreams#Public" -> nil + user = User.get_cached_by_ap_id(ap_id) -> + user + + user = User.get_by_guessed_nickname(ap_id) -> + user + true -> - User.get_cached_by_ap_id(ap_id) + User.error_user(ap_id) end end @@@ -103,7 -114,7 +114,7 @@@ |> Map.put(:context_ids, context_ids) |> Map.put(:users, users) - render_many( + safe_render_many( opts.activities, ActivityView, "activity.json", @@@ -157,7 -168,7 +168,7 @@@ def render("activity.json", %{activity: %{data: %{"type" => "Announce"}} = activity} = opts) do user = get_user(activity.data["actor"], opts) created_at = activity.data["published"] |> Utils.date_to_asctime() - announced_activity = Activity.get_create_activity_by_object_ap_id(activity.data["object"]) + announced_activity = Activity.get_create_by_object_ap_id(activity.data["object"]) text = "#{user.nickname} retweeted a status." @@@ -181,7 -192,7 +192,7 @@@ def render("activity.json", %{activity: %{data: %{"type" => "Like"}} = activity} = opts) do user = get_user(activity.data["actor"], opts) - liked_activity = Activity.get_create_activity_by_object_ap_id(activity.data["object"]) + liked_activity = Activity.get_create_by_object_ap_id(activity.data["object"]) liked_activity_id = if liked_activity, do: liked_activity.id, else: nil created_at = @@@ -190,6 -201,11 +201,11 @@@ text = "#{user.nickname} favorited a status." + favorited_status = + if liked_activity, + do: render("activity.json", Map.merge(opts, %{activity: liked_activity})), + else: nil + %{ "id" => activity.id, "user" => UserView.render("show.json", %{user: user, for: opts[:for]}), @@@ -199,6 -215,7 +215,7 @@@ "is_post_verb" => false, "uri" => "tag:#{activity.data["id"]}:objectType=Favourite", "created_at" => created_at, + "favorited_status" => favorited_status, "in_reply_to_status_id" => liked_activity_id, "external_url" => activity.data["id"], "activity_type" => "like" @@@ -207,71 -224,103 +224,105 @@@ def render( "activity.json", - %{activity: %{data: %{"type" => "Create", "object" => object}} = activity} = opts + %{activity: %{data: %{"type" => "Create", "object" => object_id}} = activity} = opts ) do user = get_user(activity.data["actor"], opts) - created_at = object["published"] |> Utils.date_to_asctime() - like_count = object["like_count"] || 0 - announcement_count = object["announcement_count"] || 0 - favorited = opts[:for] && opts[:for].ap_id in (object["likes"] || []) - repeated = opts[:for] && opts[:for].ap_id in (object["announcements"] || []) + object = Object.normalize(object_id) + + created_at = object.data["published"] |> Utils.date_to_asctime() + like_count = object.data["like_count"] || 0 + announcement_count = object.data["announcement_count"] || 0 + favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || []) + repeated = opts[:for] && opts[:for].ap_id in (object.data["announcements"] || []) + pinned = activity.id in user.info.pinned_activities attentions = - activity.recipients + [] + |> Utils.maybe_notify_to_recipients(activity) + |> Utils.maybe_notify_mentioned_recipients(activity) |> Enum.map(fn ap_id -> get_user(ap_id, opts) end) |> Enum.filter(& &1) |> Enum.map(fn user -> UserView.render("show.json", %{user: user, for: opts[:for]}) end) conversation_id = get_context_id(activity, opts) - tags = activity.data["object"]["tag"] || [] - possibly_sensitive = activity.data["object"]["sensitive"] || Enum.member?(tags, "nsfw") + tags = object.data["tag"] || [] + possibly_sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw") tags = if possibly_sensitive, do: Enum.uniq(["nsfw" | tags]), else: tags - {summary, content} = render_content(object) + {summary, content} = render_content(object.data) html = - HTML.filter_tags(content, User.html_filter_policy(opts[:for])) + content + |> HTML.get_cached_scrubbed_html_for_activity( + User.html_filter_policy(opts[:for]), + activity, + "twitterapi:content" + ) - |> Formatter.emojify(object["emoji"]) + |> Formatter.emojify(object.data["emoji"]) + text = + if content do + content + |> String.replace(~r//, "\n") + |> HTML.get_cached_stripped_html_for_activity(activity, "twitterapi:content") + else + "" + end + reply_parent = Activity.get_in_reply_to_activity(activity) reply_user = reply_parent && User.get_cached_by_ap_id(reply_parent.actor) + summary = HTML.strip_tags(summary) + + card = + StatusView.render( + "card.json", + Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) + ) + %{ "id" => activity.id, - "uri" => activity.data["object"]["id"], + "uri" => object.data["id"], "user" => UserView.render("show.json", %{user: user, for: opts[:for]}), "statusnet_html" => html, - "text" => HTML.strip_tags(content), + "text" => text, "is_local" => activity.local, "is_post_verb" => true, "created_at" => created_at, - "in_reply_to_status_id" => object.data["inReplyToStatusId"], + "in_reply_to_status_id" => reply_parent && reply_parent.id, "in_reply_to_screen_name" => reply_user && reply_user.nickname, "in_reply_to_profileurl" => User.profile_url(reply_user), "in_reply_to_ostatus_uri" => reply_user && reply_user.ap_id, "in_reply_to_user_id" => reply_user && reply_user.id, "statusnet_conversation_id" => conversation_id, - "attachments" => (object["attachment"] || []) |> ObjectRepresenter.enum_to_list(opts), + "attachments" => (object.data["attachment"] || []) |> ObjectRepresenter.enum_to_list(opts), "attentions" => attentions, "fave_num" => like_count, "repeat_num" => announcement_count, "favorited" => !!favorited, "repeated" => !!repeated, + "pinned" => pinned, - "external_url" => object["external_url"] || object["id"], + "external_url" => object.data["external_url"] || object.data["id"], "tags" => tags, "activity_type" => "post", "possibly_sensitive" => possibly_sensitive, - "visibility" => Pleroma.Web.MastodonAPI.StatusView.get_visibility(object.data), - "summary" => summary + "visibility" => StatusView.get_visibility(object), + "summary" => summary, - "summary_html" => summary |> Formatter.emojify(object["emoji"]), ++ "summary_html" => summary |> Formatter.emojify(object.data["emoji"]), + "card" => card, + "muted" => CommonAPI.thread_muted?(user, activity) || User.mutes?(opts[:for], user) } end + def render("activity.json", %{activity: unhandled_activity}) do + Logger.warn("#{__MODULE__} unhandled activity: #{inspect(unhandled_activity)}") + nil + end + def render_content(%{"type" => "Note"} = object) do summary = object["summary"] @@@ -285,7 -334,8 +336,8 @@@ {summary, content} end - def render_content(%{"type" => object_type} = object) when object_type in ["Article", "Page"] do + def render_content(%{"type" => object_type} = object) + when object_type in ["Article", "Page", "Video"] do summary = object["name"] || object["summary"] content = diff --combined test/object_test.exs index dac6c3be7,911757d57..a30efd48c --- a/test/object_test.exs +++ b/test/object_test.exs @@@ -1,7 -1,12 +1,12 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2018 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.ObjectTest do use Pleroma.DataCase import Pleroma.Factory - alias Pleroma.{Repo, Object} + alias Pleroma.Object + alias Pleroma.Repo test "returns an object by it's AP id" do object = insert(:note) @@@ -32,6 -37,8 +37,8 @@@ found_object = Object.get_by_ap_id(object.data["id"]) refute object == found_object + + assert found_object.data["type"] == "Tombstone" end test "ensures cache is cleared for the object" do @@@ -47,28 -54,8 +54,30 @@@ cached_object = Object.get_cached_by_ap_id(object.data["id"]) refute object == cached_object + + assert cached_object.data["type"] == "Tombstone" end end + + describe "normalizer" do + test "fetches unknown objects by default" do + %Object{} = + object = Object.normalize("http://mastodon.example.org/@admin/99541947525187367") + + assert object.data["url"] == "http://mastodon.example.org/@admin/99541947525187367" + end + + test "fetches unknown objects when fetch_remote is explicitly true" do + %Object{} = + object = Object.normalize("http://mastodon.example.org/@admin/99541947525187367", true) + + assert object.data["url"] == "http://mastodon.example.org/@admin/99541947525187367" + end + + test "does not fetch unknown objects when fetch_remote is false" do + assert is_nil( + Object.normalize("http://mastodon.example.org/@admin/99541947525187367", false) + ) + end + end end diff --combined test/web/activity_pub/activity_pub_test.exs index bc9fcc75d,17fec05b1..68bfb3858 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@@ -1,12 -1,70 +1,70 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2019 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.ActivityPub.ActivityPubTest do use Pleroma.DataCase + alias Pleroma.Activity + alias Pleroma.Builders.ActivityBuilder + alias Pleroma.Instances + alias Pleroma.Object + alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI - alias Pleroma.{Activity, Object, User} - alias Pleroma.Builders.ActivityBuilder import Pleroma.Factory + import Tesla.Mock + import Mock + + setup do + mock(fn env -> apply(HttpRequestMock, :request, [env]) end) + :ok + end + + describe "fetching restricted by visibility" do + test "it restricts by the appropriate visibility" do + user = insert(:user) + + {:ok, public_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "public"}) + + {:ok, direct_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"}) + + {:ok, unlisted_activity} = + CommonAPI.post(user, %{"status" => ".", "visibility" => "unlisted"}) + + {:ok, private_activity} = + CommonAPI.post(user, %{"status" => ".", "visibility" => "private"}) + + activities = + ActivityPub.fetch_activities([], %{:visibility => "direct", "actor_id" => user.ap_id}) + + assert activities == [direct_activity] + + activities = + ActivityPub.fetch_activities([], %{:visibility => "unlisted", "actor_id" => user.ap_id}) + + assert activities == [unlisted_activity] + + activities = + ActivityPub.fetch_activities([], %{:visibility => "private", "actor_id" => user.ap_id}) + + assert activities == [private_activity] + + activities = + ActivityPub.fetch_activities([], %{:visibility => "public", "actor_id" => user.ap_id}) + + assert activities == [public_activity] + + activities = + ActivityPub.fetch_activities([], %{ + :visibility => ~w[private public], + "actor_id" => user.ap_id + }) + + assert activities == [public_activity, private_activity] + end + end describe "building a user from his ap id" do test "it returns a user" do @@@ -18,14 -76,71 +76,71 @@@ assert user.info.ap_enabled assert user.follower_address == "http://mastodon.example.org/users/admin/followers" end + + test "it fetches the appropriate tag-restricted posts" do + user = insert(:user) + + {:ok, status_one} = CommonAPI.post(user, %{"status" => ". #test"}) + {:ok, status_two} = CommonAPI.post(user, %{"status" => ". #essais"}) + {:ok, status_three} = CommonAPI.post(user, %{"status" => ". #test #reject"}) + + fetch_one = ActivityPub.fetch_activities([], %{"tag" => "test"}) + fetch_two = ActivityPub.fetch_activities([], %{"tag" => ["test", "essais"]}) + + fetch_three = + ActivityPub.fetch_activities([], %{ + "tag" => ["test", "essais"], + "tag_reject" => ["reject"] + }) + + fetch_four = + ActivityPub.fetch_activities([], %{ + "tag" => ["test"], + "tag_all" => ["test", "reject"] + }) + + assert fetch_one == [status_one, status_three] + assert fetch_two == [status_one, status_two, status_three] + assert fetch_three == [status_one, status_two] + assert fetch_four == [status_three] + end end describe "insertion" do + test "drops activities beyond a certain limit" do + limit = Pleroma.Config.get([:instance, :remote_limit]) + + random_text = + :crypto.strong_rand_bytes(limit + 1) + |> Base.encode64() + |> binary_part(0, limit + 1) + + data = %{ + "ok" => true, + "object" => %{ + "content" => random_text + } + } + + assert {:error, {:remote_limit_error, _}} = ActivityPub.insert(data) + end + + test "doesn't drop activities with content being null" do + data = %{ + "ok" => true, + "object" => %{ + "content" => nil + } + } + + assert {:ok, _} = ActivityPub.insert(data) + end + test "returns the activity if one with the same id is already in" do activity = insert(:note_activity) {:ok, new_activity} = ActivityPub.insert(activity.data) - assert activity == new_activity + assert activity.id == new_activity.id end test "inserts a given map into the activity database, giving it an id if it has none." do @@@ -77,10 -192,8 +192,10 @@@ } {:ok, %Activity{} = activity} = ActivityPub.insert(data) - assert is_binary(activity.data["object"]["id"]) - assert %Object{} = Object.get_by_ap_id(activity.data["object"]["id"]) + object = Object.normalize(activity.data["object"]) + + assert is_binary(object.data["id"]) + assert %Object{} = Object.get_by_ap_id(activity.data["object"]) end end @@@ -93,16 -206,64 +208,68 @@@ to: ["user1", "user1", "user2"], actor: user, context: "", - object: %{} + object: %{ + "to" => ["user1", "user1", "user2"], + "type" => "Note", + "content" => "testing" + } }) assert activity.data["to"] == ["user1", "user2"] assert activity.actor == user.ap_id - assert activity.recipients == ["user1", "user2"] + assert activity.recipients == ["user1", "user2", user.ap_id] + end + + test "increases user note count only for public activities" do + user = insert(:user) + + {:ok, _} = + CommonAPI.post(User.get_by_id(user.id), %{"status" => "1", "visibility" => "public"}) + + {:ok, _} = + CommonAPI.post(User.get_by_id(user.id), %{"status" => "2", "visibility" => "unlisted"}) + + {:ok, _} = + CommonAPI.post(User.get_by_id(user.id), %{"status" => "2", "visibility" => "private"}) + + {:ok, _} = + CommonAPI.post(User.get_by_id(user.id), %{"status" => "3", "visibility" => "direct"}) + + user = User.get_by_id(user.id) + assert user.info.note_count == 2 + end + + test "increases replies count" do + user = insert(:user) + user2 = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "1", "visibility" => "public"}) + ap_id = activity.data["id"] + reply_data = %{"status" => "1", "in_reply_to_status_id" => activity.id} + + # public + {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "public")) + assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id) + assert data["object"]["repliesCount"] == 1 + assert object.data["repliesCount"] == 1 + + # unlisted + {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "unlisted")) + assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id) + assert data["object"]["repliesCount"] == 2 + assert object.data["repliesCount"] == 2 + + # private + {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "private")) + assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id) + assert data["object"]["repliesCount"] == 2 + assert object.data["repliesCount"] == 2 + + # direct + {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "direct")) + assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id) + assert data["object"]["repliesCount"] == 2 + assert object.data["repliesCount"] == 2 end end @@@ -142,7 -303,8 +309,8 @@@ booster = insert(:user) {:ok, user} = User.block(user, %{ap_id: activity_one.data["actor"]}) - activities = ActivityPub.fetch_activities([], %{"blocking_user" => user}) + activities = + ActivityPub.fetch_activities([], %{"blocking_user" => user, "skip_preload" => true}) assert Enum.member?(activities, activity_two) assert Enum.member?(activities, activity_three) @@@ -150,7 -312,8 +318,8 @@@ {:ok, user} = User.unblock(user, %{ap_id: activity_one.data["actor"]}) - activities = ActivityPub.fetch_activities([], %{"blocking_user" => user}) + activities = + ActivityPub.fetch_activities([], %{"blocking_user" => user, "skip_preload" => true}) assert Enum.member?(activities, activity_two) assert Enum.member?(activities, activity_three) @@@ -158,17 -321,76 +327,76 @@@ {:ok, user} = User.block(user, %{ap_id: activity_three.data["actor"]}) {:ok, _announce, %{data: %{"id" => id}}} = CommonAPI.repeat(activity_three.id, booster) - %Activity{} = boost_activity = Activity.get_create_activity_by_object_ap_id(id) - activity_three = Repo.get(Activity, activity_three.id) + %Activity{} = boost_activity = Activity.get_create_by_object_ap_id(id) + activity_three = Activity.get_by_id(activity_three.id) + + activities = + ActivityPub.fetch_activities([], %{"blocking_user" => user, "skip_preload" => true}) + + assert Enum.member?(activities, activity_two) + refute Enum.member?(activities, activity_three) + refute Enum.member?(activities, boost_activity) + assert Enum.member?(activities, activity_one) + + activities = + ActivityPub.fetch_activities([], %{"blocking_user" => nil, "skip_preload" => true}) + + assert Enum.member?(activities, activity_two) + assert Enum.member?(activities, activity_three) + assert Enum.member?(activities, boost_activity) + assert Enum.member?(activities, activity_one) + end + + test "doesn't return muted activities" do + activity_one = insert(:note_activity) + activity_two = insert(:note_activity) + activity_three = insert(:note_activity) + user = insert(:user) + booster = insert(:user) + {:ok, user} = User.mute(user, %User{ap_id: activity_one.data["actor"]}) + + activities = + ActivityPub.fetch_activities([], %{"muting_user" => user, "skip_preload" => true}) + + assert Enum.member?(activities, activity_two) + assert Enum.member?(activities, activity_three) + refute Enum.member?(activities, activity_one) + + # Calling with 'with_muted' will deliver muted activities, too. + activities = + ActivityPub.fetch_activities([], %{ + "muting_user" => user, + "with_muted" => true, + "skip_preload" => true + }) + + assert Enum.member?(activities, activity_two) + assert Enum.member?(activities, activity_three) + assert Enum.member?(activities, activity_one) + + {:ok, user} = User.unmute(user, %User{ap_id: activity_one.data["actor"]}) + + activities = + ActivityPub.fetch_activities([], %{"muting_user" => user, "skip_preload" => true}) + + assert Enum.member?(activities, activity_two) + assert Enum.member?(activities, activity_three) + assert Enum.member?(activities, activity_one) + + {:ok, user} = User.mute(user, %User{ap_id: activity_three.data["actor"]}) + {:ok, _announce, %{data: %{"id" => id}}} = CommonAPI.repeat(activity_three.id, booster) + %Activity{} = boost_activity = Activity.get_create_by_object_ap_id(id) + activity_three = Activity.get_by_id(activity_three.id) - activities = ActivityPub.fetch_activities([], %{"blocking_user" => user}) + activities = + ActivityPub.fetch_activities([], %{"muting_user" => user, "skip_preload" => true}) assert Enum.member?(activities, activity_two) refute Enum.member?(activities, activity_three) refute Enum.member?(activities, boost_activity) assert Enum.member?(activities, activity_one) - activities = ActivityPub.fetch_activities([], %{"blocking_user" => nil}) + activities = ActivityPub.fetch_activities([], %{"muting_user" => nil, "skip_preload" => true}) assert Enum.member?(activities, activity_two) assert Enum.member?(activities, activity_three) @@@ -176,11 -398,35 +404,35 @@@ assert Enum.member?(activities, activity_one) end + test "does include announces on request" do + activity_three = insert(:note_activity) + user = insert(:user) + booster = insert(:user) + + {:ok, user} = User.follow(user, booster) + + {:ok, announce, _object} = CommonAPI.repeat(activity_three.id, booster) + + [announce_activity] = ActivityPub.fetch_activities([user.ap_id | user.following]) + + assert announce_activity.id == announce.id + end + + test "excludes reblogs on request" do + user = insert(:user) + {:ok, expected_activity} = ActivityBuilder.insert(%{"type" => "Create"}, %{:user => user}) + {:ok, _} = ActivityBuilder.insert(%{"type" => "Announce"}, %{:user => user}) + + [activity] = ActivityPub.fetch_user_activities(user, nil, %{"exclude_reblogs" => "true"}) + + assert activity == expected_activity + end + describe "public fetch activities" do test "doesn't retrieve unlisted activities" do user = insert(:user) - {:ok, unlisted_activity} = + {:ok, _unlisted_activity} = CommonAPI.post(user, %{"status" => "yeah", "visibility" => "unlisted"}) {:ok, listed_activity} = CommonAPI.post(user, %{"status" => "yeah"}) @@@ -237,6 -483,33 +489,33 @@@ assert length(activities) == 20 assert last == last_expected end + + test "doesn't return reblogs for users for whom reblogs have been muted" do + activity = insert(:note_activity) + user = insert(:user) + booster = insert(:user) + {:ok, user} = CommonAPI.hide_reblogs(user, booster) + + {:ok, activity, _} = CommonAPI.repeat(activity.id, booster) + + activities = ActivityPub.fetch_activities([], %{"muting_user" => user}) + + refute Enum.any?(activities, fn %{id: id} -> id == activity.id end) + end + + test "returns reblogs for users for whom reblogs have not been muted" do + activity = insert(:note_activity) + user = insert(:user) + booster = insert(:user) + {:ok, user} = CommonAPI.hide_reblogs(user, booster) + {:ok, user} = CommonAPI.show_reblogs(user, booster) + + {:ok, activity, _} = CommonAPI.repeat(activity.id, booster) + + activities = ActivityPub.fetch_activities([], %{"muting_user" => user}) + + assert Enum.any?(activities, fn %{id: id} -> id == activity.id end) + end end describe "like an object" do @@@ -262,7 -535,7 +541,7 @@@ assert like_activity == same_like_activity assert object.data["likes"] == [user.ap_id] - [note_activity] = Activity.all_by_object_ap_id(object.data["id"]) + [note_activity] = Activity.get_all_create_by_object_ap_id(object.data["id"]) assert note_activity.data["object"]["like_count"] == 1 {:ok, _like_activity, object} = ActivityPub.like(user_two, object) @@@ -286,7 -559,7 +565,7 @@@ {:ok, _, _, object} = ActivityPub.unlike(user, object) assert object.data["like_count"] == 0 - assert Repo.get(Activity, like_activity.id) == nil + assert Activity.get_by_id(like_activity.id) == nil end end @@@ -337,7 -610,7 +616,7 @@@ assert unannounce_activity.data["actor"] == user.ap_id assert unannounce_activity.data["context"] == announce_activity.data["context"] - assert Repo.get(Activity, announce_activity.id) == nil + assert Activity.get_by_id(announce_activity.id) == nil end end @@@ -362,16 -635,43 +641,53 @@@ end end + describe "fetch the latest Follow" do + test "fetches the latest Follow activity" do + %Activity{data: %{"type" => "Follow"}} = activity = insert(:follow_activity) + follower = Repo.get_by(User, ap_id: activity.data["actor"]) + followed = Repo.get_by(User, ap_id: activity.data["object"]) + + assert activity == Utils.fetch_latest_follow(follower, followed) + end + end + + describe "fetching an object" do + test "it fetches an object" do + {:ok, object} = + ActivityPub.fetch_object_from_id("http://mastodon.example.org/@admin/99541947525187367") + + assert activity = Activity.get_create_by_object_ap_id(object.data["id"]) + assert activity.data["id"] + + {:ok, object_again} = + ActivityPub.fetch_object_from_id("http://mastodon.example.org/@admin/99541947525187367") + + assert [attachment] = object.data["attachment"] + assert is_list(attachment["url"]) + + assert object == object_again + end + + test "it works with objects only available via Ostatus" do + {:ok, object} = ActivityPub.fetch_object_from_id("https://shitposter.club/notice/2827873") + assert activity = Activity.get_create_by_object_ap_id(object.data["id"]) + assert activity.data["id"] + + {:ok, object_again} = + ActivityPub.fetch_object_from_id("https://shitposter.club/notice/2827873") + + assert object == object_again + end + + test "it correctly stitches up conversations between ostatus and ap" do + last = "https://mstdn.io/users/mayuutann/statuses/99568293732299394" + {:ok, object} = ActivityPub.fetch_object_from_id(last) + + object = Object.get_by_ap_id(object.data["inReplyTo"]) + assert object + end + end + describe "following / unfollowing" do test "creates a follow activity" do follower = insert(:user) @@@ -439,9 -739,88 +755,88 @@@ assert delete.data["actor"] == note.data["actor"] assert delete.data["object"] == note.data["object"]["id"] - assert Repo.get(Activity, delete.id) != nil + assert Activity.get_by_id(delete.id) != nil - assert Repo.get(Object, object.id) == nil + assert Repo.get(Object, object.id).data["type"] == "Tombstone" + end + + test "decrements user note count only for public activities" do + user = insert(:user, info: %{note_count: 10}) + + {:ok, a1} = + CommonAPI.post(User.get_by_id(user.id), %{"status" => "yeah", "visibility" => "public"}) + + {:ok, a2} = + CommonAPI.post(User.get_by_id(user.id), %{"status" => "yeah", "visibility" => "unlisted"}) + + {:ok, a3} = + CommonAPI.post(User.get_by_id(user.id), %{"status" => "yeah", "visibility" => "private"}) + + {:ok, a4} = + CommonAPI.post(User.get_by_id(user.id), %{"status" => "yeah", "visibility" => "direct"}) + + {:ok, _} = a1.data["object"]["id"] |> Object.get_by_ap_id() |> ActivityPub.delete() + {:ok, _} = a2.data["object"]["id"] |> Object.get_by_ap_id() |> ActivityPub.delete() + {:ok, _} = a3.data["object"]["id"] |> Object.get_by_ap_id() |> ActivityPub.delete() + {:ok, _} = a4.data["object"]["id"] |> Object.get_by_ap_id() |> ActivityPub.delete() + + user = User.get_by_id(user.id) + assert user.info.note_count == 10 + end + + test "it creates a delete activity and checks that it is also sent to users mentioned by the deleted object" do + user = insert(:user) + note = insert(:note_activity) + + {:ok, object} = + Object.get_by_ap_id(note.data["object"]["id"]) + |> Object.change(%{ + data: %{ + "actor" => note.data["object"]["actor"], + "id" => note.data["object"]["id"], + "to" => [user.ap_id], + "type" => "Note" + } + }) + |> Object.update_and_set_cache() + + {:ok, delete} = ActivityPub.delete(object) + + assert user.ap_id in delete.data["to"] + end + + test "decreases reply count" do + user = insert(:user) + user2 = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "1", "visibility" => "public"}) + reply_data = %{"status" => "1", "in_reply_to_status_id" => activity.id} + ap_id = activity.data["id"] + + {:ok, public_reply} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "public")) + {:ok, unlisted_reply} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "unlisted")) + {:ok, private_reply} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "private")) + {:ok, direct_reply} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "direct")) + + _ = CommonAPI.delete(direct_reply.id, user2) + assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id) + assert data["object"]["repliesCount"] == 2 + assert object.data["repliesCount"] == 2 + + _ = CommonAPI.delete(private_reply.id, user2) + assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id) + assert data["object"]["repliesCount"] == 2 + assert object.data["repliesCount"] == 2 + + _ = CommonAPI.delete(public_reply.id, user2) + assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id) + assert data["object"]["repliesCount"] == 1 + assert object.data["repliesCount"] == 1 + + _ = CommonAPI.delete(unlisted_reply.id, user2) + assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id) + assert data["object"]["repliesCount"] == 0 + assert object.data["repliesCount"] == 0 end end @@@ -479,10 -858,9 +874,9 @@@ "in_reply_to_status_id" => private_activity_2.id }) - assert user1.following == [user3.ap_id <> "/followers", user1.ap_id] - activities = ActivityPub.fetch_activities([user1.ap_id | user1.following]) + private_activity_1 = Activity.get_by_ap_id_with_object(private_activity_1.data["id"]) assert [public_activity, private_activity_1, private_activity_3] == activities assert length(activities) == 3 @@@ -493,6 -871,15 +887,6 @@@ end end - test "it can fetch plume articles" do - {:ok, object} = - ActivityPub.fetch_object_from_id( - "https://baptiste.gelez.xyz/~/PlumeDevelopment/this-month-in-plume-june-2018/" - ) - - assert object - end - describe "update" do test "it creates an update activity with the new user data" do user = insert(:user) @@@ -514,6 -901,177 +908,177 @@@ end end + test "it can fetch peertube videos" do + {:ok, object} = + ActivityPub.fetch_object_from_id( + "https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3" + ) + + assert object + end + + test "returned pinned statuses" do + Pleroma.Config.put([:instance, :max_pinned_statuses], 3) + user = insert(:user) + + {:ok, activity_one} = CommonAPI.post(user, %{"status" => "HI!!!"}) + {:ok, activity_two} = CommonAPI.post(user, %{"status" => "HI!!!"}) + {:ok, activity_three} = CommonAPI.post(user, %{"status" => "HI!!!"}) + + CommonAPI.pin(activity_one.id, user) + user = refresh_record(user) + + CommonAPI.pin(activity_two.id, user) + user = refresh_record(user) + + CommonAPI.pin(activity_three.id, user) + user = refresh_record(user) + + activities = ActivityPub.fetch_user_activities(user, nil, %{"pinned" => "true"}) + + assert 3 = length(activities) + end + + test "it can create a Flag activity" do + reporter = insert(:user) + target_account = insert(:user) + {:ok, activity} = CommonAPI.post(target_account, %{"status" => "foobar"}) + context = Utils.generate_context_id() + content = "foobar" + + reporter_ap_id = reporter.ap_id + target_ap_id = target_account.ap_id + activity_ap_id = activity.data["id"] + + assert {:ok, activity} = + ActivityPub.flag(%{ + actor: reporter, + context: context, + account: target_account, + statuses: [activity], + content: content + }) + + assert %Activity{ + actor: ^reporter_ap_id, + data: %{ + "type" => "Flag", + "content" => ^content, + "context" => ^context, + "object" => [^target_ap_id, ^activity_ap_id] + } + } = activity + end + + describe "publish_one/1" do + test_with_mock "calls `Instances.set_reachable` on successful federation if `unreachable_since` is not specified", + Instances, + [:passthrough], + [] do + actor = insert(:user) + inbox = "http://200.site/users/nick1/inbox" + + assert {:ok, _} = ActivityPub.publish_one(%{inbox: inbox, json: "{}", actor: actor, id: 1}) + + assert called(Instances.set_reachable(inbox)) + end + + test_with_mock "calls `Instances.set_reachable` on successful federation if `unreachable_since` is set", + Instances, + [:passthrough], + [] do + actor = insert(:user) + inbox = "http://200.site/users/nick1/inbox" + + assert {:ok, _} = + ActivityPub.publish_one(%{ + inbox: inbox, + json: "{}", + actor: actor, + id: 1, + unreachable_since: NaiveDateTime.utc_now() + }) + + assert called(Instances.set_reachable(inbox)) + end + + test_with_mock "does NOT call `Instances.set_reachable` on successful federation if `unreachable_since` is nil", + Instances, + [:passthrough], + [] do + actor = insert(:user) + inbox = "http://200.site/users/nick1/inbox" + + assert {:ok, _} = + ActivityPub.publish_one(%{ + inbox: inbox, + json: "{}", + actor: actor, + id: 1, + unreachable_since: nil + }) + + refute called(Instances.set_reachable(inbox)) + end + + test_with_mock "calls `Instances.set_unreachable` on target inbox on non-2xx HTTP response code", + Instances, + [:passthrough], + [] do + actor = insert(:user) + inbox = "http://404.site/users/nick1/inbox" + + assert {:error, _} = + ActivityPub.publish_one(%{inbox: inbox, json: "{}", actor: actor, id: 1}) + + assert called(Instances.set_unreachable(inbox)) + end + + test_with_mock "it calls `Instances.set_unreachable` on target inbox on request error of any kind", + Instances, + [:passthrough], + [] do + actor = insert(:user) + inbox = "http://connrefused.site/users/nick1/inbox" + + assert {:error, _} = + ActivityPub.publish_one(%{inbox: inbox, json: "{}", actor: actor, id: 1}) + + assert called(Instances.set_unreachable(inbox)) + end + + test_with_mock "does NOT call `Instances.set_unreachable` if target is reachable", + Instances, + [:passthrough], + [] do + actor = insert(:user) + inbox = "http://200.site/users/nick1/inbox" + + assert {:ok, _} = ActivityPub.publish_one(%{inbox: inbox, json: "{}", actor: actor, id: 1}) + + refute called(Instances.set_unreachable(inbox)) + end + + test_with_mock "does NOT call `Instances.set_unreachable` if target instance has non-nil `unreachable_since`", + Instances, + [:passthrough], + [] do + actor = insert(:user) + inbox = "http://connrefused.site/users/nick1/inbox" + + assert {:error, _} = + ActivityPub.publish_one(%{ + inbox: inbox, + json: "{}", + actor: actor, + id: 1, + unreachable_since: NaiveDateTime.utc_now() + }) + + refute called(Instances.set_unreachable(inbox)) + end + end + def data_uri do File.read!("test/fixtures/avatar_data_uri") end diff --combined test/web/activity_pub/transmogrifier_test.exs index ea9d9fe58,c857a7ec1..5559cdf87 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@@ -1,17 -1,27 +1,27 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2018 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do use Pleroma.DataCase + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.Utils - alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.OStatus - alias Pleroma.{Activity, Object} - alias Pleroma.User - alias Pleroma.Repo alias Pleroma.Web.Websub.WebsubClientSubscription import Pleroma.Factory alias Pleroma.Web.CommonAPI + setup_all do + Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) + :ok + end + describe "handle_incoming" do test "it ignores an incoming notice if we already have it" do activity = insert(:note_activity) @@@ -40,16 -50,14 +50,14 @@@ |> Map.put("object", object) {:ok, returned_activity} = Transmogrifier.handle_incoming(data) + returned_object = Object.normalize(returned_activity.data["object"]) assert activity = - Activity.get_create_activity_by_object_ap_id( + Activity.get_create_by_object_ap_id( "tag:shitposter.club,2017-05-05:noticeId=2827873:objectType=comment" ) - assert returned_activity.data["object"]["inReplyToAtomUri"] == - "https://shitposter.club/notice/2827873" + assert returned_object.data["inReplyToAtomUri"] == "https://shitposter.club/notice/2827873" - - assert returned_object.data["inReplyToStatusId"] == activity.id end test "it works for incoming notices" do @@@ -72,7 -80,7 +80,7 @@@ assert data["actor"] == "http://mastodon.example.org/users/admin" - object = data["object"] + object = Object.normalize(data["object"]).data assert object["id"] == "http://mastodon.example.org/users/admin/statuses/99512778738411822" assert object["to"] == ["https://www.w3.org/ns/activitystreams#Public"] @@@ -99,9 -107,7 +107,9 @@@ data = File.read!("test/fixtures/mastodon-post-activity-hashtag.json") |> Poison.decode!() {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - assert Enum.at(data["object"]["tag"], 2) == "moo" + object = Object.normalize(data["object"]) + + assert Enum.at(object.data["tag"], 2) == "moo" end test "it works for incoming notices with contentMap" do @@@ -109,9 -115,8 +117,9 @@@ File.read!("test/fixtures/mastodon-post-activity-contentmap.json") |> Poison.decode!() {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + object = Object.normalize(data["object"]) - assert data["object"]["content"] == + assert object.data["content"] == "

@lain

" end @@@ -119,9 -124,8 +127,9 @@@ data = File.read!("test/fixtures/kroeg-post-activity.json") |> Poison.decode!() {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + object = Object.normalize(data["object"]) - assert data["object"]["content"] == + assert object.data["content"] == "

henlo from my Psion netBook

message sent from my Psion netBook

" end @@@ -137,29 -141,56 +145,59 @@@ data = File.read!("test/fixtures/kroeg-array-less-emoji.json") |> Poison.decode!() {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + object = Object.normalize(data["object"]) - assert data["object"]["emoji"] == %{ + assert object.data["emoji"] == %{ "icon_e_smile" => "https://puckipedia.com/forum/images/smilies/icon_e_smile.png" } data = File.read!("test/fixtures/kroeg-array-less-hashtag.json") |> Poison.decode!() {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + object = Object.normalize(data["object"]) - assert "test" in data["object"]["tag"] + assert "test" in object.data["tag"] end test "it works for incoming notices with url not being a string (prismo)" do data = File.read!("test/fixtures/prismo-url-map.json") |> Poison.decode!() {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + object = Object.normalize(data["object"]) - assert data["object"]["url"] == "https://prismo.news/posts/83" + assert object.data["url"] == "https://prismo.news/posts/83" end + test "it cleans up incoming notices which are not really DMs" do + user = insert(:user) + other_user = insert(:user) + + to = [user.ap_id, other_user.ap_id] + + data = + File.read!("test/fixtures/mastodon-post-activity.json") + |> Poison.decode!() + |> Map.put("to", to) + |> Map.put("cc", []) + + object = + data["object"] + |> Map.put("to", to) + |> Map.put("cc", []) + + data = Map.put(data, "object", object) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["to"] == [] + assert data["cc"] == to + + object = data["object"] + + assert object["to"] == [] + assert object["cc"] == to + end + test "it works for incoming follow requests" do user = insert(:user) @@@ -200,14 -231,14 +238,14 @@@ data = File.read!("test/fixtures/mastodon-like.json") |> Poison.decode!() - |> Map.put("object", activity.data["object"]["id"]) + |> Map.put("object", activity.data["object"]) {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) assert data["actor"] == "http://mastodon.example.org/users/admin" assert data["type"] == "Like" assert data["id"] == "http://mastodon.example.org/users/admin#likes/2" - assert data["object"] == activity.data["object"]["id"] + assert data["object"] == activity.data["object"] end test "it returns an error for incoming unlikes wihout a like activity" do @@@ -217,7 -248,7 +255,7 @@@ data = File.read!("test/fixtures/mastodon-undo-like.json") |> Poison.decode!() - |> Map.put("object", activity.data["object"]["id"]) + |> Map.put("object", activity.data["object"]) assert Transmogrifier.handle_incoming(data) == :error end @@@ -229,7 -260,7 +267,7 @@@ like_data = File.read!("test/fixtures/mastodon-like.json") |> Poison.decode!() - |> Map.put("object", activity.data["object"]["id"]) + |> Map.put("object", activity.data["object"]) {:ok, %Activity{data: like_data, local: false}} = Transmogrifier.handle_incoming(like_data) @@@ -261,7 -292,7 +299,7 @@@ assert data["object"] == "http://mastodon.example.org/users/admin/statuses/99541947525187367" - assert Activity.get_create_activity_by_object_ap_id(data["object"]) + assert Activity.get_create_by_object_ap_id(data["object"]) end test "it works for incoming announces with an existing activity" do @@@ -271,7 -302,7 +309,7 @@@ data = File.read!("test/fixtures/mastodon-announce.json") |> Poison.decode!() - |> Map.put("object", activity.data["object"]["id"]) + |> Map.put("object", activity.data["object"]) {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) @@@ -281,9 -312,72 +319,72 @@@ assert data["id"] == "http://mastodon.example.org/users/admin/statuses/99542391527669785/activity" - assert data["object"] == activity.data["object"]["id"] + assert data["object"] == activity.data["object"] - assert Activity.get_create_activity_by_object_ap_id(data["object"]).id == activity.id + assert Activity.get_create_by_object_ap_id(data["object"]).id == activity.id + end + + test "it does not clobber the addressing on announce activities" do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"}) + + data = + File.read!("test/fixtures/mastodon-announce.json") + |> Poison.decode!() + |> Map.put("object", activity.data["object"]["id"]) + |> Map.put("to", ["http://mastodon.example.org/users/admin/followers"]) + |> Map.put("cc", []) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["to"] == ["http://mastodon.example.org/users/admin/followers"] + end + + test "it ensures that as:Public activities make it to their followers collection" do + user = insert(:user) + + data = + File.read!("test/fixtures/mastodon-post-activity.json") + |> Poison.decode!() + |> Map.put("actor", user.ap_id) + |> Map.put("to", ["https://www.w3.org/ns/activitystreams#Public"]) + |> Map.put("cc", []) + + object = + data["object"] + |> Map.put("attributedTo", user.ap_id) + |> Map.put("to", ["https://www.w3.org/ns/activitystreams#Public"]) + |> Map.put("cc", []) + + data = Map.put(data, "object", object) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["cc"] == [User.ap_followers(user)] + end + + test "it ensures that address fields become lists" do + user = insert(:user) + + data = + File.read!("test/fixtures/mastodon-post-activity.json") + |> Poison.decode!() + |> Map.put("actor", user.ap_id) + |> Map.put("to", nil) + |> Map.put("cc", nil) + + object = + data["object"] + |> Map.put("attributedTo", user.ap_id) + |> Map.put("to", nil) + |> Map.put("cc", nil) + + data = Map.put(data, "object", object) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert !is_nil(data["to"]) + assert !is_nil(data["cc"]) end test "it works for incoming update activities" do @@@ -356,7 -450,7 +457,7 @@@ object = data["object"] - |> Map.put("id", activity.data["object"]["id"]) + |> Map.put("id", activity.data["object"]) data = data @@@ -365,7 -459,7 +466,7 @@@ {:ok, %Activity{local: false}} = Transmogrifier.handle_incoming(data) - refute Repo.get(Activity, activity.id) + refute Activity.get_by_id(activity.id) end test "it fails for incoming deletes with spoofed origin" do @@@ -377,7 -471,7 +478,7 @@@ object = data["object"] - |> Map.put("id", activity.data["object"]["id"]) + |> Map.put("id", activity.data["object"]) data = data @@@ -385,7 -479,7 +486,7 @@@ :error = Transmogrifier.handle_incoming(data) - assert Repo.get(Activity, activity.id) + assert Activity.get_by_id(activity.id) end test "it works for incoming unannounces with an existing notice" do @@@ -395,7 -489,7 +496,7 @@@ announce_data = File.read!("test/fixtures/mastodon-announce.json") |> Poison.decode!() - |> Map.put("object", activity.data["object"]["id"]) + |> Map.put("object", activity.data["object"]) {:ok, %Activity{data: announce_data, local: false}} = Transmogrifier.handle_incoming(announce_data) @@@ -410,7 -504,7 +511,7 @@@ assert data["type"] == "Undo" assert data["object"]["type"] == "Announce" - assert data["object"]["object"] == activity.data["object"]["id"] + assert data["object"]["object"] == activity.data["object"] assert data["object"]["id"] == "http://mastodon.example.org/users/admin/statuses/99542391527669785/activity" @@@ -543,7 -637,7 +644,7 @@@ assert activity.data["object"] == follow_activity.data["id"] - follower = Repo.get(User, follower.id) + follower = User.get_by_id(follower.id) assert User.following?(follower, followed) == true end @@@ -565,7 -659,7 +666,7 @@@ {:ok, activity} = Transmogrifier.handle_incoming(accept_data) assert activity.data["object"] == follow_activity.data["id"] - follower = Repo.get(User, follower.id) + follower = User.get_by_id(follower.id) assert User.following?(follower, followed) == true end @@@ -585,7 -679,7 +686,7 @@@ {:ok, activity} = Transmogrifier.handle_incoming(accept_data) assert activity.data["object"] == follow_activity.data["id"] - follower = Repo.get(User, follower.id) + follower = User.get_by_id(follower.id) assert User.following?(follower, followed) == true end @@@ -604,7 -698,7 +705,7 @@@ :error = Transmogrifier.handle_incoming(accept_data) - follower = Repo.get(User, follower.id) + follower = User.get_by_id(follower.id) refute User.following?(follower, followed) == true end @@@ -623,7 -717,7 +724,7 @@@ :error = Transmogrifier.handle_incoming(accept_data) - follower = Repo.get(User, follower.id) + follower = User.get_by_id(follower.id) refute User.following?(follower, followed) == true end @@@ -648,7 -742,7 +749,7 @@@ {:ok, activity} = Transmogrifier.handle_incoming(reject_data) refute activity.local - follower = Repo.get(User, follower.id) + follower = User.get_by_id(follower.id) assert User.following?(follower, followed) == false end @@@ -670,7 -764,7 +771,7 @@@ {:ok, %Activity{data: _}} = Transmogrifier.handle_incoming(reject_data) - follower = Repo.get(User, follower.id) + follower = User.get_by_id(follower.id) assert User.following?(follower, followed) == false end @@@ -686,6 -780,60 +787,60 @@@ :error = Transmogrifier.handle_incoming(data) end + + test "it remaps video URLs as attachments if necessary" do + {:ok, object} = + ActivityPub.fetch_object_from_id( + "https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3" + ) + + attachment = %{ + "type" => "Link", + "mediaType" => "video/mp4", + "href" => + "https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4", + "mimeType" => "video/mp4", + "size" => 5_015_880, + "url" => [ + %{ + "href" => + "https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4", + "mediaType" => "video/mp4", + "type" => "Link" + } + ], + "width" => 480 + } + + assert object.data["url"] == + "https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3" + + assert object.data["attachment"] == [attachment] + end + + test "it accepts Flag activities" do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "test post"}) + object = Object.normalize(activity.data["object"]) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "cc" => [user.ap_id], + "object" => [user.ap_id, object.data["id"]], + "type" => "Flag", + "content" => "blocked AND reported!!!", + "actor" => other_user.ap_id + } + + assert {:ok, activity} = Transmogrifier.handle_incoming(message) + + assert activity.data["object"] == [user.ap_id, object.data["id"]] + assert activity.data["content"] == "blocked AND reported!!!" + assert activity.data["actor"] == other_user.ap_id + assert activity.data["cc"] == [user.ap_id] + end end describe "prepare outgoing" do @@@ -797,12 -945,61 +952,61 @@@ assert length(modified["object"]["tag"]) == 2 assert is_nil(modified["object"]["emoji"]) - assert is_nil(modified["object"]["likes"]) assert is_nil(modified["object"]["like_count"]) assert is_nil(modified["object"]["announcements"]) assert is_nil(modified["object"]["announcement_count"]) assert is_nil(modified["object"]["context_id"]) end + + test "it strips internal fields of article" do + activity = insert(:article_activity) + + {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) + + assert length(modified["object"]["tag"]) == 2 + + assert is_nil(modified["object"]["emoji"]) + assert is_nil(modified["object"]["like_count"]) + assert is_nil(modified["object"]["announcements"]) + assert is_nil(modified["object"]["announcement_count"]) + assert is_nil(modified["object"]["context_id"]) + end + + test "it adds like collection to object" do + activity = insert(:note_activity) + {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) + + assert modified["object"]["likes"]["type"] == "OrderedCollection" + assert modified["object"]["likes"]["totalItems"] == 0 + end + + test "the directMessage flag is present" do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "2hu :moominmamma:"}) + + {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) + + assert modified["directMessage"] == false + + {:ok, activity} = + CommonAPI.post(user, %{"status" => "@#{other_user.nickname} :moominmamma:"}) + + {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) + + assert modified["directMessage"] == false + + {:ok, activity} = + CommonAPI.post(user, %{ + "status" => "@#{other_user.nickname} :moominmamma:", + "visibility" => "direct" + }) + + {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) + + assert modified["directMessage"] == true + end end describe "user upgrade" do @@@ -821,7 -1018,7 +1025,7 @@@ {:ok, unrelated_activity} = CommonAPI.post(user_two, %{"status" => "test"}) assert "http://localhost:4001/users/rye@niu.moe/followers" in activity.recipients - user = Repo.get(User, user.id) + user = User.get_by_id(user.id) assert user.info.note_count == 1 {:ok, user} = Transmogrifier.upgrade_user_from_ap_id("https://niu.moe/users/rye") @@@ -829,13 -1026,10 +1033,10 @@@ assert user.info.note_count == 1 assert user.follower_address == "https://niu.moe/users/rye/followers" - # Wait for the background task - :timer.sleep(1000) - - user = Repo.get(User, user.id) + user = User.get_by_id(user.id) assert user.info.note_count == 1 - activity = Repo.get(Activity, activity.id) + activity = Activity.get_by_id(activity.id) assert user.follower_address in activity.recipients assert %{ @@@ -858,10 -1052,10 +1059,10 @@@ refute "..." in activity.recipients - unrelated_activity = Repo.get(Activity, unrelated_activity.id) + unrelated_activity = Activity.get_by_id(unrelated_activity.id) refute user.follower_address in unrelated_activity.recipients - user_two = Repo.get(User, user_two.id) + user_two = User.get_by_id(user_two.id) assert user.follower_address in user_two.following refute "..." in user_two.following end @@@ -894,6 -1088,10 +1095,6 @@@ end describe "actor origin containment" do - test "it rejects objects with a bogus origin" do - {:error, _} = ActivityPub.fetch_object_from_id("https://info.pleroma.site/activity.json") - end - test "it rejects activities which reference objects with bogus origins" do data = %{ "@context" => "https://www.w3.org/ns/activitystreams", @@@ -907,6 -1105,10 +1108,6 @@@ :error = Transmogrifier.handle_incoming(data) end - test "it rejects objects when attributedTo is wrong (variant 1)" do - {:error, _} = ActivityPub.fetch_object_from_id("https://info.pleroma.site/activity2.json") - end - test "it rejects activities which reference objects that have an incorrect attribution (variant 1)" do data = %{ "@context" => "https://www.w3.org/ns/activitystreams", @@@ -920,6 -1122,10 +1121,6 @@@ :error = Transmogrifier.handle_incoming(data) end - test "it rejects objects when attributedTo is wrong (variant 2)" do - {:error, _} = ActivityPub.fetch_object_from_id("https://info.pleroma.site/activity3.json") - end - test "it rejects activities which reference objects that have an incorrect attribution (variant 2)" do data = %{ "@context" => "https://www.w3.org/ns/activitystreams", @@@ -933,4 -1139,114 +1134,114 @@@ :error = Transmogrifier.handle_incoming(data) end end + + describe "general origin containment" do + test "contain_origin_from_id() catches obvious spoofing attempts" do + data = %{ + "id" => "http://example.com/~alyssa/activities/1234.json" + } + + :error = + Transmogrifier.contain_origin_from_id( + "http://example.org/~alyssa/activities/1234.json", + data + ) + end + + test "contain_origin_from_id() allows alternate IDs within the same origin domain" do + data = %{ + "id" => "http://example.com/~alyssa/activities/1234.json" + } + + :ok = + Transmogrifier.contain_origin_from_id( + "http://example.com/~alyssa/activities/1234", + data + ) + end + + test "contain_origin_from_id() allows matching IDs" do + data = %{ + "id" => "http://example.com/~alyssa/activities/1234.json" + } + + :ok = + Transmogrifier.contain_origin_from_id( + "http://example.com/~alyssa/activities/1234.json", + data + ) + end + + test "users cannot be collided through fake direction spoofing attempts" do + insert(:user, %{ + nickname: "rye@niu.moe", + local: false, + ap_id: "https://niu.moe/users/rye", + follower_address: User.ap_followers(%User{nickname: "rye@niu.moe"}) + }) + + {:error, _} = User.get_or_fetch_by_ap_id("https://n1u.moe/users/rye") + end + + test "all objects with fake directions are rejected by the object fetcher" do + {:error, _} = + ActivityPub.fetch_and_contain_remote_object_from_id( + "https://info.pleroma.site/activity4.json" + ) + end + end + + describe "reserialization" do + test "successfully reserializes a message with inReplyTo == nil" do + user = insert(:user) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "type" => "Create", + "object" => %{ + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "type" => "Note", + "content" => "Hi", + "inReplyTo" => nil, + "attributedTo" => user.ap_id + }, + "actor" => user.ap_id + } + + {:ok, activity} = Transmogrifier.handle_incoming(message) + + {:ok, _} = Transmogrifier.prepare_outgoing(activity.data) + end + + test "successfully reserializes a message with AS2 objects in IR" do + user = insert(:user) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "type" => "Create", + "object" => %{ + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "type" => "Note", + "content" => "Hi", + "inReplyTo" => nil, + "attributedTo" => user.ap_id, + "tag" => [ + %{"name" => "#2hu", "href" => "http://example.com/2hu", "type" => "Hashtag"}, + %{"name" => "Bob", "href" => "http://example.com/bob", "type" => "Mention"} + ] + }, + "actor" => user.ap_id + } + + {:ok, activity} = Transmogrifier.handle_incoming(message) + + {:ok, _} = Transmogrifier.prepare_outgoing(activity.data) + end + end end diff --combined test/web/common_api/common_api_test.exs index 3dc5f6f84,34aa5bf18..b9ed088e4 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@@ -1,19 -1,47 +1,50 @@@ - defmodule Pleroma.Web.CommonAPI.Test do + # Pleroma: A lightweight social networking server + # Copyright © 2017-2019 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + + defmodule Pleroma.Web.CommonAPITest do use Pleroma.DataCase + alias Pleroma.Activity + alias Pleroma.User ++ alias Pleroma.Object alias Pleroma.Web.CommonAPI - alias Pleroma.{User, Object} import Pleroma.Factory + test "with the safe_dm_mention option set, it does not mention people beyond the initial tags" do + har = insert(:user) + jafnhar = insert(:user) + tridi = insert(:user) + option = Pleroma.Config.get([:instance, :safe_dm_mentions]) + Pleroma.Config.put([:instance, :safe_dm_mentions], true) + + {:ok, activity} = + CommonAPI.post(har, %{ + "status" => "@#{jafnhar.nickname} hey, i never want to see @#{tridi.nickname} again", + "visibility" => "direct" + }) + + refute tridi.ap_id in activity.recipients + assert jafnhar.ap_id in activity.recipients + Pleroma.Config.put([:instance, :safe_dm_mentions], option) + end + test "it de-duplicates tags" do user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{"status" => "#2hu #2HU"}) - assert activity.data["object"]["tag"] == ["2hu"] + object = Object.normalize(activity.data["object"]) + + assert object.data["tag"] == ["2hu"] end + test "it adds emoji in the object" do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{"status" => ":moominmamma:"}) + + assert activity.data["object"]["emoji"]["moominmamma"] + end + test "it adds emoji when updating profiles" do user = insert(:user, %{name: ":karjalanpiirakka:"}) @@@ -36,9 -64,8 +67,9 @@@ "content_type" => "text/html" }) - content = activity.data["object"]["content"] - assert content == "

2hu

alert('xss')" + object = Object.normalize(activity.data["object"]) + + assert object.data["content"] == "

2hu

alert('xss')" end test "it filters out obviously bad tags when accepting a post as Markdown" do @@@ -52,9 -79,187 +83,188 @@@ "content_type" => "text/markdown" }) - content = activity.data["object"]["content"] - assert content == "

2hu

alert('xss')" + object = Object.normalize(activity.data["object"]) + + assert object.data["content"] == "

2hu

alert('xss')" end end + + describe "reactions" do + test "repeating a status" do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) + + {:ok, %Activity{}, _} = CommonAPI.repeat(activity.id, user) + end + + test "favoriting a status" do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) + + {:ok, %Activity{}, _} = CommonAPI.favorite(activity.id, user) + end + + test "retweeting a status twice returns an error" do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) + {:ok, %Activity{}, _object} = CommonAPI.repeat(activity.id, user) + {:error, _} = CommonAPI.repeat(activity.id, user) + end + + test "favoriting a status twice returns an error" do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) + {:ok, %Activity{}, _object} = CommonAPI.favorite(activity.id, user) + {:error, _} = CommonAPI.favorite(activity.id, user) + end + end + + describe "pinned statuses" do + setup do + Pleroma.Config.put([:instance, :max_pinned_statuses], 1) + + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{"status" => "HI!!!"}) + + [user: user, activity: activity] + end + + test "pin status", %{user: user, activity: activity} do + assert {:ok, ^activity} = CommonAPI.pin(activity.id, user) + + id = activity.id + user = refresh_record(user) + + assert %User{info: %{pinned_activities: [^id]}} = user + end + + test "only self-authored can be pinned", %{activity: activity} do + user = insert(:user) + + assert {:error, "Could not pin"} = CommonAPI.pin(activity.id, user) + end + + test "max pinned statuses", %{user: user, activity: activity_one} do + {:ok, activity_two} = CommonAPI.post(user, %{"status" => "HI!!!"}) + + assert {:ok, ^activity_one} = CommonAPI.pin(activity_one.id, user) + + user = refresh_record(user) + + assert {:error, "You have already pinned the maximum number of statuses"} = + CommonAPI.pin(activity_two.id, user) + end + + test "unpin status", %{user: user, activity: activity} do + {:ok, activity} = CommonAPI.pin(activity.id, user) + + user = refresh_record(user) + + assert {:ok, ^activity} = CommonAPI.unpin(activity.id, user) + + user = refresh_record(user) + + assert %User{info: %{pinned_activities: []}} = user + end + + test "should unpin when deleting a status", %{user: user, activity: activity} do + {:ok, activity} = CommonAPI.pin(activity.id, user) + + user = refresh_record(user) + + assert {:ok, _} = CommonAPI.delete(activity.id, user) + + user = refresh_record(user) + + assert %User{info: %{pinned_activities: []}} = user + end + end + + describe "mute tests" do + setup do + user = insert(:user) + + activity = insert(:note_activity) + + [user: user, activity: activity] + end + + test "add mute", %{user: user, activity: activity} do + {:ok, _} = CommonAPI.add_mute(user, activity) + assert CommonAPI.thread_muted?(user, activity) + end + + test "remove mute", %{user: user, activity: activity} do + CommonAPI.add_mute(user, activity) + {:ok, _} = CommonAPI.remove_mute(user, activity) + refute CommonAPI.thread_muted?(user, activity) + end + + test "check that mutes can't be duplicate", %{user: user, activity: activity} do + CommonAPI.add_mute(user, activity) + {:error, _} = CommonAPI.add_mute(user, activity) + end + end + + describe "reports" do + test "creates a report" do + reporter = insert(:user) + target_user = insert(:user) + + {:ok, activity} = CommonAPI.post(target_user, %{"status" => "foobar"}) + + reporter_ap_id = reporter.ap_id + target_ap_id = target_user.ap_id + activity_ap_id = activity.data["id"] + comment = "foobar" + + report_data = %{ + "account_id" => target_user.id, + "comment" => comment, + "status_ids" => [activity.id] + } + + assert {:ok, flag_activity} = CommonAPI.report(reporter, report_data) + + assert %Activity{ + actor: ^reporter_ap_id, + data: %{ + "type" => "Flag", + "content" => ^comment, + "object" => [^target_ap_id, ^activity_ap_id] + } + } = flag_activity + end + end + + describe "reblog muting" do + setup do + muter = insert(:user) + + muted = insert(:user) + + [muter: muter, muted: muted] + end + + test "add a reblog mute", %{muter: muter, muted: muted} do + {:ok, muter} = CommonAPI.hide_reblogs(muter, muted) + + assert Pleroma.User.showing_reblogs?(muter, muted) == false + end + + test "remove a reblog mute", %{muter: muter, muted: muted} do + {:ok, muter} = CommonAPI.hide_reblogs(muter, muted) + {:ok, muter} = CommonAPI.show_reblogs(muter, muted) + + assert Pleroma.User.showing_reblogs?(muter, muted) == true + end + end end diff --combined test/web/mastodon_api/status_view_test.exs index 4f58ce8af,db2fdc2f6..4ea50c7c6 --- a/test/web/mastodon_api/status_view_test.exs +++ b/test/web/mastodon_api/status_view_test.exs @@@ -1,24 -1,68 +1,70 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2018 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.MastodonAPI.StatusViewTest do use Pleroma.DataCase - alias Pleroma.Web.MastodonAPI.{StatusView, AccountView} - alias Pleroma.{Repo, User, Object} - alias Pleroma.Web.OStatus + alias Pleroma.Activity + alias Pleroma.User ++ alias Pleroma.Repo ++ alias Pleroma.Object + alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.OStatus import Pleroma.Factory + import Tesla.Mock + + setup do + mock(fn env -> apply(HttpRequestMock, :request, [env]) end) + :ok + end + + test "returns a temporary ap_id based user for activities missing db users" do + user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "Hey @shp!", "visibility" => "direct"}) + + Repo.delete(user) + Cachex.clear(:user_cache) + + %{account: ms_user} = StatusView.render("status.json", activity: activity) + + assert ms_user.acct == "erroruser@example.com" + end + + test "tries to get a user by nickname if fetching by ap_id doesn't work" do + user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "Hey @shp!", "visibility" => "direct"}) + + {:ok, user} = + user + |> Ecto.Changeset.change(%{ap_id: "#{user.ap_id}/extension/#{user.nickname}"}) + |> Repo.update() + + Cachex.clear(:user_cache) + + result = StatusView.render("status.json", activity: activity) + + assert result[:account][:id] == to_string(user.id) + end test "a note with null content" do note = insert(:note_activity) + note_object = Object.normalize(note.data["object"]) data = - note.data - |> put_in(["object", "content"], nil) + note_object.data + |> Map.put("content", nil) - note = - note - |> Map.put(:data, data) + Object.change(note_object, %{data: data}) + |> Repo.update() - user = User.get_cached_by_ap_id(note.data["actor"]) + User.get_cached_by_ap_id(note.data["actor"]) status = StatusView.render("status.json", %{activity: note}) @@@ -29,6 -73,8 +75,8 @@@ note = insert(:note_activity) user = User.get_cached_by_ap_id(note.data["actor"]) + convo_id = Utils.context_to_conversation_id(note.data["object"]["context"]) + status = StatusView.render("status.json", %{activity: note}) created_at = @@@ -38,10 -84,11 +86,11 @@@ expected = %{ id: to_string(note.id), uri: note.data["object"]["id"], - url: note.data["object"]["id"], + url: Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, note), account: AccountView.render("account.json", %{user: user}), in_reply_to_id: nil, in_reply_to_account_id: nil, + card: nil, reblog: nil, content: HtmlSanitizeEx.basic_html(note.data["object"]["content"]), created_at: created_at, @@@ -49,14 -96,21 +98,21 @@@ replies_count: 0, favourites_count: 0, reblogged: false, + bookmarked: false, favourited: false, muted: false, + pinned: false, sensitive: false, - spoiler_text: note.data["object"]["summary"], + spoiler_text: HtmlSanitizeEx.basic_html(note.data["object"]["summary"]), visibility: "public", media_attachments: [], mentions: [], - tags: [], + tags: [ + %{ + name: "#{note.data["object"]["tag"]}", + url: "/tag/#{note.data["object"]["tag"]}" + } + ], application: %{ name: "Web", website: nil @@@ -69,12 -123,34 +125,34 @@@ static_url: "corndog.png", visible_in_picker: false } - ] + ], + pleroma: %{ + local: true, + conversation_id: convo_id, + content: %{"text/plain" => HtmlSanitizeEx.strip_tags(note.data["object"]["content"])}, + spoiler_text: %{"text/plain" => HtmlSanitizeEx.strip_tags(note.data["object"]["summary"])} + } } assert status == expected end + test "tells if the message is muted for some reason" do + user = insert(:user) + other_user = insert(:user) + + {:ok, user} = User.mute(user, other_user) + + {:ok, activity} = CommonAPI.post(other_user, %{"status" => "test"}) + status = StatusView.render("status.json", %{activity: activity}) + + assert status.muted == false + + status = StatusView.render("status.json", %{activity: activity, for: user}) + + assert status.muted == true + end + test "a reply" do note = insert(:note_activity) user = insert(:user) @@@ -101,7 -177,10 +179,10 @@@ status = StatusView.render("status.json", %{activity: activity}) - assert status.mentions == [AccountView.render("mention.json", %{user: user})] + actor = User.get_by_ap_id(activity.actor) + + assert status.mentions == + Enum.map([user, actor], fn u -> AccountView.render("mention.json", %{user: u}) end) end test "attachments" do @@@ -123,7 -202,8 +204,8 @@@ remote_url: "someurl", preview_url: "someurl", text_url: "someurl", - description: nil + description: nil, + pleroma: %{mime_type: "image/png"} } assert expected == StatusView.render("attachment.json", %{attachment: object}) @@@ -145,4 -225,96 +227,96 @@@ assert represented[:reblog][:id] == to_string(activity.id) assert represented[:emojis] == [] end + + test "a peertube video" do + user = insert(:user) + + {:ok, object} = + ActivityPub.fetch_object_from_id( + "https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3" + ) + + %Activity{} = activity = Activity.get_create_by_object_ap_id(object.data["id"]) + + represented = StatusView.render("status.json", %{for: user, activity: activity}) + + assert represented[:id] == to_string(activity.id) + assert length(represented[:media_attachments]) == 1 + end + + describe "build_tags/1" do + test "it returns a a dictionary tags" do + object_tags = [ + "fediverse", + "mastodon", + "nextcloud", + %{ + "href" => "https://kawen.space/users/lain", + "name" => "@lain@kawen.space", + "type" => "Mention" + } + ] + + assert StatusView.build_tags(object_tags) == [ + %{name: "fediverse", url: "/tag/fediverse"}, + %{name: "mastodon", url: "/tag/mastodon"}, + %{name: "nextcloud", url: "/tag/nextcloud"} + ] + end + end + + describe "rich media cards" do + test "a rich media card without a site name renders correctly" do + page_url = "http://example.com" + + card = %{ + url: page_url, + image: page_url <> "/example.jpg", + title: "Example website" + } + + %{provider_name: "example.com"} = + StatusView.render("card.json", %{page_url: page_url, rich_media: card}) + end + + test "a rich media card without a site name or image renders correctly" do + page_url = "http://example.com" + + card = %{ + url: page_url, + title: "Example website" + } + + %{provider_name: "example.com"} = + StatusView.render("card.json", %{page_url: page_url, rich_media: card}) + end + + test "a rich media card without an image renders correctly" do + page_url = "http://example.com" + + card = %{ + url: page_url, + site_name: "Example site name", + title: "Example website" + } + + %{provider_name: "Example site name"} = + StatusView.render("card.json", %{page_url: page_url, rich_media: card}) + end + + test "a rich media card with all relevant data renders correctly" do + page_url = "http://example.com" + + card = %{ + url: page_url, + site_name: "Example site name", + title: "Example website", + image: page_url <> "/example.jpg", + description: "Example description" + } + + %{provider_name: "Example site name"} = + StatusView.render("card.json", %{page_url: page_url, rich_media: card}) + end + end end diff --combined test/web/ostatus/ostatus_test.exs index b5805c668,9fd100f63..50467c71f --- a/test/web/ostatus/ostatus_test.exs +++ b/test/web/ostatus/ostatus_test.exs @@@ -1,11 -1,24 +1,24 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2018 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.OStatusTest do use Pleroma.DataCase + alias Pleroma.Activity + alias Pleroma.Instances + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User alias Pleroma.Web.OStatus alias Pleroma.Web.XML - alias Pleroma.{Object, Repo, User, Activity} import Pleroma.Factory import ExUnit.CaptureLog + setup_all do + Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) + :ok + end + test "don't insert create notes twice" do incoming = File.read!("test/fixtures/incoming_note_activity.xml") {:ok, [activity]} = OStatus.handle_incoming(incoming) @@@ -15,35 -28,34 +28,35 @@@ test "handle incoming note - GS, Salmon" do incoming = File.read!("test/fixtures/incoming_note_activity.xml") {:ok, [activity]} = OStatus.handle_incoming(incoming) + object = Object.normalize(activity.data["object"]) user = User.get_by_ap_id(activity.data["actor"]) assert user.info.note_count == 1 assert activity.data["type"] == "Create" - assert activity.data["object"]["type"] == "Note" + assert object.data["type"] == "Note" - assert activity.data["object"]["id"] == - "tag:gs.example.org:4040,2017-04-23:noticeId=29:objectType=note" + assert object.data["id"] == "tag:gs.example.org:4040,2017-04-23:noticeId=29:objectType=note" assert activity.data["published"] == "2017-04-23T14:51:03+00:00" - assert activity.data["object"]["published"] == "2017-04-23T14:51:03+00:00" + assert object.data["published"] == "2017-04-23T14:51:03+00:00" assert activity.data["context"] == "tag:gs.example.org:4040,2017-04-23:objectType=thread:nonce=f09e22f58abd5c7b" assert "http://pleroma.example.org:4000/users/lain3" in activity.data["to"] - assert activity.data["object"]["emoji"] == %{"marko" => "marko.png", "reimu" => "reimu.png"} + assert object.data["emoji"] == %{"marko" => "marko.png", "reimu" => "reimu.png"} assert activity.local == false end test "handle incoming notes - GS, subscription" do incoming = File.read!("test/fixtures/ostatus_incoming_post.xml") {:ok, [activity]} = OStatus.handle_incoming(incoming) + object = Object.normalize(activity.data["object"]) assert activity.data["type"] == "Create" - assert activity.data["object"]["type"] == "Note" - assert activity.data["object"]["actor"] == "https://social.heldscal.la/user/23211" - assert activity.data["object"]["content"] == "Will it blend?" + assert object.data["type"] == "Note" + assert object.data["actor"] == "https://social.heldscal.la/user/23211" + assert object.data["content"] == "Will it blend?" user = User.get_cached_by_ap_id(activity.data["actor"]) assert User.ap_followers(user) in activity.data["to"] assert "https://www.w3.org/ns/activitystreams#Public" in activity.data["to"] @@@ -52,22 -64,20 +65,22 @@@ test "handle incoming notes with attachments - GS, subscription" do incoming = File.read!("test/fixtures/incoming_websub_gnusocial_attachments.xml") {:ok, [activity]} = OStatus.handle_incoming(incoming) + object = Object.normalize(activity.data["object"]) assert activity.data["type"] == "Create" - assert activity.data["object"]["type"] == "Note" - assert activity.data["object"]["actor"] == "https://social.heldscal.la/user/23211" - assert activity.data["object"]["attachment"] |> length == 2 - assert activity.data["object"]["external_url"] == "https://social.heldscal.la/notice/2020923" + assert object.data["type"] == "Note" + assert object.data["actor"] == "https://social.heldscal.la/user/23211" + assert object.data["attachment"] |> length == 2 + assert object.data["external_url"] == "https://social.heldscal.la/notice/2020923" assert "https://www.w3.org/ns/activitystreams#Public" in activity.data["to"] end test "handle incoming notes with tags" do incoming = File.read!("test/fixtures/ostatus_incoming_post_tag.xml") {:ok, [activity]} = OStatus.handle_incoming(incoming) + object = Object.normalize(activity.data["object"]) - assert activity.data["object"]["tag"] == ["nsfw"] + assert object.data["tag"] == ["nsfw"] assert "https://www.w3.org/ns/activitystreams#Public" in activity.data["to"] end @@@ -82,11 -92,10 +95,11 @@@ incoming = File.read!("test/fixtures/incoming_reply_mastodon.xml") {:ok, [activity]} = OStatus.handle_incoming(incoming) + object = Object.normalize(activity.data["object"]) assert activity.data["type"] == "Create" - assert activity.data["object"]["type"] == "Note" - assert activity.data["object"]["actor"] == "https://mastodon.social/users/lambadalambda" + assert object.data["type"] == "Note" + assert object.data["actor"] == "https://mastodon.social/users/lambadalambda" assert activity.data["context"] == "2hu" assert "https://www.w3.org/ns/activitystreams#Public" in activity.data["to"] end @@@ -94,47 -103,42 +107,47 @@@ test "handle incoming notes - Mastodon, with CW" do incoming = File.read!("test/fixtures/mastodon-note-cw.xml") {:ok, [activity]} = OStatus.handle_incoming(incoming) + object = Object.normalize(activity.data["object"]) assert activity.data["type"] == "Create" - assert activity.data["object"]["type"] == "Note" - assert activity.data["object"]["actor"] == "https://mastodon.social/users/lambadalambda" - assert activity.data["object"]["summary"] == "technologic" + assert object.data["type"] == "Note" + assert object.data["actor"] == "https://mastodon.social/users/lambadalambda" + assert object.data["summary"] == "technologic" assert "https://www.w3.org/ns/activitystreams#Public" in activity.data["to"] end test "handle incoming unlisted messages, put public into cc" do incoming = File.read!("test/fixtures/mastodon-note-unlisted.xml") {:ok, [activity]} = OStatus.handle_incoming(incoming) + object = Object.normalize(activity.data["object"]) + refute "https://www.w3.org/ns/activitystreams#Public" in activity.data["to"] assert "https://www.w3.org/ns/activitystreams#Public" in activity.data["cc"] - refute "https://www.w3.org/ns/activitystreams#Public" in activity.data["object"]["to"] - assert "https://www.w3.org/ns/activitystreams#Public" in activity.data["object"]["cc"] + refute "https://www.w3.org/ns/activitystreams#Public" in object.data["to"] + assert "https://www.w3.org/ns/activitystreams#Public" in object.data["cc"] end test "handle incoming retweets - Mastodon, with CW" do incoming = File.read!("test/fixtures/cw_retweet.xml") {:ok, [[_activity, retweeted_activity]]} = OStatus.handle_incoming(incoming) + retweeted_object = Object.normalize(retweeted_activity.data["object"]) - assert retweeted_activity.data["object"]["summary"] == "Hey." + assert retweeted_object.data["summary"] == "Hey." end test "handle incoming notes - GS, subscription, reply" do incoming = File.read!("test/fixtures/ostatus_incoming_reply.xml") {:ok, [activity]} = OStatus.handle_incoming(incoming) + object = Object.normalize(activity.data["object"]) assert activity.data["type"] == "Create" - assert activity.data["object"]["type"] == "Note" - assert activity.data["object"]["actor"] == "https://social.heldscal.la/user/23211" + assert object.data["type"] == "Note" + assert object.data["actor"] == "https://social.heldscal.la/user/23211" - assert activity.data["object"]["content"] == + assert object.data["content"] == "@shpbot why not indeed." - assert activity.data["object"]["inReplyTo"] == + assert object.data["inReplyTo"] == "tag:gs.archae.me,2017-04-30:noticeId=778260:objectType=note" assert "https://www.w3.org/ns/activitystreams#Public" in activity.data["to"] @@@ -146,19 -150,17 +159,18 @@@ assert activity.data["type"] == "Announce" assert activity.data["actor"] == "https://social.heldscal.la/user/23211" - assert activity.data["object"] == retweeted_activity.data["object"]["id"] + assert activity.data["object"] == retweeted_activity.data["object"] assert "https://pleroma.soykaf.com/users/lain" in activity.data["to"] refute activity.local - retweeted_activity = Repo.get(Activity, retweeted_activity.id) + retweeted_activity = Activity.get_by_id(retweeted_activity.id) + retweeted_object = Object.normalize(retweeted_activity.data["object"]) - assert retweeted_activity.data["type"] == "Create" assert retweeted_activity.data["actor"] == "https://pleroma.soykaf.com/users/lain" refute retweeted_activity.local - assert retweeted_activity.data["object"]["announcement_count"] == 1 - assert String.contains?(retweeted_activity.data["object"]["content"], "mastodon") - refute String.contains?(retweeted_activity.data["object"]["content"], "Test account") + assert retweeted_object.data["announcement_count"] == 1 + assert String.contains?(retweeted_object.data["content"], "mastodon") + refute String.contains?(retweeted_object.data["content"], "Test account") end test "handle incoming retweets - GS, subscription - local message" do @@@ -179,7 -181,7 +191,7 @@@ assert user.ap_id in activity.data["to"] refute activity.local - retweeted_activity = Repo.get(Activity, retweeted_activity.id) + retweeted_activity = Activity.get_by_id(retweeted_activity.id) assert note_activity.id == retweeted_activity.id assert retweeted_activity.data["type"] == "Create" assert retweeted_activity.data["actor"] == user.ap_id @@@ -190,11 -192,10 +202,11 @@@ test "handle incoming retweets - Mastodon, salmon" do incoming = File.read!("test/fixtures/share.xml") {:ok, [[activity, retweeted_activity]]} = OStatus.handle_incoming(incoming) + retweeted_object = Object.normalize(retweeted_activity.data["object"]) assert activity.data["type"] == "Announce" assert activity.data["actor"] == "https://mastodon.social/users/lambadalambda" - assert activity.data["object"] == retweeted_activity.data["object"]["id"] + assert activity.data["object"] == retweeted_activity.data["object"] assert activity.data["id"] == "tag:mastodon.social,2017-05-03:objectId=4934452:objectType=Status" @@@ -203,7 -204,7 +215,7 @@@ assert retweeted_activity.data["type"] == "Create" assert retweeted_activity.data["actor"] == "https://pleroma.soykaf.com/users/lain" refute retweeted_activity.local - refute String.contains?(retweeted_activity.data["object"]["content"], "Test account") + refute String.contains?(retweeted_object.data["content"], "Test account") end test "handle incoming favorites - GS, websub" do @@@ -213,7 -214,7 +225,7 @@@ assert activity.data["type"] == "Like" assert activity.data["actor"] == "https://social.heldscal.la/user/23211" - assert activity.data["object"] == favorited_activity.data["object"]["id"] + assert activity.data["object"] == favorited_activity.data["object"] assert activity.data["id"] == "tag:social.heldscal.la,2017-05-05:fave:23211:comment:2061643:2017-05-05T09:12:50+00:00" @@@ -222,7 -223,7 +234,7 @@@ assert favorited_activity.data["type"] == "Create" assert favorited_activity.data["actor"] == "https://shitposter.club/user/1" - assert favorited_activity.data["object"]["id"] == + assert favorited_activity.data["object"] == "tag:shitposter.club,2017-05-05:noticeId=2827873:objectType=comment" refute favorited_activity.local @@@ -257,17 -258,17 +269,17 @@@ test "handle incoming replies" do incoming = File.read!("test/fixtures/incoming_note_activity_answer.xml") {:ok, [activity]} = OStatus.handle_incoming(incoming) + object = Object.normalize(activity.data["object"]) assert activity.data["type"] == "Create" - assert activity.data["object"]["type"] == "Note" + assert object.data["type"] == "Note" - assert activity.data["object"]["inReplyTo"] == + assert object.data["inReplyTo"] == "http://pleroma.example.org:4000/objects/55bce8fc-b423-46b1-af71-3759ab4670bc" assert "http://pleroma.example.org:4000/users/lain5" in activity.data["to"] - assert activity.data["object"]["id"] == - "tag:gs.example.org:4040,2017-04-25:noticeId=55:objectType=note" + assert object.data["id"] == "tag:gs.example.org:4040,2017-04-25:noticeId=55:objectType=note" assert "https://www.w3.org/ns/activitystreams#Public" in activity.data["to"] end @@@ -314,6 -315,22 +326,22 @@@ refute User.following?(follower, followed) end + test "it clears `unreachable` federation status of the sender" do + incoming_reaction_xml = File.read!("test/fixtures/share-gs.xml") + doc = XML.parse_document(incoming_reaction_xml) + actor_uri = XML.string_from_xpath("//author/uri[1]", doc) + reacted_to_author_uri = XML.string_from_xpath("//author/uri[2]", doc) + + Instances.set_consistently_unreachable(actor_uri) + Instances.set_consistently_unreachable(reacted_to_author_uri) + refute Instances.reachable?(actor_uri) + refute Instances.reachable?(reacted_to_author_uri) + + {:ok, _} = OStatus.handle_incoming(incoming_reaction_xml) + assert Instances.reachable?(actor_uri) + refute Instances.reachable?(reacted_to_author_uri) + end + describe "new remote user creation" do test "returns local users" do local_user = insert(:user) @@@ -327,7 -344,7 +355,7 @@@ {:ok, user} = OStatus.find_or_make_user(uri) - user = Repo.get(Pleroma.User, user.id) + user = Pleroma.User.get_by_id(user.id) assert user.name == "Constance Variable" assert user.nickname == "lambadalambda@social.heldscal.la" assert user.local == false @@@ -478,7 -495,7 +506,7 @@@ assert activity.data["actor"] == "https://shitposter.club/user/1" - assert activity.data["object"]["id"] == + assert activity.data["object"] == "tag:shitposter.club,2017-05-05:noticeId=2827873:objectType=comment" end) end @@@ -487,7 -504,7 +515,7 @@@ url = "https://social.sakamoto.gq/objects/0ccc1a2c-66b0-4305-b23a-7f7f2b040056" {:ok, [activity]} = OStatus.fetch_activity_from_url(url) assert activity.data["actor"] == "https://social.sakamoto.gq/users/eal" - assert activity.data["object"]["id"] == url + assert activity.data["object"] == url end end @@@ -517,8 -534,10 +545,10 @@@ note_object.data |> Map.put("type", "Article") + Cachex.clear(:object_cache) + cs = Object.change(note_object, %{data: note_data}) - {:ok, article_object} = Repo.update(cs) + {:ok, _article_object} = Repo.update(cs) # the underlying object is now an Article instead of a note, so this should fail refute OStatus.is_representable?(note_activity) diff --combined test/web/twitter_api/twitter_api_test.exs index bc53fe68a,4c9ae2da8..5bea1037a --- a/test/web/twitter_api/twitter_api_test.exs +++ b/test/web/twitter_api/twitter_api_test.exs @@@ -1,16 -1,29 +1,29 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2018 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do use Pleroma.DataCase - alias Pleroma.Builders.UserBuilder - alias Pleroma.Web.TwitterAPI.{TwitterAPI, UserView} - alias Pleroma.{Activity, User, Object, Repo, UserInviteToken} + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.UserInviteToken alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.TwitterAPI.ActivityView + alias Pleroma.Web.TwitterAPI.TwitterAPI + alias Pleroma.Web.TwitterAPI.UserView import Pleroma.Factory + setup_all do + Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) + :ok + end + test "create a status" do user = insert(:user) - _mentioned_user = insert(:user, %{nickname: "shp", ap_id: "shp"}) + mentioned_user = insert(:user, %{nickname: "shp", ap_id: "shp"}) object_data = %{ "type" => "Image", @@@ -33,14 -46,13 +46,14 @@@ } {:ok, activity = %Activity{}} = TwitterAPI.create_status(user, input) + object = Object.normalize(activity.data["object"]) expected_text = - "Hello again, @shp.<script></script>
This is on another :moominmamma: line.
image.jpg" + "Hello again, @shp.<script></script>
This is on another :moominmamma: line.
image.jpg" - assert get_in(activity.data, ["object", "content"]) == expected_text - assert get_in(activity.data, ["object", "type"]) == "Note" - assert get_in(activity.data, ["object", "actor"]) == user.ap_id + assert get_in(object.data, ["content"]) == expected_text + assert get_in(object.data, ["type"]) == "Note" + assert get_in(object.data, ["actor"]) == user.ap_id assert get_in(activity.data, ["actor"]) == user.ap_id assert Enum.member?(get_in(activity.data, ["cc"]), User.ap_followers(user)) @@@ -53,18 -65,18 +66,18 @@@ assert activity.local == true assert %{"moominmamma" => "http://localhost:4001/finmoji/128px/moominmamma-128.png"} = - activity.data["object"]["emoji"] + object.data["emoji"] # hashtags - assert activity.data["object"]["tag"] == ["2hu", "epic", "phantasmagoric"] + assert object.data["tag"] == ["2hu", "epic", "phantasmagoric"] # Add a context assert is_binary(get_in(activity.data, ["context"])) - assert is_binary(get_in(activity.data, ["object", "context"])) + assert is_binary(get_in(object.data, ["context"])) - assert is_list(activity.data["object"]["attachment"]) + assert is_list(object.data["attachment"]) - assert activity.data["object"] == Object.get_by_ap_id(activity.data["object"]["id"]).data + assert activity.data["object"] == object.data["id"] user = User.get_by_ap_id(user.ap_id) @@@ -79,7 -91,6 +92,7 @@@ } {:ok, activity = %Activity{}} = TwitterAPI.create_status(user, input) + object = Object.normalize(activity.data["object"]) input = %{ "status" => "Here's your (you).", @@@ -87,14 -98,14 +100,14 @@@ } {:ok, reply = %Activity{}} = TwitterAPI.create_status(user, input) + reply_object = Object.normalize(reply.data["object"]) assert get_in(reply.data, ["context"]) == get_in(activity.data, ["context"]) - assert get_in(reply.data, ["object", "context"]) == - get_in(activity.data, ["object", "context"]) + assert get_in(reply_object.data, ["context"]) == get_in(object.data, ["context"]) - assert get_in(reply.data, ["object", "inReplyTo"]) == get_in(activity.data, ["object", "id"]) + assert get_in(reply_object.data, ["inReplyTo"]) == get_in(activity.data, ["object"]) - assert get_in(reply_object.data, ["inReplyToStatusId"]) == activity.id + assert Activity.get_in_reply_to_activity(reply).id == activity.id end test "Follow another user using user_id" do @@@ -184,25 -195,42 +197,42 @@@ end test "upload a file" do + user = insert(:user) + file = %Plug.Upload{ content_type: "image/jpg", path: Path.absname("test/fixtures/image.jpg"), filename: "an_image.jpg" } - response = TwitterAPI.upload(file) + response = TwitterAPI.upload(file, user) assert is_binary(response) end test "it favorites a status, returns the updated activity" do user = insert(:user) + other_user = insert(:user) note_activity = insert(:note_activity) {:ok, status} = TwitterAPI.fav(user, note_activity.id) updated_activity = Activity.get_by_ap_id(note_activity.data["id"]) + assert ActivityView.render("activity.json", %{activity: updated_activity})["fave_num"] == 1 + + object = Object.normalize(note_activity.data["object"]) + + assert object.data["like_count"] == 1 assert status == updated_activity + + {:ok, _status} = TwitterAPI.fav(other_user, note_activity.id) + + object = Object.normalize(note_activity.data["object"]) + + assert object.data["like_count"] == 2 + + updated_activity = Activity.get_by_ap_id(note_activity.data["id"]) + assert ActivityView.render("activity.json", %{activity: updated_activity})["fave_num"] == 2 end test "it unfavorites a status, returns the updated activity" do @@@ -246,19 -274,69 +276,69 @@@ "nickname" => "lain", "email" => "lain@wired.jp", "fullname" => "lain iwakura", - "bio" => "close the world.", "password" => "bear", "confirm" => "bear" } {:ok, user} = TwitterAPI.register_user(data) - fetched_user = Repo.get_by(User, nickname: "lain") + fetched_user = User.get_by_nickname("lain") assert UserView.render("show.json", %{user: user}) == UserView.render("show.json", %{user: fetched_user}) end + test "it registers a new user with empty string in bio and returns the user." do + data = %{ + "nickname" => "lain", + "email" => "lain@wired.jp", + "fullname" => "lain iwakura", + "bio" => "", + "password" => "bear", + "confirm" => "bear" + } + + {:ok, user} = TwitterAPI.register_user(data) + + fetched_user = User.get_by_nickname("lain") + + assert UserView.render("show.json", %{user: user}) == + UserView.render("show.json", %{user: fetched_user}) + end + + test "it sends confirmation email if :account_activation_required is specified in instance config" do + setting = Pleroma.Config.get([:instance, :account_activation_required]) + + unless setting do + Pleroma.Config.put([:instance, :account_activation_required], true) + on_exit(fn -> Pleroma.Config.put([:instance, :account_activation_required], setting) end) + end + + data = %{ + "nickname" => "lain", + "email" => "lain@wired.jp", + "fullname" => "lain iwakura", + "bio" => "", + "password" => "bear", + "confirm" => "bear" + } + + {:ok, user} = TwitterAPI.register_user(data) + + assert user.info.confirmation_pending + + email = Pleroma.Emails.UserEmail.account_confirmation_email(user) + + notify_email = Pleroma.Config.get([:instance, :notify_email]) + instance_name = Pleroma.Config.get([:instance, :name]) + + Swoosh.TestAssertions.assert_email_sent( + from: {instance_name, notify_email}, + to: {user.name, user.email}, + html_body: email.html_body + ) + end + test "it registers a new user and parses mentions in the bio" do data1 = %{ "nickname" => "john", @@@ -283,73 -361,318 +363,318 @@@ {:ok, user2} = TwitterAPI.register_user(data2) expected_text = - "@john test" + "@john test" assert user2.bio == expected_text end - @moduletag skip: "needs 'registrations_open: false' in config" - test "it registers a new user via invite token and returns the user." do - {:ok, token} = UserInviteToken.create_token() + describe "register with one time token" do + setup do + setting = Pleroma.Config.get([:instance, :registrations_open]) - data = %{ - "nickname" => "vinny", - "email" => "pasta@pizza.vs", - "fullname" => "Vinny Vinesauce", - "bio" => "streamer", - "password" => "hiptofbees", - "confirm" => "hiptofbees", - "token" => token.token - } + if setting do + Pleroma.Config.put([:instance, :registrations_open], false) + on_exit(fn -> Pleroma.Config.put([:instance, :registrations_open], setting) end) + end - {:ok, user} = TwitterAPI.register_user(data) + :ok + end - fetched_user = Repo.get_by(User, nickname: "vinny") - token = Repo.get_by(UserInviteToken, token: token.token) + test "returns user on success" do + {:ok, invite} = UserInviteToken.create_invite() - assert token.used == true + data = %{ + "nickname" => "vinny", + "email" => "pasta@pizza.vs", + "fullname" => "Vinny Vinesauce", + "bio" => "streamer", + "password" => "hiptofbees", + "confirm" => "hiptofbees", + "token" => invite.token + } - assert UserView.render("show.json", %{user: user}) == - UserView.render("show.json", %{user: fetched_user}) + {:ok, user} = TwitterAPI.register_user(data) + + fetched_user = User.get_by_nickname("vinny") + invite = Repo.get_by(UserInviteToken, token: invite.token) + + assert invite.used == true + + assert UserView.render("show.json", %{user: user}) == + UserView.render("show.json", %{user: fetched_user}) + end + + test "returns error on invalid token" do + data = %{ + "nickname" => "GrimReaper", + "email" => "death@reapers.afterlife", + "fullname" => "Reaper Grim", + "bio" => "Your time has come", + "password" => "scythe", + "confirm" => "scythe", + "token" => "DudeLetMeInImAFairy" + } + + {:error, msg} = TwitterAPI.register_user(data) + + assert msg == "Invalid token" + refute User.get_by_nickname("GrimReaper") + end + + test "returns error on expired token" do + {:ok, invite} = UserInviteToken.create_invite() + UserInviteToken.update_invite!(invite, used: true) + + data = %{ + "nickname" => "GrimReaper", + "email" => "death@reapers.afterlife", + "fullname" => "Reaper Grim", + "bio" => "Your time has come", + "password" => "scythe", + "confirm" => "scythe", + "token" => invite.token + } + + {:error, msg} = TwitterAPI.register_user(data) + + assert msg == "Expired token" + refute User.get_by_nickname("GrimReaper") + end end - @moduletag skip: "needs 'registrations_open: false' in config" - test "it returns an error if invalid token submitted" do - data = %{ - "nickname" => "GrimReaper", - "email" => "death@reapers.afterlife", - "fullname" => "Reaper Grim", - "bio" => "Your time has come", - "password" => "scythe", - "confirm" => "scythe", - "token" => "DudeLetMeInImAFairy" - } + describe "registers with date limited token" do + setup do + setting = Pleroma.Config.get([:instance, :registrations_open]) + + if setting do + Pleroma.Config.put([:instance, :registrations_open], false) + on_exit(fn -> Pleroma.Config.put([:instance, :registrations_open], setting) end) + end + + data = %{ + "nickname" => "vinny", + "email" => "pasta@pizza.vs", + "fullname" => "Vinny Vinesauce", + "bio" => "streamer", + "password" => "hiptofbees", + "confirm" => "hiptofbees" + } + + check_fn = fn invite -> + data = Map.put(data, "token", invite.token) + {:ok, user} = TwitterAPI.register_user(data) + fetched_user = User.get_by_nickname("vinny") + + assert UserView.render("show.json", %{user: user}) == + UserView.render("show.json", %{user: fetched_user}) + end + + {:ok, data: data, check_fn: check_fn} + end + + test "returns user on success", %{check_fn: check_fn} do + {:ok, invite} = UserInviteToken.create_invite(%{expires_at: Date.utc_today()}) + + check_fn.(invite) + + invite = Repo.get_by(UserInviteToken, token: invite.token) + + refute invite.used + end + + test "returns user on token which expired tomorrow", %{check_fn: check_fn} do + {:ok, invite} = UserInviteToken.create_invite(%{expires_at: Date.add(Date.utc_today(), 1)}) + + check_fn.(invite) + + invite = Repo.get_by(UserInviteToken, token: invite.token) + + refute invite.used + end + + test "returns an error on overdue date", %{data: data} do + {:ok, invite} = UserInviteToken.create_invite(%{expires_at: Date.add(Date.utc_today(), -1)}) - {:error, msg} = TwitterAPI.register_user(data) + data = Map.put(data, "token", invite.token) - assert msg == "Invalid token" - refute Repo.get_by(User, nickname: "GrimReaper") + {:error, msg} = TwitterAPI.register_user(data) + + assert msg == "Expired token" + refute User.get_by_nickname("vinny") + invite = Repo.get_by(UserInviteToken, token: invite.token) + + refute invite.used + end end - @moduletag skip: "needs 'registrations_open: false' in config" - test "it returns an error if expired token submitted" do - {:ok, token} = UserInviteToken.create_token() - UserInviteToken.mark_as_used(token.token) + describe "registers with reusable token" do + setup do + setting = Pleroma.Config.get([:instance, :registrations_open]) - data = %{ - "nickname" => "GrimReaper", - "email" => "death@reapers.afterlife", - "fullname" => "Reaper Grim", - "bio" => "Your time has come", - "password" => "scythe", - "confirm" => "scythe", - "token" => token.token - } + if setting do + Pleroma.Config.put([:instance, :registrations_open], false) + on_exit(fn -> Pleroma.Config.put([:instance, :registrations_open], setting) end) + end + + :ok + end + + test "returns user on success, after him registration fails" do + {:ok, invite} = UserInviteToken.create_invite(%{max_use: 100}) + + UserInviteToken.update_invite!(invite, uses: 99) + + data = %{ + "nickname" => "vinny", + "email" => "pasta@pizza.vs", + "fullname" => "Vinny Vinesauce", + "bio" => "streamer", + "password" => "hiptofbees", + "confirm" => "hiptofbees", + "token" => invite.token + } + + {:ok, user} = TwitterAPI.register_user(data) + fetched_user = User.get_by_nickname("vinny") + invite = Repo.get_by(UserInviteToken, token: invite.token) + + assert invite.used == true + + assert UserView.render("show.json", %{user: user}) == + UserView.render("show.json", %{user: fetched_user}) + + data = %{ + "nickname" => "GrimReaper", + "email" => "death@reapers.afterlife", + "fullname" => "Reaper Grim", + "bio" => "Your time has come", + "password" => "scythe", + "confirm" => "scythe", + "token" => invite.token + } + + {:error, msg} = TwitterAPI.register_user(data) - {:error, msg} = TwitterAPI.register_user(data) + assert msg == "Expired token" + refute User.get_by_nickname("GrimReaper") + end + end + + describe "registers with reusable date limited token" do + setup do + setting = Pleroma.Config.get([:instance, :registrations_open]) + + if setting do + Pleroma.Config.put([:instance, :registrations_open], false) + on_exit(fn -> Pleroma.Config.put([:instance, :registrations_open], setting) end) + end + + :ok + end + + test "returns user on success" do + {:ok, invite} = UserInviteToken.create_invite(%{expires_at: Date.utc_today(), max_use: 100}) + + data = %{ + "nickname" => "vinny", + "email" => "pasta@pizza.vs", + "fullname" => "Vinny Vinesauce", + "bio" => "streamer", + "password" => "hiptofbees", + "confirm" => "hiptofbees", + "token" => invite.token + } + + {:ok, user} = TwitterAPI.register_user(data) + fetched_user = User.get_by_nickname("vinny") + invite = Repo.get_by(UserInviteToken, token: invite.token) + + refute invite.used + + assert UserView.render("show.json", %{user: user}) == + UserView.render("show.json", %{user: fetched_user}) + end + + test "error after max uses" do + {:ok, invite} = UserInviteToken.create_invite(%{expires_at: Date.utc_today(), max_use: 100}) + + UserInviteToken.update_invite!(invite, uses: 99) + + data = %{ + "nickname" => "vinny", + "email" => "pasta@pizza.vs", + "fullname" => "Vinny Vinesauce", + "bio" => "streamer", + "password" => "hiptofbees", + "confirm" => "hiptofbees", + "token" => invite.token + } + + {:ok, user} = TwitterAPI.register_user(data) + fetched_user = User.get_by_nickname("vinny") + invite = Repo.get_by(UserInviteToken, token: invite.token) + assert invite.used == true + + assert UserView.render("show.json", %{user: user}) == + UserView.render("show.json", %{user: fetched_user}) + + data = %{ + "nickname" => "GrimReaper", + "email" => "death@reapers.afterlife", + "fullname" => "Reaper Grim", + "bio" => "Your time has come", + "password" => "scythe", + "confirm" => "scythe", + "token" => invite.token + } + + {:error, msg} = TwitterAPI.register_user(data) + + assert msg == "Expired token" + refute User.get_by_nickname("GrimReaper") + end + + test "returns error on overdue date" do + {:ok, invite} = + UserInviteToken.create_invite(%{expires_at: Date.add(Date.utc_today(), -1), max_use: 100}) + + data = %{ + "nickname" => "GrimReaper", + "email" => "death@reapers.afterlife", + "fullname" => "Reaper Grim", + "bio" => "Your time has come", + "password" => "scythe", + "confirm" => "scythe", + "token" => invite.token + } - assert msg == "Expired token" - refute Repo.get_by(User, nickname: "GrimReaper") + {:error, msg} = TwitterAPI.register_user(data) + + assert msg == "Expired token" + refute User.get_by_nickname("GrimReaper") + end + + test "returns error on with overdue date and after max" do + {:ok, invite} = + UserInviteToken.create_invite(%{expires_at: Date.add(Date.utc_today(), -1), max_use: 100}) + + UserInviteToken.update_invite!(invite, uses: 100) + + data = %{ + "nickname" => "GrimReaper", + "email" => "death@reapers.afterlife", + "fullname" => "Reaper Grim", + "bio" => "Your time has come", + "password" => "scythe", + "confirm" => "scythe", + "token" => invite.token + } + + {:error, msg} = TwitterAPI.register_user(data) + + assert msg == "Expired token" + refute User.get_by_nickname("GrimReaper") + end end test "it returns the error on registration problems" do @@@ -364,7 -687,7 +689,7 @@@ {:error, error_object} = TwitterAPI.register_user(data) assert is_binary(error_object[:error]) - refute Repo.get_by(User, nickname: "lain") + refute User.get_by_nickname("lain") end test "it assigns an integer conversation_id" do @@@ -380,22 -703,6 +705,6 @@@ :ok end - describe "context_to_conversation_id" do - test "creates a mapping object" do - conversation_id = TwitterAPI.context_to_conversation_id("random context") - object = Object.get_by_ap_id("random context") - - assert conversation_id == object.id - end - - test "returns an existing mapping for an existing object" do - {:ok, object} = Object.context_mapping("random context") |> Repo.insert() - conversation_id = TwitterAPI.context_to_conversation_id("random context") - - assert conversation_id == object.id - end - end - describe "fetching a user by uri" do test "fetches a user by uri" do id = "https://mastodon.social/users/lambadalambda" @@@ -406,7 -713,8 +715,8 @@@ assert represented["id"] == UserView.render("show.json", %{user: remote, for: user})["id"] # Also fetches the feed. - # assert Activity.get_create_activity_by_object_ap_id("tag:mastodon.social,2017-04-05:objectId=1641750:objectType=Status") + # assert Activity.get_create_by_object_ap_id("tag:mastodon.social,2017-04-05:objectId=1641750:objectType=Status") + # credo:disable-for-previous-line Credo.Check.Readability.MaxLineLength end end end diff --combined test/web/twitter_api/views/activity_view_test.exs index f4741cf24,ee9a0c834..7ef0270cc --- a/test/web/twitter_api/views/activity_view_test.exs +++ b/test/web/twitter_api/views/activity_view_test.exs @@@ -1,29 -1,134 +1,136 @@@ + # Pleroma: A lightweight social networking server + # Copyright © 2017-2019 Pleroma Authors + # SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.TwitterAPI.ActivityViewTest do use Pleroma.DataCase + alias Pleroma.Activity ++ alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.TwitterAPI.ActivityView alias Pleroma.Web.TwitterAPI.UserView - alias Pleroma.Web.TwitterAPI.TwitterAPI - alias Pleroma.Repo - alias Pleroma.{Activity, Object} - alias Pleroma.User - alias Pleroma.Web.ActivityPub.ActivityPub import Pleroma.Factory + import Tesla.Mock + + setup do + mock(fn env -> apply(HttpRequestMock, :request, [env]) end) + :ok + end + import Mock + test "returns a temporary ap_id based user for activities missing db users" do + user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "Hey @shp!", "visibility" => "direct"}) + + Repo.delete(user) + Cachex.clear(:user_cache) + + %{"user" => tw_user} = ActivityView.render("activity.json", activity: activity) + + assert tw_user["screen_name"] == "erroruser@example.com" + assert tw_user["name"] == user.ap_id + assert tw_user["statusnet_profile_url"] == user.ap_id + end + + test "tries to get a user by nickname if fetching by ap_id doesn't work" do + user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "Hey @shp!", "visibility" => "direct"}) + + {:ok, user} = + user + |> Ecto.Changeset.change(%{ap_id: "#{user.ap_id}/extension/#{user.nickname}"}) + |> Repo.update() + + Cachex.clear(:user_cache) + + result = ActivityView.render("activity.json", activity: activity) + assert result["user"]["id"] == user.id + end + + test "tells if the message is muted for some reason" do + user = insert(:user) + other_user = insert(:user) + + {:ok, user} = User.mute(user, other_user) + + {:ok, activity} = CommonAPI.post(other_user, %{"status" => "test"}) + status = ActivityView.render("activity.json", %{activity: activity}) + + assert status["muted"] == false + + status = ActivityView.render("activity.json", %{activity: activity, for: user}) + + assert status["muted"] == true + end + + test "a create activity with a html status" do + text = """ + #Bike log - Commute Tuesday\nhttps://pla.bike/posts/20181211/\n#cycling #CHScycling #commute\nMVIMG_20181211_054020.jpg + """ + + {:ok, activity} = CommonAPI.post(insert(:user), %{"status" => text}) + + result = ActivityView.render("activity.json", activity: activity) + + assert result["statusnet_html"] == + "#Bike log - Commute Tuesday
https://pla.bike/posts/20181211/
#cycling #CHScycling #commute
MVIMG_20181211_054020.jpg" + + assert result["text"] == + "#Bike log - Commute Tuesday\nhttps://pla.bike/posts/20181211/\n#cycling #CHScycling #commute\nMVIMG_20181211_054020.jpg" + end + + test "a create activity with a summary containing emoji" do + {:ok, activity} = + CommonAPI.post(insert(:user), %{ + "spoiler_text" => ":woollysocks: meow", + "status" => "." + }) + + result = ActivityView.render("activity.json", activity: activity) + + expected = ":woollysocks: meow" + + expected_html = + "\"woollysocks\" meow" + + assert result["summary"] == expected + assert result["summary_html"] == expected_html + end + + test "a create activity with a summary containing invalid HTML" do + {:ok, activity} = + CommonAPI.post(insert(:user), %{ + "spoiler_text" => "meow", + "status" => "." + }) + + result = ActivityView.render("activity.json", activity: activity) + + expected = "meow" + + assert result["summary"] == expected + assert result["summary_html"] == expected + end + test "a create activity with a note" do user = insert(:user) other_user = insert(:user, %{nickname: "shp"}) {:ok, activity} = CommonAPI.post(user, %{"status" => "Hey @shp!", "visibility" => "direct"}) + object = Object.normalize(activity.data["object"]) result = ActivityView.render("activity.json", activity: activity) - convo_id = TwitterAPI.context_to_conversation_id(object.data["context"]) - convo_id = Utils.context_to_conversation_id(activity.data["object"]["context"]) ++ convo_id = Utils.context_to_conversation_id(object.data["context"]) expected = %{ "activity_type" => "post", @@@ -31,8 -136,8 +138,8 @@@ "attentions" => [ UserView.render("show.json", %{user: other_user}) ], - "created_at" => activity.data["object"]["published"] |> Utils.date_to_asctime(), - "external_url" => activity.data["object"]["id"], + "created_at" => object.data["published"] |> Utils.date_to_asctime(), + "external_url" => object.data["id"], "fave_num" => 0, "favorited" => false, "id" => activity.id, @@@ -46,15 -151,21 +153,21 @@@ "possibly_sensitive" => false, "repeat_num" => 0, "repeated" => false, + "pinned" => false, "statusnet_conversation_id" => convo_id, + "summary" => "", + "summary_html" => "", "statusnet_html" => - "Hey @shp!", + "Hey @shp!", "tags" => [], "text" => "Hey @shp!", - "uri" => activity.data["object"]["id"], + "uri" => object.data["id"], "user" => UserView.render("show.json", %{user: user}), "visibility" => "direct", - "summary" => nil + "card" => nil, + "muted" => false } assert result == expected @@@ -64,14 -175,13 +177,14 @@@ user = insert(:user) other_user = insert(:user, %{nickname: "shp"}) {:ok, activity} = CommonAPI.post(user, %{"status" => "Hey @shp!"}) + object = Object.normalize(activity.data["object"]) - convo_id = TwitterAPI.context_to_conversation_id(object.data["context"]) - convo_id = Utils.context_to_conversation_id(activity.data["object"]["context"]) ++ convo_id = Utils.context_to_conversation_id(object.data["context"]) mocks = [ { - TwitterAPI, - [], + Utils, + [:passthrough], [context_to_conversation_id: fn _ -> false end] }, { @@@ -86,7 -196,7 +199,7 @@@ assert result["statusnet_conversation_id"] == convo_id assert result["user"] - refute called(TwitterAPI.context_to_conversation_id(:_)) + refute called(Utils.context_to_conversation_id(:_)) refute called(User.get_cached_by_ap_id(user.ap_id)) refute called(User.get_cached_by_ap_id(other_user.ap_id)) end @@@ -114,6 -224,7 +227,7 @@@ {:ok, like, _object} = CommonAPI.favorite(activity.id, other_user) result = ActivityView.render("activity.json", activity: like) + activity = Pleroma.Activity.get_by_ap_id(activity.data["id"]) expected = %{ "activity_type" => "like", @@@ -123,6 -234,7 +237,7 @@@ "in_reply_to_status_id" => activity.id, "is_local" => true, "is_post_verb" => false, + "favorited_status" => ActivityView.render("activity.json", activity: activity), "statusnet_html" => "shp favorited a status.", "text" => "shp favorited a status.", "uri" => "tag:#{like.data["id"]}:objectType=Favourite", @@@ -150,6 -262,7 +265,7 @@@ "in_reply_to_status_id" => nil, "is_local" => true, "is_post_verb" => false, + "favorited_status" => nil, "statusnet_html" => "shp favorited a status.", "text" => "shp favorited a status.", "uri" => "tag:#{like.data["id"]}:objectType=Favourite", @@@ -164,11 -277,11 +280,11 @@@ other_user = insert(:user, %{nickname: "shp"}) {:ok, activity} = CommonAPI.post(user, %{"status" => "Hey @shp!"}) - {:ok, announce, _object} = CommonAPI.repeat(activity.id, other_user) + {:ok, announce, object} = CommonAPI.repeat(activity.id, other_user) - convo_id = TwitterAPI.context_to_conversation_id(object.data["context"]) - convo_id = Utils.context_to_conversation_id(activity.data["object"]["context"]) ++ convo_id = Utils.context_to_conversation_id(object.data["context"]) - activity = Repo.get(Activity, activity.id) + activity = Activity.get_by_id(activity.id) result = ActivityView.render("activity.json", activity: announce) @@@ -241,4 -354,18 +357,18 @@@ assert result == expected end + + test "a peertube video" do + {:ok, object} = + ActivityPub.fetch_object_from_id( + "https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3" + ) + + %Activity{} = activity = Activity.get_create_by_object_ap_id(object.data["id"]) + + result = ActivityView.render("activity.json", activity: activity) + + assert length(result["attachments"]) == 1 + assert result["summary"] == "Friday Night" + end end