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