# Pleroma: A lightweight social networking server # Copyright © 2017-2022 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.StatusView do use Pleroma.Web, :view require Pleroma.Constants alias Pleroma.Activity alias Pleroma.HTML alias Pleroma.Maps alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User alias Pleroma.UserRelationship alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.PollView alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MediaProxy alias Pleroma.Web.PleromaAPI.EmojiReactionController import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1, visible_for_user?: 2] # This is a naive way to do this, just spawning a process per activity # to fetch the preview. However it should be fine considering # pagination is restricted to 40 activities at a time defp fetch_rich_media_for_activities(activities) do Enum.each(activities, fn activity -> spawn(fn -> Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) end) end) end # TODO: Add cached version. defp get_replied_to_activities([]), do: %{} defp get_replied_to_activities(activities) do activities |> Enum.map(fn %{data: %{"type" => "Create"}} = activity -> object = Object.normalize(activity, fetch: false) object && object.data["inReplyTo"] != "" && object.data["inReplyTo"] _ -> nil end) |> Enum.filter(& &1) |> Activity.create_by_object_ap_id_with_object() |> Repo.all() |> Enum.reduce(%{}, fn activity, acc -> object = Object.normalize(activity, fetch: false) if object, do: Map.put(acc, object.data["id"], activity), else: acc end) end defp get_quoted_activities([]), do: %{} defp get_quoted_activities(activities) do activities |> Enum.map(fn %{data: %{"type" => "Create"}} = activity -> object = Object.normalize(activity, fetch: false) object && object.data["quoteUrl"] != "" && object.data["quoteUrl"] _ -> nil end) |> Enum.filter(& &1) |> Activity.create_by_object_ap_id_with_object() |> Repo.all() |> Enum.reduce(%{}, fn activity, acc -> object = Object.normalize(activity, fetch: false) if object, do: Map.put(acc, object.data["id"], activity), else: acc end) end # DEPRECATED This field seems to be a left-over from the StatusNet era. # If your application uses `pleroma.conversation_id`: this field is deprecated. # It is currently stubbed instead by doing a CRC32 of the context, and # clearing the MSB to avoid overflow exceptions with signed integers on the # different clients using this field (Java/Kotlin code, mostly; see Husky.) # This should be removed in a future version of Pleroma. Pleroma-FE currently # depends on this field, as well. defp get_context_id(%{data: %{"context" => context}}) when is_binary(context) do import Bitwise :erlang.crc32(context) |> band(bnot(0x8000_0000)) end defp get_context_id(_), do: nil # Check if the user reblogged this status defp reblogged?(activity, %User{ap_id: ap_id}) do with %Object{data: %{"announcements" => announcements}} when is_list(announcements) <- Object.normalize(activity, fetch: false) do ap_id in announcements else _ -> false end end # False if the user is logged out defp reblogged?(_activity, _user), do: false def render("index.json", opts) do reading_user = opts[:for] # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list activities = Enum.filter(opts.activities, & &1) # Start fetching rich media before doing anything else, so that later calls to get the cards # only block for timeout in the worst case, as opposed to # length(activities_with_links) * timeout fetch_rich_media_for_activities(activities) replied_to_activities = get_replied_to_activities(activities) quoted_activities = get_quoted_activities(activities) parent_activities = activities |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"])) |> Enum.map(&Object.normalize(&1, fetch: false).data["id"]) |> Activity.create_by_object_ap_id() |> Activity.with_preloaded_object(:left) |> Activity.with_preloaded_bookmark(reading_user) |> Activity.with_set_thread_muted_field(reading_user) |> Repo.all() relationships_opt = cond do Map.has_key?(opts, :relationships) -> opts[:relationships] is_nil(reading_user) -> UserRelationship.view_relationships_option(nil, []) true -> # Note: unresolved users are filtered out actors = (activities ++ parent_activities) |> Enum.map(&CommonAPI.get_user(&1.data["actor"], false)) |> Enum.filter(& &1) UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes) end opts = opts |> Map.put(:replied_to_activities, replied_to_activities) |> Map.put(:quoted_activities, quoted_activities) |> Map.put(:parent_activities, parent_activities) |> Map.put(:relationships, relationships_opt) safe_render_many(activities, StatusView, "show.json", opts) end def render( "show.json", %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts ) do user = CommonAPI.get_user(activity.data["actor"]) created_at = Utils.to_masto_date(activity.data["published"]) object = Object.normalize(activity, fetch: false) reblogged_parent_activity = if opts[:parent_activities] do Activity.Queries.find_by_object_ap_id( opts[:parent_activities], object.data["id"] ) else Activity.create_by_object_ap_id(object.data["id"]) |> Activity.with_preloaded_bookmark(opts[:for]) |> Activity.with_set_thread_muted_field(opts[:for]) |> Repo.one() end reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity) reblogged = render("show.json", reblog_rendering_opts) favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || []) bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil mentions = activity.recipients |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end) |> Enum.filter(& &1) |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end) {pinned?, pinned_at} = pin_data(object, user) %{ id: to_string(activity.id), uri: object.data["id"], url: object.data["id"], account: AccountView.render("show.json", %{ user: user, for: opts[:for] }), in_reply_to_id: nil, in_reply_to_account_id: nil, reblog: reblogged, content: reblogged[:content] || "", created_at: created_at, reblogs_count: 0, replies_count: 0, favourites_count: 0, reblogged: reblogged?(reblogged_parent_activity, opts[:for]), favourited: present?(favorited), bookmarked: present?(bookmarked), muted: false, pinned: pinned?, sensitive: false, spoiler_text: "", visibility: get_visibility(activity), media_attachments: reblogged[:media_attachments] || [], mentions: mentions, tags: reblogged[:tags] || [], application: build_application(object.data["generator"]), language: nil, emojis: [], pleroma: %{ local: activity.local, pinned_at: pinned_at } } end def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do object = Object.normalize(activity, fetch: false) user = CommonAPI.get_user(activity.data["actor"]) user_follower_address = user.follower_address like_count = object.data["like_count"] || 0 announcement_count = object.data["announcement_count"] || 0 hashtags = Object.hashtags(object) sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw") tags = Object.tags(object) tag_mentions = tags |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end) |> Enum.map(fn tag -> tag["href"] end) mentions = (object.data["to"] ++ tag_mentions) |> Enum.uniq() |> Enum.map(fn Pleroma.Constants.as_public() -> nil ^user_follower_address -> nil ap_id -> User.get_cached_by_ap_id(ap_id) end) |> Enum.filter(& &1) |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end) favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || []) bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil client_posted_this_activity = opts[:for] && user.id == opts[:for].id expires_at = with true <- client_posted_this_activity, %Oban.Job{scheduled_at: scheduled_at} <- Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do scheduled_at else _ -> nil end thread_muted? = cond do is_nil(opts[:for]) -> false is_boolean(activity.thread_muted?) -> activity.thread_muted? true -> CommonAPI.thread_muted?(opts[:for], activity) end attachment_data = object.data["attachment"] || [] attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment) created_at = Utils.to_masto_date(object.data["published"]) edited_at = with %{"updated" => updated} <- object.data, date <- Utils.to_masto_date(updated), true <- date != "" do date else _ -> nil end reply_to = get_reply_to(activity, opts) reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"]) history_len = 1 + (Object.Updater.history_for(object.data) |> Map.get("orderedItems") |> length()) # See render("history.json", ...) for more details # Here the implicit index of the current content is 0 chrono_order = history_len - 1 quote_activity = get_quote(activity, opts) quote_post = if visible_for_user?(quote_activity, opts[:for]) do quote_rendering_opts = Map.merge(opts, %{activity: quote_activity, show_quote: false}) render("show.json", quote_rendering_opts) else nil end content = object |> render_content() content_html = content |> Activity.HTML.get_cached_scrubbed_html_for_activity( User.html_filter_policy(opts[:for]), activity, "mastoapi:content:#{chrono_order}" ) content_plaintext = content |> Activity.HTML.get_cached_stripped_html_for_activity( activity, "mastoapi:content:#{chrono_order}" ) summary = object.data["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.data["url"] || object.data["external_url"] || object.data["id"] end direct_conversation_id = with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]}, {_, true} <- {:include_id, opts[:with_direct_conversation_id]}, {_, %User{} = for_user} <- {:for_user, opts[:for]} do Activity.direct_conversation_id(activity, for_user) else {:direct_conversation_id, participation_id} when is_integer(participation_id) -> participation_id _e -> nil end emoji_reactions = object |> Object.get_emoji_reactions() |> EmojiReactionController.filter_allowed_users( opts[:for], Map.get(opts, :with_muted, false) ) |> Stream.map(fn {emoji, users, url} -> build_emoji_map(emoji, users, url, opts[:for]) end) |> Enum.to_list() # Status muted state (would do 1 request per status unless user mutes are preloaded) muted = thread_muted? || UserRelationship.exists?( get_in(opts, [:relationships, :user_relationships]), :mute, opts[:for], user, fn for_user, user -> User.mutes?(for_user, user) end ) {pinned?, pinned_at} = pin_data(object, user) %{ id: to_string(activity.id), uri: object.data["id"], url: url, account: AccountView.render("show.json", %{ user: user, for: opts[:for] }), 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, card: card, content: content_html, text: opts[:with_source] && get_source_text(object.data["source"]), created_at: created_at, edited_at: edited_at, reblogs_count: announcement_count, replies_count: object.data["repliesCount"] || 0, favourites_count: like_count, reblogged: reblogged?(activity, opts[:for]), favourited: present?(favorited), bookmarked: present?(bookmarked), muted: muted, pinned: pinned?, sensitive: sensitive, spoiler_text: summary, visibility: get_visibility(object), media_attachments: attachments, poll: render(PollView, "show.json", object: object, for: opts[:for]), mentions: mentions, tags: build_tags(tags), application: build_application(object.data["generator"]), language: nil, emojis: build_emojis(object.data["emoji"]), pleroma: %{ local: activity.local, conversation_id: get_context_id(activity), context: object.data["context"], in_reply_to_account_acct: reply_to_user && reply_to_user.nickname, quote: quote_post, quote_url: object.data["quoteUrl"], content: %{"text/plain" => content_plaintext}, spoiler_text: %{"text/plain" => summary}, expires_at: expires_at, direct_conversation_id: direct_conversation_id, thread_muted: thread_muted?, emoji_reactions: emoji_reactions, parent_visible: visible_for_user?(reply_to, opts[:for]), pinned_at: pinned_at } } end def render("show.json", _) do nil end def render("history.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do object = Object.normalize(activity, fetch: false) hashtags = Object.hashtags(object) user = CommonAPI.get_user(activity.data["actor"]) past_history = Object.Updater.history_for(object.data) |> Map.get("orderedItems") |> Enum.map(&Map.put(&1, "id", object.data["id"])) |> Enum.map(&%Object{data: &1, id: object.id}) history = [object | past_history] # Mastodon expects the original to be at the first |> Enum.reverse() |> Enum.with_index() |> Enum.map(fn {object, chrono_order} -> %{ # The history is prepended every time there is a new edit. # In chrono_order, the oldest item is always at 0, and so on. # The chrono_order is an invariant kept between edits. chrono_order: chrono_order, object: object } end) individual_opts = opts |> Map.put(:as, :item) |> Map.put(:user, user) |> Map.put(:hashtags, hashtags) render_many(history, StatusView, "history_item.json", individual_opts) end def render( "history_item.json", %{ activity: activity, user: user, item: %{object: object, chrono_order: chrono_order}, hashtags: hashtags } = opts ) do sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw") attachment_data = object.data["attachment"] || [] attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment) created_at = Utils.to_masto_date(object.data["updated"] || object.data["published"]) content = object |> render_content() content_html = content |> Activity.HTML.get_cached_scrubbed_html_for_activity( User.html_filter_policy(opts[:for]), activity, "mastoapi:content:#{chrono_order}" ) summary = object.data["summary"] || "" %{ account: AccountView.render("show.json", %{ user: user, for: opts[:for] }), content: content_html, sensitive: sensitive, spoiler_text: summary, created_at: created_at, media_attachments: attachments, emojis: build_emojis(object.data["emoji"]), poll: render(PollView, "show.json", object: object, for: opts[:for]) } end def render("source.json", %{activity: %{data: %{"object" => _object}} = activity} = _opts) do object = Object.normalize(activity, fetch: false) %{ id: activity.id, text: get_source_text(Map.get(object.data, "source", "")), spoiler_text: Map.get(object.data, "summary", ""), content_type: get_source_content_type(object.data["source"]) } 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 is_binary(rich_media["url"]) 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_data = if is_binary(rich_media["image"]) do URI.parse(rich_media["image"]) else nil end image_url = build_image_url(image_url_data, page_url_data) %{ type: "link", provider_name: page_url_data.host, 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 def render("attachment.json", %{attachment: attachment}) do [attachment_url | _] = attachment["url"] media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image" href = attachment_url["href"] |> MediaProxy.url() href_preview = attachment_url["href"] |> MediaProxy.preview_url() meta = render("attachment_meta.json", %{attachment: attachment}) type = cond do String.contains?(media_type, "image") -> "image" String.contains?(media_type, "video") -> "video" String.contains?(media_type, "audio") -> "audio" true -> "unknown" end attachment_id = with {_, ap_id} when is_binary(ap_id) <- {:ap_id, attachment["id"]}, {_, %Object{data: _object_data, id: object_id}} <- {:object, Object.get_by_ap_id(ap_id)} do to_string(object_id) else _ -> <> = :crypto.hash(:md5, href) to_string(attachment["id"] || hash_id) end %{ id: attachment_id, url: href, remote_url: href, preview_url: href_preview, text_url: href, type: type, description: attachment["name"], pleroma: %{mime_type: media_type}, blurhash: attachment["blurhash"] } |> Maps.put_if_present(:meta, meta) end def render("attachment_meta.json", %{ attachment: %{"url" => [%{"width" => width, "height" => height} | _]} }) when is_integer(width) and is_integer(height) do %{ original: %{ width: width, height: height, aspect: width / height } } end def render("attachment_meta.json", _), do: nil def render("context.json", %{activity: activity, activities: activities, user: user}) do %{ancestors: ancestors, descendants: descendants} = activities |> Enum.reverse() |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end) |> Map.put_new(:ancestors, []) |> Map.put_new(:descendants, []) %{ ancestors: render("index.json", for: user, activities: ancestors, as: :activity), descendants: render("index.json", for: user, activities: descendants, as: :activity) } end def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do object = Object.normalize(activity, fetch: false) 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}} = activity, _) do object = Object.normalize(activity, fetch: false) if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do Activity.get_create_by_object_ap_id(object.data["inReplyTo"]) else nil end end def get_quote(_activity, %{show_quote: false}) do nil end def get_quote(activity, %{quoted_activities: quoted_activities}) do object = Object.normalize(activity, fetch: false) quoted_activities[object.data["quoteUrl"]] end def get_quote(%{data: %{"object" => _object}} = activity, _) do object = Object.normalize(activity, fetch: false) if object.data["quoteUrl"] && object.data["quoteUrl"] != "" do Activity.get_create_by_object_ap_id(object.data["quoteUrl"]) else nil end end def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do url = object.data["url"] || object.data["id"] "

#{name}

#{object.data["content"]}" end 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 |> Enum.filter(&is_binary/1) |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"}) end def build_tags(_), do: [] @doc """ Builds list emojis. 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 defp present?(nil), do: false defp present?(false), do: false defp present?(_), do: true defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do if pinned_at = pinned_objects[object_id] do {true, Utils.to_masto_date(pinned_at)} else {false, nil} end end defp build_emoji_map(emoji, users, url, current_user) do %{ name: Pleroma.Web.PleromaAPI.EmojiReactionView.emoji_name(emoji, url), count: length(users), url: MediaProxy.url(url), me: !!(current_user && current_user.ap_id in users), account_ids: Enum.map(users, fn user -> User.get_cached_by_ap_id(user).id end) } end @spec build_application(map() | nil) :: map() | nil defp build_application(%{"type" => _type, "name" => name, "url" => url}), do: %{name: name, website: url} defp build_application(_), do: nil # Workaround for Elixir issue #10771 # Avoid applying URI.merge unless necessary # TODO: revert to always attempting URI.merge(image_url_data, page_url_data) # when Elixir 1.12 is the minimum supported version @spec build_image_url(struct() | nil, struct()) :: String.t() | nil defp build_image_url( %URI{scheme: image_scheme, host: image_host} = image_url_data, %URI{} = _page_url_data ) when not is_nil(image_scheme) and not is_nil(image_host) do image_url_data |> to_string end defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do URI.merge(page_url_data, image_url_data) |> to_string end defp build_image_url(_, _), do: nil defp get_source_text(%{"content" => content} = _source) do content end defp get_source_text(source) when is_binary(source) do source end defp get_source_text(_) do "" end defp get_source_content_type(%{"mediaType" => type} = _source) do type end defp get_source_content_type(_source) do Utils.get_content_type(nil) end end