Merge branch 'develop' into tests/mastodon_api_controller.ex
[akkoma] / lib / pleroma / web / ostatus / ostatus.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Web.OStatus do
6 import Pleroma.Web.XML
7 require Logger
8
9 alias Pleroma.Activity
10 alias Pleroma.HTTP
11 alias Pleroma.Object
12 alias Pleroma.User
13 alias Pleroma.Web
14 alias Pleroma.Web.ActivityPub.ActivityPub
15 alias Pleroma.Web.ActivityPub.Transmogrifier
16 alias Pleroma.Web.ActivityPub.Visibility
17 alias Pleroma.Web.OStatus.DeleteHandler
18 alias Pleroma.Web.OStatus.FollowHandler
19 alias Pleroma.Web.OStatus.NoteHandler
20 alias Pleroma.Web.OStatus.UnfollowHandler
21 alias Pleroma.Web.WebFinger
22 alias Pleroma.Web.Websub
23
24 def is_representable?(%Activity{} = activity) do
25 object = Object.normalize(activity)
26
27 cond do
28 is_nil(object) ->
29 false
30
31 Visibility.is_public?(activity) && object.data["type"] == "Note" ->
32 true
33
34 true ->
35 false
36 end
37 end
38
39 def feed_path(user), do: "#{user.ap_id}/feed.atom"
40
41 def pubsub_path(user), do: "#{Web.base_url()}/push/hub/#{user.nickname}"
42
43 def salmon_path(user), do: "#{user.ap_id}/salmon"
44
45 def remote_follow_path, do: "#{Web.base_url()}/ostatus_subscribe?acct={uri}"
46
47 def handle_incoming(xml_string, options \\ []) do
48 with doc when doc != :error <- parse_document(xml_string) do
49 with {:ok, actor_user} <- find_make_or_update_actor(doc),
50 do: Pleroma.Instances.set_reachable(actor_user.ap_id)
51
52 entries = :xmerl_xpath.string('//entry', doc)
53
54 activities =
55 Enum.map(entries, fn entry ->
56 {:xmlObj, :string, object_type} =
57 :xmerl_xpath.string('string(/entry/activity:object-type[1])', entry)
58
59 {:xmlObj, :string, verb} = :xmerl_xpath.string('string(/entry/activity:verb[1])', entry)
60 Logger.debug("Handling #{verb}")
61
62 try do
63 case verb do
64 'http://activitystrea.ms/schema/1.0/delete' ->
65 with {:ok, activity} <- DeleteHandler.handle_delete(entry, doc), do: activity
66
67 'http://activitystrea.ms/schema/1.0/follow' ->
68 with {:ok, activity} <- FollowHandler.handle(entry, doc), do: activity
69
70 'http://activitystrea.ms/schema/1.0/unfollow' ->
71 with {:ok, activity} <- UnfollowHandler.handle(entry, doc), do: activity
72
73 'http://activitystrea.ms/schema/1.0/share' ->
74 with {:ok, activity, retweeted_activity} <- handle_share(entry, doc),
75 do: [activity, retweeted_activity]
76
77 'http://activitystrea.ms/schema/1.0/favorite' ->
78 with {:ok, activity, favorited_activity} <- handle_favorite(entry, doc),
79 do: [activity, favorited_activity]
80
81 _ ->
82 case object_type do
83 'http://activitystrea.ms/schema/1.0/note' ->
84 with {:ok, activity} <- NoteHandler.handle_note(entry, doc, options),
85 do: activity
86
87 'http://activitystrea.ms/schema/1.0/comment' ->
88 with {:ok, activity} <- NoteHandler.handle_note(entry, doc, options),
89 do: activity
90
91 _ ->
92 Logger.error("Couldn't parse incoming document")
93 nil
94 end
95 end
96 rescue
97 e ->
98 Logger.error("Error occured while handling activity")
99 Logger.error(xml_string)
100 Logger.error(inspect(e))
101 nil
102 end
103 end)
104 |> Enum.filter(& &1)
105
106 {:ok, activities}
107 else
108 _e -> {:error, []}
109 end
110 end
111
112 def make_share(entry, doc, retweeted_activity) do
113 with {:ok, actor} <- find_make_or_update_actor(doc),
114 %Object{} = object <- Object.normalize(retweeted_activity),
115 id when not is_nil(id) <- string_from_xpath("/entry/id", entry),
116 {:ok, activity, _object} = ActivityPub.announce(actor, object, id, false) do
117 {:ok, activity}
118 end
119 end
120
121 def handle_share(entry, doc) do
122 with {:ok, retweeted_activity} <- get_or_build_object(entry),
123 {:ok, activity} <- make_share(entry, doc, retweeted_activity) do
124 {:ok, activity, retweeted_activity}
125 else
126 e -> {:error, e}
127 end
128 end
129
130 def make_favorite(entry, doc, favorited_activity) do
131 with {:ok, actor} <- find_make_or_update_actor(doc),
132 %Object{} = object <- Object.normalize(favorited_activity),
133 id when not is_nil(id) <- string_from_xpath("/entry/id", entry),
134 {:ok, activity, _object} = ActivityPub.like(actor, object, id, false) do
135 {:ok, activity}
136 end
137 end
138
139 def get_or_build_object(entry) do
140 with {:ok, activity} <- get_or_try_fetching(entry) do
141 {:ok, activity}
142 else
143 _e ->
144 with [object] <- :xmerl_xpath.string('/entry/activity:object', entry) do
145 NoteHandler.handle_note(object, object)
146 end
147 end
148 end
149
150 def get_or_try_fetching(entry) do
151 Logger.debug("Trying to get entry from db")
152
153 with id when not is_nil(id) <- string_from_xpath("//activity:object[1]/id", entry),
154 %Activity{} = activity <- Activity.get_create_by_object_ap_id_with_object(id) do
155 {:ok, activity}
156 else
157 _ ->
158 Logger.debug("Couldn't get, will try to fetch")
159
160 with href when not is_nil(href) <-
161 string_from_xpath("//activity:object[1]/link[@type=\"text/html\"]/@href", entry),
162 {:ok, [favorited_activity]} <- fetch_activity_from_url(href) do
163 {:ok, favorited_activity}
164 else
165 e -> Logger.debug("Couldn't find href: #{inspect(e)}")
166 end
167 end
168 end
169
170 def handle_favorite(entry, doc) do
171 with {:ok, favorited_activity} <- get_or_try_fetching(entry),
172 {:ok, activity} <- make_favorite(entry, doc, favorited_activity) do
173 {:ok, activity, favorited_activity}
174 else
175 e -> {:error, e}
176 end
177 end
178
179 def get_attachments(entry) do
180 :xmerl_xpath.string('/entry/link[@rel="enclosure"]', entry)
181 |> Enum.map(fn enclosure ->
182 with href when not is_nil(href) <- string_from_xpath("/link/@href", enclosure),
183 type when not is_nil(type) <- string_from_xpath("/link/@type", enclosure) do
184 %{
185 "type" => "Attachment",
186 "url" => [
187 %{
188 "type" => "Link",
189 "mediaType" => type,
190 "href" => href
191 }
192 ]
193 }
194 end
195 end)
196 |> Enum.filter(& &1)
197 end
198
199 @doc """
200 Gets the content from a an entry.
201 """
202 def get_content(entry) do
203 string_from_xpath("//content", entry)
204 end
205
206 @doc """
207 Get the cw that mastodon uses.
208 """
209 def get_cw(entry) do
210 case string_from_xpath("/*/summary", entry) do
211 cw when not is_nil(cw) -> cw
212 _ -> nil
213 end
214 end
215
216 def get_tags(entry) do
217 :xmerl_xpath.string('//category', entry)
218 |> Enum.map(fn category -> string_from_xpath("/category/@term", category) end)
219 |> Enum.filter(& &1)
220 |> Enum.map(&String.downcase/1)
221 end
222
223 def maybe_update(doc, user) do
224 case string_from_xpath("//author[1]/ap_enabled", doc) do
225 "true" ->
226 Transmogrifier.upgrade_user_from_ap_id(user.ap_id)
227
228 _ ->
229 maybe_update_ostatus(doc, user)
230 end
231 end
232
233 def maybe_update_ostatus(doc, user) do
234 old_data = Map.take(user, [:bio, :avatar, :name])
235
236 with false <- user.local,
237 avatar <- make_avatar_object(doc),
238 bio <- string_from_xpath("//author[1]/summary", doc),
239 name <- string_from_xpath("//author[1]/poco:displayName", doc),
240 new_data <- %{
241 avatar: avatar || old_data.avatar,
242 name: name || old_data.name,
243 bio: bio || old_data.bio
244 },
245 false <- new_data == old_data do
246 change = Ecto.Changeset.change(user, new_data)
247 User.update_and_set_cache(change)
248 else
249 _ ->
250 {:ok, user}
251 end
252 end
253
254 def find_make_or_update_actor(doc) do
255 uri = string_from_xpath("//author/uri[1]", doc)
256
257 with {:ok, %User{} = user} <- find_or_make_user(uri),
258 {:ap_enabled, false} <- {:ap_enabled, User.ap_enabled?(user)} do
259 maybe_update(doc, user)
260 else
261 {:ap_enabled, true} ->
262 {:error, :invalid_protocol}
263
264 _ ->
265 {:error, :unknown_user}
266 end
267 end
268
269 @spec find_or_make_user(String.t()) :: {:ok, User.t()}
270 def find_or_make_user(uri) do
271 case User.get_by_ap_id(uri) do
272 %User{} = user -> {:ok, user}
273 _ -> make_user(uri)
274 end
275 end
276
277 @spec make_user(String.t(), boolean()) :: {:ok, User.t()} | {:error, any()}
278 def make_user(uri, update \\ false) do
279 with {:ok, info} <- gather_user_info(uri) do
280 with false <- update,
281 %User{} = user <- User.get_cached_by_ap_id(info["uri"]) do
282 {:ok, user}
283 else
284 _e -> User.insert_or_update_user(build_user_data(info))
285 end
286 end
287 end
288
289 defp build_user_data(info) do
290 %{
291 name: info["name"],
292 nickname: info["nickname"] <> "@" <> info["host"],
293 ap_id: info["uri"],
294 info: info,
295 avatar: info["avatar"],
296 bio: info["bio"]
297 }
298 end
299
300 # TODO: Just takes the first one for now.
301 def make_avatar_object(author_doc, rel \\ "avatar") do
302 href = string_from_xpath("//author[1]/link[@rel=\"#{rel}\"]/@href", author_doc)
303 type = string_from_xpath("//author[1]/link[@rel=\"#{rel}\"]/@type", author_doc)
304
305 if href do
306 %{
307 "type" => "Image",
308 "url" => [%{"type" => "Link", "mediaType" => type, "href" => href}]
309 }
310 else
311 nil
312 end
313 end
314
315 @spec gather_user_info(String.t()) :: {:ok, map()} | {:error, any()}
316 def gather_user_info(username) do
317 with {:ok, webfinger_data} <- WebFinger.finger(username),
318 {:ok, feed_data} <- Websub.gather_feed_data(webfinger_data["topic"]) do
319 data =
320 webfinger_data
321 |> Map.merge(feed_data)
322 |> Map.put("fqn", username)
323
324 {:ok, data}
325 else
326 e ->
327 Logger.debug(fn -> "Couldn't gather info for #{username}" end)
328 {:error, e}
329 end
330 end
331
332 # Regex-based 'parsing' so we don't have to pull in a full html parser
333 # It's a hack anyway. Maybe revisit this in the future
334 @mastodon_regex ~r/<link href='(.*)' rel='alternate' type='application\/atom\+xml'>/
335 @gs_regex ~r/<link title=.* href="(.*)" type="application\/atom\+xml" rel="alternate">/
336 @gs_classic_regex ~r/<link rel="alternate" href="(.*)" type="application\/atom\+xml" title=.*>/
337 def get_atom_url(body) do
338 cond do
339 Regex.match?(@mastodon_regex, body) ->
340 [[_, match]] = Regex.scan(@mastodon_regex, body)
341 {:ok, match}
342
343 Regex.match?(@gs_regex, body) ->
344 [[_, match]] = Regex.scan(@gs_regex, body)
345 {:ok, match}
346
347 Regex.match?(@gs_classic_regex, body) ->
348 [[_, match]] = Regex.scan(@gs_classic_regex, body)
349 {:ok, match}
350
351 true ->
352 Logger.debug(fn -> "Couldn't find Atom link in #{inspect(body)}" end)
353 {:error, "Couldn't find the Atom link"}
354 end
355 end
356
357 def fetch_activity_from_atom_url(url, options \\ []) do
358 with true <- String.starts_with?(url, "http"),
359 {:ok, %{body: body, status: code}} when code in 200..299 <-
360 HTTP.get(url, [{:Accept, "application/atom+xml"}]) do
361 Logger.debug("Got document from #{url}, handling...")
362 handle_incoming(body, options)
363 else
364 e ->
365 Logger.debug("Couldn't get #{url}: #{inspect(e)}")
366 e
367 end
368 end
369
370 def fetch_activity_from_html_url(url, options \\ []) do
371 Logger.debug("Trying to fetch #{url}")
372
373 with true <- String.starts_with?(url, "http"),
374 {:ok, %{body: body}} <- HTTP.get(url, []),
375 {:ok, atom_url} <- get_atom_url(body) do
376 fetch_activity_from_atom_url(atom_url, options)
377 else
378 e ->
379 Logger.debug("Couldn't get #{url}: #{inspect(e)}")
380 e
381 end
382 end
383
384 def fetch_activity_from_url(url, options \\ []) do
385 with {:ok, [_ | _] = activities} <- fetch_activity_from_atom_url(url, options) do
386 {:ok, activities}
387 else
388 _e -> fetch_activity_from_html_url(url, options)
389 end
390 rescue
391 e ->
392 Logger.debug("Couldn't get #{url}: #{inspect(e)}")
393 {:error, "Couldn't get #{url}: #{inspect(e)}"}
394 end
395 end