# 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 require Pleroma.Constants alias Pleroma.Activity alias Pleroma.ActivityExpiration alias Pleroma.Conversation alias Pleroma.Conversation.Participation 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.AccountView alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MediaProxy import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1] # 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) 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) if object, do: Map.put(acc, object.data["id"], activity), else: acc 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 = 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) parallel = unless is_nil(opts[:parallel]), do: opts[:parallel], else: true opts.activities |> safe_render_many( StatusView, "status.json", Map.put(opts, :replied_to_activities, replied_to_activities), parallel ) end def render( "status.json", %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts ) do user = get_user(activity.data["actor"]) created_at = Utils.to_masto_date(activity.data["published"]) activity_object = Object.normalize(activity) reblogged_activity = Activity.create_by_object_ap_id(activity_object.data["id"]) |> Activity.with_preloaded_bookmark(opts[:for]) |> Activity.with_set_thread_muted_field(opts[:for]) |> Repo.one() reblogged = render("status.json", Map.put(opts, :activity, reblogged_activity)) favorited = opts[:for] && opts[:for].ap_id in (activity_object.data["likes"] || []) bookmarked = Activity.get_bookmark(reblogged_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) %{ id: to_string(activity.id), uri: activity_object.data["id"], url: activity_object.data["id"], account: AccountView.render("account.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_activity, opts[:for]), favourited: present?(favorited), bookmarked: present?(bookmarked), muted: false, pinned: pinned?(activity, user), sensitive: false, spoiler_text: "", visibility: "public", media_attachments: reblogged[:media_attachments] || [], mentions: mentions, tags: reblogged[:tags] || [], application: %{ name: "Web", website: nil }, language: nil, emojis: [], pleroma: %{ local: activity.local } } end def render("status.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do object = Object.normalize(activity) user = 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 tags = object.data["tag"] || [] sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw") 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, expiration when not is_nil(expiration) <- ActivityExpiration.get_by_activity_id(activity.id) do expiration.scheduled_at end thread_muted? = case activity.thread_muted? do thread_muted? when is_boolean(thread_muted?) -> thread_muted? nil -> (opts[:for] && CommonAPI.thread_muted?(opts[:for], activity)) || false 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"]) reply_to = get_reply_to(activity, opts) reply_to_user = reply_to && get_user(reply_to.data["actor"]) content = 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.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.data["url"] || object.data["external_url"] || object.data["id"] end direct_conversation_id = with {_, true} <- {:include_id, opts[:with_direct_conversation_id]}, {_, %User{} = for_user} <- {:for_user, opts[:for]}, %{data: %{"context" => context}} when is_binary(context) <- activity, %Conversation{} = conversation <- Conversation.get_for_ap_id(context), %Participation{id: participation_id} <- Participation.for_user_and_conversation(for_user, conversation) do participation_id else _e -> nil end %{ id: to_string(activity.id), uri: object.data["id"], url: url, account: AccountView.render("account.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, created_at: created_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: thread_muted? || User.mutes?(opts[:for], user), pinned: pinned?(activity, user), sensitive: sensitive, spoiler_text: summary_html, visibility: get_visibility(object), media_attachments: attachments, poll: render("poll.json", %{object: object, for: opts[:for]}), mentions: mentions, tags: build_tags(tags), application: %{ name: "Web", website: nil }, language: nil, emojis: build_emojis(object.data["emoji"]), pleroma: %{ local: activity.local, conversation_id: get_context_id(activity), in_reply_to_account_acct: reply_to_user && reply_to_user.nickname, content: %{"text/plain" => content_plaintext}, spoiler_text: %{"text/plain" => summary_plaintext}, expires_at: expires_at, direct_conversation_id: direct_conversation_id, thread_muted: thread_muted? } } end def render("status.json", _) do 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" href = attachment_url["href"] |> MediaProxy.url() type = cond do String.contains?(media_type, "image") -> "image" String.contains?(media_type, "video") -> "video" String.contains?(media_type, "audio") -> "audio" true -> "unknown" end <> = :crypto.hash(:md5, href) %{ id: to_string(attachment["id"] || hash_id), url: href, remote_url: href, preview_url: href, text_url: href, type: type, description: attachment["name"], pleroma: %{mime_type: media_type} } end def render("poll.json", %{object: object} = opts) do {multiple, options} = case object.data do %{"anyOf" => options} when is_list(options) -> {true, options} %{"oneOf" => options} when is_list(options) -> {false, options} _ -> {nil, nil} end if options do {end_time, expired} = case object.data["closed"] || object.data["endTime"] do end_time when is_binary(end_time) -> end_time = (object.data["closed"] || object.data["endTime"]) |> NaiveDateTime.from_iso8601!() expired = end_time |> NaiveDateTime.compare(NaiveDateTime.utc_now()) |> case do :lt -> true _ -> false end end_time = Utils.to_masto_date(end_time) {end_time, expired} _ -> {nil, false} end voted = if opts[:for] do existing_votes = Pleroma.Web.ActivityPub.Utils.get_existing_votes(opts[:for].ap_id, object) existing_votes != [] or opts[:for].ap_id == object.data["actor"] else false end {options, votes_count} = Enum.map_reduce(options, 0, fn %{"name" => name} = option, count -> current_count = option["replies"]["totalItems"] || 0 {%{ title: HTML.strip_tags(name), votes_count: current_count }, current_count + count} end) %{ # Mastodon uses separate ids for polls, but an object can't have # more than one poll embedded so object id is fine id: to_string(object.id), expires_at: end_time, expired: expired, multiple: multiple, votes_count: votes_count, options: options, voted: voted, emojis: build_emojis(object.data["emoji"]) } else nil end end def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do object = Object.normalize(activity) 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) if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do Activity.get_create_by_object_ap_id(object.data["inReplyTo"]) else nil end end 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.data["content"] || "" end end 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.data["name"], url when is_bitstring(url) <- object.data["url"] do "

#{summary}

#{object.data["content"]}" else _ -> object.data["content"] || "" end 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 = for tag when is_binary(tag) <- object_tags, do: tag Enum.reduce(object_tags, [], fn tag, tags -> tags ++ [%{name: tag, url: "/tag/#{URI.encode(tag)}"}] end) 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 pinned?(%Activity{id: id}, %User{info: %{pinned_activities: pinned_activities}}), do: id in pinned_activities end