912dd181f4056a93d59ecad9dbc4841b93fdefde
[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.HTTP
13 alias Pleroma.Object
14 alias Pleroma.Pagination
15 alias Pleroma.Plugs.RateLimiter
16 alias Pleroma.Repo
17 alias Pleroma.Stats
18 alias Pleroma.User
19 alias Pleroma.Web
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
32
33 require Logger
34
35 plug(RateLimiter, :password_reset when action == :password_reset)
36
37 @local_mastodon_name "Mastodon-Local"
38
39 action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
40
41 def create_app(conn, params) do
42 scopes = Scopes.fetch_scopes(params, ["read"])
43
44 app_attrs =
45 params
46 |> Map.drop(["scope", "scopes"])
47 |> Map.put("scopes", scopes)
48
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
52 conn
53 |> put_view(AppView)
54 |> render("show.json", %{app: app})
55 end
56 end
57
58 def verify_app_credentials(%{assigns: %{user: _user, token: token}} = conn, _) do
59 with %Token{app: %App{} = app} <- Repo.preload(token, :app) do
60 conn
61 |> put_view(AppView)
62 |> render("short.json", %{app: app})
63 end
64 end
65
66 @mastodon_api_level "2.7.2"
67
68 def masto_instance(conn, _params) do
69 instance = Config.get(:instance)
70
71 response = %{
72 uri: Web.base_url(),
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),
77 urls: %{
78 streaming_api: Pleroma.Web.Endpoint.websocket_url()
79 },
80 stats: Stats.get_stats(),
81 thumbnail: Web.base_url() <> "/instance/thumbnail.jpeg",
82 languages: ["en"],
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)
87 }
88
89 json(conn, response)
90 end
91
92 def peers(conn, _params) do
93 json(conn, Stats.get_peers())
94 end
95
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))
100
101 %{
102 "shortcode" => shortcode,
103 "static_url" => url,
104 "visible_in_picker" => true,
105 "url" => url,
106 "tags" => tags,
107 # Assuming that a comma is authorized in the category name
108 "category" => (tags -- ["Custom"]) |> Enum.join(",")
109 }
110 end)
111 end
112
113 def custom_emojis(conn, _params) do
114 mastodon_emoji = mastodonized_emoji()
115 json(conn, mastodon_emoji)
116 end
117
118 def update_media(
119 %{assigns: %{user: user}} = conn,
120 %{"id" => id, "description" => description} = _
121 )
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)
127
128 conn
129 |> put_view(StatusView)
130 |> render("attachment.json", %{attachment: attachment_data})
131 end
132 end
133
134 def update_media(_conn, _data), do: {:error, :bad_request}
135
136 def upload(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do
137 with {:ok, object} <-
138 ActivityPub.upload(
139 file,
140 actor: User.ap_id(user),
141 description: Map.get(data, "description")
142 ) do
143 attachment_data = Map.put(object.data, "id", object.id)
144
145 conn
146 |> put_view(StatusView)
147 |> render("attachment.json", %{attachment: attachment_data})
148 end
149 end
150
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
155 conn
156 |> put_view(AccountView)
157 |> render("show.json", %{user: followed, for: follower})
158 else
159 {:followed, _} ->
160 {:error, :not_found}
161
162 {:error, message} ->
163 conn
164 |> put_status(:forbidden)
165 |> json(%{error: message})
166 end
167 end
168
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)
172 json(conn, res)
173 end
174 end
175
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)
179 json(conn, res)
180 end
181 end
182
183 def favourites(%{assigns: %{user: user}} = conn, params) do
184 params =
185 params
186 |> Map.put("type", "Create")
187 |> Map.put("favorited_by", user.ap_id)
188 |> Map.put("blocking_user", user)
189
190 activities =
191 ActivityPub.fetch_activities([], params)
192 |> Enum.reverse()
193
194 conn
195 |> add_link_headers(activities)
196 |> put_view(StatusView)
197 |> render("index.json", %{activities: activities, for: user, as: :activity})
198 end
199
200 def bookmarks(%{assigns: %{user: user}} = conn, params) do
201 user = User.get_cached_by_id(user.id)
202
203 bookmarks =
204 Bookmark.for_user_query(user.id)
205 |> Pagination.fetch_paginated(params)
206
207 activities =
208 bookmarks
209 |> Enum.map(fn b -> Map.put(b.activity, :bookmark, Map.delete(b, :activity)) end)
210
211 conn
212 |> add_link_headers(bookmarks)
213 |> put_view(StatusView)
214 |> render("index.json", %{activities: activities, for: user, as: :activity})
215 end
216
217 def index(%{assigns: %{user: user}} = conn, _params) do
218 token = get_session(conn, :oauth_token)
219
220 if user && token do
221 mastodon_emoji = mastodonized_emoji()
222
223 limit = Config.get([:instance, :limit])
224
225 accounts = Map.put(%{}, user.id, AccountView.render("show.json", %{user: user, for: user}))
226
227 initial_state =
228 %{
229 meta: %{
230 streaming_api_base_url: Pleroma.Web.Endpoint.websocket_url(),
231 access_token: token,
232 locale: "en",
233 domain: Pleroma.Web.Endpoint.host(),
234 admin: "1",
235 me: "#{user.id}",
236 unfollow_modal: false,
237 boost_modal: false,
238 delete_modal: true,
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"]
244 },
245 poll_limits: Config.get([:instance, :poll_limits]),
246 rights: %{
247 delete_others_notice: present?(user.info.is_moderator),
248 admin: present?(user.info.is_admin)
249 },
250 compose: %{
251 me: "#{user.id}",
252 default_privacy: user.info.default_scope,
253 default_sensitive: false,
254 allow_content_types: Config.get([:instance, :allowed_post_formats])
255 },
256 media_attachments: %{
257 accept_content_types: [
258 ".jpg",
259 ".jpeg",
260 ".png",
261 ".gif",
262 ".webm",
263 ".mp4",
264 ".m4v",
265 "image\/jpeg",
266 "image\/png",
267 "image\/gif",
268 "video\/webm",
269 "video\/mp4"
270 ]
271 },
272 settings:
273 user.info.settings ||
274 %{
275 onboarded: true,
276 home: %{
277 shows: %{
278 reblog: true,
279 reply: true
280 }
281 },
282 notifications: %{
283 alerts: %{
284 follow: true,
285 favourite: true,
286 reblog: true,
287 mention: true
288 },
289 shows: %{
290 follow: true,
291 favourite: true,
292 reblog: true,
293 mention: true
294 },
295 sounds: %{
296 follow: true,
297 favourite: true,
298 reblog: true,
299 mention: true
300 }
301 }
302 },
303 push_subscription: nil,
304 accounts: accounts,
305 custom_emojis: mastodon_emoji,
306 char_limit: limit
307 }
308 |> Jason.encode!()
309
310 conn
311 |> put_layout(false)
312 |> put_view(MastodonView)
313 |> render("index.html", %{initial_state: initial_state})
314 else
315 conn
316 |> put_session(:return_to, conn.request_path)
317 |> redirect(to: "/web/login")
318 end
319 end
320
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
323 json(conn, %{})
324 else
325 e ->
326 conn
327 |> put_status(:internal_server_error)
328 |> json(%{error: inspect(e)})
329 end
330 end
331
332 def login(%{assigns: %{user: %User{}}} = conn, _params) do
333 redirect(conn, to: local_mastodon_root_path(conn))
334 end
335
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
341 conn
342 |> put_session(:oauth_token, token.token)
343 |> redirect(to: local_mastodon_root_path(conn))
344 end
345 end
346
347 @doc "Local Mastodon FE callback action"
348 def login(conn, _) do
349 with {:ok, app} <- get_or_make_app() do
350 path =
351 o_auth_path(conn, :authorize,
352 response_type: "code",
353 client_id: app.client_id,
354 redirect_uri: ".",
355 scope: Enum.join(app.scopes, " ")
356 )
357
358 redirect(conn, to: path)
359 end
360 end
361
362 defp local_mastodon_root_path(conn) do
363 case get_session(conn, :return_to) do
364 nil ->
365 mastodon_api_path(conn, :index, ["getting-started"])
366
367 return_to ->
368 delete_session(conn, :return_to)
369 return_to
370 end
371 end
372
373 @spec get_or_make_app() :: {:ok, App.t()} | {:error, Ecto.Changeset.t()}
374 defp get_or_make_app do
375 App.get_or_make(
376 %{client_name: @local_mastodon_name, redirect_uris: "."},
377 ["read", "write", "follow", "push"]
378 )
379 end
380
381 def logout(conn, _) do
382 conn
383 |> clear_session
384 |> redirect(to: "/")
385 end
386
387 # Stubs for unimplemented mastodon api
388 #
389 def empty_array(conn, _) do
390 Logger.debug("Unimplemented, returning an empty array")
391 json(conn, [])
392 end
393
394 def empty_object(conn, _) do
395 Logger.debug("Unimplemented, returning an empty object")
396 json(conn, %{})
397 end
398
399 def suggestions(%{assigns: %{user: user}} = conn, _) do
400 suggestions = Config.get(:suggestions)
401
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)
406
407 host = Config.get([Pleroma.Web.Endpoint, :url, :host])
408
409 user = user.nickname
410
411 url =
412 api
413 |> String.replace("{{host}}", host)
414 |> String.replace("{{user}}", user)
415
416 with {:ok, %{status: 200, body: body}} <-
417 HTTP.get(url, [], adapter: [recv_timeout: timeout, pool: :default]),
418 {:ok, data} <- Jason.decode(body) do
419 data =
420 data
421 |> Enum.slice(0, limit)
422 |> Enum.map(fn x ->
423 x
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"]))
427 end)
428
429 json(conn, data)
430 else
431 e ->
432 Logger.error("Could not retrieve suggestions at fetch #{url}, #{inspect(e)}")
433 end
434 else
435 json(conn, [])
436 end
437 end
438
439 defp fetch_suggestion_id(attrs) do
440 case User.get_or_fetch(attrs["acct"]) do
441 {:ok, %User{id: id}} -> id
442 _ -> 0
443 end
444 end
445
446 def password_reset(conn, params) do
447 nickname_or_email = params["email"] || params["nickname"]
448
449 with {:ok, _} <- TwitterAPI.password_reset(nickname_or_email) do
450 conn
451 |> put_status(:no_content)
452 |> json("")
453 else
454 {:error, "unknown user"} ->
455 send_resp(conn, :not_found, "")
456
457 {:error, _} ->
458 send_resp(conn, :bad_request, "")
459 end
460 end
461
462 defp present?(nil), do: false
463 defp present?(false), do: false
464 defp present?(_), do: true
465 end