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.Bookmark
14 alias Pleroma.Pagination
15 alias Pleroma.Plugs.RateLimiter
20 alias Pleroma.Web.ActivityPub.ActivityPub
21 alias Pleroma.Web.CommonAPI
22 alias Pleroma.Web.MastodonAPI.AccountView
23 alias Pleroma.Web.MastodonAPI.AppView
24 alias Pleroma.Web.MastodonAPI.MastodonView
25 alias Pleroma.Web.MastodonAPI.StatusView
26 alias Pleroma.Web.MediaProxy
27 alias Pleroma.Web.OAuth.App
28 alias Pleroma.Web.OAuth.Authorization
29 alias Pleroma.Web.OAuth.Scopes
30 alias Pleroma.Web.OAuth.Token
31 alias Pleroma.Web.TwitterAPI.TwitterAPI
35 plug(RateLimiter, :password_reset when action == :password_reset)
37 @local_mastodon_name "Mastodon-Local"
39 action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
41 def create_app(conn, params) do
42 scopes = Scopes.fetch_scopes(params, ["read"])
46 |> Map.drop(["scope", "scopes"])
47 |> Map.put("scopes", scopes)
49 with cs <- App.register_changeset(%App{}, app_attrs),
50 false <- cs.changes[:client_name] == @local_mastodon_name,
51 {:ok, app} <- Repo.insert(cs) do
54 |> render("show.json", %{app: app})
58 def verify_app_credentials(%{assigns: %{user: _user, token: token}} = conn, _) do
59 with %Token{app: %App{} = app} <- Repo.preload(token, :app) do
62 |> render("short.json", %{app: app})
66 @mastodon_api_level "2.7.2"
68 def masto_instance(conn, _params) do
69 instance = Config.get(:instance)
73 title: Keyword.get(instance, :name),
74 description: Keyword.get(instance, :description),
75 version: "#{@mastodon_api_level} (compatible; #{Pleroma.Application.named_version()})",
76 email: Keyword.get(instance, :email),
78 streaming_api: Pleroma.Web.Endpoint.websocket_url()
80 stats: Stats.get_stats(),
81 thumbnail: Web.base_url() <> "/instance/thumbnail.jpeg",
83 registrations: Pleroma.Config.get([:instance, :registrations_open]),
84 # Extra (not present in Mastodon):
85 max_toot_chars: Keyword.get(instance, :limit),
86 poll_limits: Keyword.get(instance, :poll_limits)
92 def peers(conn, _params) do
93 json(conn, Stats.get_peers())
96 defp mastodonized_emoji do
97 Pleroma.Emoji.get_all()
98 |> Enum.map(fn {shortcode, %Pleroma.Emoji{file: relative_url, tags: tags}} ->
99 url = to_string(URI.merge(Web.base_url(), relative_url))
102 "shortcode" => shortcode,
104 "visible_in_picker" => true,
107 # Assuming that a comma is authorized in the category name
108 "category" => (tags -- ["Custom"]) |> Enum.join(",")
113 def custom_emojis(conn, _params) do
114 mastodon_emoji = mastodonized_emoji()
115 json(conn, mastodon_emoji)
119 %{assigns: %{user: user}} = conn,
120 %{"id" => id, "description" => description} = _
122 when is_binary(description) do
123 with %Object{} = object <- Repo.get(Object, id),
124 true <- Object.authorize_mutation(object, user),
125 {:ok, %Object{data: data}} <- Object.update_data(object, %{"name" => description}) do
126 attachment_data = Map.put(data, "id", object.id)
129 |> put_view(StatusView)
130 |> render("attachment.json", %{attachment: attachment_data})
134 def update_media(_conn, _data), do: {:error, :bad_request}
136 def upload(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do
137 with {:ok, object} <-
140 actor: User.ap_id(user),
141 description: Map.get(data, "description")
143 attachment_data = Map.put(object.data, "id", object.id)
146 |> put_view(StatusView)
147 |> render("attachment.json", %{attachment: attachment_data})
151 def follows(%{assigns: %{user: follower}} = conn, %{"uri" => uri}) do
152 with {_, %User{} = followed} <- {:followed, User.get_cached_by_nickname(uri)},
153 {_, true} <- {:followed, follower.id != followed.id},
154 {:ok, follower, followed, _} <- CommonAPI.follow(follower, followed) do
156 |> put_view(AccountView)
157 |> render("show.json", %{user: followed, for: follower})
164 |> put_status(:forbidden)
165 |> json(%{error: message})
169 def mutes(%{assigns: %{user: user}} = conn, _) do
170 with muted_accounts <- User.muted_users(user) do
171 res = AccountView.render("index.json", users: muted_accounts, for: user, as: :user)
176 def blocks(%{assigns: %{user: user}} = conn, _) do
177 with blocked_accounts <- User.blocked_users(user) do
178 res = AccountView.render("index.json", users: blocked_accounts, for: user, as: :user)
183 def favourites(%{assigns: %{user: user}} = conn, params) do
186 |> Map.put("type", "Create")
187 |> Map.put("favorited_by", user.ap_id)
188 |> Map.put("blocking_user", user)
191 ActivityPub.fetch_activities([], params)
195 |> add_link_headers(activities)
196 |> put_view(StatusView)
197 |> render("index.json", %{activities: activities, for: user, as: :activity})
200 def bookmarks(%{assigns: %{user: user}} = conn, params) do
201 user = User.get_cached_by_id(user.id)
204 Bookmark.for_user_query(user.id)
205 |> Pagination.fetch_paginated(params)
209 |> Enum.map(fn b -> Map.put(b.activity, :bookmark, Map.delete(b, :activity)) end)
212 |> add_link_headers(bookmarks)
213 |> put_view(StatusView)
214 |> render("index.json", %{activities: activities, for: user, as: :activity})
217 def index(%{assigns: %{user: user}} = conn, _params) do
218 token = get_session(conn, :oauth_token)
221 mastodon_emoji = mastodonized_emoji()
223 limit = Config.get([:instance, :limit])
225 accounts = Map.put(%{}, user.id, AccountView.render("show.json", %{user: user, for: user}))
230 streaming_api_base_url: Pleroma.Web.Endpoint.websocket_url(),
233 domain: Pleroma.Web.Endpoint.host(),
236 unfollow_modal: false,
239 auto_play_gif: false,
240 display_sensitive_media: false,
241 reduce_motion: false,
242 max_toot_chars: limit,
243 mascot: User.get_mascot(user)["url"]
245 poll_limits: Config.get([:instance, :poll_limits]),
247 delete_others_notice: present?(user.info.is_moderator),
248 admin: present?(user.info.is_admin)
252 default_privacy: user.info.default_scope,
253 default_sensitive: false,
254 allow_content_types: Config.get([:instance, :allowed_post_formats])
256 media_attachments: %{
257 accept_content_types: [
273 user.info.settings ||
303 push_subscription: nil,
305 custom_emojis: mastodon_emoji,
312 |> put_view(MastodonView)
313 |> render("index.html", %{initial_state: initial_state})
316 |> put_session(:return_to, conn.request_path)
317 |> redirect(to: "/web/login")
321 def put_settings(%{assigns: %{user: user}} = conn, %{"data" => settings} = _params) do
322 with {:ok, _} <- User.update_info(user, &User.Info.mastodon_settings_update(&1, settings)) do
327 |> put_status(:internal_server_error)
328 |> json(%{error: inspect(e)})
332 def login(%{assigns: %{user: %User{}}} = conn, _params) do
333 redirect(conn, to: local_mastodon_root_path(conn))
336 @doc "Local Mastodon FE login init action"
337 def login(conn, %{"code" => auth_token}) do
338 with {:ok, app} <- get_or_make_app(),
339 {:ok, auth} <- Authorization.get_by_token(app, auth_token),
340 {:ok, token} <- Token.exchange_token(app, auth) do
342 |> put_session(:oauth_token, token.token)
343 |> redirect(to: local_mastodon_root_path(conn))
347 @doc "Local Mastodon FE callback action"
348 def login(conn, _) do
349 with {:ok, app} <- get_or_make_app() do
351 o_auth_path(conn, :authorize,
352 response_type: "code",
353 client_id: app.client_id,
355 scope: Enum.join(app.scopes, " ")
358 redirect(conn, to: path)
362 defp local_mastodon_root_path(conn) do
363 case get_session(conn, :return_to) do
365 mastodon_api_path(conn, :index, ["getting-started"])
368 delete_session(conn, :return_to)
373 @spec get_or_make_app() :: {:ok, App.t()} | {:error, Ecto.Changeset.t()}
374 defp get_or_make_app do
376 %{client_name: @local_mastodon_name, redirect_uris: "."},
377 ["read", "write", "follow", "push"]
381 def logout(conn, _) do
387 # Stubs for unimplemented mastodon api
389 def empty_array(conn, _) do
390 Logger.debug("Unimplemented, returning an empty array")
394 def empty_object(conn, _) do
395 Logger.debug("Unimplemented, returning an empty object")
399 def suggestions(%{assigns: %{user: user}} = conn, _) do
400 suggestions = Config.get(:suggestions)
402 if Keyword.get(suggestions, :enabled, false) do
403 api = Keyword.get(suggestions, :third_party_engine, "")
404 timeout = Keyword.get(suggestions, :timeout, 5000)
405 limit = Keyword.get(suggestions, :limit, 23)
407 host = Config.get([Pleroma.Web.Endpoint, :url, :host])
413 |> String.replace("{{host}}", host)
414 |> String.replace("{{user}}", user)
416 with {:ok, %{status: 200, body: body}} <-
417 HTTP.get(url, [], adapter: [recv_timeout: timeout, pool: :default]),
418 {:ok, data} <- Jason.decode(body) do
421 |> Enum.slice(0, limit)
424 |> Map.put("id", fetch_suggestion_id(x))
425 |> Map.put("avatar", MediaProxy.url(x["avatar"]))
426 |> Map.put("avatar_static", MediaProxy.url(x["avatar_static"]))
432 Logger.error("Could not retrieve suggestions at fetch #{url}, #{inspect(e)}")
439 defp fetch_suggestion_id(attrs) do
440 case User.get_or_fetch(attrs["acct"]) do
441 {:ok, %User{id: id}} -> id
446 def password_reset(conn, params) do
447 nickname_or_email = params["email"] || params["nickname"]
449 with {:ok, _} <- TwitterAPI.password_reset(nickname_or_email) do
451 |> put_status(:no_content)
454 {:error, "unknown user"} ->
455 send_resp(conn, :not_found, "")
458 send_resp(conn, :bad_request, "")
462 defp present?(nil), do: false
463 defp present?(false), do: false
464 defp present?(_), do: true