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, only: [add_link_headers: 2, truthy_param?: 1]
10 alias Pleroma.Activity
11 alias Pleroma.Bookmark
16 alias Pleroma.Pagination
17 alias Pleroma.Plugs.RateLimiter
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
38 plug(RateLimiter, :password_reset when action == :password_reset)
40 @local_mastodon_name "Mastodon-Local"
42 action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
44 def create_app(conn, params) do
45 scopes = Scopes.fetch_scopes(params, ["read"])
49 |> Map.drop(["scope", "scopes"])
50 |> Map.put("scopes", scopes)
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
57 |> render("show.json", %{app: app})
66 value_function \\ fn x -> {:ok, x} end
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)
78 def update_credentials(%{assigns: %{user: user}} = conn, params) do
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
94 emojis_text = (user_params["display_name"] || "") <> (user_params["note"] || "")
98 |> Map.get(:emoji, [])
99 |> Enum.concat(Emoji.Formatter.get_emoji_map(emojis_text))
106 :hide_followers_count,
112 :skip_thread_containment,
115 |> Enum.reduce(%{}, fn key, acc ->
116 add_if_present(acc, params, to_string(key), key, fn value ->
117 {:ok, truthy_param?(value)}
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)
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)}
130 |> add_if_present(params, "header", :banner, fn value ->
131 with %Plug.Upload{} <- value,
132 {:ok, object} <- ActivityPub.upload(value, type: :banner) do
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
146 |> Map.put(:emoji, user_info_emojis)
150 |> User.update_changeset(user_params)
151 |> User.change_info(&User.Info.profile_update(&1, info_params))
153 with {:ok, user} <- User.update_and_set_cache(changeset) do
154 if original_user != user, do: CommonAPI.update(user)
158 AccountView.render("show.json", %{user: user, for: user, with_pleroma_settings: true})
161 _e -> render_error(conn, :forbidden, "Invalid request")
165 def verify_app_credentials(%{assigns: %{user: _user, token: token}} = conn, _) do
166 with %Token{app: %App{} = app} <- Repo.preload(token, :app) do
169 |> render("short.json", %{app: app})
173 @mastodon_api_level "2.7.2"
175 def masto_instance(conn, _params) do
176 instance = Config.get(:instance)
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),
185 streaming_api: Pleroma.Web.Endpoint.websocket_url()
187 stats: Stats.get_stats(),
188 thumbnail: Web.base_url() <> "/instance/thumbnail.jpeg",
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)
199 def peers(conn, _params) do
200 json(conn, Stats.get_peers())
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))
209 "shortcode" => shortcode,
211 "visible_in_picker" => true,
214 # Assuming that a comma is authorized in the category name
215 "category" => (tags -- ["Custom"]) |> Enum.join(",")
220 def custom_emojis(conn, _params) do
221 mastodon_emoji = mastodonized_emoji()
222 json(conn, mastodon_emoji)
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
230 |> put_view(StatusView)
231 |> try_render("poll.json", %{object: object, for: user})
233 error when is_nil(error) or error == false ->
234 render_error(conn, :not_found, "Record not found")
238 defp get_cached_vote_or_vote(user, object, choices) do
239 idempotency_key = "polls:#{user.id}:#{object.data["id"]}"
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}
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
259 |> put_view(StatusView)
260 |> try_render("poll.json", %{object: object, for: user})
263 render_error(conn, :not_found, "Record not found")
266 render_error(conn, :not_found, "Record not found")
270 |> put_status(:unprocessable_entity)
271 |> json(%{error: message})
276 %{assigns: %{user: user}} = conn,
277 %{"id" => id, "description" => description} = _
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)
286 |> put_view(StatusView)
287 |> render("attachment.json", %{attachment: attachment_data})
291 def update_media(_conn, _data), do: {:error, :bad_request}
293 def upload(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do
294 with {:ok, object} <-
297 actor: User.ap_id(user),
298 description: Map.get(data, "description")
300 attachment_data = Map.put(object.data, "id", object.id)
303 |> put_view(StatusView)
304 |> render("attachment.json", %{attachment: attachment_data})
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
315 # Save to the user's info
316 {:ok, _user} = User.update_info(user, &User.Info.mascot_update(&1, rendered))
320 %{type: _} -> render_error(conn, :unsupported_media_type, "mascots can only be images")
324 def get_mascot(%{assigns: %{user: user}} = conn, _params) do
325 mascot = User.get_mascot(user)
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
335 |> put_view(AccountView)
336 |> render("show.json", %{user: followed, for: follower})
343 |> put_status(:forbidden)
344 |> json(%{error: message})
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)
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)
362 def favourites(%{assigns: %{user: user}} = conn, params) do
365 |> Map.put("type", "Create")
366 |> Map.put("favorited_by", user.ap_id)
367 |> Map.put("blocking_user", user)
370 ActivityPub.fetch_activities([], params)
374 |> add_link_headers(activities)
375 |> put_view(StatusView)
376 |> render("index.json", %{activities: activities, for: user, as: :activity})
379 def bookmarks(%{assigns: %{user: user}} = conn, params) do
380 user = User.get_cached_by_id(user.id)
383 Bookmark.for_user_query(user.id)
384 |> Pagination.fetch_paginated(params)
388 |> Enum.map(fn b -> Map.put(b.activity, :bookmark, Map.delete(b, :activity)) end)
391 |> add_link_headers(bookmarks)
392 |> put_view(StatusView)
393 |> render("index.json", %{activities: activities, for: user, as: :activity})
396 def index(%{assigns: %{user: user}} = conn, _params) do
397 token = get_session(conn, :oauth_token)
400 mastodon_emoji = mastodonized_emoji()
402 limit = Config.get([:instance, :limit])
404 accounts = Map.put(%{}, user.id, AccountView.render("show.json", %{user: user, for: user}))
409 streaming_api_base_url: Pleroma.Web.Endpoint.websocket_url(),
412 domain: Pleroma.Web.Endpoint.host(),
415 unfollow_modal: false,
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"]
424 poll_limits: Config.get([:instance, :poll_limits]),
426 delete_others_notice: present?(user.info.is_moderator),
427 admin: present?(user.info.is_admin)
431 default_privacy: user.info.default_scope,
432 default_sensitive: false,
433 allow_content_types: Config.get([:instance, :allowed_post_formats])
435 media_attachments: %{
436 accept_content_types: [
452 user.info.settings ||
482 push_subscription: nil,
484 custom_emojis: mastodon_emoji,
491 |> put_view(MastodonView)
492 |> render("index.html", %{initial_state: initial_state})
495 |> put_session(:return_to, conn.request_path)
496 |> redirect(to: "/web/login")
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
506 |> put_status(:internal_server_error)
507 |> json(%{error: inspect(e)})
511 def login(%{assigns: %{user: %User{}}} = conn, _params) do
512 redirect(conn, to: local_mastodon_root_path(conn))
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
521 |> put_session(:oauth_token, token.token)
522 |> redirect(to: local_mastodon_root_path(conn))
526 @doc "Local Mastodon FE callback action"
527 def login(conn, _) do
528 with {:ok, app} <- get_or_make_app() do
530 o_auth_path(conn, :authorize,
531 response_type: "code",
532 client_id: app.client_id,
534 scope: Enum.join(app.scopes, " ")
537 redirect(conn, to: path)
541 defp local_mastodon_root_path(conn) do
542 case get_session(conn, :return_to) do
544 mastodon_api_path(conn, :index, ["getting-started"])
547 delete_session(conn, :return_to)
552 @spec get_or_make_app() :: {:ok, App.t()} | {:error, Ecto.Changeset.t()}
553 defp get_or_make_app do
555 %{client_name: @local_mastodon_name, redirect_uris: "."},
556 ["read", "write", "follow", "push"]
560 def logout(conn, _) do
566 # Stubs for unimplemented mastodon api
568 def empty_array(conn, _) do
569 Logger.debug("Unimplemented, returning an empty array")
573 def empty_object(conn, _) do
574 Logger.debug("Unimplemented, returning an empty object")
578 def suggestions(%{assigns: %{user: user}} = conn, _) do
579 suggestions = Config.get(:suggestions)
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)
586 host = Config.get([Pleroma.Web.Endpoint, :url, :host])
592 |> String.replace("{{host}}", host)
593 |> String.replace("{{user}}", user)
595 with {:ok, %{status: 200, body: body}} <-
596 HTTP.get(url, [], adapter: [recv_timeout: timeout, pool: :default]),
597 {:ok, data} <- Jason.decode(body) do
600 |> Enum.slice(0, limit)
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"]))
611 Logger.error("Could not retrieve suggestions at fetch #{url}, #{inspect(e)}")
618 defp fetch_suggestion_id(attrs) do
619 case User.get_or_fetch(attrs["acct"]) do
620 {:ok, %User{id: id}} -> id
625 def password_reset(conn, params) do
626 nickname_or_email = params["email"] || params["nickname"]
628 with {:ok, _} <- TwitterAPI.password_reset(nickname_or_email) do
630 |> put_status(:no_content)
633 {:error, "unknown user"} ->
634 send_resp(conn, :not_found, "")
637 send_resp(conn, :bad_request, "")
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")
649 def try_render(conn, _, _) do
650 render_error(conn, :not_implemented, "Can't display this activity")
653 defp present?(nil), do: false
654 defp present?(false), do: false
655 defp present?(_), do: true