[#1260] Merge remote-tracking branch 'remotes/upstream/develop' into 1260-rate-limite...
[akkoma] / lib / pleroma / web / mastodon_api / controllers / mastodon_api_controller.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.MastodonAPI.MastodonAPIController do
6 use Pleroma.Web, :controller
7
8 import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2]
9
10 alias Pleroma.Activity
11 alias Pleroma.Bookmark
12 alias Pleroma.Config
13 alias Pleroma.HTTP
14 alias Pleroma.Object
15 alias Pleroma.Pagination
16 alias Pleroma.Plugs.RateLimiter
17 alias Pleroma.Repo
18 alias Pleroma.Stats
19 alias Pleroma.User
20 alias Pleroma.Web
21 alias Pleroma.Web.ActivityPub.ActivityPub
22 alias Pleroma.Web.ActivityPub.Visibility
23 alias Pleroma.Web.CommonAPI
24 alias Pleroma.Web.MastodonAPI.AccountView
25 alias Pleroma.Web.MastodonAPI.AppView
26 alias Pleroma.Web.MastodonAPI.MastodonView
27 alias Pleroma.Web.MastodonAPI.StatusView
28 alias Pleroma.Web.MediaProxy
29 alias Pleroma.Web.OAuth.App
30 alias Pleroma.Web.OAuth.Authorization
31 alias Pleroma.Web.OAuth.Scopes
32 alias Pleroma.Web.OAuth.Token
33 alias Pleroma.Web.TwitterAPI.TwitterAPI
34
35 require Logger
36
37 plug(RateLimiter, :password_reset when action == :password_reset)
38
39 @local_mastodon_name "Mastodon-Local"
40
41 action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
42
43 def create_app(conn, params) do
44 scopes = Scopes.fetch_scopes(params, ["read"])
45
46 app_attrs =
47 params
48 |> Map.drop(["scope", "scopes"])
49 |> Map.put("scopes", scopes)
50
51 with cs <- App.register_changeset(%App{}, app_attrs),
52 false <- cs.changes[:client_name] == @local_mastodon_name,
53 {:ok, app} <- Repo.insert(cs) do
54 conn
55 |> put_view(AppView)
56 |> render("show.json", %{app: app})
57 end
58 end
59
60 def verify_app_credentials(%{assigns: %{user: _user, token: token}} = conn, _) do
61 with %Token{app: %App{} = app} <- Repo.preload(token, :app) do
62 conn
63 |> put_view(AppView)
64 |> render("short.json", %{app: app})
65 end
66 end
67
68 @mastodon_api_level "2.7.2"
69
70 def masto_instance(conn, _params) do
71 instance = Config.get(:instance)
72
73 response = %{
74 uri: Web.base_url(),
75 title: Keyword.get(instance, :name),
76 description: Keyword.get(instance, :description),
77 version: "#{@mastodon_api_level} (compatible; #{Pleroma.Application.named_version()})",
78 email: Keyword.get(instance, :email),
79 urls: %{
80 streaming_api: Pleroma.Web.Endpoint.websocket_url()
81 },
82 stats: Stats.get_stats(),
83 thumbnail: Web.base_url() <> "/instance/thumbnail.jpeg",
84 languages: ["en"],
85 registrations: Pleroma.Config.get([:instance, :registrations_open]),
86 # Extra (not present in Mastodon):
87 max_toot_chars: Keyword.get(instance, :limit),
88 poll_limits: Keyword.get(instance, :poll_limits)
89 }
90
91 json(conn, response)
92 end
93
94 def peers(conn, _params) do
95 json(conn, Stats.get_peers())
96 end
97
98 defp mastodonized_emoji do
99 Pleroma.Emoji.get_all()
100 |> Enum.map(fn {shortcode, %Pleroma.Emoji{file: relative_url, tags: tags}} ->
101 url = to_string(URI.merge(Web.base_url(), relative_url))
102
103 %{
104 "shortcode" => shortcode,
105 "static_url" => url,
106 "visible_in_picker" => true,
107 "url" => url,
108 "tags" => tags,
109 # Assuming that a comma is authorized in the category name
110 "category" => (tags -- ["Custom"]) |> Enum.join(",")
111 }
112 end)
113 end
114
115 def custom_emojis(conn, _params) do
116 mastodon_emoji = mastodonized_emoji()
117 json(conn, mastodon_emoji)
118 end
119
120 def get_poll(%{assigns: %{user: user}} = conn, %{"id" => id}) do
121 with %Object{} = object <- Object.get_by_id_and_maybe_refetch(id, interval: 60),
122 %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]),
123 true <- Visibility.visible_for_user?(activity, user) do
124 conn
125 |> put_view(StatusView)
126 |> try_render("poll.json", %{object: object, for: user})
127 else
128 error when is_nil(error) or error == false ->
129 render_error(conn, :not_found, "Record not found")
130 end
131 end
132
133 defp get_cached_vote_or_vote(user, object, choices) do
134 idempotency_key = "polls:#{user.id}:#{object.data["id"]}"
135
136 {_, res} =
137 Cachex.fetch(:idempotency_cache, idempotency_key, fn _ ->
138 case CommonAPI.vote(user, object, choices) do
139 {:error, _message} = res -> {:ignore, res}
140 res -> {:commit, res}
141 end
142 end)
143
144 res
145 end
146
147 def poll_vote(%{assigns: %{user: user}} = conn, %{"id" => id, "choices" => choices}) do
148 with %Object{} = object <- Object.get_by_id(id),
149 true <- object.data["type"] == "Question",
150 %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]),
151 true <- Visibility.visible_for_user?(activity, user),
152 {:ok, _activities, object} <- get_cached_vote_or_vote(user, object, choices) do
153 conn
154 |> put_view(StatusView)
155 |> try_render("poll.json", %{object: object, for: user})
156 else
157 nil ->
158 render_error(conn, :not_found, "Record not found")
159
160 false ->
161 render_error(conn, :not_found, "Record not found")
162
163 {:error, message} ->
164 conn
165 |> put_status(:unprocessable_entity)
166 |> json(%{error: message})
167 end
168 end
169
170 def update_media(
171 %{assigns: %{user: user}} = conn,
172 %{"id" => id, "description" => description} = _
173 )
174 when is_binary(description) do
175 with %Object{} = object <- Repo.get(Object, id),
176 true <- Object.authorize_mutation(object, user),
177 {:ok, %Object{data: data}} <- Object.update_data(object, %{"name" => description}) do
178 attachment_data = Map.put(data, "id", object.id)
179
180 conn
181 |> put_view(StatusView)
182 |> render("attachment.json", %{attachment: attachment_data})
183 end
184 end
185
186 def update_media(_conn, _data), do: {:error, :bad_request}
187
188 def upload(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do
189 with {:ok, object} <-
190 ActivityPub.upload(
191 file,
192 actor: User.ap_id(user),
193 description: Map.get(data, "description")
194 ) do
195 attachment_data = Map.put(object.data, "id", object.id)
196
197 conn
198 |> put_view(StatusView)
199 |> render("attachment.json", %{attachment: attachment_data})
200 end
201 end
202
203 def follows(%{assigns: %{user: follower}} = conn, %{"uri" => uri}) do
204 with {_, %User{} = followed} <- {:followed, User.get_cached_by_nickname(uri)},
205 {_, true} <- {:followed, follower.id != followed.id},
206 {:ok, follower, followed, _} <- CommonAPI.follow(follower, followed) do
207 conn
208 |> put_view(AccountView)
209 |> render("show.json", %{user: followed, for: follower})
210 else
211 {:followed, _} ->
212 {:error, :not_found}
213
214 {:error, message} ->
215 conn
216 |> put_status(:forbidden)
217 |> json(%{error: message})
218 end
219 end
220
221 def mutes(%{assigns: %{user: user}} = conn, _) do
222 with muted_accounts <- User.muted_users(user) do
223 res = AccountView.render("index.json", users: muted_accounts, for: user, as: :user)
224 json(conn, res)
225 end
226 end
227
228 def blocks(%{assigns: %{user: user}} = conn, _) do
229 with blocked_accounts <- User.blocked_users(user) do
230 res = AccountView.render("index.json", users: blocked_accounts, for: user, as: :user)
231 json(conn, res)
232 end
233 end
234
235 def favourites(%{assigns: %{user: user}} = conn, params) do
236 params =
237 params
238 |> Map.put("type", "Create")
239 |> Map.put("favorited_by", user.ap_id)
240 |> Map.put("blocking_user", user)
241
242 activities =
243 ActivityPub.fetch_activities([], params)
244 |> Enum.reverse()
245
246 conn
247 |> add_link_headers(activities)
248 |> put_view(StatusView)
249 |> render("index.json", %{activities: activities, for: user, as: :activity})
250 end
251
252 def bookmarks(%{assigns: %{user: user}} = conn, params) do
253 user = User.get_cached_by_id(user.id)
254
255 bookmarks =
256 Bookmark.for_user_query(user.id)
257 |> Pagination.fetch_paginated(params)
258
259 activities =
260 bookmarks
261 |> Enum.map(fn b -> Map.put(b.activity, :bookmark, Map.delete(b, :activity)) end)
262
263 conn
264 |> add_link_headers(bookmarks)
265 |> put_view(StatusView)
266 |> render("index.json", %{activities: activities, for: user, as: :activity})
267 end
268
269 def index(%{assigns: %{user: user}} = conn, _params) do
270 token = get_session(conn, :oauth_token)
271
272 if user && token do
273 mastodon_emoji = mastodonized_emoji()
274
275 limit = Config.get([:instance, :limit])
276
277 accounts = Map.put(%{}, user.id, AccountView.render("show.json", %{user: user, for: user}))
278
279 initial_state =
280 %{
281 meta: %{
282 streaming_api_base_url: Pleroma.Web.Endpoint.websocket_url(),
283 access_token: token,
284 locale: "en",
285 domain: Pleroma.Web.Endpoint.host(),
286 admin: "1",
287 me: "#{user.id}",
288 unfollow_modal: false,
289 boost_modal: false,
290 delete_modal: true,
291 auto_play_gif: false,
292 display_sensitive_media: false,
293 reduce_motion: false,
294 max_toot_chars: limit,
295 mascot: User.get_mascot(user)["url"]
296 },
297 poll_limits: Config.get([:instance, :poll_limits]),
298 rights: %{
299 delete_others_notice: present?(user.info.is_moderator),
300 admin: present?(user.info.is_admin)
301 },
302 compose: %{
303 me: "#{user.id}",
304 default_privacy: user.info.default_scope,
305 default_sensitive: false,
306 allow_content_types: Config.get([:instance, :allowed_post_formats])
307 },
308 media_attachments: %{
309 accept_content_types: [
310 ".jpg",
311 ".jpeg",
312 ".png",
313 ".gif",
314 ".webm",
315 ".mp4",
316 ".m4v",
317 "image\/jpeg",
318 "image\/png",
319 "image\/gif",
320 "video\/webm",
321 "video\/mp4"
322 ]
323 },
324 settings:
325 user.info.settings ||
326 %{
327 onboarded: true,
328 home: %{
329 shows: %{
330 reblog: true,
331 reply: true
332 }
333 },
334 notifications: %{
335 alerts: %{
336 follow: true,
337 favourite: true,
338 reblog: true,
339 mention: true
340 },
341 shows: %{
342 follow: true,
343 favourite: true,
344 reblog: true,
345 mention: true
346 },
347 sounds: %{
348 follow: true,
349 favourite: true,
350 reblog: true,
351 mention: true
352 }
353 }
354 },
355 push_subscription: nil,
356 accounts: accounts,
357 custom_emojis: mastodon_emoji,
358 char_limit: limit
359 }
360 |> Jason.encode!()
361
362 conn
363 |> put_layout(false)
364 |> put_view(MastodonView)
365 |> render("index.html", %{initial_state: initial_state})
366 else
367 conn
368 |> put_session(:return_to, conn.request_path)
369 |> redirect(to: "/web/login")
370 end
371 end
372
373 def put_settings(%{assigns: %{user: user}} = conn, %{"data" => settings} = _params) do
374 with {:ok, _} <- User.update_info(user, &User.Info.mastodon_settings_update(&1, settings)) do
375 json(conn, %{})
376 else
377 e ->
378 conn
379 |> put_status(:internal_server_error)
380 |> json(%{error: inspect(e)})
381 end
382 end
383
384 def login(%{assigns: %{user: %User{}}} = conn, _params) do
385 redirect(conn, to: local_mastodon_root_path(conn))
386 end
387
388 @doc "Local Mastodon FE login init action"
389 def login(conn, %{"code" => auth_token}) do
390 with {:ok, app} <- get_or_make_app(),
391 {:ok, auth} <- Authorization.get_by_token(app, auth_token),
392 {:ok, token} <- Token.exchange_token(app, auth) do
393 conn
394 |> put_session(:oauth_token, token.token)
395 |> redirect(to: local_mastodon_root_path(conn))
396 end
397 end
398
399 @doc "Local Mastodon FE callback action"
400 def login(conn, _) do
401 with {:ok, app} <- get_or_make_app() do
402 path =
403 o_auth_path(conn, :authorize,
404 response_type: "code",
405 client_id: app.client_id,
406 redirect_uri: ".",
407 scope: Enum.join(app.scopes, " ")
408 )
409
410 redirect(conn, to: path)
411 end
412 end
413
414 defp local_mastodon_root_path(conn) do
415 case get_session(conn, :return_to) do
416 nil ->
417 mastodon_api_path(conn, :index, ["getting-started"])
418
419 return_to ->
420 delete_session(conn, :return_to)
421 return_to
422 end
423 end
424
425 @spec get_or_make_app() :: {:ok, App.t()} | {:error, Ecto.Changeset.t()}
426 defp get_or_make_app do
427 App.get_or_make(
428 %{client_name: @local_mastodon_name, redirect_uris: "."},
429 ["read", "write", "follow", "push"]
430 )
431 end
432
433 def logout(conn, _) do
434 conn
435 |> clear_session
436 |> redirect(to: "/")
437 end
438
439 # Stubs for unimplemented mastodon api
440 #
441 def empty_array(conn, _) do
442 Logger.debug("Unimplemented, returning an empty array")
443 json(conn, [])
444 end
445
446 def empty_object(conn, _) do
447 Logger.debug("Unimplemented, returning an empty object")
448 json(conn, %{})
449 end
450
451 def suggestions(%{assigns: %{user: user}} = conn, _) do
452 suggestions = Config.get(:suggestions)
453
454 if Keyword.get(suggestions, :enabled, false) do
455 api = Keyword.get(suggestions, :third_party_engine, "")
456 timeout = Keyword.get(suggestions, :timeout, 5000)
457 limit = Keyword.get(suggestions, :limit, 23)
458
459 host = Config.get([Pleroma.Web.Endpoint, :url, :host])
460
461 user = user.nickname
462
463 url =
464 api
465 |> String.replace("{{host}}", host)
466 |> String.replace("{{user}}", user)
467
468 with {:ok, %{status: 200, body: body}} <-
469 HTTP.get(url, [], adapter: [recv_timeout: timeout, pool: :default]),
470 {:ok, data} <- Jason.decode(body) do
471 data =
472 data
473 |> Enum.slice(0, limit)
474 |> Enum.map(fn x ->
475 x
476 |> Map.put("id", fetch_suggestion_id(x))
477 |> Map.put("avatar", MediaProxy.url(x["avatar"]))
478 |> Map.put("avatar_static", MediaProxy.url(x["avatar_static"]))
479 end)
480
481 json(conn, data)
482 else
483 e ->
484 Logger.error("Could not retrieve suggestions at fetch #{url}, #{inspect(e)}")
485 end
486 else
487 json(conn, [])
488 end
489 end
490
491 defp fetch_suggestion_id(attrs) do
492 case User.get_or_fetch(attrs["acct"]) do
493 {:ok, %User{id: id}} -> id
494 _ -> 0
495 end
496 end
497
498 def password_reset(conn, params) do
499 nickname_or_email = params["email"] || params["nickname"]
500
501 with {:ok, _} <- TwitterAPI.password_reset(nickname_or_email) do
502 conn
503 |> put_status(:no_content)
504 |> json("")
505 else
506 {:error, "unknown user"} ->
507 send_resp(conn, :not_found, "")
508
509 {:error, _} ->
510 send_resp(conn, :bad_request, "")
511 end
512 end
513
514 def try_render(conn, target, params)
515 when is_binary(target) do
516 case render(conn, target, params) do
517 nil -> render_error(conn, :not_implemented, "Can't display this activity")
518 res -> res
519 end
520 end
521
522 def try_render(conn, _, _) do
523 render_error(conn, :not_implemented, "Can't display this activity")
524 end
525
526 defp present?(nil), do: false
527 defp present?(false), do: false
528 defp present?(_), do: true
529 end