30a2bf0e01ffe6610ee8524fa120f919925cedae
[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.Activity
11 alias Pleroma.Bookmark
12 alias Pleroma.Config
13 alias Pleroma.HTTP
14 alias Pleroma.Object
15 alias Pleroma.Pagination
16 alias Pleroma.Plugs.RateLimiter
17 alias Pleroma.Repo
18 alias Pleroma.Stats
19 alias Pleroma.User
20 alias Pleroma.Web
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
34
35 require Logger
36
37 plug(RateLimiter, :password_reset when action == :password_reset)
38
39 @local_mastodon_name "Mastodon-Local"
40
41 action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
42
43 def create_app(conn, params) do
44 scopes = Scopes.fetch_scopes(params, ["read"])
45
46 app_attrs =
47 params
48 |> Map.drop(["scope", "scopes"])
49 |> Map.put("scopes", scopes)
50
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
54 conn
55 |> put_view(AppView)
56 |> render("show.json", %{app: app})
57 end
58 end
59
60 def verify_app_credentials(%{assigns: %{user: _user, token: token}} = conn, _) do
61 with %Token{app: %App{} = app} <- Repo.preload(token, :app) do
62 conn
63 |> put_view(AppView)
64 |> render("short.json", %{app: app})
65 end
66 end
67
68 @mastodon_api_level "2.7.2"
69
70 def masto_instance(conn, _params) do
71 instance = Config.get(:instance)
72
73 response = %{
74 uri: Web.base_url(),
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),
79 urls: %{
80 streaming_api: Pleroma.Web.Endpoint.websocket_url()
81 },
82 stats: Stats.get_stats(),
83 thumbnail: Web.base_url() <> "/instance/thumbnail.jpeg",
84 languages: ["en"],
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)
89 }
90
91 json(conn, response)
92 end
93
94 def peers(conn, _params) do
95 json(conn, Stats.get_peers())
96 end
97
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))
102
103 %{
104 "shortcode" => shortcode,
105 "static_url" => url,
106 "visible_in_picker" => true,
107 "url" => url,
108 "tags" => tags,
109 # Assuming that a comma is authorized in the category name
110 "category" => (tags -- ["Custom"]) |> Enum.join(",")
111 }
112 end)
113 end
114
115 def custom_emojis(conn, _params) do
116 mastodon_emoji = mastodonized_emoji()
117 json(conn, mastodon_emoji)
118 end
119
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
124 conn
125 |> put_view(StatusView)
126 |> try_render("poll.json", %{object: object, for: user})
127 else
128 error when is_nil(error) or error == false ->
129 render_error(conn, :not_found, "Record not found")
130 end
131 end
132
133 defp get_cached_vote_or_vote(user, object, choices) do
134 idempotency_key = "polls:#{user.id}:#{object.data["id"]}"
135
136 {_, res} =
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}
141 end
142 end)
143
144 res
145 end
146
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
153 conn
154 |> put_view(StatusView)
155 |> try_render("poll.json", %{object: object, for: user})
156 else
157 nil ->
158 render_error(conn, :not_found, "Record not found")
159
160 false ->
161 render_error(conn, :not_found, "Record not found")
162
163 {:error, message} ->
164 conn
165 |> put_status(:unprocessable_entity)
166 |> json(%{error: message})
167 end
168 end
169
170 def update_media(
171 %{assigns: %{user: user}} = conn,
172 %{"id" => id, "description" => description} = _
173 )
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)
179
180 conn
181 |> put_view(StatusView)
182 |> render("attachment.json", %{attachment: attachment_data})
183 end
184 end
185
186 def update_media(_conn, _data), do: {:error, :bad_request}
187
188 def upload(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do
189 with {:ok, object} <-
190 ActivityPub.upload(
191 file,
192 actor: User.ap_id(user),
193 description: Map.get(data, "description")
194 ) do
195 attachment_data = Map.put(object.data, "id", object.id)
196
197 conn
198 |> put_view(StatusView)
199 |> render("attachment.json", %{attachment: attachment_data})
200 end
201 end
202
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
209 # Sure!
210 # Save to the user's info
211 {:ok, _user} = User.update_info(user, &User.Info.mascot_update(&1, rendered))
212
213 json(conn, rendered)
214 else
215 %{type: _} -> render_error(conn, :unsupported_media_type, "mascots can only be images")
216 end
217 end
218
219 def get_mascot(%{assigns: %{user: user}} = conn, _params) do
220 mascot = User.get_mascot(user)
221
222 json(conn, mascot)
223 end
224
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
229 conn
230 |> put_view(AccountView)
231 |> render("show.json", %{user: followed, for: follower})
232 else
233 {:followed, _} ->
234 {:error, :not_found}
235
236 {:error, message} ->
237 conn
238 |> put_status(:forbidden)
239 |> json(%{error: message})
240 end
241 end
242
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)
246 json(conn, res)
247 end
248 end
249
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)
253 json(conn, res)
254 end
255 end
256
257 def favourites(%{assigns: %{user: user}} = conn, params) do
258 params =
259 params
260 |> Map.put("type", "Create")
261 |> Map.put("favorited_by", user.ap_id)
262 |> Map.put("blocking_user", user)
263
264 activities =
265 ActivityPub.fetch_activities([], params)
266 |> Enum.reverse()
267
268 conn
269 |> add_link_headers(activities)
270 |> put_view(StatusView)
271 |> render("index.json", %{activities: activities, for: user, as: :activity})
272 end
273
274 def bookmarks(%{assigns: %{user: user}} = conn, params) do
275 user = User.get_cached_by_id(user.id)
276
277 bookmarks =
278 Bookmark.for_user_query(user.id)
279 |> Pagination.fetch_paginated(params)
280
281 activities =
282 bookmarks
283 |> Enum.map(fn b -> Map.put(b.activity, :bookmark, Map.delete(b, :activity)) end)
284
285 conn
286 |> add_link_headers(bookmarks)
287 |> put_view(StatusView)
288 |> render("index.json", %{activities: activities, for: user, as: :activity})
289 end
290
291 def index(%{assigns: %{user: user}} = conn, _params) do
292 token = get_session(conn, :oauth_token)
293
294 if user && token do
295 mastodon_emoji = mastodonized_emoji()
296
297 limit = Config.get([:instance, :limit])
298
299 accounts = Map.put(%{}, user.id, AccountView.render("show.json", %{user: user, for: user}))
300
301 initial_state =
302 %{
303 meta: %{
304 streaming_api_base_url: Pleroma.Web.Endpoint.websocket_url(),
305 access_token: token,
306 locale: "en",
307 domain: Pleroma.Web.Endpoint.host(),
308 admin: "1",
309 me: "#{user.id}",
310 unfollow_modal: false,
311 boost_modal: false,
312 delete_modal: true,
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"]
318 },
319 poll_limits: Config.get([:instance, :poll_limits]),
320 rights: %{
321 delete_others_notice: present?(user.info.is_moderator),
322 admin: present?(user.info.is_admin)
323 },
324 compose: %{
325 me: "#{user.id}",
326 default_privacy: user.info.default_scope,
327 default_sensitive: false,
328 allow_content_types: Config.get([:instance, :allowed_post_formats])
329 },
330 media_attachments: %{
331 accept_content_types: [
332 ".jpg",
333 ".jpeg",
334 ".png",
335 ".gif",
336 ".webm",
337 ".mp4",
338 ".m4v",
339 "image\/jpeg",
340 "image\/png",
341 "image\/gif",
342 "video\/webm",
343 "video\/mp4"
344 ]
345 },
346 settings:
347 user.info.settings ||
348 %{
349 onboarded: true,
350 home: %{
351 shows: %{
352 reblog: true,
353 reply: true
354 }
355 },
356 notifications: %{
357 alerts: %{
358 follow: true,
359 favourite: true,
360 reblog: true,
361 mention: true
362 },
363 shows: %{
364 follow: true,
365 favourite: true,
366 reblog: true,
367 mention: true
368 },
369 sounds: %{
370 follow: true,
371 favourite: true,
372 reblog: true,
373 mention: true
374 }
375 }
376 },
377 push_subscription: nil,
378 accounts: accounts,
379 custom_emojis: mastodon_emoji,
380 char_limit: limit
381 }
382 |> Jason.encode!()
383
384 conn
385 |> put_layout(false)
386 |> put_view(MastodonView)
387 |> render("index.html", %{initial_state: initial_state})
388 else
389 conn
390 |> put_session(:return_to, conn.request_path)
391 |> redirect(to: "/web/login")
392 end
393 end
394
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
397 json(conn, %{})
398 else
399 e ->
400 conn
401 |> put_status(:internal_server_error)
402 |> json(%{error: inspect(e)})
403 end
404 end
405
406 def login(%{assigns: %{user: %User{}}} = conn, _params) do
407 redirect(conn, to: local_mastodon_root_path(conn))
408 end
409
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
415 conn
416 |> put_session(:oauth_token, token.token)
417 |> redirect(to: local_mastodon_root_path(conn))
418 end
419 end
420
421 @doc "Local Mastodon FE callback action"
422 def login(conn, _) do
423 with {:ok, app} <- get_or_make_app() do
424 path =
425 o_auth_path(conn, :authorize,
426 response_type: "code",
427 client_id: app.client_id,
428 redirect_uri: ".",
429 scope: Enum.join(app.scopes, " ")
430 )
431
432 redirect(conn, to: path)
433 end
434 end
435
436 defp local_mastodon_root_path(conn) do
437 case get_session(conn, :return_to) do
438 nil ->
439 mastodon_api_path(conn, :index, ["getting-started"])
440
441 return_to ->
442 delete_session(conn, :return_to)
443 return_to
444 end
445 end
446
447 @spec get_or_make_app() :: {:ok, App.t()} | {:error, Ecto.Changeset.t()}
448 defp get_or_make_app do
449 App.get_or_make(
450 %{client_name: @local_mastodon_name, redirect_uris: "."},
451 ["read", "write", "follow", "push"]
452 )
453 end
454
455 def logout(conn, _) do
456 conn
457 |> clear_session
458 |> redirect(to: "/")
459 end
460
461 # Stubs for unimplemented mastodon api
462 #
463 def empty_array(conn, _) do
464 Logger.debug("Unimplemented, returning an empty array")
465 json(conn, [])
466 end
467
468 def empty_object(conn, _) do
469 Logger.debug("Unimplemented, returning an empty object")
470 json(conn, %{})
471 end
472
473 def suggestions(%{assigns: %{user: user}} = conn, _) do
474 suggestions = Config.get(:suggestions)
475
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)
480
481 host = Config.get([Pleroma.Web.Endpoint, :url, :host])
482
483 user = user.nickname
484
485 url =
486 api
487 |> String.replace("{{host}}", host)
488 |> String.replace("{{user}}", user)
489
490 with {:ok, %{status: 200, body: body}} <-
491 HTTP.get(url, [], adapter: [recv_timeout: timeout, pool: :default]),
492 {:ok, data} <- Jason.decode(body) do
493 data =
494 data
495 |> Enum.slice(0, limit)
496 |> Enum.map(fn x ->
497 x
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"]))
501 end)
502
503 json(conn, data)
504 else
505 e ->
506 Logger.error("Could not retrieve suggestions at fetch #{url}, #{inspect(e)}")
507 end
508 else
509 json(conn, [])
510 end
511 end
512
513 defp fetch_suggestion_id(attrs) do
514 case User.get_or_fetch(attrs["acct"]) do
515 {:ok, %User{id: id}} -> id
516 _ -> 0
517 end
518 end
519
520 def password_reset(conn, params) do
521 nickname_or_email = params["email"] || params["nickname"]
522
523 with {:ok, _} <- TwitterAPI.password_reset(nickname_or_email) do
524 conn
525 |> put_status(:no_content)
526 |> json("")
527 else
528 {:error, "unknown user"} ->
529 send_resp(conn, :not_found, "")
530
531 {:error, _} ->
532 send_resp(conn, :bad_request, "")
533 end
534 end
535
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")
540 res -> res
541 end
542 end
543
544 def try_render(conn, _, _) do
545 render_error(conn, :not_implemented, "Can't display this activity")
546 end
547
548 defp present?(nil), do: false
549 defp present?(false), do: false
550 defp present?(_), do: true
551 end