1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
5 defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
6 use Pleroma.Web, :controller
8 import Pleroma.Web.ControllerHelper,
9 only: [json_response: 3, add_link_headers: 2, truthy_param?: 1]
12 alias Pleroma.Activity
13 alias Pleroma.Bookmark
18 alias Pleroma.Pagination
19 alias Pleroma.Plugs.RateLimiter
24 alias Pleroma.Web.ActivityPub.ActivityPub
25 alias Pleroma.Web.ActivityPub.Visibility
26 alias Pleroma.Web.CommonAPI
27 alias Pleroma.Web.MastodonAPI.AccountView
28 alias Pleroma.Web.MastodonAPI.AppView
29 alias Pleroma.Web.MastodonAPI.MastodonView
30 alias Pleroma.Web.MastodonAPI.StatusView
31 alias Pleroma.Web.MediaProxy
32 alias Pleroma.Web.OAuth.App
33 alias Pleroma.Web.OAuth.Authorization
34 alias Pleroma.Web.OAuth.Scopes
35 alias Pleroma.Web.OAuth.Token
36 alias Pleroma.Web.TwitterAPI.TwitterAPI
40 plug(RateLimiter, :app_account_creation when action == :account_register)
41 plug(RateLimiter, :search when action in [:search, :search2, :account_search])
42 plug(RateLimiter, :password_reset when action == :password_reset)
43 plug(RateLimiter, :account_confirmation_resend when action == :account_confirmation_resend)
45 @local_mastodon_name "Mastodon-Local"
47 action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
49 def create_app(conn, params) do
50 scopes = Scopes.fetch_scopes(params, ["read"])
54 |> Map.drop(["scope", "scopes"])
55 |> Map.put("scopes", scopes)
57 with cs <- App.register_changeset(%App{}, app_attrs),
58 false <- cs.changes[:client_name] == @local_mastodon_name,
59 {:ok, app} <- Repo.insert(cs) do
62 |> render("show.json", %{app: app})
71 value_function \\ fn x -> {:ok, x} end
73 if Map.has_key?(params, params_field) do
74 case value_function.(params[params_field]) do
75 {:ok, new_value} -> Map.put(map, map_field, new_value)
83 def update_credentials(%{assigns: %{user: user}} = conn, params) do
88 |> add_if_present(params, "display_name", :name)
89 |> add_if_present(params, "note", :bio, fn value -> {:ok, User.parse_bio(value, user)} end)
90 |> add_if_present(params, "avatar", :avatar, fn value ->
91 with %Plug.Upload{} <- value,
92 {:ok, object} <- ActivityPub.upload(value, type: :avatar) do
99 emojis_text = (user_params["display_name"] || "") <> (user_params["note"] || "")
103 |> Map.get(:emoji, [])
104 |> Enum.concat(Emoji.Formatter.get_emoji_map(emojis_text))
111 :hide_followers_count,
117 :skip_thread_containment,
120 |> Enum.reduce(%{}, fn key, acc ->
121 add_if_present(acc, params, to_string(key), key, fn value ->
122 {:ok, truthy_param?(value)}
125 |> add_if_present(params, "default_scope", :default_scope)
126 |> add_if_present(params, "fields", :fields, fn fields ->
127 fields = Enum.map(fields, fn f -> Map.update!(f, "value", &AutoLinker.link(&1)) end)
131 |> add_if_present(params, "fields", :raw_fields)
132 |> add_if_present(params, "pleroma_settings_store", :pleroma_settings_store, fn value ->
133 {:ok, Map.merge(user.info.pleroma_settings_store, value)}
135 |> add_if_present(params, "header", :banner, fn value ->
136 with %Plug.Upload{} <- value,
137 {:ok, object} <- ActivityPub.upload(value, type: :banner) do
143 |> add_if_present(params, "pleroma_background_image", :background, fn value ->
144 with %Plug.Upload{} <- value,
145 {:ok, object} <- ActivityPub.upload(value, type: :background) do
151 |> Map.put(:emoji, user_info_emojis)
155 |> User.update_changeset(user_params)
156 |> User.change_info(&User.Info.profile_update(&1, info_params))
158 with {:ok, user} <- User.update_and_set_cache(changeset) do
159 if original_user != user, do: CommonAPI.update(user)
163 AccountView.render("show.json", %{user: user, for: user, with_pleroma_settings: true})
166 _e -> render_error(conn, :forbidden, "Invalid request")
170 def update_avatar(%{assigns: %{user: user}} = conn, %{"img" => ""}) do
171 change = Changeset.change(user, %{avatar: nil})
172 {:ok, user} = User.update_and_set_cache(change)
173 CommonAPI.update(user)
175 json(conn, %{url: nil})
178 def update_avatar(%{assigns: %{user: user}} = conn, params) do
179 {:ok, object} = ActivityPub.upload(params, type: :avatar)
180 change = Changeset.change(user, %{avatar: object.data})
181 {:ok, user} = User.update_and_set_cache(change)
182 CommonAPI.update(user)
183 %{"url" => [%{"href" => href} | _]} = object.data
185 json(conn, %{url: href})
188 def update_banner(%{assigns: %{user: user}} = conn, %{"banner" => ""}) do
189 new_info = %{"banner" => %{}}
191 with {:ok, user} <- User.update_info(user, &User.Info.profile_update(&1, new_info)) do
192 CommonAPI.update(user)
193 json(conn, %{url: nil})
197 def update_banner(%{assigns: %{user: user}} = conn, params) do
198 with {:ok, object} <- ActivityPub.upload(%{"img" => params["banner"]}, type: :banner),
199 new_info <- %{"banner" => object.data},
200 {:ok, user} <- User.update_info(user, &User.Info.profile_update(&1, new_info)) do
201 CommonAPI.update(user)
202 %{"url" => [%{"href" => href} | _]} = object.data
204 json(conn, %{url: href})
208 def update_background(%{assigns: %{user: user}} = conn, %{"img" => ""}) do
209 new_info = %{"background" => %{}}
211 with {:ok, _user} <- User.update_info(user, &User.Info.profile_update(&1, new_info)) do
212 json(conn, %{url: nil})
216 def update_background(%{assigns: %{user: user}} = conn, params) do
217 with {:ok, object} <- ActivityPub.upload(params, type: :background),
218 new_info <- %{"background" => object.data},
219 {:ok, _user} <- User.update_info(user, &User.Info.profile_update(&1, new_info)) do
220 %{"url" => [%{"href" => href} | _]} = object.data
222 json(conn, %{url: href})
226 def verify_credentials(%{assigns: %{user: user}} = conn, _) do
227 chat_token = Phoenix.Token.sign(conn, "user socket", user.id)
230 AccountView.render("show.json", %{
233 with_pleroma_settings: true,
234 with_chat_token: chat_token
240 def verify_app_credentials(%{assigns: %{user: _user, token: token}} = conn, _) do
241 with %Token{app: %App{} = app} <- Repo.preload(token, :app) do
244 |> render("short.json", %{app: app})
248 @mastodon_api_level "2.7.2"
250 def masto_instance(conn, _params) do
251 instance = Config.get(:instance)
255 title: Keyword.get(instance, :name),
256 description: Keyword.get(instance, :description),
257 version: "#{@mastodon_api_level} (compatible; #{Pleroma.Application.named_version()})",
258 email: Keyword.get(instance, :email),
260 streaming_api: Pleroma.Web.Endpoint.websocket_url()
262 stats: Stats.get_stats(),
263 thumbnail: Web.base_url() <> "/instance/thumbnail.jpeg",
265 registrations: Pleroma.Config.get([:instance, :registrations_open]),
266 # Extra (not present in Mastodon):
267 max_toot_chars: Keyword.get(instance, :limit),
268 poll_limits: Keyword.get(instance, :poll_limits)
274 def peers(conn, _params) do
275 json(conn, Stats.get_peers())
278 defp mastodonized_emoji do
279 Pleroma.Emoji.get_all()
280 |> Enum.map(fn {shortcode, %Pleroma.Emoji{file: relative_url, tags: tags}} ->
281 url = to_string(URI.merge(Web.base_url(), relative_url))
284 "shortcode" => shortcode,
286 "visible_in_picker" => true,
289 # Assuming that a comma is authorized in the category name
290 "category" => (tags -- ["Custom"]) |> Enum.join(",")
295 def custom_emojis(conn, _params) do
296 mastodon_emoji = mastodonized_emoji()
297 json(conn, mastodon_emoji)
300 def get_poll(%{assigns: %{user: user}} = conn, %{"id" => id}) do
301 with %Object{} = object <- Object.get_by_id_and_maybe_refetch(id, interval: 60),
302 %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]),
303 true <- Visibility.visible_for_user?(activity, user) do
305 |> put_view(StatusView)
306 |> try_render("poll.json", %{object: object, for: user})
308 error when is_nil(error) or error == false ->
309 render_error(conn, :not_found, "Record not found")
313 defp get_cached_vote_or_vote(user, object, choices) do
314 idempotency_key = "polls:#{user.id}:#{object.data["id"]}"
317 Cachex.fetch(:idempotency_cache, idempotency_key, fn _ ->
318 case CommonAPI.vote(user, object, choices) do
319 {:error, _message} = res -> {:ignore, res}
320 res -> {:commit, res}
327 def poll_vote(%{assigns: %{user: user}} = conn, %{"id" => id, "choices" => choices}) do
328 with %Object{} = object <- Object.get_by_id(id),
329 true <- object.data["type"] == "Question",
330 %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]),
331 true <- Visibility.visible_for_user?(activity, user),
332 {:ok, _activities, object} <- get_cached_vote_or_vote(user, object, choices) do
334 |> put_view(StatusView)
335 |> try_render("poll.json", %{object: object, for: user})
338 render_error(conn, :not_found, "Record not found")
341 render_error(conn, :not_found, "Record not found")
345 |> put_status(:unprocessable_entity)
346 |> json(%{error: message})
350 def relationships(%{assigns: %{user: user}} = conn, %{"id" => id}) do
351 targets = User.get_all_by_ids(List.wrap(id))
354 |> put_view(AccountView)
355 |> render("relationships.json", %{user: user, targets: targets})
358 # Instead of returning a 400 when no "id" params is present, Mastodon returns an empty array.
359 def relationships(%{assigns: %{user: _user}} = conn, _), do: json(conn, [])
362 %{assigns: %{user: user}} = conn,
363 %{"id" => id, "description" => description} = _
365 when is_binary(description) do
366 with %Object{} = object <- Repo.get(Object, id),
367 true <- Object.authorize_mutation(object, user),
368 {:ok, %Object{data: data}} <- Object.update_data(object, %{"name" => description}) do
369 attachment_data = Map.put(data, "id", object.id)
372 |> put_view(StatusView)
373 |> render("attachment.json", %{attachment: attachment_data})
377 def update_media(_conn, _data), do: {:error, :bad_request}
379 def upload(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do
380 with {:ok, object} <-
383 actor: User.ap_id(user),
384 description: Map.get(data, "description")
386 attachment_data = Map.put(object.data, "id", object.id)
389 |> put_view(StatusView)
390 |> render("attachment.json", %{attachment: attachment_data})
394 def set_mascot(%{assigns: %{user: user}} = conn, %{"file" => file}) do
395 with {:ok, object} <- ActivityPub.upload(file, actor: User.ap_id(user)),
396 %{} = attachment_data <- Map.put(object.data, "id", object.id),
397 # Reject if not an image
398 %{type: "image"} = rendered <-
399 StatusView.render("attachment.json", %{attachment: attachment_data}) do
401 # Save to the user's info
402 {:ok, _user} = User.update_info(user, &User.Info.mascot_update(&1, rendered))
406 %{type: _} -> render_error(conn, :unsupported_media_type, "mascots can only be images")
410 def get_mascot(%{assigns: %{user: user}} = conn, _params) do
411 mascot = User.get_mascot(user)
416 def follows(%{assigns: %{user: follower}} = conn, %{"uri" => uri}) do
417 with {_, %User{} = followed} <- {:followed, User.get_cached_by_nickname(uri)},
418 {_, true} <- {:followed, follower.id != followed.id},
419 {:ok, follower, followed, _} <- CommonAPI.follow(follower, followed) do
421 |> put_view(AccountView)
422 |> render("show.json", %{user: followed, for: follower})
429 |> put_status(:forbidden)
430 |> json(%{error: message})
434 def mutes(%{assigns: %{user: user}} = conn, _) do
435 with muted_accounts <- User.muted_users(user) do
436 res = AccountView.render("index.json", users: muted_accounts, for: user, as: :user)
441 def blocks(%{assigns: %{user: user}} = conn, _) do
442 with blocked_accounts <- User.blocked_users(user) do
443 res = AccountView.render("index.json", users: blocked_accounts, for: user, as: :user)
448 def favourites(%{assigns: %{user: user}} = conn, params) do
451 |> Map.put("type", "Create")
452 |> Map.put("favorited_by", user.ap_id)
453 |> Map.put("blocking_user", user)
456 ActivityPub.fetch_activities([], params)
460 |> add_link_headers(activities)
461 |> put_view(StatusView)
462 |> render("index.json", %{activities: activities, for: user, as: :activity})
465 def bookmarks(%{assigns: %{user: user}} = conn, params) do
466 user = User.get_cached_by_id(user.id)
469 Bookmark.for_user_query(user.id)
470 |> Pagination.fetch_paginated(params)
474 |> Enum.map(fn b -> Map.put(b.activity, :bookmark, Map.delete(b, :activity)) end)
477 |> add_link_headers(bookmarks)
478 |> put_view(StatusView)
479 |> render("index.json", %{activities: activities, for: user, as: :activity})
482 def index(%{assigns: %{user: user}} = conn, _params) do
483 token = get_session(conn, :oauth_token)
486 mastodon_emoji = mastodonized_emoji()
488 limit = Config.get([:instance, :limit])
490 accounts = Map.put(%{}, user.id, AccountView.render("show.json", %{user: user, for: user}))
495 streaming_api_base_url: Pleroma.Web.Endpoint.websocket_url(),
498 domain: Pleroma.Web.Endpoint.host(),
501 unfollow_modal: false,
504 auto_play_gif: false,
505 display_sensitive_media: false,
506 reduce_motion: false,
507 max_toot_chars: limit,
508 mascot: User.get_mascot(user)["url"]
510 poll_limits: Config.get([:instance, :poll_limits]),
512 delete_others_notice: present?(user.info.is_moderator),
513 admin: present?(user.info.is_admin)
517 default_privacy: user.info.default_scope,
518 default_sensitive: false,
519 allow_content_types: Config.get([:instance, :allowed_post_formats])
521 media_attachments: %{
522 accept_content_types: [
538 user.info.settings ||
568 push_subscription: nil,
570 custom_emojis: mastodon_emoji,
577 |> put_view(MastodonView)
578 |> render("index.html", %{initial_state: initial_state})
581 |> put_session(:return_to, conn.request_path)
582 |> redirect(to: "/web/login")
586 def put_settings(%{assigns: %{user: user}} = conn, %{"data" => settings} = _params) do
587 with {:ok, _} <- User.update_info(user, &User.Info.mastodon_settings_update(&1, settings)) do
592 |> put_status(:internal_server_error)
593 |> json(%{error: inspect(e)})
597 def login(%{assigns: %{user: %User{}}} = conn, _params) do
598 redirect(conn, to: local_mastodon_root_path(conn))
601 @doc "Local Mastodon FE login init action"
602 def login(conn, %{"code" => auth_token}) do
603 with {:ok, app} <- get_or_make_app(),
604 {:ok, auth} <- Authorization.get_by_token(app, auth_token),
605 {:ok, token} <- Token.exchange_token(app, auth) do
607 |> put_session(:oauth_token, token.token)
608 |> redirect(to: local_mastodon_root_path(conn))
612 @doc "Local Mastodon FE callback action"
613 def login(conn, _) do
614 with {:ok, app} <- get_or_make_app() do
616 o_auth_path(conn, :authorize,
617 response_type: "code",
618 client_id: app.client_id,
620 scope: Enum.join(app.scopes, " ")
623 redirect(conn, to: path)
627 defp local_mastodon_root_path(conn) do
628 case get_session(conn, :return_to) do
630 mastodon_api_path(conn, :index, ["getting-started"])
633 delete_session(conn, :return_to)
638 @spec get_or_make_app() :: {:ok, App.t()} | {:error, Ecto.Changeset.t()}
639 defp get_or_make_app do
641 %{client_name: @local_mastodon_name, redirect_uris: "."},
642 ["read", "write", "follow", "push"]
646 def logout(conn, _) do
652 # Stubs for unimplemented mastodon api
654 def empty_array(conn, _) do
655 Logger.debug("Unimplemented, returning an empty array")
659 def empty_object(conn, _) do
660 Logger.debug("Unimplemented, returning an empty object")
664 def suggestions(%{assigns: %{user: user}} = conn, _) do
665 suggestions = Config.get(:suggestions)
667 if Keyword.get(suggestions, :enabled, false) do
668 api = Keyword.get(suggestions, :third_party_engine, "")
669 timeout = Keyword.get(suggestions, :timeout, 5000)
670 limit = Keyword.get(suggestions, :limit, 23)
672 host = Config.get([Pleroma.Web.Endpoint, :url, :host])
678 |> String.replace("{{host}}", host)
679 |> String.replace("{{user}}", user)
681 with {:ok, %{status: 200, body: body}} <-
682 HTTP.get(url, [], adapter: [recv_timeout: timeout, pool: :default]),
683 {:ok, data} <- Jason.decode(body) do
686 |> Enum.slice(0, limit)
689 |> Map.put("id", fetch_suggestion_id(x))
690 |> Map.put("avatar", MediaProxy.url(x["avatar"]))
691 |> Map.put("avatar_static", MediaProxy.url(x["avatar_static"]))
697 Logger.error("Could not retrieve suggestions at fetch #{url}, #{inspect(e)}")
704 defp fetch_suggestion_id(attrs) do
705 case User.get_or_fetch(attrs["acct"]) do
706 {:ok, %User{id: id}} -> id
711 def account_register(
712 %{assigns: %{app: app}} = conn,
713 %{"username" => nickname, "email" => _, "password" => _, "agreement" => true} = params
721 "captcha_answer_data",
725 |> Map.put("nickname", nickname)
726 |> Map.put("fullname", params["fullname"] || nickname)
727 |> Map.put("bio", params["bio"] || "")
728 |> Map.put("confirm", params["password"])
730 with {:ok, user} <- TwitterAPI.register_user(params, need_confirmation: true),
731 {:ok, token} <- Token.create_token(app, user, %{scopes: app.scopes}) do
733 token_type: "Bearer",
734 access_token: token.token,
736 created_at: Token.Utils.format_created_at(token)
741 |> put_status(:bad_request)
746 def account_register(%{assigns: %{app: _app}} = conn, _) do
747 render_error(conn, :bad_request, "Missing parameters")
750 def account_register(conn, _) do
751 render_error(conn, :forbidden, "Invalid credentials")
754 def password_reset(conn, params) do
755 nickname_or_email = params["email"] || params["nickname"]
757 with {:ok, _} <- TwitterAPI.password_reset(nickname_or_email) do
759 |> put_status(:no_content)
762 {:error, "unknown user"} ->
763 send_resp(conn, :not_found, "")
766 send_resp(conn, :bad_request, "")
770 def account_confirmation_resend(conn, params) do
771 nickname_or_email = params["email"] || params["nickname"]
773 with %User{} = user <- User.get_by_nickname_or_email(nickname_or_email),
774 {:ok, _} <- User.try_send_confirmation_email(user) do
776 |> json_response(:no_content, "")
780 def try_render(conn, target, params)
781 when is_binary(target) do
782 case render(conn, target, params) do
783 nil -> render_error(conn, :not_implemented, "Can't display this activity")
788 def try_render(conn, _, _) do
789 render_error(conn, :not_implemented, "Can't display this activity")
792 defp present?(nil), do: false
793 defp present?(false), do: false
794 defp present?(_), do: true