# Pleroma: A lightweight social networking server # Copyright © 2017-2019 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do use Pleroma.Web, :controller import Pleroma.Web.ControllerHelper, only: [json_response: 3, add_link_headers: 2, add_link_headers: 3] alias Ecto.Changeset alias Pleroma.Activity alias Pleroma.Bookmark alias Pleroma.Config alias Pleroma.Conversation.Participation alias Pleroma.Filter alias Pleroma.Formatter alias Pleroma.HTTP alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Pagination alias Pleroma.Plugs.RateLimiter alias Pleroma.Repo alias Pleroma.ScheduledActivity alias Pleroma.Stats alias Pleroma.User alias Pleroma.Web alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.AppView alias Pleroma.Web.MastodonAPI.ConversationView alias Pleroma.Web.MastodonAPI.FilterView alias Pleroma.Web.MastodonAPI.ListView alias Pleroma.Web.MastodonAPI.MastodonAPI alias Pleroma.Web.MastodonAPI.MastodonView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.ReportView alias Pleroma.Web.MastodonAPI.ScheduledActivityView alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MediaProxy alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.Authorization alias Pleroma.Web.OAuth.Scopes alias Pleroma.Web.OAuth.Token alias Pleroma.Web.RichMedia alias Pleroma.Web.TwitterAPI.TwitterAPI alias Pleroma.Web.ControllerHelper import Ecto.Query require Logger require Pleroma.Constants @rate_limited_relations_actions ~w(follow unfollow)a @rate_limited_status_actions ~w(reblog_status unreblog_status fav_status unfav_status post_status delete_status)a plug( RateLimiter, {:status_id_action, bucket_name: "status_id_action:reblog_unreblog", params: ["id"]} when action in ~w(reblog_status unreblog_status)a ) plug( RateLimiter, {:status_id_action, bucket_name: "status_id_action:fav_unfav", params: ["id"]} when action in ~w(fav_status unfav_status)a ) plug( RateLimiter, {:relations_id_action, params: ["id", "uri"]} when action in @rate_limited_relations_actions ) plug(RateLimiter, :relations_actions when action in @rate_limited_relations_actions) plug(RateLimiter, :statuses_actions when action in @rate_limited_status_actions) plug(RateLimiter, :app_account_creation when action == :account_register) plug(RateLimiter, :search when action in [:search, :search2, :account_search]) plug(RateLimiter, :password_reset when action == :password_reset) plug(RateLimiter, :account_confirmation_resend when action == :account_confirmation_resend) @local_mastodon_name "Mastodon-Local" action_fallback(Pleroma.Web.MastodonAPI.FallbackController) def create_app(conn, params) do scopes = Scopes.fetch_scopes(params, ["read"]) app_attrs = params |> Map.drop(["scope", "scopes"]) |> Map.put("scopes", scopes) with cs <- App.register_changeset(%App{}, app_attrs), false <- cs.changes[:client_name] == @local_mastodon_name, {:ok, app} <- Repo.insert(cs) do conn |> put_view(AppView) |> render("show.json", %{app: app}) end end defp add_if_present( map, params, params_field, map_field, value_function \\ fn x -> {:ok, x} end ) do if Map.has_key?(params, params_field) do case value_function.(params[params_field]) do {:ok, new_value} -> Map.put(map, map_field, new_value) :error -> map end else map end end def update_credentials(%{assigns: %{user: user}} = conn, params) do original_user = user user_params = %{} |> add_if_present(params, "display_name", :name) |> add_if_present(params, "note", :bio, fn value -> {:ok, User.parse_bio(value, user)} end) |> add_if_present(params, "avatar", :avatar, fn value -> with %Plug.Upload{} <- value, {:ok, object} <- ActivityPub.upload(value, type: :avatar) do {:ok, object.data} else _ -> :error end end) emojis_text = (user_params["display_name"] || "") <> (user_params["note"] || "") user_info_emojis = user.info |> Map.get(:emoji, []) |> Enum.concat(Formatter.get_emoji_map(emojis_text)) |> Enum.dedup() info_params = [ :no_rich_text, :locked, :hide_followers_count, :hide_follows_count, :hide_followers, :hide_follows, :hide_favorites, :show_role, :skip_thread_containment ] |> Enum.reduce(%{}, fn key, acc -> add_if_present(acc, params, to_string(key), key, fn value -> {:ok, ControllerHelper.truthy_param?(value)} end) end) |> add_if_present(params, "default_scope", :default_scope) |> add_if_present(params, "fields", :fields, fn fields -> fields = Enum.map(fields, fn f -> Map.update!(f, "value", &AutoLinker.link(&1)) end) {:ok, fields} end) |> add_if_present(params, "fields", :raw_fields) |> add_if_present(params, "pleroma_settings_store", :pleroma_settings_store, fn value -> {:ok, Map.merge(user.info.pleroma_settings_store, value)} end) |> add_if_present(params, "header", :banner, fn value -> with %Plug.Upload{} <- value, {:ok, object} <- ActivityPub.upload(value, type: :banner) do {:ok, object.data} else _ -> :error end end) |> add_if_present(params, "pleroma_background_image", :background, fn value -> with %Plug.Upload{} <- value, {:ok, object} <- ActivityPub.upload(value, type: :background) do {:ok, object.data} else _ -> :error end end) |> Map.put(:emoji, user_info_emojis) info_cng = User.Info.profile_update(user.info, info_params) with changeset <- User.update_changeset(user, user_params), changeset <- Changeset.put_embed(changeset, :info, info_cng), {:ok, user} <- User.update_and_set_cache(changeset) do if original_user != user do CommonAPI.update(user) end json( conn, AccountView.render("account.json", %{user: user, for: user, with_pleroma_settings: true}) ) else _e -> render_error(conn, :forbidden, "Invalid request") end end def update_avatar(%{assigns: %{user: user}} = conn, %{"img" => ""}) do change = Changeset.change(user, %{avatar: nil}) {:ok, user} = User.update_and_set_cache(change) CommonAPI.update(user) json(conn, %{url: nil}) end def update_avatar(%{assigns: %{user: user}} = conn, params) do {:ok, object} = ActivityPub.upload(params, type: :avatar) change = Changeset.change(user, %{avatar: object.data}) {:ok, user} = User.update_and_set_cache(change) CommonAPI.update(user) %{"url" => [%{"href" => href} | _]} = object.data json(conn, %{url: href}) end def update_banner(%{assigns: %{user: user}} = conn, %{"banner" => ""}) do with new_info <- %{"banner" => %{}}, info_cng <- User.Info.profile_update(user.info, new_info), changeset <- Changeset.change(user) |> Changeset.put_embed(:info, info_cng), {:ok, user} <- User.update_and_set_cache(changeset) do CommonAPI.update(user) json(conn, %{url: nil}) end end def update_banner(%{assigns: %{user: user}} = conn, params) do with {:ok, object} <- ActivityPub.upload(%{"img" => params["banner"]}, type: :banner), new_info <- %{"banner" => object.data}, info_cng <- User.Info.profile_update(user.info, new_info), changeset <- Changeset.change(user) |> Changeset.put_embed(:info, info_cng), {:ok, user} <- User.update_and_set_cache(changeset) do CommonAPI.update(user) %{"url" => [%{"href" => href} | _]} = object.data json(conn, %{url: href}) end end def update_background(%{assigns: %{user: user}} = conn, %{"img" => ""}) do with new_info <- %{"background" => %{}}, info_cng <- User.Info.profile_update(user.info, new_info), changeset <- Changeset.change(user) |> Changeset.put_embed(:info, info_cng), {:ok, _user} <- User.update_and_set_cache(changeset) do json(conn, %{url: nil}) end end def update_background(%{assigns: %{user: user}} = conn, params) do with {:ok, object} <- ActivityPub.upload(params, type: :background), new_info <- %{"background" => object.data}, info_cng <- User.Info.profile_update(user.info, new_info), changeset <- Changeset.change(user) |> Changeset.put_embed(:info, info_cng), {:ok, _user} <- User.update_and_set_cache(changeset) do %{"url" => [%{"href" => href} | _]} = object.data json(conn, %{url: href}) end end def verify_credentials(%{assigns: %{user: user}} = conn, _) do chat_token = Phoenix.Token.sign(conn, "user socket", user.id) account = AccountView.render("account.json", %{ user: user, for: user, with_pleroma_settings: true, with_chat_token: chat_token }) json(conn, account) end def verify_app_credentials(%{assigns: %{user: _user, token: token}} = conn, _) do with %Token{app: %App{} = app} <- Repo.preload(token, :app) do conn |> put_view(AppView) |> render("short.json", %{app: app}) end end def user(%{assigns: %{user: for_user}} = conn, %{"id" => nickname_or_id}) do with %User{} = user <- User.get_cached_by_nickname_or_id(nickname_or_id, for: for_user), true <- User.auth_active?(user) || user.id == for_user.id || User.superuser?(for_user) do account = AccountView.render("account.json", %{user: user, for: for_user}) json(conn, account) else _e -> render_error(conn, :not_found, "Can't find user") end end @mastodon_api_level "2.7.2" def masto_instance(conn, _params) do instance = Config.get(:instance) response = %{ uri: Web.base_url(), title: Keyword.get(instance, :name), description: Keyword.get(instance, :description), version: "#{@mastodon_api_level} (compatible; #{Pleroma.Application.named_version()})", email: Keyword.get(instance, :email), urls: %{ streaming_api: Pleroma.Web.Endpoint.websocket_url() }, stats: Stats.get_stats(), thumbnail: Web.base_url() <> "/instance/thumbnail.jpeg", languages: ["en"], registrations: Pleroma.Config.get([:instance, :registrations_open]), # Extra (not present in Mastodon): max_toot_chars: Keyword.get(instance, :limit), poll_limits: Keyword.get(instance, :poll_limits) } json(conn, response) end def peers(conn, _params) do json(conn, Stats.get_peers()) end defp mastodonized_emoji do Pleroma.Emoji.get_all() |> Enum.map(fn {shortcode, relative_url, tags} -> url = to_string(URI.merge(Web.base_url(), relative_url)) %{ "shortcode" => shortcode, "static_url" => url, "visible_in_picker" => true, "url" => url, "tags" => tags, # Assuming that a comma is authorized in the category name "category" => (tags -- ["Custom"]) |> Enum.join(",") } end) end def custom_emojis(conn, _params) do mastodon_emoji = mastodonized_emoji() json(conn, mastodon_emoji) end def home_timeline(%{assigns: %{user: user}} = conn, params) do params = params |> Map.put("type", ["Create", "Announce"]) |> Map.put("blocking_user", user) |> Map.put("muting_user", user) |> Map.put("user", user) activities = [user.ap_id | user.following] |> ActivityPub.fetch_activities(params) |> Enum.reverse() conn |> add_link_headers(activities) |> put_view(StatusView) |> render("index.json", %{activities: activities, for: user, as: :activity}) end def public_timeline(%{assigns: %{user: user}} = conn, params) do local_only = params["local"] in [true, "True", "true", "1"] activities = params |> Map.put("type", ["Create", "Announce"]) |> Map.put("local_only", local_only) |> Map.put("blocking_user", user) |> Map.put("muting_user", user) |> Map.put("user", user) |> ActivityPub.fetch_public_activities() |> Enum.reverse() conn |> add_link_headers(activities, %{"local" => local_only}) |> put_view(StatusView) |> render("index.json", %{activities: activities, for: user, as: :activity}) end def user_statuses(%{assigns: %{user: reading_user}} = conn, params) do with %User{} = user <- User.get_cached_by_nickname_or_id(params["id"], for: reading_user) do params = params |> Map.put("tag", params["tagged"]) activities = ActivityPub.fetch_user_activities(user, reading_user, params) conn |> add_link_headers(activities) |> put_view(StatusView) |> render("index.json", %{ activities: activities, for: reading_user, as: :activity }) end end def dm_timeline(%{assigns: %{user: user}} = conn, params) do params = params |> Map.put("type", "Create") |> Map.put("blocking_user", user) |> Map.put("user", user) |> Map.put(:visibility, "direct") activities = [user.ap_id] |> ActivityPub.fetch_activities_query(params) |> Pagination.fetch_paginated(params) conn |> add_link_headers(activities) |> put_view(StatusView) |> render("index.json", %{activities: activities, for: user, as: :activity}) end def get_statuses(%{assigns: %{user: user}} = conn, %{"ids" => ids}) do limit = 100 activities = ids |> Enum.take(limit) |> Activity.all_by_ids_with_object() |> Enum.filter(&Visibility.visible_for_user?(&1, user)) conn |> put_view(StatusView) |> render("index.json", activities: activities, for: user, as: :activity) end def get_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do with %Activity{} = activity <- Activity.get_by_id_with_object(id), true <- Visibility.visible_for_user?(activity, user) do conn |> put_view(StatusView) |> try_render("status.json", %{activity: activity, for: user}) end end def get_context(%{assigns: %{user: user}} = conn, %{"id" => id}) do with %Activity{} = activity <- Activity.get_by_id(id), activities <- ActivityPub.fetch_activities_for_context(activity.data["context"], %{ "blocking_user" => user, "user" => user, "exclude_id" => activity.id }), grouped_activities <- Enum.group_by(activities, fn %{id: id} -> id < activity.id end) do result = %{ ancestors: StatusView.render("index.json", for: user, activities: grouped_activities[true] || [], as: :activity ) |> Enum.reverse(), # credo:disable-for-previous-line Credo.Check.Refactor.PipeChainStart descendants: StatusView.render("index.json", for: user, activities: grouped_activities[false] || [], as: :activity ) |> Enum.reverse() # credo:disable-for-previous-line Credo.Check.Refactor.PipeChainStart } json(conn, result) end end def get_poll(%{assigns: %{user: user}} = conn, %{"id" => id}) do with %Object{} = object <- Object.get_by_id_and_maybe_refetch(id, interval: 60), %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), true <- Visibility.visible_for_user?(activity, user) do conn |> put_view(StatusView) |> try_render("poll.json", %{object: object, for: user}) else error when is_nil(error) or error == false -> render_error(conn, :not_found, "Record not found") end end defp get_cached_vote_or_vote(user, object, choices) do idempotency_key = "polls:#{user.id}:#{object.data["id"]}" {_, res} = Cachex.fetch(:idempotency_cache, idempotency_key, fn _ -> case CommonAPI.vote(user, object, choices) do {:error, _message} = res -> {:ignore, res} res -> {:commit, res} end end) res end def poll_vote(%{assigns: %{user: user}} = conn, %{"id" => id, "choices" => choices}) do with %Object{} = object <- Object.get_by_id(id), true <- object.data["type"] == "Question", %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), true <- Visibility.visible_for_user?(activity, user), {:ok, _activities, object} <- get_cached_vote_or_vote(user, object, choices) do conn |> put_view(StatusView) |> try_render("poll.json", %{object: object, for: user}) else nil -> render_error(conn, :not_found, "Record not found") false -> render_error(conn, :not_found, "Record not found") {:error, message} -> conn |> put_status(:unprocessable_entity) |> json(%{error: message}) end end def scheduled_statuses(%{assigns: %{user: user}} = conn, params) do with scheduled_activities <- MastodonAPI.get_scheduled_activities(user, params) do conn |> add_link_headers(scheduled_activities) |> put_view(ScheduledActivityView) |> render("index.json", %{scheduled_activities: scheduled_activities}) end end def show_scheduled_status(%{assigns: %{user: user}} = conn, %{"id" => scheduled_activity_id}) do with %ScheduledActivity{} = scheduled_activity <- ScheduledActivity.get(user, scheduled_activity_id) do conn |> put_view(ScheduledActivityView) |> render("show.json", %{scheduled_activity: scheduled_activity}) else _ -> {:error, :not_found} end end def update_scheduled_status( %{assigns: %{user: user}} = conn, %{"id" => scheduled_activity_id} = params ) do with %ScheduledActivity{} = scheduled_activity <- ScheduledActivity.get(user, scheduled_activity_id), {:ok, scheduled_activity} <- ScheduledActivity.update(scheduled_activity, params) do conn |> put_view(ScheduledActivityView) |> render("show.json", %{scheduled_activity: scheduled_activity}) else nil -> {:error, :not_found} error -> error end end def delete_scheduled_status(%{assigns: %{user: user}} = conn, %{"id" => scheduled_activity_id}) do with %ScheduledActivity{} = scheduled_activity <- ScheduledActivity.get(user, scheduled_activity_id), {:ok, scheduled_activity} <- ScheduledActivity.delete(scheduled_activity) do conn |> put_view(ScheduledActivityView) |> render("show.json", %{scheduled_activity: scheduled_activity}) else nil -> {:error, :not_found} error -> error end end def post_status(%{assigns: %{user: user}} = conn, %{"status" => _} = params) do params = params |> Map.put("in_reply_to_status_id", params["in_reply_to_id"]) scheduled_at = params["scheduled_at"] if scheduled_at && ScheduledActivity.far_enough?(scheduled_at) do with {:ok, scheduled_activity} <- ScheduledActivity.create(user, %{"params" => params, "scheduled_at" => scheduled_at}) do conn |> put_view(ScheduledActivityView) |> render("show.json", %{scheduled_activity: scheduled_activity}) end else params = Map.drop(params, ["scheduled_at"]) case CommonAPI.post(user, params) do {:error, message} -> conn |> put_status(:unprocessable_entity) |> json(%{error: message}) {:ok, activity} -> conn |> put_view(StatusView) |> try_render("status.json", %{ activity: activity, for: user, as: :activity, with_direct_conversation_id: true }) end end end def delete_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do with {:ok, %Activity{}} <- CommonAPI.delete(id, user) do json(conn, %{}) else _e -> render_error(conn, :forbidden, "Can't delete this post") end end def reblog_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do with {:ok, announce, _activity} <- CommonAPI.repeat(ap_id_or_id, user), %Activity{} = announce <- Activity.normalize(announce.data) do conn |> put_view(StatusView) |> try_render("status.json", %{activity: announce, for: user, as: :activity}) end end def unreblog_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do with {:ok, _unannounce, %{data: %{"id" => id}}} <- CommonAPI.unrepeat(ap_id_or_id, user), %Activity{} = activity <- Activity.get_create_by_object_ap_id_with_object(id) do conn |> put_view(StatusView) |> try_render("status.json", %{activity: activity, for: user, as: :activity}) end end def fav_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do with {:ok, _fav, %{data: %{"id" => id}}} <- CommonAPI.favorite(ap_id_or_id, user), %Activity{} = activity <- Activity.get_create_by_object_ap_id(id) do conn |> put_view(StatusView) |> try_render("status.json", %{activity: activity, for: user, as: :activity}) end end def unfav_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do with {:ok, _, _, %{data: %{"id" => id}}} <- CommonAPI.unfavorite(ap_id_or_id, user), %Activity{} = activity <- Activity.get_create_by_object_ap_id(id) do conn |> put_view(StatusView) |> try_render("status.json", %{activity: activity, for: user, as: :activity}) end end def pin_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do with {:ok, activity} <- CommonAPI.pin(ap_id_or_id, user) do conn |> put_view(StatusView) |> try_render("status.json", %{activity: activity, for: user, as: :activity}) end end def unpin_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do with {:ok, activity} <- CommonAPI.unpin(ap_id_or_id, user) do conn |> put_view(StatusView) |> try_render("status.json", %{activity: activity, for: user, as: :activity}) end end def bookmark_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do with %Activity{} = activity <- Activity.get_by_id_with_object(id), %User{} = user <- User.get_cached_by_nickname(user.nickname), true <- Visibility.visible_for_user?(activity, user), {:ok, _bookmark} <- Bookmark.create(user.id, activity.id) do conn |> put_view(StatusView) |> try_render("status.json", %{activity: activity, for: user, as: :activity}) end end def unbookmark_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do with %Activity{} = activity <- Activity.get_by_id_with_object(id), %User{} = user <- User.get_cached_by_nickname(user.nickname), true <- Visibility.visible_for_user?(activity, user), {:ok, _bookmark} <- Bookmark.destroy(user.id, activity.id) do conn |> put_view(StatusView) |> try_render("status.json", %{activity: activity, for: user, as: :activity}) end end def mute_conversation(%{assigns: %{user: user}} = conn, %{"id" => id}) do activity = Activity.get_by_id(id) with {:ok, activity} <- CommonAPI.add_mute(user, activity) do conn |> put_view(StatusView) |> try_render("status.json", %{activity: activity, for: user, as: :activity}) end end def unmute_conversation(%{assigns: %{user: user}} = conn, %{"id" => id}) do activity = Activity.get_by_id(id) with {:ok, activity} <- CommonAPI.remove_mute(user, activity) do conn |> put_view(StatusView) |> try_render("status.json", %{activity: activity, for: user, as: :activity}) end end def notifications(%{assigns: %{user: user}} = conn, params) do notifications = MastodonAPI.get_notifications(user, params) conn |> add_link_headers(notifications) |> put_view(NotificationView) |> render("index.json", %{notifications: notifications, for: user}) end def get_notification(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do with {:ok, notification} <- Notification.get(user, id) do conn |> put_view(NotificationView) |> render("show.json", %{notification: notification, for: user}) else {:error, reason} -> conn |> put_status(:forbidden) |> json(%{"error" => reason}) end end def clear_notifications(%{assigns: %{user: user}} = conn, _params) do Notification.clear(user) json(conn, %{}) end def dismiss_notification(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do with {:ok, _notif} <- Notification.dismiss(user, id) do json(conn, %{}) else {:error, reason} -> conn |> put_status(:forbidden) |> json(%{"error" => reason}) end end def destroy_multiple(%{assigns: %{user: user}} = conn, %{"ids" => ids} = _params) do Notification.destroy_multiple(user, ids) json(conn, %{}) end def relationships(%{assigns: %{user: user}} = conn, %{"id" => id}) do targets = User.get_all_by_ids(List.wrap(id)) conn |> put_view(AccountView) |> render("relationships.json", %{user: user, targets: targets}) end # Instead of returning a 400 when no "id" params is present, Mastodon returns an empty array. def relationships(%{assigns: %{user: _user}} = conn, _), do: json(conn, []) def update_media( %{assigns: %{user: user}} = conn, %{"id" => id, "description" => description} = _ ) when is_binary(description) do with %Object{} = object <- Repo.get(Object, id), true <- Object.authorize_mutation(object, user), {:ok, %Object{data: data}} <- Object.update_data(object, %{"name" => description}) do attachment_data = Map.put(data, "id", object.id) conn |> put_view(StatusView) |> render("attachment.json", %{attachment: attachment_data}) end end def update_media(_conn, _data), do: {:error, :bad_request} def upload(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do with {:ok, object} <- ActivityPub.upload( file, actor: User.ap_id(user), description: Map.get(data, "description") ) do attachment_data = Map.put(object.data, "id", object.id) conn |> put_view(StatusView) |> render("attachment.json", %{attachment: attachment_data}) end end def set_mascot(%{assigns: %{user: user}} = conn, %{"file" => file}) do with {:ok, object} <- ActivityPub.upload(file, actor: User.ap_id(user)), %{} = attachment_data <- Map.put(object.data, "id", object.id), %{type: "image"} = rendered <- StatusView.render("attachment.json", %{attachment: attachment_data}), {:ok, _user} = User.update_mascot(user, rendered) do json(conn, rendered) else %{type: _type} = _ -> render_error(conn, :unsupported_media_type, "mascots can only be images") e -> e end end def get_mascot(%{assigns: %{user: user}} = conn, _params) do mascot = User.get_mascot(user) json(conn, mascot) end def favourited_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do with %Activity{} = activity <- Activity.get_by_id_with_object(id), {:visible, true} <- {:visible, Visibility.visible_for_user?(activity, user)}, %Object{data: %{"likes" => likes}} <- Object.normalize(activity) do q = from(u in User, where: u.ap_id in ^likes) users = Repo.all(q) |> Enum.filter(&(not User.blocks?(user, &1))) conn |> put_view(AccountView) |> render("accounts.json", %{for: user, users: users, as: :user}) else {:visible, false} -> {:error, :not_found} _ -> json(conn, []) end end def reblogged_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do with %Activity{} = activity <- Activity.get_by_id_with_object(id), {:visible, true} <- {:visible, Visibility.visible_for_user?(activity, user)}, %Object{data: %{"announcements" => announces}} <- Object.normalize(activity) do q = from(u in User, where: u.ap_id in ^announces) users = Repo.all(q) |> Enum.filter(&(not User.blocks?(user, &1))) conn |> put_view(AccountView) |> render("accounts.json", %{for: user, users: users, as: :user}) else {:visible, false} -> {:error, :not_found} _ -> json(conn, []) end end def hashtag_timeline(%{assigns: %{user: user}} = conn, params) do local_only = params["local"] in [true, "True", "true", "1"] tags = [params["tag"], params["any"]] |> List.flatten() |> Enum.uniq() |> Enum.filter(& &1) |> Enum.map(&String.downcase(&1)) tag_all = params["all"] || [] |> Enum.map(&String.downcase(&1)) tag_reject = params["none"] || [] |> Enum.map(&String.downcase(&1)) activities = params |> Map.put("type", "Create") |> Map.put("local_only", local_only) |> Map.put("blocking_user", user) |> Map.put("muting_user", user) |> Map.put("user", user) |> Map.put("tag", tags) |> Map.put("tag_all", tag_all) |> Map.put("tag_reject", tag_reject) |> ActivityPub.fetch_public_activities() |> Enum.reverse() conn |> add_link_headers(activities, %{"local" => local_only}) |> put_view(StatusView) |> render("index.json", %{activities: activities, for: user, as: :activity}) end def followers(%{assigns: %{user: for_user}} = conn, %{"id" => id} = params) do with %User{} = user <- User.get_cached_by_id(id), followers <- MastodonAPI.get_followers(user, params) do followers = cond do for_user && user.id == for_user.id -> followers user.info.hide_followers -> [] true -> followers end conn |> add_link_headers(followers) |> put_view(AccountView) |> render("accounts.json", %{for: for_user, users: followers, as: :user}) end end def following(%{assigns: %{user: for_user}} = conn, %{"id" => id} = params) do with %User{} = user <- User.get_cached_by_id(id), followers <- MastodonAPI.get_friends(user, params) do followers = cond do for_user && user.id == for_user.id -> followers user.info.hide_follows -> [] true -> followers end conn |> add_link_headers(followers) |> put_view(AccountView) |> render("accounts.json", %{for: for_user, users: followers, as: :user}) end end def follow_requests(%{assigns: %{user: followed}} = conn, _params) do with {:ok, follow_requests} <- User.get_follow_requests(followed) do conn |> put_view(AccountView) |> render("accounts.json", %{for: followed, users: follow_requests, as: :user}) end end def authorize_follow_request(%{assigns: %{user: followed}} = conn, %{"id" => id}) do with %User{} = follower <- User.get_cached_by_id(id), {:ok, follower} <- CommonAPI.accept_follow_request(follower, followed) do conn |> put_view(AccountView) |> render("relationship.json", %{user: followed, target: follower}) else {:error, message} -> conn |> put_status(:forbidden) |> json(%{error: message}) end end def reject_follow_request(%{assigns: %{user: followed}} = conn, %{"id" => id}) do with %User{} = follower <- User.get_cached_by_id(id), {:ok, follower} <- CommonAPI.reject_follow_request(follower, followed) do conn |> put_view(AccountView) |> render("relationship.json", %{user: followed, target: follower}) else {:error, message} -> conn |> put_status(:forbidden) |> json(%{error: message}) end end def follow(%{assigns: %{user: follower}} = conn, %{"id" => id}) do with {_, %User{} = followed} <- {:followed, User.get_cached_by_id(id)}, {_, true} <- {:followed, follower.id != followed.id}, {:ok, follower} <- MastodonAPI.follow(follower, followed, conn.params) do conn |> put_view(AccountView) |> render("relationship.json", %{user: follower, target: followed}) else {:followed, _} -> {:error, :not_found} {:error, message} -> conn |> put_status(:forbidden) |> json(%{error: message}) end end def follow(%{assigns: %{user: follower}} = conn, %{"uri" => uri}) do with {_, %User{} = followed} <- {:followed, User.get_cached_by_nickname(uri)}, {_, true} <- {:followed, follower.id != followed.id}, {:ok, follower, followed, _} <- CommonAPI.follow(follower, followed) do conn |> put_view(AccountView) |> render("account.json", %{user: followed, for: follower}) else {:followed, _} -> {:error, :not_found} {:error, message} -> conn |> put_status(:forbidden) |> json(%{error: message}) end end def unfollow(%{assigns: %{user: follower}} = conn, %{"id" => id}) do with {_, %User{} = followed} <- {:followed, User.get_cached_by_id(id)}, {_, true} <- {:followed, follower.id != followed.id}, {:ok, follower} <- CommonAPI.unfollow(follower, followed) do conn |> put_view(AccountView) |> render("relationship.json", %{user: follower, target: followed}) else {:followed, _} -> {:error, :not_found} error -> error end end def mute(%{assigns: %{user: muter}} = conn, %{"id" => id} = params) do notifications = if Map.has_key?(params, "notifications"), do: params["notifications"] in [true, "True", "true", "1"], else: true with %User{} = muted <- User.get_cached_by_id(id), {:ok, muter} <- User.mute(muter, muted, notifications) do conn |> put_view(AccountView) |> render("relationship.json", %{user: muter, target: muted}) else {:error, message} -> conn |> put_status(:forbidden) |> json(%{error: message}) end end def unmute(%{assigns: %{user: muter}} = conn, %{"id" => id}) do with %User{} = muted <- User.get_cached_by_id(id), {:ok, muter} <- User.unmute(muter, muted) do conn |> put_view(AccountView) |> render("relationship.json", %{user: muter, target: muted}) else {:error, message} -> conn |> put_status(:forbidden) |> json(%{error: message}) end end def mutes(%{assigns: %{user: user}} = conn, _) do with muted_accounts <- User.muted_users(user) do res = AccountView.render("accounts.json", users: muted_accounts, for: user, as: :user) json(conn, res) end end def block(%{assigns: %{user: blocker}} = conn, %{"id" => id}) do with %User{} = blocked <- User.get_cached_by_id(id), {:ok, blocker} <- User.block(blocker, blocked), {:ok, _activity} <- ActivityPub.block(blocker, blocked) do conn |> put_view(AccountView) |> render("relationship.json", %{user: blocker, target: blocked}) else {:error, message} -> conn |> put_status(:forbidden) |> json(%{error: message}) end end def unblock(%{assigns: %{user: blocker}} = conn, %{"id" => id}) do with %User{} = blocked <- User.get_cached_by_id(id), {:ok, blocker} <- User.unblock(blocker, blocked), {:ok, _activity} <- ActivityPub.unblock(blocker, blocked) do conn |> put_view(AccountView) |> render("relationship.json", %{user: blocker, target: blocked}) else {:error, message} -> conn |> put_status(:forbidden) |> json(%{error: message}) end end def blocks(%{assigns: %{user: user}} = conn, _) do with blocked_accounts <- User.blocked_users(user) do res = AccountView.render("accounts.json", users: blocked_accounts, for: user, as: :user) json(conn, res) end end def domain_blocks(%{assigns: %{user: %{info: info}}} = conn, _) do json(conn, info.domain_blocks || []) end def block_domain(%{assigns: %{user: blocker}} = conn, %{"domain" => domain}) do User.block_domain(blocker, domain) json(conn, %{}) end def unblock_domain(%{assigns: %{user: blocker}} = conn, %{"domain" => domain}) do User.unblock_domain(blocker, domain) json(conn, %{}) end def subscribe(%{assigns: %{user: user}} = conn, %{"id" => id}) do with %User{} = subscription_target <- User.get_cached_by_id(id), {:ok, subscription_target} = User.subscribe(user, subscription_target) do conn |> put_view(AccountView) |> render("relationship.json", %{user: user, target: subscription_target}) else nil -> {:error, :not_found} e -> e end end def unsubscribe(%{assigns: %{user: user}} = conn, %{"id" => id}) do with %User{} = subscription_target <- User.get_cached_by_id(id), {:ok, subscription_target} = User.unsubscribe(user, subscription_target) do conn |> put_view(AccountView) |> render("relationship.json", %{user: user, target: subscription_target}) else nil -> {:error, :not_found} e -> e end end def favourites(%{assigns: %{user: user}} = conn, params) do params = params |> Map.put("type", "Create") |> Map.put("favorited_by", user.ap_id) |> Map.put("blocking_user", user) activities = ActivityPub.fetch_activities([], params) |> Enum.reverse() conn |> add_link_headers(activities) |> put_view(StatusView) |> render("index.json", %{activities: activities, for: user, as: :activity}) end def user_favourites(%{assigns: %{user: for_user}} = conn, %{"id" => id} = params) do with %User{} = user <- User.get_by_id(id), false <- user.info.hide_favorites do params = params |> Map.put("type", "Create") |> Map.put("favorited_by", user.ap_id) |> Map.put("blocking_user", for_user) recipients = if for_user do [Pleroma.Constants.as_public()] ++ [for_user.ap_id | for_user.following] else [Pleroma.Constants.as_public()] end activities = recipients |> ActivityPub.fetch_activities(params) |> Enum.reverse() conn |> add_link_headers(activities) |> put_view(StatusView) |> render("index.json", %{activities: activities, for: for_user, as: :activity}) else nil -> {:error, :not_found} true -> render_error(conn, :forbidden, "Can't get favorites") end end def bookmarks(%{assigns: %{user: user}} = conn, params) do user = User.get_cached_by_id(user.id) bookmarks = Bookmark.for_user_query(user.id) |> Pagination.fetch_paginated(params) activities = bookmarks |> Enum.map(fn b -> Map.put(b.activity, :bookmark, Map.delete(b, :activity)) end) conn |> add_link_headers(bookmarks) |> put_view(StatusView) |> render("index.json", %{activities: activities, for: user, as: :activity}) end def account_lists(%{assigns: %{user: user}} = conn, %{"id" => account_id}) do lists = Pleroma.List.get_lists_account_belongs(user, account_id) conn |> put_view(ListView) |> render("index.json", %{lists: lists}) end def list_timeline(%{assigns: %{user: user}} = conn, %{"list_id" => id} = params) do with %Pleroma.List{title: _title, following: following} <- Pleroma.List.get(id, user) do params = params |> Map.put("type", "Create") |> Map.put("blocking_user", user) |> Map.put("user", user) |> Map.put("muting_user", user) # we must filter the following list for the user to avoid leaking statuses the user # does not actually have permission to see (for more info, peruse security issue #270). activities = following |> Enum.filter(fn x -> x in user.following end) |> ActivityPub.fetch_activities_bounded(following, params) |> Enum.reverse() conn |> put_view(StatusView) |> render("index.json", %{activities: activities, for: user, as: :activity}) else _e -> render_error(conn, :forbidden, "Error.") end end def index(%{assigns: %{user: user}} = conn, _params) do token = get_session(conn, :oauth_token) if user && token do mastodon_emoji = mastodonized_emoji() limit = Config.get([:instance, :limit]) accounts = Map.put(%{}, user.id, AccountView.render("account.json", %{user: user, for: user})) initial_state = %{ meta: %{ streaming_api_base_url: Pleroma.Web.Endpoint.websocket_url(), access_token: token, locale: "en", domain: Pleroma.Web.Endpoint.host(), admin: "1", me: "#{user.id}", unfollow_modal: false, boost_modal: false, delete_modal: true, auto_play_gif: false, display_sensitive_media: false, reduce_motion: false, max_toot_chars: limit, mascot: User.get_mascot(user)["url"] }, poll_limits: Config.get([:instance, :poll_limits]), rights: %{ delete_others_notice: present?(user.info.is_moderator), admin: present?(user.info.is_admin) }, compose: %{ me: "#{user.id}", default_privacy: user.info.default_scope, default_sensitive: false, allow_content_types: Config.get([:instance, :allowed_post_formats]) }, media_attachments: %{ accept_content_types: [ ".jpg", ".jpeg", ".png", ".gif", ".webm", ".mp4", ".m4v", "image\/jpeg", "image\/png", "image\/gif", "video\/webm", "video\/mp4" ] }, settings: user.info.settings || %{ onboarded: true, home: %{ shows: %{ reblog: true, reply: true } }, notifications: %{ alerts: %{ follow: true, favourite: true, reblog: true, mention: true }, shows: %{ follow: true, favourite: true, reblog: true, mention: true }, sounds: %{ follow: true, favourite: true, reblog: true, mention: true } } }, push_subscription: nil, accounts: accounts, custom_emojis: mastodon_emoji, char_limit: limit } |> Jason.encode!() conn |> put_layout(false) |> put_view(MastodonView) |> render("index.html", %{initial_state: initial_state}) else conn |> put_session(:return_to, conn.request_path) |> redirect(to: "/web/login") end end def put_settings(%{assigns: %{user: user}} = conn, %{"data" => settings} = _params) do info_cng = User.Info.mastodon_settings_update(user.info, settings) with changeset <- Changeset.change(user), changeset <- Changeset.put_embed(changeset, :info, info_cng), {:ok, _user} <- User.update_and_set_cache(changeset) do json(conn, %{}) else e -> conn |> put_status(:internal_server_error) |> json(%{error: inspect(e)}) end end def login(%{assigns: %{user: %User{}}} = conn, _params) do redirect(conn, to: local_mastodon_root_path(conn)) end @doc "Local Mastodon FE login init action" def login(conn, %{"code" => auth_token}) do with {:ok, app} <- get_or_make_app(), {:ok, auth} <- Authorization.get_by_token(app, auth_token), {:ok, token} <- Token.exchange_token(app, auth) do conn |> put_session(:oauth_token, token.token) |> redirect(to: local_mastodon_root_path(conn)) end end @doc "Local Mastodon FE callback action" def login(conn, _) do with {:ok, app} <- get_or_make_app() do path = o_auth_path(conn, :authorize, response_type: "code", client_id: app.client_id, redirect_uri: ".", scope: Enum.join(app.scopes, " ") ) redirect(conn, to: path) end end defp local_mastodon_root_path(conn) do case get_session(conn, :return_to) do nil -> mastodon_api_path(conn, :index, ["getting-started"]) return_to -> delete_session(conn, :return_to) return_to end end @spec get_or_make_app() :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} defp get_or_make_app do App.get_or_make( %{client_name: @local_mastodon_name, redirect_uris: "."}, ["read", "write", "follow", "push"] ) end def logout(conn, _) do conn |> clear_session |> redirect(to: "/") end # Stubs for unimplemented mastodon api # def empty_array(conn, _) do Logger.debug("Unimplemented, returning an empty array") json(conn, []) end def get_filters(%{assigns: %{user: user}} = conn, _) do filters = Filter.get_filters(user) res = FilterView.render("filters.json", filters: filters) json(conn, res) end def create_filter( %{assigns: %{user: user}} = conn, %{"phrase" => phrase, "context" => context} = params ) do query = %Filter{ user_id: user.id, phrase: phrase, context: context, hide: Map.get(params, "irreversible", false), whole_word: Map.get(params, "boolean", true) # expires_at } {:ok, response} = Filter.create(query) res = FilterView.render("filter.json", filter: response) json(conn, res) end def get_filter(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do filter = Filter.get(filter_id, user) res = FilterView.render("filter.json", filter: filter) json(conn, res) end def update_filter( %{assigns: %{user: user}} = conn, %{"phrase" => phrase, "context" => context, "id" => filter_id} = params ) do query = %Filter{ user_id: user.id, filter_id: filter_id, phrase: phrase, context: context, hide: Map.get(params, "irreversible", nil), whole_word: Map.get(params, "boolean", true) # expires_at } {:ok, response} = Filter.update(query) res = FilterView.render("filter.json", filter: response) json(conn, res) end def delete_filter(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do query = %Filter{ user_id: user.id, filter_id: filter_id } {:ok, _} = Filter.delete(query) json(conn, %{}) end def suggestions(%{assigns: %{user: user}} = conn, _) do suggestions = Config.get(:suggestions) if Keyword.get(suggestions, :enabled, false) do api = Keyword.get(suggestions, :third_party_engine, "") timeout = Keyword.get(suggestions, :timeout, 5000) limit = Keyword.get(suggestions, :limit, 23) host = Config.get([Pleroma.Web.Endpoint, :url, :host]) user = user.nickname url = api |> String.replace("{{host}}", host) |> String.replace("{{user}}", user) with {:ok, %{status: 200, body: body}} <- HTTP.get(url, [], adapter: [recv_timeout: timeout, pool: :default]), {:ok, data} <- Jason.decode(body) do data = data |> Enum.slice(0, limit) |> Enum.map(fn x -> x |> Map.put("id", fetch_suggestion_id(x)) |> Map.put("avatar", MediaProxy.url(x["avatar"])) |> Map.put("avatar_static", MediaProxy.url(x["avatar_static"])) end) json(conn, data) else e -> Logger.error("Could not retrieve suggestions at fetch #{url}, #{inspect(e)}") end else json(conn, []) end end defp fetch_suggestion_id(attrs) do case User.get_or_fetch(attrs["acct"]) do {:ok, %User{id: id}} -> id _ -> 0 end end def status_card(%{assigns: %{user: user}} = conn, %{"id" => id}) do with %Activity{} = activity <- Activity.get_by_id(id), true <- Visibility.visible_for_user?(activity, user) do data = RichMedia.Helpers.fetch_data_for_activity(activity) conn |> put_view(StatusView) |> render("card.json", data) else _e -> {:error, :not_found} end end def reports(%{assigns: %{user: user}} = conn, params) do case CommonAPI.report(user, params) do {:ok, activity} -> conn |> put_view(ReportView) |> try_render("report.json", %{activity: activity}) {:error, err} -> conn |> put_status(:bad_request) |> json(%{error: err}) end end def account_register( %{assigns: %{app: app}} = conn, %{"username" => nickname, "email" => _, "password" => _, "agreement" => true} = params ) do params = params |> Map.take([ "email", "captcha_solution", "captcha_token", "captcha_answer_data", "token", "password" ]) |> Map.put("nickname", nickname) |> Map.put("fullname", params["fullname"] || nickname) |> Map.put("bio", params["bio"] || "") |> Map.put("confirm", params["password"]) with {:ok, user} <- TwitterAPI.register_user(params, need_confirmation: true), {:ok, token} <- Token.create_token(app, user, %{scopes: app.scopes}) do json(conn, %{ token_type: "Bearer", access_token: token.token, scope: app.scopes, created_at: Token.Utils.format_created_at(token) }) else {:error, errors} -> conn |> put_status(:bad_request) |> json(errors) end end def account_register(%{assigns: %{app: _app}} = conn, _) do render_error(conn, :bad_request, "Missing parameters") end def account_register(conn, _) do render_error(conn, :forbidden, "Invalid credentials") end def conversations(%{assigns: %{user: user}} = conn, params) do participations = Participation.for_user_with_last_activity_id(user, params) conversations = Enum.map(participations, fn participation -> ConversationView.render("participation.json", %{participation: participation, for: user}) end) conn |> add_link_headers(participations) |> json(conversations) end def conversation_read(%{assigns: %{user: user}} = conn, %{"id" => participation_id}) do with %Participation{} = participation <- Repo.get_by(Participation, id: participation_id, user_id: user.id), {:ok, participation} <- Participation.mark_as_read(participation) do participation_view = ConversationView.render("participation.json", %{participation: participation, for: user}) conn |> json(participation_view) end end def password_reset(conn, params) do nickname_or_email = params["email"] || params["nickname"] with {:ok, _} <- TwitterAPI.password_reset(nickname_or_email) do conn |> put_status(:no_content) |> json("") else {:error, "unknown user"} -> send_resp(conn, :not_found, "") {:error, _} -> send_resp(conn, :bad_request, "") end end def account_confirmation_resend(conn, params) do nickname_or_email = params["email"] || params["nickname"] with %User{} = user <- User.get_by_nickname_or_email(nickname_or_email), {:ok, _} <- User.try_send_confirmation_email(user) do conn |> json_response(:no_content, "") end end defp try_render(conn, target, params) when is_binary(target) do case render(conn, target, params) do nil -> render_error(conn, :not_implemented, "Can't display this activity") res -> res end end defp try_render(conn, _, _) do render_error(conn, :not_implemented, "Can't display this activity") end defp present?(nil), do: false defp present?(false), do: false defp present?(_), do: true end