diff options
Diffstat (limited to 'lib')
37 files changed, 758 insertions, 782 deletions
diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 462940e7e..11e4fde43 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -8,18 +8,7 @@ defmodule Mix.Tasks.Pleroma.Config do alias Pleroma.Repo alias Pleroma.Web.AdminAPI.Config @shortdoc "Manages the location of the config" - @moduledoc """ - Manages the location of the config. - - ## Transfers config from file to DB. - - mix pleroma.config migrate_to_db - - ## Transfers config from DB to file `config/env.exported_from_db.secret.exs` - - mix pleroma.config migrate_from_db ENV - """ - + @moduledoc File.read!("docs/administration/CLI_tasks/config.md") def run(["migrate_to_db"]) do start_pleroma() diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 890a383df..cfd9eeada 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -13,34 +13,8 @@ defmodule Mix.Tasks.Pleroma.Database do use Mix.Task @shortdoc "A collection of database related tasks" - @moduledoc """ - A collection of database related tasks + @moduledoc File.read!("docs/administration/CLI_tasks/database.md") - ## Replace embedded objects with their references - - Replaces embedded objects with references to them in the `objects` table. Only needs to be ran once. The reason why this is not a migration is because it could significantly increase the database size after being ran, however after this `VACUUM FULL` will be able to reclaim about 20% (really depends on what is in the database, your mileage may vary) of the db size before the migration. - - mix pleroma.database remove_embedded_objects - - Options: - - `--vacuum` - run `VACUUM FULL` after the embedded objects are replaced with their references - - ## Prune old objects from the database - - mix pleroma.database prune_objects - - ## Create a conversation for all existing DMs. Can be safely re-run. - - mix pleroma.database bump_all_conversations - - ## Remove duplicated items from following and update followers count for all users - - mix pleroma.database update_users_following_followers_counts - - ## Fix the pre-existing "likes" collections for all objects - - mix pleroma.database fix_likes_collections - """ def run(["remove_embedded_objects" | args]) do {options, [], []} = OptionParser.parse( diff --git a/lib/mix/tasks/pleroma/digest.ex b/lib/mix/tasks/pleroma/digest.ex index 430116a50..7d09e70c5 100644 --- a/lib/mix/tasks/pleroma/digest.ex +++ b/lib/mix/tasks/pleroma/digest.ex @@ -2,16 +2,8 @@ defmodule Mix.Tasks.Pleroma.Digest do use Mix.Task @shortdoc "Manages digest emails" - @moduledoc """ - Manages digest emails + @moduledoc File.read!("docs/administration/CLI_tasks/digest.md") - ## Send digest email since given date (user registration date by default) - ignoring user activity status. - - ``mix pleroma.digest test <nickname> <since_date>`` - - Example: ``mix pleroma.digest test donaldtheduck 2019-05-20`` - """ def run(["test", nickname | opts]) do Mix.Pleroma.start_pleroma() diff --git a/lib/mix/tasks/pleroma/emoji.ex b/lib/mix/tasks/pleroma/emoji.ex index 881a6f725..6ef0a635d 100644 --- a/lib/mix/tasks/pleroma/emoji.ex +++ b/lib/mix/tasks/pleroma/emoji.ex @@ -6,54 +6,7 @@ defmodule Mix.Tasks.Pleroma.Emoji do use Mix.Task @shortdoc "Manages emoji packs" - @moduledoc """ - Manages emoji packs - - ## ls-packs - - mix pleroma.emoji ls-packs [OPTION...] - - Lists the emoji packs and metadata specified in the manifest. - - ### Options - - - `-m, --manifest PATH/URL` - path to a custom manifest, it can - either be an URL starting with `http`, in that case the - manifest will be fetched from that address, or a local path - - ## get-packs - - mix pleroma.emoji get-packs [OPTION...] PACKS - - Fetches, verifies and installs the specified PACKS from the - manifest into the `STATIC-DIR/emoji/PACK-NAME` - - ### Options - - - `-m, --manifest PATH/URL` - same as ls-packs - - ## gen-pack - - mix pleroma.emoji gen-pack PACK-URL - - Creates a new manifest entry and a file list from the specified - remote pack file. Currently, only .zip archives are recognized - as remote pack files and packs are therefore assumed to be zip - archives. This command is intended to run interactively and will - first ask you some basic questions about the pack, then download - the remote file and generate an SHA256 checksum for it, then - generate an emoji file list for you. - - The manifest entry will either be written to a newly created - `index.json` file or appended to the existing one, *replacing* - the old pack with the same name if it was in the file previously. - - The file list will be written to the file specified previously, - *replacing* that file. You _should_ check that the file list doesn't - contain anything you don't need in the pack, that is, anything that is - not an emoji (the whole pack is downloaded, but only emoji files - are extracted). - """ + @moduledoc File.read!("docs/administration/CLI_tasks/emoji.md") def run(["ls-packs" | args]) do Application.ensure_all_started(:hackney) diff --git a/lib/mix/tasks/pleroma/instance.ex b/lib/mix/tasks/pleroma/instance.ex index 1a1634fe9..9af6cda30 100644 --- a/lib/mix/tasks/pleroma/instance.ex +++ b/lib/mix/tasks/pleroma/instance.ex @@ -7,36 +7,7 @@ defmodule Mix.Tasks.Pleroma.Instance do import Mix.Pleroma @shortdoc "Manages Pleroma instance" - @moduledoc """ - Manages Pleroma instance. - - ## Generate a new instance config. - - mix pleroma.instance gen [OPTION...] - - If any options are left unspecified, you will be prompted interactively - - ## Options - - - `-f`, `--force` - overwrite any output files - - `-o PATH`, `--output PATH` - the output file for the generated configuration - - `--output-psql PATH` - the output file for the generated PostgreSQL setup - - `--domain DOMAIN` - the domain of your instance - - `--instance-name INSTANCE_NAME` - the name of your instance - - `--admin-email ADMIN_EMAIL` - the email address of the instance admin - - `--notify-email NOTIFY_EMAIL` - email address for notifications - - `--dbhost HOSTNAME` - the hostname of the PostgreSQL database to use - - `--dbname DBNAME` - the name of the database to use - - `--dbuser DBUSER` - the user (aka role) to use for the database connection - - `--dbpass DBPASS` - the password to use for the database connection - - `--rum Y/N` - Whether to enable RUM indexes - - `--indexable Y/N` - Allow/disallow indexing site by search engines - - `--db-configurable Y/N` - Allow/disallow configuring instance from admin part - - `--uploads-dir` - the directory uploads go in when using a local uploader - - `--static-dir` - the directory custom public files should be read from (custom emojis, frontend bundle overrides, robots.txt, etc.) - - `--listen-ip` - the ip the app should listen to, defaults to 127.0.0.1 - - `--listen-port` - the port the app should listen to, defaults to 4000 - """ + @moduledoc File.read!("docs/administration/CLI_tasks/instance.md") def run(["gen" | rest]) do {options, [], []} = diff --git a/lib/mix/tasks/pleroma/relay.ex b/lib/mix/tasks/pleroma/relay.ex index 200721163..d7a7b599f 100644 --- a/lib/mix/tasks/pleroma/relay.ex +++ b/lib/mix/tasks/pleroma/relay.ex @@ -9,25 +9,8 @@ defmodule Mix.Tasks.Pleroma.Relay do alias Pleroma.Web.ActivityPub.Relay @shortdoc "Manages remote relays" - @moduledoc """ - Manages remote relays + @moduledoc File.read!("docs/administration/CLI_tasks/relay.md") - ## Follow a remote relay - - ``mix pleroma.relay follow <relay_url>`` - - Example: ``mix pleroma.relay follow https://example.org/relay`` - - ## Unfollow a remote relay - - ``mix pleroma.relay unfollow <relay_url>`` - - Example: ``mix pleroma.relay unfollow https://example.org/relay`` - - ## List relay subscriptions - - ``mix pleroma.relay list`` - """ def run(["follow", target]) do start_pleroma() diff --git a/lib/mix/tasks/pleroma/uploads.ex b/lib/mix/tasks/pleroma/uploads.ex index 95392d81b..3e6fc7ee0 100644 --- a/lib/mix/tasks/pleroma/uploads.ex +++ b/lib/mix/tasks/pleroma/uploads.ex @@ -12,16 +12,8 @@ defmodule Mix.Tasks.Pleroma.Uploads do @log_every 50 @shortdoc "Migrates uploads from local to remote storage" - @moduledoc """ - Manages uploads + @moduledoc File.read!("docs/administration/CLI_tasks/uploads.md") - ## Migrate uploads from local to remote storage - mix pleroma.uploads migrate_local TARGET_UPLOADER [OPTIONS...] - Options: - - `--delete` - delete local uploads after migrating them to the target uploader - - A list of available uploaders can be seen in config.exs - """ def run(["migrate_local", target_uploader | args]) do delete? = Enum.member?(args, "--delete") start_pleroma() diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex index d93ba8dee..134b5bccc 100644 --- a/lib/mix/tasks/pleroma/user.ex +++ b/lib/mix/tasks/pleroma/user.ex @@ -10,86 +10,8 @@ defmodule Mix.Tasks.Pleroma.User do alias Pleroma.Web.OAuth @shortdoc "Manages Pleroma users" - @moduledoc """ - Manages Pleroma users. + @moduledoc File.read!("docs/administration/CLI_tasks/user.md") - ## Create a new user. - - mix pleroma.user new NICKNAME EMAIL [OPTION...] - - Options: - - `--name NAME` - the user's name (i.e., "Lain Iwakura") - - `--bio BIO` - the user's bio - - `--password PASSWORD` - the user's password - - `--moderator`/`--no-moderator` - whether the user is a moderator - - `--admin`/`--no-admin` - whether the user is an admin - - `-y`, `--assume-yes`/`--no-assume-yes` - whether to assume yes to all questions - - ## Generate an invite link. - - mix pleroma.user invite [OPTION...] - - Options: - - `--expires-at DATE` - last day on which token is active (e.g. "2019-04-05") - - `--max-use NUMBER` - maximum numbers of token uses - - ## List generated invites - - mix pleroma.user invites - - ## Revoke invite - - mix pleroma.user revoke_invite TOKEN OR TOKEN_ID - - ## Delete the user's account. - - mix pleroma.user rm NICKNAME - - ## Delete the user's activities. - - mix pleroma.user delete_activities NICKNAME - - ## Sign user out from all applications (delete user's OAuth tokens and authorizations). - - mix pleroma.user sign_out NICKNAME - - ## Deactivate or activate the user's account. - - mix pleroma.user toggle_activated NICKNAME - - ## Unsubscribe local users from user's account and deactivate it - - mix pleroma.user unsubscribe NICKNAME - - ## Unsubscribe local users from an entire instance and deactivate all accounts - - mix pleroma.user unsubscribe_all_from_instance INSTANCE - - ## Create a password reset link. - - mix pleroma.user reset_password NICKNAME - - ## Set the value of the given user's settings. - - mix pleroma.user set NICKNAME [OPTION...] - - Options: - - `--locked`/`--no-locked` - whether the user's account is locked - - `--moderator`/`--no-moderator` - whether the user is a moderator - - `--admin`/`--no-admin` - whether the user is an admin - - ## Add tags to a user. - - mix pleroma.user tag NICKNAME TAGS - - ## Delete tags from a user. - - mix pleroma.user untag NICKNAME TAGS - - ## Toggle confirmation of the user's account. - - mix pleroma.user toggle_confirmed NICKNAME - """ def run(["new", nickname, email | rest]) do {options, [], []} = OptionParser.parse( diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 7aec2c545..9e35b02c0 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -102,7 +102,8 @@ defmodule Pleroma.Application do build_cachex("scrubber", limit: 2500), build_cachex("idempotency", expiration: idempotency_expiration(), limit: 2500), build_cachex("web_resp", limit: 2500), - build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10) + build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10), + build_cachex("failed_proxy_url", limit: 2500) ] end diff --git a/lib/pleroma/reverse_proxy/reverse_proxy.ex b/lib/pleroma/reverse_proxy/reverse_proxy.ex index 03efad30a..78144cae3 100644 --- a/lib/pleroma/reverse_proxy/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy/reverse_proxy.ex @@ -15,6 +15,7 @@ defmodule Pleroma.ReverseProxy do @valid_resp_codes [200, 206, 304] @max_read_duration :timer.seconds(30) @max_body_length :infinity + @failed_request_ttl :timer.seconds(60) @methods ~w(GET HEAD) @moduledoc """ @@ -48,6 +49,8 @@ defmodule Pleroma.ReverseProxy do * `max_read_duration` (default `#{inspect(@max_read_duration)}` ms): the total time the connection is allowed to read from the remote upstream. + * `failed_request_ttl` (default `#{inspect(@failed_request_ttl)}` ms): the time the failed request is cached and cannot be retried. + * `inline_content_types`: * `true` will not alter `content-disposition` (up to the upstream), * `false` will add `content-disposition: attachment` to any request, @@ -83,6 +86,7 @@ defmodule Pleroma.ReverseProxy do {:keep_user_agent, boolean} | {:max_read_duration, :timer.time() | :infinity} | {:max_body_length, non_neg_integer() | :infinity} + | {:failed_request_ttl, :timer.time() | :infinity} | {:http, []} | {:req_headers, [{String.t(), String.t()}]} | {:resp_headers, [{String.t(), String.t()}]} @@ -108,7 +112,8 @@ defmodule Pleroma.ReverseProxy do opts end - with {:ok, code, headers, client} <- request(method, url, req_headers, hackney_opts), + with {:ok, nil} <- Cachex.get(:failed_proxy_url_cache, url), + {:ok, code, headers, client} <- request(method, url, req_headers, hackney_opts), :ok <- header_length_constraint( headers, @@ -116,12 +121,18 @@ defmodule Pleroma.ReverseProxy do ) do response(conn, client, url, code, headers, opts) else + {:ok, true} -> + conn + |> error_or_redirect(url, 500, "Request failed", opts) + |> halt() + {:ok, code, headers} -> head_response(conn, url, code, headers, opts) |> halt() {:error, {:invalid_http_response, code}} -> Logger.error("#{__MODULE__}: request to #{inspect(url)} failed with HTTP status #{code}") + track_failed_url(url, code, opts) conn |> error_or_redirect( @@ -134,6 +145,7 @@ defmodule Pleroma.ReverseProxy do {:error, error} -> Logger.error("#{__MODULE__}: request to #{inspect(url)} failed: #{inspect(error)}") + track_failed_url(url, error, opts) conn |> error_or_redirect(url, 500, "Request failed", opts) @@ -388,4 +400,17 @@ defmodule Pleroma.ReverseProxy do end defp client, do: Pleroma.ReverseProxy.Client + + defp track_failed_url(url, code, opts) do + code = to_string(code) + + ttl = + if code in ["403", "404"] or String.starts_with?(code, "5") do + Keyword.get(opts, :failed_request_ttl, @failed_request_ttl) + else + nil + end + + Cachex.put(:failed_proxy_url_cache, url, true, ttl: ttl) + end end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 95f994c17..c52efb578 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -346,7 +346,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do local \\ true, public \\ true ) do - with true <- is_public?(object), + with true <- is_announceable?(object, user, public), announce_data <- make_announce_data(user, object, activity_id, public), {:ok, activity} <- insert(announce_data, local), {:ok, object} <- add_announce_to_object(activity, object), diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 3ca2e8773..64c470fc8 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -830,6 +830,27 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do {:ok, data} end + def prepare_outgoing(%{"type" => "Announce", "actor" => ap_id, "object" => object_id} = data) do + object = + object_id + |> Object.normalize() + + data = + if Visibility.is_private?(object) && object.data["actor"] == ap_id do + data |> Map.put("object", object |> Map.get(:data) |> prepare_object) + else + data |> maybe_fix_object_url + end + + data = + data + |> strip_internal_fields + |> Map.merge(Utils.make_json_ld_header()) + |> Map.delete("bcc") + + {:ok, data} + end + # Mastodon Accept/Reject requires a non-normalized object containing the actor URIs, # because of course it does. def prepare_outgoing(%{"type" => "Accept"} = data) do diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index 2ba182f4e..0828591ee 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -494,7 +494,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do @spec add_announce_to_object(Activity.t(), Object.t()) :: {:ok, Object.t()} | {:error, Ecto.Changeset.t()} def add_announce_to_object( - %Activity{data: %{"actor" => actor, "cc" => [Pleroma.Constants.as_public()]}}, + %Activity{data: %{"actor" => actor}}, object ) do announcements = take_announcements(object) diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index c94c5a225..6bc55c85b 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -22,7 +22,7 @@ defmodule Pleroma.Web.ActivityPub.UserView do def render("endpoints.json", %{user: %User{local: true} = _user}) do %{ "oauthAuthorizationEndpoint" => Helpers.o_auth_url(Endpoint, :authorize), - "oauthRegistrationEndpoint" => Helpers.mastodon_api_url(Endpoint, :create_app), + "oauthRegistrationEndpoint" => Helpers.app_url(Endpoint, :create), "oauthTokenEndpoint" => Helpers.o_auth_url(Endpoint, :token_exchange), "sharedInbox" => Helpers.activity_pub_url(Endpoint, :inbox), "uploadMedia" => Helpers.activity_pub_url(Endpoint, :upload_media) diff --git a/lib/pleroma/web/activity_pub/visibility.ex b/lib/pleroma/web/activity_pub/visibility.ex index dfb166b65..270d0fa02 100644 --- a/lib/pleroma/web/activity_pub/visibility.ex +++ b/lib/pleroma/web/activity_pub/visibility.ex @@ -27,6 +27,11 @@ defmodule Pleroma.Web.ActivityPub.Visibility do end end + def is_announceable?(activity, user, public \\ true) do + is_public?(activity) || + (!public && is_private?(activity) && activity.data["actor"] == user.ap_id) + end + def is_direct?(%Activity{data: %{"directMessage" => true}}), do: true def is_direct?(%Object{data: %{"directMessage" => true}}), do: true diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 2ec017ff8..ce73b3270 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -76,11 +76,12 @@ defmodule Pleroma.Web.CommonAPI do end end - def repeat(id_or_ap_id, user) do + def repeat(id_or_ap_id, user, params \\ %{}) do with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id), object <- Object.normalize(activity), - nil <- Utils.get_existing_announce(user.ap_id, object) do - ActivityPub.announce(user, object) + nil <- Utils.get_existing_announce(user.ap_id, object), + public <- public_announce?(object, params) do + ActivityPub.announce(user, object, nil, true, public) else _ -> {:error, dgettext("errors", "Could not repeat")} end @@ -169,6 +170,14 @@ defmodule Pleroma.Web.CommonAPI do end end + def public_announce?(_, %{"visibility" => visibility}) + when visibility in ~w{public unlisted private direct}, + do: visibility in ~w(public unlisted) + + def public_announce?(object, _) do + Visibility.is_public?(object) + end + def get_visibility(_, _, %Participation{}), do: {"direct", "direct"} def get_visibility(%{"visibility" => visibility}, in_reply_to, _) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 83b884ba9..9a4e322c9 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -75,4 +75,16 @@ defmodule Pleroma.Web.ControllerHelper do nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt() end end + + def 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 + + def try_render(conn, _, _) do + render_error(conn, :not_implemented, "Can't display this activity") + end end diff --git a/lib/pleroma/web/masto_fe_controller.ex b/lib/pleroma/web/masto_fe_controller.ex new file mode 100644 index 000000000..ac9af7502 --- /dev/null +++ b/lib/pleroma/web/masto_fe_controller.ex @@ -0,0 +1,36 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastoFEController do + use Pleroma.Web, :controller + + alias Pleroma.User + + @doc "GET /web/*path" + def index(%{assigns: %{user: user}} = conn, _params) do + token = get_session(conn, :oauth_token) + + if user && token do + conn + |> put_layout(false) + |> render("index.html", token: token, user: user, custom_emojis: Pleroma.Emoji.get_all()) + else + conn + |> put_session(:return_to, conn.request_path) + |> redirect(to: "/web/login") + end + end + + @doc "PUT /api/web/settings" + def put_settings(%{assigns: %{user: user}} = conn, %{"data" => settings} = _params) do + with {:ok, _} <- User.update_info(user, &User.Info.mastodon_settings_update(&1, settings)) do + json(conn, %{}) + else + e -> + conn + |> put_status(:internal_server_error) + |> json(%{error: inspect(e)}) + end + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex new file mode 100644 index 000000000..abbe16a88 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex @@ -0,0 +1,39 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.AppController do + use Pleroma.Web, :controller + + alias Pleroma.Repo + alias Pleroma.Web.OAuth.App + alias Pleroma.Web.OAuth.Scopes + alias Pleroma.Web.OAuth.Token + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + @local_mastodon_name "Mastodon-Local" + + @doc "POST /api/v1/apps" + def create(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 + render(conn, "show.json", app: app) + end + end + + @doc "GET /api/v1/apps/verify_credentials" + def verify_credentials(%{assigns: %{user: _user, token: token}} = conn, _) do + with %Token{app: %App{} = app} <- Repo.preload(token, :app) do + render(conn, "short.json", app: app) + end + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex new file mode 100644 index 000000000..bfd5120ba --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex @@ -0,0 +1,91 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.AuthController do + use Pleroma.Web, :controller + + alias Pleroma.User + alias Pleroma.Web.OAuth.App + alias Pleroma.Web.OAuth.Authorization + alias Pleroma.Web.OAuth.Token + alias Pleroma.Web.TwitterAPI.TwitterAPI + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + @local_mastodon_name "Mastodon-Local" + + plug(Pleroma.Plugs.RateLimiter, :password_reset when action == :password_reset) + + @doc "GET /web/login" + 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 + + @doc "DELETE /auth/sign_out" + def logout(conn, _) do + conn + |> clear_session + |> redirect(to: "/") + end + + @doc "POST /auth/password" + 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 + + defp local_mastodon_root_path(conn) do + case get_session(conn, :return_to) do + nil -> + masto_fe_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 + %{client_name: @local_mastodon_name, redirect_uris: "."} + |> App.get_or_make(["read", "write", "follow", "push"]) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex b/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex new file mode 100644 index 000000000..391c0648b --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex @@ -0,0 +1,11 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.CustomEmojiController do + use Pleroma.Web, :controller + + def index(conn, _params) do + render(conn, "index.json", custom_emojis: Pleroma.Emoji.get_all()) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex new file mode 100644 index 000000000..a55f60fec --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex @@ -0,0 +1,17 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.InstanceController do + use Pleroma.Web, :controller + + @doc "GET /api/v1/instance" + def show(conn, _params) do + render(conn, "show.json") + end + + @doc "GET /api/v1/instance/peers" + def peers(conn, _params) do + json(conn, Pleroma.Stats.get_peers()) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex index 1484a0174..e92f5d089 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -7,199 +7,18 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] - alias Pleroma.Activity alias Pleroma.Bookmark - alias Pleroma.Config - alias Pleroma.HTTP - alias Pleroma.Object alias Pleroma.Pagination - alias Pleroma.Plugs.RateLimiter - alias Pleroma.Repo - 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.MastodonView 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.TwitterAPI.TwitterAPI require Logger - plug(RateLimiter, :password_reset when action == :password_reset) - - @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 - - 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 - - @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, %Pleroma.Emoji{file: relative_url, tags: 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 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 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 follows(%{assigns: %{user: follower}} = conn, %{"uri" => uri}) do with {_, %User{} = followed} <- {:followed, User.get_cached_by_nickname(uri)}, {_, true} <- {:followed, follower.id != followed.id}, @@ -266,176 +85,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do |> render("index.json", %{activities: activities, for: user, as: :activity}) 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("show.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 - with {:ok, _} <- User.update_info(user, &User.Info.mastodon_settings_update(&1, settings)) 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 @@ -447,83 +96,4 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do Logger.debug("Unimplemented, returning an empty object") 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 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 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 - - def 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 diff --git a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex new file mode 100644 index 000000000..57a5b60fb --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.MediaController do + use Pleroma.Web, :controller + + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + plug(:put_view, Pleroma.Web.MastodonAPI.StatusView) + + @doc "POST /api/v1/media" + def create(%{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) + + render(conn, "attachment.json", %{attachment: attachment_data}) + end + end + + @doc "PUT /api/v1/media/:id" + def update(%{assigns: %{user: user}} = conn, %{"id" => id, "description" => description}) + when is_binary(description) do + with %Object{} = object <- Object.get_by_id(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) + + render(conn, "attachment.json", %{attachment: attachment_data}) + end + end + + def update(_conn, _data), do: {:error, :bad_request} +end diff --git a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex new file mode 100644 index 000000000..fbf7f8673 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex @@ -0,0 +1,53 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.PollController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [try_render: 3, json_response: 3] + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.CommonAPI + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + @doc "GET /api/v1/polls/:id" + def show(%{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 + try_render(conn, "show.json", %{object: object, for: user}) + else + error when is_nil(error) or error == false -> + render_error(conn, :not_found, "Record not found") + end + end + + @doc "POST /api/v1/polls/:id/votes" + def vote(%{assigns: %{user: user}} = conn, %{"id" => id, "choices" => choices}) do + with %Object{data: %{"type" => "Question"}} = object <- Object.get_by_id(id), + %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 + try_render(conn, "show.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} -> json_response(conn, :unprocessable_entity, %{error: message}) + end + end + + defp get_cached_vote_or_vote(user, object, choices) do + idempotency_key = "polls:#{user.id}:#{object.data["id"]}" + + Cachex.fetch!(:idempotency_cache, idempotency_key, fn -> + case CommonAPI.vote(user, object, choices) do + {:error, _message} = res -> {:ignore, res} + res -> {:commit, res} + end + end) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 3c6987a5f..79cced163 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do use Pleroma.Web, :controller - import Pleroma.Web.MastodonAPI.MastodonAPIController, only: [try_render: 3] + import Pleroma.Web.ControllerHelper, only: [try_render: 3] require Ecto.Query @@ -125,8 +125,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do end @doc "POST /api/v1/statuses/:id/reblog" - def reblog(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do - with {:ok, announce, _activity} <- CommonAPI.repeat(ap_id_or_id, user), + def reblog(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id} = params) do + with {:ok, announce, _activity} <- CommonAPI.repeat(ap_id_or_id, user, params), %Activity{} = announce <- Activity.normalize(announce.data) do try_render(conn, "show.json", %{activity: announce, for: user, as: :activity}) end @@ -242,7 +242,19 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do 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 + %Object{data: %{"announcements" => announces, "id" => ap_id}} <- + Object.normalize(activity) do + announces = + "Announce" + |> Activity.Queries.by_type() + |> Ecto.Query.where([a], a.actor in ^announces) + # this is to use the index + |> Activity.Queries.by_object_id(ap_id) + |> Repo.all() + |> Enum.filter(&Visibility.visible_for_user?(&1, user)) + |> Enum.map(& &1.actor) + |> Enum.uniq() + users = User |> Ecto.Query.where([u], u.ap_id in ^announces) diff --git a/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex b/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex new file mode 100644 index 000000000..9076bb849 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex @@ -0,0 +1,63 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.SuggestionController do + use Pleroma.Web, :controller + + require Logger + + alias Pleroma.Config + alias Pleroma.User + alias Pleroma.Web.MediaProxy + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + @doc "GET /api/v1/suggestions" + def index(%{assigns: %{user: user}} = conn, _) do + if Config.get([:suggestions, :enabled], false) do + with {:ok, data} <- fetch_suggestions(user) do + limit = Config.get([:suggestions, :limit], 23) + + 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) + end + else + json(conn, []) + end + end + + defp fetch_suggestions(user) do + api = Config.get([:suggestions, :third_party_engine], "") + timeout = Config.get([:suggestions, :timeout], 5000) + host = Config.get([Pleroma.Web.Endpoint, :url, :host]) + + url = + api + |> String.replace("{{host}}", host) + |> String.replace("{{user}}", user.nickname) + + with {:ok, %{status: 200, body: body}} <- + Pleroma.HTTP.get(url, [], adapter: [recv_timeout: timeout, pool: :default]) do + Jason.decode(body) + else + e -> Logger.error("Could not retrieve suggestions at fetch #{url}, #{inspect(e)}") + end + end + + defp fetch_suggestion_id(attrs) do + case User.get_or_fetch(attrs["acct"]) do + {:ok, %User{id: id}} -> id + _ -> 0 + end + end +end diff --git a/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex b/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex new file mode 100644 index 000000000..cb8688941 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex @@ -0,0 +1,28 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.CustomEmojiView do + use Pleroma.Web, :view + + alias Pleroma.Emoji + alias Pleroma.Web + + def render("index.json", %{custom_emojis: custom_emojis}) do + render_many(custom_emojis, __MODULE__, "show.json") + end + + def render("show.json", %{custom_emoji: {shortcode, %Emoji{file: relative_url, tags: tags}}}) do + url = Web.base_url() |> URI.merge(relative_url) |> to_string() + + %{ + "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 |> List.delete("Custom") |> Enum.join(",") + } + end +end diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex new file mode 100644 index 000000000..c4866e510 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -0,0 +1,35 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.InstanceView do + use Pleroma.Web, :view + + @mastodon_api_level "2.7.2" + + def render("show.json", _) do + instance = Pleroma.Config.get(:instance) + + %{ + uri: Pleroma.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: Pleroma.Stats.get_stats(), + thumbnail: Pleroma.Web.base_url() <> "/instance/thumbnail.jpeg", + languages: ["en"], + registrations: Keyword.get(instance, :registrations_open), + # Extra (not present in Mastodon): + max_toot_chars: Keyword.get(instance, :limit), + poll_limits: Keyword.get(instance, :poll_limits), + upload_limit: Keyword.get(instance, :upload_limit), + avatar_upload_limit: Keyword.get(instance, :avatar_upload_limit), + background_upload_limit: Keyword.get(instance, :background_upload_limit), + banner_upload_limit: Keyword.get(instance, :banner_upload_limit) + } + end +end diff --git a/lib/pleroma/web/mastodon_api/views/mastodon_view.ex b/lib/pleroma/web/mastodon_api/views/mastodon_view.ex deleted file mode 100644 index 33b9a74be..000000000 --- a/lib/pleroma/web/mastodon_api/views/mastodon_view.ex +++ /dev/null @@ -1,8 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.MastodonAPI.MastodonView do - use Pleroma.Web, :view - import Phoenix.HTML -end diff --git a/lib/pleroma/web/mastodon_api/views/poll_view.ex b/lib/pleroma/web/mastodon_api/views/poll_view.ex new file mode 100644 index 000000000..753039da3 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/poll_view.ex @@ -0,0 +1,74 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.PollView do + use Pleroma.Web, :view + + alias Pleroma.HTML + alias Pleroma.Web.CommonAPI.Utils + + def render("show.json", %{object: object, multiple: multiple, options: options} = params) do + {end_time, expired} = end_time_and_expired(object) + {options, votes_count} = options_and_votes_count(options) + + %{ + # Mastodon uses separate ids for polls, but an object can't have + # more than one poll embedded so object id is fine + id: to_string(object.id), + expires_at: end_time, + expired: expired, + multiple: multiple, + votes_count: votes_count, + options: options, + voted: voted?(params), + emojis: Pleroma.Web.MastodonAPI.StatusView.build_emojis(object.data["emoji"]) + } + end + + def render("show.json", %{object: object} = params) do + case object.data do + %{"anyOf" => options} when is_list(options) -> + render(__MODULE__, "show.json", Map.merge(params, %{multiple: true, options: options})) + + %{"oneOf" => options} when is_list(options) -> + render(__MODULE__, "show.json", Map.merge(params, %{multiple: false, options: options})) + + _ -> + nil + end + end + + defp end_time_and_expired(object) do + case object.data["closed"] || object.data["endTime"] do + end_time when is_binary(end_time) -> + end_time = NaiveDateTime.from_iso8601!(end_time) + expired = NaiveDateTime.compare(end_time, NaiveDateTime.utc_now()) == :lt + + {Utils.to_masto_date(end_time), expired} + + _ -> + {nil, false} + end + end + + defp options_and_votes_count(options) do + Enum.map_reduce(options, 0, fn %{"name" => name} = option, count -> + current_count = option["replies"]["totalItems"] || 0 + + {%{ + title: HTML.strip_tags(name), + votes_count: current_count + }, current_count + count} + end) + end + + defp voted?(%{object: object} = opts) do + if opts[:for] do + existing_votes = Pleroma.Web.ActivityPub.Utils.get_existing_votes(opts[:for].ap_id, object) + existing_votes != [] or opts[:for].ap_id == object.data["actor"] + else + false + end + end +end diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index bc527ad1b..9b8dd3086 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -18,6 +18,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.MastodonAPI.PollView alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MediaProxy @@ -124,7 +125,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do pinned: pinned?(activity, user), sensitive: false, spoiler_text: "", - visibility: "public", + visibility: get_visibility(activity), media_attachments: reblogged[:media_attachments] || [], mentions: mentions, tags: reblogged[:tags] || [], @@ -277,7 +278,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do spoiler_text: summary_html, visibility: get_visibility(object), media_attachments: attachments, - poll: render("poll.json", %{object: object, for: opts[:for]}), + poll: render(PollView, "show.json", object: object, for: opts[:for]), mentions: mentions, tags: build_tags(tags), application: %{ @@ -389,75 +390,6 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do safe_render_many(opts.activities, StatusView, "listen.json", opts) end - def render("poll.json", %{object: object} = opts) do - {multiple, options} = - case object.data do - %{"anyOf" => options} when is_list(options) -> {true, options} - %{"oneOf" => options} when is_list(options) -> {false, options} - _ -> {nil, nil} - end - - if options do - {end_time, expired} = - case object.data["closed"] || object.data["endTime"] do - end_time when is_binary(end_time) -> - end_time = - (object.data["closed"] || object.data["endTime"]) - |> NaiveDateTime.from_iso8601!() - - expired = - end_time - |> NaiveDateTime.compare(NaiveDateTime.utc_now()) - |> case do - :lt -> true - _ -> false - end - - end_time = Utils.to_masto_date(end_time) - - {end_time, expired} - - _ -> - {nil, false} - end - - voted = - if opts[:for] do - existing_votes = - Pleroma.Web.ActivityPub.Utils.get_existing_votes(opts[:for].ap_id, object) - - existing_votes != [] or opts[:for].ap_id == object.data["actor"] - else - false - end - - {options, votes_count} = - Enum.map_reduce(options, 0, fn %{"name" => name} = option, count -> - current_count = option["replies"]["totalItems"] || 0 - - {%{ - title: HTML.strip_tags(name), - votes_count: current_count - }, current_count + count} - end) - - %{ - # Mastodon uses separate ids for polls, but an object can't have - # more than one poll embedded so object id is fine - id: to_string(object.id), - expires_at: end_time, - expired: expired, - multiple: multiple, - votes_count: votes_count, - options: options, - voted: voted, - emojis: build_emojis(object.data["emoji"]) - } - else - nil - end - end - def render("context.json", %{activity: activity, activities: activities, user: user}) do %{ancestors: ancestors, descendants: descendants} = activities diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index a57670e02..1cd7294e7 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -212,13 +212,31 @@ defmodule Pleroma.Web.OAuth.OAuthController do {:auth_active, false} -> # Per https://github.com/tootsuite/mastodon/blob/ # 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L76 - render_error(conn, :forbidden, "Your login is missing a confirmed e-mail address") + render_error( + conn, + :forbidden, + "Your login is missing a confirmed e-mail address", + %{}, + "missing_confirmed_email" + ) {:user_active, false} -> - render_error(conn, :forbidden, "Your account is currently disabled") + render_error( + conn, + :forbidden, + "Your account is currently disabled", + %{}, + "account_is_disabled" + ) {:password_reset_pending, true} -> - render_error(conn, :forbidden, "Password reset is required") + render_error( + conn, + :forbidden, + "Password reset is required", + %{}, + "password_reset_required" + ) _error -> render_invalid_credentials_error(conn) @@ -442,7 +460,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do end # Special case: Local MastodonFE - defp redirect_uri(%Plug.Conn{} = conn, "."), do: mastodon_api_url(conn, :login) + defp redirect_uri(%Plug.Conn{} = conn, "."), do: auth_url(conn, :login) defp redirect_uri(%Plug.Conn{}, redirect_uri), do: redirect_uri diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index eab55a27c..f91af8137 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -373,7 +373,7 @@ defmodule Pleroma.Web.Router do get("/filters", FilterController, :index) - get("/suggestions", MastodonAPIController, :suggestions) + get("/suggestions", SuggestionController, :index) get("/conversations", ConversationController, :index) post("/conversations/:id/read", ConversationController, :read) @@ -403,10 +403,10 @@ defmodule Pleroma.Web.Router do put("/scheduled_statuses/:id", ScheduledActivityController, :update) delete("/scheduled_statuses/:id", ScheduledActivityController, :delete) - post("/polls/:id/votes", MastodonAPIController, :poll_vote) + post("/polls/:id/votes", PollController, :vote) - post("/media", MastodonAPIController, :upload) - put("/media/:id", MastodonAPIController, :update_media) + post("/media", MediaController, :create) + put("/media/:id", MediaController, :update) delete("/lists/:id", ListController, :delete) post("/lists", ListController, :create) @@ -451,31 +451,31 @@ defmodule Pleroma.Web.Router do end end - scope "/api/web", Pleroma.Web.MastodonAPI do + scope "/api/web", Pleroma.Web do pipe_through([:authenticated_api, :oauth_write]) - put("/settings", MastodonAPIController, :put_settings) + put("/settings", MastoFEController, :put_settings) end scope "/api/v1", Pleroma.Web.MastodonAPI do pipe_through(:api) post("/accounts", AccountController, :create) + get("/accounts/search", SearchController, :account_search) - get("/instance", MastodonAPIController, :masto_instance) - get("/instance/peers", MastodonAPIController, :peers) - post("/apps", MastodonAPIController, :create_app) - get("/apps/verify_credentials", MastodonAPIController, :verify_app_credentials) - get("/custom_emojis", MastodonAPIController, :custom_emojis) + get("/instance", InstanceController, :show) + get("/instance/peers", InstanceController, :peers) - get("/statuses/:id/card", StatusController, :card) + post("/apps", AppController, :create) + get("/apps/verify_credentials", AppController, :verify_credentials) + get("/statuses/:id/card", StatusController, :card) get("/statuses/:id/favourited_by", StatusController, :favourited_by) get("/statuses/:id/reblogged_by", StatusController, :reblogged_by) - get("/trends", MastodonAPIController, :empty_array) + get("/custom_emojis", CustomEmojiController, :index) - get("/accounts/search", SearchController, :account_search) + get("/trends", MastodonAPIController, :empty_array) scope [] do pipe_through(:oauth_read_or_public) @@ -488,7 +488,7 @@ defmodule Pleroma.Web.Router do get("/statuses/:id", StatusController, :show) get("/statuses/:id/context", StatusController, :context) - get("/polls/:id", MastodonAPIController, :get_poll) + get("/polls/:id", PollController, :show) get("/accounts/:id/statuses", AccountController, :statuses) get("/accounts/:id/followers", AccountController, :followers) @@ -658,17 +658,17 @@ defmodule Pleroma.Web.Router do get("/:version", Nodeinfo.NodeinfoController, :nodeinfo) end - scope "/", Pleroma.Web.MastodonAPI do + scope "/", Pleroma.Web do pipe_through(:mastodon_html) - get("/web/login", MastodonAPIController, :login) - delete("/auth/sign_out", MastodonAPIController, :logout) + get("/web/login", MastodonAPI.AuthController, :login) + delete("/auth/sign_out", MastodonAPI.AuthController, :logout) - post("/auth/password", MastodonAPIController, :password_reset) + post("/auth/password", MastodonAPI.AuthController, :password_reset) scope [] do pipe_through(:oauth_read) - get("/web/*path", MastodonAPIController, :index) + get("/web/*path", MastoFEController, :index) end end diff --git a/lib/pleroma/web/templates/mastodon_api/mastodon/index.html.eex b/lib/pleroma/web/templates/masto_fe/index.html.eex index 3325beca1..feff36fae 100644 --- a/lib/pleroma/web/templates/mastodon_api/mastodon/index.html.eex +++ b/lib/pleroma/web/templates/masto_fe/index.html.eex @@ -14,7 +14,7 @@ <link rel='preload' as='script' crossorigin='anonymous' href='/packs/features/compose.js'> <link rel='preload' as='script' crossorigin='anonymous' href='/packs/features/home_timeline.js'> <link rel='preload' as='script' crossorigin='anonymous' href='/packs/features/notifications.js'> -<script id='initial-state' type='application/json'><%= raw @initial_state %></script> +<script id='initial-state' type='application/json'><%= initial_state(@token, @user, @custom_emojis) %></script> <script src="/packs/core/common.js"></script> <link rel="stylesheet" media="all" href="/packs/core/common.css" /> diff --git a/lib/pleroma/web/translation_helpers.ex b/lib/pleroma/web/translation_helpers.ex index 8f5a43bf6..a104ea6b8 100644 --- a/lib/pleroma/web/translation_helpers.ex +++ b/lib/pleroma/web/translation_helpers.ex @@ -3,15 +3,27 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.TranslationHelpers do - defmacro render_error(conn, status, msgid, bindings \\ Macro.escape(%{})) do + defmacro render_error( + conn, + status, + msgid, + bindings \\ Macro.escape(%{}), + identifier \\ Macro.escape("") + ) do quote do require Pleroma.Web.Gettext + error_map = + %{ + error: Pleroma.Web.Gettext.dgettext("errors", unquote(msgid), unquote(bindings)), + identifier: unquote(identifier) + } + |> Enum.reject(fn {_k, v} -> v == "" end) + |> Map.new() + unquote(conn) |> Plug.Conn.put_status(unquote(status)) - |> Phoenix.Controller.json(%{ - error: Pleroma.Web.Gettext.dgettext("errors", unquote(msgid), unquote(bindings)) - }) + |> Phoenix.Controller.json(error_map) end end end diff --git a/lib/pleroma/web/views/masto_fe_view.ex b/lib/pleroma/web/views/masto_fe_view.ex new file mode 100644 index 000000000..21b086d4c --- /dev/null +++ b/lib/pleroma/web/views/masto_fe_view.ex @@ -0,0 +1,102 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastoFEView do + use Pleroma.Web, :view + alias Pleroma.Config + alias Pleroma.User + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.MastodonAPI.CustomEmojiView + + @default_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 + } + } + } + + def initial_state(token, user, custom_emojis) do + limit = Config.get([:instance, :limit]) + + %{ + 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 || @default_settings, + push_subscription: nil, + accounts: %{user.id => render(AccountView, "show.json", user: user, for: user)}, + custom_emojis: render(CustomEmojiView, "index.json", custom_emojis: custom_emojis), + char_limit: limit + } + |> Jason.encode!() + |> Phoenix.HTML.raw() + end + + defp present?(nil), do: false + defp present?(false), do: false + defp present?(_), do: true +end |