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