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]
10 alias Pleroma.Activity
11 alias Pleroma.Bookmark
15 alias Pleroma.Pagination
16 alias Pleroma.Plugs.RateLimiter
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
37 plug(RateLimiter, :password_reset when action == :password_reset)
39 @local_mastodon_name "Mastodon-Local"
41 action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
43 def create_app(conn, params) do
44 scopes = Scopes.fetch_scopes(params, ["read"])
48 |> Map.drop(["scope", "scopes"])
49 |> Map.put("scopes", scopes)
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
56 |> render("show.json", %{app: app})
60 def verify_app_credentials(%{assigns: %{user: _user, token: token}} = conn, _) do
61 with %Token{app: %App{} = app} <- Repo.preload(token, :app) do
64 |> render("short.json", %{app: app})
68 @mastodon_api_level "2.7.2"
70 def masto_instance(conn, _params) do
71 instance = Config.get(:instance)
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),
80 streaming_api: Pleroma.Web.Endpoint.websocket_url()
82 stats: Stats.get_stats(),
83 thumbnail: Web.base_url() <> "/instance/thumbnail.jpeg",
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)
94 def peers(conn, _params) do
95 json(conn, Stats.get_peers())
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))
104 "shortcode" => shortcode,
106 "visible_in_picker" => true,
109 # Assuming that a comma is authorized in the category name
110 "category" => (tags -- ["Custom"]) |> Enum.join(",")
115 def custom_emojis(conn, _params) do
116 mastodon_emoji = mastodonized_emoji()
117 json(conn, mastodon_emoji)
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
125 |> put_view(StatusView)
126 |> try_render("poll.json", %{object: object, for: user})
128 error when is_nil(error) or error == false ->
129 render_error(conn, :not_found, "Record not found")
133 defp get_cached_vote_or_vote(user, object, choices) do
134 idempotency_key = "polls:#{user.id}:#{object.data["id"]}"
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}
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
154 |> put_view(StatusView)
155 |> try_render("poll.json", %{object: object, for: user})
158 render_error(conn, :not_found, "Record not found")
161 render_error(conn, :not_found, "Record not found")
165 |> put_status(:unprocessable_entity)
166 |> json(%{error: message})
171 %{assigns: %{user: user}} = conn,
172 %{"id" => id, "description" => description} = _
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)
181 |> put_view(StatusView)
182 |> render("attachment.json", %{attachment: attachment_data})
186 def update_media(_conn, _data), do: {:error, :bad_request}
188 def upload(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do
189 with {:ok, object} <-
192 actor: User.ap_id(user),
193 description: Map.get(data, "description")
195 attachment_data = Map.put(object.data, "id", object.id)
198 |> put_view(StatusView)
199 |> render("attachment.json", %{attachment: attachment_data})
203 def set_mascot(%{assigns: %{user: user}} = conn, %{"file" => file}) do
204 with {:ok, object} <- ActivityPub.upload(file, actor: User.ap_id(user)),
205 %{} = attachment_data <- Map.put(object.data, "id", object.id),
206 # Reject if not an image
207 %{type: "image"} = rendered <-
208 StatusView.render("attachment.json", %{attachment: attachment_data}) do
210 # Save to the user's info
211 {:ok, _user} = User.update_info(user, &User.Info.mascot_update(&1, rendered))
215 %{type: _} -> render_error(conn, :unsupported_media_type, "mascots can only be images")
219 def get_mascot(%{assigns: %{user: user}} = conn, _params) do
220 mascot = User.get_mascot(user)
225 def follows(%{assigns: %{user: follower}} = conn, %{"uri" => uri}) do
226 with {_, %User{} = followed} <- {:followed, User.get_cached_by_nickname(uri)},
227 {_, true} <- {:followed, follower.id != followed.id},
228 {:ok, follower, followed, _} <- CommonAPI.follow(follower, followed) do
230 |> put_view(AccountView)
231 |> render("show.json", %{user: followed, for: follower})
238 |> put_status(:forbidden)
239 |> json(%{error: message})
243 def mutes(%{assigns: %{user: user}} = conn, _) do
244 with muted_accounts <- User.muted_users(user) do
245 res = AccountView.render("index.json", users: muted_accounts, for: user, as: :user)
250 def blocks(%{assigns: %{user: user}} = conn, _) do
251 with blocked_accounts <- User.blocked_users(user) do
252 res = AccountView.render("index.json", users: blocked_accounts, for: user, as: :user)
257 def favourites(%{assigns: %{user: user}} = conn, params) do
260 |> Map.put("type", "Create")
261 |> Map.put("favorited_by", user.ap_id)
262 |> Map.put("blocking_user", user)
265 ActivityPub.fetch_activities([], params)
269 |> add_link_headers(activities)
270 |> put_view(StatusView)
271 |> render("index.json", %{activities: activities, for: user, as: :activity})
274 def bookmarks(%{assigns: %{user: user}} = conn, params) do
275 user = User.get_cached_by_id(user.id)
278 Bookmark.for_user_query(user.id)
279 |> Pagination.fetch_paginated(params)
283 |> Enum.map(fn b -> Map.put(b.activity, :bookmark, Map.delete(b, :activity)) end)
286 |> add_link_headers(bookmarks)
287 |> put_view(StatusView)
288 |> render("index.json", %{activities: activities, for: user, as: :activity})
291 def index(%{assigns: %{user: user}} = conn, _params) do
292 token = get_session(conn, :oauth_token)
295 mastodon_emoji = mastodonized_emoji()
297 limit = Config.get([:instance, :limit])
299 accounts = Map.put(%{}, user.id, AccountView.render("show.json", %{user: user, for: user}))
304 streaming_api_base_url: Pleroma.Web.Endpoint.websocket_url(),
307 domain: Pleroma.Web.Endpoint.host(),
310 unfollow_modal: false,
313 auto_play_gif: false,
314 display_sensitive_media: false,
315 reduce_motion: false,
316 max_toot_chars: limit,
317 mascot: User.get_mascot(user)["url"]
319 poll_limits: Config.get([:instance, :poll_limits]),
321 delete_others_notice: present?(user.info.is_moderator),
322 admin: present?(user.info.is_admin)
326 default_privacy: user.info.default_scope,
327 default_sensitive: false,
328 allow_content_types: Config.get([:instance, :allowed_post_formats])
330 media_attachments: %{
331 accept_content_types: [
347 user.info.settings ||
377 push_subscription: nil,
379 custom_emojis: mastodon_emoji,
386 |> put_view(MastodonView)
387 |> render("index.html", %{initial_state: initial_state})
390 |> put_session(:return_to, conn.request_path)
391 |> redirect(to: "/web/login")
395 def put_settings(%{assigns: %{user: user}} = conn, %{"data" => settings} = _params) do
396 with {:ok, _} <- User.update_info(user, &User.Info.mastodon_settings_update(&1, settings)) do
401 |> put_status(:internal_server_error)
402 |> json(%{error: inspect(e)})
406 def login(%{assigns: %{user: %User{}}} = conn, _params) do
407 redirect(conn, to: local_mastodon_root_path(conn))
410 @doc "Local Mastodon FE login init action"
411 def login(conn, %{"code" => auth_token}) do
412 with {:ok, app} <- get_or_make_app(),
413 {:ok, auth} <- Authorization.get_by_token(app, auth_token),
414 {:ok, token} <- Token.exchange_token(app, auth) do
416 |> put_session(:oauth_token, token.token)
417 |> redirect(to: local_mastodon_root_path(conn))
421 @doc "Local Mastodon FE callback action"
422 def login(conn, _) do
423 with {:ok, app} <- get_or_make_app() do
425 o_auth_path(conn, :authorize,
426 response_type: "code",
427 client_id: app.client_id,
429 scope: Enum.join(app.scopes, " ")
432 redirect(conn, to: path)
436 defp local_mastodon_root_path(conn) do
437 case get_session(conn, :return_to) do
439 mastodon_api_path(conn, :index, ["getting-started"])
442 delete_session(conn, :return_to)
447 @spec get_or_make_app() :: {:ok, App.t()} | {:error, Ecto.Changeset.t()}
448 defp get_or_make_app do
450 %{client_name: @local_mastodon_name, redirect_uris: "."},
451 ["read", "write", "follow", "push"]
455 def logout(conn, _) do
461 # Stubs for unimplemented mastodon api
463 def empty_array(conn, _) do
464 Logger.debug("Unimplemented, returning an empty array")
468 def empty_object(conn, _) do
469 Logger.debug("Unimplemented, returning an empty object")
473 def suggestions(%{assigns: %{user: user}} = conn, _) do
474 suggestions = Config.get(:suggestions)
476 if Keyword.get(suggestions, :enabled, false) do
477 api = Keyword.get(suggestions, :third_party_engine, "")
478 timeout = Keyword.get(suggestions, :timeout, 5000)
479 limit = Keyword.get(suggestions, :limit, 23)
481 host = Config.get([Pleroma.Web.Endpoint, :url, :host])
487 |> String.replace("{{host}}", host)
488 |> String.replace("{{user}}", user)
490 with {:ok, %{status: 200, body: body}} <-
491 HTTP.get(url, [], adapter: [recv_timeout: timeout, pool: :default]),
492 {:ok, data} <- Jason.decode(body) do
495 |> Enum.slice(0, limit)
498 |> Map.put("id", fetch_suggestion_id(x))
499 |> Map.put("avatar", MediaProxy.url(x["avatar"]))
500 |> Map.put("avatar_static", MediaProxy.url(x["avatar_static"]))
506 Logger.error("Could not retrieve suggestions at fetch #{url}, #{inspect(e)}")
513 defp fetch_suggestion_id(attrs) do
514 case User.get_or_fetch(attrs["acct"]) do
515 {:ok, %User{id: id}} -> id
520 def password_reset(conn, params) do
521 nickname_or_email = params["email"] || params["nickname"]
523 with {:ok, _} <- TwitterAPI.password_reset(nickname_or_email) do
525 |> put_status(:no_content)
528 {:error, "unknown user"} ->
529 send_resp(conn, :not_found, "")
532 send_resp(conn, :bad_request, "")
536 def try_render(conn, target, params)
537 when is_binary(target) do
538 case render(conn, target, params) do
539 nil -> render_error(conn, :not_implemented, "Can't display this activity")
544 def try_render(conn, _, _) do
545 render_error(conn, :not_implemented, "Can't display this activity")
548 defp present?(nil), do: false
549 defp present?(false), do: false
550 defp present?(_), do: true