Merge branch 'features/private-reblogs' into 'develop'
[akkoma] / lib / pleroma / web / mastodon_api / controllers / mastodon_api_controller.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
6 use Pleroma.Web, :controller
7
8 import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2]
9
10 alias Pleroma.Bookmark
11 alias Pleroma.Config
12 alias Pleroma.Pagination
13 alias Pleroma.Plugs.RateLimiter
14 alias Pleroma.Stats
15 alias Pleroma.User
16 alias Pleroma.Web
17 alias Pleroma.Web.ActivityPub.ActivityPub
18 alias Pleroma.Web.CommonAPI
19 alias Pleroma.Web.MastodonAPI.AccountView
20 alias Pleroma.Web.MastodonAPI.MastodonView
21 alias Pleroma.Web.MastodonAPI.StatusView
22 alias Pleroma.Web.OAuth.App
23 alias Pleroma.Web.OAuth.Authorization
24 alias Pleroma.Web.OAuth.Token
25 alias Pleroma.Web.TwitterAPI.TwitterAPI
26
27 require Logger
28
29 plug(RateLimiter, :password_reset when action == :password_reset)
30
31 action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
32
33 @local_mastodon_name "Mastodon-Local"
34 @mastodon_api_level "2.7.2"
35
36 def masto_instance(conn, _params) do
37 instance = Config.get(:instance)
38
39 response = %{
40 uri: Web.base_url(),
41 title: Keyword.get(instance, :name),
42 description: Keyword.get(instance, :description),
43 version: "#{@mastodon_api_level} (compatible; #{Pleroma.Application.named_version()})",
44 email: Keyword.get(instance, :email),
45 urls: %{
46 streaming_api: Pleroma.Web.Endpoint.websocket_url()
47 },
48 stats: Stats.get_stats(),
49 thumbnail: Web.base_url() <> "/instance/thumbnail.jpeg",
50 languages: ["en"],
51 registrations: Pleroma.Config.get([:instance, :registrations_open]),
52 # Extra (not present in Mastodon):
53 max_toot_chars: Keyword.get(instance, :limit),
54 poll_limits: Keyword.get(instance, :poll_limits),
55 upload_limit: Keyword.get(instance, :upload_limit),
56 avatar_upload_limit: Keyword.get(instance, :avatar_upload_limit),
57 background_upload_limit: Keyword.get(instance, :background_upload_limit),
58 banner_upload_limit: Keyword.get(instance, :banner_upload_limit)
59 }
60
61 json(conn, response)
62 end
63
64 def peers(conn, _params) do
65 json(conn, Stats.get_peers())
66 end
67
68 defp mastodonized_emoji do
69 Pleroma.Emoji.get_all()
70 |> Enum.map(fn {shortcode, %Pleroma.Emoji{file: relative_url, tags: tags}} ->
71 url = to_string(URI.merge(Web.base_url(), relative_url))
72
73 %{
74 "shortcode" => shortcode,
75 "static_url" => url,
76 "visible_in_picker" => true,
77 "url" => url,
78 "tags" => tags,
79 # Assuming that a comma is authorized in the category name
80 "category" => (tags -- ["Custom"]) |> Enum.join(",")
81 }
82 end)
83 end
84
85 def custom_emojis(conn, _params) do
86 mastodon_emoji = mastodonized_emoji()
87 json(conn, mastodon_emoji)
88 end
89
90 def follows(%{assigns: %{user: follower}} = conn, %{"uri" => uri}) do
91 with {_, %User{} = followed} <- {:followed, User.get_cached_by_nickname(uri)},
92 {_, true} <- {:followed, follower.id != followed.id},
93 {:ok, follower, followed, _} <- CommonAPI.follow(follower, followed) do
94 conn
95 |> put_view(AccountView)
96 |> render("show.json", %{user: followed, for: follower})
97 else
98 {:followed, _} ->
99 {:error, :not_found}
100
101 {:error, message} ->
102 conn
103 |> put_status(:forbidden)
104 |> json(%{error: message})
105 end
106 end
107
108 def mutes(%{assigns: %{user: user}} = conn, _) do
109 with muted_accounts <- User.muted_users(user) do
110 res = AccountView.render("index.json", users: muted_accounts, for: user, as: :user)
111 json(conn, res)
112 end
113 end
114
115 def blocks(%{assigns: %{user: user}} = conn, _) do
116 with blocked_accounts <- User.blocked_users(user) do
117 res = AccountView.render("index.json", users: blocked_accounts, for: user, as: :user)
118 json(conn, res)
119 end
120 end
121
122 def favourites(%{assigns: %{user: user}} = conn, params) do
123 params =
124 params
125 |> Map.put("type", "Create")
126 |> Map.put("favorited_by", user.ap_id)
127 |> Map.put("blocking_user", user)
128
129 activities =
130 ActivityPub.fetch_activities([], params)
131 |> Enum.reverse()
132
133 conn
134 |> add_link_headers(activities)
135 |> put_view(StatusView)
136 |> render("index.json", %{activities: activities, for: user, as: :activity})
137 end
138
139 def bookmarks(%{assigns: %{user: user}} = conn, params) do
140 user = User.get_cached_by_id(user.id)
141
142 bookmarks =
143 Bookmark.for_user_query(user.id)
144 |> Pagination.fetch_paginated(params)
145
146 activities =
147 bookmarks
148 |> Enum.map(fn b -> Map.put(b.activity, :bookmark, Map.delete(b, :activity)) end)
149
150 conn
151 |> add_link_headers(bookmarks)
152 |> put_view(StatusView)
153 |> render("index.json", %{activities: activities, for: user, as: :activity})
154 end
155
156 def index(%{assigns: %{user: user}} = conn, _params) do
157 token = get_session(conn, :oauth_token)
158
159 if user && token do
160 mastodon_emoji = mastodonized_emoji()
161
162 limit = Config.get([:instance, :limit])
163
164 accounts = Map.put(%{}, user.id, AccountView.render("show.json", %{user: user, for: user}))
165
166 initial_state =
167 %{
168 meta: %{
169 streaming_api_base_url: Pleroma.Web.Endpoint.websocket_url(),
170 access_token: token,
171 locale: "en",
172 domain: Pleroma.Web.Endpoint.host(),
173 admin: "1",
174 me: "#{user.id}",
175 unfollow_modal: false,
176 boost_modal: false,
177 delete_modal: true,
178 auto_play_gif: false,
179 display_sensitive_media: false,
180 reduce_motion: false,
181 max_toot_chars: limit,
182 mascot: User.get_mascot(user)["url"]
183 },
184 poll_limits: Config.get([:instance, :poll_limits]),
185 rights: %{
186 delete_others_notice: present?(user.info.is_moderator),
187 admin: present?(user.info.is_admin)
188 },
189 compose: %{
190 me: "#{user.id}",
191 default_privacy: user.info.default_scope,
192 default_sensitive: false,
193 allow_content_types: Config.get([:instance, :allowed_post_formats])
194 },
195 media_attachments: %{
196 accept_content_types: [
197 ".jpg",
198 ".jpeg",
199 ".png",
200 ".gif",
201 ".webm",
202 ".mp4",
203 ".m4v",
204 "image\/jpeg",
205 "image\/png",
206 "image\/gif",
207 "video\/webm",
208 "video\/mp4"
209 ]
210 },
211 settings:
212 user.info.settings ||
213 %{
214 onboarded: true,
215 home: %{
216 shows: %{
217 reblog: true,
218 reply: true
219 }
220 },
221 notifications: %{
222 alerts: %{
223 follow: true,
224 favourite: true,
225 reblog: true,
226 mention: true
227 },
228 shows: %{
229 follow: true,
230 favourite: true,
231 reblog: true,
232 mention: true
233 },
234 sounds: %{
235 follow: true,
236 favourite: true,
237 reblog: true,
238 mention: true
239 }
240 }
241 },
242 push_subscription: nil,
243 accounts: accounts,
244 custom_emojis: mastodon_emoji,
245 char_limit: limit
246 }
247 |> Jason.encode!()
248
249 conn
250 |> put_layout(false)
251 |> put_view(MastodonView)
252 |> render("index.html", %{initial_state: initial_state})
253 else
254 conn
255 |> put_session(:return_to, conn.request_path)
256 |> redirect(to: "/web/login")
257 end
258 end
259
260 def put_settings(%{assigns: %{user: user}} = conn, %{"data" => settings} = _params) do
261 with {:ok, _} <- User.update_info(user, &User.Info.mastodon_settings_update(&1, settings)) do
262 json(conn, %{})
263 else
264 e ->
265 conn
266 |> put_status(:internal_server_error)
267 |> json(%{error: inspect(e)})
268 end
269 end
270
271 def login(%{assigns: %{user: %User{}}} = conn, _params) do
272 redirect(conn, to: local_mastodon_root_path(conn))
273 end
274
275 @doc "Local Mastodon FE login init action"
276 def login(conn, %{"code" => auth_token}) do
277 with {:ok, app} <- get_or_make_app(),
278 {:ok, auth} <- Authorization.get_by_token(app, auth_token),
279 {:ok, token} <- Token.exchange_token(app, auth) do
280 conn
281 |> put_session(:oauth_token, token.token)
282 |> redirect(to: local_mastodon_root_path(conn))
283 end
284 end
285
286 @doc "Local Mastodon FE callback action"
287 def login(conn, _) do
288 with {:ok, app} <- get_or_make_app() do
289 path =
290 o_auth_path(conn, :authorize,
291 response_type: "code",
292 client_id: app.client_id,
293 redirect_uri: ".",
294 scope: Enum.join(app.scopes, " ")
295 )
296
297 redirect(conn, to: path)
298 end
299 end
300
301 defp local_mastodon_root_path(conn) do
302 case get_session(conn, :return_to) do
303 nil ->
304 mastodon_api_path(conn, :index, ["getting-started"])
305
306 return_to ->
307 delete_session(conn, :return_to)
308 return_to
309 end
310 end
311
312 @spec get_or_make_app() :: {:ok, App.t()} | {:error, Ecto.Changeset.t()}
313 defp get_or_make_app do
314 App.get_or_make(
315 %{client_name: @local_mastodon_name, redirect_uris: "."},
316 ["read", "write", "follow", "push"]
317 )
318 end
319
320 def logout(conn, _) do
321 conn
322 |> clear_session
323 |> redirect(to: "/")
324 end
325
326 # Stubs for unimplemented mastodon api
327 #
328 def empty_array(conn, _) do
329 Logger.debug("Unimplemented, returning an empty array")
330 json(conn, [])
331 end
332
333 def empty_object(conn, _) do
334 Logger.debug("Unimplemented, returning an empty object")
335 json(conn, %{})
336 end
337
338 def password_reset(conn, params) do
339 nickname_or_email = params["email"] || params["nickname"]
340
341 with {:ok, _} <- TwitterAPI.password_reset(nickname_or_email) do
342 conn
343 |> put_status(:no_content)
344 |> json("")
345 else
346 {:error, "unknown user"} ->
347 send_resp(conn, :not_found, "")
348
349 {:error, _} ->
350 send_resp(conn, :bad_request, "")
351 end
352 end
353
354 defp present?(nil), do: false
355 defp present?(false), do: false
356 defp present?(_), do: true
357 end