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 follows(%{assigns: %{user: follower}} = conn, %{"uri" => uri}) do
204 with {_, %User{} = followed} <- {:followed, User.get_cached_by_nickname(uri)},
205 {_, true} <- {:followed, follower.id != followed.id},
206 {:ok, follower, followed, _} <- CommonAPI.follow(follower, followed) do
208 |> put_view(AccountView)
209 |> render("show.json", %{user: followed, for: follower})
216 |> put_status(:forbidden)
217 |> json(%{error: message})
221 def mutes(%{assigns: %{user: user}} = conn, _) do
222 with muted_accounts <- User.muted_users(user) do
223 res = AccountView.render("index.json", users: muted_accounts, for: user, as: :user)
228 def blocks(%{assigns: %{user: user}} = conn, _) do
229 with blocked_accounts <- User.blocked_users(user) do
230 res = AccountView.render("index.json", users: blocked_accounts, for: user, as: :user)
235 def favourites(%{assigns: %{user: user}} = conn, params) do
238 |> Map.put("type", "Create")
239 |> Map.put("favorited_by", user.ap_id)
240 |> Map.put("blocking_user", user)
243 ActivityPub.fetch_activities([], params)
247 |> add_link_headers(activities)
248 |> put_view(StatusView)
249 |> render("index.json", %{activities: activities, for: user, as: :activity})
252 def bookmarks(%{assigns: %{user: user}} = conn, params) do
253 user = User.get_cached_by_id(user.id)
256 Bookmark.for_user_query(user.id)
257 |> Pagination.fetch_paginated(params)
261 |> Enum.map(fn b -> Map.put(b.activity, :bookmark, Map.delete(b, :activity)) end)
264 |> add_link_headers(bookmarks)
265 |> put_view(StatusView)
266 |> render("index.json", %{activities: activities, for: user, as: :activity})
269 def index(%{assigns: %{user: user}} = conn, _params) do
270 token = get_session(conn, :oauth_token)
273 mastodon_emoji = mastodonized_emoji()
275 limit = Config.get([:instance, :limit])
277 accounts = Map.put(%{}, user.id, AccountView.render("show.json", %{user: user, for: user}))
282 streaming_api_base_url: Pleroma.Web.Endpoint.websocket_url(),
285 domain: Pleroma.Web.Endpoint.host(),
288 unfollow_modal: false,
291 auto_play_gif: false,
292 display_sensitive_media: false,
293 reduce_motion: false,
294 max_toot_chars: limit,
295 mascot: User.get_mascot(user)["url"]
297 poll_limits: Config.get([:instance, :poll_limits]),
299 delete_others_notice: present?(user.info.is_moderator),
300 admin: present?(user.info.is_admin)
304 default_privacy: user.info.default_scope,
305 default_sensitive: false,
306 allow_content_types: Config.get([:instance, :allowed_post_formats])
308 media_attachments: %{
309 accept_content_types: [
325 user.info.settings ||
355 push_subscription: nil,
357 custom_emojis: mastodon_emoji,
364 |> put_view(MastodonView)
365 |> render("index.html", %{initial_state: initial_state})
368 |> put_session(:return_to, conn.request_path)
369 |> redirect(to: "/web/login")
373 def put_settings(%{assigns: %{user: user}} = conn, %{"data" => settings} = _params) do
374 with {:ok, _} <- User.update_info(user, &User.Info.mastodon_settings_update(&1, settings)) do
379 |> put_status(:internal_server_error)
380 |> json(%{error: inspect(e)})
384 def login(%{assigns: %{user: %User{}}} = conn, _params) do
385 redirect(conn, to: local_mastodon_root_path(conn))
388 @doc "Local Mastodon FE login init action"
389 def login(conn, %{"code" => auth_token}) do
390 with {:ok, app} <- get_or_make_app(),
391 {:ok, auth} <- Authorization.get_by_token(app, auth_token),
392 {:ok, token} <- Token.exchange_token(app, auth) do
394 |> put_session(:oauth_token, token.token)
395 |> redirect(to: local_mastodon_root_path(conn))
399 @doc "Local Mastodon FE callback action"
400 def login(conn, _) do
401 with {:ok, app} <- get_or_make_app() do
403 o_auth_path(conn, :authorize,
404 response_type: "code",
405 client_id: app.client_id,
407 scope: Enum.join(app.scopes, " ")
410 redirect(conn, to: path)
414 defp local_mastodon_root_path(conn) do
415 case get_session(conn, :return_to) do
417 mastodon_api_path(conn, :index, ["getting-started"])
420 delete_session(conn, :return_to)
425 @spec get_or_make_app() :: {:ok, App.t()} | {:error, Ecto.Changeset.t()}
426 defp get_or_make_app do
428 %{client_name: @local_mastodon_name, redirect_uris: "."},
429 ["read", "write", "follow", "push"]
433 def logout(conn, _) do
439 # Stubs for unimplemented mastodon api
441 def empty_array(conn, _) do
442 Logger.debug("Unimplemented, returning an empty array")
446 def empty_object(conn, _) do
447 Logger.debug("Unimplemented, returning an empty object")
451 def suggestions(%{assigns: %{user: user}} = conn, _) do
452 suggestions = Config.get(:suggestions)
454 if Keyword.get(suggestions, :enabled, false) do
455 api = Keyword.get(suggestions, :third_party_engine, "")
456 timeout = Keyword.get(suggestions, :timeout, 5000)
457 limit = Keyword.get(suggestions, :limit, 23)
459 host = Config.get([Pleroma.Web.Endpoint, :url, :host])
465 |> String.replace("{{host}}", host)
466 |> String.replace("{{user}}", user)
468 with {:ok, %{status: 200, body: body}} <-
469 HTTP.get(url, [], adapter: [recv_timeout: timeout, pool: :default]),
470 {:ok, data} <- Jason.decode(body) do
473 |> Enum.slice(0, limit)
476 |> Map.put("id", fetch_suggestion_id(x))
477 |> Map.put("avatar", MediaProxy.url(x["avatar"]))
478 |> Map.put("avatar_static", MediaProxy.url(x["avatar_static"]))
484 Logger.error("Could not retrieve suggestions at fetch #{url}, #{inspect(e)}")
491 defp fetch_suggestion_id(attrs) do
492 case User.get_or_fetch(attrs["acct"]) do
493 {:ok, %User{id: id}} -> id
498 def password_reset(conn, params) do
499 nickname_or_email = params["email"] || params["nickname"]
501 with {:ok, _} <- TwitterAPI.password_reset(nickname_or_email) do
503 |> put_status(:no_content)
506 {:error, "unknown user"} ->
507 send_resp(conn, :not_found, "")
510 send_resp(conn, :bad_request, "")
514 def try_render(conn, target, params)
515 when is_binary(target) do
516 case render(conn, target, params) do
517 nil -> render_error(conn, :not_implemented, "Can't display this activity")
522 def try_render(conn, _, _) do
523 render_error(conn, :not_implemented, "Can't display this activity")
526 defp present?(nil), do: false
527 defp present?(false), do: false
528 defp present?(_), do: true