0a8c98b442875c81e56f97d06f6027e817779d5d
[anni] / lib / pleroma / web / mastodon_api / views / status_view.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Web.MastodonAPI.StatusView do
6   use Pleroma.Web, :view
7
8   require Pleroma.Constants
9
10   alias Pleroma.Activity
11   alias Pleroma.HTML
12   alias Pleroma.Maps
13   alias Pleroma.Object
14   alias Pleroma.Repo
15   alias Pleroma.User
16   alias Pleroma.UserRelationship
17   alias Pleroma.Web.CommonAPI
18   alias Pleroma.Web.CommonAPI.Utils
19   alias Pleroma.Web.MastodonAPI.AccountView
20   alias Pleroma.Web.MastodonAPI.PollView
21   alias Pleroma.Web.MastodonAPI.StatusView
22   alias Pleroma.Web.MediaProxy
23   alias Pleroma.Web.PleromaAPI.EmojiReactionController
24
25   import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1, visible_for_user?: 2]
26
27   # This is a naive way to do this, just spawning a process per activity
28   # to fetch the preview. However it should be fine considering
29   # pagination is restricted to 40 activities at a time
30   defp fetch_rich_media_for_activities(activities) do
31     Enum.each(activities, fn activity ->
32       spawn(fn ->
33         Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity)
34       end)
35     end)
36   end
37
38   # TODO: Add cached version.
39   defp get_replied_to_activities([]), do: %{}
40
41   defp get_replied_to_activities(activities) do
42     activities
43     |> Enum.map(fn
44       %{data: %{"type" => "Create"}} = activity ->
45         object = Object.normalize(activity, fetch: false)
46         object && object.data["inReplyTo"] != "" && object.data["inReplyTo"]
47
48       _ ->
49         nil
50     end)
51     |> Enum.filter(& &1)
52     |> Activity.create_by_object_ap_id_with_object()
53     |> Repo.all()
54     |> Enum.reduce(%{}, fn activity, acc ->
55       object = Object.normalize(activity, fetch: false)
56       if object, do: Map.put(acc, object.data["id"], activity), else: acc
57     end)
58   end
59
60   # DEPRECATED This field seems to be a left-over from the StatusNet era.
61   # If your application uses `pleroma.conversation_id`: this field is deprecated.
62   # It is currently stubbed instead by doing a CRC32 of the context, and
63   # clearing the MSB to avoid overflow exceptions with signed integers on the
64   # different clients using this field (Java/Kotlin code, mostly; see Husky.)
65   # This should be removed in a future version of Pleroma. Pleroma-FE currently
66   # depends on this field, as well.
67   defp get_context_id(%{data: %{"context" => context}}) when is_binary(context) do
68     import Bitwise
69
70     :erlang.crc32(context)
71     |> band(bnot(0x8000_0000))
72   end
73
74   defp get_context_id(_), do: nil
75
76   # Check if the user reblogged this status
77   defp reblogged?(activity, %User{ap_id: ap_id}) do
78     with %Object{data: %{"announcements" => announcements}} when is_list(announcements) <-
79            Object.normalize(activity, fetch: false) do
80       ap_id in announcements
81     else
82       _ -> false
83     end
84   end
85
86   # False if the user is logged out
87   defp reblogged?(_activity, _user), do: false
88
89   def render("index.json", opts) do
90     reading_user = opts[:for]
91
92     # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
93     activities = Enum.filter(opts.activities, & &1)
94
95     # Start fetching rich media before doing anything else, so that later calls to get the cards
96     # only block for timeout in the worst case, as opposed to
97     # length(activities_with_links) * timeout
98     fetch_rich_media_for_activities(activities)
99     replied_to_activities = get_replied_to_activities(activities)
100
101     parent_activities =
102       activities
103       |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
104       |> Enum.map(&Object.normalize(&1, fetch: false).data["id"])
105       |> Activity.create_by_object_ap_id()
106       |> Activity.with_preloaded_object(:left)
107       |> Activity.with_preloaded_bookmark(reading_user)
108       |> Activity.with_set_thread_muted_field(reading_user)
109       |> Repo.all()
110
111     relationships_opt =
112       cond do
113         Map.has_key?(opts, :relationships) ->
114           opts[:relationships]
115
116         is_nil(reading_user) ->
117           UserRelationship.view_relationships_option(nil, [])
118
119         true ->
120           # Note: unresolved users are filtered out
121           actors =
122             (activities ++ parent_activities)
123             |> Enum.map(&CommonAPI.get_user(&1.data["actor"], false))
124             |> Enum.filter(& &1)
125
126           UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes)
127       end
128
129     opts =
130       opts
131       |> Map.put(:replied_to_activities, replied_to_activities)
132       |> Map.put(:parent_activities, parent_activities)
133       |> Map.put(:relationships, relationships_opt)
134
135     safe_render_many(activities, StatusView, "show.json", opts)
136   end
137
138   def render(
139         "show.json",
140         %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
141       ) do
142     user = CommonAPI.get_user(activity.data["actor"])
143     created_at = Utils.to_masto_date(activity.data["published"])
144     object = Object.normalize(activity, fetch: false)
145
146     reblogged_parent_activity =
147       if opts[:parent_activities] do
148         Activity.Queries.find_by_object_ap_id(
149           opts[:parent_activities],
150           object.data["id"]
151         )
152       else
153         Activity.create_by_object_ap_id(object.data["id"])
154         |> Activity.with_preloaded_bookmark(opts[:for])
155         |> Activity.with_set_thread_muted_field(opts[:for])
156         |> Repo.one()
157       end
158
159     reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
160     reblogged = render("show.json", reblog_rendering_opts)
161
162     favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
163
164     bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
165
166     mentions =
167       activity.recipients
168       |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
169       |> Enum.filter(& &1)
170       |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
171
172     {pinned?, pinned_at} = pin_data(object, user)
173
174     %{
175       id: to_string(activity.id),
176       uri: object.data["id"],
177       url: object.data["id"],
178       account:
179         AccountView.render("show.json", %{
180           user: user,
181           for: opts[:for]
182         }),
183       in_reply_to_id: nil,
184       in_reply_to_account_id: nil,
185       reblog: reblogged,
186       content: reblogged[:content] || "",
187       created_at: created_at,
188       reblogs_count: 0,
189       replies_count: 0,
190       favourites_count: 0,
191       reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
192       favourited: present?(favorited),
193       bookmarked: present?(bookmarked),
194       muted: false,
195       pinned: pinned?,
196       sensitive: false,
197       spoiler_text: "",
198       visibility: get_visibility(activity),
199       media_attachments: reblogged[:media_attachments] || [],
200       mentions: mentions,
201       tags: reblogged[:tags] || [],
202       application: build_application(object.data["generator"]),
203       language: nil,
204       emojis: [],
205       pleroma: %{
206         local: activity.local,
207         pinned_at: pinned_at
208       }
209     }
210   end
211
212   def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
213     object = Object.normalize(activity, fetch: false)
214
215     user = CommonAPI.get_user(activity.data["actor"])
216     user_follower_address = user.follower_address
217
218     like_count = object.data["like_count"] || 0
219     announcement_count = object.data["announcement_count"] || 0
220
221     hashtags = Object.hashtags(object)
222     sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
223
224     tags = Object.tags(object)
225
226     tag_mentions =
227       tags
228       |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
229       |> Enum.map(fn tag -> tag["href"] end)
230
231     mentions =
232       (object.data["to"] ++ tag_mentions)
233       |> Enum.uniq()
234       |> Enum.map(fn
235         Pleroma.Constants.as_public() -> nil
236         ^user_follower_address -> nil
237         ap_id -> User.get_cached_by_ap_id(ap_id)
238       end)
239       |> Enum.filter(& &1)
240       |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
241
242     favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
243
244     bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
245
246     client_posted_this_activity = opts[:for] && user.id == opts[:for].id
247
248     expires_at =
249       with true <- client_posted_this_activity,
250            %Oban.Job{scheduled_at: scheduled_at} <-
251              Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
252         scheduled_at
253       else
254         _ -> nil
255       end
256
257     thread_muted? =
258       cond do
259         is_nil(opts[:for]) -> false
260         is_boolean(activity.thread_muted?) -> activity.thread_muted?
261         true -> CommonAPI.thread_muted?(opts[:for], activity)
262       end
263
264     attachment_data = object.data["attachment"] || []
265     attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
266
267     created_at = Utils.to_masto_date(object.data["published"])
268
269     edited_at =
270       with %{"updated" => updated} <- object.data,
271            date <- Utils.to_masto_date(updated),
272            true <- date != "" do
273         date
274       else
275         _ ->
276           nil
277       end
278
279     reply_to = get_reply_to(activity, opts)
280
281     reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
282
283     history_len =
284       1 +
285         (Object.Updater.history_for(object.data)
286          |> Map.get("orderedItems")
287          |> length())
288
289     # See render("history.json", ...) for more details
290     # Here the implicit index of the current content is 0
291     chrono_order = history_len - 1
292
293     content =
294       object
295       |> render_content()
296
297     content_html =
298       content
299       |> Activity.HTML.get_cached_scrubbed_html_for_activity(
300         User.html_filter_policy(opts[:for]),
301         activity,
302         "mastoapi:content:#{chrono_order}"
303       )
304
305     content_plaintext =
306       content
307       |> Activity.HTML.get_cached_stripped_html_for_activity(
308         activity,
309         "mastoapi:content:#{chrono_order}"
310       )
311
312     summary = object.data["summary"] || ""
313
314     card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
315
316     url =
317       if user.local do
318         Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
319       else
320         object.data["url"] || object.data["external_url"] || object.data["id"]
321       end
322
323     direct_conversation_id =
324       with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
325            {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
326            {_, %User{} = for_user} <- {:for_user, opts[:for]} do
327         Activity.direct_conversation_id(activity, for_user)
328       else
329         {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
330           participation_id
331
332         _e ->
333           nil
334       end
335
336     emoji_reactions =
337       object.data
338       |> Map.get("reactions", [])
339       |> EmojiReactionController.filter_allowed_users(
340         opts[:for],
341         Map.get(opts, :with_muted, false)
342       )
343       |> Stream.map(fn {emoji, users} ->
344         build_emoji_map(emoji, users, opts[:for])
345       end)
346       |> Enum.to_list()
347
348     # Status muted state (would do 1 request per status unless user mutes are preloaded)
349     muted =
350       thread_muted? ||
351         UserRelationship.exists?(
352           get_in(opts, [:relationships, :user_relationships]),
353           :mute,
354           opts[:for],
355           user,
356           fn for_user, user -> User.mutes?(for_user, user) end
357         )
358
359     {pinned?, pinned_at} = pin_data(object, user)
360
361     %{
362       id: to_string(activity.id),
363       uri: object.data["id"],
364       url: url,
365       account:
366         AccountView.render("show.json", %{
367           user: user,
368           for: opts[:for]
369         }),
370       in_reply_to_id: reply_to && to_string(reply_to.id),
371       in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
372       reblog: nil,
373       card: card,
374       content: content_html,
375       text: opts[:with_source] && get_source_text(object.data["source"]),
376       created_at: created_at,
377       edited_at: edited_at,
378       reblogs_count: announcement_count,
379       replies_count: object.data["repliesCount"] || 0,
380       favourites_count: like_count,
381       reblogged: reblogged?(activity, opts[:for]),
382       favourited: present?(favorited),
383       bookmarked: present?(bookmarked),
384       muted: muted,
385       pinned: pinned?,
386       sensitive: sensitive,
387       spoiler_text: summary,
388       visibility: get_visibility(object),
389       media_attachments: attachments,
390       poll: render(PollView, "show.json", object: object, for: opts[:for]),
391       mentions: mentions,
392       tags: build_tags(tags),
393       application: build_application(object.data["generator"]),
394       language: nil,
395       emojis: build_emojis(object.data["emoji"]),
396       pleroma: %{
397         local: activity.local,
398         conversation_id: get_context_id(activity),
399         context: object.data["context"],
400         in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
401         content: %{"text/plain" => content_plaintext},
402         spoiler_text: %{"text/plain" => summary},
403         expires_at: expires_at,
404         direct_conversation_id: direct_conversation_id,
405         thread_muted: thread_muted?,
406         emoji_reactions: emoji_reactions,
407         parent_visible: visible_for_user?(reply_to, opts[:for]),
408         pinned_at: pinned_at
409       }
410     }
411   end
412
413   def render("show.json", _) do
414     nil
415   end
416
417   def render("history.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
418     object = Object.normalize(activity, fetch: false)
419
420     hashtags = Object.hashtags(object)
421
422     user = CommonAPI.get_user(activity.data["actor"])
423
424     past_history =
425       Object.Updater.history_for(object.data)
426       |> Map.get("orderedItems")
427       |> Enum.map(&Map.put(&1, "id", object.data["id"]))
428       |> Enum.map(&%Object{data: &1, id: object.id})
429
430     history =
431       [object | past_history]
432       # Mastodon expects the original to be at the first
433       |> Enum.reverse()
434       |> Enum.with_index()
435       |> Enum.map(fn {object, chrono_order} ->
436         %{
437           # The history is prepended every time there is a new edit.
438           # In chrono_order, the oldest item is always at 0, and so on.
439           # The chrono_order is an invariant kept between edits.
440           chrono_order: chrono_order,
441           object: object
442         }
443       end)
444
445     individual_opts =
446       opts
447       |> Map.put(:as, :item)
448       |> Map.put(:user, user)
449       |> Map.put(:hashtags, hashtags)
450
451     render_many(history, StatusView, "history_item.json", individual_opts)
452   end
453
454   def render(
455         "history_item.json",
456         %{
457           activity: activity,
458           user: user,
459           item: %{object: object, chrono_order: chrono_order},
460           hashtags: hashtags
461         } = opts
462       ) do
463     sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
464
465     attachment_data = object.data["attachment"] || []
466     attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
467
468     created_at = Utils.to_masto_date(object.data["updated"] || object.data["published"])
469
470     content =
471       object
472       |> render_content()
473
474     content_html =
475       content
476       |> Activity.HTML.get_cached_scrubbed_html_for_activity(
477         User.html_filter_policy(opts[:for]),
478         activity,
479         "mastoapi:content:#{chrono_order}"
480       )
481
482     summary = object.data["summary"] || ""
483
484     %{
485       account:
486         AccountView.render("show.json", %{
487           user: user,
488           for: opts[:for]
489         }),
490       content: content_html,
491       sensitive: sensitive,
492       spoiler_text: summary,
493       created_at: created_at,
494       media_attachments: attachments,
495       emojis: build_emojis(object.data["emoji"]),
496       poll: render(PollView, "show.json", object: object, for: opts[:for])
497     }
498   end
499
500   def render("source.json", %{activity: %{data: %{"object" => _object}} = activity} = _opts) do
501     object = Object.normalize(activity, fetch: false)
502
503     %{
504       id: activity.id,
505       text: get_source_text(Map.get(object.data, "source", "")),
506       spoiler_text: Map.get(object.data, "summary", ""),
507       content_type: get_source_content_type(object.data["source"])
508     }
509   end
510
511   def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
512     page_url_data = URI.parse(page_url)
513
514     page_url_data =
515       if is_binary(rich_media["url"]) do
516         URI.merge(page_url_data, URI.parse(rich_media["url"]))
517       else
518         page_url_data
519       end
520
521     page_url = page_url_data |> to_string
522
523     image_url_data =
524       if is_binary(rich_media["image"]) do
525         URI.parse(rich_media["image"])
526       else
527         nil
528       end
529
530     image_url = build_image_url(image_url_data, page_url_data)
531
532     %{
533       type: "link",
534       provider_name: page_url_data.host,
535       provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
536       url: page_url,
537       image: image_url |> MediaProxy.url(),
538       title: rich_media["title"] || "",
539       description: rich_media["description"] || "",
540       pleroma: %{
541         opengraph: rich_media
542       }
543     }
544   end
545
546   def render("card.json", _), do: nil
547
548   def render("attachment.json", %{attachment: attachment}) do
549     [attachment_url | _] = attachment["url"]
550     media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
551     href = attachment_url["href"] |> MediaProxy.url()
552     href_preview = attachment_url["href"] |> MediaProxy.preview_url()
553     meta = render("attachment_meta.json", %{attachment: attachment})
554
555     type =
556       cond do
557         String.contains?(media_type, "image") -> "image"
558         String.contains?(media_type, "video") -> "video"
559         String.contains?(media_type, "audio") -> "audio"
560         true -> "unknown"
561       end
562
563     attachment_id =
564       with {_, ap_id} when is_binary(ap_id) <- {:ap_id, attachment["id"]},
565            {_, %Object{data: _object_data, id: object_id}} <-
566              {:object, Object.get_by_ap_id(ap_id)} do
567         to_string(object_id)
568       else
569         _ ->
570           <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
571           to_string(attachment["id"] || hash_id)
572       end
573
574     %{
575       id: attachment_id,
576       url: href,
577       remote_url: href,
578       preview_url: href_preview,
579       text_url: href,
580       type: type,
581       description: attachment["name"],
582       pleroma: %{mime_type: media_type},
583       blurhash: attachment["blurhash"]
584     }
585     |> Maps.put_if_present(:meta, meta)
586   end
587
588   def render("attachment_meta.json", %{
589         attachment: %{"url" => [%{"width" => width, "height" => height} | _]}
590       })
591       when is_integer(width) and is_integer(height) do
592     %{
593       original: %{
594         width: width,
595         height: height,
596         aspect: width / height
597       }
598     }
599   end
600
601   def render("attachment_meta.json", _), do: nil
602
603   def render("context.json", %{activity: activity, activities: activities, user: user}) do
604     %{ancestors: ancestors, descendants: descendants} =
605       activities
606       |> Enum.reverse()
607       |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
608       |> Map.put_new(:ancestors, [])
609       |> Map.put_new(:descendants, [])
610
611     %{
612       ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
613       descendants: render("index.json", for: user, activities: descendants, as: :activity)
614     }
615   end
616
617   def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
618     object = Object.normalize(activity, fetch: false)
619
620     with nil <- replied_to_activities[object.data["inReplyTo"]] do
621       # If user didn't participate in the thread
622       Activity.get_in_reply_to_activity(activity)
623     end
624   end
625
626   def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
627     object = Object.normalize(activity, fetch: false)
628
629     if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
630       Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
631     else
632       nil
633     end
634   end
635
636   def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
637     url = object.data["url"] || object.data["id"]
638
639     "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
640   end
641
642   def render_content(object), do: object.data["content"] || ""
643
644   @doc """
645   Builds a dictionary tags.
646
647   ## Examples
648
649   iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
650   [{"name": "fediverse", "url": "/tag/fediverse"},
651    {"name": "nextcloud", "url": "/tag/nextcloud"}]
652
653   """
654   @spec build_tags(list(any())) :: list(map())
655   def build_tags(object_tags) when is_list(object_tags) do
656     object_tags
657     |> Enum.filter(&is_binary/1)
658     |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"})
659   end
660
661   def build_tags(_), do: []
662
663   @doc """
664   Builds list emojis.
665
666   Arguments: `nil` or list tuple of name and url.
667
668   Returns list emojis.
669
670   ## Examples
671
672   iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
673   [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
674
675   """
676   @spec build_emojis(nil | list(tuple())) :: list(map())
677   def build_emojis(nil), do: []
678
679   def build_emojis(emojis) do
680     emojis
681     |> Enum.map(fn {name, url} ->
682       name = HTML.strip_tags(name)
683
684       url =
685         url
686         |> HTML.strip_tags()
687         |> MediaProxy.url()
688
689       %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
690     end)
691   end
692
693   defp present?(nil), do: false
694   defp present?(false), do: false
695   defp present?(_), do: true
696
697   defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do
698     if pinned_at = pinned_objects[object_id] do
699       {true, Utils.to_masto_date(pinned_at)}
700     else
701       {false, nil}
702     end
703   end
704
705   defp build_emoji_map(emoji, users, current_user) do
706     %{
707       name: emoji,
708       count: length(users),
709       me: !!(current_user && current_user.ap_id in users)
710     }
711   end
712
713   @spec build_application(map() | nil) :: map() | nil
714   defp build_application(%{"type" => _type, "name" => name, "url" => url}),
715     do: %{name: name, website: url}
716
717   defp build_application(_), do: nil
718
719   # Workaround for Elixir issue #10771
720   # Avoid applying URI.merge unless necessary
721   # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
722   # when Elixir 1.12 is the minimum supported version
723   @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
724   defp build_image_url(
725          %URI{scheme: image_scheme, host: image_host} = image_url_data,
726          %URI{} = _page_url_data
727        )
728        when not is_nil(image_scheme) and not is_nil(image_host) do
729     image_url_data |> to_string
730   end
731
732   defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
733     URI.merge(page_url_data, image_url_data) |> to_string
734   end
735
736   defp build_image_url(_, _), do: nil
737
738   defp get_source_text(%{"content" => content} = _source) do
739     content
740   end
741
742   defp get_source_text(source) when is_binary(source) do
743     source
744   end
745
746   defp get_source_text(_) do
747     ""
748   end
749
750   defp get_source_content_type(%{"mediaType" => type} = _source) do
751     type
752   end
753
754   defp get_source_content_type(_source) do
755     Utils.get_content_type(nil)
756   end
757 end