diff options
author | lain <lain@soykaf.club> | 2019-10-10 14:40:59 +0200 |
---|---|---|
committer | lain <lain@soykaf.club> | 2019-10-10 14:40:59 +0200 |
commit | c54ae662dcc08c0c04a1dff7bb7a361665e877b8 (patch) | |
tree | afb9cce0d7bcc15cd2fe3221609d7883d45d0057 /lib | |
parent | 02f8e2a8ab65c3e8497bab4576ce4e75f8df3217 (diff) | |
parent | 6355694309b0bad3687a8a7820b81ebf6625751d (diff) | |
download | pleroma-c54ae662dcc08c0c04a1dff7bb7a361665e877b8.tar.gz |
Merge remote-tracking branch 'origin/develop' into benchmark-finishing
Diffstat (limited to 'lib')
182 files changed, 7957 insertions, 7159 deletions
diff --git a/lib/mix/pleroma.ex b/lib/mix/pleroma.ex index 1b758ea33..faeb30e1d 100644 --- a/lib/mix/pleroma.ex +++ b/lib/mix/pleroma.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Mix.Pleroma do diff --git a/lib/mix/tasks/pleroma/benchmark.ex b/lib/mix/tasks/pleroma/benchmark.ex index 4cc634727..84dccf7f3 100644 --- a/lib/mix/tasks/pleroma/benchmark.ex +++ b/lib/mix/tasks/pleroma/benchmark.ex @@ -27,7 +27,7 @@ defmodule Mix.Tasks.Pleroma.Benchmark do }) end - def run(["render_timeline", nickname]) do + def run(["render_timeline", nickname | _] = args) do start_pleroma() user = Pleroma.User.get_by_nickname(nickname) @@ -37,33 +37,37 @@ defmodule Mix.Tasks.Pleroma.Benchmark do |> Map.put("blocking_user", user) |> Map.put("muting_user", user) |> Map.put("user", user) - |> Map.put("limit", 80) + |> Map.put("limit", 4096) |> Pleroma.Web.ActivityPub.ActivityPub.fetch_public_activities() |> Enum.reverse() inputs = %{ - "One activity" => Enum.take_random(activities, 1), - "Ten activities" => Enum.take_random(activities, 10), - "Twenty activities" => Enum.take_random(activities, 20), - "Forty activities" => Enum.take_random(activities, 40), - "Eighty activities" => Enum.take_random(activities, 80) + "1 activity" => Enum.take_random(activities, 1), + "10 activities" => Enum.take_random(activities, 10), + "20 activities" => Enum.take_random(activities, 20), + "40 activities" => Enum.take_random(activities, 40), + "80 activities" => Enum.take_random(activities, 80) } + inputs = + if Enum.at(args, 2) == "extended" do + Map.merge(inputs, %{ + "200 activities" => Enum.take_random(activities, 200), + "500 activities" => Enum.take_random(activities, 500), + "2000 activities" => Enum.take_random(activities, 2000), + "4096 activities" => Enum.take_random(activities, 4096) + }) + else + inputs + end + Benchee.run( %{ - "Parallel rendering" => fn activities -> - Pleroma.Web.MastodonAPI.StatusView.render("index.json", %{ - activities: activities, - for: user, - as: :activity - }) - end, "Standart rendering" => fn activities -> Pleroma.Web.MastodonAPI.StatusView.render("index.json", %{ activities: activities, for: user, - as: :activity, - parallel: false + as: :activity }) end }, 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 bcc2052d6..cfd9eeada 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Mix.Tasks.Pleroma.Database do @@ -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/docs.ex b/lib/mix/tasks/pleroma/docs.ex new file mode 100644 index 000000000..0d2663648 --- /dev/null +++ b/lib/mix/tasks/pleroma/docs.ex @@ -0,0 +1,42 @@ +defmodule Mix.Tasks.Pleroma.Docs do + use Mix.Task + import Mix.Pleroma + + @shortdoc "Generates docs from descriptions.exs" + @moduledoc """ + Generates docs from `descriptions.exs`. + + Supports two formats: `markdown` and `json`. + + ## Generate Markdown docs + + `mix pleroma.docs` + + ## Generate JSON docs + + `mix pleroma.docs json` + """ + + def run(["json"]) do + do_run(Pleroma.Docs.JSON) + end + + def run(_) do + do_run(Pleroma.Docs.Markdown) + end + + defp do_run(implementation) do + start_pleroma() + + with {descriptions, _paths} <- Mix.Config.eval!("config/description.exs"), + {:ok, file_path} <- + Pleroma.Docs.Generator.process( + implementation, + descriptions[:pleroma][:config_description] + ) do + type = if implementation == Pleroma.Docs.Markdown, do: "Markdown", else: "JSON" + + Mix.shell().info([:green, "#{type} docs successfully generated to #{file_path}."]) + end + end +end diff --git a/lib/mix/tasks/pleroma/ecto/ecto.ex b/lib/mix/tasks/pleroma/ecto/ecto.ex index b66f63376..36808b93f 100644 --- a/lib/mix/tasks/pleroma/ecto/ecto.ex +++ b/lib/mix/tasks/pleroma/ecto/ecto.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-onl defmodule Mix.Tasks.Pleroma.Ecto do diff --git a/lib/mix/tasks/pleroma/ecto/migrate.ex b/lib/mix/tasks/pleroma/ecto/migrate.ex index 855c977f6..d87b6957d 100644 --- a/lib/mix/tasks/pleroma/ecto/migrate.ex +++ b/lib/mix/tasks/pleroma/ecto/migrate.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-onl defmodule Mix.Tasks.Pleroma.Ecto.Migrate do diff --git a/lib/mix/tasks/pleroma/ecto/rollback.ex b/lib/mix/tasks/pleroma/ecto/rollback.ex index 2ffb0901c..a1af73fa1 100644 --- a/lib/mix/tasks/pleroma/ecto/rollback.ex +++ b/lib/mix/tasks/pleroma/ecto/rollback.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-onl defmodule Mix.Tasks.Pleroma.Ecto.Rollback do diff --git a/lib/mix/tasks/pleroma/emoji.ex b/lib/mix/tasks/pleroma/emoji.ex index c2225af7d..6ef0a635d 100644 --- a/lib/mix/tasks/pleroma/emoji.ex +++ b/lib/mix/tasks/pleroma/emoji.ex @@ -1,59 +1,12 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only 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) @@ -235,7 +188,7 @@ defmodule Mix.Tasks.Pleroma.Emoji do cwd: tmp_pack_dir ) - emoji_map = Pleroma.Emoji.make_shortcode_to_file_map(tmp_pack_dir, exts) + emoji_map = Pleroma.Emoji.Loader.make_shortcode_to_file_map(tmp_pack_dir, exts) File.write!(files_name, Jason.encode!(emoji_map, pretty: true)) diff --git a/lib/mix/tasks/pleroma/instance.ex b/lib/mix/tasks/pleroma/instance.ex index b9b1991c2..9af6cda30 100644 --- a/lib/mix/tasks/pleroma/instance.ex +++ b/lib/mix/tasks/pleroma/instance.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Mix.Tasks.Pleroma.Instance do @@ -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 a738fae75..d7a7b599f 100644 --- a/lib/mix/tasks/pleroma/relay.ex +++ b/lib/mix/tasks/pleroma/relay.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Mix.Tasks.Pleroma.Relay do @@ -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 be45383ee..3e6fc7ee0 100644 --- a/lib/mix/tasks/pleroma/uploads.ex +++ b/lib/mix/tasks/pleroma/uploads.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Mix.Tasks.Pleroma.Uploads do @@ -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 a3f8bc945..134b5bccc 100644 --- a/lib/mix/tasks/pleroma/user.ex +++ b/lib/mix/tasks/pleroma/user.ex @@ -1,96 +1,17 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Mix.Tasks.Pleroma.User do use Mix.Task - import Ecto.Changeset import Mix.Pleroma alias Pleroma.User alias Pleroma.UserInviteToken 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( @@ -228,9 +149,9 @@ defmodule Mix.Tasks.Pleroma.User do shell_info("Deactivating #{user.nickname}") User.deactivate(user) - {:ok, friends} = User.get_friends(user) - - Enum.each(friends, fn friend -> + user + |> User.get_friends() + |> Enum.each(fn friend -> user = User.get_cached_by_id(user.id) shell_info("Unsubscribing #{friend.nickname} from #{user.nickname}") @@ -405,7 +326,7 @@ defmodule Mix.Tasks.Pleroma.User do start_pleroma() with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do - {:ok, _} = User.delete_user_activities(user) + User.delete_user_activities(user) shell_info("User #{nickname} statuses deleted.") else _ -> @@ -443,39 +364,21 @@ defmodule Mix.Tasks.Pleroma.User do end defp set_moderator(user, value) do - info_cng = User.Info.admin_api_update(user.info, %{is_moderator: value}) - - user_cng = - Ecto.Changeset.change(user) - |> put_embed(:info, info_cng) - - {:ok, user} = User.update_and_set_cache(user_cng) + {:ok, user} = User.update_info(user, &User.Info.admin_api_update(&1, %{is_moderator: value})) shell_info("Moderator status of #{user.nickname}: #{user.info.is_moderator}") user end defp set_admin(user, value) do - info_cng = User.Info.admin_api_update(user.info, %{is_admin: value}) - - user_cng = - Ecto.Changeset.change(user) - |> put_embed(:info, info_cng) - - {:ok, user} = User.update_and_set_cache(user_cng) + {:ok, user} = User.update_info(user, &User.Info.admin_api_update(&1, %{is_admin: value})) shell_info("Admin status of #{user.nickname}: #{user.info.is_admin}") user end defp set_locked(user, value) do - info_cng = User.Info.user_upgrade(user.info, %{locked: value}) - - user_cng = - Ecto.Changeset.change(user) - |> put_embed(:info, info_cng) - - {:ok, user} = User.update_and_set_cache(user_cng) + {:ok, user} = User.update_info(user, &User.Info.user_upgrade(&1, %{locked: value})) shell_info("Locked status of #{user.nickname}: #{user.info.locked}") user diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 2d4e9da0c..c1065611b 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Activity do use Ecto.Schema alias Pleroma.Activity + alias Pleroma.Activity.Queries alias Pleroma.ActivityExpiration alias Pleroma.Bookmark alias Pleroma.Notification @@ -20,7 +21,7 @@ defmodule Pleroma.Activity do @type t :: %__MODULE__{} @type actor :: String.t() - @primary_key {:id, Pleroma.FlakeId, autogenerate: true} + @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} # https://github.com/tootsuite/mastodon/blob/master/app/models/notification.rb#L19 @mastodon_notification_types %{ @@ -65,8 +66,8 @@ defmodule Pleroma.Activity do timestamps() end - def with_joined_object(query) do - join(query, :inner, [activity], o in Object, + def with_joined_object(query, join_type \\ :inner) do + join(query, join_type, [activity], o in Object, on: fragment( "(?->>'id') = COALESCE(?->'object'->>'id', ?->>'object')", @@ -78,10 +79,10 @@ defmodule Pleroma.Activity do ) end - def with_preloaded_object(query) do + def with_preloaded_object(query, join_type \\ :inner) do query |> has_named_binding?(:object) - |> if(do: query, else: with_joined_object(query)) + |> if(do: query, else: with_joined_object(query, join_type)) |> preload([activity, object: object], object: object) end @@ -107,12 +108,9 @@ defmodule Pleroma.Activity do def with_set_thread_muted_field(query, _), do: query def get_by_ap_id(ap_id) do - Repo.one( - from( - activity in Activity, - where: fragment("(?)->>'id' = ?", activity.data, ^to_string(ap_id)) - ) - ) + ap_id + |> Queries.by_ap_id() + |> Repo.one() end def get_bookmark(%Activity{} = activity, %User{} = user) do @@ -133,91 +131,55 @@ defmodule Pleroma.Activity do end def get_by_ap_id_with_object(ap_id) do - Repo.one( - from( - activity in Activity, - where: fragment("(?)->>'id' = ?", activity.data, ^to_string(ap_id)), - left_join: o in Object, - on: - fragment( - "(?->>'id') = COALESCE(?->'object'->>'id', ?->>'object')", - o.data, - activity.data, - activity.data - ), - preload: [object: o] - ) - ) + ap_id + |> Queries.by_ap_id() + |> with_preloaded_object(:left) + |> Repo.one() end + @spec get_by_id(String.t()) :: Activity.t() | nil def get_by_id(id) do - Activity - |> where([a], a.id == ^id) - |> restrict_deactivated_users() - |> Repo.one() + case FlakeId.flake_id?(id) do + true -> + Activity + |> where([a], a.id == ^id) + |> restrict_deactivated_users() + |> Repo.one() + + _ -> + nil + end end def get_by_id_with_object(id) do - from(activity in Activity, - where: activity.id == ^id, - inner_join: o in Object, - on: - fragment( - "(?->>'id') = COALESCE(?->'object'->>'id', ?->>'object')", - o.data, - activity.data, - activity.data - ), - preload: [object: o] - ) + Activity + |> where(id: ^id) + |> with_preloaded_object() |> Repo.one() end - def by_object_ap_id(ap_id) do - from( - activity in Activity, - where: - fragment( - "coalesce((?)->'object'->>'id', (?)->>'object') = ?", - activity.data, - activity.data, - ^to_string(ap_id) - ) - ) - end - - def create_by_object_ap_id(ap_ids) when is_list(ap_ids) do - from( - activity in Activity, - where: - fragment( - "coalesce((?)->'object'->>'id', (?)->>'object') = ANY(?)", - activity.data, - activity.data, - ^ap_ids - ), - where: fragment("(?)->>'type' = 'Create'", activity.data) - ) + def all_by_ids_with_object(ids) do + Activity + |> where([a], a.id in ^ids) + |> with_preloaded_object() + |> Repo.all() end - def create_by_object_ap_id(ap_id) when is_binary(ap_id) do - from( - activity in Activity, - where: - fragment( - "coalesce((?)->'object'->>'id', (?)->>'object') = ?", - activity.data, - activity.data, - ^to_string(ap_id) - ), - where: fragment("(?)->>'type' = 'Create'", activity.data) - ) + @doc """ + Accepts `ap_id` or list of `ap_id`. + Returns a query. + """ + @spec create_by_object_ap_id(String.t() | [String.t()]) :: Ecto.Queryable.t() + def create_by_object_ap_id(ap_id) do + ap_id + |> Queries.by_object_id() + |> Queries.by_type("Create") end - def create_by_object_ap_id(_), do: nil - def get_all_create_by_object_ap_id(ap_id) do - Repo.all(create_by_object_ap_id(ap_id)) + ap_id + |> create_by_object_ap_id() + |> Repo.all() end def get_create_by_object_ap_id(ap_id) when is_binary(ap_id) do @@ -228,54 +190,17 @@ defmodule Pleroma.Activity do def get_create_by_object_ap_id(_), do: nil - def create_by_object_ap_id_with_object(ap_ids) when is_list(ap_ids) do - from( - activity in Activity, - where: - fragment( - "coalesce((?)->'object'->>'id', (?)->>'object') = ANY(?)", - activity.data, - activity.data, - ^ap_ids - ), - where: fragment("(?)->>'type' = 'Create'", activity.data), - inner_join: o in Object, - on: - fragment( - "(?->>'id') = COALESCE(?->'object'->>'id', ?->>'object')", - o.data, - activity.data, - activity.data - ), - preload: [object: o] - ) - end - - def create_by_object_ap_id_with_object(ap_id) when is_binary(ap_id) do - from( - activity in Activity, - where: - fragment( - "coalesce((?)->'object'->>'id', (?)->>'object') = ?", - activity.data, - activity.data, - ^to_string(ap_id) - ), - where: fragment("(?)->>'type' = 'Create'", activity.data), - inner_join: o in Object, - on: - fragment( - "(?->>'id') = COALESCE(?->'object'->>'id', ?->>'object')", - o.data, - activity.data, - activity.data - ), - preload: [object: o] - ) + @doc """ + Accepts `ap_id` or list of `ap_id`. + Returns a query. + """ + @spec create_by_object_ap_id_with_object(String.t() | [String.t()]) :: Ecto.Queryable.t() + def create_by_object_ap_id_with_object(ap_id) do + ap_id + |> create_by_object_ap_id() + |> with_preloaded_object() end - def create_by_object_ap_id_with_object(_), do: nil - def get_create_by_object_ap_id_with_object(ap_id) when is_binary(ap_id) do ap_id |> create_by_object_ap_id_with_object() @@ -299,7 +224,8 @@ defmodule Pleroma.Activity do def normalize(_), do: nil def delete_by_ap_id(id) when is_binary(id) do - by_object_ap_id(id) + id + |> Queries.by_object_id() |> select([u], u) |> Repo.delete_all() |> elem(1) @@ -308,10 +234,19 @@ defmodule Pleroma.Activity do %{data: %{"type" => "Create", "object" => %{"id" => ap_id}}} -> ap_id == id _ -> nil end) + |> purge_web_resp_cache() end def delete_by_ap_id(_), do: nil + defp purge_web_resp_cache(%Activity{} = activity) do + %{path: path} = URI.parse(activity.data["id"]) + Cachex.del(:web_resp_cache, path) + activity + end + + defp purge_web_resp_cache(nil), do: nil + for {ap_type, type} <- @mastodon_notification_types do def mastodon_notification_type(%Activity{data: %{"type" => unquote(ap_type)}}), do: unquote(type) @@ -334,40 +269,19 @@ defmodule Pleroma.Activity do end def follow_requests_for_actor(%Pleroma.User{ap_id: ap_id}) do - from( - a in Activity, - where: - fragment( - "? ->> 'type' = 'Follow'", - a.data - ), - where: - fragment( - "? ->> 'state' = 'pending'", - a.data - ), - where: - fragment( - "coalesce((?)->'object'->>'id', (?)->>'object') = ?", - a.data, - a.data, - ^ap_id - ) - ) - end - - @spec query_by_actor(actor()) :: Ecto.Query.t() - def query_by_actor(actor) do - from(a in Activity, where: a.actor == ^actor) + ap_id + |> Queries.by_object_id() + |> Queries.by_type("Follow") + |> where([a], fragment("? ->> 'state' = 'pending'", a.data)) end def restrict_deactivated_users(query) do + deactivated_users = + from(u in User.Query.build(deactivated: true), select: u.ap_id) + |> Repo.all() + from(activity in query, - where: - fragment( - "? not in (SELECT ap_id FROM users WHERE info->'deactivated' @> 'true')", - activity.actor - ) + where: activity.actor not in ^deactivated_users ) end diff --git a/lib/pleroma/activity/ir/topics.ex b/lib/pleroma/activity/ir/topics.ex new file mode 100644 index 000000000..010897abc --- /dev/null +++ b/lib/pleroma/activity/ir/topics.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.Activity.Ir.Topics do + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.Visibility + + def get_activity_topics(activity) do + activity + |> Object.normalize() + |> generate_topics(activity) + |> List.flatten() + end + + defp generate_topics(%{data: %{"type" => "Answer"}}, _) do + [] + end + + defp generate_topics(object, activity) do + ["user", "list"] ++ visibility_tags(object, activity) + end + + defp visibility_tags(object, activity) do + case Visibility.get_visibility(activity) do + "public" -> + if activity.local do + ["public", "public:local"] + else + ["public"] + end + |> item_creation_tags(object, activity) + + "direct" -> + ["direct"] + + _ -> + [] + end + end + + defp item_creation_tags(tags, %{data: %{"type" => "Create"}} = object, activity) do + tags ++ hashtags_to_topics(object) ++ attachment_topics(object, activity) + end + + defp item_creation_tags(tags, _, _) do + tags + end + + defp hashtags_to_topics(%{data: %{"tag" => tags}}) do + tags + |> Enum.filter(&is_bitstring(&1)) + |> Enum.map(fn tag -> "hashtag:" <> tag end) + end + + defp hashtags_to_topics(_), do: [] + + defp attachment_topics(%{data: %{"attachment" => []}}, _act), do: [] + + defp attachment_topics(_object, %{local: true}), do: ["public:media", "public:local:media"] + + defp attachment_topics(_object, _act), do: ["public:media"] +end diff --git a/lib/pleroma/activity/queries.ex b/lib/pleroma/activity/queries.ex index aa5b29566..949f010a8 100644 --- a/lib/pleroma/activity/queries.ex +++ b/lib/pleroma/activity/queries.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Activity.Queries do @@ -13,6 +13,14 @@ defmodule Pleroma.Activity.Queries do alias Pleroma.Activity + @spec by_ap_id(query, String.t()) :: query + def by_ap_id(query \\ Activity, ap_id) do + from( + activity in query, + where: fragment("(?)->>'id' = ?", activity.data, ^to_string(ap_id)) + ) + end + @spec by_actor(query, String.t()) :: query def by_actor(query \\ Activity, actor) do from( @@ -21,8 +29,23 @@ defmodule Pleroma.Activity.Queries do ) end - @spec by_object_id(query, String.t()) :: query - def by_object_id(query \\ Activity, object_id) do + @spec by_object_id(query, String.t() | [String.t()]) :: query + def by_object_id(query \\ Activity, object_id) + + def by_object_id(query, object_ids) when is_list(object_ids) do + from( + activity in query, + where: + fragment( + "coalesce((?)->'object'->>'id', (?)->>'object') = ANY(?)", + activity.data, + activity.data, + ^object_ids + ) + ) + end + + def by_object_id(query, object_id) when is_binary(object_id) do from(activity in query, where: fragment( @@ -41,9 +64,4 @@ defmodule Pleroma.Activity.Queries do where: fragment("(?)->>'type' = ?", activity.data, ^activity_type) ) end - - @spec limit(query, pos_integer()) :: query - def limit(query \\ Activity, limit) do - from(activity in query, limit: ^limit) - end end diff --git a/lib/pleroma/activity_expiration.ex b/lib/pleroma/activity_expiration.ex index bf57abca4..7ea5c48ca 100644 --- a/lib/pleroma/activity_expiration.ex +++ b/lib/pleroma/activity_expiration.ex @@ -7,7 +7,6 @@ defmodule Pleroma.ActivityExpiration do alias Pleroma.Activity alias Pleroma.ActivityExpiration - alias Pleroma.FlakeId alias Pleroma.Repo import Ecto.Changeset @@ -17,7 +16,7 @@ defmodule Pleroma.ActivityExpiration do @min_activity_lifetime :timer.hours(1) schema "activity_expirations" do - belongs_to(:activity, Activity, type: FlakeId) + belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType) field(:scheduled_at, :naive_datetime) end diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 483ac1f39..0bf218bc7 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -31,34 +31,21 @@ defmodule Pleroma.Application do children = [ Pleroma.Repo, + Pleroma.Scheduler, Pleroma.Config.TransferTask, Pleroma.Emoji, Pleroma.Captcha, - Pleroma.FlakeId, - Pleroma.ScheduledActivityWorker, - Pleroma.ActivityExpirationWorker + Pleroma.Daemons.ScheduledActivityDaemon, + Pleroma.Daemons.ActivityExpirationDaemon ] ++ cachex_children() ++ hackney_pool_children() ++ [ - Pleroma.Web.Federator.RetryQueue, Pleroma.Stats, - %{ - id: :web_push_init, - start: {Task, :start_link, [&Pleroma.Web.Push.init/0]}, - restart: :temporary - }, - %{ - id: :federator_init, - start: {Task, :start_link, [&Pleroma.Web.Federator.init/0]}, - restart: :temporary - }, - %{ - id: :internal_fetch_init, - start: {Task, :start_link, [&Pleroma.Web.ActivityPub.InternalFetchActor.init/0]}, - restart: :temporary - } + Pleroma.JobQueueMonitor, + {Oban, Pleroma.Config.get(Oban)} ] ++ + task_children(@env) ++ oauth_cleanup_child(oauth_cleanup_enabled?()) ++ streamer_child(@env) ++ chat_child(@env, chat_enabled?()) ++ @@ -70,9 +57,7 @@ defmodule Pleroma.Application do # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: Pleroma.Supervisor] - result = Supervisor.start_link(children, opts) - :ok = after_supervisor_start() - result + Supervisor.start_link(children, opts) end defp setup_instrumenters do @@ -116,10 +101,16 @@ defmodule Pleroma.Application do build_cachex("object", default_ttl: 25_000, ttl_interval: 1000, limit: 2500), build_cachex("rich_media", default_ttl: :timer.minutes(120), limit: 5000), build_cachex("scrubber", limit: 2500), - build_cachex("idempotency", expiration: idempotency_expiration(), 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("failed_proxy_url", limit: 2500) ] end + defp emoji_packs_expiration, + do: expiration(default: :timer.seconds(5 * 60), interval: :timer.seconds(60)) + defp idempotency_expiration, do: expiration(default: :timer.seconds(6 * 60 * 60), interval: :timer.seconds(60)) @@ -141,7 +132,7 @@ defmodule Pleroma.Application do defp streamer_child(:test), do: [] defp streamer_child(_) do - [Pleroma.Web.Streamer] + [Pleroma.Web.Streamer.supervisor()] end defp oauth_cleanup_child(true), @@ -164,16 +155,38 @@ defmodule Pleroma.Application do end end - defp after_supervisor_start do - with digest_config <- Application.get_env(:pleroma, :email_notifications)[:digest], - true <- digest_config[:active] do - PleromaJobQueue.schedule( - digest_config[:schedule], - :digest_emails, - Pleroma.DigestEmailWorker - ) - end + defp task_children(:test) do + [ + %{ + id: :web_push_init, + start: {Task, :start_link, [&Pleroma.Web.Push.init/0]}, + restart: :temporary + }, + %{ + id: :federator_init, + start: {Task, :start_link, [&Pleroma.Web.Federator.init/0]}, + restart: :temporary + } + ] + end - :ok + defp task_children(_) do + [ + %{ + id: :web_push_init, + start: {Task, :start_link, [&Pleroma.Web.Push.init/0]}, + restart: :temporary + }, + %{ + id: :federator_init, + start: {Task, :start_link, [&Pleroma.Web.Federator.init/0]}, + restart: :temporary + }, + %{ + id: :internal_fetch_init, + start: {Task, :start_link, [&Pleroma.Web.ActivityPub.InternalFetchActor.init/0]}, + restart: :temporary + } + ] end end diff --git a/lib/pleroma/bbs/handler.ex b/lib/pleroma/bbs/handler.ex index 0a381f592..fa838a4e4 100644 --- a/lib/pleroma/bbs/handler.ex +++ b/lib/pleroma/bbs/handler.ex @@ -42,7 +42,7 @@ defmodule Pleroma.BBS.Handler do end def puts_activity(activity) do - status = Pleroma.Web.MastodonAPI.StatusView.render("status.json", %{activity: activity}) + status = Pleroma.Web.MastodonAPI.StatusView.render("show.json", %{activity: activity}) IO.puts("-- #{status.id} by #{status.account.display_name} (#{status.account.acct})") IO.puts(HtmlSanitizeEx.strip_tags(status.content)) IO.puts("") diff --git a/lib/pleroma/bookmark.ex b/lib/pleroma/bookmark.ex index d976f949c..221a94f34 100644 --- a/lib/pleroma/bookmark.ex +++ b/lib/pleroma/bookmark.ex @@ -10,20 +10,20 @@ defmodule Pleroma.Bookmark do alias Pleroma.Activity alias Pleroma.Bookmark - alias Pleroma.FlakeId alias Pleroma.Repo alias Pleroma.User @type t :: %__MODULE__{} schema "bookmarks" do - belongs_to(:user, User, type: FlakeId) - belongs_to(:activity, Activity, type: FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType) timestamps() end - @spec create(FlakeId.t(), FlakeId.t()) :: {:ok, Bookmark.t()} | {:error, Changeset.t()} + @spec create(FlakeId.Ecto.CompatType.t(), FlakeId.Ecto.CompatType.t()) :: + {:ok, Bookmark.t()} | {:error, Changeset.t()} def create(user_id, activity_id) do attrs = %{ user_id: user_id, @@ -37,7 +37,7 @@ defmodule Pleroma.Bookmark do |> Repo.insert() end - @spec for_user_query(FlakeId.t()) :: Ecto.Query.t() + @spec for_user_query(FlakeId.Ecto.CompatType.t()) :: Ecto.Query.t() def for_user_query(user_id) do Bookmark |> where(user_id: ^user_id) @@ -52,7 +52,8 @@ defmodule Pleroma.Bookmark do |> Repo.one() end - @spec destroy(FlakeId.t(), FlakeId.t()) :: {:ok, Bookmark.t()} | {:error, Changeset.t()} + @spec destroy(FlakeId.Ecto.CompatType.t(), FlakeId.Ecto.CompatType.t()) :: + {:ok, Bookmark.t()} | {:error, Changeset.t()} def destroy(user_id, activity_id) do from(b in Bookmark, where: b.user_id == ^user_id, diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index ef1418543..0bf20cdd0 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -6,4 +6,16 @@ defmodule Pleroma.Constants do use Const const(as_public, do: "https://www.w3.org/ns/activitystreams#Public") + + const(object_internal_fields, + do: [ + "likes", + "like_count", + "announcements", + "announcement_count", + "emoji", + "context_id", + "deleted_activity_id" + ] + ) end diff --git a/lib/pleroma/conversation.ex b/lib/pleroma/conversation.ex index be5821ad7..098016af2 100644 --- a/lib/pleroma/conversation.ex +++ b/lib/pleroma/conversation.ex @@ -67,6 +67,8 @@ defmodule Pleroma.Conversation do participations = Enum.map(users, fn user -> + User.increment_unread_conversation_count(conversation, user) + {:ok, participation} = Participation.create_for_user_and_conversation(user, conversation, opts) diff --git a/lib/pleroma/conversation/participation.ex b/lib/pleroma/conversation/participation.ex index ea5b9fe17..ab81f3217 100644 --- a/lib/pleroma/conversation/participation.ex +++ b/lib/pleroma/conversation/participation.ex @@ -13,10 +13,10 @@ defmodule Pleroma.Conversation.Participation do import Ecto.Query schema "conversation_participations" do - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:conversation, Conversation) field(:read, :boolean, default: false) - field(:last_activity_id, Pleroma.FlakeId, virtual: true) + field(:last_activity_id, FlakeId.Ecto.CompatType, virtual: true) has_many(:recipient_ships, RecipientShip) has_many(:recipients, through: [:recipient_ships, :user]) @@ -52,6 +52,15 @@ defmodule Pleroma.Conversation.Participation do participation |> read_cng(%{read: true}) |> Repo.update() + |> case do + {:ok, participation} -> + participation = Repo.preload(participation, :user) + User.set_unread_conversation_count(participation.user) + {:ok, participation} + + error -> + error + end end def mark_as_unread(participation) do @@ -135,4 +144,12 @@ defmodule Pleroma.Conversation.Participation do {:ok, Repo.preload(participation, :recipients, force: true)} end + + def unread_conversation_count_for_user(user) do + from(p in __MODULE__, + where: p.user_id == ^user.id, + where: not p.read, + select: %{count: count(p.id)} + ) + end end diff --git a/lib/pleroma/conversation/participation_recipient_ship.ex b/lib/pleroma/conversation/participation_recipient_ship.ex index 932cbd04c..e3d158cbc 100644 --- a/lib/pleroma/conversation/participation_recipient_ship.ex +++ b/lib/pleroma/conversation/participation_recipient_ship.ex @@ -12,7 +12,7 @@ defmodule Pleroma.Conversation.Participation.RecipientShip do import Ecto.Changeset schema "conversation_participation_recipient_ships" do - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:participation, Participation) end diff --git a/lib/pleroma/activity_expiration_worker.ex b/lib/pleroma/daemons/activity_expiration_daemon.ex index 0f9e715f8..cab7628c4 100644 --- a/lib/pleroma/activity_expiration_worker.ex +++ b/lib/pleroma/daemons/activity_expiration_daemon.ex @@ -2,13 +2,14 @@ # Copyright © 2019 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.ActivityExpirationWorker do +defmodule Pleroma.Daemons.ActivityExpirationDaemon do alias Pleroma.Activity alias Pleroma.ActivityExpiration alias Pleroma.Config alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.CommonAPI + require Logger use GenServer import Ecto.Query @@ -49,7 +50,10 @@ defmodule Pleroma.ActivityExpirationWorker do def handle_info(:perform, state) do ActivityExpiration.due_expirations(@schedule_interval) |> Enum.each(fn expiration -> - PleromaJobQueue.enqueue(:activity_expiration, __MODULE__, [:execute, expiration.id]) + Pleroma.Workers.ActivityExpirationWorker.enqueue( + "activity_expiration", + %{"activity_expiration_id" => expiration.id} + ) end) schedule_next() diff --git a/lib/pleroma/digest_email_worker.ex b/lib/pleroma/daemons/digest_email_daemon.ex index 5644d6a67..462ad2c55 100644 --- a/lib/pleroma/digest_email_worker.ex +++ b/lib/pleroma/daemons/digest_email_daemon.ex @@ -2,10 +2,11 @@ # Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.DigestEmailWorker do - import Ecto.Query +defmodule Pleroma.Daemons.DigestEmailDaemon do + alias Pleroma.Repo + alias Pleroma.Workers.DigestEmailsWorker - @queue_name :digest_emails + import Ecto.Query def perform do config = Pleroma.Config.get([:email_notifications, :digest]) @@ -20,8 +21,10 @@ defmodule Pleroma.DigestEmailWorker do where: u.last_digest_emailed_at < datetime_add(^now, ^negative_interval, "day"), select: u ) - |> Pleroma.Repo.all() - |> Enum.each(&PleromaJobQueue.enqueue(@queue_name, __MODULE__, [&1])) + |> Repo.all() + |> Enum.each(fn user -> + DigestEmailsWorker.enqueue("digest_email", %{"user_id" => user.id}) + end) end @doc """ diff --git a/lib/pleroma/scheduled_activity_worker.ex b/lib/pleroma/daemons/scheduled_activity_daemon.ex index 8578cab5e..aee5f723a 100644 --- a/lib/pleroma/scheduled_activity_worker.ex +++ b/lib/pleroma/daemons/scheduled_activity_daemon.ex @@ -2,7 +2,7 @@ # Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.ScheduledActivityWorker do +defmodule Pleroma.Daemons.ScheduledActivityDaemon do @moduledoc """ Sends scheduled activities to the job queue. """ @@ -11,6 +11,7 @@ defmodule Pleroma.ScheduledActivityWorker do alias Pleroma.ScheduledActivity alias Pleroma.User alias Pleroma.Web.CommonAPI + use GenServer require Logger @@ -45,7 +46,10 @@ defmodule Pleroma.ScheduledActivityWorker do def handle_info(:perform, state) do ScheduledActivity.due_activities(@schedule_interval) |> Enum.each(fn scheduled_activity -> - PleromaJobQueue.enqueue(:scheduled_activities, __MODULE__, [:execute, scheduled_activity.id]) + Pleroma.Workers.ScheduledActivityWorker.enqueue( + "execute", + %{"activity_id" => scheduled_activity.id} + ) end) schedule_next() diff --git a/lib/pleroma/delivery.ex b/lib/pleroma/delivery.ex new file mode 100644 index 000000000..1d586a252 --- /dev/null +++ b/lib/pleroma/delivery.ex @@ -0,0 +1,50 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Delivery do + use Ecto.Schema + + alias Pleroma.Delivery + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.User + + import Ecto.Changeset + import Ecto.Query + + schema "deliveries" do + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + belongs_to(:object, Object) + end + + def changeset(delivery, params \\ %{}) do + delivery + |> cast(params, [:user_id, :object_id]) + |> validate_required([:user_id, :object_id]) + |> foreign_key_constraint(:object_id) + |> foreign_key_constraint(:user_id) + |> unique_constraint(:user_id, name: :deliveries_user_id_object_id_index) + end + + def create(object_id, user_id) do + %Delivery{} + |> changeset(%{user_id: user_id, object_id: object_id}) + |> Repo.insert(on_conflict: :nothing) + end + + def get(object_id, user_id) do + from(d in Delivery, where: d.user_id == ^user_id and d.object_id == ^object_id) + |> Repo.one() + end + + # A hack because user delete activities have a fake id for whatever reason + # TODO: Get rid of this + def delete_all_by_object_id("pleroma:fake_object_id"), do: {0, []} + + def delete_all_by_object_id(object_id) do + from(d in Delivery, where: d.object_id == ^object_id) + |> Repo.delete_all() + end +end diff --git a/lib/pleroma/docs/generator.ex b/lib/pleroma/docs/generator.ex new file mode 100644 index 000000000..aa578eee2 --- /dev/null +++ b/lib/pleroma/docs/generator.ex @@ -0,0 +1,73 @@ +defmodule Pleroma.Docs.Generator do + @callback process(keyword()) :: {:ok, String.t()} + + @spec process(module(), keyword()) :: {:ok, String.t()} + def process(implementation, descriptions) do + implementation.process(descriptions) + end + + @spec uploaders_list() :: [module()] + def uploaders_list do + {:ok, modules} = :application.get_key(:pleroma, :modules) + + Enum.filter(modules, fn module -> + name_as_list = Module.split(module) + + List.starts_with?(name_as_list, ["Pleroma", "Uploaders"]) and + List.last(name_as_list) != "Uploader" + end) + end + + @spec filters_list() :: [module()] + def filters_list do + {:ok, modules} = :application.get_key(:pleroma, :modules) + + Enum.filter(modules, fn module -> + name_as_list = Module.split(module) + + List.starts_with?(name_as_list, ["Pleroma", "Upload", "Filter"]) + end) + end + + @spec mrf_list() :: [module()] + def mrf_list do + {:ok, modules} = :application.get_key(:pleroma, :modules) + + Enum.filter(modules, fn module -> + name_as_list = Module.split(module) + + List.starts_with?(name_as_list, ["Pleroma", "Web", "ActivityPub", "MRF"]) and + length(name_as_list) > 4 + end) + end + + @spec richmedia_parsers() :: [module()] + def richmedia_parsers do + {:ok, modules} = :application.get_key(:pleroma, :modules) + + Enum.filter(modules, fn module -> + name_as_list = Module.split(module) + + List.starts_with?(name_as_list, ["Pleroma", "Web", "RichMedia", "Parsers"]) and + length(name_as_list) == 5 + end) + end +end + +defimpl Jason.Encoder, for: Tuple do + def encode(tuple, opts) do + Jason.Encode.list(Tuple.to_list(tuple), opts) + end +end + +defimpl Jason.Encoder, for: [Regex, Function] do + def encode(term, opts) do + Jason.Encode.string(inspect(term), opts) + end +end + +defimpl String.Chars, for: Regex do + def to_string(term) do + inspect(term) + end +end diff --git a/lib/pleroma/docs/json.ex b/lib/pleroma/docs/json.ex new file mode 100644 index 000000000..18ba01d58 --- /dev/null +++ b/lib/pleroma/docs/json.ex @@ -0,0 +1,20 @@ +defmodule Pleroma.Docs.JSON do + @behaviour Pleroma.Docs.Generator + + @spec process(keyword()) :: {:ok, String.t()} + def process(descriptions) do + config_path = "docs/generate_config.json" + + with {:ok, file} <- File.open(config_path, [:write]), + json <- generate_json(descriptions), + :ok <- IO.write(file, json), + :ok <- File.close(file) do + {:ok, config_path} + end + end + + @spec generate_json([keyword()]) :: String.t() + def generate_json(descriptions) do + Jason.encode!(descriptions) + end +end diff --git a/lib/pleroma/docs/markdown.ex b/lib/pleroma/docs/markdown.ex new file mode 100644 index 000000000..68b106499 --- /dev/null +++ b/lib/pleroma/docs/markdown.ex @@ -0,0 +1,88 @@ +defmodule Pleroma.Docs.Markdown do + @behaviour Pleroma.Docs.Generator + + @spec process(keyword()) :: {:ok, String.t()} + def process(descriptions) do + config_path = "docs/generated_config.md" + {:ok, file} = File.open(config_path, [:utf8, :write]) + IO.write(file, "# Generated configuration\n") + IO.write(file, "Date of generation: #{Date.utc_today()}\n\n") + + IO.write( + file, + "This file describe the configuration, it is recommended to edit the relevant `*.secret.exs` file instead of the others founds in the ``config`` directory.\n\n" <> + "If you run Pleroma with ``MIX_ENV=prod`` the file is ``prod.secret.exs``, otherwise it is ``dev.secret.exs``.\n\n" + ) + + for group <- descriptions do + if is_nil(group[:key]) do + IO.write(file, "## #{inspect(group[:group])}\n") + else + IO.write(file, "## #{inspect(group[:key])}\n") + end + + IO.write(file, "#{group[:description]}\n") + + for child <- group[:children] || [] do + print_child_header(file, child) + + print_suggestions(file, child[:suggestions]) + + if child[:children] do + for subchild <- child[:children] do + print_child_header(file, subchild) + + print_suggestions(file, subchild[:suggestions]) + end + end + end + + IO.write(file, "\n") + end + + :ok = File.close(file) + {:ok, config_path} + end + + defp print_child_header(file, %{key: key, type: type, description: description} = _child) do + IO.write( + file, + "- `#{inspect(key)}` (`#{inspect(type)}`): #{description} \n" + ) + end + + defp print_child_header(file, %{key: key, type: type} = _child) do + IO.write(file, "- `#{inspect(key)}` (`#{inspect(type)}`) \n") + end + + defp print_suggestion(file, suggestion) when is_list(suggestion) do + IO.write(file, " `#{inspect(suggestion)}`\n") + end + + defp print_suggestion(file, suggestion) when is_function(suggestion) do + IO.write(file, " `#{inspect(suggestion.())}`\n") + end + + defp print_suggestion(file, suggestion, as_list \\ false) do + list_mark = if as_list, do: "- ", else: "" + IO.write(file, " #{list_mark}`#{inspect(suggestion)}`\n") + end + + defp print_suggestions(_file, nil), do: nil + + defp print_suggestions(_file, ""), do: nil + + defp print_suggestions(file, suggestions) do + if length(suggestions) > 1 do + IO.write(file, "Suggestions:\n") + + for suggestion <- suggestions do + print_suggestion(file, suggestion, true) + end + else + IO.write(file, " Suggestion: ") + + print_suggestion(file, List.first(suggestions)) + end + end +end diff --git a/lib/pleroma/emails/admin_email.ex b/lib/pleroma/emails/admin_email.ex index c14be02dd..b15e4041b 100644 --- a/lib/pleroma/emails/admin_email.ex +++ b/lib/pleroma/emails/admin_email.ex @@ -17,7 +17,7 @@ defmodule Pleroma.Emails.AdminEmail do end defp user_url(user) do - Helpers.o_status_url(Pleroma.Web.Endpoint, :feed_redirect, user.nickname) + Helpers.feed_url(Pleroma.Web.Endpoint, :feed_redirect, user.id) end def report(to, reporter, account, statuses, comment) do diff --git a/lib/pleroma/emails/mailer.ex b/lib/pleroma/emails/mailer.ex index 2e4657b7c..eb96f2e8b 100644 --- a/lib/pleroma/emails/mailer.ex +++ b/lib/pleroma/emails/mailer.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Emails.Mailer do The module contains functions to delivery email using Swoosh.Mailer. """ + alias Pleroma.Workers.MailerWorker alias Swoosh.DeliveryError @otp_app :pleroma @@ -19,7 +20,12 @@ defmodule Pleroma.Emails.Mailer do @doc "add email to queue" def deliver_async(email, config \\ []) do - PleromaJobQueue.enqueue(:mailer, __MODULE__, [:deliver_async, email, config]) + encoded_email = + email + |> :erlang.term_to_binary() + |> Base.encode64() + + MailerWorker.enqueue("email", %{"encoded_email" => encoded_email, "config" => config}) end @doc "callback to perform send email from queue" diff --git a/lib/pleroma/emoji.ex b/lib/pleroma/emoji.ex index 66e20f0e4..bafad2ae9 100644 --- a/lib/pleroma/emoji.ex +++ b/lib/pleroma/emoji.ex @@ -4,24 +4,37 @@ defmodule Pleroma.Emoji do @moduledoc """ - The emojis are loaded from: - - * emoji packs in INSTANCE-DIR/emoji - * the files: `config/emoji.txt` and `config/custom_emoji.txt` - * glob paths, nested folder is used as tag name for grouping e.g. priv/static/emoji/custom/nested_folder - - This GenServer stores in an ETS table the list of the loaded emojis, and also allows to reload the list at runtime. + This GenServer stores in an ETS table the list of the loaded emojis, + and also allows to reload the list at runtime. """ use GenServer - require Logger + alias Pleroma.Emoji.Loader - @type pattern :: Regex.t() | module() | String.t() - @type patterns :: pattern() | [pattern()] - @type group_patterns :: keyword(patterns()) + require Logger @ets __MODULE__.Ets - @ets_options [:ordered_set, :protected, :named_table, {:read_concurrency, true}] + @ets_options [ + :ordered_set, + :protected, + :named_table, + {:read_concurrency, true} + ] + + defstruct [:code, :file, :tags, :safe_code, :safe_file] + + @doc "Build emoji struct" + def build({code, file, tags}) do + %__MODULE__{ + code: code, + file: file, + tags: tags, + safe_code: Pleroma.HTML.strip_tags(code), + safe_file: Pleroma.HTML.strip_tags(file) + } + end + + def build({code, file}), do: build({code, file, []}) @doc false def start_link(_) do @@ -44,11 +57,14 @@ defmodule Pleroma.Emoji do end @doc "Returns all the emojos!!" - @spec get_all() :: [{String.t(), String.t()}, ...] + @spec get_all() :: list({String.t(), String.t(), String.t()}) def get_all do :ets.tab2list(@ets) end + @doc "Clear out old emojis" + def clear_all, do: :ets.delete_all_objects(@ets) + @doc false def init(_) do @ets = :ets.new(@ets, @ets_options) @@ -58,13 +74,13 @@ defmodule Pleroma.Emoji do @doc false def handle_cast(:reload, state) do - load() + update_emojis(Loader.load()) {:noreply, state} end @doc false def handle_call(:reload, _from, state) do - load() + update_emojis(Loader.load()) {:reply, :ok, state} end @@ -75,189 +91,11 @@ defmodule Pleroma.Emoji do @doc false def code_change(_old_vsn, state, _extra) do - load() + update_emojis(Loader.load()) {:ok, state} end - defp load do - emoji_dir_path = - Path.join( - Pleroma.Config.get!([:instance, :static_dir]), - "emoji" - ) - - emoji_groups = Pleroma.Config.get([:emoji, :groups]) - - case File.ls(emoji_dir_path) do - {:error, :enoent} -> - # The custom emoji directory doesn't exist, - # don't do anything - nil - - {:error, e} -> - # There was some other error - Logger.error("Could not access the custom emoji directory #{emoji_dir_path}: #{e}") - - {:ok, results} -> - grouped = - Enum.group_by(results, fn file -> File.dir?(Path.join(emoji_dir_path, file)) end) - - packs = grouped[true] || [] - files = grouped[false] || [] - - # Print the packs we've found - Logger.info("Found emoji packs: #{Enum.join(packs, ", ")}") - - if not Enum.empty?(files) do - Logger.warn( - "Found files in the emoji folder. These will be ignored, please move them to a subdirectory\nFound files: #{ - Enum.join(files, ", ") - }" - ) - end - - emojis = - Enum.flat_map( - packs, - fn pack -> load_pack(Path.join(emoji_dir_path, pack), emoji_groups) end - ) - - true = :ets.insert(@ets, emojis) - end - - # Compat thing for old custom emoji handling & default emoji, - # it should run even if there are no emoji packs - shortcode_globs = Pleroma.Config.get([:emoji, :shortcode_globs], []) - - emojis = - (load_from_file("config/emoji.txt", emoji_groups) ++ - load_from_file("config/custom_emoji.txt", emoji_groups) ++ - load_from_globs(shortcode_globs, emoji_groups)) - |> Enum.reject(fn value -> value == nil end) - - true = :ets.insert(@ets, emojis) - - :ok - end - - defp load_pack(pack_dir, emoji_groups) do - pack_name = Path.basename(pack_dir) - - emoji_txt = Path.join(pack_dir, "emoji.txt") - - if File.exists?(emoji_txt) do - load_from_file(emoji_txt, emoji_groups) - else - extensions = Pleroma.Config.get([:emoji, :pack_extensions]) - - Logger.info( - "No emoji.txt found for pack \"#{pack_name}\", assuming all #{Enum.join(extensions, ", ")} files are emoji" - ) - - make_shortcode_to_file_map(pack_dir, extensions) - |> Enum.map(fn {shortcode, rel_file} -> - filename = Path.join("/emoji/#{pack_name}", rel_file) - - {shortcode, filename, [to_string(match_extra(emoji_groups, filename))]} - end) - end - end - - def make_shortcode_to_file_map(pack_dir, exts) do - find_all_emoji(pack_dir, exts) - |> Enum.map(&Path.relative_to(&1, pack_dir)) - |> Enum.map(fn f -> {f |> Path.basename() |> Path.rootname(), f} end) - |> Enum.into(%{}) - end - - def find_all_emoji(dir, exts) do - Enum.reduce( - File.ls!(dir), - [], - fn f, acc -> - filepath = Path.join(dir, f) - - if File.dir?(filepath) do - acc ++ find_all_emoji(filepath, exts) - else - acc ++ [filepath] - end - end - ) - |> Enum.filter(fn f -> Path.extname(f) in exts end) - end - - defp load_from_file(file, emoji_groups) do - if File.exists?(file) do - load_from_file_stream(File.stream!(file), emoji_groups) - else - [] - end - end - - defp load_from_file_stream(stream, emoji_groups) do - stream - |> Stream.map(&String.trim/1) - |> Stream.map(fn line -> - case String.split(line, ~r/,\s*/) do - [name, file] -> - {name, file, [to_string(match_extra(emoji_groups, file))]} - - [name, file | tags] -> - {name, file, tags} - - _ -> - nil - end - end) - |> Enum.to_list() - end - - defp load_from_globs(globs, emoji_groups) do - static_path = Path.join(:code.priv_dir(:pleroma), "static") - - paths = - Enum.map(globs, fn glob -> - Path.join(static_path, glob) - |> Path.wildcard() - end) - |> Enum.concat() - - Enum.map(paths, fn path -> - tag = match_extra(emoji_groups, Path.join("/", Path.relative_to(path, static_path))) - shortcode = Path.basename(path, Path.extname(path)) - external_path = Path.join("/", Path.relative_to(path, static_path)) - {shortcode, external_path, [to_string(tag)]} - end) - end - - @doc """ - Finds a matching group for the given emoji filename - """ - @spec match_extra(group_patterns(), String.t()) :: atom() | nil - def match_extra(group_patterns, filename) do - match_group_patterns(group_patterns, fn pattern -> - case pattern do - %Regex{} = regex -> Regex.match?(regex, filename) - string when is_binary(string) -> filename == string - end - end) - end - - defp match_group_patterns(group_patterns, matcher) do - Enum.find_value(group_patterns, fn {group, patterns} -> - patterns = - patterns - |> List.wrap() - |> Enum.map(fn pattern -> - if String.contains?(pattern, "*") do - ~r(#{String.replace(pattern, "*", ".*")}) - else - pattern - end - end) - - Enum.any?(patterns, matcher) && group - end) + defp update_emojis(emojis) do + :ets.insert(@ets, emojis) end end diff --git a/lib/pleroma/emoji/formatter.ex b/lib/pleroma/emoji/formatter.ex new file mode 100644 index 000000000..4869d073e --- /dev/null +++ b/lib/pleroma/emoji/formatter.ex @@ -0,0 +1,59 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Emoji.Formatter do + alias Pleroma.Emoji + alias Pleroma.HTML + alias Pleroma.Web.MediaProxy + + def emojify(text) do + emojify(text, Emoji.get_all()) + end + + def emojify(text, nil), do: text + + def emojify(text, emoji, strip \\ false) do + Enum.reduce(emoji, text, fn + {_, %Emoji{safe_code: emoji, safe_file: file}}, text -> + String.replace(text, ":#{emoji}:", prepare_emoji_html(emoji, file, strip)) + + {unsafe_emoji, unsafe_file}, text -> + emoji = HTML.strip_tags(unsafe_emoji) + file = HTML.strip_tags(unsafe_file) + String.replace(text, ":#{emoji}:", prepare_emoji_html(emoji, file, strip)) + end) + |> HTML.filter_tags() + end + + defp prepare_emoji_html(_emoji, _file, true), do: "" + + defp prepare_emoji_html(emoji, file, _strip) do + "<img class='emoji' alt='#{emoji}' title='#{emoji}' src='#{MediaProxy.url(file)}' />" + end + + def demojify(text) do + emojify(text, Emoji.get_all(), true) + end + + def demojify(text, nil), do: text + + @doc "Outputs a list of the emoji-shortcodes in a text" + def get_emoji(text) when is_binary(text) do + Enum.filter(Emoji.get_all(), fn {emoji, %Emoji{}} -> + String.contains?(text, ":#{emoji}:") + end) + end + + def get_emoji(_), do: [] + + @doc "Outputs a list of the emoji-Maps in a text" + def get_emoji_map(text) when is_binary(text) do + get_emoji(text) + |> Enum.reduce(%{}, fn {name, %Emoji{file: file}}, acc -> + Map.put(acc, name, "#{Pleroma.Web.Endpoint.static_url()}#{file}") + end) + end + + def get_emoji_map(_), do: [] +end diff --git a/lib/pleroma/emoji/loader.ex b/lib/pleroma/emoji/loader.ex new file mode 100644 index 000000000..4f4ee51d1 --- /dev/null +++ b/lib/pleroma/emoji/loader.ex @@ -0,0 +1,224 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Emoji.Loader do + @moduledoc """ + The Loader emoji from: + + * emoji packs in INSTANCE-DIR/emoji + * the files: `config/emoji.txt` and `config/custom_emoji.txt` + * glob paths, nested folder is used as tag name for grouping e.g. priv/static/emoji/custom/nested_folder + """ + alias Pleroma.Config + alias Pleroma.Emoji + + require Logger + + @type pattern :: Regex.t() | module() | String.t() + @type patterns :: pattern() | [pattern()] + @type group_patterns :: keyword(patterns()) + @type emoji :: {String.t(), Emoji.t()} + + @doc """ + Loads emojis from files/packs. + + returns list emojis in format: + `{"000", "/emoji/freespeechextremist.com/000.png", ["Custom"]}` + """ + @spec load() :: list(emoji) + def load do + emoji_dir_path = Path.join(Config.get!([:instance, :static_dir]), "emoji") + + emoji_groups = Config.get([:emoji, :groups]) + + emojis = + case File.ls(emoji_dir_path) do + {:error, :enoent} -> + # The custom emoji directory doesn't exist, + # don't do anything + [] + + {:error, e} -> + # There was some other error + Logger.error("Could not access the custom emoji directory #{emoji_dir_path}: #{e}") + [] + + {:ok, results} -> + grouped = + Enum.group_by(results, fn file -> + File.dir?(Path.join(emoji_dir_path, file)) + end) + + packs = grouped[true] || [] + files = grouped[false] || [] + + # Print the packs we've found + Logger.info("Found emoji packs: #{Enum.join(packs, ", ")}") + + if not Enum.empty?(files) do + Logger.warn( + "Found files in the emoji folder. These will be ignored, please move them to a subdirectory\nFound files: #{ + Enum.join(files, ", ") + }" + ) + end + + emojis = + Enum.flat_map(packs, fn pack -> + load_pack(Path.join(emoji_dir_path, pack), emoji_groups) + end) + + Emoji.clear_all() + emojis + end + + # Compat thing for old custom emoji handling & default emoji, + # it should run even if there are no emoji packs + shortcode_globs = Config.get([:emoji, :shortcode_globs], []) + + emojis_txt = + (load_from_file("config/emoji.txt", emoji_groups) ++ + load_from_file("config/custom_emoji.txt", emoji_groups) ++ + load_from_globs(shortcode_globs, emoji_groups)) + |> Enum.reject(fn value -> value == nil end) + + Enum.map(emojis ++ emojis_txt, &prepare_emoji/1) + end + + defp prepare_emoji({code, _, _} = emoji), do: {code, Emoji.build(emoji)} + + defp load_pack(pack_dir, emoji_groups) do + pack_name = Path.basename(pack_dir) + + pack_file = Path.join(pack_dir, "pack.json") + + if File.exists?(pack_file) do + contents = Jason.decode!(File.read!(pack_file)) + + contents["files"] + |> Enum.map(fn {name, rel_file} -> + filename = Path.join("/emoji/#{pack_name}", rel_file) + {name, filename, ["pack:#{pack_name}"]} + end) + else + # Load from emoji.txt / all files + emoji_txt = Path.join(pack_dir, "emoji.txt") + + if File.exists?(emoji_txt) do + load_from_file(emoji_txt, emoji_groups) + else + extensions = Pleroma.Config.get([:emoji, :pack_extensions]) + + Logger.info( + "No emoji.txt found for pack \"#{pack_name}\", assuming all #{ + Enum.join(extensions, ", ") + } files are emoji" + ) + + make_shortcode_to_file_map(pack_dir, extensions) + |> Enum.map(fn {shortcode, rel_file} -> + filename = Path.join("/emoji/#{pack_name}", rel_file) + + {shortcode, filename, [to_string(match_extra(emoji_groups, filename))]} + end) + end + end + end + + def make_shortcode_to_file_map(pack_dir, exts) do + find_all_emoji(pack_dir, exts) + |> Enum.map(&Path.relative_to(&1, pack_dir)) + |> Enum.map(fn f -> {f |> Path.basename() |> Path.rootname(), f} end) + |> Enum.into(%{}) + end + + def find_all_emoji(dir, exts) do + dir + |> File.ls!() + |> Enum.flat_map(fn f -> + filepath = Path.join(dir, f) + + if File.dir?(filepath) do + find_all_emoji(filepath, exts) + else + [filepath] + end + end) + |> Enum.filter(fn f -> Path.extname(f) in exts end) + end + + defp load_from_file(file, emoji_groups) do + if File.exists?(file) do + load_from_file_stream(File.stream!(file), emoji_groups) + else + [] + end + end + + defp load_from_file_stream(stream, emoji_groups) do + stream + |> Stream.map(&String.trim/1) + |> Stream.map(fn line -> + case String.split(line, ~r/,\s*/) do + [name, file] -> + {name, file, [to_string(match_extra(emoji_groups, file))]} + + [name, file | tags] -> + {name, file, tags} + + _ -> + nil + end + end) + |> Enum.to_list() + end + + defp load_from_globs(globs, emoji_groups) do + static_path = Path.join(:code.priv_dir(:pleroma), "static") + + paths = + Enum.map(globs, fn glob -> + Path.join(static_path, glob) + |> Path.wildcard() + end) + |> Enum.concat() + + Enum.map(paths, fn path -> + tag = match_extra(emoji_groups, Path.join("/", Path.relative_to(path, static_path))) + shortcode = Path.basename(path, Path.extname(path)) + external_path = Path.join("/", Path.relative_to(path, static_path)) + {shortcode, external_path, [to_string(tag)]} + end) + end + + @doc """ + Finds a matching group for the given emoji filename + """ + @spec match_extra(group_patterns(), String.t()) :: atom() | nil + def match_extra(group_patterns, filename) do + match_group_patterns(group_patterns, fn pattern -> + case pattern do + %Regex{} = regex -> Regex.match?(regex, filename) + string when is_binary(string) -> filename == string + end + end) + end + + defp match_group_patterns(group_patterns, matcher) do + Enum.find_value(group_patterns, fn {group, patterns} -> + patterns = + patterns + |> List.wrap() + |> Enum.map(fn pattern -> + if String.contains?(pattern, "*") do + ~r(#{String.replace(pattern, "*", ".*")}) + else + pattern + end + end) + + Enum.any?(patterns, matcher) && group + end) + end +end diff --git a/lib/pleroma/filter.ex b/lib/pleroma/filter.ex index 90457dadf..c87141582 100644 --- a/lib/pleroma/filter.ex +++ b/lib/pleroma/filter.ex @@ -12,7 +12,7 @@ defmodule Pleroma.Filter do alias Pleroma.User schema "filters" do - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) field(:filter_id, :integer) field(:hide, :boolean, default: false) field(:whole_word, :boolean, default: true) diff --git a/lib/pleroma/flake_id.ex b/lib/pleroma/flake_id.ex deleted file mode 100644 index 47d61ca5f..000000000 --- a/lib/pleroma/flake_id.ex +++ /dev/null @@ -1,182 +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.FlakeId do - @moduledoc """ - Flake is a decentralized, k-ordered id generation service. - - Adapted from: - - * [flaky](https://github.com/nirvana/flaky), released under the terms of the Truly Free License, - * [Flake](https://github.com/boundary/flake), Copyright 2012, Boundary, Apache License, Version 2.0 - """ - - @type t :: binary - - @behaviour Ecto.Type - use GenServer - require Logger - alias __MODULE__ - import Kernel, except: [to_string: 1] - - defstruct node: nil, time: 0, sq: 0 - - @doc "Converts a binary Flake to a String" - def to_string(<<0::integer-size(64), id::integer-size(64)>>) do - Kernel.to_string(id) - end - - def to_string(<<_::integer-size(64), _::integer-size(48), _::integer-size(16)>> = flake) do - encode_base62(flake) - end - - def to_string(s), do: s - - def from_string(int) when is_integer(int) do - from_string(Kernel.to_string(int)) - end - - for i <- [-1, 0] do - def from_string(unquote(i)), do: <<0::integer-size(128)>> - def from_string(unquote(Kernel.to_string(i))), do: <<0::integer-size(128)>> - end - - def from_string(<<_::integer-size(128)>> = flake), do: flake - - def from_string(string) when is_binary(string) and byte_size(string) < 18 do - case Integer.parse(string) do - {id, ""} -> <<0::integer-size(64), id::integer-size(64)>> - _ -> nil - end - end - - def from_string(string) do - string |> decode_base62 |> from_integer - end - - def to_integer(<<integer::integer-size(128)>>), do: integer - - def from_integer(integer) do - <<_time::integer-size(64), _node::integer-size(48), _seq::integer-size(16)>> = - <<integer::integer-size(128)>> - end - - @doc "Generates a Flake" - @spec get :: binary - def get, do: to_string(:gen_server.call(:flake, :get)) - - # checks that ID is is valid FlakeID - # - @spec is_flake_id?(String.t()) :: boolean - def is_flake_id?(id), do: is_flake_id?(String.to_charlist(id), true) - defp is_flake_id?([c | cs], true) when c >= ?0 and c <= ?9, do: is_flake_id?(cs, true) - defp is_flake_id?([c | cs], true) when c >= ?A and c <= ?Z, do: is_flake_id?(cs, true) - defp is_flake_id?([c | cs], true) when c >= ?a and c <= ?z, do: is_flake_id?(cs, true) - defp is_flake_id?([], true), do: true - defp is_flake_id?(_, _), do: false - - # -- Ecto.Type API - @impl Ecto.Type - def type, do: :uuid - - @impl Ecto.Type - def cast(value) do - {:ok, FlakeId.to_string(value)} - end - - @impl Ecto.Type - def load(value) do - {:ok, FlakeId.to_string(value)} - end - - @impl Ecto.Type - def dump(value) do - {:ok, FlakeId.from_string(value)} - end - - def autogenerate, do: get() - - # -- GenServer API - def start_link(_) do - :gen_server.start_link({:local, :flake}, __MODULE__, [], []) - end - - @impl GenServer - def init([]) do - {:ok, %FlakeId{node: worker_id(), time: time()}} - end - - @impl GenServer - def handle_call(:get, _from, state) do - {flake, new_state} = get(time(), state) - {:reply, flake, new_state} - end - - # Matches when the calling time is the same as the state time. Incr. sq - defp get(time, %FlakeId{time: time, node: node, sq: seq}) do - new_state = %FlakeId{time: time, node: node, sq: seq + 1} - {gen_flake(new_state), new_state} - end - - # Matches when the times are different, reset sq - defp get(newtime, %FlakeId{time: time, node: node}) when newtime > time do - new_state = %FlakeId{time: newtime, node: node, sq: 0} - {gen_flake(new_state), new_state} - end - - # Error when clock is running backwards - defp get(newtime, %FlakeId{time: time}) when newtime < time do - {:error, :clock_running_backwards} - end - - defp gen_flake(%FlakeId{time: time, node: node, sq: seq}) do - <<time::integer-size(64), node::integer-size(48), seq::integer-size(16)>> - end - - defp nthchar_base62(n) when n <= 9, do: ?0 + n - defp nthchar_base62(n) when n <= 35, do: ?A + n - 10 - defp nthchar_base62(n), do: ?a + n - 36 - - defp encode_base62(<<integer::integer-size(128)>>) do - integer - |> encode_base62([]) - |> List.to_string() - end - - defp encode_base62(int, acc) when int < 0, do: encode_base62(-int, acc) - defp encode_base62(int, []) when int == 0, do: '0' - defp encode_base62(int, acc) when int == 0, do: acc - - defp encode_base62(int, acc) do - r = rem(int, 62) - id = div(int, 62) - acc = [nthchar_base62(r) | acc] - encode_base62(id, acc) - end - - defp decode_base62(s) do - decode_base62(String.to_charlist(s), 0) - end - - defp decode_base62([c | cs], acc) when c >= ?0 and c <= ?9, - do: decode_base62(cs, 62 * acc + (c - ?0)) - - defp decode_base62([c | cs], acc) when c >= ?A and c <= ?Z, - do: decode_base62(cs, 62 * acc + (c - ?A + 10)) - - defp decode_base62([c | cs], acc) when c >= ?a and c <= ?z, - do: decode_base62(cs, 62 * acc + (c - ?a + 36)) - - defp decode_base62([], acc), do: acc - - defp time do - {mega_seconds, seconds, micro_seconds} = :erlang.timestamp() - 1_000_000_000 * mega_seconds + seconds * 1000 + :erlang.trunc(micro_seconds / 1000) - end - - defp worker_id do - <<worker::integer-size(48)>> = :crypto.strong_rand_bytes(6) - worker - end -end diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index 607843a5b..931b9af2b 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -3,10 +3,8 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Formatter do - alias Pleroma.Emoji alias Pleroma.HTML alias Pleroma.User - alias Pleroma.Web.MediaProxy @safe_mention_regex ~r/^(\s*(?<mentions>(@.+?\s+){1,})+)(?<rest>.*)/s @link_regex ~r"((?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~%:/?#[\]@!\$&'\(\)\*\+,;=.]+)|[0-9a-z+\-\.]+:[0-9a-z$-_.+!*'(),]+"ui @@ -36,9 +34,9 @@ defmodule Pleroma.Formatter do nickname_text = get_nickname_text(nickname, opts) link = - "<span class='h-card'><a data-user='#{id}' class='u-url mention' href='#{ap_id}'>@<span>#{ + ~s(<span class="h-card"><a data-user="#{id}" class="u-url mention" href="#{ap_id}" rel="ugc">@<span>#{ nickname_text - }</span></a></span>" + }</span></a></span>) {link, %{acc | mentions: MapSet.put(acc.mentions, {"@" <> nickname, user})}} @@ -50,7 +48,7 @@ defmodule Pleroma.Formatter do def hashtag_handler("#" <> tag = tag_text, _buffer, _opts, acc) do tag = String.downcase(tag) url = "#{Pleroma.Web.base_url()}/tag/#{tag}" - link = "<a class='hashtag' data-tag='#{tag}' href='#{url}' rel='tag'>#{tag_text}</a>" + link = ~s(<a class="hashtag" data-tag="#{tag}" href="#{url}" rel="tag ugc">#{tag_text}</a>) {link, %{acc | tags: MapSet.put(acc.tags, {tag_text, tag})}} end @@ -100,51 +98,6 @@ defmodule Pleroma.Formatter do end end - def emojify(text) do - emojify(text, Emoji.get_all()) - end - - def emojify(text, nil), do: text - - def emojify(text, emoji, strip \\ false) do - Enum.reduce(emoji, text, fn emoji_data, text -> - emoji = HTML.strip_tags(elem(emoji_data, 0)) - file = HTML.strip_tags(elem(emoji_data, 1)) - - html = - if not strip do - "<img class='emoji' alt='#{emoji}' title='#{emoji}' src='#{MediaProxy.url(file)}' />" - else - "" - end - - String.replace(text, ":#{emoji}:", html) |> HTML.filter_tags() - end) - end - - def demojify(text) do - emojify(text, Emoji.get_all(), true) - end - - def demojify(text, nil), do: text - - @doc "Outputs a list of the emoji-shortcodes in a text" - def get_emoji(text) when is_binary(text) do - Enum.filter(Emoji.get_all(), fn {emoji, _, _} -> String.contains?(text, ":#{emoji}:") end) - end - - def get_emoji(_), do: [] - - @doc "Outputs a list of the emoji-Maps in a text" - def get_emoji_map(text) when is_binary(text) do - get_emoji(text) - |> Enum.reduce(%{}, fn {name, file, _group}, acc -> - Map.put(acc, name, "#{Pleroma.Web.Endpoint.static_url()}#{file}") - end) - end - - def get_emoji_map(_), do: [] - def html_escape({text, mentions, hashtags}, type) do {html_escape(text, type), mentions, hashtags} end diff --git a/lib/healthcheck.ex b/lib/pleroma/healthcheck.ex index f97d14432..fc2129815 100644 --- a/lib/healthcheck.ex +++ b/lib/pleroma/healthcheck.ex @@ -9,10 +9,12 @@ defmodule Pleroma.Healthcheck do alias Pleroma.Healthcheck alias Pleroma.Repo + @derive Jason.Encoder defstruct pool_size: 0, active: 0, idle: 0, memory_used: 0, + job_queue_stats: nil, healthy: true @type t :: %__MODULE__{ @@ -20,6 +22,7 @@ defmodule Pleroma.Healthcheck do active: non_neg_integer(), idle: non_neg_integer(), memory_used: number(), + job_queue_stats: map(), healthy: boolean() } @@ -29,6 +32,7 @@ defmodule Pleroma.Healthcheck do memory_used: Float.round(:erlang.memory(:total) / 1024 / 1024, 2) } |> assign_db_info() + |> assign_job_queue_stats() |> check_health() end @@ -54,6 +58,11 @@ defmodule Pleroma.Healthcheck do Map.merge(healthcheck, db_info) end + defp assign_job_queue_stats(healthcheck) do + stats = Pleroma.JobQueueMonitor.stats() + Map.put(healthcheck, :job_queue_stats, stats) + end + @spec check_health(Healthcheck.t()) :: Healthcheck.t() def check_health(%{pool_size: pool_size, active: active} = check) when active >= pool_size do diff --git a/lib/pleroma/html.ex b/lib/pleroma/html.ex index 3951f0f51..937bafed5 100644 --- a/lib/pleroma/html.ex +++ b/lib/pleroma/html.ex @@ -184,7 +184,8 @@ defmodule Pleroma.HTML.Scrubber.Default do "tag", "nofollow", "noopener", - "noreferrer" + "noreferrer", + "ugc" ]) Meta.allow_tag_with_these_attributes("a", ["name", "title"]) @@ -304,7 +305,8 @@ defmodule Pleroma.HTML.Scrubber.LinksOnly do "nofollow", "noopener", "noreferrer", - "me" + "me", + "ugc" ]) Meta.allow_tag_with_these_attributes("a", ["name", "title"]) diff --git a/lib/pleroma/instances/instance.ex b/lib/pleroma/instances/instance.ex index 4d7ed4ca1..544c4b687 100644 --- a/lib/pleroma/instances/instance.ex +++ b/lib/pleroma/instances/instance.ex @@ -90,7 +90,7 @@ defmodule Pleroma.Instances.Instance do def set_unreachable(url_or_host, unreachable_since \\ nil) def set_unreachable(url_or_host, unreachable_since) when is_binary(url_or_host) do - unreachable_since = unreachable_since || DateTime.utc_now() + unreachable_since = parse_datetime(unreachable_since) || NaiveDateTime.utc_now() host = host(url_or_host) existing_record = Repo.get_by(Instance, %{host: host}) @@ -114,4 +114,10 @@ defmodule Pleroma.Instances.Instance do end def set_unreachable(_, _), do: {:error, nil} + + defp parse_datetime(datetime) when is_binary(datetime) do + NaiveDateTime.from_iso8601(datetime) + end + + defp parse_datetime(datetime), do: datetime end diff --git a/lib/pleroma/job_queue_monitor.ex b/lib/pleroma/job_queue_monitor.ex new file mode 100644 index 000000000..3feea8381 --- /dev/null +++ b/lib/pleroma/job_queue_monitor.ex @@ -0,0 +1,78 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.JobQueueMonitor do + use GenServer + + @initial_state %{workers: %{}, queues: %{}, processed_jobs: 0} + @queue %{processed_jobs: 0, success: 0, failure: 0} + @operation %{processed_jobs: 0, success: 0, failure: 0} + + def start_link(_) do + GenServer.start_link(__MODULE__, @initial_state, name: __MODULE__) + end + + @impl true + def init(state) do + :telemetry.attach("oban-monitor-failure", [:oban, :failure], &handle_event/4, nil) + :telemetry.attach("oban-monitor-success", [:oban, :success], &handle_event/4, nil) + + {:ok, state} + end + + def stats do + GenServer.call(__MODULE__, :stats) + end + + def handle_event([:oban, status], %{duration: duration}, meta, _) do + GenServer.cast(__MODULE__, {:process_event, status, duration, meta}) + end + + @impl true + def handle_call(:stats, _from, state) do + {:reply, state, state} + end + + @impl true + def handle_cast({:process_event, status, duration, meta}, state) do + state = + state + |> Map.update!(:workers, fn workers -> + workers + |> Map.put_new(meta.worker, %{}) + |> Map.update!(meta.worker, &update_worker(&1, status, meta, duration)) + end) + |> Map.update!(:queues, fn workers -> + workers + |> Map.put_new(meta.queue, @queue) + |> Map.update!(meta.queue, &update_queue(&1, status, meta, duration)) + end) + |> Map.update!(:processed_jobs, &(&1 + 1)) + + {:noreply, state} + end + + defp update_worker(worker, status, meta, duration) do + worker + |> Map.put_new(meta.args["op"], @operation) + |> Map.update!(meta.args["op"], &update_op(&1, status, meta, duration)) + end + + defp update_op(op, :enqueue, _meta, _duration) do + op + |> Map.update!(:enqueued, &(&1 + 1)) + end + + defp update_op(op, status, _meta, _duration) do + op + |> Map.update!(:processed_jobs, &(&1 + 1)) + |> Map.update!(status, &(&1 + 1)) + end + + defp update_queue(queue, status, _meta, _duration) do + queue + |> Map.update!(:processed_jobs, &(&1 + 1)) + |> Map.update!(status, &(&1 + 1)) + end +end diff --git a/lib/pleroma/list.ex b/lib/pleroma/list.ex index c572380c2..08a94c62c 100644 --- a/lib/pleroma/list.ex +++ b/lib/pleroma/list.ex @@ -13,7 +13,7 @@ defmodule Pleroma.List do alias Pleroma.User schema "lists" do - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) field(:title, :string) field(:following, {:array, :string}, default: []) field(:ap_id, :string) @@ -84,22 +84,11 @@ defmodule Pleroma.List do end # Get lists to which the account belongs. - def get_lists_account_belongs(%User{} = owner, account_id) do - user = User.get_cached_by_id(account_id) - - query = - from( - l in Pleroma.List, - where: - l.user_id == ^owner.id and - fragment( - "? = ANY(?)", - ^user.follower_address, - l.following - ) - ) - - Repo.all(query) + def get_lists_account_belongs(%User{} = owner, user) do + Pleroma.List + |> where([l], l.user_id == ^owner.id) + |> where([l], fragment("? = ANY(?)", ^user.follower_address, l.following)) + |> Repo.all() end def rename(%Pleroma.List{} = list, title) do diff --git a/lib/pleroma/moderation_log.ex b/lib/pleroma/moderation_log.ex index 1ef6fe67a..352cad433 100644 --- a/lib/pleroma/moderation_log.ex +++ b/lib/pleroma/moderation_log.ex @@ -14,61 +14,143 @@ defmodule Pleroma.ModerationLog do timestamps() end - def get_all(page, page_size) do - from(q in __MODULE__, - order_by: [desc: q.inserted_at], + def get_all(params) do + base_query = + get_all_query() + |> maybe_filter_by_date(params) + |> maybe_filter_by_user(params) + |> maybe_filter_by_search(params) + + query_with_pagination = base_query |> paginate_query(params) + + %{ + items: Repo.all(query_with_pagination), + count: Repo.aggregate(base_query, :count, :id) + } + end + + defp maybe_filter_by_date(query, %{start_date: nil, end_date: nil}), do: query + + defp maybe_filter_by_date(query, %{start_date: start_date, end_date: nil}) do + from(q in query, + where: q.inserted_at >= ^parse_datetime(start_date) + ) + end + + defp maybe_filter_by_date(query, %{start_date: nil, end_date: end_date}) do + from(q in query, + where: q.inserted_at <= ^parse_datetime(end_date) + ) + end + + defp maybe_filter_by_date(query, %{start_date: start_date, end_date: end_date}) do + from(q in query, + where: q.inserted_at >= ^parse_datetime(start_date), + where: q.inserted_at <= ^parse_datetime(end_date) + ) + end + + defp maybe_filter_by_user(query, %{user_id: nil}), do: query + + defp maybe_filter_by_user(query, %{user_id: user_id}) do + from(q in query, + where: fragment("(?)->'actor'->>'id' = ?", q.data, ^user_id) + ) + end + + defp maybe_filter_by_search(query, %{search: search}) when is_nil(search) or search == "", + do: query + + defp maybe_filter_by_search(query, %{search: search}) do + from(q in query, + where: fragment("(?)->>'message' ILIKE ?", q.data, ^"%#{search}%") + ) + end + + defp paginate_query(query, %{page: page, page_size: page_size}) do + from(q in query, limit: ^page_size, offset: ^((page - 1) * page_size) ) - |> Repo.all() end + defp get_all_query do + from(q in __MODULE__, + order_by: [desc: q.inserted_at] + ) + end + + defp parse_datetime(datetime) do + {:ok, parsed_datetime, _} = DateTime.from_iso8601(datetime) + + parsed_datetime + end + + @spec insert_log(%{actor: User, subject: User, action: String.t(), permission: String.t()}) :: + {:ok, ModerationLog} | {:error, any} def insert_log(%{ actor: %User{} = actor, subject: %User{} = subject, action: action, permission: permission }) do - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - subject: user_to_map(subject), - action: action, - permission: permission + "actor" => user_to_map(actor), + "subject" => user_to_map(subject), + "action" => action, + "permission" => permission, + "message" => "" } - }) + } + |> insert_log_entry_with_message() end + @spec insert_log(%{actor: User, subject: User, action: String.t()}) :: + {:ok, ModerationLog} | {:error, any} def insert_log(%{ actor: %User{} = actor, action: "report_update", subject: %Activity{data: %{"type" => "Flag"}} = subject }) do - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - action: "report_update", - subject: report_to_map(subject) + "actor" => user_to_map(actor), + "action" => "report_update", + "subject" => report_to_map(subject), + "message" => "" } - }) + } + |> insert_log_entry_with_message() end + @spec insert_log(%{actor: User, subject: Activity, action: String.t(), text: String.t()}) :: + {:ok, ModerationLog} | {:error, any} def insert_log(%{ actor: %User{} = actor, action: "report_response", subject: %Activity{} = subject, text: text }) do - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - action: "report_response", - subject: report_to_map(subject), - text: text + "actor" => user_to_map(actor), + "action" => "report_response", + "subject" => report_to_map(subject), + "text" => text, + "message" => "" } - }) + } + |> insert_log_entry_with_message() end + @spec insert_log(%{ + actor: User, + subject: Activity, + action: String.t(), + sensitive: String.t(), + visibility: String.t() + }) :: {:ok, ModerationLog} | {:error, any} def insert_log(%{ actor: %User{} = actor, action: "status_update", @@ -76,41 +158,49 @@ defmodule Pleroma.ModerationLog do sensitive: sensitive, visibility: visibility }) do - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - action: "status_update", - subject: status_to_map(subject), - sensitive: sensitive, - visibility: visibility + "actor" => user_to_map(actor), + "action" => "status_update", + "subject" => status_to_map(subject), + "sensitive" => sensitive, + "visibility" => visibility, + "message" => "" } - }) + } + |> insert_log_entry_with_message() end + @spec insert_log(%{actor: User, action: String.t(), subject_id: String.t()}) :: + {:ok, ModerationLog} | {:error, any} def insert_log(%{ actor: %User{} = actor, action: "status_delete", subject_id: subject_id }) do - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - action: "status_delete", - subject_id: subject_id + "actor" => user_to_map(actor), + "action" => "status_delete", + "subject_id" => subject_id, + "message" => "" } - }) + } + |> insert_log_entry_with_message() end @spec insert_log(%{actor: User, subject: User, action: String.t()}) :: {:ok, ModerationLog} | {:error, any} def insert_log(%{actor: %User{} = actor, subject: subject, action: action}) do - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - action: action, - subject: user_to_map(subject) + "actor" => user_to_map(actor), + "action" => action, + "subject" => user_to_map(subject), + "message" => "" } - }) + } + |> insert_log_entry_with_message() end @spec insert_log(%{actor: User, subjects: [User], action: String.t()}) :: @@ -118,97 +208,128 @@ defmodule Pleroma.ModerationLog do def insert_log(%{actor: %User{} = actor, subjects: subjects, action: action}) do subjects = Enum.map(subjects, &user_to_map/1) - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - action: action, - subjects: subjects + "actor" => user_to_map(actor), + "action" => action, + "subjects" => subjects, + "message" => "" } - }) + } + |> insert_log_entry_with_message() end + @spec insert_log(%{actor: User, action: String.t(), followed: User, follower: User}) :: + {:ok, ModerationLog} | {:error, any} def insert_log(%{ actor: %User{} = actor, followed: %User{} = followed, follower: %User{} = follower, action: "follow" }) do - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - action: "follow", - followed: user_to_map(followed), - follower: user_to_map(follower) + "actor" => user_to_map(actor), + "action" => "follow", + "followed" => user_to_map(followed), + "follower" => user_to_map(follower), + "message" => "" } - }) + } + |> insert_log_entry_with_message() end + @spec insert_log(%{actor: User, action: String.t(), followed: User, follower: User}) :: + {:ok, ModerationLog} | {:error, any} def insert_log(%{ actor: %User{} = actor, followed: %User{} = followed, follower: %User{} = follower, action: "unfollow" }) do - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - action: "unfollow", - followed: user_to_map(followed), - follower: user_to_map(follower) + "actor" => user_to_map(actor), + "action" => "unfollow", + "followed" => user_to_map(followed), + "follower" => user_to_map(follower), + "message" => "" } - }) + } + |> insert_log_entry_with_message() end + @spec insert_log(%{ + actor: User, + action: String.t(), + nicknames: [String.t()], + tags: [String.t()] + }) :: {:ok, ModerationLog} | {:error, any} def insert_log(%{ actor: %User{} = actor, nicknames: nicknames, tags: tags, action: action }) do - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - nicknames: nicknames, - tags: tags, - action: action + "actor" => user_to_map(actor), + "nicknames" => nicknames, + "tags" => tags, + "action" => action, + "message" => "" } - }) + } + |> insert_log_entry_with_message() end + @spec insert_log(%{actor: User, action: String.t(), target: String.t()}) :: + {:ok, ModerationLog} | {:error, any} def insert_log(%{ actor: %User{} = actor, action: action, target: target }) when action in ["relay_follow", "relay_unfollow"] do - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - action: action, - target: target + "actor" => user_to_map(actor), + "action" => action, + "target" => target, + "message" => "" } - }) + } + |> insert_log_entry_with_message() + end + + @spec insert_log_entry_with_message(ModerationLog) :: {:ok, ModerationLog} | {:error, any} + + defp insert_log_entry_with_message(entry) do + entry.data["message"] + |> put_in(get_log_entry_message(entry)) + |> Repo.insert() end defp user_to_map(%User{} = user) do user |> Map.from_struct() |> Map.take([:id, :nickname]) - |> Map.put(:type, "user") + |> Map.new(fn {k, v} -> {Atom.to_string(k), v} end) + |> Map.put("type", "user") end defp report_to_map(%Activity{} = report) do %{ - type: "report", - id: report.id, - state: report.data["state"] + "type" => "report", + "id" => report.id, + "state" => report.data["state"] } end defp status_to_map(%Activity{} = status) do %{ - type: "status", - id: status.id + "type" => "status", + "id" => status.id } end diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index b7c880c51..d94ae5971 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -22,8 +22,8 @@ defmodule Pleroma.Notification do schema "notifications" do field(:seen, :boolean, default: false) - belongs_to(:user, User, type: Pleroma.FlakeId) - belongs_to(:activity, Activity, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType) timestamps() end @@ -210,8 +210,10 @@ defmodule Pleroma.Notification do unless skip?(activity, user) do notification = %Notification{user_id: user.id, activity: activity} {:ok, notification} = Repo.insert(notification) - Streamer.stream("user", notification) - Streamer.stream("user:notification", notification) + + ["user", "user:notification"] + |> Streamer.stream(notification) + Push.send(notification) notification end diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index d58eb7f7d..cdfbacb0e 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -38,6 +38,24 @@ defmodule Pleroma.Object do def get_by_id(nil), do: nil def get_by_id(id), do: Repo.get(Object, id) + def get_by_id_and_maybe_refetch(id, opts \\ []) do + %{updated_at: updated_at} = object = get_by_id(id) + + if opts[:interval] && + NaiveDateTime.diff(NaiveDateTime.utc_now(), updated_at) > opts[:interval] do + case Fetcher.refetch_object(object) do + {:ok, %Object{} = object} -> + object + + e -> + Logger.error("Couldn't refresh #{object.data["id"]}:\n#{inspect(e)}") + object + end + else + object + end + end + def get_by_ap_id(nil), do: nil def get_by_ap_id(ap_id) do @@ -130,14 +148,16 @@ defmodule Pleroma.Object do def delete(%Object{data: %{"id" => id}} = object) do with {:ok, _obj} = swap_object_with_tombstone(object), deleted_activity = Activity.delete_by_ap_id(id), - {:ok, true} <- Cachex.del(:object_cache, "object:#{id}") do + {:ok, true} <- Cachex.del(:object_cache, "object:#{id}"), + {:ok, _} <- Cachex.del(:web_resp_cache, URI.parse(id).path) do {:ok, object, deleted_activity} end end def prune(%Object{data: %{"id" => id}} = object) do with {:ok, object} <- Repo.delete(object), - {:ok, true} <- Cachex.del(:object_cache, "object:#{id}") do + {:ok, true} <- Cachex.del(:object_cache, "object:#{id}"), + {:ok, _} <- Cachex.del(:web_resp_cache, URI.parse(id).path) do {:ok, object} end end @@ -228,4 +248,11 @@ defmodule Pleroma.Object do _ -> :noop end end + + @doc "Updates data field of an object" + def update_data(%Object{data: data} = object, attrs \\ %{}) do + object + |> Object.change(%{data: Map.merge(data || %{}, attrs)}) + |> Repo.update() + end end diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index c1795ae0f..5e064fd87 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -6,18 +6,40 @@ defmodule Pleroma.Object.Fetcher do alias Pleroma.HTTP alias Pleroma.Object alias Pleroma.Object.Containment + alias Pleroma.Repo alias Pleroma.Signature alias Pleroma.Web.ActivityPub.InternalFetchActor alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.OStatus require Logger + require Pleroma.Constants - defp reinject_object(data) do + defp touch_changeset(changeset) do + updated_at = + NaiveDateTime.utc_now() + |> NaiveDateTime.truncate(:second) + + Ecto.Changeset.put_change(changeset, :updated_at, updated_at) + end + + defp maybe_reinject_internal_fields(data, %{data: %{} = old_data}) do + internal_fields = Map.take(old_data, Pleroma.Constants.object_internal_fields()) + + Map.merge(data, internal_fields) + end + + defp maybe_reinject_internal_fields(data, _), do: data + + @spec reinject_object(struct(), map()) :: {:ok, Object.t()} | {:error, any()} + defp reinject_object(struct, data) do Logger.debug("Reinjecting object #{data["id"]}") with data <- Transmogrifier.fix_object(data), - {:ok, object} <- Object.create(data) do + data <- maybe_reinject_internal_fields(data, struct), + changeset <- Object.change(struct, %{data: data}), + changeset <- touch_changeset(changeset), + {:ok, object} <- Repo.insert_or_update(changeset) do {:ok, object} else e -> @@ -26,55 +48,68 @@ defmodule Pleroma.Object.Fetcher do end end + def refetch_object(%Object{data: %{"id" => id}} = object) do + with {:local, false} <- {:local, String.starts_with?(id, Pleroma.Web.base_url() <> "/")}, + {:ok, data} <- fetch_and_contain_remote_object_from_id(id), + {:ok, object} <- reinject_object(object, data) do + {:ok, object} + else + {:local, true} -> object + e -> {:error, e} + end + end + # TODO: # This will create a Create activity, which we need internally at the moment. def fetch_object_from_id(id, options \\ []) do - if object = Object.get_cached_by_ap_id(id) do + with {:fetch_object, nil} <- {:fetch_object, Object.get_cached_by_ap_id(id)}, + {:fetch, {:ok, data}} <- {:fetch, fetch_and_contain_remote_object_from_id(id)}, + {:normalize, nil} <- {:normalize, Object.normalize(data, false)}, + params <- prepare_activity_params(data), + {:containment, :ok} <- {:containment, Containment.contain_origin(id, params)}, + {:ok, activity} <- Transmogrifier.handle_incoming(params, options), + {:object, _data, %Object{} = object} <- + {:object, data, Object.normalize(activity, false)} do {:ok, object} else - Logger.info("Fetching #{id} via AP") - - with {:fetch, {:ok, data}} <- {:fetch, fetch_and_contain_remote_object_from_id(id)}, - {:normalize, nil} <- {:normalize, Object.normalize(data, false)}, - params <- %{ - "type" => "Create", - "to" => data["to"], - "cc" => data["cc"], - # Should we seriously keep this attributedTo thing? - "actor" => data["actor"] || data["attributedTo"], - "object" => data - }, - {:containment, :ok} <- {:containment, Containment.contain_origin(id, params)}, - {:ok, activity} <- Transmogrifier.handle_incoming(params, options), - {:object, _data, %Object{} = object} <- - {:object, data, Object.normalize(activity, false)} do - {:ok, object} - else - {:containment, _} -> - {:error, "Object containment failed."} + {:containment, _} -> + {:error, "Object containment failed."} - {:error, {:reject, nil}} -> - {:reject, nil} + {:error, {:reject, nil}} -> + {:reject, nil} - {:object, data, nil} -> - reinject_object(data) + {:object, data, nil} -> + reinject_object(%Object{}, data) - {:normalize, object = %Object{}} -> - {:ok, object} + {:normalize, object = %Object{}} -> + {:ok, object} - _e -> - # Only fallback when receiving a fetch/normalization error with ActivityPub - Logger.info("Couldn't get object via AP, trying out OStatus fetching...") + {:fetch_object, %Object{} = object} -> + {:ok, object} - # FIXME: OStatus Object Containment? - case OStatus.fetch_activity_from_url(id) do - {:ok, [activity | _]} -> {:ok, Object.normalize(activity, false)} - e -> e - end - end + _e -> + # Only fallback when receiving a fetch/normalization error with ActivityPub + Logger.info("Couldn't get object via AP, trying out OStatus fetching...") + + # FIXME: OStatus Object Containment? + case OStatus.fetch_activity_from_url(id) do + {:ok, [activity | _]} -> {:ok, Object.normalize(activity, false)} + e -> e + end end end + defp prepare_activity_params(data) do + %{ + "type" => "Create", + "to" => data["to"], + "cc" => data["cc"], + # Should we seriously keep this attributedTo thing? + "actor" => data["actor"] || data["attributedTo"], + "object" => data + } + end + def fetch_object_from_id!(id, options \\ []) do with {:ok, object} <- fetch_object_from_id(id, options) do object diff --git a/lib/pleroma/pagination.ex b/lib/pleroma/pagination.ex index b55379c4a..9d279fba7 100644 --- a/lib/pleroma/pagination.ex +++ b/lib/pleroma/pagination.ex @@ -64,6 +64,7 @@ defmodule Pleroma.Pagination do def paginate(query, options, :offset) do query + |> restrict(:order, options) |> restrict(:offset, options) |> restrict(:limit, options) end diff --git a/lib/pleroma/password_reset_token.ex b/lib/pleroma/password_reset_token.ex index 4a833f6a5..db398b1fc 100644 --- a/lib/pleroma/password_reset_token.ex +++ b/lib/pleroma/password_reset_token.ex @@ -12,7 +12,7 @@ defmodule Pleroma.PasswordResetToken do alias Pleroma.User schema "password_reset_tokens" do - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) field(:token, :string) field(:used, :boolean, default: false) diff --git a/lib/pleroma/plugs/cache.ex b/lib/pleroma/plugs/cache.ex new file mode 100644 index 000000000..50b534e7b --- /dev/null +++ b/lib/pleroma/plugs/cache.ex @@ -0,0 +1,136 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Plugs.Cache do + @moduledoc """ + Caches successful GET responses. + + To enable the cache add the plug to a router pipeline or controller: + + plug(Pleroma.Plugs.Cache) + + ## Configuration + + To configure the plug you need to pass settings as the second argument to the `plug/2` macro: + + plug(Pleroma.Plugs.Cache, [ttl: nil, query_params: true]) + + Available options: + + - `ttl`: An expiration time (time-to-live). This value should be in milliseconds or `nil` to disable expiration. Defaults to `nil`. + - `query_params`: Take URL query string into account (`true`), ignore it (`false`) or limit to specific params only (list). Defaults to `true`. + - `tracking_fun`: A function that is called on successfull responses, no matter if the request is cached or not. It should accept a conn as the first argument and the value assigned to `tracking_fun_data` as the second. + + Additionally, you can overwrite the TTL inside a controller action by assigning `cache_ttl` to the connection struct: + + def index(conn, _params) do + ttl = 60_000 # one minute + + conn + |> assign(:cache_ttl, ttl) + |> render("index.html") + end + + """ + + import Phoenix.Controller, only: [current_path: 1, json: 2] + import Plug.Conn + + @behaviour Plug + + @defaults %{ttl: nil, query_params: true} + + @impl true + def init([]), do: @defaults + + def init(opts) do + opts = Map.new(opts) + Map.merge(@defaults, opts) + end + + @impl true + def call(%{method: "GET"} = conn, opts) do + key = cache_key(conn, opts) + + case Cachex.get(:web_resp_cache, key) do + {:ok, nil} -> + cache_resp(conn, opts) + + {:ok, {content_type, body, tracking_fun_data}} -> + conn = opts.tracking_fun.(conn, tracking_fun_data) + + send_cached(conn, {content_type, body}) + + {:ok, record} -> + send_cached(conn, record) + + {atom, message} when atom in [:ignore, :error] -> + render_error(conn, message) + end + end + + def call(conn, _), do: conn + + # full path including query params + defp cache_key(conn, %{query_params: true}), do: current_path(conn) + + # request path without query params + defp cache_key(conn, %{query_params: false}), do: conn.request_path + + # request path with specific query params + defp cache_key(conn, %{query_params: query_params}) when is_list(query_params) do + query_string = + conn.params + |> Map.take(query_params) + |> URI.encode_query() + + conn.request_path <> "?" <> query_string + end + + defp cache_resp(conn, opts) do + register_before_send(conn, fn + %{status: 200, resp_body: body} = conn -> + ttl = Map.get(conn.assigns, :cache_ttl, opts.ttl) + key = cache_key(conn, opts) + content_type = content_type(conn) + + conn = + unless opts[:tracking_fun] do + Cachex.put(:web_resp_cache, key, {content_type, body}, ttl: ttl) + conn + else + tracking_fun_data = Map.get(conn.assigns, :tracking_fun_data, nil) + Cachex.put(:web_resp_cache, key, {content_type, body, tracking_fun_data}, ttl: ttl) + + opts.tracking_fun.(conn, tracking_fun_data) + end + + put_resp_header(conn, "x-cache", "MISS from Pleroma") + + conn -> + conn + end) + end + + defp content_type(conn) do + conn + |> Plug.Conn.get_resp_header("content-type") + |> hd() + end + + defp send_cached(conn, {content_type, body}) do + conn + |> put_resp_content_type(content_type, nil) + |> put_resp_header("x-cache", "HIT from Pleroma") + |> send_resp(:ok, body) + |> halt() + end + + defp render_error(conn, message) do + conn + |> put_status(:internal_server_error) + |> json(%{error: message}) + |> halt() + end +end diff --git a/lib/pleroma/plugs/http_signature.ex b/lib/pleroma/plugs/http_signature.ex index d87fa52fa..23d22a712 100644 --- a/lib/pleroma/plugs/http_signature.ex +++ b/lib/pleroma/plugs/http_signature.ex @@ -15,7 +15,8 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlug do end def call(conn, _opts) do - [signature | _] = get_req_header(conn, "signature") + headers = get_req_header(conn, "signature") + signature = Enum.at(headers, 0) if signature do # set (request-target) header to the appropriate value diff --git a/lib/pleroma/plugs/oauth_scopes_plug.ex b/lib/pleroma/plugs/oauth_scopes_plug.ex index b508628a9..a3278dbef 100644 --- a/lib/pleroma/plugs/oauth_scopes_plug.ex +++ b/lib/pleroma/plugs/oauth_scopes_plug.ex @@ -6,6 +6,8 @@ defmodule Pleroma.Plugs.OAuthScopesPlug do import Plug.Conn import Pleroma.Web.Gettext + alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug + @behaviour Plug def init(%{scopes: _} = options), do: options @@ -13,24 +15,26 @@ defmodule Pleroma.Plugs.OAuthScopesPlug do def call(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) do op = options[:op] || :| token = assigns[:token] + matched_scopes = token && filter_descendants(scopes, token.scopes) cond do is_nil(token) -> - conn + maybe_perform_instance_privacy_check(conn, options) - op == :| && scopes -- token.scopes != scopes -> + op == :| && Enum.any?(matched_scopes) -> conn - op == :& && scopes -- token.scopes == [] -> + op == :& && matched_scopes == scopes -> conn options[:fallback] == :proceed_unauthenticated -> conn |> assign(:user, nil) |> assign(:token, nil) + |> maybe_perform_instance_privacy_check(options) true -> - missing_scopes = scopes -- token.scopes + missing_scopes = scopes -- matched_scopes permissions = Enum.join(missing_scopes, " #{op} ") error_message = @@ -42,4 +46,25 @@ defmodule Pleroma.Plugs.OAuthScopesPlug do |> halt() end end + + @doc "Filters descendants of supported scopes" + def filter_descendants(scopes, supported_scopes) do + Enum.filter( + scopes, + fn scope -> + Enum.find( + supported_scopes, + &(scope == &1 || String.starts_with?(scope, &1 <> ":")) + ) + end + ) + end + + defp maybe_perform_instance_privacy_check(%Plug.Conn{} = conn, options) do + if options[:skip_instance_privacy_check] do + conn + else + EnsurePublicOrAuthenticatedPlug.call(conn, []) + end + end end diff --git a/lib/pleroma/plugs/remote_ip.ex b/lib/pleroma/plugs/remote_ip.ex new file mode 100644 index 000000000..fdedc27ee --- /dev/null +++ b/lib/pleroma/plugs/remote_ip.ex @@ -0,0 +1,54 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Plugs.RemoteIp do + @moduledoc """ + This is a shim to call [`RemoteIp`](https://git.pleroma.social/pleroma/remote_ip) but with runtime configuration. + """ + + @behaviour Plug + + @headers ~w[ + forwarded + x-forwarded-for + x-client-ip + x-real-ip + ] + + # https://en.wikipedia.org/wiki/Localhost + # https://en.wikipedia.org/wiki/Private_network + @reserved ~w[ + 127.0.0.0/8 + ::1/128 + fc00::/7 + 10.0.0.0/8 + 172.16.0.0/12 + 192.168.0.0/16 + ] + + def init(_), do: nil + + def call(conn, _) do + config = Pleroma.Config.get(__MODULE__, []) + + if Keyword.get(config, :enabled, false) do + RemoteIp.call(conn, remote_ip_opts(config)) + else + conn + end + end + + defp remote_ip_opts(config) do + headers = config |> Keyword.get(:headers, @headers) |> MapSet.new() + reserved = Keyword.get(config, :reserved, @reserved) + + proxies = + config + |> Keyword.get(:proxies, []) + |> Enum.concat(reserved) + |> Enum.map(&InetCidr.parse/1) + + {headers, proxies} + end +end diff --git a/lib/pleroma/plugs/trailing_format_plug.ex b/lib/pleroma/plugs/trailing_format_plug.ex index 2473e07fe..ce366b218 100644 --- a/lib/pleroma/plugs/trailing_format_plug.ex +++ b/lib/pleroma/plugs/trailing_format_plug.ex @@ -23,7 +23,8 @@ defmodule Pleroma.Plugs.TrailingFormatPlug do "/nodeinfo", "/api/help", "/api/externalprofile", - "/notice" + "/notice", + "/api/pleroma/emoji" ] def init(opts) do diff --git a/lib/pleroma/registration.ex b/lib/pleroma/registration.ex index 21fd1fc3f..8544461db 100644 --- a/lib/pleroma/registration.ex +++ b/lib/pleroma/registration.ex @@ -11,10 +11,10 @@ defmodule Pleroma.Registration do alias Pleroma.Repo alias Pleroma.User - @primary_key {:id, Pleroma.FlakeId, autogenerate: true} + @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} schema "registrations" do - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) field(:provider, :string) field(:uid, :string) field(:info, :map, default: %{}) 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/scheduled_activity.ex b/lib/pleroma/scheduled_activity.ex index de0e54699..fea2cf3ff 100644 --- a/lib/pleroma/scheduled_activity.ex +++ b/lib/pleroma/scheduled_activity.ex @@ -17,7 +17,7 @@ defmodule Pleroma.ScheduledActivity do @min_offset :timer.minutes(5) schema "scheduled_activities" do - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) field(:scheduled_at, :naive_datetime) field(:params, :map) diff --git a/lib/pleroma/web/mastodon_api/views/mastodon_view.ex b/lib/pleroma/scheduler.ex index 33b9a74be..d84cd99ad 100644 --- a/lib/pleroma/web/mastodon_api/views/mastodon_view.ex +++ b/lib/pleroma/scheduler.ex @@ -2,7 +2,6 @@ # 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 +defmodule Pleroma.Scheduler do + use Quantum.Scheduler, otp_app: :pleroma end diff --git a/lib/pleroma/signature.ex b/lib/pleroma/signature.ex index f20aeb0d5..1e7c9ae86 100644 --- a/lib/pleroma/signature.ex +++ b/lib/pleroma/signature.ex @@ -48,7 +48,7 @@ defmodule Pleroma.Signature do end def sign(%User{} = user, headers) do - with {:ok, %{info: %{keys: keys}}} <- User.ensure_keys_present(user), + with {:ok, %{keys: keys}} <- User.ensure_keys_present(user), {:ok, private_key, _} <- Keys.keys_from_pem(keys) do HTTPSignatures.sign(private_key, user.ap_id <> "#main-key", headers) end diff --git a/lib/pleroma/thread_mute.ex b/lib/pleroma/thread_mute.ex index 10d31679d..65cbbede3 100644 --- a/lib/pleroma/thread_mute.ex +++ b/lib/pleroma/thread_mute.ex @@ -12,7 +12,7 @@ defmodule Pleroma.ThreadMute do require Ecto.Query schema "thread_mutes" do - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) field(:context, :string) end @@ -24,7 +24,7 @@ defmodule Pleroma.ThreadMute do end def query(user_id, context) do - user_id = Pleroma.FlakeId.from_string(user_id) + {:ok, user_id} = FlakeId.Ecto.CompatType.dump(user_id) ThreadMute |> Ecto.Query.where(user_id: ^user_id) diff --git a/lib/pleroma/uploaders/s3.ex b/lib/pleroma/uploaders/s3.ex index 8c353bed3..9876b6398 100644 --- a/lib/pleroma/uploaders/s3.ex +++ b/lib/pleroma/uploaders/s3.ex @@ -38,16 +38,26 @@ defmodule Pleroma.Uploaders.S3 do def put_file(%Pleroma.Upload{} = upload) do config = Config.get([__MODULE__]) bucket = Keyword.get(config, :bucket) + streaming = Keyword.get(config, :streaming_enabled) s3_name = strict_encode(upload.path) op = - upload.tempfile - |> ExAws.S3.Upload.stream_file() - |> ExAws.S3.upload(bucket, s3_name, [ - {:acl, :public_read}, - {:content_type, upload.content_type} - ]) + if streaming do + upload.tempfile + |> ExAws.S3.Upload.stream_file() + |> ExAws.S3.upload(bucket, s3_name, [ + {:acl, :public_read}, + {:content_type, upload.content_type} + ]) + else + {:ok, file_data} = File.read(upload.tempfile) + + ExAws.S3.put_object(bucket, s3_name, file_data, [ + {:acl, :public_read}, + {:content_type, upload.content_type} + ]) + end case ExAws.request(op) do {:ok, _} -> diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 37e59a651..1e055014e 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -11,6 +11,8 @@ defmodule Pleroma.User do alias Comeonin.Pbkdf2 alias Ecto.Multi alias Pleroma.Activity + alias Pleroma.Conversation.Participation + alias Pleroma.Delivery alias Pleroma.Keys alias Pleroma.Notification alias Pleroma.Object @@ -27,12 +29,13 @@ defmodule Pleroma.User do alias Pleroma.Web.OStatus alias Pleroma.Web.RelMe alias Pleroma.Web.Websub + alias Pleroma.Workers.BackgroundWorker require Logger @type t :: %__MODULE__{} - @primary_key {:id, Pleroma.FlakeId, autogenerate: true} + @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength @email_regex ~r/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ @@ -48,6 +51,7 @@ defmodule Pleroma.User do field(:password_hash, :string) field(:password, :string, virtual: true) field(:password_confirmation, :string, virtual: true) + field(:keys, :string) field(:following, {:array, :string}, default: []) field(:ap_id, :string) field(:avatar, :map) @@ -61,6 +65,7 @@ defmodule Pleroma.User do field(:last_digest_emailed_at, :naive_datetime) has_many(:notifications, Notification) has_many(:registrations, Registration) + has_many(:deliveries, Delivery) embeds_one(:info, User.Info) timestamps() @@ -103,9 +108,7 @@ defmodule Pleroma.User do def profile_url(%User{ap_id: ap_id}), do: ap_id def profile_url(_), do: nil - def ap_id(%User{nickname: nickname}) do - "#{Web.base_url()}/users/#{nickname}" - end + def ap_id(%User{nickname: nickname}), do: "#{Web.base_url()}/users/#{nickname}" def ap_followers(%User{follower_address: fa}) when is_binary(fa), do: fa def ap_followers(%User{} = user), do: "#{ap_id(user)}/followers" @@ -116,12 +119,9 @@ defmodule Pleroma.User do def user_info(%User{} = user, args \\ %{}) do following_count = - if args[:following_count], - do: args[:following_count], - else: user.info.following_count || following_count(user) + Map.get(args, :following_count, user.info.following_count || following_count(user)) - follower_count = - if args[:follower_count], do: args[:follower_count], else: user.info.follower_count + follower_count = Map.get(args, :follower_count, user.info.follower_count) %{ note_count: user.info.note_count, @@ -134,12 +134,11 @@ defmodule Pleroma.User do end def follow_state(%User{} = user, %User{} = target) do - follow_activity = Utils.fetch_latest_follow(user, target) - - if follow_activity, - do: follow_activity.data["state"], + case Utils.fetch_latest_follow(user, target) do + %{data: %{"state" => state}} -> state # Ideally this would be nil, but then Cachex does not commit the value - else: false + _ -> false + end end def get_cached_follow_state(user, target) do @@ -147,12 +146,9 @@ defmodule Pleroma.User do Cachex.fetch!(:user_cache, key, fn _ -> {:commit, follow_state(user, target)} end) end + @spec set_follow_state_cache(String.t(), String.t(), String.t()) :: {:ok | :error, boolean()} def set_follow_state_cache(user_ap_id, target_ap_id, state) do - Cachex.put( - :user_cache, - "follow_state:#{user_ap_id}|#{target_ap_id}", - state - ) + Cachex.put(:user_cache, "follow_state:#{user_ap_id}|#{target_ap_id}", state) end def set_info_cache(user, args) do @@ -174,39 +170,44 @@ defmodule Pleroma.User do |> Repo.aggregate(:count, :id) end + defp truncate_if_exists(params, key, max_length) do + if Map.has_key?(params, key) and is_binary(params[key]) do + {value, _chopped} = String.split_at(params[key], max_length) + Map.put(params, key, value) + else + params + end + end + def remote_user_creation(params) do bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000) name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) - params = Map.put(params, :info, params[:info] || %{}) - info_cng = User.Info.remote_user_creation(%User.Info{}, params[:info]) + params = + params + |> Map.put(:info, params[:info] || %{}) + |> truncate_if_exists(:name, name_limit) + |> truncate_if_exists(:bio, bio_limit) - changes = - %User{} + changeset = + %User{local: false} |> cast(params, [:bio, :name, :ap_id, :nickname, :avatar]) |> validate_required([:name, :ap_id]) |> unique_constraint(:nickname) |> validate_format(:nickname, @email_regex) |> validate_length(:bio, max: bio_limit) |> validate_length(:name, max: name_limit) - |> put_change(:local, false) - |> put_embed(:info, info_cng) - - if changes.valid? do - case info_cng.changes[:source_data] do - %{"followers" => followers, "following" => following} -> - changes - |> put_change(:follower_address, followers) - |> put_change(:following_address, following) + |> change_info(&User.Info.remote_user_creation(&1, params[:info])) - _ -> - followers = User.ap_followers(%User{nickname: changes.changes[:nickname]}) + case params[:info][:source_data] do + %{"followers" => followers, "following" => following} -> + changeset + |> put_change(:follower_address, followers) + |> put_change(:following_address, following) - changes - |> put_change(:follower_address, followers) - end - else - changes + _ -> + followers = ap_followers(%User{nickname: get_field(changeset, :nickname)}) + put_change(changeset, :follower_address, followers) end end @@ -227,7 +228,6 @@ defmodule Pleroma.User do name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) params = Map.put(params, :last_refreshed_at, NaiveDateTime.utc_now()) - info_cng = User.Info.user_upgrade(struct.info, params[:info], remote?) struct |> cast(params, [ @@ -242,7 +242,7 @@ defmodule Pleroma.User do |> validate_format(:nickname, local_nickname_regex()) |> validate_length(:bio, max: bio_limit) |> validate_length(:name, max: name_limit) - |> put_embed(:info, info_cng) + |> change_info(&User.Info.user_upgrade(&1, params[:info], remote?)) end def password_update_changeset(struct, params) do @@ -251,6 +251,7 @@ defmodule Pleroma.User do |> validate_required([:password, :password_confirmation]) |> validate_confirmation(:password) |> put_password_hash + |> put_embed(:info, User.Info.set_password_reset_pending(struct.info, false)) end @spec reset_password(User.t(), map) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} @@ -267,6 +268,20 @@ defmodule Pleroma.User do end end + def force_password_reset_async(user) do + BackgroundWorker.enqueue("force_password_reset", %{"user_id" => user.id}) + end + + @spec force_password_reset(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def force_password_reset(user) do + info_cng = User.Info.set_password_reset_pending(user.info, true) + + user + |> change() + |> put_embed(:info, info_cng) + |> update_and_set_cache() + end + def register_changeset(struct, params \\ %{}, opts \\ []) do bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000) name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) @@ -278,43 +293,39 @@ defmodule Pleroma.User do opts[:need_confirmation] end - info_change = - User.Info.confirmation_changeset(%User.Info{}, need_confirmation: need_confirmation?) + struct + |> cast(params, [:bio, :email, :name, :nickname, :password, :password_confirmation]) + |> validate_required([:name, :nickname, :password, :password_confirmation]) + |> validate_confirmation(:password) + |> unique_constraint(:email) + |> unique_constraint(:nickname) + |> validate_exclusion(:nickname, Pleroma.Config.get([User, :restricted_nicknames])) + |> validate_format(:nickname, local_nickname_regex()) + |> validate_format(:email, @email_regex) + |> validate_length(:bio, max: bio_limit) + |> validate_length(:name, min: 1, max: name_limit) + |> change_info(&User.Info.confirmation_changeset(&1, need_confirmation: need_confirmation?)) + |> maybe_validate_required_email(opts[:external]) + |> put_password_hash + |> put_ap_id() + |> unique_constraint(:ap_id) + |> put_following_and_follower_address() + end - changeset = - struct - |> cast(params, [:bio, :email, :name, :nickname, :password, :password_confirmation]) - |> validate_required([:name, :nickname, :password, :password_confirmation]) - |> validate_confirmation(:password) - |> unique_constraint(:email) - |> unique_constraint(:nickname) - |> validate_exclusion(:nickname, Pleroma.Config.get([User, :restricted_nicknames])) - |> validate_format(:nickname, local_nickname_regex()) - |> validate_format(:email, @email_regex) - |> validate_length(:bio, max: bio_limit) - |> validate_length(:name, min: 1, max: name_limit) - |> put_change(:info, info_change) + def maybe_validate_required_email(changeset, true), do: changeset + def maybe_validate_required_email(changeset, _), do: validate_required(changeset, [:email]) - changeset = - if opts[:external] do - changeset - else - validate_required(changeset, [:email]) - end + defp put_ap_id(changeset) do + ap_id = ap_id(%User{nickname: get_field(changeset, :nickname)}) + put_change(changeset, :ap_id, ap_id) + end - if changeset.valid? do - ap_id = User.ap_id(%User{nickname: changeset.changes[:nickname]}) - followers = User.ap_followers(%User{nickname: changeset.changes[:nickname]}) + defp put_following_and_follower_address(changeset) do + followers = ap_followers(%User{nickname: get_field(changeset, :nickname)}) - changeset - |> put_password_hash - |> put_change(:ap_id, ap_id) - |> unique_constraint(:ap_id) - |> put_change(:following, [followers]) - |> put_change(:follower_address, followers) - else - changeset - end + changeset + |> put_change(:following, [followers]) + |> put_change(:follower_address, followers) end defp autofollow_users(user) do @@ -329,9 +340,8 @@ defmodule Pleroma.User do @doc "Inserts provided changeset, performs post-registration actions (confirmation email sending etc.)" def register(%Ecto.Changeset{} = changeset) do - with {:ok, user} <- Repo.insert(changeset), - {:ok, user} <- post_register_action(user) do - {:ok, user} + with {:ok, user} <- Repo.insert(changeset) do + post_register_action(user) end end @@ -377,7 +387,7 @@ defmodule Pleroma.User do end def maybe_direct_follow(%User{} = follower, %User{} = followed) do - if not User.ap_enabled?(followed) do + if not ap_enabled?(followed) do follow(follower, followed) else {:ok, follower} @@ -410,9 +420,7 @@ defmodule Pleroma.User do {1, [follower]} = Repo.update_all(q, []) - Enum.each(followeds, fn followed -> - update_follower_count(followed) - end) + Enum.each(followeds, &update_follower_count/1) set_cache(follower) end @@ -501,6 +509,11 @@ defmodule Pleroma.User do |> Repo.all() end + def get_all_by_ids(ids) do + from(u in __MODULE__, where: u.id in ^ids) + |> Repo.all() + end + # This is mostly an SPC migration fix. This guesses the user nickname by taking the last part # of the ap_id and the domain and tries to get that user def get_by_guessed_nickname(ap_id) do @@ -524,8 +537,6 @@ defmodule Pleroma.User do def update_and_set_cache(changeset) do with {:ok, user} <- Repo.update(changeset, stale_error_field: :id) do set_cache(user) - else - e -> e end end @@ -562,9 +573,7 @@ defmodule Pleroma.User do key = "nickname:#{nickname}" Cachex.fetch!(:user_cache, key, fn -> - user_result = get_or_fetch_by_nickname(nickname) - - case user_result do + case get_or_fetch_by_nickname(nickname) do {:ok, user} -> {:commit, user} {:error, _error} -> {:ignore, nil} end @@ -575,10 +584,10 @@ defmodule Pleroma.User do restrict_to_local = Pleroma.Config.get([:instance, :limit_to_local_content]) cond do - is_integer(nickname_or_id) or Pleroma.FlakeId.is_flake_id?(nickname_or_id) -> + is_integer(nickname_or_id) or FlakeId.flake_id?(nickname_or_id) -> get_cached_by_id(nickname_or_id) || get_cached_by_nickname(nickname_or_id) - restrict_to_local == false -> + restrict_to_local == false or not String.contains?(nickname_or_id, "@") -> get_cached_by_nickname(nickname_or_id) restrict_to_local == :unauthenticated and match?(%User{}, opts[:for]) -> @@ -604,13 +613,11 @@ defmodule Pleroma.User do def get_cached_user_info(user) do key = "user_info:#{user.id}" - Cachex.fetch!(:user_cache, key, fn _ -> user_info(user) end) + Cachex.fetch!(:user_cache, key, fn -> user_info(user) end) end def fetch_by_nickname(nickname) do - ap_try = ActivityPub.make_user_from_nickname(nickname) - - case ap_try do + case ActivityPub.make_user_from_nickname(nickname) do {:ok, user} -> {:ok, user} _ -> OStatus.make_user(nickname) end @@ -635,8 +642,9 @@ defmodule Pleroma.User do end @doc "Fetch some posts when the user has just been federated with" - def fetch_initial_posts(user), - do: PleromaJobQueue.enqueue(:background, __MODULE__, [:fetch_initial_posts, user]) + def fetch_initial_posts(user) do + BackgroundWorker.enqueue("fetch_initial_posts", %{"user_id" => user.id}) + end @spec get_followers_query(User.t(), pos_integer() | nil) :: Ecto.Query.t() def get_followers_query(%User{} = user, nil) do @@ -644,7 +652,8 @@ defmodule Pleroma.User do end def get_followers_query(user, page) do - from(u in get_followers_query(user, nil)) + user + |> get_followers_query(nil) |> User.Query.paginate(page, 20) end @@ -653,25 +662,24 @@ defmodule Pleroma.User do @spec get_followers(User.t(), pos_integer()) :: {:ok, list(User.t())} def get_followers(user, page \\ nil) do - q = get_followers_query(user, page) - - {:ok, Repo.all(q)} + user + |> get_followers_query(page) + |> Repo.all() end @spec get_external_followers(User.t(), pos_integer()) :: {:ok, list(User.t())} def get_external_followers(user, page \\ nil) do - q = - user - |> get_followers_query(page) - |> User.Query.build(%{external: true}) - - {:ok, Repo.all(q)} + user + |> get_followers_query(page) + |> User.Query.build(%{external: true}) + |> Repo.all() end def get_followers_ids(user, page \\ nil) do - q = get_followers_query(user, page) - - Repo.all(from(u in q, select: u.id)) + user + |> get_followers_query(page) + |> select([u], u.id) + |> Repo.all() end @spec get_friends_query(User.t(), pos_integer() | nil) :: Ecto.Query.t() @@ -680,7 +688,8 @@ defmodule Pleroma.User do end def get_friends_query(user, page) do - from(u in get_friends_query(user, nil)) + user + |> get_friends_query(nil) |> User.Query.paginate(page, 20) end @@ -688,28 +697,27 @@ defmodule Pleroma.User do def get_friends_query(user), do: get_friends_query(user, nil) def get_friends(user, page \\ nil) do - q = get_friends_query(user, page) - - {:ok, Repo.all(q)} + user + |> get_friends_query(page) + |> Repo.all() end def get_friends_ids(user, page \\ nil) do - q = get_friends_query(user, page) - - Repo.all(from(u in q, select: u.id)) + user + |> get_friends_query(page) + |> select([u], u.id) + |> Repo.all() end @spec get_follow_requests(User.t()) :: {:ok, [User.t()]} def get_follow_requests(%User{} = user) do - users = - Activity.follow_requests_for_actor(user) - |> join(:inner, [a], u in User, on: a.actor == u.ap_id) - |> where([a, u], not fragment("? @> ?", u.following, ^[user.follower_address])) - |> group_by([a, u], u.id) - |> select([a, u], u) - |> Repo.all() - - {:ok, users} + user + |> Activity.follow_requests_for_actor() + |> join(:inner, [a], u in User, on: a.actor == u.ap_id) + |> where([a, u], not fragment("? @> ?", u.following, ^[user.follower_address])) + |> group_by([a, u], u.id) + |> select([a, u], u) + |> Repo.all() end def increase_note_count(%User{} = user) do @@ -755,20 +763,27 @@ defmodule Pleroma.User do end def update_note_count(%User{} = user) do - note_count_query = + note_count = from( a in Object, where: fragment("?->>'actor' = ? and ?->>'type' = 'Note'", a.data, ^user.ap_id, a.data), select: count(a.id) ) + |> Repo.one() - note_count = Repo.one(note_count_query) + update_info(user, &User.Info.set_note_count(&1, note_count)) + end - info_cng = User.Info.set_note_count(user.info, note_count) + def update_mascot(user, url) do + info_changeset = + User.Info.mascot_update( + user.info, + url + ) user |> change() - |> put_embed(:info, info_cng) + |> put_embed(:info, info_changeset) |> update_and_set_cache() end @@ -786,17 +801,7 @@ defmodule Pleroma.User do def fetch_follow_information(user) do with {:ok, info} <- ActivityPub.fetch_follow_information_for_user(user) do - info_cng = User.Info.follow_information_update(user.info, info) - - changeset = - user - |> change() - |> put_embed(:info, info_cng) - - update_and_set_cache(changeset) - else - {:error, _} = e -> e - e -> {:error, e} + update_info(user, &User.Info.follow_information_update(&1, info)) end end @@ -841,6 +846,61 @@ defmodule Pleroma.User do def maybe_update_following_count(user), do: user + def set_unread_conversation_count(%User{local: true} = user) do + unread_query = Participation.unread_conversation_count_for_user(user) + + User + |> join(:inner, [u], p in subquery(unread_query)) + |> update([u, p], + set: [ + info: + fragment( + "jsonb_set(?, '{unread_conversation_count}', ?::varchar::jsonb, true)", + u.info, + p.count + ) + ] + ) + |> where([u], u.id == ^user.id) + |> select([u], u) + |> Repo.update_all([]) + |> case do + {1, [user]} -> set_cache(user) + _ -> {:error, user} + end + end + + def set_unread_conversation_count(_), do: :noop + + def increment_unread_conversation_count(conversation, %User{local: true} = user) do + unread_query = + Participation.unread_conversation_count_for_user(user) + |> where([p], p.conversation_id == ^conversation.id) + + User + |> join(:inner, [u], p in subquery(unread_query)) + |> update([u, p], + set: [ + info: + fragment( + "jsonb_set(?, '{unread_conversation_count}', (coalesce((?->>'unread_conversation_count')::int, 0) + 1)::varchar::jsonb, true)", + u.info, + u.info + ) + ] + ) + |> where([u], u.id == ^user.id) + |> where([u, p], p.count == 0) + |> select([u], u) + |> Repo.update_all([]) + |> case do + {1, [user]} -> set_cache(user) + _ -> {:error, user} + end + end + + def increment_unread_conversation_count(_, _), do: :noop + def remove_duplicated_following(%User{following: following} = user) do uniq_following = Enum.uniq(following) @@ -870,62 +930,28 @@ defmodule Pleroma.User do @spec mute(User.t(), User.t(), boolean()) :: {:ok, User.t()} | {:error, String.t()} def mute(muter, %User{ap_id: ap_id}, notifications? \\ true) do - info = muter.info - - info_cng = - User.Info.add_to_mutes(info, ap_id) - |> User.Info.add_to_muted_notifications(info, ap_id, notifications?) - - cng = - change(muter) - |> put_embed(:info, info_cng) - - update_and_set_cache(cng) + update_info(muter, &User.Info.add_to_mutes(&1, ap_id, notifications?)) end def unmute(muter, %{ap_id: ap_id}) do - info = muter.info - - info_cng = - User.Info.remove_from_mutes(info, ap_id) - |> User.Info.remove_from_muted_notifications(info, ap_id) - - cng = - change(muter) - |> put_embed(:info, info_cng) - - update_and_set_cache(cng) + update_info(muter, &User.Info.remove_from_mutes(&1, ap_id)) end def subscribe(subscriber, %{ap_id: ap_id}) do - deny_follow_blocked = Pleroma.Config.get([:user, :deny_follow_blocked]) - with %User{} = subscribed <- get_cached_by_ap_id(ap_id) do - blocked = blocks?(subscribed, subscriber) and deny_follow_blocked + deny_follow_blocked = Pleroma.Config.get([:user, :deny_follow_blocked]) - if blocked do + if blocks?(subscribed, subscriber) and deny_follow_blocked do {:error, "Could not subscribe: #{subscribed.nickname} is blocking you"} else - info_cng = - subscribed.info - |> User.Info.add_to_subscribers(subscriber.ap_id) - - change(subscribed) - |> put_embed(:info, info_cng) - |> update_and_set_cache() + update_info(subscribed, &User.Info.add_to_subscribers(&1, subscriber.ap_id)) end end end def unsubscribe(unsubscriber, %{ap_id: ap_id}) do with %User{} = user <- get_cached_by_ap_id(ap_id) do - info_cng = - user.info - |> User.Info.remove_from_subscribers(unsubscriber.ap_id) - - change(user) - |> put_embed(:info, info_cng) - |> update_and_set_cache() + update_info(user, &User.Info.remove_from_subscribers(&1, unsubscriber.ap_id)) end end @@ -954,21 +980,11 @@ defmodule Pleroma.User do blocker end - if following?(blocked, blocker) do - unfollow(blocked, blocker) - end + if following?(blocked, blocker), do: unfollow(blocked, blocker) {:ok, blocker} = update_follower_count(blocker) - info_cng = - blocker.info - |> User.Info.add_to_block(ap_id) - - cng = - change(blocker) - |> put_embed(:info, info_cng) - - update_and_set_cache(cng) + update_info(blocker, &User.Info.add_to_block(&1, ap_id)) end # helper to handle the block given only an actor's AP id @@ -977,15 +993,7 @@ defmodule Pleroma.User do end def unblock(blocker, %{ap_id: ap_id}) do - info_cng = - blocker.info - |> User.Info.remove_from_block(ap_id) - - cng = - change(blocker) - |> put_embed(:info, info_cng) - - update_and_set_cache(cng) + update_info(blocker, &User.Info.remove_from_block(&1, ap_id)) end def mutes?(nil, _), do: false @@ -1042,79 +1050,53 @@ defmodule Pleroma.User do end def block_domain(user, domain) do - info_cng = - user.info - |> User.Info.add_to_domain_block(domain) - - cng = - change(user) - |> put_embed(:info, info_cng) - - update_and_set_cache(cng) + update_info(user, &User.Info.add_to_domain_block(&1, domain)) end def unblock_domain(user, domain) do - info_cng = - user.info - |> User.Info.remove_from_domain_block(domain) - - cng = - change(user) - |> put_embed(:info, info_cng) - - update_and_set_cache(cng) + update_info(user, &User.Info.remove_from_domain_block(&1, domain)) end def deactivate_async(user, status \\ true) do - PleromaJobQueue.enqueue(:background, __MODULE__, [:deactivate_async, user, status]) + BackgroundWorker.enqueue("deactivate_user", %{"user_id" => user.id, "status" => status}) end def deactivate(%User{} = user, status \\ true) do - info_cng = User.Info.set_activation_status(user.info, status) - - with {:ok, friends} <- User.get_friends(user), - {:ok, followers} <- User.get_followers(user), - {:ok, user} <- - user - |> change() - |> put_embed(:info, info_cng) - |> update_and_set_cache() do - Enum.each(followers, &invalidate_cache(&1)) - Enum.each(friends, &update_follower_count(&1)) + with {:ok, user} <- update_info(user, &User.Info.set_activation_status(&1, status)) do + Enum.each(get_followers(user), &invalidate_cache/1) + Enum.each(get_friends(user), &update_follower_count/1) {:ok, user} end end def update_notification_settings(%User{} = user, settings \\ %{}) do - info_changeset = User.Info.update_notification_settings(user.info, settings) + update_info(user, &User.Info.update_notification_settings(&1, settings)) + end - change(user) - |> put_embed(:info, info_changeset) - |> update_and_set_cache() + def delete(%User{} = user) do + BackgroundWorker.enqueue("delete_user", %{"user_id" => user.id}) end - @spec delete(User.t()) :: :ok - def delete(%User{} = user), - do: PleromaJobQueue.enqueue(:background, __MODULE__, [:delete, user]) + def perform(:force_password_reset, user), do: force_password_reset(user) @spec perform(atom(), User.t()) :: {:ok, User.t()} def perform(:delete, %User{} = user) do {:ok, _user} = ActivityPub.delete(user) # Remove all relationships - {:ok, followers} = User.get_followers(user) - - Enum.each(followers, fn follower -> + user + |> get_followers() + |> Enum.each(fn follower -> ActivityPub.unfollow(follower, user) - User.unfollow(follower, user) + unfollow(follower, user) end) - {:ok, friends} = User.get_friends(user) - - Enum.each(friends, fn followed -> + user + |> get_friends() + |> Enum.each(fn followed -> ActivityPub.unfollow(user, followed) - User.unfollow(user, followed) + unfollow(user, followed) end) delete_user_activities(user) @@ -1126,13 +1108,11 @@ defmodule Pleroma.User do def perform(:fetch_initial_posts, %User{} = user) do pages = Pleroma.Config.get!([:fetch_initial_posts, :pages]) - Enum.each( - # Insert all the posts in reverse order, so they're in the right order on the timeline - Enum.reverse(Utils.fetch_ordered_collection(user.info.source_data["outbox"], pages)), - &Pleroma.Web.Federator.incoming_ap_doc/1 - ) - - {:ok, user} + # Insert all the posts in reverse order, so they're in the right order on the timeline + user.info.source_data["outbox"] + |> Utils.fetch_ordered_collection(pages) + |> Enum.reverse() + |> Enum.each(&Pleroma.Web.Federator.incoming_ap_doc/1) end def perform(:deactivate_async, user, status), do: deactivate(user, status) @@ -1203,32 +1183,27 @@ defmodule Pleroma.User do Repo.all(query) end - def blocks_import(%User{} = blocker, blocked_identifiers) when is_list(blocked_identifiers), - do: - PleromaJobQueue.enqueue(:background, __MODULE__, [ - :blocks_import, - blocker, - blocked_identifiers - ]) + def blocks_import(%User{} = blocker, blocked_identifiers) when is_list(blocked_identifiers) do + BackgroundWorker.enqueue("blocks_import", %{ + "blocker_id" => blocker.id, + "blocked_identifiers" => blocked_identifiers + }) + end - def follow_import(%User{} = follower, followed_identifiers) when is_list(followed_identifiers), - do: - PleromaJobQueue.enqueue(:background, __MODULE__, [ - :follow_import, - follower, - followed_identifiers - ]) + def follow_import(%User{} = follower, followed_identifiers) + when is_list(followed_identifiers) do + BackgroundWorker.enqueue("follow_import", %{ + "follower_id" => follower.id, + "followed_identifiers" => followed_identifiers + }) + end - def delete_user_activities(%User{ap_id: ap_id} = user) do + def delete_user_activities(%User{ap_id: ap_id}) do ap_id - |> Activity.query_by_actor() + |> Activity.Queries.by_actor() |> RepoStreamer.chunk_stream(50) - |> Stream.each(fn activities -> - Enum.each(activities, &delete_activity(&1)) - end) + |> Stream.each(fn activities -> Enum.each(activities, &delete_activity/1) end) |> Stream.run() - - {:ok, user} end defp delete_activity(%{data: %{"type" => "Create"}} = activity) do @@ -1238,17 +1213,19 @@ defmodule Pleroma.User do end defp delete_activity(%{data: %{"type" => "Like"}} = activity) do - user = get_cached_by_ap_id(activity.actor) object = Object.normalize(activity) - ActivityPub.unlike(user, object) + activity.actor + |> get_cached_by_ap_id() + |> ActivityPub.unlike(object) end defp delete_activity(%{data: %{"type" => "Announce"}} = activity) do - user = get_cached_by_ap_id(activity.actor) object = Object.normalize(activity) - ActivityPub.unannounce(user, object) + activity.actor + |> get_cached_by_ap_id() + |> ActivityPub.unannounce(object) end defp delete_activity(_activity), do: "Doing nothing" @@ -1260,9 +1237,7 @@ defmodule Pleroma.User do def html_filter_policy(_), do: Pleroma.Config.get([:markup, :scrub_policy]) def fetch_by_ap_id(ap_id) do - ap_try = ActivityPub.make_user_from_ap_id(ap_id) - - case ap_try do + case ActivityPub.make_user_from_ap_id(ap_id) do {:ok, user} -> {:ok, user} @@ -1277,7 +1252,7 @@ defmodule Pleroma.User do def get_or_fetch_by_ap_id(ap_id) do user = get_cached_by_ap_id(ap_id) - if !is_nil(user) and !User.needs_update?(user) do + if !is_nil(user) and !needs_update?(user) do {:ok, user} else # Whether to fetch initial posts for the user (if it's a new user & the fetching is enabled) @@ -1297,19 +1272,20 @@ defmodule Pleroma.User do @doc "Creates an internal service actor by URI if missing. Optionally takes nickname for addressing." def get_or_create_service_actor_by_ap_id(uri, nickname \\ nil) do - if user = get_cached_by_ap_id(uri) do + with %User{} = user <- get_cached_by_ap_id(uri) do user else - changes = - %User{info: %User.Info{}} - |> cast(%{}, [:ap_id, :nickname, :local]) - |> put_change(:ap_id, uri) - |> put_change(:nickname, nickname) - |> put_change(:local, true) - |> put_change(:follower_address, uri <> "/followers") - - {:ok, user} = Repo.insert(changes) - user + _ -> + {:ok, user} = + %User{info: %User.Info{}} + |> cast(%{}, [:ap_id, :nickname, :local]) + |> put_change(:ap_id, uri) + |> put_change(:nickname, nickname) + |> put_change(:local, true) + |> put_change(:follower_address, uri <> "/followers") + |> Repo.insert() + + user end end @@ -1366,23 +1342,21 @@ defmodule Pleroma.User do # this is because we have synchronous follow APIs and need to simulate them # with an async handshake def wait_and_refresh(_, %User{local: true} = a, %User{local: true} = b) do - with %User{} = a <- User.get_cached_by_id(a.id), - %User{} = b <- User.get_cached_by_id(b.id) do + with %User{} = a <- get_cached_by_id(a.id), + %User{} = b <- get_cached_by_id(b.id) do {:ok, a, b} else - _e -> - :error + nil -> :error end end def wait_and_refresh(timeout, %User{} = a, %User{} = b) do with :ok <- :timer.sleep(timeout), - %User{} = a <- User.get_cached_by_id(a.id), - %User{} = b <- User.get_cached_by_id(b.id) do + %User{} = a <- get_cached_by_id(a.id), + %User{} = b <- get_cached_by_id(b.id) do {:ok, a, b} else - _e -> - :error + nil -> :error end end @@ -1444,7 +1418,7 @@ defmodule Pleroma.User do defp normalize_tags(tags) do [tags] |> List.flatten() - |> Enum.map(&String.downcase(&1)) + |> Enum.map(&String.downcase/1) end defp local_nickname_regex do @@ -1537,11 +1511,7 @@ defmodule Pleroma.User do @spec switch_email_notifications(t(), String.t(), boolean()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} def switch_email_notifications(user, type, status) do - info = Pleroma.User.Info.update_email_notifications(user.info, %{type => status}) - - change(user) - |> put_embed(:info, info) - |> update_and_set_cache() + update_info(user, &User.Info.update_email_notifications(&1, %{type => status})) end @doc """ @@ -1563,13 +1533,8 @@ defmodule Pleroma.User do def toggle_confirmation(%User{} = user) do need_confirmation? = !user.info.confirmation_pending - info_changeset = - User.Info.confirmation_changeset(user.info, need_confirmation: need_confirmation?) - user - |> change() - |> put_embed(:info, info_changeset) - |> update_and_set_cache() + |> update_info(&User.Info.confirmation_changeset(&1, need_confirmation: need_confirmation?)) end def get_mascot(%{info: %{mascot: %{} = mascot}}) when not is_nil(mascot) do @@ -1592,15 +1557,13 @@ defmodule Pleroma.User do } end - def ensure_keys_present(%User{info: info} = user) do - if info.keys do - {:ok, user} - else - {:ok, pem} = Keys.generate_rsa_pem() + def ensure_keys_present(%{keys: keys} = user) when not is_nil(keys), do: {:ok, user} + def ensure_keys_present(%User{} = user) do + with {:ok, pem} <- Keys.generate_rsa_pem() do user - |> Ecto.Changeset.change() - |> Ecto.Changeset.put_embed(:info, User.Info.set_keys(info, pem)) + |> cast(%{keys: pem}, [:keys]) + |> validate_required([:keys]) |> update_and_set_cache() end end @@ -1626,4 +1589,47 @@ defmodule Pleroma.User do def is_internal_user?(%User{nickname: nil}), do: true def is_internal_user?(%User{local: true, nickname: "internal." <> _}), do: true def is_internal_user?(_), do: false + + # A hack because user delete activities have a fake id for whatever reason + # TODO: Get rid of this + def get_delivered_users_by_object_id("pleroma:fake_object_id"), do: [] + + def get_delivered_users_by_object_id(object_id) do + from(u in User, + inner_join: delivery in assoc(u, :deliveries), + where: delivery.object_id == ^object_id + ) + |> Repo.all() + end + + def change_email(user, email) do + user + |> cast(%{email: email}, [:email]) + |> validate_required([:email]) + |> unique_constraint(:email) + |> validate_format(:email, @email_regex) + |> update_and_set_cache() + end + + @doc """ + Changes `user.info` and returns the user changeset. + + `fun` is called with the `user.info`. + """ + def change_info(user, fun) do + changeset = change(user) + info = get_field(changeset, :info) || %User.Info{} + put_embed(changeset, :info, fun.(info)) + end + + @doc """ + Updates `user.info` and sets cache. + + `fun` is called with the `user.info`. + """ + def update_info(user, fun) do + user + |> change_info(fun) + |> update_and_set_cache() + end end diff --git a/lib/pleroma/user/info.ex b/lib/pleroma/user/info.ex index 779bfbc18..4b5b43d7f 100644 --- a/lib/pleroma/user/info.ex +++ b/lib/pleroma/user/info.ex @@ -20,6 +20,7 @@ defmodule Pleroma.User.Info do field(:following_count, :integer, default: nil) field(:locked, :boolean, default: false) field(:confirmation_pending, :boolean, default: false) + field(:password_reset_pending, :boolean, default: false) field(:confirmation_token, :string, default: nil) field(:default_scope, :string, default: "public") field(:blocks, {:array, :string}, default: []) @@ -41,9 +42,12 @@ defmodule Pleroma.User.Info do field(:topic, :string, default: nil) field(:hub, :string, default: nil) field(:salmon, :string, default: nil) + field(:hide_followers_count, :boolean, default: false) + field(:hide_follows_count, :boolean, default: false) field(:hide_followers, :boolean, default: false) field(:hide_follows, :boolean, default: false) field(:hide_favorites, :boolean, default: true) + field(:unread_conversation_count, :integer, default: 0) field(:pinned_activities, {:array, :string}, default: []) field(:email_notifications, :map, default: %{"digest" => false}) field(:mascot, :map, default: nil) @@ -51,6 +55,7 @@ defmodule Pleroma.User.Info do field(:pleroma_settings_store, :map, default: %{}) field(:fields, {:array, :map}, default: nil) field(:raw_fields, {:array, :map}, default: []) + field(:discoverable, :boolean, default: false) field(:notification_settings, :map, default: %{ @@ -80,6 +85,14 @@ defmodule Pleroma.User.Info do |> validate_required([:deactivated]) end + def set_password_reset_pending(info, pending) do + params = %{password_reset_pending: pending} + + info + |> cast(params, [:password_reset_pending]) + |> validate_required([:password_reset_pending]) + end + def update_notification_settings(info, settings) do settings = settings @@ -176,16 +189,11 @@ defmodule Pleroma.User.Info do |> validate_required([:subscribers]) end - @spec add_to_mutes(Info.t(), String.t()) :: Changeset.t() - def add_to_mutes(info, muted) do - set_mutes(info, Enum.uniq([muted | info.mutes])) - end - - @spec add_to_muted_notifications(Changeset.t(), Info.t(), String.t(), boolean()) :: - Changeset.t() - def add_to_muted_notifications(changeset, info, muted, notifications?) do - set_notification_mutes( - changeset, + @spec add_to_mutes(Info.t(), String.t(), boolean()) :: Changeset.t() + def add_to_mutes(info, muted, notifications?) do + info + |> set_mutes(Enum.uniq([muted | info.mutes])) + |> set_notification_mutes( Enum.uniq([muted | info.muted_notifications]), notifications? ) @@ -193,12 +201,9 @@ defmodule Pleroma.User.Info do @spec remove_from_mutes(Info.t(), String.t()) :: Changeset.t() def remove_from_mutes(info, muted) do - set_mutes(info, List.delete(info.mutes, muted)) - end - - @spec remove_from_muted_notifications(Changeset.t(), Info.t(), String.t()) :: Changeset.t() - def remove_from_muted_notifications(changeset, info, muted) do - set_notification_mutes(changeset, List.delete(info.muted_notifications, muted), true) + info + |> set_mutes(List.delete(info.mutes, muted)) + |> set_notification_mutes(List.delete(info.muted_notifications, muted), true) end def add_to_block(info, blocked) do @@ -242,6 +247,13 @@ defmodule Pleroma.User.Info do end def remote_user_creation(info, params) do + params = + if Map.has_key?(params, :fields) do + Map.put(params, :fields, Enum.map(params[:fields], &truncate_field/1)) + else + params + end + info |> cast(params, [ :ap_enabled, @@ -255,9 +267,12 @@ defmodule Pleroma.User.Info do :salmon, :hide_followers, :hide_follows, + :hide_followers_count, + :hide_follows_count, :follower_count, :fields, - :following_count + :following_count, + :discoverable ]) |> validate_fields(true) end @@ -274,7 +289,10 @@ defmodule Pleroma.User.Info do :following_count, :hide_follows, :fields, - :hide_followers + :hide_followers, + :discoverable, + :hide_followers_count, + :hide_follows_count ]) |> validate_fields(remote?) end @@ -288,13 +306,16 @@ defmodule Pleroma.User.Info do :banner, :hide_follows, :hide_followers, + :hide_followers_count, + :hide_follows_count, :hide_favorites, :background, :show_role, :skip_thread_containment, :fields, :raw_fields, - :pleroma_settings_store + :pleroma_settings_store, + :discoverable ]) |> validate_fields() end @@ -318,14 +339,22 @@ defmodule Pleroma.User.Info do name_limit = Pleroma.Config.get([:instance, :account_field_name_length], 255) value_limit = Pleroma.Config.get([:instance, :account_field_value_length], 255) - is_binary(name) && - is_binary(value) && - String.length(name) <= name_limit && + is_binary(name) && is_binary(value) && String.length(name) <= name_limit && String.length(value) <= value_limit end defp valid_field?(_), do: false + defp truncate_field(%{"name" => name, "value" => value}) do + {name, _chopped} = + String.split_at(name, Pleroma.Config.get([:instance, :account_field_name_length], 255)) + + {value, _chopped} = + String.split_at(value, Pleroma.Config.get([:instance, :account_field_value_length], 255)) + + %{"name" => name, "value" => value} + end + @spec confirmation_changeset(Info.t(), keyword()) :: Changeset.t() def confirmation_changeset(info, opts) do need_confirmation? = Keyword.get(opts, :need_confirmation) @@ -441,7 +470,9 @@ defmodule Pleroma.User.Info do :hide_followers, :hide_follows, :follower_count, - :following_count + :following_count, + :hide_followers_count, + :hide_follows_count ]) end end diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex index f9bcc9e19..2baf016cf 100644 --- a/lib/pleroma/user/query.ex +++ b/lib/pleroma/user/query.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.User.Query do diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index f2b322314..b1dee010b 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -4,6 +4,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do alias Pleroma.Activity + alias Pleroma.Activity.Ir.Topics alias Pleroma.Config alias Pleroma.Conversation alias Pleroma.Notification @@ -16,7 +17,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do alias Pleroma.User alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.Streamer alias Pleroma.Web.WebFinger + alias Pleroma.Workers.BackgroundWorker import Ecto.Query import Pleroma.Web.ActivityPub.Utils @@ -145,7 +149,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do activity end - PleromaJobQueue.enqueue(:background, Pleroma.Web.RichMedia.Helpers, [:fetch, activity]) + BackgroundWorker.enqueue("fetch_data_for_activity", %{"activity_id" => activity.id}) Notification.create_notifications(activity) @@ -186,9 +190,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do participations |> Repo.preload(:user) - Enum.each(participations, fn participation -> - Pleroma.Web.Streamer.stream("participation", participation) - end) + Streamer.stream("participation", participations) end def stream_out_participations(%Object{data: %{"context" => context}}, user) do @@ -207,41 +209,15 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do def stream_out_participations(_, _), do: :noop - def stream_out(activity) do - if activity.data["type"] in ["Create", "Announce", "Delete"] do - object = Object.normalize(activity) - # Do not stream out poll replies - unless object.data["type"] == "Answer" do - Pleroma.Web.Streamer.stream("user", activity) - Pleroma.Web.Streamer.stream("list", activity) - - if get_visibility(activity) == "public" do - Pleroma.Web.Streamer.stream("public", activity) - - if activity.local do - Pleroma.Web.Streamer.stream("public:local", activity) - end - - if activity.data["type"] in ["Create"] do - object.data - |> Map.get("tag", []) - |> Enum.filter(fn tag -> is_bitstring(tag) end) - |> Enum.each(fn tag -> Pleroma.Web.Streamer.stream("hashtag:" <> tag, activity) end) - - if object.data["attachment"] != [] do - Pleroma.Web.Streamer.stream("public:media", activity) - - if activity.local do - Pleroma.Web.Streamer.stream("public:local:media", activity) - end - end - end - else - if get_visibility(activity) == "direct", - do: Pleroma.Web.Streamer.stream("direct", activity) - end - end - end + def stream_out(%Activity{data: %{"type" => data_type}} = activity) + when data_type in ["Create", "Announce", "Delete"] do + activity + |> Topics.get_activity_topics() + |> Streamer.stream(activity) + end + + def stream_out(_activity) do + :noop end def create(%{to: to, actor: actor, context: context, object: object} = params, fake \\ false) do @@ -278,6 +254,26 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end end + def listen(%{to: to, actor: actor, context: context, object: object} = params) do + additional = params[:additional] || %{} + # only accept false as false value + local = !(params[:local] == false) + published = params[:published] + + with listen_data <- + make_listen_data( + %{to: to, actor: actor, published: published, context: context, object: object}, + additional + ), + {:ok, activity} <- insert(listen_data, local), + :ok <- maybe_federate(activity) do + {:ok, activity} + else + {:error, message} -> + {:error, message} + end + end + def accept(%{to: to, actor: actor, object: object} = params) do # only accept false as false value local = !(params[:local] == false) @@ -301,8 +297,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end def update(%{to: to, cc: cc, actor: actor, object: object} = params) do - # only accept false as false value local = !(params[:local] == false) + activity_id = params[:activity_id] with data <- %{ "to" => to, @@ -311,6 +307,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do "actor" => actor, "object" => object }, + data <- Utils.maybe_put(data, "id", activity_id), {:ok, activity} <- insert(data, local), :ok <- maybe_federate(activity) do {:ok, activity} @@ -356,7 +353,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), @@ -440,6 +437,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end end + @spec block(User.t(), User.t(), String.t() | nil, boolean) :: {:ok, Activity.t() | nil} def block(blocker, blocked, activity_id \\ nil, local \\ true) do outgoing_blocks = Config.get([:activitypub, :outgoing_blocks]) unfollow_blocked = Config.get([:activitypub, :unfollow_blocked]) @@ -468,10 +466,11 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end end + @spec flag(map()) :: {:ok, Activity.t()} | any def flag( %{ actor: actor, - context: context, + context: _context, account: account, statuses: statuses, content: content @@ -483,14 +482,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do additional = params[:additional] || %{} - params = %{ - actor: actor, - context: context, - account: account, - statuses: statuses, - content: content - } - additional = if forward do Map.merge(additional, %{"to" => [], "cc" => [account.ap_id]}) @@ -546,7 +537,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end @spec fetch_latest_activity_id_for_context(String.t(), keyword() | map()) :: - Pleroma.FlakeId.t() | nil + FlakeId.Ecto.CompatType.t() | nil def fetch_latest_activity_id_for_context(context, opts \\ %{}) do context |> fetch_activities_for_context_query(Map.merge(%{"skip_preload" => true}, opts)) @@ -555,12 +546,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> Repo.one() end - def fetch_public_activities(opts \\ %{}) do - q = fetch_activities_query([Pleroma.Constants.as_public()], opts) + def fetch_public_activities(opts \\ %{}, pagination \\ :keyset) do + opts = Map.drop(opts, ["user"]) - q + [Pleroma.Constants.as_public()] + |> fetch_activities_query(opts) |> restrict_unlisted() - |> Pagination.fetch_paginated(opts) + |> Pagination.fetch_paginated(opts, pagination) |> Enum.reverse() end @@ -623,6 +615,23 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_thread_visibility(query, _, _), do: query + def fetch_user_abstract_activities(user, reading_user, params \\ %{}) do + params = + params + |> Map.put("user", reading_user) + |> Map.put("actor_id", user.ap_id) + |> Map.put("whole_db", true) + + recipients = + user_activities_recipients(%{ + "godmode" => params["godmode"], + "reading_user" => reading_user + }) + + fetch_activities(recipients, params) + |> Enum.reverse() + end + def fetch_user_activities(user, reading_user, params \\ %{}) do params = params @@ -776,8 +785,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_replies(query, %{"exclude_replies" => val}) when val == "true" or val == "1" do from( - activity in query, - where: fragment("?->'object'->>'inReplyTo' is null", activity.data) + [_activity, object] in query, + where: fragment("?->>'inReplyTo' is null", object.data) ) end @@ -801,7 +810,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do ) unless opts["skip_preload"] do - from([thread_mute: tm] in query, where: is_nil(tm)) + from([thread_mute: tm] in query, where: is_nil(tm.user_id)) else query end @@ -869,7 +878,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_muted_reblogs(query, _), do: query - defp exclude_poll_votes(query, %{"include_poll_votes" => "true"}), do: query + defp exclude_poll_votes(query, %{"include_poll_votes" => true}), do: query defp exclude_poll_votes(query, _) do if has_named_binding?(query, :object) do @@ -953,11 +962,11 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> exclude_poll_votes(opts) end - def fetch_activities(recipients, opts \\ %{}) do + def fetch_activities(recipients, opts \\ %{}, pagination \\ :keyset) do list_memberships = Pleroma.List.memberships(opts["user"]) fetch_activities_query(recipients ++ list_memberships, opts) - |> Pagination.fetch_paginated(opts) + |> Pagination.fetch_paginated(opts, pagination) |> Enum.reverse() |> maybe_update_cc(list_memberships, opts["user"]) end @@ -988,10 +997,15 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do ) end - def fetch_activities_bounded(recipients, recipients_with_public, opts \\ %{}) do + def fetch_activities_bounded( + recipients, + recipients_with_public, + opts \\ %{}, + pagination \\ :keyset + ) do fetch_activities_query([], opts) |> fetch_activities_bounded_query(recipients, recipients_with_public) - |> Pagination.fetch_paginated(opts) + |> Pagination.fetch_paginated(opts, pagination) |> Enum.reverse() end @@ -1031,6 +1045,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do locked = data["manuallyApprovesFollowers"] || false data = Transmogrifier.maybe_fix_user_object(data) + discoverable = data["discoverable"] || false user_data = %{ ap_id: data["id"], @@ -1039,7 +1054,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do source_data: data, banner: banner, fields: fields, - locked: locked + locked: locked, + discoverable: discoverable }, avatar: avatar, name: data["name"], diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 08bf1c752..080030eb5 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do use Pleroma.Web, :controller alias Pleroma.Activity + alias Pleroma.Delivery alias Pleroma.Object alias Pleroma.Object.Fetcher alias Pleroma.User @@ -23,6 +24,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do action_fallback(:errors) + plug( + Pleroma.Plugs.Cache, + [query_params: false, tracking_fun: &__MODULE__.track_object_fetch/2] + when action in [:activity, :object] + ) + plug(Pleroma.Web.FederatingPlug when action in [:inbox, :relay]) plug(:set_requester_reachable when action in [:inbox]) plug(:relay_active? when action in [:relay]) @@ -42,7 +49,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do {:ok, user} <- User.ensure_keys_present(user) do conn |> put_resp_content_type("application/activity+json") - |> json(UserView.render("user.json", %{user: user})) + |> put_view(UserView) + |> render("user.json", %{user: user}) else nil -> {:error, :not_found} end @@ -53,42 +61,25 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do %Object{} = object <- Object.get_cached_by_ap_id(ap_id), {_, true} <- {:public?, Visibility.is_public?(object)} do conn + |> assign(:tracking_fun_data, object.id) + |> set_cache_ttl_for(object) |> put_resp_content_type("application/activity+json") - |> json(ObjectView.render("object.json", %{object: object})) + |> put_view(ObjectView) + |> render("object.json", object: object) else {:public?, false} -> {:error, :not_found} end end - def object_likes(conn, %{"uuid" => uuid, "page" => page}) do - with ap_id <- o_status_url(conn, :object, uuid), - %Object{} = object <- Object.get_cached_by_ap_id(ap_id), - {_, true} <- {:public?, Visibility.is_public?(object)}, - likes <- Utils.get_object_likes(object) do - {page, _} = Integer.parse(page) + def track_object_fetch(conn, nil), do: conn - conn - |> put_resp_content_type("application/activity+json") - |> json(ObjectView.render("likes.json", ap_id, likes, page)) - else - {:public?, false} -> - {:error, :not_found} + def track_object_fetch(conn, object_id) do + with %{assigns: %{user: %User{id: user_id}}} <- conn do + Delivery.create(object_id, user_id) end - end - def object_likes(conn, %{"uuid" => uuid}) do - with ap_id <- o_status_url(conn, :object, uuid), - %Object{} = object <- Object.get_cached_by_ap_id(ap_id), - {_, true} <- {:public?, Visibility.is_public?(object)}, - likes <- Utils.get_object_likes(object) do - conn - |> put_resp_content_type("application/activity+json") - |> json(ObjectView.render("likes.json", ap_id, likes)) - else - {:public?, false} -> - {:error, :not_found} - end + conn end def activity(conn, %{"uuid" => uuid}) do @@ -96,19 +87,50 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do %Activity{} = activity <- Activity.normalize(ap_id), {_, true} <- {:public?, Visibility.is_public?(activity)} do conn + |> maybe_set_tracking_data(activity) + |> set_cache_ttl_for(activity) |> put_resp_content_type("application/activity+json") - |> json(ObjectView.render("object.json", %{object: activity})) + |> put_view(ObjectView) + |> render("object.json", object: activity) else - {:public?, false} -> - {:error, :not_found} + {:public?, false} -> {:error, :not_found} + nil -> {:error, :not_found} end end + defp maybe_set_tracking_data(conn, %Activity{data: %{"type" => "Create"}} = activity) do + object_id = Object.normalize(activity).id + assign(conn, :tracking_fun_data, object_id) + end + + defp maybe_set_tracking_data(conn, _activity), do: conn + + defp set_cache_ttl_for(conn, %Activity{object: object}) do + set_cache_ttl_for(conn, object) + end + + defp set_cache_ttl_for(conn, entity) do + ttl = + case entity do + %Object{data: %{"type" => "Question"}} -> + Pleroma.Config.get([:web_cache_ttl, :activity_pub_question]) + + %Object{} -> + Pleroma.Config.get([:web_cache_ttl, :activity_pub]) + + _ -> + nil + end + + assign(conn, :cache_ttl, ttl) + end + # GET /relay/following def following(%{assigns: %{relay: true}} = conn, _params) do conn |> put_resp_content_type("application/activity+json") - |> json(UserView.render("following.json", %{user: Relay.get_actor()})) + |> put_view(UserView) + |> render("following.json", %{user: Relay.get_actor()}) end def following(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname, "page" => page}) do @@ -120,7 +142,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do conn |> put_resp_content_type("application/activity+json") - |> json(UserView.render("following.json", %{user: user, page: page, for: for_user})) + |> put_view(UserView) + |> render("following.json", %{user: user, page: page, for: for_user}) else {:show_follows, _} -> conn @@ -134,7 +157,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do {user, for_user} <- ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user) do conn |> put_resp_content_type("application/activity+json") - |> json(UserView.render("following.json", %{user: user, for: for_user})) + |> put_view(UserView) + |> render("following.json", %{user: user, for: for_user}) end end @@ -142,7 +166,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do def followers(%{assigns: %{relay: true}} = conn, _params) do conn |> put_resp_content_type("application/activity+json") - |> json(UserView.render("followers.json", %{user: Relay.get_actor()})) + |> put_view(UserView) + |> render("followers.json", %{user: Relay.get_actor()}) end def followers(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname, "page" => page}) do @@ -154,7 +179,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do conn |> put_resp_content_type("application/activity+json") - |> json(UserView.render("followers.json", %{user: user, page: page, for: for_user})) + |> put_view(UserView) + |> render("followers.json", %{user: user, page: page, for: for_user}) else {:show_followers, _} -> conn @@ -168,16 +194,48 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do {user, for_user} <- ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user) do conn |> put_resp_content_type("application/activity+json") - |> json(UserView.render("followers.json", %{user: user, for: for_user})) + |> put_view(UserView) + |> render("followers.json", %{user: user, for: for_user}) end end - def outbox(conn, %{"nickname" => nickname} = params) do + def outbox(conn, %{"nickname" => nickname, "page" => page?} = params) + when page? in [true, "true"] do with %User{} = user <- User.get_cached_by_nickname(nickname), {:ok, user} <- User.ensure_keys_present(user) do + activities = + if params["max_id"] do + ActivityPub.fetch_user_activities(user, nil, %{ + "max_id" => params["max_id"], + # This is a hack because postgres generates inefficient queries when filtering by + # 'Answer', poll votes will be hidden by the visibility filter in this case anyway + "include_poll_votes" => true, + "limit" => 10 + }) + else + ActivityPub.fetch_user_activities(user, nil, %{ + "limit" => 10, + "include_poll_votes" => true + }) + end + conn |> put_resp_content_type("application/activity+json") - |> json(UserView.render("outbox.json", %{user: user, max_id: params["max_id"]})) + |> put_view(UserView) + |> render("activity_collection_page.json", %{ + activities: activities, + iri: "#{user.ap_id}/outbox" + }) + end + end + + def outbox(conn, %{"nickname" => nickname}) do + with %User{} = user <- User.get_cached_by_nickname(nickname), + {:ok, user} <- User.ensure_keys_present(user) do + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("activity_collection.json", %{iri: "#{user.ap_id}/outbox"}) end end @@ -225,7 +283,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do with {:ok, user} <- User.ensure_keys_present(user) do conn |> put_resp_content_type("application/activity+json") - |> json(UserView.render("user.json", %{user: user})) + |> put_view(UserView) + |> render("user.json", %{user: user}) else nil -> {:error, :not_found} end @@ -243,32 +302,73 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do |> represent_service_actor(conn) end + @doc "Returns the authenticated user's ActivityPub User object or a 404 Not Found if non-authenticated" def whoami(%{assigns: %{user: %User{} = user}} = conn, _params) do conn |> put_resp_content_type("application/activity+json") - |> json(UserView.render("user.json", %{user: user})) + |> put_view(UserView) + |> render("user.json", %{user: user}) end def whoami(_conn, _params), do: {:error, :not_found} - def read_inbox(%{assigns: %{user: user}} = conn, %{"nickname" => nickname} = params) do - if nickname == user.nickname do - conn - |> put_resp_content_type("application/activity+json") - |> json(UserView.render("inbox.json", %{user: user, max_id: params["max_id"]})) - else - err = - dgettext("errors", "can't read inbox of %{nickname} as %{as_nickname}", - nickname: nickname, - as_nickname: user.nickname - ) + def read_inbox( + %{assigns: %{user: %{nickname: nickname} = user}} = conn, + %{"nickname" => nickname, "page" => page?} = params + ) + when page? in [true, "true"] do + activities = + if params["max_id"] do + ActivityPub.fetch_activities([user.ap_id | user.following], %{ + "max_id" => params["max_id"], + "limit" => 10 + }) + else + ActivityPub.fetch_activities([user.ap_id | user.following], %{"limit" => 10}) + end + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("activity_collection_page.json", %{ + activities: activities, + iri: "#{user.ap_id}/inbox" + }) + end + + def read_inbox(%{assigns: %{user: %{nickname: nickname} = user}} = conn, %{ + "nickname" => nickname + }) do + with {:ok, user} <- User.ensure_keys_present(user) do conn - |> put_status(:forbidden) - |> json(err) + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("activity_collection.json", %{iri: "#{user.ap_id}/inbox"}) end end + def read_inbox(%{assigns: %{user: nil}} = conn, %{"nickname" => nickname}) do + err = dgettext("errors", "can't read inbox of %{nickname}", nickname: nickname) + + conn + |> put_status(:forbidden) + |> json(err) + end + + def read_inbox(%{assigns: %{user: %{nickname: as_nickname}}} = conn, %{ + "nickname" => nickname + }) do + err = + dgettext("errors", "can't read inbox of %{nickname} as %{as_nickname}", + nickname: nickname, + as_nickname: as_nickname + ) + + conn + |> put_status(:forbidden) + |> json(err) + end + def handle_user_activity(user, %{"type" => "Create"} = params) do object = params["object"] @@ -378,4 +478,31 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do {new_user, for_user} end + + # TODO: Add support for "object" field + @doc """ + Endpoint based on <https://www.w3.org/wiki/SocialCG/ActivityPub/MediaUpload> + + Parameters: + - (required) `file`: data of the media + - (optionnal) `description`: description of the media, intended for accessibility + + Response: + - HTTP Code: 201 Created + - HTTP Body: ActivityPub object to be inserted into another's `attachment` field + """ + def upload_media(%{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 + Logger.debug(inspect(object)) + + conn + |> put_status(:created) + |> json(object.data) + end + end end diff --git a/lib/pleroma/web/activity_pub/mrf/mediaproxy_warming_policy.ex b/lib/pleroma/web/activity_pub/mrf/mediaproxy_warming_policy.ex index a179dd54d..26b8539fe 100644 --- a/lib/pleroma/web/activity_pub/mrf/mediaproxy_warming_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/mediaproxy_warming_policy.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do alias Pleroma.HTTP alias Pleroma.Web.MediaProxy + alias Pleroma.Workers.BackgroundWorker require Logger @@ -30,7 +31,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do url |> Enum.each(fn %{"href" => href} -> - PleromaJobQueue.enqueue(:background, __MODULE__, [:prefetch, href]) + BackgroundWorker.enqueue("media_proxy_prefetch", %{"url" => href}) x -> Logger.debug("Unhandled attachment URL object #{inspect(x)}") @@ -46,7 +47,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do %{"type" => "Create", "object" => %{"attachment" => attachments} = _object} = message ) when is_list(attachments) and length(attachments) > 0 do - PleromaJobQueue.enqueue(:background, __MODULE__, [:preload, message]) + BackgroundWorker.enqueue("media_proxy_preload", %{"message" => message}) {:ok, message} end diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index 8aa6852f0..8e53296e7 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -168,7 +168,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do when obj_type in ["Application", "Group", "Organization", "Person", "Service"] do actor_info = URI.parse(actor) - with {:ok, object} <- check_avatar_removal(actor_info, object), + with {:ok, object} <- check_accept(actor_info, object), + {:ok, object} <- check_reject(actor_info, object), + {:ok, object} <- check_avatar_removal(actor_info, object), {:ok, object} <- check_banner_removal(actor_info, object) do {:ok, object} else diff --git a/lib/pleroma/web/activity_pub/publisher.ex b/lib/pleroma/web/activity_pub/publisher.ex index c97405690..3866dacee 100644 --- a/lib/pleroma/web/activity_pub/publisher.ex +++ b/lib/pleroma/web/activity_pub/publisher.ex @@ -5,8 +5,10 @@ defmodule Pleroma.Web.ActivityPub.Publisher do alias Pleroma.Activity alias Pleroma.Config + alias Pleroma.Delivery alias Pleroma.HTTP alias Pleroma.Instances + alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.ActivityPub.Transmogrifier @@ -84,6 +86,15 @@ defmodule Pleroma.Web.ActivityPub.Publisher do end end + def publish_one(%{actor_id: actor_id} = params) do + actor = User.get_cached_by_id(actor_id) + + params + |> Map.delete(:actor_id) + |> Map.put(:actor, actor) + |> publish_one() + end + defp should_federate?(inbox, public) do if public do true @@ -100,14 +111,25 @@ defmodule Pleroma.Web.ActivityPub.Publisher do @spec recipients(User.t(), Activity.t()) :: list(User.t()) | [] defp recipients(actor, activity) do - {:ok, followers} = + followers = if actor.follower_address in activity.recipients do User.get_external_followers(actor) else - {:ok, []} + [] + end + + fetchers = + with %Activity{data: %{"type" => "Delete"}} <- activity, + %Object{id: object_id} <- Object.normalize(activity), + fetchers <- User.get_delivered_users_by_object_id(object_id), + _ <- Delivery.delete_all_by_object_id(object_id) do + fetchers + else + _ -> + [] end - Pleroma.Web.Salmon.remote_users(actor, activity) ++ followers + Pleroma.Web.Salmon.remote_users(actor, activity) ++ followers ++ fetchers end defp get_cc_ap_ids(ap_id, recipients) do @@ -159,7 +181,8 @@ defmodule Pleroma.Web.ActivityPub.Publisher do Publishes an activity with BCC to all relevant peers. """ - def publish(actor, %{data: %{"bcc" => bcc}} = activity) when is_list(bcc) and bcc != [] do + def publish(%User{} = actor, %{data: %{"bcc" => bcc}} = activity) + when is_list(bcc) and bcc != [] do public = is_public?(activity) {:ok, data} = Transmogrifier.prepare_outgoing(activity.data) @@ -186,7 +209,7 @@ defmodule Pleroma.Web.ActivityPub.Publisher do Pleroma.Web.Federator.Publisher.enqueue_one(__MODULE__, %{ inbox: inbox, json: json, - actor: actor, + actor_id: actor.id, id: activity.data["id"], unreachable_since: unreachable_since }) @@ -221,7 +244,7 @@ defmodule Pleroma.Web.ActivityPub.Publisher do %{ inbox: inbox, json: json, - actor: actor, + actor_id: actor.id, id: activity.data["id"], unreachable_since: unreachable_since } diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 468961bd0..872ed0eb2 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -15,6 +15,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.Federator + alias Pleroma.Workers.TransmogrifierWorker import Ecto.Query @@ -41,8 +42,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do end def fix_summary(%{"summary" => nil} = object) do - object - |> Map.put("summary", "") + Map.put(object, "summary", "") end def fix_summary(%{"summary" => _} = object) do @@ -50,10 +50,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do object end - def fix_summary(object) do - object - |> Map.put("summary", "") - end + def fix_summary(object), do: Map.put(object, "summary", "") def fix_addressing_list(map, field) do cond do @@ -73,13 +70,9 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do explicit_mentions, follower_collection ) do - explicit_to = - to - |> Enum.filter(fn x -> x in explicit_mentions end) + explicit_to = Enum.filter(to, fn x -> x in explicit_mentions end) - explicit_cc = - to - |> Enum.filter(fn x -> x not in explicit_mentions end) + explicit_cc = Enum.filter(to, fn x -> x not in explicit_mentions end) final_cc = (cc ++ explicit_cc) @@ -97,13 +90,19 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def fix_explicit_addressing(%{"directMessage" => true} = object), do: object def fix_explicit_addressing(object) do - explicit_mentions = - object - |> Utils.determine_explicit_mentions() + explicit_mentions = Utils.determine_explicit_mentions(object) - follower_collection = User.get_cached_by_ap_id(Containment.get_actor(object)).follower_address + %User{follower_address: follower_collection} = + object + |> Containment.get_actor() + |> User.get_cached_by_ap_id() - explicit_mentions = explicit_mentions ++ [Pleroma.Constants.as_public(), follower_collection] + explicit_mentions = + explicit_mentions ++ + [ + Pleroma.Constants.as_public(), + follower_collection + ] fix_explicit_addressing(object, explicit_mentions, follower_collection) end @@ -147,50 +146,27 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do end def fix_actor(%{"attributedTo" => actor} = object) do - object - |> Map.put("actor", Containment.get_actor(%{"actor" => actor})) + Map.put(object, "actor", Containment.get_actor(%{"actor" => actor})) end def fix_in_reply_to(object, options \\ []) def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object, options) when not is_nil(in_reply_to) do - in_reply_to_id = - cond do - is_bitstring(in_reply_to) -> - in_reply_to - - is_map(in_reply_to) && is_bitstring(in_reply_to["id"]) -> - in_reply_to["id"] - - is_list(in_reply_to) && is_bitstring(Enum.at(in_reply_to, 0)) -> - Enum.at(in_reply_to, 0) - - # Maybe I should output an error too? - true -> - "" - end - + in_reply_to_id = prepare_in_reply_to(in_reply_to) object = Map.put(object, "inReplyToAtomUri", in_reply_to_id) if Federator.allowed_incoming_reply_depth?(options[:depth]) do - case get_obj_helper(in_reply_to_id, options) do - {:ok, replied_object} -> - with %Activity{} = _activity <- - Activity.get_create_by_object_ap_id(replied_object.data["id"]) do - object - |> Map.put("inReplyTo", replied_object.data["id"]) - |> Map.put("inReplyToAtomUri", object["inReplyToAtomUri"] || in_reply_to_id) - |> Map.put("conversation", replied_object.data["context"] || object["conversation"]) - |> Map.put("context", replied_object.data["context"] || object["conversation"]) - else - e -> - Logger.error("Couldn't fetch \"#{inspect(in_reply_to_id)}\", error: #{inspect(e)}") - object - end - + with {:ok, replied_object} <- get_obj_helper(in_reply_to_id, options), + %Activity{} = _ <- Activity.get_create_by_object_ap_id(replied_object.data["id"]) do + object + |> Map.put("inReplyTo", replied_object.data["id"]) + |> Map.put("inReplyToAtomUri", object["inReplyToAtomUri"] || in_reply_to_id) + |> Map.put("conversation", replied_object.data["context"] || object["conversation"]) + |> Map.put("context", replied_object.data["context"] || object["conversation"]) + else e -> - Logger.error("Couldn't fetch \"#{inspect(in_reply_to_id)}\", error: #{inspect(e)}") + Logger.error("Couldn't fetch #{inspect(in_reply_to_id)}, error: #{inspect(e)}") object end else @@ -200,6 +176,22 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def fix_in_reply_to(object, _options), do: object + defp prepare_in_reply_to(in_reply_to) do + cond do + is_bitstring(in_reply_to) -> + in_reply_to + + is_map(in_reply_to) && is_bitstring(in_reply_to["id"]) -> + in_reply_to["id"] + + is_list(in_reply_to) && is_bitstring(Enum.at(in_reply_to, 0)) -> + Enum.at(in_reply_to, 0) + + true -> + "" + end + end + def fix_context(object) do context = object["context"] || object["conversation"] || Utils.generate_context_id() @@ -210,11 +202,9 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do attachments = - attachment - |> Enum.map(fn data -> + Enum.map(attachment, fn data -> media_type = data["mediaType"] || data["mimeType"] href = data["url"] || data["href"] - url = [%{"type" => "Link", "mediaType" => media_type, "href" => href}] data @@ -222,30 +212,25 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do |> Map.put("url", url) end) - object - |> Map.put("attachment", attachments) + Map.put(object, "attachment", attachments) end def fix_attachments(%{"attachment" => attachment} = object) when is_map(attachment) do - Map.put(object, "attachment", [attachment]) + object + |> Map.put("attachment", [attachment]) |> fix_attachments() end def fix_attachments(object), do: object def fix_url(%{"url" => url} = object) when is_map(url) do - object - |> Map.put("url", url["href"]) + Map.put(object, "url", url["href"]) end def fix_url(%{"type" => "Video", "url" => url} = object) when is_list(url) do first_element = Enum.at(url, 0) - link_element = - url - |> Enum.filter(fn x -> is_map(x) end) - |> Enum.filter(fn x -> x["mimeType"] == "text/html" end) - |> Enum.at(0) + link_element = Enum.find(url, fn x -> is_map(x) and x["mimeType"] == "text/html" end) object |> Map.put("attachment", [first_element]) @@ -263,36 +248,32 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do true -> "" end - object - |> Map.put("url", url_string) + Map.put(object, "url", url_string) end def fix_url(object), do: object def fix_emoji(%{"tag" => tags} = object) when is_list(tags) do - emoji = tags |> Enum.filter(fn data -> data["type"] == "Emoji" and data["icon"] end) - emoji = - emoji + tags + |> Enum.filter(fn data -> data["type"] == "Emoji" and data["icon"] end) |> Enum.reduce(%{}, fn data, mapping -> name = String.trim(data["name"], ":") - mapping |> Map.put(name, data["icon"]["url"]) + Map.put(mapping, name, data["icon"]["url"]) end) # we merge mastodon and pleroma emoji into a single mapping, to allow for both wire formats emoji = Map.merge(object["emoji"] || %{}, emoji) - object - |> Map.put("emoji", emoji) + Map.put(object, "emoji", emoji) end def fix_emoji(%{"tag" => %{"type" => "Emoji"} = tag} = object) do name = String.trim(tag["name"], ":") emoji = %{name => tag["icon"]["url"]} - object - |> Map.put("emoji", emoji) + Map.put(object, "emoji", emoji) end def fix_emoji(object), do: object @@ -303,17 +284,13 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do |> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end) |> Enum.map(fn data -> String.slice(data["name"], 1..-1) end) - combined = tag ++ tags - - object - |> Map.put("tag", combined) + Map.put(object, "tag", tag ++ tags) end def fix_tag(%{"tag" => %{"type" => "Hashtag", "name" => hashtag} = tag} = object) do combined = [tag, String.slice(hashtag, 1..-1)] - object - |> Map.put("tag", combined) + Map.put(object, "tag", combined) end def fix_tag(%{"tag" => %{} = tag} = object), do: Map.put(object, "tag", [tag]) @@ -325,8 +302,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do content_groups = Map.to_list(content_map) {_, content} = Enum.at(content_groups, 0) - object - |> Map.put("content", content) + Map.put(object, "content", content) end def fix_content_map(object), do: object @@ -335,16 +311,11 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def fix_type(%{"inReplyTo" => reply_id, "name" => _} = object, options) when is_binary(reply_id) do - reply = - with true <- Federator.allowed_incoming_reply_depth?(options[:depth]), - {:ok, object} <- get_obj_helper(reply_id, options) do - object - end - - if reply && reply.data["type"] == "Question" do + with true <- Federator.allowed_incoming_reply_depth?(options[:depth]), + {:ok, %{data: %{"type" => "Question"} = _} = _} <- get_obj_helper(reply_id, options) do Map.put(object, "type", "Answer") else - object + _ -> object end end @@ -376,6 +347,17 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do end end + # Reduce the object list to find the reported user. + defp get_reported(objects) do + Enum.reduce_while(objects, nil, fn ap_id, _ -> + with %User{} = user <- User.get_cached_by_ap_id(ap_id) do + {:halt, user} + else + _ -> {:cont, nil} + end + end) + end + def handle_incoming(data, options \\ []) # Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them @@ -384,31 +366,19 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do with context <- data["context"] || Utils.generate_context_id(), content <- data["content"] || "", %User{} = actor <- User.get_cached_by_ap_id(actor), - # Reduce the object list to find the reported user. - %User{} = account <- - Enum.reduce_while(objects, nil, fn ap_id, _ -> - with %User{} = user <- User.get_cached_by_ap_id(ap_id) do - {:halt, user} - else - _ -> {:cont, nil} - end - end), - + %User{} = account <- get_reported(objects), # Remove the reported user from the object list. statuses <- Enum.filter(objects, fn ap_id -> ap_id != account.ap_id end) do - params = %{ + %{ actor: actor, context: context, account: account, statuses: statuses, content: content, - additional: %{ - "cc" => [account.ap_id] - } + additional: %{"cc" => [account.ap_id]} } - - ActivityPub.flag(params) + |> ActivityPub.flag() end end @@ -461,6 +431,36 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do end def handle_incoming( + %{"type" => "Listen", "object" => %{"type" => "Audio"} = object} = data, + options + ) do + actor = Containment.get_actor(data) + + data = + Map.put(data, "actor", actor) + |> fix_addressing + + with {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(data["actor"]) do + options = Keyword.put(options, :depth, (options[:depth] || 0) + 1) + object = fix_object(object, options) + + params = %{ + to: data["to"], + object: object, + actor: user, + context: nil, + local: false, + published: data["published"], + additional: Map.take(data, ["cc", "id"]) + } + + ActivityPub.listen(params) + else + _e -> :error + end + end + + def handle_incoming( %{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = data, _options ) do @@ -580,7 +580,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do ) do with actor <- Containment.get_actor(data), {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor), - {:ok, object} <- get_obj_helper(object_id), + {:ok, object} <- get_embedded_obj_helper(object_id, actor), public <- Visibility.is_public?(data), {:ok, activity, _object} <- ActivityPub.announce(actor, object, id, false, public) do {:ok, activity} @@ -621,7 +621,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do to: data["to"] || [], cc: data["cc"] || [], object: object, - actor: actor_id + actor: actor_id, + activity_id: data["id"] }) else e -> @@ -753,10 +754,55 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do end end + # For Undos that don't have the complete object attached, try to find it in our database. + def handle_incoming( + %{ + "type" => "Undo", + "object" => object + } = activity, + options + ) + when is_binary(object) do + with %Activity{data: data} <- Activity.get_by_ap_id(object) do + activity + |> Map.put("object", data) + |> handle_incoming(options) + else + _e -> :error + end + end + def handle_incoming(_, _), do: :error + @spec get_obj_helper(String.t(), Keyword.t()) :: {:ok, Object.t()} | nil def get_obj_helper(id, options \\ []) do - if object = Object.normalize(id, true, options), do: {:ok, object}, else: nil + case Object.normalize(id, true, options) do + %Object{} = object -> {:ok, object} + _ -> nil + end + end + + @spec get_embedded_obj_helper(String.t() | Object.t(), User.t()) :: {:ok, Object.t()} | nil + def get_embedded_obj_helper(%{"attributedTo" => attributed_to, "id" => object_id} = data, %User{ + ap_id: ap_id + }) + when attributed_to == ap_id do + with {:ok, activity} <- + handle_incoming(%{ + "type" => "Create", + "to" => data["to"], + "cc" => data["cc"], + "actor" => attributed_to, + "object" => data + }) do + {:ok, Object.normalize(activity)} + else + _ -> get_obj_helper(object_id) + end + end + + def get_embedded_obj_helper(object_id, _) do + get_obj_helper(object_id) end def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) when is_binary(in_reply_to) do @@ -791,7 +837,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do # internal -> Mastodon # """ - def prepare_outgoing(%{"type" => "Create", "object" => object_id} = data) do + def prepare_outgoing(%{"type" => activity_type, "object" => object_id} = data) + when activity_type in ["Create", "Listen"] do object = object_id |> Object.normalize() @@ -807,6 +854,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 @@ -855,27 +923,24 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do {:ok, data} end - def maybe_fix_object_url(data) do - if is_binary(data["object"]) and not String.starts_with?(data["object"], "http") do - case get_obj_helper(data["object"]) do - {:ok, relative_object} -> - if relative_object.data["external_url"] do - _data = - data - |> Map.put("object", relative_object.data["external_url"]) - else - data - end - - e -> - Logger.error("Couldn't fetch #{data["object"]} #{inspect(e)}") - data - end + def maybe_fix_object_url(%{"object" => object} = data) when is_binary(object) do + with false <- String.starts_with?(object, "http"), + {:fetch, {:ok, relative_object}} <- {:fetch, get_obj_helper(object)}, + %{data: %{"external_url" => external_url}} when not is_nil(external_url) <- + relative_object do + Map.put(data, "object", external_url) else - data + {:fetch, e} -> + Logger.error("Couldn't fetch #{object} #{inspect(e)}") + data + + _ -> + data end end + def maybe_fix_object_url(data), do: data + def add_hashtags(object) do tags = (object["tag"] || []) @@ -893,53 +958,49 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do tag end) - object - |> Map.put("tag", tags) + Map.put(object, "tag", tags) end def add_mention_tags(object) do mentions = object |> Utils.get_notified_from_object() - |> Enum.map(fn user -> - %{"type" => "Mention", "href" => user.ap_id, "name" => "@#{user.nickname}"} - end) + |> Enum.map(&build_mention_tag/1) tags = object["tag"] || [] - object - |> Map.put("tag", tags ++ mentions) + Map.put(object, "tag", tags ++ mentions) end - def add_emoji_tags(%User{info: %{"emoji" => _emoji} = user_info} = object) do - user_info = add_emoji_tags(user_info) + defp build_mention_tag(%{ap_id: ap_id, nickname: nickname} = _) do + %{"type" => "Mention", "href" => ap_id, "name" => "@#{nickname}"} + end - object - |> Map.put(:info, user_info) + def take_emoji_tags(%User{info: %{emoji: emoji} = _user_info} = _user) do + emoji + |> Enum.flat_map(&Map.to_list/1) + |> Enum.map(&build_emoji_tag/1) end # TODO: we should probably send mtime instead of unix epoch time for updated def add_emoji_tags(%{"emoji" => emoji} = object) do tags = object["tag"] || [] - out = - emoji - |> Enum.map(fn {name, url} -> - %{ - "icon" => %{"url" => url, "type" => "Image"}, - "name" => ":" <> name <> ":", - "type" => "Emoji", - "updated" => "1970-01-01T00:00:00Z", - "id" => url - } - end) + out = Enum.map(emoji, &build_emoji_tag/1) - object - |> Map.put("tag", tags ++ out) + Map.put(object, "tag", tags ++ out) end - def add_emoji_tags(object) do - object + def add_emoji_tags(object), do: object + + defp build_emoji_tag({name, url}) do + %{ + "icon" => %{"url" => url, "type" => "Image"}, + "name" => ":" <> name <> ":", + "type" => "Emoji", + "updated" => "1970-01-01T00:00:00Z", + "id" => url + } end def set_conversation(object) do @@ -959,9 +1020,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def add_attributed_to(object) do attributed_to = object["attributedTo"] || object["actor"] - - object - |> Map.put("attributedTo", attributed_to) + Map.put(object, "attributedTo", attributed_to) end def prepare_attachments(object) do @@ -972,30 +1031,18 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do %{"url" => href, "mediaType" => media_type, "name" => data["name"], "type" => "Document"} end) - object - |> Map.put("attachment", attachments) + Map.put(object, "attachment", attachments) end defp strip_internal_fields(object) do object - |> Map.drop([ - "likes", - "like_count", - "announcements", - "announcement_count", - "emoji", - "context_id", - "deleted_activity_id" - ]) + |> Map.drop(Pleroma.Constants.object_internal_fields()) end defp strip_internal_tags(%{"tag" => tags} = object) do - tags = - tags - |> Enum.filter(fn x -> is_map(x) end) + tags = Enum.filter(tags, fn x -> is_map(x) end) - object - |> Map.put("tag", tags) + Map.put(object, "tag", tags) end defp strip_internal_tags(object), do: object @@ -1049,9 +1096,9 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do with %User{local: false} = user <- User.get_cached_by_ap_id(ap_id), {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id), already_ap <- User.ap_enabled?(user), - {:ok, user} <- user |> User.upgrade_changeset(data) |> User.update_and_set_cache() do - unless already_ap do - PleromaJobQueue.enqueue(:transmogrifier, __MODULE__, [:user_upgrade, user]) + {:ok, user} <- upgrade_user(user, data) do + if not already_ap do + TransmogrifierWorker.enqueue("user_upgrade", %{"user_id" => user.id}) end {:ok, user} @@ -1061,6 +1108,12 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do end end + defp upgrade_user(user, data) do + user + |> User.upgrade_changeset(data, true) + |> User.update_and_set_cache() + end + def maybe_retire_websub(ap_id) do # some sanity checks if is_binary(ap_id) && String.length(ap_id) > 8 do @@ -1074,16 +1127,11 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do end end - def maybe_fix_user_url(data) do - if is_map(data["url"]) do - Map.put(data, "url", data["url"]["href"]) - else - data - end + def maybe_fix_user_url(%{"url" => url} = data) when is_map(url) do + Map.put(data, "url", url["href"]) end - def maybe_fix_user_object(data) do - data - |> maybe_fix_user_url - end + def maybe_fix_user_url(data), do: data + + def maybe_fix_user_object(data), do: maybe_fix_user_url(data) end diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index c9c0c3763..4ef479f96 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -20,7 +20,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do require Logger require Pleroma.Constants - @supported_object_types ["Article", "Note", "Video", "Page", "Question", "Answer"] + @supported_object_types ["Article", "Note", "Video", "Page", "Question", "Answer", "Audio"] @supported_report_states ~w(open closed resolved) @valid_visibilities ~w(public unlisted private direct) @@ -33,50 +33,40 @@ defmodule Pleroma.Web.ActivityPub.Utils do Map.put(params, "actor", get_ap_id(params["actor"])) end - def determine_explicit_mentions(%{"tag" => tag} = _object) when is_list(tag) do - tag - |> Enum.filter(fn x -> is_map(x) end) - |> Enum.filter(fn x -> x["type"] == "Mention" end) - |> Enum.map(fn x -> x["href"] end) + @spec determine_explicit_mentions(map()) :: map() + def determine_explicit_mentions(%{"tag" => tag} = _) when is_list(tag) do + Enum.flat_map(tag, fn + %{"type" => "Mention", "href" => href} -> [href] + _ -> [] + end) end def determine_explicit_mentions(%{"tag" => tag} = object) when is_map(tag) do - Map.put(object, "tag", [tag]) + object + |> Map.put("tag", [tag]) |> determine_explicit_mentions() end def determine_explicit_mentions(_), do: [] + @spec recipient_in_collection(any(), any()) :: boolean() defp recipient_in_collection(ap_id, coll) when is_binary(coll), do: ap_id == coll defp recipient_in_collection(ap_id, coll) when is_list(coll), do: ap_id in coll defp recipient_in_collection(_, _), do: false + @spec recipient_in_message(User.t(), User.t(), map()) :: boolean() def recipient_in_message(%User{ap_id: ap_id} = recipient, %User{} = actor, params) do - cond do - recipient_in_collection(ap_id, params["to"]) -> - true - - recipient_in_collection(ap_id, params["cc"]) -> - true - - recipient_in_collection(ap_id, params["bto"]) -> - true - - recipient_in_collection(ap_id, params["bcc"]) -> - true + addresses = [params["to"], params["cc"], params["bto"], params["bcc"]] + cond do + Enum.any?(addresses, &recipient_in_collection(ap_id, &1)) -> true # if the message is unaddressed at all, then assume it is directly addressed # to the recipient - !params["to"] && !params["cc"] && !params["bto"] && !params["bcc"] -> - true - + Enum.all?(addresses, &is_nil(&1)) -> true # if the message is sent from somebody the user is following, then assume it # is addressed to the recipient - User.following?(recipient, actor) -> - true - - true -> - false + User.following?(recipient, actor) -> true + true -> false end end @@ -85,15 +75,13 @@ defmodule Pleroma.Web.ActivityPub.Utils do defp extract_list(_), do: [] def maybe_splice_recipient(ap_id, params) do - need_splice = + need_splice? = !recipient_in_collection(ap_id, params["to"]) && !recipient_in_collection(ap_id, params["cc"]) - cc_list = extract_list(params["cc"]) - - if need_splice do - params - |> Map.put("cc", [ap_id | cc_list]) + if need_splice? do + cc_list = extract_list(params["cc"]) + Map.put(params, "cc", [ap_id | cc_list]) else params end @@ -139,7 +127,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do "object" => object } - Notification.get_notified_from_activity(%Activity{data: fake_create_activity}, false) + get_notified_from_object(fake_create_activity) end def get_notified_from_object(object) do @@ -169,14 +157,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do @spec maybe_federate(any()) :: :ok def maybe_federate(%Activity{local: true} = activity) do if Pleroma.Config.get!([:instance, :federating]) do - priority = - case activity.data["type"] do - "Delete" -> 10 - "Create" -> 1 - _ -> 5 - end - - Pleroma.Web.Federator.publish(activity, priority) + Pleroma.Web.Federator.publish(activity) end :ok @@ -188,63 +169,66 @@ defmodule Pleroma.Web.ActivityPub.Utils do Adds an id and a published data if they aren't there, also adds it to an included object """ - def lazy_put_activity_defaults(map, fake \\ false) do - map = - unless fake do - %{data: %{"id" => context}, id: context_id} = create_context(map["context"]) - - map - |> Map.put_new_lazy("id", &generate_activity_id/0) - |> Map.put_new_lazy("published", &make_date/0) - |> Map.put_new("context", context) - |> Map.put_new("context_id", context_id) - else - map - |> Map.put_new("id", "pleroma:fakeid") - |> Map.put_new_lazy("published", &make_date/0) - |> Map.put_new("context", "pleroma:fakecontext") - |> Map.put_new("context_id", -1) - end + @spec lazy_put_activity_defaults(map(), boolean) :: map() + def lazy_put_activity_defaults(map, fake? \\ false) - if is_map(map["object"]) do - object = lazy_put_object_defaults(map["object"], map, fake) - %{map | "object" => object} - else - map - end + def lazy_put_activity_defaults(map, true) do + map + |> Map.put_new("id", "pleroma:fakeid") + |> Map.put_new_lazy("published", &make_date/0) + |> Map.put_new("context", "pleroma:fakecontext") + |> Map.put_new("context_id", -1) + |> lazy_put_object_defaults(true) end - @doc """ - Adds an id and published date if they aren't there. - """ - def lazy_put_object_defaults(map, activity \\ %{}, fake) + def lazy_put_activity_defaults(map, _fake?) do + %{data: %{"id" => context}, id: context_id} = create_context(map["context"]) - def lazy_put_object_defaults(map, activity, true = _fake) do map + |> Map.put_new_lazy("id", &generate_activity_id/0) |> Map.put_new_lazy("published", &make_date/0) - |> Map.put_new("id", "pleroma:fake_object_id") - |> Map.put_new("context", activity["context"]) - |> Map.put_new("fake", true) - |> Map.put_new("context_id", activity["context_id"]) + |> Map.put_new("context", context) + |> Map.put_new("context_id", context_id) + |> lazy_put_object_defaults(false) end - def lazy_put_object_defaults(map, activity, _fake) do - map - |> Map.put_new_lazy("id", &generate_object_id/0) - |> Map.put_new_lazy("published", &make_date/0) - |> Map.put_new("context", activity["context"]) - |> Map.put_new("context_id", activity["context_id"]) + # Adds an id and published date if they aren't there. + # + @spec lazy_put_object_defaults(map(), boolean()) :: map() + defp lazy_put_object_defaults(%{"object" => map} = activity, true) + when is_map(map) do + object = + map + |> Map.put_new("id", "pleroma:fake_object_id") + |> Map.put_new_lazy("published", &make_date/0) + |> Map.put_new("context", activity["context"]) + |> Map.put_new("context_id", activity["context_id"]) + |> Map.put_new("fake", true) + + %{activity | "object" => object} end + defp lazy_put_object_defaults(%{"object" => map} = activity, _) + when is_map(map) do + object = + map + |> Map.put_new_lazy("id", &generate_object_id/0) + |> Map.put_new_lazy("published", &make_date/0) + |> Map.put_new("context", activity["context"]) + |> Map.put_new("context_id", activity["context_id"]) + + %{activity | "object" => object} + end + + defp lazy_put_object_defaults(activity, _), do: activity + @doc """ Inserts a full object if it is contained in an activity. """ def insert_full_object(%{"object" => %{"type" => type} = object_data} = map) when is_map(object_data) and type in @supported_object_types do with {:ok, object} <- Object.create(object_data) do - map = - map - |> Map.put("object", object.data["id"]) + map = Map.put(map, "object", object.data["id"]) {:ok, map, object} end @@ -263,20 +247,10 @@ defmodule Pleroma.Web.ActivityPub.Utils do |> Activity.Queries.by_actor() |> Activity.Queries.by_object_id(id) |> Activity.Queries.by_type("Like") - |> Activity.Queries.limit(1) + |> limit(1) |> Repo.one() end - @doc """ - Returns like activities targeting an object - """ - def get_object_likes(%{data: %{"id" => id}}) do - id - |> Activity.Queries.by_object_id() - |> Activity.Queries.by_type("Like") - |> Repo.all() - end - @spec make_like_data(User.t(), map(), String.t()) :: map() def make_like_data( %User{ap_id: ap_id} = actor, @@ -356,36 +330,35 @@ defmodule Pleroma.Web.ActivityPub.Utils do @doc """ Updates a follow activity's state (for locked accounts). """ + @spec update_follow_state_for_all(Activity.t(), String.t()) :: {:ok, Activity} | {:error, any()} def update_follow_state_for_all( %Activity{data: %{"actor" => actor, "object" => object}} = activity, state ) do - try do - Ecto.Adapters.SQL.query!( - Repo, - "UPDATE activities SET data = jsonb_set(data, '{state}', $1) WHERE data->>'type' = 'Follow' AND data->>'actor' = $2 AND data->>'object' = $3 AND data->>'state' = 'pending'", - [state, actor, object] - ) + "Follow" + |> Activity.Queries.by_type() + |> Activity.Queries.by_actor(actor) + |> Activity.Queries.by_object_id(object) + |> where(fragment("data->>'state' = 'pending'")) + |> update(set: [data: fragment("jsonb_set(data, '{state}', ?)", ^state)]) + |> Repo.update_all([]) - User.set_follow_state_cache(actor, object, state) - activity = Activity.get_by_id(activity.id) - {:ok, activity} - rescue - e -> - {:error, e} - end + User.set_follow_state_cache(actor, object, state) + + activity = Activity.get_by_id(activity.id) + + {:ok, activity} end def update_follow_state( %Activity{data: %{"actor" => actor, "object" => object}} = activity, state ) do - with new_data <- - activity.data - |> Map.put("state", state), - changeset <- Changeset.change(activity, data: new_data), - {:ok, activity} <- Repo.update(changeset), - _ <- User.set_follow_state_cache(actor, object, state) do + new_data = Map.put(activity.data, "state", state) + changeset = Changeset.change(activity, data: new_data) + + with {:ok, activity} <- Repo.update(changeset) do + User.set_follow_state_cache(actor, object, state) {:ok, activity} end end @@ -410,28 +383,14 @@ defmodule Pleroma.Web.ActivityPub.Utils do end def fetch_latest_follow(%User{ap_id: follower_id}, %User{ap_id: followed_id}) do - query = - from( - activity in Activity, - where: - fragment( - "? ->> 'type' = 'Follow'", - activity.data - ), - where: activity.actor == ^follower_id, - # this is to use the index - where: - fragment( - "coalesce((?)->'object'->>'id', (?)->>'object') = ?", - activity.data, - activity.data, - ^followed_id - ), - order_by: [fragment("? desc nulls last", activity.id)], - limit: 1 - ) - - Repo.one(query) + "Follow" + |> Activity.Queries.by_type() + |> where(actor: ^follower_id) + # this is to use the index + |> Activity.Queries.by_object_id(followed_id) + |> order_by([activity], fragment("? desc nulls last", activity.id)) + |> limit(1) + |> Repo.one() end #### Announce-related helpers @@ -439,23 +398,14 @@ defmodule Pleroma.Web.ActivityPub.Utils do @doc """ Retruns an existing announce activity if the notice has already been announced """ - def get_existing_announce(actor, %{data: %{"id" => id}}) do - query = - from( - activity in Activity, - where: activity.actor == ^actor, - # this is to use the index - where: - fragment( - "coalesce((?)->'object'->>'id', (?)->>'object') = ?", - activity.data, - activity.data, - ^id - ), - where: fragment("(?)->>'type' = 'Announce'", activity.data) - ) - - Repo.one(query) + @spec get_existing_announce(String.t(), map()) :: Activity.t() | nil + def get_existing_announce(actor, %{data: %{"id" => ap_id}}) do + "Announce" + |> Activity.Queries.by_type() + |> where(actor: ^actor) + # this is to use the index + |> Activity.Queries.by_object_id(ap_id) + |> Repo.one() end @doc """ @@ -501,14 +451,16 @@ defmodule Pleroma.Web.ActivityPub.Utils do """ def make_unannounce_data( %User{ap_id: ap_id} = user, - %Activity{data: %{"context" => context}} = activity, + %Activity{data: %{"context" => context, "object" => object}} = activity, activity_id ) do + object = Object.normalize(object) + %{ "type" => "Undo", "actor" => ap_id, "object" => activity.data, - "to" => [user.follower_address, activity.data["actor"]], + "to" => [user.follower_address, object.data["actor"]], "cc" => [Pleroma.Constants.as_public()], "context" => context } @@ -517,45 +469,51 @@ defmodule Pleroma.Web.ActivityPub.Utils do def make_unlike_data( %User{ap_id: ap_id} = user, - %Activity{data: %{"context" => context}} = activity, + %Activity{data: %{"context" => context, "object" => object}} = activity, activity_id ) do + object = Object.normalize(object) + %{ "type" => "Undo", "actor" => ap_id, "object" => activity.data, - "to" => [user.follower_address, activity.data["actor"]], + "to" => [user.follower_address, object.data["actor"]], "cc" => [Pleroma.Constants.as_public()], "context" => context } |> maybe_put("id", activity_id) end + @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 = - if is_list(object.data["announcements"]), do: object.data["announcements"], else: [] + announcements = take_announcements(object) - with announcements <- [actor | announcements] |> Enum.uniq() do + with announcements <- Enum.uniq([actor | announcements]) do update_element_in_object("announcement", announcements, object) end end def add_announce_to_object(_, object), do: {:ok, object} + @spec remove_announce_from_object(Activity.t(), Object.t()) :: + {:ok, Object.t()} | {:error, Ecto.Changeset.t()} def remove_announce_from_object(%Activity{data: %{"actor" => actor}}, object) do - announcements = - if is_list(object.data["announcements"]), do: object.data["announcements"], else: [] - - with announcements <- announcements |> List.delete(actor) do + with announcements <- List.delete(take_announcements(object), actor) do update_element_in_object("announcement", announcements, object) end end + defp take_announcements(%{data: %{"announcements" => announcements}} = _) + when is_list(announcements), + do: announcements + + defp take_announcements(_), do: [] + #### Unfollow-related helpers def make_unfollow_data(follower, followed, follow_activity, activity_id) do @@ -569,29 +527,16 @@ defmodule Pleroma.Web.ActivityPub.Utils do end #### Block-related helpers + @spec fetch_latest_block(User.t(), User.t()) :: Activity.t() | nil def fetch_latest_block(%User{ap_id: blocker_id}, %User{ap_id: blocked_id}) do - query = - from( - activity in Activity, - where: - fragment( - "? ->> 'type' = 'Block'", - activity.data - ), - where: activity.actor == ^blocker_id, - # this is to use the index - where: - fragment( - "coalesce((?)->'object'->>'id', (?)->>'object') = ?", - activity.data, - activity.data, - ^blocked_id - ), - order_by: [fragment("? desc nulls last", activity.id)], - limit: 1 - ) - - Repo.one(query) + "Block" + |> Activity.Queries.by_type() + |> where(actor: ^blocker_id) + # this is to use the index + |> Activity.Queries.by_object_id(blocked_id) + |> order_by([activity], fragment("? desc nulls last", activity.id)) + |> limit(1) + |> Repo.one() end def make_block_data(blocker, blocked, activity_id) do @@ -630,29 +575,48 @@ defmodule Pleroma.Web.ActivityPub.Utils do |> Map.merge(additional) end - #### Flag-related helpers - - def make_flag_data(params, additional) do - status_ap_ids = - Enum.map(params.statuses || [], fn - %Activity{} = act -> act.data["id"] - act when is_map(act) -> act["id"] - act when is_binary(act) -> act - end) + #### Listen-related helpers + def make_listen_data(params, additional) do + published = params.published || make_date() - object = [params.account.ap_id] ++ status_ap_ids + %{ + "type" => "Listen", + "to" => params.to |> Enum.uniq(), + "actor" => params.actor.ap_id, + "object" => params.object, + "published" => published, + "context" => params.context + } + |> Map.merge(additional) + end + #### Flag-related helpers + @spec make_flag_data(map(), map()) :: map() + def make_flag_data(%{actor: actor, context: context, content: content} = params, additional) do %{ "type" => "Flag", - "actor" => params.actor.ap_id, - "content" => params.content, - "object" => object, - "context" => params.context, + "actor" => actor.ap_id, + "content" => content, + "object" => build_flag_object(params), + "context" => context, "state" => "open" } |> Map.merge(additional) end + def make_flag_data(_, _), do: %{} + + defp build_flag_object(%{account: account, statuses: statuses} = _) do + [account.ap_id] ++ + Enum.map(statuses || [], fn + %Activity{} = act -> act.data["id"] + act when is_map(act) -> act["id"] + act when is_binary(act) -> act + end) + end + + defp build_flag_object(_), do: [] + @doc """ Fetches the OrderedCollection/OrderedCollectionPage from `from`, limiting the amount of pages fetched after the first one to `pages_left` pages. @@ -695,11 +659,11 @@ defmodule Pleroma.Web.ActivityPub.Utils do #### Report-related helpers def update_report_state(%Activity{} = activity, state) when state in @supported_report_states do - with new_data <- Map.put(activity.data, "state", state), - changeset <- Changeset.change(activity, data: new_data), - {:ok, activity} <- Repo.update(changeset) do - {:ok, activity} - end + new_data = Map.put(activity.data, "state", state) + + activity + |> Changeset.change(data: new_data) + |> Repo.update() end def update_report_state(_, _), do: {:error, "Unsupported state"} @@ -766,23 +730,15 @@ defmodule Pleroma.Web.ActivityPub.Utils do end def get_existing_votes(actor, %{data: %{"id" => id}}) do - query = - from( - [activity, object: object] in Activity.with_preloaded_object(Activity), - where: fragment("(?)->>'type' = 'Create'", activity.data), - where: fragment("(?)->>'actor' = ?", activity.data, ^actor), - where: - fragment( - "(?)->>'inReplyTo' = ?", - object.data, - ^to_string(id) - ), - where: fragment("(?)->>'type' = 'Answer'", object.data) - ) - - Repo.all(query) + actor + |> Activity.Queries.by_actor() + |> Activity.Queries.by_type("Create") + |> Activity.with_preloaded_object() + |> where([a, object: o], fragment("(?)->>'inReplyTo' = ?", o.data, ^to_string(id))) + |> where([a, object: o], fragment("(?)->>'type' = 'Answer'", o.data)) + |> Repo.all() end - defp maybe_put(map, _key, nil), do: map - defp maybe_put(map, key, value), do: Map.put(map, key, value) + def maybe_put(map, _key, nil), do: map + def maybe_put(map, key, value), do: Map.put(map, key, value) end diff --git a/lib/pleroma/web/activity_pub/views/object_view.ex b/lib/pleroma/web/activity_pub/views/object_view.ex index 94d05f49b..d8a3ec288 100644 --- a/lib/pleroma/web/activity_pub/views/object_view.ex +++ b/lib/pleroma/web/activity_pub/views/object_view.ex @@ -15,7 +15,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectView do Map.merge(base, additional) end - def render("object.json", %{object: %Activity{data: %{"type" => "Create"}} = activity}) do + def render("object.json", %{object: %Activity{data: %{"type" => activity_type}} = activity}) + when activity_type in ["Create", "Listen"] do base = Pleroma.Web.ActivityPub.Utils.make_json_ld_header() object = Object.normalize(activity) @@ -36,40 +37,4 @@ defmodule Pleroma.Web.ActivityPub.ObjectView do Map.merge(base, additional) end - - def render("likes.json", ap_id, likes, page) do - collection(likes, "#{ap_id}/likes", page) - |> Map.merge(Pleroma.Web.ActivityPub.Utils.make_json_ld_header()) - end - - def render("likes.json", ap_id, likes) do - %{ - "id" => "#{ap_id}/likes", - "type" => "OrderedCollection", - "totalItems" => length(likes), - "first" => collection(likes, "#{ap_id}/likes", 1) - } - |> Map.merge(Pleroma.Web.ActivityPub.Utils.make_json_ld_header()) - end - - def collection(collection, iri, page) do - offset = (page - 1) * 10 - items = Enum.slice(collection, offset, 10) - items = Enum.map(items, fn object -> Transmogrifier.prepare_object(object.data) end) - total = length(collection) - - map = %{ - "id" => "#{iri}?page=#{page}", - "type" => "OrderedCollectionPage", - "partOf" => iri, - "totalItems" => total, - "orderedItems" => items - } - - if offset + length(items) < total do - Map.put(map, "next", "#{iri}?page=#{page + 1}") - else - map - end - end end diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index 7be734b26..9b39d1629 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -8,7 +8,6 @@ defmodule Pleroma.Web.ActivityPub.UserView do alias Pleroma.Keys alias Pleroma.Repo alias Pleroma.User - alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.Endpoint @@ -23,9 +22,10 @@ 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) + "sharedInbox" => Helpers.activity_pub_url(Endpoint, :inbox), + "uploadMedia" => Helpers.activity_pub_url(Endpoint, :upload_media) } end @@ -33,7 +33,7 @@ defmodule Pleroma.Web.ActivityPub.UserView do def render("service.json", %{user: user}) do {:ok, user} = User.ensure_keys_present(user) - {:ok, _, public_key} = Keys.keys_from_pem(user.info.keys) + {:ok, _, public_key} = Keys.keys_from_pem(user.keys) public_key = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key) public_key = :public_key.pem_encode([public_key]) @@ -69,16 +69,13 @@ defmodule Pleroma.Web.ActivityPub.UserView do def render("user.json", %{user: user}) do {:ok, user} = User.ensure_keys_present(user) - {:ok, _, public_key} = Keys.keys_from_pem(user.info.keys) + {:ok, _, public_key} = Keys.keys_from_pem(user.keys) public_key = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key) public_key = :public_key.pem_encode([public_key]) endpoints = render("endpoints.json", %{user: user}) - user_tags = - user - |> Transmogrifier.add_emoji_tags() - |> Map.get("tag", []) + emoji_tags = Transmogrifier.take_emoji_tags(user) fields = user.info @@ -110,7 +107,8 @@ defmodule Pleroma.Web.ActivityPub.UserView do }, "endpoints" => endpoints, "attachment" => fields, - "tag" => (user.info.source_data["tag"] || []) ++ user_tags + "tag" => (user.info.source_data["tag"] || []) ++ emoji_tags, + "discoverable" => user.info.discoverable } |> Map.merge(maybe_make_image(&User.avatar_url/2, "icon", user)) |> Map.merge(maybe_make_image(&User.banner_url/2, "image", user)) @@ -118,30 +116,34 @@ defmodule Pleroma.Web.ActivityPub.UserView do end def render("following.json", %{user: user, page: page} = opts) do - showing = (opts[:for] && opts[:for] == user) || !user.info.hide_follows + showing_items = (opts[:for] && opts[:for] == user) || !user.info.hide_follows + showing_count = showing_items || !user.info.hide_follows_count + query = User.get_friends_query(user) query = from(user in query, select: [:ap_id]) following = Repo.all(query) total = - if showing do + if showing_count do length(following) else 0 end - collection(following, "#{user.ap_id}/following", page, showing, total) + collection(following, "#{user.ap_id}/following", page, showing_items, total) |> Map.merge(Utils.make_json_ld_header()) end def render("following.json", %{user: user} = opts) do - showing = (opts[:for] && opts[:for] == user) || !user.info.hide_follows + showing_items = (opts[:for] && opts[:for] == user) || !user.info.hide_follows + showing_count = showing_items || !user.info.hide_follows_count + query = User.get_friends_query(user) query = from(user in query, select: [:ap_id]) following = Repo.all(query) total = - if showing do + if showing_count do length(following) else 0 @@ -152,7 +154,7 @@ defmodule Pleroma.Web.ActivityPub.UserView do "type" => "OrderedCollection", "totalItems" => total, "first" => - if showing do + if showing_items do collection(following, "#{user.ap_id}/following", 1, !user.info.hide_follows) else "#{user.ap_id}/following?page=1" @@ -162,32 +164,34 @@ defmodule Pleroma.Web.ActivityPub.UserView do end def render("followers.json", %{user: user, page: page} = opts) do - showing = (opts[:for] && opts[:for] == user) || !user.info.hide_followers + showing_items = (opts[:for] && opts[:for] == user) || !user.info.hide_followers + showing_count = showing_items || !user.info.hide_followers_count query = User.get_followers_query(user) query = from(user in query, select: [:ap_id]) followers = Repo.all(query) total = - if showing do + if showing_count do length(followers) else 0 end - collection(followers, "#{user.ap_id}/followers", page, showing, total) + collection(followers, "#{user.ap_id}/followers", page, showing_items, total) |> Map.merge(Utils.make_json_ld_header()) end def render("followers.json", %{user: user} = opts) do - showing = (opts[:for] && opts[:for] == user) || !user.info.hide_followers + showing_items = (opts[:for] && opts[:for] == user) || !user.info.hide_followers + showing_count = showing_items || !user.info.hide_followers_count query = User.get_followers_query(user) query = from(user in query, select: [:ap_id]) followers = Repo.all(query) total = - if showing do + if showing_count do length(followers) else 0 @@ -198,8 +202,8 @@ defmodule Pleroma.Web.ActivityPub.UserView do "type" => "OrderedCollection", "totalItems" => total, "first" => - if showing do - collection(followers, "#{user.ap_id}/followers", 1, showing, total) + if showing_items do + collection(followers, "#{user.ap_id}/followers", 1, showing_items, total) else "#{user.ap_id}/followers?page=1" end @@ -207,25 +211,22 @@ defmodule Pleroma.Web.ActivityPub.UserView do |> Map.merge(Utils.make_json_ld_header()) end - def render("outbox.json", %{user: user, max_id: max_qid}) do - params = %{ - "limit" => "10" + def render("activity_collection.json", %{iri: iri}) do + %{ + "id" => iri, + "type" => "OrderedCollection", + "first" => "#{iri}?page=true" } + |> Map.merge(Utils.make_json_ld_header()) + end - params = - if max_qid != nil do - Map.put(params, "max_id", max_qid) - else - params - end - - activities = ActivityPub.fetch_user_activities(user, nil, params) - + def render("activity_collection_page.json", %{activities: activities, iri: iri}) do + # this is sorted chronologically, so first activity is the newest (max) {max_id, min_id, collection} = if length(activities) > 0 do { - Enum.at(Enum.reverse(activities), 0).id, Enum.at(activities, 0).id, + Enum.at(Enum.reverse(activities), 0).id, Enum.map(activities, fn act -> {:ok, data} = Transmogrifier.prepare_outgoing(act.data) data @@ -239,71 +240,14 @@ defmodule Pleroma.Web.ActivityPub.UserView do } end - iri = "#{user.ap_id}/outbox" - - page = %{ - "id" => "#{iri}?max_id=#{max_id}", - "type" => "OrderedCollectionPage", - "partOf" => iri, - "orderedItems" => collection, - "next" => "#{iri}?max_id=#{min_id}" - } - - if max_qid == nil do - %{ - "id" => iri, - "type" => "OrderedCollection", - "first" => page - } - |> Map.merge(Utils.make_json_ld_header()) - else - page |> Map.merge(Utils.make_json_ld_header()) - end - end - - def render("inbox.json", %{user: user, max_id: max_qid}) do - params = %{ - "limit" => "10" - } - - params = - if max_qid != nil do - Map.put(params, "max_id", max_qid) - else - params - end - - activities = ActivityPub.fetch_activities([user.ap_id | user.following], params) - - min_id = Enum.at(Enum.reverse(activities), 0).id - max_id = Enum.at(activities, 0).id - - collection = - Enum.map(activities, fn act -> - {:ok, data} = Transmogrifier.prepare_outgoing(act.data) - data - end) - - iri = "#{user.ap_id}/inbox" - - page = %{ - "id" => "#{iri}?max_id=#{max_id}", + %{ + "id" => "#{iri}?max_id=#{max_id}&page=true", "type" => "OrderedCollectionPage", "partOf" => iri, "orderedItems" => collection, - "next" => "#{iri}?max_id=#{min_id}" + "next" => "#{iri}?max_id=#{min_id}&page=true" } - - if max_qid == nil do - %{ - "id" => iri, - "type" => "OrderedCollection", - "first" => page - } - |> Map.merge(Utils.make_json_ld_header()) - else - page |> Map.merge(Utils.make_json_ld_header()) - end + |> Map.merge(Utils.make_json_ld_header()) end def collection(collection, iri, page, show_items \\ true, total \\ nil) do 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/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 544b9d7d8..513bae800 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do use Pleroma.Web, :controller alias Pleroma.Activity alias Pleroma.ModerationLog + alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User alias Pleroma.UserInviteToken alias Pleroma.Web.ActivityPub.ActivityPub @@ -14,15 +15,79 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Web.AdminAPI.Config alias Pleroma.Web.AdminAPI.ConfigView alias Pleroma.Web.AdminAPI.ModerationLogView + alias Pleroma.Web.AdminAPI.Report alias Pleroma.Web.AdminAPI.ReportView alias Pleroma.Web.AdminAPI.Search alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Endpoint alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.Router import Pleroma.Web.ControllerHelper, only: [json_response: 3] require Logger + plug( + OAuthScopesPlug, + %{scopes: ["read:accounts"]} + when action in [:list_users, :user_show, :right_get, :invites] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:accounts"]} + when action in [ + :get_invite_token, + :revoke_invite, + :email_invite, + :get_password_reset, + :user_follow, + :user_unfollow, + :user_delete, + :users_create, + :user_toggle_activation, + :tag_users, + :untag_users, + :right_add, + :right_delete, + :set_activation_status + ] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["read:reports"]} when action in [:list_reports, :report_show] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:reports"]} + when action in [:report_update_state, :report_respond] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["read:statuses"]} when action == :list_user_statuses + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:statuses"]} + when action in [:status_update, :status_delete] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["read"]} + when action in [:config_show, :migrate_to_db, :migrate_from_db, :list_log] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write"]} + when action in [:relay_follow, :relay_unfollow, :config_update] + ) + @users_page_size 50 action_fallback(:errors) @@ -139,7 +204,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do def user_show(conn, %{"nickname" => nickname}) do with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do conn - |> json(AccountView.render("show.json", %{user: user})) + |> put_view(AccountView) + |> render("show.json", %{user: user}) else _ -> {:error, :not_found} end @@ -158,7 +224,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do }) conn - |> json(StatusView.render("index.json", %{activities: activities, as: :activity})) + |> put_view(StatusView) + |> render("index.json", %{activities: activities, as: :activity}) else _ -> {:error, :not_found} end @@ -178,7 +245,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do }) conn - |> json(AccountView.render("show.json", %{user: updated_user})) + |> put_view(AccountView) + |> render("show.json", %{user: updated_user}) end def tag_users(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames, "tags" => tags}) do @@ -250,18 +318,12 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do "nickname" => nickname }) when permission_group in ["moderator", "admin"] do - user = User.get_cached_by_nickname(nickname) - - info = - %{} - |> Map.put("is_" <> permission_group, true) - - info_cng = User.Info.admin_api_update(user.info, info) + info = Map.put(%{}, "is_" <> permission_group, true) - cng = - user - |> Ecto.Changeset.change() - |> Ecto.Changeset.put_embed(:info, info_cng) + {:ok, user} = + nickname + |> User.get_cached_by_nickname() + |> User.update_info(&User.Info.admin_api_update(&1, info)) ModerationLog.insert_log(%{ action: "grant", @@ -270,8 +332,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do permission: permission_group }) - {:ok, _user} = User.update_and_set_cache(cng) - json(conn, info) end @@ -289,40 +349,33 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do }) end + def right_delete(%{assigns: %{user: %{nickname: nickname}}} = conn, %{"nickname" => nickname}) do + render_error(conn, :forbidden, "You can't revoke your own admin status.") + end + def right_delete( - %{assigns: %{user: %User{:nickname => admin_nickname} = admin}} = conn, + %{assigns: %{user: admin}} = conn, %{ "permission_group" => permission_group, "nickname" => nickname } ) when permission_group in ["moderator", "admin"] do - if admin_nickname == nickname do - render_error(conn, :forbidden, "You can't revoke your own admin status.") - else - user = User.get_cached_by_nickname(nickname) - - info = - %{} - |> Map.put("is_" <> permission_group, false) + info = Map.put(%{}, "is_" <> permission_group, false) - info_cng = User.Info.admin_api_update(user.info, info) + {:ok, user} = + nickname + |> User.get_cached_by_nickname() + |> User.update_info(&User.Info.admin_api_update(&1, info)) - cng = - Ecto.Changeset.change(user) - |> Ecto.Changeset.put_embed(:info, info_cng) - - {:ok, _user} = User.update_and_set_cache(cng) - - ModerationLog.insert_log(%{ - action: "revoke", - actor: admin, - subject: user, - permission: permission_group - }) + ModerationLog.insert_log(%{ + action: "revoke", + actor: admin, + subject: user, + permission: permission_group + }) - json(conn, info) - end + json(conn, info) end def right_delete(conn, _) do @@ -400,13 +453,23 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do end end - @doc "Get a account registeration invite token (base64 string)" - def get_invite_token(conn, params) do - options = params["invite"] || %{} - {:ok, invite} = UserInviteToken.create_invite(options) + @doc "Create an account registration invite token" + def create_invite_token(conn, params) do + opts = %{} - conn - |> json(invite.token) + opts = + if params["max_use"], + do: Map.put(opts, :max_use, params["max_use"]), + else: opts + + opts = + if params["expires_at"], + do: Map.put(opts, :expires_at, params["expires_at"]), + else: opts + + {:ok, invite} = UserInviteToken.create_invite(opts) + + json(conn, AccountView.render("invite.json", %{invite: invite})) end @doc "Get list of created invites" @@ -414,7 +477,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do invites = UserInviteToken.list_invites() conn - |> json(AccountView.render("invites.json", %{invites: invites})) + |> put_view(AccountView) + |> render("invites.json", %{invites: invites}) end @doc "Revokes invite by token" @@ -422,7 +486,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do with {:ok, invite} <- UserInviteToken.find_by_token(token), {:ok, updated_invite} = UserInviteToken.update_invite(invite, %{used: true}) do conn - |> json(AccountView.render("invite.json", %{invite: updated_invite})) + |> put_view(AccountView) + |> render("invite.json", %{invite: updated_invite}) else nil -> {:error, :not_found} end @@ -434,19 +499,33 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do {:ok, token} = Pleroma.PasswordResetToken.create_token(user) conn - |> json(token.token) + |> json(%{ + token: token.token, + link: Router.Helpers.reset_password_url(Endpoint, :reset, token.token) + }) + end + + @doc "Force password reset for a given user" + def force_password_reset(conn, %{"nickname" => nickname}) do + (%User{local: true} = user) = User.get_cached_by_nickname(nickname) + + User.force_password_reset_async(user) + + json_response(conn, :no_content, "") end def list_reports(conn, params) do + {page, page_size} = page_params(params) + params = params |> Map.put("type", "Flag") |> Map.put("skip_preload", true) + |> Map.put("total", true) + |> Map.put("limit", page_size) + |> Map.put("offset", (page - 1) * page_size) - reports = - [] - |> ActivityPub.fetch_activities(params) - |> Enum.reverse() + reports = ActivityPub.fetch_activities([], params, :offset) conn |> put_view(ReportView) @@ -457,7 +536,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do with %Activity{} = report <- Activity.get_by_id(id) do conn |> put_view(ReportView) - |> render("show.json", %{report: report}) + |> render("show.json", Report.extract_report_info(report)) else _ -> {:error, :not_found} end @@ -473,7 +552,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do conn |> put_view(ReportView) - |> render("show.json", %{report: report}) + |> render("show.json", Report.extract_report_info(report)) end end @@ -496,7 +575,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do conn |> put_view(StatusView) - |> render("status.json", %{activity: activity}) + |> render("show.json", %{activity: activity}) else true -> {:param_cast, nil} @@ -520,7 +599,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do conn |> put_view(StatusView) - |> render("status.json", %{activity: activity}) + |> render("show.json", %{activity: activity}) end end @@ -539,7 +618,15 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do def list_log(conn, params) do {page, page_size} = page_params(params) - log = ModerationLog.get_all(page, page_size) + log = + ModerationLog.get_all(%{ + page: page, + page_size: page_size, + start_date: params["start_date"], + end_date: params["end_date"], + user_id: params["user_id"], + search: params["search"] + }) conn |> put_view(ModerationLogView) @@ -591,6 +678,12 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do |> render("index.json", %{configs: updated}) end + def reload_emoji(conn, _params) do + Pleroma.Emoji.reload() + + conn |> json("ok") + end + def errors(conn, {:error, :not_found}) do conn |> put_status(:not_found) diff --git a/lib/pleroma/web/admin_api/config.ex b/lib/pleroma/web/admin_api/config.ex index a10cc779b..1917a5580 100644 --- a/lib/pleroma/web/admin_api/config.ex +++ b/lib/pleroma/web/admin_api/config.ex @@ -90,6 +90,8 @@ defmodule Pleroma.Web.AdminAPI.Config do for v <- entity, into: [], do: do_convert(v) end + defp do_convert(%Regex{} = entity), do: inspect(entity) + defp do_convert(entity) when is_map(entity) do for {k, v} <- entity, into: %{}, do: {do_convert(k), do_convert(v)} end @@ -122,7 +124,7 @@ defmodule Pleroma.Web.AdminAPI.Config do def transform(entity), do: :erlang.term_to_binary(entity) - defp do_transform(%Regex{} = entity) when is_map(entity), do: entity + defp do_transform(%Regex{} = entity), do: entity defp do_transform(%{"tuple" => [":dispatch", [entity]]}) do {dispatch_settings, []} = do_eval(entity) @@ -154,8 +156,15 @@ defmodule Pleroma.Web.AdminAPI.Config do defp do_transform(entity), do: entity defp do_transform_string("~r/" <> pattern) do - pattern = String.trim_trailing(pattern, "/") - ~r/#{pattern}/ + modificator = String.split(pattern, "/") |> List.last() + pattern = String.trim_trailing(pattern, "/" <> modificator) + + case modificator do + "" -> ~r/#{pattern}/ + "i" -> ~r/#{pattern}/i + "u" -> ~r/#{pattern}/u + "s" -> ~r/#{pattern}/s + end end defp do_transform_string(":" <> atom), do: String.to_atom(atom) diff --git a/lib/pleroma/web/admin_api/report.ex b/lib/pleroma/web/admin_api/report.ex new file mode 100644 index 000000000..c751dc2be --- /dev/null +++ b/lib/pleroma/web/admin_api/report.ex @@ -0,0 +1,22 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.Report do + alias Pleroma.Activity + alias Pleroma.User + + def extract_report_info( + %{data: %{"actor" => actor, "object" => [account_ap_id | status_ap_ids]}} = report + ) do + user = User.get_cached_by_ap_id(actor) + account = User.get_cached_by_ap_id(account_ap_id) + + statuses = + Enum.map(status_ap_ids, fn ap_id -> + Activity.get_by_ap_id_with_object(ap_id) + end) + + %{report: report, user: user, account: account, statuses: statuses} + end +end diff --git a/lib/pleroma/web/admin_api/views/moderation_log_view.ex b/lib/pleroma/web/admin_api/views/moderation_log_view.ex index b3fc7cfe5..e7752d1f3 100644 --- a/lib/pleroma/web/admin_api/views/moderation_log_view.ex +++ b/lib/pleroma/web/admin_api/views/moderation_log_view.ex @@ -8,7 +8,10 @@ defmodule Pleroma.Web.AdminAPI.ModerationLogView do alias Pleroma.ModerationLog def render("index.json", %{log: log}) do - render_many(log, __MODULE__, "show.json", as: :log_entry) + %{ + items: render_many(log.items, __MODULE__, "show.json", as: :log_entry), + total: log.count + } end def render("show.json", %{log_entry: log_entry}) do diff --git a/lib/pleroma/web/admin_api/views/report_view.ex b/lib/pleroma/web/admin_api/views/report_view.ex index a25f3f1fe..101a74c63 100644 --- a/lib/pleroma/web/admin_api/views/report_view.ex +++ b/lib/pleroma/web/admin_api/views/report_view.ex @@ -4,25 +4,26 @@ defmodule Pleroma.Web.AdminAPI.ReportView do use Pleroma.Web, :view - alias Pleroma.Activity alias Pleroma.HTML alias Pleroma.User + alias Pleroma.Web.AdminAPI.Report alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.StatusView def render("index.json", %{reports: reports}) do %{ - reports: render_many(reports, __MODULE__, "show.json", as: :report) + reports: + reports[:items] + |> Enum.map(&Report.extract_report_info(&1)) + |> Enum.map(&render(__MODULE__, "show.json", &1)) + |> Enum.reverse(), + total: reports[:total] } end - def render("show.json", %{report: report}) do - user = User.get_cached_by_ap_id(report.data["actor"]) + def render("show.json", %{report: report, user: user, account: account, statuses: statuses}) do created_at = Utils.to_masto_date(report.data["published"]) - [account_ap_id | status_ap_ids] = report.data["object"] - account = User.get_cached_by_ap_id(account_ap_id) - content = unless is_nil(report.data["content"]) do HTML.filter_tags(report.data["content"]) @@ -30,11 +31,6 @@ defmodule Pleroma.Web.AdminAPI.ReportView do nil end - statuses = - Enum.map(status_ap_ids, fn ap_id -> - Activity.get_by_ap_id_with_object(ap_id) - end) - %{ id: report.id, account: merge_account_views(account), @@ -47,7 +43,7 @@ defmodule Pleroma.Web.AdminAPI.ReportView do end defp merge_account_views(%User{} = user) do - Pleroma.Web.MastodonAPI.AccountView.render("account.json", %{user: user}) + Pleroma.Web.MastodonAPI.AccountView.render("show.json", %{user: user}) |> Map.merge(Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: user})) end diff --git a/lib/pleroma/web/chat_channel.ex b/lib/pleroma/web/chat_channel.ex index b543909f1..08841a3e8 100644 --- a/lib/pleroma/web/chat_channel.ex +++ b/lib/pleroma/web/chat_channel.ex @@ -22,7 +22,7 @@ defmodule Pleroma.Web.ChatChannel do if String.length(text) > 0 do author = User.get_cached_by_nickname(user_name) - author = Pleroma.Web.MastodonAPI.AccountView.render("account.json", user: author) + author = Pleroma.Web.MastodonAPI.AccountView.render("show.json", user: author) message = ChatChannelState.add_message(%{text: text, author: author}) broadcast!(socket, "new_msg", message) diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex new file mode 100644 index 000000000..f7da81b34 --- /dev/null +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -0,0 +1,219 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.CommonAPI.ActivityDraft do + alias Pleroma.Activity + alias Pleroma.Conversation.Participation + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.CommonAPI.Utils + + import Pleroma.Web.Gettext + + defstruct valid?: true, + errors: [], + user: nil, + params: %{}, + status: nil, + summary: nil, + full_payload: nil, + attachments: [], + in_reply_to: nil, + in_reply_to_conversation: nil, + visibility: nil, + expires_at: nil, + poll: nil, + emoji: %{}, + content_html: nil, + mentions: [], + tags: [], + to: [], + cc: [], + context: nil, + sensitive: false, + object: nil, + preview?: false, + changes: %{} + + def create(user, params) do + %__MODULE__{user: user} + |> put_params(params) + |> status() + |> summary() + |> with_valid(&attachments/1) + |> full_payload() + |> expires_at() + |> poll() + |> with_valid(&in_reply_to/1) + |> with_valid(&in_reply_to_conversation/1) + |> with_valid(&visibility/1) + |> content() + |> with_valid(&to_and_cc/1) + |> with_valid(&context/1) + |> sensitive() + |> with_valid(&object/1) + |> preview?() + |> with_valid(&changes/1) + |> validate() + end + + defp put_params(draft, params) do + params = Map.put_new(params, "in_reply_to_status_id", params["in_reply_to_id"]) + %__MODULE__{draft | params: params} + end + + defp status(%{params: %{"status" => status}} = draft) do + %__MODULE__{draft | status: String.trim(status)} + end + + defp summary(%{params: params} = draft) do + %__MODULE__{draft | summary: Map.get(params, "spoiler_text", "")} + end + + defp full_payload(%{status: status, summary: summary} = draft) do + full_payload = String.trim(status <> summary) + + case Utils.validate_character_limit(full_payload, draft.attachments) do + :ok -> %__MODULE__{draft | full_payload: full_payload} + {:error, message} -> add_error(draft, message) + end + end + + defp attachments(%{params: params} = draft) do + attachments = Utils.attachments_from_ids(params) + %__MODULE__{draft | attachments: attachments} + end + + defp in_reply_to(draft) do + case Map.get(draft.params, "in_reply_to_status_id") do + "" -> draft + nil -> draft + id -> %__MODULE__{draft | in_reply_to: Activity.get_by_id(id)} + end + end + + defp in_reply_to_conversation(draft) do + in_reply_to_conversation = Participation.get(draft.params["in_reply_to_conversation_id"]) + %__MODULE__{draft | in_reply_to_conversation: in_reply_to_conversation} + end + + defp visibility(%{params: params} = draft) do + case CommonAPI.get_visibility(params, draft.in_reply_to, draft.in_reply_to_conversation) do + {visibility, "direct"} when visibility != "direct" -> + add_error(draft, dgettext("errors", "The message visibility must be direct")) + + {visibility, _} -> + %__MODULE__{draft | visibility: visibility} + end + end + + defp expires_at(draft) do + case CommonAPI.check_expiry_date(draft.params["expires_in"]) do + {:ok, expires_at} -> %__MODULE__{draft | expires_at: expires_at} + {:error, message} -> add_error(draft, message) + end + end + + defp poll(draft) do + case Utils.make_poll_data(draft.params) do + {:ok, {poll, poll_emoji}} -> + %__MODULE__{draft | poll: poll, emoji: Map.merge(draft.emoji, poll_emoji)} + + {:error, message} -> + add_error(draft, message) + end + end + + defp content(draft) do + {content_html, mentions, tags} = + Utils.make_content_html( + draft.status, + draft.attachments, + draft.params, + draft.visibility + ) + + %__MODULE__{draft | content_html: content_html, mentions: mentions, tags: tags} + end + + defp to_and_cc(draft) do + addressed_users = + draft.mentions + |> Enum.map(fn {_, mentioned_user} -> mentioned_user.ap_id end) + |> Utils.get_addressed_users(draft.params["to"]) + + {to, cc} = + Utils.get_to_and_cc( + draft.user, + addressed_users, + draft.in_reply_to, + draft.visibility, + draft.in_reply_to_conversation + ) + + %__MODULE__{draft | to: to, cc: cc} + end + + defp context(draft) do + context = Utils.make_context(draft.in_reply_to, draft.in_reply_to_conversation) + %__MODULE__{draft | context: context} + end + + defp sensitive(draft) do + sensitive = draft.params["sensitive"] || Enum.member?(draft.tags, {"#nsfw", "nsfw"}) + %__MODULE__{draft | sensitive: sensitive} + end + + defp object(draft) do + emoji = Map.merge(Pleroma.Emoji.Formatter.get_emoji_map(draft.full_payload), draft.emoji) + + object = + Utils.make_note_data( + draft.user.ap_id, + draft.to, + draft.context, + draft.content_html, + draft.attachments, + draft.in_reply_to, + draft.tags, + draft.summary, + draft.cc, + draft.sensitive, + draft.poll + ) + |> Map.put("emoji", emoji) + + %__MODULE__{draft | object: object} + end + + defp preview?(draft) do + preview? = Pleroma.Web.ControllerHelper.truthy_param?(draft.params["preview"]) || false + %__MODULE__{draft | preview?: preview?} + end + + defp changes(draft) do + direct? = draft.visibility == "direct" + + changes = + %{ + to: draft.to, + actor: draft.user, + context: draft.context, + object: draft.object, + additional: %{"cc" => draft.cc, "directMessage" => direct?} + } + |> Utils.maybe_add_list_data(draft.user, draft.visibility) + + %__MODULE__{draft | changes: changes} + end + + defp with_valid(%{valid?: true} = draft, func), do: func.(draft) + defp with_valid(draft, _func), do: draft + + defp add_error(draft, message) do + %__MODULE__{draft | valid?: false, errors: [message | draft.errors]} + end + + defp validate(%{valid?: true} = draft), do: {:ok, draft} + defp validate(%{errors: [message | _]}), do: {:error, message} +end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 5faddc9f4..386408d51 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -6,7 +6,6 @@ defmodule Pleroma.Web.CommonAPI do alias Pleroma.Activity alias Pleroma.ActivityExpiration alias Pleroma.Conversation.Participation - alias Pleroma.Formatter alias Pleroma.Object alias Pleroma.ThreadMute alias Pleroma.User @@ -17,15 +16,14 @@ defmodule Pleroma.Web.CommonAPI do import Pleroma.Web.Gettext import Pleroma.Web.CommonAPI.Utils + require Pleroma.Constants + def follow(follower, followed) do + timeout = Pleroma.Config.get([:activitypub, :follow_handshake_timeout]) + with {:ok, follower} <- User.maybe_direct_follow(follower, followed), {:ok, activity} <- ActivityPub.follow(follower, followed), - {:ok, follower, followed} <- - User.wait_and_refresh( - Pleroma.Config.get([:activitypub, :follow_handshake_timeout]), - follower, - followed - ) do + {:ok, follower, followed} <- User.wait_and_refresh(timeout, follower, followed) do {:ok, follower, followed, activity} end end @@ -76,29 +74,27 @@ defmodule Pleroma.Web.CommonAPI do {:ok, delete} <- ActivityPub.delete(object) do {:ok, delete} else - _ -> - {:error, dgettext("errors", "Could not delete")} + _ -> {:error, dgettext("errors", "Could not delete")} 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")} + _ -> {:error, dgettext("errors", "Could not repeat")} end end def unrepeat(id_or_ap_id, user) do - with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id), - object <- Object.normalize(activity) do + with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id) do + object = Object.normalize(activity) ActivityPub.unannounce(user, object) else - _ -> - {:error, dgettext("errors", "Could not unrepeat")} + _ -> {:error, dgettext("errors", "Could not unrepeat")} end end @@ -108,30 +104,23 @@ defmodule Pleroma.Web.CommonAPI do nil <- Utils.get_existing_like(user.ap_id, object) do ActivityPub.like(user, object) else - _ -> - {:error, dgettext("errors", "Could not favorite")} + _ -> {:error, dgettext("errors", "Could not favorite")} end end def unfavorite(id_or_ap_id, user) do - with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id), - object <- Object.normalize(activity) do + with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id) do + object = Object.normalize(activity) ActivityPub.unlike(user, object) else - _ -> - {:error, dgettext("errors", "Could not unfavorite")} + _ -> {:error, dgettext("errors", "Could not unfavorite")} end end - def vote(user, object, choices) do - with "Question" <- object.data["type"], - {:author, false} <- {:author, object.data["actor"] == user.ap_id}, - {:existing_votes, []} <- {:existing_votes, Utils.get_existing_votes(user.ap_id, object)}, - {options, max_count} <- get_options_and_max_count(object), - option_count <- Enum.count(options), - {:choice_check, {choices, true}} <- - {:choice_check, normalize_and_validate_choice_indices(choices, option_count)}, - {:count_check, true} <- {:count_check, Enum.count(choices) <= max_count} do + def vote(user, %{data: %{"type" => "Question"}} = object, choices) do + with :ok <- validate_not_author(object, user), + :ok <- validate_existing_votes(user, object), + {:ok, options, choices} <- normalize_and_validate_choices(choices, object) do answer_activities = Enum.map(choices, fn index -> answer_data = make_answer_data(user, object, Enum.at(options, index)["name"]) @@ -150,33 +139,49 @@ defmodule Pleroma.Web.CommonAPI do object = Object.get_cached_by_ap_id(object.data["id"]) {:ok, answer_activities, object} - else - {:author, _} -> {:error, dgettext("errors", "Poll's author can't vote")} - {:existing_votes, _} -> {:error, dgettext("errors", "Already voted")} - {:choice_check, {_, false}} -> {:error, dgettext("errors", "Invalid indices")} - {:count_check, false} -> {:error, dgettext("errors", "Too many choices")} end end - defp get_options_and_max_count(object) do - if Map.has_key?(object.data, "anyOf") do - {object.data["anyOf"], Enum.count(object.data["anyOf"])} + defp validate_not_author(%{data: %{"actor" => ap_id}}, %{ap_id: ap_id}), + do: {:error, dgettext("errors", "Poll's author can't vote")} + + defp validate_not_author(_, _), do: :ok + + defp validate_existing_votes(%{ap_id: ap_id}, object) do + if Utils.get_existing_votes(ap_id, object) == [] do + :ok else - {object.data["oneOf"], 1} + {:error, dgettext("errors", "Already voted")} end end - defp normalize_and_validate_choice_indices(choices, count) do - Enum.map_reduce(choices, true, fn index, valid -> - index = if is_binary(index), do: String.to_integer(index), else: index - {index, if(valid, do: index < count, else: valid)} - end) + defp get_options_and_max_count(%{data: %{"anyOf" => any_of}}), do: {any_of, Enum.count(any_of)} + defp get_options_and_max_count(%{data: %{"oneOf" => one_of}}), do: {one_of, 1} + + defp normalize_and_validate_choices(choices, object) do + choices = Enum.map(choices, fn i -> if is_binary(i), do: String.to_integer(i), else: i end) + {options, max_count} = get_options_and_max_count(object) + count = Enum.count(options) + + with {_, true} <- {:valid_choice, Enum.all?(choices, &(&1 < count))}, + {_, true} <- {:count_check, Enum.count(choices) <= max_count} do + {:ok, options, choices} + else + {:valid_choice, _} -> {:error, dgettext("errors", "Invalid indices")} + {:count_check, _} -> {:error, dgettext("errors", "Too many choices")} + end end - def get_visibility(_, _, %Participation{}) do - {"direct", "direct"} + 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, _) when visibility in ~w{public unlisted private direct}, do: {visibility, get_replied_to_visibility(in_reply_to)} @@ -197,13 +202,13 @@ defmodule Pleroma.Web.CommonAPI do def get_replied_to_visibility(activity) do with %Object{} = object <- Object.normalize(activity) do - Pleroma.Web.ActivityPub.Visibility.get_visibility(object) + Visibility.get_visibility(object) end end - defp check_expiry_date({:ok, nil} = res), do: res + def check_expiry_date({:ok, nil} = res), do: res - defp check_expiry_date({:ok, in_seconds}) do + def check_expiry_date({:ok, in_seconds}) do expiry = NaiveDateTime.utc_now() |> NaiveDateTime.add(in_seconds) if ActivityExpiration.expires_late_enough?(expiry) do @@ -213,110 +218,62 @@ defmodule Pleroma.Web.CommonAPI do end end - defp check_expiry_date(expiry_str) do + def check_expiry_date(expiry_str) do Ecto.Type.cast(:integer, expiry_str) |> check_expiry_date() end - def post(user, %{"status" => status} = data) do - limit = Pleroma.Config.get([:instance, :limit]) - - with status <- String.trim(status), - attachments <- attachments_from_ids(data), - in_reply_to <- get_replied_to_activity(data["in_reply_to_status_id"]), - in_reply_to_conversation <- Participation.get(data["in_reply_to_conversation_id"]), - {visibility, in_reply_to_visibility} <- - get_visibility(data, in_reply_to, in_reply_to_conversation), - {_, false} <- - {:private_to_public, in_reply_to_visibility == "direct" && visibility != "direct"}, - {content_html, mentions, tags} <- - make_content_html( - status, - attachments, - data, - visibility - ), - mentioned_users <- for({_, mentioned_user} <- mentions, do: mentioned_user.ap_id), - addressed_users <- get_addressed_users(mentioned_users, data["to"]), - {poll, poll_emoji} <- make_poll_data(data), - {to, cc} <- - get_to_and_cc(user, addressed_users, in_reply_to, visibility, in_reply_to_conversation), - context <- make_context(in_reply_to, in_reply_to_conversation), - cw <- data["spoiler_text"] || "", - sensitive <- data["sensitive"] || Enum.member?(tags, {"#nsfw", "nsfw"}), - {:ok, expires_at} <- check_expiry_date(data["expires_in"]), - full_payload <- String.trim(status <> cw), - :ok <- validate_character_limit(full_payload, attachments, limit), - object <- - make_note_data( - user.ap_id, - to, - context, - content_html, - attachments, - in_reply_to, - tags, - cw, - cc, - sensitive, - poll - ), - object <- - Map.put( - object, - "emoji", - Map.merge(Formatter.get_emoji_map(full_payload), poll_emoji) - ) do - preview? = Pleroma.Web.ControllerHelper.truthy_param?(data["preview"]) || false - direct? = visibility == "direct" - - result = - %{ - to: to, - actor: user, - context: context, - object: object, - additional: %{"cc" => cc, "directMessage" => direct?} - } - |> maybe_add_list_data(user, visibility) - |> ActivityPub.create(preview?) - - if expires_at do - with {:ok, activity} <- result do - {:ok, _} = ActivityExpiration.create(activity, expires_at) - end - end - - result - else - {:private_to_public, true} -> - {:error, dgettext("errors", "The message visibility must be direct")} + def listen(user, %{"title" => _} = data) do + with visibility <- data["visibility"] || "public", + {to, cc} <- get_to_and_cc(user, [], nil, visibility, nil), + listen_data <- + Map.take(data, ["album", "artist", "title", "length"]) + |> Map.put("type", "Audio") + |> Map.put("to", to) + |> Map.put("cc", cc) + |> Map.put("actor", user.ap_id), + {:ok, activity} <- + ActivityPub.listen(%{ + actor: user, + to: to, + object: listen_data, + context: Utils.generate_context_id(), + additional: %{"cc" => cc} + }) do + {:ok, activity} + end + end - {:error, _} = e -> - e + def post(user, %{"status" => _} = data) do + with {:ok, draft} <- Pleroma.Web.CommonAPI.ActivityDraft.create(user, data) do + draft.changes + |> ActivityPub.create(draft.preview?) + |> maybe_create_activity_expiration(draft.expires_at) + end + end - e -> - {:error, e} + defp maybe_create_activity_expiration({:ok, activity}, %NaiveDateTime{} = expires_at) do + with {:ok, _} <- ActivityExpiration.create(activity, expires_at) do + {:ok, activity} end end + defp maybe_create_activity_expiration(result, _), do: result + # Updates the emojis for a user based on their profile def update(user) do + emoji = emoji_from_profile(user) + source_data = user.info |> Map.get(:source_data, %{}) |> Map.put("tag", emoji) + user = - with emoji <- emoji_from_profile(user), - source_data <- (user.info.source_data || %{}) |> Map.put("tag", emoji), - info_cng <- User.Info.set_source_data(user.info, source_data), - change <- Ecto.Changeset.change(user) |> Ecto.Changeset.put_embed(:info, info_cng), - {:ok, user} <- User.update_and_set_cache(change) do - user - else - _e -> - user + case User.update_info(user, &User.Info.set_source_data(&1, source_data)) do + {:ok, user} -> user + _ -> user end ActivityPub.update(%{ local: true, - to: [user.follower_address], + to: [Pleroma.Constants.as_public(), user.follower_address], cc: [], actor: user.ap_id, object: Pleroma.Web.ActivityPub.UserView.render("user.json", %{user: user}) @@ -326,44 +283,25 @@ defmodule Pleroma.Web.CommonAPI do def pin(id_or_ap_id, %{ap_id: user_ap_id} = user) do with %Activity{ actor: ^user_ap_id, - data: %{ - "type" => "Create" - }, - object: %Object{ - data: %{ - "type" => "Note" - } - } + data: %{"type" => "Create"}, + object: %Object{data: %{"type" => "Note"}} } = activity <- get_by_id_or_ap_id(id_or_ap_id), true <- Visibility.is_public?(activity), - %{valid?: true} = info_changeset <- User.Info.add_pinnned_activity(user.info, activity), - changeset <- - Ecto.Changeset.change(user) |> Ecto.Changeset.put_embed(:info, info_changeset), - {:ok, _user} <- User.update_and_set_cache(changeset) do + {:ok, _user} <- User.update_info(user, &User.Info.add_pinnned_activity(&1, activity)) do {:ok, activity} else - %{errors: [pinned_activities: {err, _}]} -> - {:error, err} - - _ -> - {:error, dgettext("errors", "Could not pin")} + {:error, %{changes: %{info: %{errors: [pinned_activities: {err, _}]}}}} -> {:error, err} + _ -> {:error, dgettext("errors", "Could not pin")} end end def unpin(id_or_ap_id, user) do with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id), - %{valid?: true} = info_changeset <- - User.Info.remove_pinnned_activity(user.info, activity), - changeset <- - Ecto.Changeset.change(user) |> Ecto.Changeset.put_embed(:info, info_changeset), - {:ok, _user} <- User.update_and_set_cache(changeset) do + {:ok, _user} <- User.update_info(user, &User.Info.remove_pinnned_activity(&1, activity)) do {:ok, activity} else - %{errors: [pinned_activities: {err, _}]} -> - {:error, err} - - _ -> - {:error, dgettext("errors", "Could not unpin")} + %{errors: [pinned_activities: {err, _}]} -> {:error, err} + _ -> {:error, dgettext("errors", "Could not unpin")} end end @@ -383,51 +321,46 @@ defmodule Pleroma.Web.CommonAPI do def thread_muted?(%{id: nil} = _user, _activity), do: false def thread_muted?(user, activity) do - with [] <- ThreadMute.check_muted(user.id, activity.data["context"]) do - false - else - _ -> true - end + ThreadMute.check_muted(user.id, activity.data["context"]) != [] end - def report(user, data) do - with {:account_id, %{"account_id" => account_id}} <- {:account_id, data}, - {:account, %User{} = account} <- {:account, User.get_cached_by_id(account_id)}, + def report(user, %{"account_id" => account_id} = data) do + with {:ok, account} <- get_reported_account(account_id), {:ok, {content_html, _, _}} <- make_report_content_html(data["comment"]), - {:ok, statuses} <- get_report_statuses(account, data), - {:ok, activity} <- - ActivityPub.flag(%{ - context: Utils.generate_context_id(), - actor: user, - account: account, - statuses: statuses, - content: content_html, - forward: data["forward"] || false - }) do - {:ok, activity} - else - {:error, err} -> {:error, err} - {:account_id, %{}} -> {:error, dgettext("errors", "Valid `account_id` required")} - {:account, nil} -> {:error, dgettext("errors", "Account not found")} + {:ok, statuses} <- get_report_statuses(account, data) do + ActivityPub.flag(%{ + context: Utils.generate_context_id(), + actor: user, + account: account, + statuses: statuses, + content: content_html, + forward: data["forward"] || false + }) + end + end + + def report(_user, _params), do: {:error, dgettext("errors", "Valid `account_id` required")} + + defp get_reported_account(account_id) do + case User.get_cached_by_id(account_id) do + %User{} = account -> {:ok, account} + _ -> {:error, dgettext("errors", "Account not found")} end end def update_report_state(activity_id, state) do - with %Activity{} = activity <- Activity.get_by_id(activity_id), - {:ok, activity} <- Utils.update_report_state(activity, state) do - {:ok, activity} + with %Activity{} = activity <- Activity.get_by_id(activity_id) do + Utils.update_report_state(activity, state) else nil -> {:error, :not_found} - {:error, reason} -> {:error, reason} _ -> {:error, dgettext("errors", "Could not update state")} end end def update_activity_scope(activity_id, opts \\ %{}) do with %Activity{} = activity <- Activity.get_by_id_with_object(activity_id), - {:ok, activity} <- toggle_sensitive(activity, opts), - {:ok, activity} <- set_visibility(activity, opts) do - {:ok, activity} + {:ok, activity} <- toggle_sensitive(activity, opts) do + set_visibility(activity, opts) else nil -> {:error, :not_found} {:error, reason} -> {:error, reason} @@ -458,23 +391,15 @@ defmodule Pleroma.Web.CommonAPI do defp set_visibility(activity, _), do: {:ok, activity} - def hide_reblogs(user, muted) do - ap_id = muted.ap_id - + def hide_reblogs(user, %{ap_id: ap_id} = _muted) do if ap_id not in user.info.muted_reblogs do - info_changeset = User.Info.add_reblog_mute(user.info, ap_id) - changeset = Ecto.Changeset.change(user) |> Ecto.Changeset.put_embed(:info, info_changeset) - User.update_and_set_cache(changeset) + User.update_info(user, &User.Info.add_reblog_mute(&1, ap_id)) end end - def show_reblogs(user, muted) do - ap_id = muted.ap_id - + def show_reblogs(user, %{ap_id: ap_id} = _muted) do if ap_id in user.info.muted_reblogs do - info_changeset = User.Info.remove_reblog_mute(user.info, ap_id) - changeset = Ecto.Changeset.change(user) |> Ecto.Changeset.put_embed(:info, info_changeset) - User.update_and_set_cache(changeset) + User.update_info(user, &User.Info.remove_reblog_mute(&1, ap_id)) end end end diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 6958c7511..88a5f434a 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -4,11 +4,13 @@ defmodule Pleroma.Web.CommonAPI.Utils do import Pleroma.Web.Gettext + import Pleroma.Web.ControllerHelper, only: [truthy_param?: 1] alias Calendar.Strftime alias Pleroma.Activity alias Pleroma.Config alias Pleroma.Conversation.Participation + alias Pleroma.Emoji alias Pleroma.Formatter alias Pleroma.Object alias Pleroma.Plugs.AuthenticationPlug @@ -25,7 +27,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do # This is a hack for twidere. def get_by_id_or_ap_id(id) do activity = - with true <- Pleroma.FlakeId.is_flake_id?(id), + with true <- FlakeId.flake_id?(id), %Activity{} = activity <- Activity.get_by_id_with_object(id) do activity else @@ -40,14 +42,6 @@ defmodule Pleroma.Web.CommonAPI.Utils do end end - def get_replied_to_activity(""), do: nil - - def get_replied_to_activity(id) when not is_nil(id) do - Activity.get_by_id(id) - end - - def get_replied_to_activity(_), do: nil - def attachments_from_ids(%{"media_ids" => ids, "descriptions" => desc} = _) do attachments_from_ids_descs(ids, desc) end @@ -158,70 +152,74 @@ defmodule Pleroma.Web.CommonAPI.Utils do def maybe_add_list_data(activity_params, _, _), do: activity_params + def make_poll_data(%{"poll" => %{"expires_in" => expires_in}} = data) + when is_binary(expires_in) do + # In some cases mastofe sends out strings instead of integers + data + |> put_in(["poll", "expires_in"], String.to_integer(expires_in)) + |> make_poll_data() + end + def make_poll_data(%{"poll" => %{"options" => options, "expires_in" => expires_in}} = data) when is_list(options) do - %{max_expiration: max_expiration, min_expiration: min_expiration} = - limits = Pleroma.Config.get([:instance, :poll_limits]) - - # XXX: There is probably a cleaner way of doing this - try do - # In some cases mastofe sends out strings instead of integers - expires_in = if is_binary(expires_in), do: String.to_integer(expires_in), else: expires_in - - if Enum.count(options) > limits.max_options do - raise ArgumentError, message: "Poll can't contain more than #{limits.max_options} options" - end + limits = Pleroma.Config.get([:instance, :poll_limits]) - {poll, emoji} = + with :ok <- validate_poll_expiration(expires_in, limits), + :ok <- validate_poll_options_amount(options, limits), + :ok <- validate_poll_options_length(options, limits) do + {option_notes, emoji} = Enum.map_reduce(options, %{}, fn option, emoji -> - if String.length(option) > limits.max_option_chars do - raise ArgumentError, - message: - "Poll options cannot be longer than #{limits.max_option_chars} characters each" - end - - {%{ - "name" => option, - "type" => "Note", - "replies" => %{"type" => "Collection", "totalItems" => 0} - }, Map.merge(emoji, Formatter.get_emoji_map(option))} - end) - - case expires_in do - expires_in when expires_in > max_expiration -> - raise ArgumentError, message: "Expiration date is too far in the future" - - expires_in when expires_in < min_expiration -> - raise ArgumentError, message: "Expiration date is too soon" + note = %{ + "name" => option, + "type" => "Note", + "replies" => %{"type" => "Collection", "totalItems" => 0} + } - _ -> - :noop - end + {note, Map.merge(emoji, Emoji.Formatter.get_emoji_map(option))} + end) end_time = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in) |> NaiveDateTime.to_iso8601() - poll = - if Pleroma.Web.ControllerHelper.truthy_param?(data["poll"]["multiple"]) do - %{"type" => "Question", "anyOf" => poll, "closed" => end_time} - else - %{"type" => "Question", "oneOf" => poll, "closed" => end_time} - end + key = if truthy_param?(data["poll"]["multiple"]), do: "anyOf", else: "oneOf" + poll = %{"type" => "Question", key => option_notes, "closed" => end_time} - {poll, emoji} - rescue - e in ArgumentError -> e.message + {:ok, {poll, emoji}} end end def make_poll_data(%{"poll" => poll}) when is_map(poll) do - "Invalid poll" + {:error, "Invalid poll"} end def make_poll_data(_data) do - {%{}, %{}} + {:ok, {%{}, %{}}} + end + + defp validate_poll_options_amount(options, %{max_options: max_options}) do + if Enum.count(options) > max_options do + {:error, "Poll can't contain more than #{max_options} options"} + else + :ok + end + end + + defp validate_poll_options_length(options, %{max_option_chars: max_option_chars}) do + if Enum.any?(options, &(String.length(&1) > max_option_chars)) do + {:error, "Poll options cannot be longer than #{max_option_chars} characters each"} + else + :ok + end + end + + defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: max}) do + cond do + expires_in > max -> {:error, "Expiration date is too far in the future"} + expires_in < min -> {:error, "Expiration date is too soon"} + true -> :ok + end end def make_content_html( @@ -233,7 +231,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do no_attachment_links = data |> Map.get("no_attachment_links", Config.get([:instance, :no_attachment_links])) - |> Kernel.in([true, "true"]) + |> truthy_param?() content_type = get_content_type(data["content_type"]) @@ -346,25 +344,25 @@ defmodule Pleroma.Web.CommonAPI.Utils do attachments, in_reply_to, tags, - cw \\ nil, + summary \\ nil, cc \\ [], sensitive \\ false, - merge \\ %{} + extra_params \\ %{} ) do %{ "type" => "Note", "to" => to, "cc" => cc, "content" => content_html, - "summary" => cw, - "sensitive" => !Enum.member?(["false", "False", "0", false], sensitive), + "summary" => summary, + "sensitive" => truthy_param?(sensitive), "context" => context, "attachment" => attachments, "actor" => actor, "tag" => Keyword.values(tags) |> Enum.uniq() } |> add_in_reply_to(in_reply_to) - |> Map.merge(merge) + |> Map.merge(extra_params) end defp add_in_reply_to(object, nil), do: object @@ -433,12 +431,14 @@ defmodule Pleroma.Web.CommonAPI.Utils do end end - def emoji_from_profile(%{info: _info} = user) do - (Formatter.get_emoji(user.bio) ++ Formatter.get_emoji(user.name)) - |> Enum.map(fn {shortcode, url, _} -> + def emoji_from_profile(%User{bio: bio, name: name}) do + [bio, name] + |> Enum.map(&Emoji.Formatter.get_emoji/1) + |> Enum.concat() + |> Enum.map(fn {shortcode, %Emoji{file: path}} -> %{ "type" => "Emoji", - "icon" => %{"type" => "Image", "url" => "#{Endpoint.url()}#{url}"}, + "icon" => %{"type" => "Image", "url" => "#{Endpoint.url()}#{path}"}, "name" => ":#{shortcode}:" } end) @@ -570,15 +570,16 @@ defmodule Pleroma.Web.CommonAPI.Utils do } end - def validate_character_limit(full_payload, attachments, limit) do + def validate_character_limit("" = _full_payload, [] = _attachments) do + {:error, dgettext("errors", "Cannot post an empty status without attachments")} + end + + def validate_character_limit(full_payload, _attachments) do + limit = Pleroma.Config.get([:instance, :limit]) length = String.length(full_payload) if length < limit do - if length > 0 or Enum.count(attachments) > 0 do - :ok - else - {:error, dgettext("errors", "Cannot post an empty status without attachments")} - end + :ok else {:error, dgettext("errors", "The status is over the character limit")} end diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index eeac9f503..9a4e322c9 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.ControllerHelper do use Pleroma.Web, :controller # As in MastoAPI, per https://api.rubyonrails.org/classes/ActiveModel/Type/Boolean.html - @falsy_param_values [false, 0, "0", "f", "F", "false", "FALSE", "off", "OFF"] + @falsy_param_values [false, 0, "0", "f", "F", "false", "False", "FALSE", "off", "OFF"] def truthy_param?(blank_value) when blank_value in [nil, ""], do: nil def truthy_param?(value), do: value not in @falsy_param_values @@ -34,79 +34,57 @@ defmodule Pleroma.Web.ControllerHelper do defp param_to_integer(_, default), do: default - def add_link_headers( - conn, - method, - activities, - param \\ nil, - params \\ %{}, - func3 \\ nil, - func4 \\ nil - ) do - params = - conn.params - |> Map.drop(["since_id", "max_id", "min_id"]) - |> Map.merge(params) - - last = List.last(activities) - - func3 = func3 || (&mastodon_api_url/3) - func4 = func4 || (&mastodon_api_url/4) - - if last do - max_id = last.id - - limit = - params - |> Map.get("limit", "20") - |> String.to_integer() - - min_id = - if length(activities) <= limit do - activities - |> List.first() - |> Map.get(:id) - else - activities - |> Enum.at(limit * -1) - |> Map.get(:id) - end - - {next_url, prev_url} = - if param do - { - func4.( - Pleroma.Web.Endpoint, - method, - param, - Map.merge(params, %{max_id: max_id}) - ), - func4.( - Pleroma.Web.Endpoint, - method, - param, - Map.merge(params, %{min_id: min_id}) - ) - } - else - { - func3.( - Pleroma.Web.Endpoint, - method, - Map.merge(params, %{max_id: max_id}) - ), - func3.( - Pleroma.Web.Endpoint, - method, - Map.merge(params, %{min_id: min_id}) - ) - } - end - - conn - |> put_resp_header("link", "<#{next_url}>; rel=\"next\", <#{prev_url}>; rel=\"prev\"") - else - conn + def add_link_headers(conn, activities, extra_params \\ %{}) do + case List.last(activities) do + %{id: max_id} -> + params = + conn.params + |> Map.drop(Map.keys(conn.path_params)) + |> Map.drop(["since_id", "max_id", "min_id"]) + |> Map.merge(extra_params) + + limit = + params + |> Map.get("limit", "20") + |> String.to_integer() + + min_id = + if length(activities) <= limit do + activities + |> List.first() + |> Map.get(:id) + else + activities + |> Enum.at(limit * -1) + |> Map.get(:id) + end + + next_url = current_url(conn, Map.merge(params, %{max_id: max_id})) + prev_url = current_url(conn, Map.merge(params, %{min_id: min_id})) + + put_resp_header(conn, "link", "<#{next_url}>; rel=\"next\", <#{prev_url}>; rel=\"prev\"") + + _ -> + conn end end + + def assign_account_by_id(%{params: %{"id" => id}} = conn, _) do + case Pleroma.User.get_cached_by_id(id) do + %Pleroma.User{} = account -> assign(conn, :account, account) + 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/endpoint.ex b/lib/pleroma/web/endpoint.ex index eb805e853..2212e93f4 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -97,10 +97,7 @@ defmodule Pleroma.Web.Endpoint do extra: extra ) - # Note: the plug and its configuration is compile-time this can't be upstreamed yet - if proxies = Pleroma.Config.get([__MODULE__, :reverse_proxies]) do - plug(RemoteIp, proxies: proxies) - end + plug(Pleroma.Plugs.RemoteIp) defmodule Instrumenter do use Prometheus.PhoenixInstrumenter diff --git a/lib/pleroma/web/federator/federator.ex b/lib/pleroma/web/federator/federator.ex index f4f9e83e0..1a2da014a 100644 --- a/lib/pleroma/web/federator/federator.ex +++ b/lib/pleroma/web/federator/federator.ex @@ -10,16 +10,17 @@ defmodule Pleroma.Web.Federator do alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.Federator.Publisher - alias Pleroma.Web.Federator.RetryQueue alias Pleroma.Web.OStatus alias Pleroma.Web.Websub + alias Pleroma.Workers.PublisherWorker + alias Pleroma.Workers.ReceiverWorker + alias Pleroma.Workers.SubscriberWorker require Logger def init do - # 1 minute - Process.sleep(1000 * 60) - refresh_subscriptions() + # To do: consider removing this call in favor of scheduled execution (`quantum`-based) + refresh_subscriptions(schedule_in: 60) end @doc "Addresses [memory leaks on recursive replies fetching](https://git.pleroma.social/pleroma/pleroma/issues/161)" @@ -37,50 +38,38 @@ defmodule Pleroma.Web.Federator do # Client API def incoming_doc(doc) do - PleromaJobQueue.enqueue(:federator_incoming, __MODULE__, [:incoming_doc, doc]) + ReceiverWorker.enqueue("incoming_doc", %{"body" => doc}) end def incoming_ap_doc(params) do - PleromaJobQueue.enqueue(:federator_incoming, __MODULE__, [:incoming_ap_doc, params]) + ReceiverWorker.enqueue("incoming_ap_doc", %{"params" => params}) end - def publish(activity, priority \\ 1) do - PleromaJobQueue.enqueue(:federator_outgoing, __MODULE__, [:publish, activity], priority) + def publish(%{id: "pleroma:fakeid"} = activity) do + perform(:publish, activity) end - def verify_websub(websub) do - PleromaJobQueue.enqueue(:federator_outgoing, __MODULE__, [:verify_websub, websub]) + def publish(activity) do + PublisherWorker.enqueue("publish", %{"activity_id" => activity.id}) end - def request_subscription(sub) do - PleromaJobQueue.enqueue(:federator_outgoing, __MODULE__, [:request_subscription, sub]) + def verify_websub(websub) do + SubscriberWorker.enqueue("verify_websub", %{"websub_id" => websub.id}) end - def refresh_subscriptions do - PleromaJobQueue.enqueue(:federator_outgoing, __MODULE__, [:refresh_subscriptions]) + def request_subscription(websub) do + SubscriberWorker.enqueue("request_subscription", %{"websub_id" => websub.id}) end - # Job Worker Callbacks - - def perform(:refresh_subscriptions) do - Logger.debug("Federator running refresh subscriptions") - Websub.refresh_subscriptions() - - spawn(fn -> - # 6 hours - Process.sleep(1000 * 60 * 60 * 6) - refresh_subscriptions() - end) + def refresh_subscriptions(worker_args \\ []) do + SubscriberWorker.enqueue("refresh_subscriptions", %{}, worker_args ++ [max_attempts: 1]) end - def perform(:request_subscription, websub) do - Logger.debug("Refreshing #{websub.topic}") + # Job Worker Callbacks - with {:ok, websub} <- Websub.request_subscription(websub) do - Logger.debug("Successfully refreshed #{websub.topic}") - else - _e -> Logger.debug("Couldn't refresh #{websub.topic}") - end + @spec perform(atom(), module(), any()) :: {:ok, any()} | {:error, any()} + def perform(:publish_one, module, params) do + apply(module, :publish_one, [params]) end def perform(:publish, activity) do @@ -92,14 +81,6 @@ defmodule Pleroma.Web.Federator do end end - def perform(:verify_websub, websub) do - Logger.debug(fn -> - "Running WebSub verification for #{websub.id} (#{websub.topic}, #{websub.callback})" - end) - - Websub.verify(websub) - end - def perform(:incoming_doc, doc) do Logger.info("Got document, trying to parse") OStatus.handle_incoming(doc) @@ -130,22 +111,27 @@ defmodule Pleroma.Web.Federator do end end - def perform( - :publish_single_websub, - %{xml: _xml, topic: _topic, callback: _callback, secret: _secret} = params - ) do - case Websub.publish_one(params) do - {:ok, _} -> - :ok + def perform(:request_subscription, websub) do + Logger.debug("Refreshing #{websub.topic}") - {:error, _} -> - RetryQueue.enqueue(params, Websub) + with {:ok, websub} <- Websub.request_subscription(websub) do + Logger.debug("Successfully refreshed #{websub.topic}") + else + _e -> Logger.debug("Couldn't refresh #{websub.topic}") end end - def perform(type, _) do - Logger.debug(fn -> "Unknown task: #{type}" end) - {:error, "Don't know what to do with this"} + def perform(:verify_websub, websub) do + Logger.debug(fn -> + "Running WebSub verification for #{websub.id} (#{websub.topic}, #{websub.callback})" + end) + + Websub.verify(websub) + end + + def perform(:refresh_subscriptions) do + Logger.debug("Federator running refresh subscriptions") + Websub.refresh_subscriptions() end def ap_enabled_actor(id) do diff --git a/lib/pleroma/web/federator/publisher.ex b/lib/pleroma/web/federator/publisher.ex index 70f870244..937064638 100644 --- a/lib/pleroma/web/federator/publisher.ex +++ b/lib/pleroma/web/federator/publisher.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.Federator.Publisher do alias Pleroma.Activity alias Pleroma.Config alias Pleroma.User - alias Pleroma.Web.Federator.RetryQueue + alias Pleroma.Workers.PublisherWorker require Logger @@ -30,23 +30,11 @@ defmodule Pleroma.Web.Federator.Publisher do Enqueue publishing a single activity. """ @spec enqueue_one(module(), Map.t()) :: :ok - def enqueue_one(module, %{} = params), - do: PleromaJobQueue.enqueue(:federator_outgoing, __MODULE__, [:publish_one, module, params]) - - @spec perform(atom(), module(), any()) :: {:ok, any()} | {:error, any()} - def perform(:publish_one, module, params) do - case apply(module, :publish_one, [params]) do - {:ok, _} -> - :ok - - {:error, _e} -> - RetryQueue.enqueue(params, module) - end - end - - def perform(type, _, _) do - Logger.debug("Unknown task: #{type}") - {:error, "Don't know what to do with this"} + def enqueue_one(module, %{} = params) do + PublisherWorker.enqueue( + "publish_one", + %{"module" => to_string(module), "params" => params} + ) end @doc """ diff --git a/lib/pleroma/web/federator/retry_queue.ex b/lib/pleroma/web/federator/retry_queue.ex deleted file mode 100644 index 9eab8c218..000000000 --- a/lib/pleroma/web/federator/retry_queue.ex +++ /dev/null @@ -1,239 +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.Federator.RetryQueue do - use GenServer - - require Logger - - def init(args) do - queue_table = :ets.new(:pleroma_retry_queue, [:bag, :protected]) - - {:ok, %{args | queue_table: queue_table, running_jobs: :sets.new()}} - end - - def start_link(_) do - enabled = - if Pleroma.Config.get(:env) == :test, - do: true, - else: Pleroma.Config.get([__MODULE__, :enabled], false) - - if enabled do - Logger.info("Starting retry queue") - - linkres = - GenServer.start_link( - __MODULE__, - %{delivered: 0, dropped: 0, queue_table: nil, running_jobs: nil}, - name: __MODULE__ - ) - - maybe_kickoff_timer() - linkres - else - Logger.info("Retry queue disabled") - :ignore - end - end - - def enqueue(data, transport, retries \\ 0) do - GenServer.cast(__MODULE__, {:maybe_enqueue, data, transport, retries + 1}) - end - - def get_stats do - GenServer.call(__MODULE__, :get_stats) - end - - def reset_stats do - GenServer.call(__MODULE__, :reset_stats) - end - - def get_retry_params(retries) do - if retries > Pleroma.Config.get([__MODULE__, :max_retries]) do - {:drop, "Max retries reached"} - else - {:retry, growth_function(retries)} - end - end - - def get_retry_timer_interval do - Pleroma.Config.get([:retry_queue, :interval], 1000) - end - - defp ets_count_expires(table, current_time) do - :ets.select_count( - table, - [ - { - {:"$1", :"$2"}, - [{:"=<", :"$1", {:const, current_time}}], - [true] - } - ] - ) - end - - defp ets_pop_n_expired(table, current_time, desired) do - {popped, _continuation} = - :ets.select( - table, - [ - { - {:"$1", :"$2"}, - [{:"=<", :"$1", {:const, current_time}}], - [:"$_"] - } - ], - desired - ) - - popped - |> Enum.each(fn e -> - :ets.delete_object(table, e) - end) - - popped - end - - def maybe_start_job(running_jobs, queue_table) do - # we don't want to hit the ets or the DateTime more times than we have to - # could optimize slightly further by not using the count, and instead grabbing - # up to N objects early... - current_time = DateTime.to_unix(DateTime.utc_now()) - n_running_jobs = :sets.size(running_jobs) - - if n_running_jobs < Pleroma.Config.get([__MODULE__, :max_jobs]) do - n_ready_jobs = ets_count_expires(queue_table, current_time) - - if n_ready_jobs > 0 do - # figure out how many we could start - available_job_slots = Pleroma.Config.get([__MODULE__, :max_jobs]) - n_running_jobs - start_n_jobs(running_jobs, queue_table, current_time, available_job_slots) - else - running_jobs - end - else - running_jobs - end - end - - defp start_n_jobs(running_jobs, _queue_table, _current_time, 0) do - running_jobs - end - - defp start_n_jobs(running_jobs, queue_table, current_time, available_job_slots) - when available_job_slots > 0 do - candidates = ets_pop_n_expired(queue_table, current_time, available_job_slots) - - candidates - |> List.foldl(running_jobs, fn {_, e}, rj -> - {:ok, pid} = Task.start(fn -> worker(e) end) - mref = Process.monitor(pid) - :sets.add_element(mref, rj) - end) - end - - def worker({:send, data, transport, retries}) do - case transport.publish_one(data) do - {:ok, _} -> - GenServer.cast(__MODULE__, :inc_delivered) - :delivered - - {:error, _reason} -> - enqueue(data, transport, retries) - :retry - end - end - - def handle_call(:get_stats, _from, %{delivered: delivery_count, dropped: drop_count} = state) do - {:reply, %{delivered: delivery_count, dropped: drop_count}, state} - end - - def handle_call(:reset_stats, _from, %{delivered: delivery_count, dropped: drop_count} = state) do - {:reply, %{delivered: delivery_count, dropped: drop_count}, - %{state | delivered: 0, dropped: 0}} - end - - def handle_cast(:reset_stats, state) do - {:noreply, %{state | delivered: 0, dropped: 0}} - end - - def handle_cast( - {:maybe_enqueue, data, transport, retries}, - %{dropped: drop_count, queue_table: queue_table, running_jobs: running_jobs} = state - ) do - case get_retry_params(retries) do - {:retry, timeout} -> - :ets.insert(queue_table, {timeout, {:send, data, transport, retries}}) - running_jobs = maybe_start_job(running_jobs, queue_table) - {:noreply, %{state | running_jobs: running_jobs}} - - {:drop, message} -> - Logger.debug(message) - {:noreply, %{state | dropped: drop_count + 1}} - end - end - - def handle_cast(:kickoff_timer, state) do - retry_interval = get_retry_timer_interval() - Process.send_after(__MODULE__, :retry_timer_run, retry_interval) - {:noreply, state} - end - - def handle_cast(:inc_delivered, %{delivered: delivery_count} = state) do - {:noreply, %{state | delivered: delivery_count + 1}} - end - - def handle_cast(:inc_dropped, %{dropped: drop_count} = state) do - {:noreply, %{state | dropped: drop_count + 1}} - end - - def handle_info({:send, data, transport, retries}, %{delivered: delivery_count} = state) do - case transport.publish_one(data) do - {:ok, _} -> - {:noreply, %{state | delivered: delivery_count + 1}} - - {:error, _reason} -> - enqueue(data, transport, retries) - {:noreply, state} - end - end - - def handle_info( - :retry_timer_run, - %{queue_table: queue_table, running_jobs: running_jobs} = state - ) do - maybe_kickoff_timer() - running_jobs = maybe_start_job(running_jobs, queue_table) - {:noreply, %{state | running_jobs: running_jobs}} - end - - def handle_info({:DOWN, ref, :process, _pid, _reason}, state) do - %{running_jobs: running_jobs, queue_table: queue_table} = state - running_jobs = :sets.del_element(ref, running_jobs) - running_jobs = maybe_start_job(running_jobs, queue_table) - {:noreply, %{state | running_jobs: running_jobs}} - end - - def handle_info(unknown, state) do - Logger.debug("RetryQueue: don't know what to do with #{inspect(unknown)}, ignoring") - {:noreply, state} - end - - if Pleroma.Config.get(:env) == :test do - defp growth_function(_retries) do - _shutit = Pleroma.Config.get([__MODULE__, :initial_timeout]) - DateTime.to_unix(DateTime.utc_now()) - 1 - end - else - defp growth_function(retries) do - round(Pleroma.Config.get([__MODULE__, :initial_timeout]) * :math.pow(retries, 3)) + - DateTime.to_unix(DateTime.utc_now()) - end - end - - defp maybe_kickoff_timer do - GenServer.cast(__MODULE__, :kickoff_timer) - end -end diff --git a/lib/pleroma/web/feed/feed_controller.ex b/lib/pleroma/web/feed/feed_controller.ex new file mode 100644 index 000000000..d91ecef9c --- /dev/null +++ b/lib/pleroma/web/feed/feed_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.Feed.FeedController do + use Pleroma.Web, :controller + + alias Fallback.RedirectController + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.ActivityPubController + + plug(Pleroma.Plugs.SetFormatPlug when action in [:feed_redirect]) + + action_fallback(:errors) + + def feed_redirect(%{assigns: %{format: "html"}} = conn, %{"nickname" => nickname}) do + with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname_or_id(nickname)} do + RedirectController.redirector_with_meta(conn, %{user: user}) + end + end + + def feed_redirect(%{assigns: %{format: format}} = conn, _params) + when format in ["json", "activity+json"] do + ActivityPubController.call(conn, :user) + end + + def feed_redirect(conn, %{"nickname" => nickname}) do + with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)} do + redirect(conn, external: "#{feed_url(conn, :feed, user.nickname)}.atom") + end + end + + def feed(conn, %{"nickname" => nickname} = params) do + with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)} do + query_params = + params + |> Map.take(["max_id"]) + |> Map.put("type", ["Create"]) + |> Map.put("whole_db", true) + |> Map.put("actor_id", user.ap_id) + + activities = + query_params + |> ActivityPub.fetch_public_activities() + |> Enum.reverse() + + conn + |> put_resp_content_type("application/atom+xml") + |> render("feed.xml", user: user, activities: activities) + end + end + + def errors(conn, {:error, :not_found}) do + render_error(conn, :not_found, "Not found") + end + + def errors(conn, {:fetch_user, nil}), do: errors(conn, {:error, :not_found}) + + def errors(conn, _) do + render_error(conn, :internal_server_error, "Something went wrong") + end +end diff --git a/lib/pleroma/web/feed/feed_view.ex b/lib/pleroma/web/feed/feed_view.ex new file mode 100644 index 000000000..5eef1e757 --- /dev/null +++ b/lib/pleroma/web/feed/feed_view.ex @@ -0,0 +1,77 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Feed.FeedView do + use Phoenix.HTML + use Pleroma.Web, :view + + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.MediaProxy + + require Pleroma.Constants + + def most_recent_update(activities, user) do + (List.first(activities) || user).updated_at + |> NaiveDateTime.to_iso8601() + end + + def logo(user) do + user + |> User.avatar_url() + |> MediaProxy.url() + end + + def last_activity(activities) do + List.last(activities) + end + + def activity_object(activity) do + Object.normalize(activity) + end + + def activity_object_data(activity) do + activity + |> activity_object() + |> Map.get(:data) + end + + def activity_content(activity) do + content = activity_object_data(activity)["content"] + + content + |> String.replace(~r/[\n\r]/, "") + |> escape() + end + + def activity_context(activity) do + activity.data["context"] + end + + def attachment_href(attachment) do + attachment["url"] + |> hd() + |> Map.get("href") + end + + def attachment_type(attachment) do + attachment["url"] + |> hd() + |> Map.get("mediaType") + end + + def get_href(id) do + with %Object{data: %{"external_url" => external_url}} <- Object.get_cached_by_ap_id(id) do + external_url + else + _e -> id + end + end + + def escape(html) do + html + |> html_escape() + |> safe_to_string() + 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..87860f1d5 --- /dev/null +++ b/lib/pleroma/web/masto_fe_controller.ex @@ -0,0 +1,48 @@ +# 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.Plugs.OAuthScopesPlug + alias Pleroma.User + + plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action == :put_settings) + + # Note: :index action handles attempt of unauthenticated access to private instance with redirect + plug( + OAuthScopesPlug, + %{scopes: ["read"], fallback: :proceed_unauthenticated, skip_instance_privacy_check: true} + when action == :index + ) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action != :index) + + @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/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex new file mode 100644 index 000000000..9ef7fd48d --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -0,0 +1,393 @@ +# 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.AccountController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, + only: [add_link_headers: 2, truthy_param?: 1, assign_account_by_id: 2, json_response: 3] + + alias Pleroma.Emoji + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Plugs.RateLimiter + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.ListView + alias Pleroma.Web.MastodonAPI.MastodonAPI + alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.OAuth.Token + alias Pleroma.Web.TwitterAPI.TwitterAPI + + plug( + OAuthScopesPlug, + %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]} + when action == :show + ) + + plug( + OAuthScopesPlug, + %{scopes: ["read:accounts"]} + when action in [:endorsements, :verify_credentials, :followers, :following] + ) + + plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action == :update_credentials) + + plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :lists) + + plug( + OAuthScopesPlug, + %{scopes: ["follow", "read:blocks"]} when action == :blocks + ) + + plug( + OAuthScopesPlug, + %{scopes: ["follow", "write:blocks"]} when action in [:block, :unblock] + ) + + plug(OAuthScopesPlug, %{scopes: ["read:follows"]} when action == :relationships) + + # Note: :follows (POST /api/v1/follows) is the same as :follow, consider removing :follows + plug( + OAuthScopesPlug, + %{scopes: ["follow", "write:follows"]} when action in [:follows, :follow, :unfollow] + ) + + plug(OAuthScopesPlug, %{scopes: ["follow", "read:mutes"]} when action == :mutes) + + plug(OAuthScopesPlug, %{scopes: ["follow", "write:mutes"]} when action in [:mute, :unmute]) + + plug( + Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug + when action != :create + ) + + @relations [:follow, :unfollow] + @needs_account ~W(followers following lists follow unfollow mute unmute block unblock)a + + plug(RateLimiter, {:relations_id_action, params: ["id", "uri"]} when action in @relations) + plug(RateLimiter, :relations_actions when action in @relations) + plug(RateLimiter, :app_account_creation when action == :create) + plug(:assign_account_by_id when action in @needs_account) + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + @doc "POST /api/v1/accounts" + def create( + %{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} -> json_response(conn, :bad_request, errors) + end + end + + def create(%{assigns: %{app: _app}} = conn, _) do + render_error(conn, :bad_request, "Missing parameters") + end + + def create(conn, _) do + render_error(conn, :forbidden, "Invalid credentials") + end + + @doc "GET /api/v1/accounts/verify_credentials" + def verify_credentials(%{assigns: %{user: user}} = conn, _) do + chat_token = Phoenix.Token.sign(conn, "user socket", user.id) + + render(conn, "show.json", + user: user, + for: user, + with_pleroma_settings: true, + with_chat_token: chat_token + ) + end + + @doc "PATCH /api/v1/accounts/update_credentials" + def update_credentials(%{assigns: %{user: original_user}} = conn, params) do + user = original_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} + end + end) + + emojis_text = (user_params["display_name"] || "") <> (user_params["note"] || "") + + user_info_emojis = + user.info + |> Map.get(:emoji, []) + |> Enum.concat(Emoji.Formatter.get_emoji_map(emojis_text)) + |> Enum.dedup() + + params = + if Map.has_key?(params, "fields_attributes") do + Map.update!(params, "fields_attributes", fn fields -> + fields + |> normalize_fields_attributes() + |> Enum.filter(fn %{"name" => n} -> n != "" end) + end) + else + params + end + + info_params = + [ + :no_rich_text, + :locked, + :hide_followers_count, + :hide_follows_count, + :hide_followers, + :hide_follows, + :hide_favorites, + :show_role, + :skip_thread_containment, + :discoverable + ] + |> Enum.reduce(%{}, fn key, acc -> + add_if_present(acc, params, to_string(key), key, &{:ok, truthy_param?(&1)}) + end) + |> add_if_present(params, "default_scope", :default_scope) + |> add_if_present(params, "fields_attributes", :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_attributes", :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} + 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} + end + end) + |> Map.put(:emoji, user_info_emojis) + + changeset = + user + |> User.update_changeset(user_params) + |> User.change_info(&User.Info.profile_update(&1, info_params)) + + with {:ok, user} <- User.update_and_set_cache(changeset) do + if original_user != user, do: CommonAPI.update(user) + + render(conn, "show.json", user: user, for: user, with_pleroma_settings: true) + else + _e -> render_error(conn, :forbidden, "Invalid request") + end + end + + defp add_if_present(map, params, params_field, map_field, value_function \\ &{:ok, &1}) do + with true <- Map.has_key?(params, params_field), + {:ok, new_value} <- value_function.(params[params_field]) do + Map.put(map, map_field, new_value) + else + _ -> map + end + end + + defp normalize_fields_attributes(fields) do + if Enum.all?(fields, &is_tuple/1) do + Enum.map(fields, fn {_, v} -> v end) + else + fields + end + end + + @doc "GET /api/v1/accounts/relationships" + def relationships(%{assigns: %{user: user}} = conn, %{"id" => id}) do + targets = User.get_all_by_ids(List.wrap(id)) + + render(conn, "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, []) + + @doc "GET /api/v1/accounts/:id" + def show(%{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 + render(conn, "show.json", user: user, for: for_user) + else + _e -> render_error(conn, :not_found, "Can't find user") + end + end + + @doc "GET /api/v1/accounts/:id/statuses" + def 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 = Map.put(params, "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 + + @doc "GET /api/v1/accounts/:id/followers" + def followers(%{assigns: %{user: for_user, account: user}} = conn, params) do + followers = + cond do + for_user && user.id == for_user.id -> MastodonAPI.get_followers(user, params) + user.info.hide_followers -> [] + true -> MastodonAPI.get_followers(user, params) + end + + conn + |> add_link_headers(followers) + |> render("index.json", for: for_user, users: followers, as: :user) + end + + @doc "GET /api/v1/accounts/:id/following" + def following(%{assigns: %{user: for_user, account: user}} = conn, params) do + followers = + cond do + for_user && user.id == for_user.id -> MastodonAPI.get_friends(user, params) + user.info.hide_follows -> [] + true -> MastodonAPI.get_friends(user, params) + end + + conn + |> add_link_headers(followers) + |> render("index.json", for: for_user, users: followers, as: :user) + end + + @doc "GET /api/v1/accounts/:id/lists" + def lists(%{assigns: %{user: user, account: account}} = conn, _params) do + lists = Pleroma.List.get_lists_account_belongs(user, account) + + conn + |> put_view(ListView) + |> render("index.json", lists: lists) + end + + @doc "POST /api/v1/accounts/:id/follow" + def follow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do + {:error, :not_found} + end + + def follow(%{assigns: %{user: follower, account: followed}} = conn, _params) do + with {:ok, follower} <- MastodonAPI.follow(follower, followed, conn.params) do + render(conn, "relationship.json", user: follower, target: followed) + else + {:error, message} -> json_response(conn, :forbidden, %{error: message}) + end + end + + @doc "POST /api/v1/accounts/:id/unfollow" + def unfollow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do + {:error, :not_found} + end + + def unfollow(%{assigns: %{user: follower, account: followed}} = conn, _params) do + with {:ok, follower} <- CommonAPI.unfollow(follower, followed) do + render(conn, "relationship.json", user: follower, target: followed) + end + end + + @doc "POST /api/v1/accounts/:id/mute" + def mute(%{assigns: %{user: muter, account: muted}} = conn, params) do + notifications? = params |> Map.get("notifications", true) |> truthy_param?() + + with {:ok, muter} <- User.mute(muter, muted, notifications?) do + render(conn, "relationship.json", user: muter, target: muted) + else + {:error, message} -> json_response(conn, :forbidden, %{error: message}) + end + end + + @doc "POST /api/v1/accounts/:id/unmute" + def unmute(%{assigns: %{user: muter, account: muted}} = conn, _params) do + with {:ok, muter} <- User.unmute(muter, muted) do + render(conn, "relationship.json", user: muter, target: muted) + else + {:error, message} -> json_response(conn, :forbidden, %{error: message}) + end + end + + @doc "POST /api/v1/accounts/:id/block" + def block(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do + with {:ok, blocker} <- User.block(blocker, blocked), + {:ok, _activity} <- ActivityPub.block(blocker, blocked) do + render(conn, "relationship.json", user: blocker, target: blocked) + else + {:error, message} -> json_response(conn, :forbidden, %{error: message}) + end + end + + @doc "POST /api/v1/accounts/:id/unblock" + def unblock(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do + with {:ok, blocker} <- User.unblock(blocker, blocked), + {:ok, _activity} <- ActivityPub.unblock(blocker, blocked) do + render(conn, "relationship.json", user: blocker, target: blocked) + else + {:error, message} -> json_response(conn, :forbidden, %{error: message}) + end + end + + @doc "POST /api/v1/follows" + 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}, + {:ok, follower, followed, _} <- CommonAPI.follow(follower, followed) do + render(conn, "show.json", user: followed, for: follower) + else + {:followed, _} -> {:error, :not_found} + {:error, message} -> json_response(conn, :forbidden, %{error: message}) + end + end + + @doc "GET /api/v1/mutes" + def mutes(%{assigns: %{user: user}} = conn, _) do + render(conn, "index.json", users: User.muted_users(user), for: user, as: :user) + end + + @doc "GET /api/v1/blocks" + def blocks(%{assigns: %{user: user}} = conn, _) do + render(conn, "index.json", users: User.blocked_users(user), for: user, as: :user) + end + + @doc "GET /api/v1/endorsements" + def endorsements(conn, params), + do: Pleroma.Web.MastodonAPI.MastodonAPIController.empty_array(conn, params) +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..13a30a34d --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/app_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.AppController do + use Pleroma.Web, :controller + + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Repo + alias Pleroma.Web.OAuth.App + alias Pleroma.Web.OAuth.Scopes + alias Pleroma.Web.OAuth.Token + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :verify_credentials) + + @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/conversation_controller.ex b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex new file mode 100644 index 000000000..6c0584c54 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex @@ -0,0 +1,38 @@ +# 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.ConversationController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] + + alias Pleroma.Conversation.Participation + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Repo + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action == :index) + plug(OAuthScopesPlug, %{scopes: ["write:conversations"]} when action == :read) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + @doc "GET /api/v1/conversations" + def index(%{assigns: %{user: user}} = conn, params) do + participations = Participation.for_user_with_last_activity_id(user, params) + + conn + |> add_link_headers(participations) + |> render("participations.json", participations: participations, for: user) + end + + @doc "POST /api/v1/conversations/:id/read" + def 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 + render(conn, "participation.json", participation: participation, for: user) + end + 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/domain_block_controller.ex b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex new file mode 100644 index 000000000..c7606246b --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/domain_block_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.DomainBlockController do + use Pleroma.Web, :controller + + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.User + + plug( + OAuthScopesPlug, + %{scopes: ["follow", "read:blocks"]} when action == :index + ) + + plug( + OAuthScopesPlug, + %{scopes: ["follow", "write:blocks"]} when action != :index + ) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + @doc "GET /api/v1/domain_blocks" + def index(%{assigns: %{user: %{info: info}}} = conn, _) do + json(conn, Map.get(info, :domain_blocks, [])) + end + + @doc "POST /api/v1/domain_blocks" + def create(%{assigns: %{user: blocker}} = conn, %{"domain" => domain}) do + User.block_domain(blocker, domain) + json(conn, %{}) + end + + @doc "DELETE /api/v1/domain_blocks" + def delete(%{assigns: %{user: blocker}} = conn, %{"domain" => domain}) do + User.unblock_domain(blocker, domain) + json(conn, %{}) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex new file mode 100644 index 000000000..cadef72e1 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex @@ -0,0 +1,84 @@ +# 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.FilterController do + use Pleroma.Web, :controller + + alias Pleroma.Filter + alias Pleroma.Plugs.OAuthScopesPlug + + @oauth_read_actions [:show, :index] + + plug(OAuthScopesPlug, %{scopes: ["read:filters"]} when action in @oauth_read_actions) + + plug( + OAuthScopesPlug, + %{scopes: ["write:filters"]} when action not in @oauth_read_actions + ) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + @doc "GET /api/v1/filters" + def index(%{assigns: %{user: user}} = conn, _) do + filters = Filter.get_filters(user) + + render(conn, "filters.json", filters: filters) + end + + @doc "POST /api/v1/filters" + def create( + %{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) + + render(conn, "filter.json", filter: response) + end + + @doc "GET /api/v1/filters/:id" + def show(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do + filter = Filter.get(filter_id, user) + + render(conn, "filter.json", filter: filter) + end + + @doc "PUT /api/v1/filters/:id" + def update( + %{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) + render(conn, "filter.json", filter: response) + end + + @doc "DELETE /api/v1/filters/:id" + def delete(%{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 +end diff --git a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex new file mode 100644 index 000000000..3ccbdf1c6 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex @@ -0,0 +1,59 @@ +# 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.FollowRequestController do + use Pleroma.Web, :controller + + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.User + alias Pleroma.Web.CommonAPI + + plug(:put_view, Pleroma.Web.MastodonAPI.AccountView) + plug(:assign_follower when action != :index) + + action_fallback(:errors) + + plug(OAuthScopesPlug, %{scopes: ["follow", "read:follows"]} when action == :index) + + plug( + OAuthScopesPlug, + %{scopes: ["follow", "write:follows"]} when action != :index + ) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + @doc "GET /api/v1/follow_requests" + def index(%{assigns: %{user: followed}} = conn, _params) do + follow_requests = User.get_follow_requests(followed) + + render(conn, "index.json", for: followed, users: follow_requests, as: :user) + end + + @doc "POST /api/v1/follow_requests/:id/authorize" + def authorize(%{assigns: %{user: followed, follower: follower}} = conn, _params) do + with {:ok, follower} <- CommonAPI.accept_follow_request(follower, followed) do + render(conn, "relationship.json", user: followed, target: follower) + end + end + + @doc "POST /api/v1/follow_requests/:id/reject" + def reject(%{assigns: %{user: followed, follower: follower}} = conn, _params) do + with {:ok, follower} <- CommonAPI.reject_follow_request(follower, followed) do + render(conn, "relationship.json", user: followed, target: follower) + end + end + + defp assign_follower(%{params: %{"id" => id}} = conn, _) do + case User.get_cached_by_id(id) do + %User{} = follower -> assign(conn, :follower, follower) + nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt() + end + end + + defp errors(conn, {:error, message}) do + conn + |> put_status(:forbidden) + |> json(%{error: message}) + 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/list_controller.ex b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex index 2873deda8..e0ffdba21 100644 --- a/lib/pleroma/web/mastodon_api/controllers/list_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex @@ -5,11 +5,22 @@ defmodule Pleroma.Web.MastodonAPI.ListController do use Pleroma.Web, :controller + alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User alias Pleroma.Web.MastodonAPI.AccountView plug(:list_by_id_and_user when action not in [:index, :create]) + plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action in [:index, :show, :list_accounts]) + + plug( + OAuthScopesPlug, + %{scopes: ["write:lists"]} + when action in [:create, :update, :delete, :add_to_list, :remove_from_list] + ) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) # GET /api/v1/lists @@ -49,7 +60,7 @@ defmodule Pleroma.Web.MastodonAPI.ListController do with {:ok, users} <- Pleroma.List.get_following(list) do conn |> put_view(AccountView) - |> render("accounts.json", for: user, users: users, as: :user) + |> render("index.json", for: user, users: users, as: :user) 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 8dfad7a54..7d839a8cf 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -5,1443 +5,12 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, - only: [json_response: 3, add_link_headers: 5, add_link_headers: 4, 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.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, - :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(:home_timeline, 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(:public_timeline, activities, false, %{"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(:user_statuses, activities, params["id"]) - |> 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(:dm_timeline, activities) - |> 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(id), - %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_statuses, 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}) - 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, 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 - id = List.wrap(id) - q = from(u in User, where: u.id in ^id) - targets = Repo.all(q) - - 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, data) do - with %Object{} = object <- Repo.get(Object, data["id"]), - true <- Object.authorize_mutation(object, user), - true <- is_binary(data["description"]), - description <- data["description"] do - new_data = %{object.data | "name" => description} - - {:ok, _} = - object - |> Object.change(%{data: new_data}) - |> Repo.update() - - attachment_data = Map.put(new_data, "id", object.id) - - conn - |> put_view(StatusView) - |> render("attachment.json", %{attachment: attachment_data}) - end - end - - 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: type} = rendered <- - StatusView.render("attachment.json", %{attachment: attachment_data}) do - # Reject if not an image - if type == "image" do - # Sure! - # Save to the user's info - info_changeset = User.Info.mascot_update(user.info, rendered) - - user_changeset = - user - |> Changeset.change() - |> Changeset.put_embed(:info, info_changeset) - - {:ok, _user} = User.update_and_set_cache(user_changeset) - - conn - |> json(rendered) - else - render_error(conn, :unsupported_media_type, "mascots can only be images") - end - end - end - - def get_mascot(%{assigns: %{user: user}} = conn, _params) do - mascot = User.get_mascot(user) - - conn - |> json(mascot) - end - - def favourited_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with %Activity{} = activity <- Activity.get_by_id_with_object(id), - %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 - _ -> json(conn, []) - end - end - - def reblogged_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with %Activity{} = activity <- Activity.get_by_id_with_object(id), - %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 - _ -> 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(:hashtag_timeline, activities, params["tag"], %{"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, followers, user) - |> 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(:following, followers, user) - |> 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 - {:error, message} -> - conn - |> put_status(:forbidden) - |> json(%{error: message}) - 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 - {:error, message} -> - conn - |> put_status(:forbidden) - |> json(%{error: message}) - 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(:favourites, 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(:favourites, 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, 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) - res = ListView.render("lists.json", lists: lists) - json(conn, res) - 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(), - %Authorization{} = auth <- Repo.get_by(Authorization, token: auth_token, app_id: app.id), - {: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 - - defp get_or_make_app do - find_attrs = %{client_name: @local_mastodon_name, redirect_uris: "."} - scopes = ["read", "write", "follow", "push"] - - with %App{} = app <- Repo.get_by(App, find_attrs) do - {:ok, app} = - if app.scopes == scopes do - {:ok, app} - else - app - |> Changeset.change(%{scopes: scopes}) - |> Repo.update() - end - - {:ok, app} - else - _e -> - cs = - App.register_changeset( - %App{}, - Map.put(find_attrs, :scopes, scopes) - ) - - Repo.insert(cs) - end - end - - def logout(conn, _) do - conn - |> clear_session - |> redirect(to: "/") - end - - def relationship_noop(%{assigns: %{user: user}} = conn, %{"id" => id}) do - Logger.debug("Unimplemented, returning unmodified relationship") - - with %User{} = target <- User.get_cached_by_id(id) do - conn - |> put_view(AccountView) - |> render("relationship.json", %{user: user, target: target}) - end - end - + # Stubs for unimplemented mastodon api + # def empty_array(conn, _) do Logger.debug("Unimplemented, returning an empty array") json(conn, []) @@ -1451,250 +20,4 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do Logger.debug("Unimplemented, returning an empty object") 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" => status_id}) do - with %Activity{} = activity <- Activity.get_by_id(status_id), - true <- Visibility.visible_for_user?(activity, user) do - data = - StatusView.render( - "card.json", - Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) - ) - - json(conn, data) - else - _e -> - %{} - 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, _params) 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(:conversations, 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 - - 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..ed4c08d99 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex @@ -0,0 +1,47 @@ +# 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.Plugs.OAuthScopesPlug + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + plug(:put_view, Pleroma.Web.MastodonAPI.StatusView) + + plug(OAuthScopesPlug, %{scopes: ["write:media"]}) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + @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/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex new file mode 100644 index 000000000..16759be6a --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex @@ -0,0 +1,69 @@ +# 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.NotificationController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] + + alias Pleroma.Notification + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Web.MastodonAPI.MastodonAPI + + @oauth_read_actions [:show, :index] + + plug( + OAuthScopesPlug, + %{scopes: ["read:notifications"]} when action in @oauth_read_actions + ) + + plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action not in @oauth_read_actions) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + # GET /api/v1/notifications + def index(%{assigns: %{user: user}} = conn, params) do + notifications = MastodonAPI.get_notifications(user, params) + + conn + |> add_link_headers(notifications) + |> render("index.json", notifications: notifications, for: user) + end + + # GET /api/v1/notifications/:id + def show(%{assigns: %{user: user}} = conn, %{"id" => id}) do + with {:ok, notification} <- Notification.get(user, id) do + render(conn, "show.json", notification: notification, for: user) + else + {:error, reason} -> + conn + |> put_status(:forbidden) + |> json(%{"error" => reason}) + end + end + + # POST /api/v1/notifications/clear + def clear(%{assigns: %{user: user}} = conn, _params) do + Notification.clear(user) + json(conn, %{}) + end + + # POST /api/v1/notifications/dismiss + def dismiss(%{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 + + # DELETE /api/v1/notifications/destroy_multiple + def destroy_multiple(%{assigns: %{user: user}} = conn, %{"ids" => ids} = _params) do + Notification.destroy_multiple(user, ids) + json(conn, %{}) + end +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..d129f8672 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/poll_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.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.Plugs.OAuthScopesPlug + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.CommonAPI + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + plug( + OAuthScopesPlug, + %{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} when action == :show + ) + + plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :vote) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + @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/report_controller.ex b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex new file mode 100644 index 000000000..263c2180f --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex @@ -0,0 +1,22 @@ +# 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.ReportController do + alias Pleroma.Plugs.OAuthScopesPlug + + use Pleroma.Web, :controller + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + plug(OAuthScopesPlug, %{scopes: ["write:reports"]} when action == :create) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + @doc "POST /api/v1/reports" + def create(%{assigns: %{user: user}} = conn, params) do + with {:ok, activity} <- Pleroma.Web.CommonAPI.report(user, params) do + render(conn, "show.json", activity: activity) + end + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex new file mode 100644 index 000000000..ff9276541 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex @@ -0,0 +1,59 @@ +# 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.ScheduledActivityController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] + + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.ScheduledActivity + alias Pleroma.Web.MastodonAPI.MastodonAPI + + plug(:assign_scheduled_activity when action != :index) + + @oauth_read_actions [:show, :index] + + plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in @oauth_read_actions) + plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action not in @oauth_read_actions) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + @doc "GET /api/v1/scheduled_statuses" + def index(%{assigns: %{user: user}} = conn, params) do + with scheduled_activities <- MastodonAPI.get_scheduled_activities(user, params) do + conn + |> add_link_headers(scheduled_activities) + |> render("index.json", scheduled_activities: scheduled_activities) + end + end + + @doc "GET /api/v1/scheduled_statuses/:id" + def show(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, _params) do + render(conn, "show.json", scheduled_activity: scheduled_activity) + end + + @doc "PUT /api/v1/scheduled_statuses/:id" + def update(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, params) do + with {:ok, scheduled_activity} <- ScheduledActivity.update(scheduled_activity, params) do + render(conn, "show.json", scheduled_activity: scheduled_activity) + end + end + + @doc "DELETE /api/v1/scheduled_statuses/:id" + def delete(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, _params) do + with {:ok, scheduled_activity} <- ScheduledActivity.delete(scheduled_activity) do + render(conn, "show.json", scheduled_activity: scheduled_activity) + end + end + + defp assign_scheduled_activity(%{assigns: %{user: user}, params: %{"id" => id}} = conn, _) do + case ScheduledActivity.get(user, id) do + %ScheduledActivity{} = activity -> assign(conn, :scheduled_activity, activity) + nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt() + end + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index 9072aa7a4..6cfd68a84 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do use Pleroma.Web, :controller alias Pleroma.Activity + alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.RateLimiter alias Pleroma.Repo alias Pleroma.User @@ -15,13 +16,20 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do alias Pleroma.Web.MastodonAPI.StatusView require Logger + + # Note: Mastodon doesn't allow unauthenticated access (requires read:accounts / read:search) + plug(OAuthScopesPlug, %{scopes: ["read:search"], fallback: :proceed_unauthenticated}) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + plug(RateLimiter, :search when action in [:search, :search2, :account_search]) def account_search(%{assigns: %{user: user}} = conn, %{"q" => query} = params) do accounts = User.search(query, search_options(params, user)) - res = AccountView.render("accounts.json", users: accounts, for: user, as: :user) - json(conn, res) + conn + |> put_view(AccountView) + |> render("index.json", users: accounts, for: user, as: :user) end def search2(conn, params), do: do_search(:v2, conn, params) @@ -71,7 +79,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do defp resource_search(_, "accounts", query, options) do accounts = with_fallback(fn -> User.search(query, options) end) - AccountView.render("accounts.json", users: accounts, for: options[:for_user], as: :user) + AccountView.render("index.json", users: accounts, for: options[:for_user], as: :user) end defp resource_search(_, "statuses", query, options) do diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex new file mode 100644 index 000000000..0c16e9b0f --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -0,0 +1,377 @@ +# 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.StatusController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [try_render: 3, add_link_headers: 2] + + require Ecto.Query + + alias Pleroma.Activity + alias Pleroma.Bookmark + alias Pleroma.Object + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Plugs.RateLimiter + alias Pleroma.Repo + alias Pleroma.ScheduledActivity + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.MastodonAPI.ScheduledActivityView + + @unauthenticated_access %{fallback: :proceed_unauthenticated, scopes: []} + + plug( + OAuthScopesPlug, + %{@unauthenticated_access | scopes: ["read:statuses"]} + when action in [ + :index, + :show, + :card, + :context + ] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:statuses"]} + when action in [ + :create, + :delete, + :reblog, + :unreblog + ] + ) + + plug(OAuthScopesPlug, %{scopes: ["read:favourites"]} when action == :favourites) + + plug( + OAuthScopesPlug, + %{scopes: ["write:favourites"]} when action in [:favourite, :unfavourite] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:mutes"]} when action in [:mute_conversation, :unmute_conversation] + ) + + plug( + OAuthScopesPlug, + %{@unauthenticated_access | scopes: ["read:accounts"]} + when action in [:favourited_by, :reblogged_by] + ) + + plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action in [:pin, :unpin]) + + # Note: scope not present in Mastodon: read:bookmarks + plug(OAuthScopesPlug, %{scopes: ["read:bookmarks"]} when action == :bookmarks) + + # Note: scope not present in Mastodon: write:bookmarks + plug( + OAuthScopesPlug, + %{scopes: ["write:bookmarks"]} when action in [:bookmark, :unbookmark] + ) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + @rate_limited_status_actions ~w(reblog unreblog favourite unfavourite create delete)a + + plug( + RateLimiter, + {:status_id_action, bucket_name: "status_id_action:reblog_unreblog", params: ["id"]} + when action in ~w(reblog unreblog)a + ) + + plug( + RateLimiter, + {:status_id_action, bucket_name: "status_id_action:fav_unfav", params: ["id"]} + when action in ~w(favourite unfavourite)a + ) + + plug(RateLimiter, :statuses_actions when action in @rate_limited_status_actions) + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + @doc """ + GET `/api/v1/statuses?ids[]=1&ids[]=2` + + `ids` query param is required + """ + def index(%{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)) + + render(conn, "index.json", activities: activities, for: user, as: :activity) + end + + @doc """ + POST /api/v1/statuses + + Creates a scheduled status when `scheduled_at` param is present and it's far enough + """ + def create( + %{assigns: %{user: user}} = conn, + %{"status" => _, "scheduled_at" => scheduled_at} = params + ) do + params = Map.put(params, "in_reply_to_status_id", params["in_reply_to_id"]) + + if 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 + create(conn, Map.drop(params, ["scheduled_at"])) + end + end + + @doc """ + POST /api/v1/statuses + + Creates a regular status + """ + def create(%{assigns: %{user: user}} = conn, %{"status" => _} = params) do + params = Map.put(params, "in_reply_to_status_id", params["in_reply_to_id"]) + + with {:ok, activity} <- CommonAPI.post(user, params) do + try_render(conn, "show.json", + activity: activity, + for: user, + as: :activity, + with_direct_conversation_id: true + ) + else + {:error, message} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: message}) + end + end + + def create(%{assigns: %{user: _user}} = conn, %{"media_ids" => _} = params) do + create(conn, Map.put(params, "status", "")) + end + + @doc "GET /api/v1/statuses/:id" + def show(%{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 + try_render(conn, "show.json", activity: activity, for: user) + end + end + + @doc "DELETE /api/v1/statuses/:id" + def delete(%{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 + + @doc "POST /api/v1/statuses/:id/reblog" + 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 + end + + @doc "POST /api/v1/statuses/:id/unreblog" + def unreblog(%{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 + try_render(conn, "show.json", %{activity: activity, for: user, as: :activity}) + end + end + + @doc "POST /api/v1/statuses/:id/favourite" + def favourite(%{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 + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "POST /api/v1/statuses/:id/unfavourite" + def unfavourite(%{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 + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "POST /api/v1/statuses/:id/pin" + def pin(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do + with {:ok, activity} <- CommonAPI.pin(ap_id_or_id, user) do + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "POST /api/v1/statuses/:id/unpin" + def unpin(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do + with {:ok, activity} <- CommonAPI.unpin(ap_id_or_id, user) do + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "POST /api/v1/statuses/:id/bookmark" + def bookmark(%{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 + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "POST /api/v1/statuses/:id/unbookmark" + def unbookmark(%{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 + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "POST /api/v1/statuses/:id/mute" + def mute_conversation(%{assigns: %{user: user}} = conn, %{"id" => id}) do + with %Activity{} = activity <- Activity.get_by_id(id), + {:ok, activity} <- CommonAPI.add_mute(user, activity) do + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "POST /api/v1/statuses/:id/unmute" + def unmute_conversation(%{assigns: %{user: user}} = conn, %{"id" => id}) do + with %Activity{} = activity <- Activity.get_by_id(id), + {:ok, activity} <- CommonAPI.remove_mute(user, activity) do + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "GET /api/v1/statuses/:id/card" + @deprecated "https://github.com/tootsuite/mastodon/pull/11213" + def card(%{assigns: %{user: user}} = conn, %{"id" => status_id}) do + with %Activity{} = activity <- Activity.get_by_id(status_id), + true <- Visibility.visible_for_user?(activity, user) do + data = Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) + render(conn, "card.json", data) + else + _ -> render_error(conn, :not_found, "Record not found") + end + end + + @doc "GET /api/v1/statuses/:id/favourited_by" + 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 + users = + User + |> Ecto.Query.where([u], u.ap_id in ^likes) + |> Repo.all() + |> Enum.filter(&(not User.blocks?(user, &1))) + + conn + |> put_view(AccountView) + |> render("index.json", for: user, users: users, as: :user) + else + {:visible, false} -> {:error, :not_found} + _ -> json(conn, []) + end + end + + @doc "GET /api/v1/statuses/:id/reblogged_by" + 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, "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) + |> Repo.all() + |> Enum.filter(&(not User.blocks?(user, &1))) + + conn + |> put_view(AccountView) + |> render("index.json", for: user, users: users, as: :user) + else + {:visible, false} -> {:error, :not_found} + _ -> json(conn, []) + end + end + + @doc "GET /api/v1/statuses/:id/context" + def context(%{assigns: %{user: user}} = conn, %{"id" => id}) do + with %Activity{} = activity <- Activity.get_by_id(id) do + activities = + ActivityPub.fetch_activities_for_context(activity.data["context"], %{ + "blocking_user" => user, + "user" => user, + "exclude_id" => activity.id + }) + + render(conn, "context.json", activity: activity, activities: activities, user: user) + end + end + + @doc "GET /api/v1/favourites" + 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) + |> render("index.json", activities: activities, for: user, as: :activity) + end + + @doc "GET /api/v1/bookmarks" + def bookmarks(%{assigns: %{user: user}} = conn, params) do + user = User.get_cached_by_id(user.id) + + bookmarks = + user.id + |> Bookmark.for_user_query() + |> Pleroma.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) + |> render("index.json", %{activities: activities, for: user, as: :activity}) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex b/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex index e2b17aab1..fc7d52824 100644 --- a/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex @@ -12,6 +12,10 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionController do action_fallback(:errors) + plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["push"]}) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + # Creates PushSubscription # POST /api/v1/push/subscription # 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..fe71c36af --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex @@ -0,0 +1,68 @@ +# 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.Plugs.OAuthScopesPlug + alias Pleroma.User + alias Pleroma.Web.MediaProxy + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :index) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + @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/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex new file mode 100644 index 000000000..9f086a8c2 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -0,0 +1,142 @@ +# 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.TimelineController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, + only: [add_link_headers: 2, add_link_headers: 3, truthy_param?: 1] + + alias Pleroma.Pagination + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Web.ActivityPub.ActivityPub + + plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in [:home, :direct]) + plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :list) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + plug(:put_view, Pleroma.Web.MastodonAPI.StatusView) + + # GET /api/v1/timelines/home + def home(%{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) + + recipients = [user.ap_id | user.following] + + activities = + recipients + |> ActivityPub.fetch_activities(params) + |> Enum.reverse() + + conn + |> add_link_headers(activities) + |> render("index.json", activities: activities, for: user, as: :activity) + end + + # GET /api/v1/timelines/direct + def direct(%{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) + |> render("index.json", activities: activities, for: user, as: :activity) + end + + # GET /api/v1/timelines/public + def public(%{assigns: %{user: user}} = conn, params) do + local_only = truthy_param?(params["local"]) + + activities = + params + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("local_only", local_only) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> ActivityPub.fetch_public_activities() + |> Enum.reverse() + + conn + |> add_link_headers(activities, %{"local" => local_only}) + |> render("index.json", activities: activities, for: user, as: :activity) + end + + # GET /api/v1/timelines/tag/:tag + def hashtag(%{assigns: %{user: user}} = conn, params) do + local_only = truthy_param?(params["local"]) + + tags = + [params["tag"], params["any"]] + |> List.flatten() + |> Enum.uniq() + |> Enum.filter(& &1) + |> Enum.map(&String.downcase(&1)) + + tag_all = + params + |> Map.get("all", []) + |> Enum.map(&String.downcase(&1)) + + tag_reject = + params + |> Map.get("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}) + |> render("index.json", activities: activities, for: user, as: :activity) + end + + # GET /api/v1/timelines/list/:list_id + def list(%{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() + + render(conn, "index.json", activities: activities, for: user, as: :activity) + else + _e -> render_error(conn, :forbidden, "Error.") + end + end +end diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 169116d0d..2d4976891 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -11,15 +11,15 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MediaProxy - def render("accounts.json", %{users: users} = opts) do + def render("index.json", %{users: users} = opts) do users - |> render_many(AccountView, "account.json", opts) + |> render_many(AccountView, "show.json", opts) |> Enum.filter(&Enum.any?/1) end - def render("account.json", %{user: user} = opts) do + def render("show.json", %{user: user} = opts) do if User.visible_for?(user, opts[:for]), - do: do_render("account.json", opts), + do: do_render("show.json", opts), else: %{} end @@ -66,7 +66,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do render_many(targets, AccountView, "relationship.json", user: user, as: :target) end - defp do_render("account.json", %{user: user} = opts) do + defp do_render("show.json", %{user: user} = opts) do display_name = HTML.strip_tags(user.name || user.nickname) image = User.avatar_url(user) |> MediaProxy.url() @@ -74,10 +74,18 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do user_info = User.get_cached_user_info(user) following_count = - ((!user.info.hide_follows or opts[:for] == user) && user_info.following_count) || 0 + if !user.info.hide_follows_count or !user.info.hide_follows or opts[:for] == user do + user_info.following_count + else + 0 + end followers_count = - ((!user.info.hide_followers or opts[:for] == user) && user_info.follower_count) || 0 + if !user.info.hide_followers_count or !user.info.hide_followers or opts[:for] == user do + user_info.follower_count + else + 0 + end bot = (user.info.source_data["type"] || "Person") in ["Application", "Service"] @@ -108,6 +116,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do bio = HTML.filter_tags(user.bio, User.html_filter_policy(opts[:for])) relationship = render("relationship.json", %{user: opts[:for], target: user}) + discoverable = user.info.discoverable + %{ id: to_string(user.id), username: username_from_nickname(user.nickname), @@ -131,13 +141,17 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do note: HTML.strip_tags((user.bio || "") |> String.replace("<br>", "\n")), sensitive: false, fields: raw_fields, - pleroma: %{} + pleroma: %{ + discoverable: discoverable + } }, # Pleroma extension pleroma: %{ confirmation_pending: user_info.confirmation_pending, tags: user.tags, + hide_followers_count: user.info.hide_followers_count, + hide_follows_count: user.info.hide_follows_count, hide_followers: user.info.hide_followers, hide_follows: user.info.hide_follows, hide_favorites: user.info.hide_favorites, @@ -152,6 +166,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do |> maybe_put_settings_store(user, opts[:for], opts) |> maybe_put_chat_token(user, opts[:for], opts) |> maybe_put_activation_status(user, opts[:for]) + |> maybe_put_follow_requests_count(user, opts[:for]) + |> maybe_put_unread_conversation_count(user, opts[:for]) end defp username_from_nickname(string) when is_binary(string) do @@ -160,6 +176,21 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do defp username_from_nickname(_), do: nil + defp maybe_put_follow_requests_count( + data, + %User{id: user_id} = user, + %User{id: user_id} + ) do + count = + User.get_follow_requests(user) + |> length() + + data + |> Kernel.put_in([:follow_requests_count], count) + end + + defp maybe_put_follow_requests_count(data, _, _), do: data + defp maybe_put_settings( data, %User{id: user_id} = user, @@ -218,6 +249,16 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do defp maybe_put_activation_status(data, _, _), do: data + defp maybe_put_unread_conversation_count(data, %User{id: user_id} = user, %User{id: user_id}) do + data + |> Kernel.put_in( + [:pleroma, :unread_conversation_count], + user.info.unread_conversation_count + ) + end + + defp maybe_put_unread_conversation_count(data, _, _), do: data + defp image_url(%{"url" => [%{"href" => href} | _]}), do: href defp image_url(_), do: nil end diff --git a/lib/pleroma/web/mastodon_api/views/conversation_view.ex b/lib/pleroma/web/mastodon_api/views/conversation_view.ex index 40acc07b3..e9d2735b3 100644 --- a/lib/pleroma/web/mastodon_api/views/conversation_view.ex +++ b/lib/pleroma/web/mastodon_api/views/conversation_view.ex @@ -11,6 +11,10 @@ defmodule Pleroma.Web.MastodonAPI.ConversationView do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.StatusView + def render("participations.json", %{participations: participations, for: user}) do + render_many(participations, __MODULE__, "participation.json", as: :participation, for: user) + end + def render("participation.json", %{participation: participation, for: user}) do participation = Repo.preload(participation, conversation: [], recipients: []) @@ -23,25 +27,14 @@ defmodule Pleroma.Web.MastodonAPI.ConversationView do end activity = Activity.get_by_id_with_object(last_activity_id) - - last_status = StatusView.render("status.json", %{activity: activity, for: user}) - # Conversations return all users except the current user. - users = - participation.recipients - |> Enum.reject(&(&1.id == user.id)) - - accounts = - AccountView.render("accounts.json", %{ - users: users, - as: :user - }) + users = Enum.reject(participation.recipients, &(&1.id == user.id)) %{ id: participation.id |> to_string(), - accounts: accounts, + accounts: render(AccountView, "index.json", users: users, as: :user), unread: !participation.read, - last_status: last_status + last_status: render(StatusView, "show.json", activity: activity, for: user) } 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/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index ec8eadcaa..5e3dbe728 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -25,40 +25,44 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do parent_activity = Activity.get_create_by_object_ap_id(activity.data["object"]) mastodon_type = Activity.mastodon_notification_type(activity) - response = %{ - id: to_string(notification.id), - type: mastodon_type, - created_at: CommonAPI.Utils.to_masto_date(notification.inserted_at), - account: AccountView.render("account.json", %{user: actor, for: user}), - pleroma: %{ - is_seen: notification.seen + with %{id: _} = account <- AccountView.render("show.json", %{user: actor, for: user}) do + response = %{ + id: to_string(notification.id), + type: mastodon_type, + created_at: CommonAPI.Utils.to_masto_date(notification.inserted_at), + account: account, + pleroma: %{ + is_seen: notification.seen + } } - } - case mastodon_type do - "mention" -> - response - |> Map.merge(%{ - status: StatusView.render("status.json", %{activity: activity, for: user}) - }) + case mastodon_type do + "mention" -> + response + |> Map.merge(%{ + status: StatusView.render("show.json", %{activity: activity, for: user}) + }) - "favourite" -> - response - |> Map.merge(%{ - status: StatusView.render("status.json", %{activity: parent_activity, for: user}) - }) + "favourite" -> + response + |> Map.merge(%{ + status: StatusView.render("show.json", %{activity: parent_activity, for: user}) + }) - "reblog" -> - response - |> Map.merge(%{ - status: StatusView.render("status.json", %{activity: parent_activity, for: user}) - }) + "reblog" -> + response + |> Map.merge(%{ + status: StatusView.render("show.json", %{activity: parent_activity, for: user}) + }) - "follow" -> - response + "follow" -> + response - _ -> - nil + _ -> + nil + end + else + _ -> nil end end 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/report_view.ex b/lib/pleroma/web/mastodon_api/views/report_view.ex index a16e7ff10..9da2dd740 100644 --- a/lib/pleroma/web/mastodon_api/views/report_view.ex +++ b/lib/pleroma/web/mastodon_api/views/report_view.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.MastodonAPI.ReportView do use Pleroma.Web, :view - def render("report.json", %{activity: activity}) do + def render("show.json", %{activity: activity}) do %{ id: to_string(activity.id), action_taken: false diff --git a/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex b/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex index 0aae15ab9..fc042a276 100644 --- a/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex +++ b/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex @@ -7,11 +7,10 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityView do alias Pleroma.ScheduledActivity alias Pleroma.Web.CommonAPI - alias Pleroma.Web.MastodonAPI.ScheduledActivityView alias Pleroma.Web.MastodonAPI.StatusView def render("index.json", %{scheduled_activities: scheduled_activities}) do - render_many(scheduled_activities, ScheduledActivityView, "show.json") + render_many(scheduled_activities, __MODULE__, "show.json") end def render("show.json", %{scheduled_activity: %ScheduledActivity{} = scheduled_activity}) do @@ -24,12 +23,8 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityView do end defp with_media_attachments(data, %{params: %{"media_attachments" => media_attachments}}) do - try do - attachments = render_many(media_attachments, StatusView, "attachment.json", as: :attachment) - Map.put(data, :media_attachments, attachments) - rescue - _ -> data - end + attachments = render_many(media_attachments, StatusView, "attachment.json", as: :attachment) + Map.put(data, :media_attachments, attachments) end defp with_media_attachments(data, _), do: data @@ -45,13 +40,9 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityView do in_reply_to_id: params["in_reply_to_id"] } - data = - if media_ids = params["media_ids"] do - Map.put(data, :media_ids, media_ids) - else - data - end - - data + case params["media_ids"] do + nil -> data + media_ids -> Map.put(data, :media_ids, media_ids) + 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 e71083b91..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 @@ -73,19 +74,13 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do def render("index.json", opts) do replied_to_activities = get_replied_to_activities(opts.activities) - parallel = unless is_nil(opts[:parallel]), do: opts[:parallel], else: true - - opts.activities - |> safe_render_many( - StatusView, - "status.json", - Map.put(opts, :replied_to_activities, replied_to_activities), - parallel - ) + opts = Map.put(opts, :replied_to_activities, replied_to_activities) + + safe_render_many(opts.activities, StatusView, "show.json", opts) end def render( - "status.json", + "show.json", %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts ) do user = get_user(activity.data["actor"]) @@ -98,7 +93,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do |> Activity.with_set_thread_muted_field(opts[:for]) |> Repo.one() - reblogged = render("status.json", Map.put(opts, :activity, reblogged_activity)) + reblogged = render("show.json", Map.put(opts, :activity, reblogged_activity)) favorited = opts[:for] && opts[:for].ap_id in (activity_object.data["likes"] || []) @@ -114,7 +109,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do id: to_string(activity.id), uri: activity_object.data["id"], url: activity_object.data["id"], - account: AccountView.render("account.json", %{user: user, for: opts[:for]}), + account: AccountView.render("show.json", %{user: user, for: opts[:for]}), in_reply_to_id: nil, in_reply_to_account_id: nil, reblog: reblogged, @@ -130,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] || [], @@ -146,7 +141,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do } end - def render("status.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do + def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do object = Object.normalize(activity) user = get_user(activity.data["actor"]) @@ -264,7 +259,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do id: to_string(activity.id), uri: object.data["id"], url: url, - account: AccountView.render("account.json", %{user: user, for: opts[:for]}), + account: AccountView.render("show.json", %{user: user, for: opts[:for]}), in_reply_to_id: reply_to && to_string(reply_to.id), in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id), reblog: nil, @@ -283,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: %{ @@ -305,7 +300,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do } end - def render("status.json", _) do + def render("show.json", _) do nil end @@ -345,9 +340,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do } end - def render("card.json", _) do - nil - end + def render("card.json", _), do: nil def render("attachment.json", %{attachment: attachment}) do [attachment_url | _] = attachment["url"] @@ -376,73 +369,39 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do } 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 + def render("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do + object = Object.normalize(activity) - 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 + user = get_user(activity.data["actor"]) + created_at = Utils.to_masto_date(activity.data["published"]) + + %{ + id: activity.id, + account: AccountView.render("show.json", %{user: user, for: opts[:for]}), + created_at: created_at, + title: object.data["title"] |> HTML.strip_tags(), + artist: object.data["artist"] |> HTML.strip_tags(), + album: object.data["album"] |> HTML.strip_tags(), + length: object.data["length"] + } + end + + def render("listens.json", opts) do + safe_render_many(opts.activities, StatusView, "listen.json", opts) + end + + def render("context.json", %{activity: activity, activities: activities, user: user}) do + %{ancestors: ancestors, descendants: descendants} = + activities + |> Enum.reverse() + |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end) + |> Map.put_new(:ancestors, []) + |> Map.put_new(:descendants, []) + + %{ + ancestors: render("index.json", for: user, activities: ancestors, as: :activity), + descendants: render("index.json", for: user, activities: descendants, as: :activity) + } end def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do @@ -499,7 +458,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do object_tags = for tag when is_binary(tag) <- object_tags, do: tag Enum.reduce(object_tags, [], fn tag, tags -> - tags ++ [%{name: tag, url: "/tag/#{tag}"}] + tags ++ [%{name: tag, url: "/tag/#{URI.encode(tag)}"}] end) end diff --git a/lib/pleroma/web/mastodon_api/websocket_handler.ex b/lib/pleroma/web/mastodon_api/websocket_handler.ex index dbd3542ea..3c26eb406 100644 --- a/lib/pleroma/web/mastodon_api/websocket_handler.ex +++ b/lib/pleroma/web/mastodon_api/websocket_handler.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.OAuth.Token + alias Pleroma.Web.Streamer @behaviour :cowboy_websocket @@ -24,7 +25,7 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do ] @anonymous_streams ["public", "public:local", "hashtag"] - # Handled by periodic keepalive in Pleroma.Web.Streamer. + # Handled by periodic keepalive in Pleroma.Web.Streamer.Ping. @timeout :infinity def init(%{qs: qs} = req, state) do @@ -65,7 +66,7 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do }, topic #{state.topic}" ) - Pleroma.Web.Streamer.add_socket(state.topic, streamer_socket(state)) + Streamer.add_socket(state.topic, streamer_socket(state)) {:ok, state} end @@ -80,7 +81,7 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do }, topic #{state.topic || "?"}: #{inspect(reason)}" ) - Pleroma.Web.Streamer.remove_socket(state.topic, streamer_socket(state)) + Streamer.remove_socket(state.topic, streamer_socket(state)) :ok end diff --git a/lib/pleroma/web/metadata/feed.ex b/lib/pleroma/web/metadata/feed.ex new file mode 100644 index 000000000..8043e6c54 --- /dev/null +++ b/lib/pleroma/web/metadata/feed.ex @@ -0,0 +1,23 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.Providers.Feed do + alias Pleroma.Web.Endpoint + alias Pleroma.Web.Metadata.Providers.Provider + alias Pleroma.Web.Router.Helpers + + @behaviour Provider + + @impl Provider + def build_tags(%{user: user}) do + [ + {:link, + [ + rel: "alternate", + type: "application/atom+xml", + href: Helpers.feed_path(Endpoint, :feed, user.nickname) <> ".atom" + ], []} + ] + end +end diff --git a/lib/pleroma/web/metadata/utils.ex b/lib/pleroma/web/metadata/utils.ex index 720bd4519..382ecf426 100644 --- a/lib/pleroma/web/metadata/utils.ex +++ b/lib/pleroma/web/metadata/utils.ex @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Metadata.Utils do + alias Pleroma.Emoji alias Pleroma.Formatter alias Pleroma.HTML alias Pleroma.Web.MediaProxy @@ -13,7 +14,7 @@ defmodule Pleroma.Web.Metadata.Utils do |> HtmlEntities.decode() |> String.replace(~r/<br\s?\/?>/, " ") |> HTML.get_cached_stripped_html_for_activity(object, "metadata") - |> Formatter.demojify() + |> Emoji.Formatter.demojify() |> Formatter.truncate() end @@ -23,7 +24,7 @@ defmodule Pleroma.Web.Metadata.Utils do |> HtmlEntities.decode() |> String.replace(~r/<br\s?\/?>/, " ") |> HTML.strip_tags() - |> Formatter.demojify() + |> Emoji.Formatter.demojify() |> Formatter.truncate(max_length) end diff --git a/lib/pleroma/web/mongooseim/mongoose_im_controller.ex b/lib/pleroma/web/mongooseim/mongoose_im_controller.ex index b786a521b..6ed181cff 100644 --- a/lib/pleroma/web/mongooseim/mongoose_im_controller.ex +++ b/lib/pleroma/web/mongooseim/mongoose_im_controller.ex @@ -4,10 +4,15 @@ defmodule Pleroma.Web.MongooseIM.MongooseIMController do use Pleroma.Web, :controller + alias Comeonin.Pbkdf2 + alias Pleroma.Plugs.RateLimiter alias Pleroma.Repo alias Pleroma.User + plug(RateLimiter, :authentication when action in [:user_exists, :check_password]) + plug(RateLimiter, {:authentication, params: ["user"]} when action == :check_password) + def user_exists(conn, %{"user" => username}) do with %User{} <- Repo.get_by(User, nickname: username, local: true) do conn diff --git a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex index ee14cfd6b..192984242 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex @@ -57,6 +57,7 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do "mastodon_api_streaming", "polls", "pleroma_explicit_addressing", + "shareable_emoji_packs", if Config.get([:media_proxy, :enabled]) do "media_proxy" end, diff --git a/lib/pleroma/web/oauth/app.ex b/lib/pleroma/web/oauth/app.ex index ddcdb1871..cc3fb1ce5 100644 --- a/lib/pleroma/web/oauth/app.ex +++ b/lib/pleroma/web/oauth/app.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.OAuth.App do use Ecto.Schema import Ecto.Changeset + alias Pleroma.Repo @type t :: %__MODULE__{} @@ -39,4 +40,29 @@ defmodule Pleroma.Web.OAuth.App do changeset end end + + @doc """ + Gets app by attrs or create new with attrs. + And updates the scopes if need. + """ + @spec get_or_make(map(), list(String.t())) :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} + def get_or_make(attrs, scopes) do + with %__MODULE__{} = app <- Repo.get_by(__MODULE__, attrs) do + update_scopes(app, scopes) + else + _e -> + %__MODULE__{} + |> register_changeset(Map.put(attrs, :scopes, scopes)) + |> Repo.insert() + end + end + + defp update_scopes(%__MODULE__{} = app, []), do: {:ok, app} + defp update_scopes(%__MODULE__{scopes: scopes} = app, scopes), do: {:ok, app} + + defp update_scopes(%__MODULE__{} = app, scopes) do + app + |> change(%{scopes: scopes}) + |> Repo.update() + end end diff --git a/lib/pleroma/web/oauth/authorization.ex b/lib/pleroma/web/oauth/authorization.ex index d53e20d12..ed42a34f3 100644 --- a/lib/pleroma/web/oauth/authorization.ex +++ b/lib/pleroma/web/oauth/authorization.ex @@ -20,7 +20,7 @@ defmodule Pleroma.Web.OAuth.Authorization do field(:scopes, {:array, :string}, default: []) field(:valid_until, :naive_datetime_usec) field(:used, :boolean, default: false) - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:app, App) timestamps() diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index 81eae2c8b..03c9a5027 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -24,6 +24,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do plug(:fetch_session) plug(:fetch_flash) + plug(Pleroma.Plugs.RateLimiter, :authentication when action == :create_authorization) action_fallback(Pleroma.Web.OAuth.FallbackController) @@ -202,6 +203,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do {:ok, app} <- Token.Utils.fetch_app(conn), {:auth_active, true} <- {:auth_active, User.auth_active?(user)}, {:user_active, true} <- {:user_active, !user.info.deactivated}, + {:password_reset_pending, false} <- + {:password_reset_pending, user.info.password_reset_pending}, {:ok, scopes} <- validate_scopes(app, params), {:ok, auth} <- Authorization.create_authorization(app, user, scopes), {:ok, token} <- Token.exchange_token(app, auth) do @@ -210,10 +213,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", + %{}, + "password_reset_required" + ) _error -> render_invalid_credentials_error(conn) @@ -437,7 +461,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 @@ -451,7 +475,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do defp validate_scopes(app, params) do params |> Scopes.fetch_scopes(app.scopes) - |> Scopes.validates(app.scopes) + |> Scopes.validate(app.scopes) end def default_redirect_uri(%App{} = app) do diff --git a/lib/pleroma/web/oauth/scopes.ex b/lib/pleroma/web/oauth/scopes.ex index ad9dfb260..48bd14407 100644 --- a/lib/pleroma/web/oauth/scopes.ex +++ b/lib/pleroma/web/oauth/scopes.ex @@ -8,7 +8,7 @@ defmodule Pleroma.Web.OAuth.Scopes do """ @doc """ - Fetch scopes from requiest params. + Fetch scopes from request params. Note: `scopes` is used by Mastodon — supporting it but sticking to OAuth's standard `scope` wherever we control it @@ -53,14 +53,14 @@ defmodule Pleroma.Web.OAuth.Scopes do @doc """ Validates scopes. """ - @spec validates(list() | nil, list()) :: + @spec validate(list() | nil, list()) :: {:ok, list()} | {:error, :missing_scopes | :unsupported_scopes} - def validates([], _app_scopes), do: {:error, :missing_scopes} - def validates(nil, _app_scopes), do: {:error, :missing_scopes} + def validate([], _app_scopes), do: {:error, :missing_scopes} + def validate(nil, _app_scopes), do: {:error, :missing_scopes} - def validates(scopes, app_scopes) do - case scopes -- app_scopes do - [] -> {:ok, scopes} + def validate(scopes, app_scopes) do + case Pleroma.Plugs.OAuthScopesPlug.filter_descendants(scopes, app_scopes) do + ^scopes -> {:ok, scopes} _ -> {:error, :unsupported_scopes} end end diff --git a/lib/pleroma/web/oauth/token.ex b/lib/pleroma/web/oauth/token.ex index 40f131b57..8ea373805 100644 --- a/lib/pleroma/web/oauth/token.ex +++ b/lib/pleroma/web/oauth/token.ex @@ -21,7 +21,7 @@ defmodule Pleroma.Web.OAuth.Token do field(:refresh_token, :string) field(:scopes, {:array, :string}, default: []) field(:valid_until, :naive_datetime_usec) - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:app, App) timestamps() diff --git a/lib/pleroma/web/oauth/token/clean_worker.ex b/lib/pleroma/web/oauth/token/clean_worker.ex index f50098302..f639f9c6f 100644 --- a/lib/pleroma/web/oauth/token/clean_worker.ex +++ b/lib/pleroma/web/oauth/token/clean_worker.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.OAuth.Token.CleanWorker do @@ -17,6 +17,7 @@ defmodule Pleroma.Web.OAuth.Token.CleanWorker do ) alias Pleroma.Web.OAuth.Token + alias Pleroma.Workers.BackgroundWorker def start_link(_), do: GenServer.start_link(__MODULE__, %{}) @@ -27,9 +28,11 @@ defmodule Pleroma.Web.OAuth.Token.CleanWorker do @doc false def handle_info(:perform, state) do - Token.delete_expired_tokens() + BackgroundWorker.enqueue("clean_expired_tokens", %{}) Process.send_after(self(), :perform, @interval) {:noreply, state} end + + def perform(:clean), do: Token.delete_expired_tokens() end diff --git a/lib/pleroma/web/oauth/token/query.ex b/lib/pleroma/web/oauth/token/query.ex index d92e1f071..9642103e6 100644 --- a/lib/pleroma/web/oauth/token/query.ex +++ b/lib/pleroma/web/oauth/token/query.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/> +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.OAuth.Token.Query do diff --git a/lib/pleroma/web/ostatus/ostatus.ex b/lib/pleroma/web/ostatus/ostatus.ex index 331cbc0b7..5de1ceef3 100644 --- a/lib/pleroma/web/ostatus/ostatus.ex +++ b/lib/pleroma/web/ostatus/ostatus.ex @@ -3,14 +3,12 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.OStatus do - import Ecto.Query import Pleroma.Web.XML require Logger alias Pleroma.Activity alias Pleroma.HTTP alias Pleroma.Object - alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web alias Pleroma.Web.ActivityPub.ActivityPub @@ -38,21 +36,13 @@ defmodule Pleroma.Web.OStatus do end end - def feed_path(user) do - "#{user.ap_id}/feed.atom" - end + def feed_path(user), do: "#{user.ap_id}/feed.atom" - def pubsub_path(user) do - "#{Web.base_url()}/push/hub/#{user.nickname}" - end + def pubsub_path(user), do: "#{Web.base_url()}/push/hub/#{user.nickname}" - def salmon_path(user) do - "#{user.ap_id}/salmon" - end + def salmon_path(user), do: "#{user.ap_id}/salmon" - def remote_follow_path do - "#{Web.base_url()}/ostatus_subscribe?acct={uri}" - end + def remote_follow_path, do: "#{Web.base_url()}/ostatus_subscribe?acct={uri}" def handle_incoming(xml_string, options \\ []) do with doc when doc != :error <- parse_document(xml_string) do @@ -217,10 +207,9 @@ defmodule Pleroma.Web.OStatus do Get the cw that mastodon uses. """ def get_cw(entry) do - with cw when not is_nil(cw) <- string_from_xpath("/*/summary", entry) do - cw - else - _e -> nil + case string_from_xpath("/*/summary", entry) do + cw when not is_nil(cw) -> cw + _ -> nil end end @@ -232,19 +221,17 @@ defmodule Pleroma.Web.OStatus do end def maybe_update(doc, user) do - if "true" == string_from_xpath("//author[1]/ap_enabled", doc) do - Transmogrifier.upgrade_user_from_ap_id(user.ap_id) - else - maybe_update_ostatus(doc, user) + case string_from_xpath("//author[1]/ap_enabled", doc) do + "true" -> + Transmogrifier.upgrade_user_from_ap_id(user.ap_id) + + _ -> + maybe_update_ostatus(doc, user) end end def maybe_update_ostatus(doc, user) do - old_data = %{ - avatar: user.avatar, - bio: user.bio, - name: user.name - } + old_data = Map.take(user, [:bio, :avatar, :name]) with false <- user.local, avatar <- make_avatar_object(doc), @@ -279,38 +266,37 @@ defmodule Pleroma.Web.OStatus do end end + @spec find_or_make_user(String.t()) :: {:ok, User.t()} def find_or_make_user(uri) do - query = from(user in User, where: user.ap_id == ^uri) - - user = Repo.one(query) - - if is_nil(user) do - make_user(uri) - else - {:ok, user} + case User.get_by_ap_id(uri) do + %User{} = user -> {:ok, user} + _ -> make_user(uri) end end + @spec make_user(String.t(), boolean()) :: {:ok, User.t()} | {:error, any()} def make_user(uri, update \\ false) do with {:ok, info} <- gather_user_info(uri) do - data = %{ - name: info["name"], - nickname: info["nickname"] <> "@" <> info["host"], - ap_id: info["uri"], - info: info, - avatar: info["avatar"], - bio: info["bio"] - } - with false <- update, - %User{} = user <- User.get_cached_by_ap_id(data.ap_id) do + %User{} = user <- User.get_cached_by_ap_id(info["uri"]) do {:ok, user} else - _e -> User.insert_or_update_user(data) + _e -> User.insert_or_update_user(build_user_data(info)) end end end + defp build_user_data(info) do + %{ + name: info["name"], + nickname: info["nickname"] <> "@" <> info["host"], + ap_id: info["uri"], + info: info, + avatar: info["avatar"], + bio: info["bio"] + } + end + # TODO: Just takes the first one for now. def make_avatar_object(author_doc, rel \\ "avatar") do href = string_from_xpath("//author[1]/link[@rel=\"#{rel}\"]/@href", author_doc) @@ -319,23 +305,23 @@ defmodule Pleroma.Web.OStatus do if href do %{ "type" => "Image", - "url" => [ - %{ - "type" => "Link", - "mediaType" => type, - "href" => href - } - ] + "url" => [%{"type" => "Link", "mediaType" => type, "href" => href}] } else nil end end + @spec gather_user_info(String.t()) :: {:ok, map()} | {:error, any()} def gather_user_info(username) do with {:ok, webfinger_data} <- WebFinger.finger(username), {:ok, feed_data} <- Websub.gather_feed_data(webfinger_data["topic"]) do - {:ok, Map.merge(webfinger_data, feed_data) |> Map.put("fqn", username)} + data = + webfinger_data + |> Map.merge(feed_data) + |> Map.put("fqn", username) + + {:ok, data} else e -> Logger.debug(fn -> "Couldn't gather info for #{username}" end) @@ -371,10 +357,7 @@ defmodule Pleroma.Web.OStatus do def fetch_activity_from_atom_url(url, options \\ []) do with true <- String.starts_with?(url, "http"), {:ok, %{body: body, status: code}} when code in 200..299 <- - HTTP.get( - url, - [{:Accept, "application/atom+xml"}] - ) do + HTTP.get(url, [{:Accept, "application/atom+xml"}]) do Logger.debug("Got document from #{url}, handling...") handle_incoming(body, options) else diff --git a/lib/pleroma/web/ostatus/ostatus_controller.ex b/lib/pleroma/web/ostatus/ostatus_controller.ex index 07e2a4c2d..20f2d9ddc 100644 --- a/lib/pleroma/web/ostatus/ostatus_controller.ex +++ b/lib/pleroma/web/ostatus/ostatus_controller.ex @@ -9,16 +9,13 @@ defmodule Pleroma.Web.OStatus.OStatusController do alias Pleroma.Activity alias Pleroma.Object alias Pleroma.User - alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPubController alias Pleroma.Web.ActivityPub.ObjectView alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.Endpoint alias Pleroma.Web.Federator alias Pleroma.Web.Metadata.PlayerView - alias Pleroma.Web.OStatus alias Pleroma.Web.OStatus.ActivityRepresenter - alias Pleroma.Web.OStatus.FeedRepresenter alias Pleroma.Web.Router alias Pleroma.Web.XML @@ -31,50 +28,11 @@ defmodule Pleroma.Web.OStatus.OStatusController do plug( Pleroma.Plugs.SetFormatPlug - when action in [:feed_redirect, :object, :activity, :notice] + when action in [:object, :activity, :notice] ) action_fallback(:errors) - def feed_redirect(%{assigns: %{format: "html"}} = conn, %{"nickname" => nickname}) do - with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname_or_id(nickname)} do - RedirectController.redirector_with_meta(conn, %{user: user}) - end - end - - def feed_redirect(%{assigns: %{format: format}} = conn, _params) - when format in ["json", "activity+json"] do - ActivityPubController.call(conn, :user) - end - - def feed_redirect(conn, %{"nickname" => nickname}) do - with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)} do - redirect(conn, external: OStatus.feed_path(user)) - end - end - - def feed(conn, %{"nickname" => nickname} = params) do - with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)} do - query_params = - Map.take(params, ["max_id"]) - |> Map.merge(%{"whole_db" => true, "actor_id" => user.ap_id}) - - activities = - ActivityPub.fetch_public_activities(query_params) - |> Enum.reverse() - - response = - user - |> FeedRepresenter.to_simple_form(activities, [user]) - |> :xmerl.export_simple(:xmerl_xml) - |> to_string - - conn - |> put_resp_content_type("application/atom+xml") - |> send_resp(200, response) - end - end - defp decode_or_retry(body) do with {:ok, magic_key} <- Pleroma.Web.Salmon.fetch_magic_key(body), {:ok, doc} <- Pleroma.Web.Salmon.decode_and_validate(magic_key, body) do @@ -98,8 +56,7 @@ defmodule Pleroma.Web.OStatus.OStatusController do Federator.incoming_doc(doc) - conn - |> send_resp(200, "") + send_resp(conn, 200, "") end def object(%{assigns: %{format: format}} = conn, %{"uuid" => _uuid}) @@ -218,7 +175,8 @@ defmodule Pleroma.Web.OStatus.OStatusController do conn |> put_resp_header("content-type", "application/activity+json") - |> json(ObjectView.render("object.json", %{object: object})) + |> put_view(ObjectView) + |> render("object.json", %{object: object}) end defp represent_activity(_conn, "activity+json", _, _) do diff --git a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex new file mode 100644 index 000000000..9012e2175 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex @@ -0,0 +1,168 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.AccountController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, + only: [json_response: 3, add_link_headers: 2, assign_account_by_id: 2] + + alias Ecto.Changeset + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Plugs.RateLimiter + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.StatusView + + require Pleroma.Constants + + plug( + OAuthScopesPlug, + %{scopes: ["follow", "write:follows"]} when action in [:subscribe, :unsubscribe] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:accounts"]} + # Note: the following actions are not permission-secured in Mastodon: + when action in [ + :update_avatar, + :update_banner, + :update_background + ] + ) + + plug(OAuthScopesPlug, %{scopes: ["read:favourites"]} when action == :favourites) + + # An extra safety measure for possible actions not guarded by OAuth permissions specification + plug( + Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug + when action != :confirmation_resend + ) + + plug(RateLimiter, :account_confirmation_resend when action == :confirmation_resend) + plug(:assign_account_by_id when action in [:favourites, :subscribe, :unsubscribe]) + plug(:put_view, Pleroma.Web.MastodonAPI.AccountView) + + @doc "POST /api/v1/pleroma/accounts/confirmation_resend" + def 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 + json_response(conn, :no_content, "") + end + end + + @doc "PATCH /api/v1/pleroma/accounts/update_avatar" + def update_avatar(%{assigns: %{user: user}} = conn, %{"img" => ""}) do + {:ok, user} = + user + |> Changeset.change(%{avatar: nil}) + |> User.update_and_set_cache() + + CommonAPI.update(user) + + json(conn, %{url: nil}) + end + + def update_avatar(%{assigns: %{user: user}} = conn, params) do + {:ok, %{data: data}} = ActivityPub.upload(params, type: :avatar) + {:ok, user} = user |> Changeset.change(%{avatar: data}) |> User.update_and_set_cache() + %{"url" => [%{"href" => href} | _]} = data + + CommonAPI.update(user) + + json(conn, %{url: href}) + end + + @doc "PATCH /api/v1/pleroma/accounts/update_banner" + def update_banner(%{assigns: %{user: user}} = conn, %{"banner" => ""}) do + new_info = %{"banner" => %{}} + + with {:ok, user} <- User.update_info(user, &User.Info.profile_update(&1, new_info)) 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}, + {:ok, user} <- User.update_info(user, &User.Info.profile_update(&1, new_info)) do + CommonAPI.update(user) + %{"url" => [%{"href" => href} | _]} = object.data + + json(conn, %{url: href}) + end + end + + @doc "PATCH /api/v1/pleroma/accounts/update_background" + def update_background(%{assigns: %{user: user}} = conn, %{"img" => ""}) do + new_info = %{"background" => %{}} + + with {:ok, _user} <- User.update_info(user, &User.Info.profile_update(&1, new_info)) 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}, + {:ok, _user} <- User.update_info(user, &User.Info.profile_update(&1, new_info)) do + %{"url" => [%{"href" => href} | _]} = object.data + + json(conn, %{url: href}) + end + end + + @doc "GET /api/v1/pleroma/accounts/:id/favourites" + def favourites(%{assigns: %{account: %{info: %{hide_favorites: true}}}} = conn, _params) do + render_error(conn, :forbidden, "Can't get favorites") + end + + def favourites(%{assigns: %{user: for_user, account: user}} = conn, params) 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) + end + + @doc "POST /api/v1/pleroma/accounts/:id/subscribe" + def subscribe(%{assigns: %{user: user, account: subscription_target}} = conn, _params) do + with {:ok, subscription_target} <- User.subscribe(user, subscription_target) do + render(conn, "relationship.json", user: user, target: subscription_target) + else + {:error, message} -> json_response(conn, :forbidden, %{error: message}) + end + end + + @doc "POST /api/v1/pleroma/accounts/:id/unsubscribe" + def unsubscribe(%{assigns: %{user: user, account: subscription_target}} = conn, _params) do + with {:ok, subscription_target} <- User.unsubscribe(user, subscription_target) do + render(conn, "relationship.json", user: user, target: subscription_target) + else + {:error, message} -> json_response(conn, :forbidden, %{error: message}) + end + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex new file mode 100644 index 000000000..a474d41d4 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex @@ -0,0 +1,635 @@ +defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do + use Pleroma.Web, :controller + + alias Pleroma.Plugs.OAuthScopesPlug + + require Logger + + plug( + OAuthScopesPlug, + %{scopes: ["write"]} + when action in [ + :create, + :delete, + :download_from, + :list_from, + :import_from_fs, + :update_file, + :update_metadata + ] + ) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + def emoji_dir_path do + Path.join( + Pleroma.Config.get!([:instance, :static_dir]), + "emoji" + ) + end + + @doc """ + Lists packs from the remote instance. + + Since JS cannot ask remote instances for their packs due to CPS, it has to + be done by the server + """ + def list_from(conn, %{"instance_address" => address}) do + address = String.trim(address) + + if shareable_packs_available(address) do + list_resp = + "#{address}/api/pleroma/emoji/packs" |> Tesla.get!() |> Map.get(:body) |> Jason.decode!() + + json(conn, list_resp) + else + conn + |> put_status(:internal_server_error) + |> json(%{error: "The requested instance does not support sharing emoji packs"}) + end + end + + @doc """ + Lists the packs available on the instance as JSON. + + The information is public and does not require authentification. The format is + a map of "pack directory name" to pack.json contents. + """ + def list_packs(conn, _params) do + # Create the directory first if it does not exist. This is probably the first request made + # with the API so it should be sufficient + with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(emoji_dir_path())}, + {:ls, {:ok, results}} <- {:ls, File.ls(emoji_dir_path())} do + pack_infos = + results + |> Enum.filter(&has_pack_json?/1) + |> Enum.map(&load_pack/1) + # Check if all the files are in place and can be sent + |> Enum.map(&validate_pack/1) + # Transform into a map of pack-name => pack-data + |> Enum.into(%{}) + + json(conn, pack_infos) + else + {:create_dir, {:error, e}} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "Failed to create the emoji pack directory at #{emoji_dir_path()}: #{e}"}) + + {:ls, {:error, e}} -> + conn + |> put_status(:internal_server_error) + |> json(%{ + error: + "Failed to get the contents of the emoji pack directory at #{emoji_dir_path()}: #{e}" + }) + end + end + + defp has_pack_json?(file) do + dir_path = Path.join(emoji_dir_path(), file) + # Filter to only use the pack.json packs + File.dir?(dir_path) and File.exists?(Path.join(dir_path, "pack.json")) + end + + defp load_pack(pack_name) do + pack_path = Path.join(emoji_dir_path(), pack_name) + pack_file = Path.join(pack_path, "pack.json") + + {pack_name, Jason.decode!(File.read!(pack_file))} + end + + defp validate_pack({name, pack}) do + pack_path = Path.join(emoji_dir_path(), name) + + if can_download?(pack, pack_path) do + archive_for_sha = make_archive(name, pack, pack_path) + archive_sha = :crypto.hash(:sha256, archive_for_sha) |> Base.encode16() + + pack = + pack + |> put_in(["pack", "can-download"], true) + |> put_in(["pack", "download-sha256"], archive_sha) + + {name, pack} + else + {name, put_in(pack, ["pack", "can-download"], false)} + end + end + + defp can_download?(pack, pack_path) do + # If the pack is set as shared, check if it can be downloaded + # That means that when asked, the pack can be packed and sent to the remote + # Otherwise, they'd have to download it from external-src + pack["pack"]["share-files"] && + Enum.all?(pack["files"], fn {_, path} -> + File.exists?(Path.join(pack_path, path)) + end) + end + + defp create_archive_and_cache(name, pack, pack_dir, md5) do + files = + ['pack.json'] ++ + (pack["files"] |> Enum.map(fn {_, path} -> to_charlist(path) end)) + + {:ok, {_, zip_result}} = :zip.zip('#{name}.zip', files, [:memory, cwd: to_charlist(pack_dir)]) + + cache_seconds_per_file = Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file]) + cache_ms = :timer.seconds(cache_seconds_per_file * Enum.count(files)) + + Cachex.put!( + :emoji_packs_cache, + name, + # if pack.json MD5 changes, the cache is not valid anymore + %{pack_json_md5: md5, pack_data: zip_result}, + # Add a minute to cache time for every file in the pack + ttl: cache_ms + ) + + Logger.debug("Created an archive for the '#{name}' emoji pack, \ +keeping it in cache for #{div(cache_ms, 1000)}s") + + zip_result + end + + defp make_archive(name, pack, pack_dir) do + # Having a different pack.json md5 invalidates cache + pack_file_md5 = :crypto.hash(:md5, File.read!(Path.join(pack_dir, "pack.json"))) + + case Cachex.get!(:emoji_packs_cache, name) do + %{pack_file_md5: ^pack_file_md5, pack_data: zip_result} -> + Logger.debug("Using cache for the '#{name}' shared emoji pack") + zip_result + + _ -> + create_archive_and_cache(name, pack, pack_dir, pack_file_md5) + end + end + + @doc """ + An endpoint for other instances (via admin UI) or users (via browser) + to download packs that the instance shares. + """ + def download_shared(conn, %{"name" => name}) do + pack_dir = Path.join(emoji_dir_path(), name) + pack_file = Path.join(pack_dir, "pack.json") + + with {_, true} <- {:exists?, File.exists?(pack_file)}, + pack = Jason.decode!(File.read!(pack_file)), + {_, true} <- {:can_download?, can_download?(pack, pack_dir)} do + zip_result = make_archive(name, pack, pack_dir) + send_download(conn, {:binary, zip_result}, filename: "#{name}.zip") + else + {:can_download?, _} -> + conn + |> put_status(:forbidden) + |> json(%{ + error: "Pack #{name} cannot be downloaded from this instance, either pack sharing\ + was disabled for this pack or some files are missing" + }) + + {:exists?, _} -> + conn + |> put_status(:not_found) + |> json(%{error: "Pack #{name} does not exist"}) + end + end + + defp shareable_packs_available(address) do + "#{address}/.well-known/nodeinfo" + |> Tesla.get!() + |> Map.get(:body) + |> Jason.decode!() + |> Map.get("links") + |> List.last() + |> Map.get("href") + # Get the actual nodeinfo address and fetch it + |> Tesla.get!() + |> Map.get(:body) + |> Jason.decode!() + |> get_in(["metadata", "features"]) + |> Enum.member?("shareable_emoji_packs") + end + + @doc """ + An admin endpoint to request downloading a pack named `pack_name` from the instance + `instance_address`. + + If the requested instance's admin chose to share the pack, it will be downloaded + from that instance, otherwise it will be downloaded from the fallback source, if there is one. + """ + def download_from(conn, %{"instance_address" => address, "pack_name" => name} = data) do + address = String.trim(address) + + if shareable_packs_available(address) do + full_pack = + "#{address}/api/pleroma/emoji/packs/list" + |> Tesla.get!() + |> Map.get(:body) + |> Jason.decode!() + |> Map.get(name) + + pack_info_res = + case full_pack["pack"] do + %{"share-files" => true, "can-download" => true, "download-sha256" => sha} -> + {:ok, + %{ + sha: sha, + uri: "#{address}/api/pleroma/emoji/packs/download_shared/#{name}" + }} + + %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) -> + {:ok, + %{ + sha: sha, + uri: src, + fallback: true + }} + + _ -> + {:error, + "The pack was not set as shared and there is no fallback src to download from"} + end + + with {:ok, %{sha: sha, uri: uri} = pinfo} <- pack_info_res, + %{body: emoji_archive} <- Tesla.get!(uri), + {_, true} <- {:checksum, Base.decode16!(sha) == :crypto.hash(:sha256, emoji_archive)} do + local_name = data["as"] || name + pack_dir = Path.join(emoji_dir_path(), local_name) + File.mkdir_p!(pack_dir) + + files = Enum.map(full_pack["files"], fn {_, path} -> to_charlist(path) end) + # Fallback cannot contain a pack.json file + files = if pinfo[:fallback], do: files, else: ['pack.json'] ++ files + + {:ok, _} = :zip.unzip(emoji_archive, cwd: to_charlist(pack_dir), file_list: files) + + # Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256 + # in it to depend on itself + if pinfo[:fallback] do + pack_file_path = Path.join(pack_dir, "pack.json") + + File.write!(pack_file_path, Jason.encode!(full_pack, pretty: true)) + end + + json(conn, "ok") + else + {:error, e} -> + conn |> put_status(:internal_server_error) |> json(%{error: e}) + + {:checksum, _} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"}) + end + else + conn + |> put_status(:internal_server_error) + |> json(%{error: "The requested instance does not support sharing emoji packs"}) + end + end + + @doc """ + Creates an empty pack named `name` which then can be updated via the admin UI. + """ + def create(conn, %{"name" => name}) do + pack_dir = Path.join(emoji_dir_path(), name) + + if not File.exists?(pack_dir) do + File.mkdir_p!(pack_dir) + + pack_file_p = Path.join(pack_dir, "pack.json") + + File.write!( + pack_file_p, + Jason.encode!(%{pack: %{}, files: %{}}, pretty: true) + ) + + conn |> json("ok") + else + conn + |> put_status(:conflict) + |> json(%{error: "A pack named \"#{name}\" already exists"}) + end + end + + @doc """ + Deletes the pack `name` and all it's files. + """ + def delete(conn, %{"name" => name}) do + pack_dir = Path.join(emoji_dir_path(), name) + + case File.rm_rf(pack_dir) do + {:ok, _} -> + conn |> json("ok") + + {:error, _} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "Couldn't delete the pack #{name}"}) + end + end + + @doc """ + An endpoint to update `pack_names`'s metadata. + + `new_data` is the new metadata for the pack, that will replace the old metadata. + """ + def update_metadata(conn, %{"pack_name" => name, "new_data" => new_data}) do + pack_file_p = Path.join([emoji_dir_path(), name, "pack.json"]) + + full_pack = Jason.decode!(File.read!(pack_file_p)) + + # The new fallback-src is in the new data and it's not the same as it was in the old data + should_update_fb_sha = + not is_nil(new_data["fallback-src"]) and + new_data["fallback-src"] != full_pack["pack"]["fallback-src"] + + with {_, true} <- {:should_update?, should_update_fb_sha}, + %{body: pack_arch} <- Tesla.get!(new_data["fallback-src"]), + {:ok, flist} <- :zip.unzip(pack_arch, [:memory]), + {_, true} <- {:has_all_files?, has_all_files?(full_pack, flist)} do + fallback_sha = :crypto.hash(:sha256, pack_arch) |> Base.encode16() + + new_data = Map.put(new_data, "fallback-src-sha256", fallback_sha) + update_metadata_and_send(conn, full_pack, new_data, pack_file_p) + else + {:should_update?, _} -> + update_metadata_and_send(conn, full_pack, new_data, pack_file_p) + + {:has_all_files?, _} -> + conn + |> put_status(:bad_request) + |> json(%{error: "The fallback archive does not have all files specified in pack.json"}) + end + end + + # Check if all files from the pack.json are in the archive + defp has_all_files?(%{"files" => files}, flist) do + Enum.all?(files, fn {_, from_manifest} -> + Enum.find(flist, fn {from_archive, _} -> + to_string(from_archive) == from_manifest + end) + end) + end + + defp update_metadata_and_send(conn, full_pack, new_data, pack_file_p) do + full_pack = Map.put(full_pack, "pack", new_data) + File.write!(pack_file_p, Jason.encode!(full_pack, pretty: true)) + + # Send new data back with fallback sha filled + json(conn, new_data) + end + + defp get_filename(%{"filename" => filename}), do: filename + + defp get_filename(%{"file" => file}) do + case file do + %Plug.Upload{filename: filename} -> filename + url when is_binary(url) -> Path.basename(url) + end + end + + defp empty?(str), do: String.trim(str) == "" + + defp update_file_and_send(conn, updated_full_pack, pack_file_p) do + # Write the emoji pack file + File.write!(pack_file_p, Jason.encode!(updated_full_pack, pretty: true)) + + # Return the modified file list + json(conn, updated_full_pack["files"]) + end + + @doc """ + Updates a file in a pack. + + Updating can mean three things: + + - `add` adds an emoji named `shortcode` to the pack `pack_name`, + that means that the emoji file needs to be uploaded with the request + (thus requiring it to be a multipart request) and be named `file`. + There can also be an optional `filename` that will be the new emoji file name + (if it's not there, the name will be taken from the uploaded file). + - `update` changes emoji shortcode (from `shortcode` to `new_shortcode` or moves the file + (from the current filename to `new_filename`) + - `remove` removes the emoji named `shortcode` and it's associated file + """ + + # Add + def update_file( + conn, + %{"pack_name" => pack_name, "action" => "add", "shortcode" => shortcode} = params + ) do + pack_dir = Path.join(emoji_dir_path(), pack_name) + pack_file_p = Path.join(pack_dir, "pack.json") + + full_pack = Jason.decode!(File.read!(pack_file_p)) + + with {_, false} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)}, + filename <- get_filename(params), + false <- empty?(shortcode), + false <- empty?(filename) do + file_path = Path.join(pack_dir, filename) + + # If the name contains directories, create them + if String.contains?(file_path, "/") do + File.mkdir_p!(Path.dirname(file_path)) + end + + case params["file"] do + %Plug.Upload{path: upload_path} -> + # Copy the uploaded file from the temporary directory + File.copy!(upload_path, file_path) + + url when is_binary(url) -> + # Download and write the file + file_contents = Tesla.get!(url).body + File.write!(file_path, file_contents) + end + + updated_full_pack = put_in(full_pack, ["files", shortcode], filename) + update_file_and_send(conn, updated_full_pack, pack_file_p) + else + {:has_shortcode, _} -> + conn + |> put_status(:conflict) + |> json(%{error: "An emoji with the \"#{shortcode}\" shortcode already exists"}) + + true -> + conn + |> put_status(:bad_request) + |> json(%{error: "shortcode or filename cannot be empty"}) + end + end + + # Remove + def update_file(conn, %{ + "pack_name" => pack_name, + "action" => "remove", + "shortcode" => shortcode + }) do + pack_dir = Path.join(emoji_dir_path(), pack_name) + pack_file_p = Path.join(pack_dir, "pack.json") + + full_pack = Jason.decode!(File.read!(pack_file_p)) + + if Map.has_key?(full_pack["files"], shortcode) do + {emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode]) + + emoji_file_path = Path.join(pack_dir, emoji_file_path) + + # Delete the emoji file + File.rm!(emoji_file_path) + + # If the old directory has no more files, remove it + if String.contains?(emoji_file_path, "/") do + dir = Path.dirname(emoji_file_path) + + if Enum.empty?(File.ls!(dir)) do + File.rmdir!(dir) + end + end + + update_file_and_send(conn, updated_full_pack, pack_file_p) + else + conn + |> put_status(:bad_request) + |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) + end + end + + # Update + def update_file( + conn, + %{"pack_name" => pack_name, "action" => "update", "shortcode" => shortcode} = params + ) do + pack_dir = Path.join(emoji_dir_path(), pack_name) + pack_file_p = Path.join(pack_dir, "pack.json") + + full_pack = Jason.decode!(File.read!(pack_file_p)) + + with {_, true} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)}, + %{"new_shortcode" => new_shortcode, "new_filename" => new_filename} <- params, + false <- empty?(new_shortcode), + false <- empty?(new_filename) do + # First, remove the old shortcode, saving the old path + {old_emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode]) + old_emoji_file_path = Path.join(pack_dir, old_emoji_file_path) + new_emoji_file_path = Path.join(pack_dir, new_filename) + + # If the name contains directories, create them + if String.contains?(new_emoji_file_path, "/") do + File.mkdir_p!(Path.dirname(new_emoji_file_path)) + end + + # Move/Rename the old filename to a new filename + # These are probably on the same filesystem, so just rename should work + :ok = File.rename(old_emoji_file_path, new_emoji_file_path) + + # If the old directory has no more files, remove it + if String.contains?(old_emoji_file_path, "/") do + dir = Path.dirname(old_emoji_file_path) + + if Enum.empty?(File.ls!(dir)) do + File.rmdir!(dir) + end + end + + # Then, put in the new shortcode with the new path + updated_full_pack = put_in(updated_full_pack, ["files", new_shortcode], new_filename) + update_file_and_send(conn, updated_full_pack, pack_file_p) + else + {:has_shortcode, _} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) + + true -> + conn + |> put_status(:bad_request) + |> json(%{error: "new_shortcode or new_filename cannot be empty"}) + + _ -> + conn + |> put_status(:bad_request) + |> json(%{error: "new_shortcode or new_file were not specified"}) + end + end + + def update_file(conn, %{"action" => action}) do + conn + |> put_status(:bad_request) + |> json(%{error: "Unknown action: #{action}"}) + end + + @doc """ + Imports emoji from the filesystem. + + Importing means checking all the directories in the + `$instance_static/emoji/` for directories which do not have + `pack.json`. If one has an emoji.txt file, that file will be used + to create a `pack.json` file with it's contents. If the directory has + neither, all the files with specific configured extenstions will be + assumed to be emojis and stored in the new `pack.json` file. + """ + def import_from_fs(conn, _params) do + with {:ok, results} <- File.ls(emoji_dir_path()) do + imported_pack_names = + results + |> Enum.filter(fn file -> + dir_path = Path.join(emoji_dir_path(), file) + # Find the directories that do NOT have pack.json + File.dir?(dir_path) and not File.exists?(Path.join(dir_path, "pack.json")) + end) + |> Enum.map(&write_pack_json_contents/1) + + json(conn, imported_pack_names) + else + {:error, _} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "Error accessing emoji pack directory"}) + end + end + + defp write_pack_json_contents(dir) do + dir_path = Path.join(emoji_dir_path(), dir) + emoji_txt_path = Path.join(dir_path, "emoji.txt") + + files_for_pack = files_for_pack(emoji_txt_path, dir_path) + pack_json_contents = Jason.encode!(%{pack: %{}, files: files_for_pack}) + + File.write!(Path.join(dir_path, "pack.json"), pack_json_contents) + + dir + end + + defp files_for_pack(emoji_txt_path, dir_path) do + if File.exists?(emoji_txt_path) do + # There's an emoji.txt file, it's likely from a pack installed by the pack manager. + # Make a pack.json file from the contents of that emoji.txt fileh + + # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2 + + # Create a map of shortcodes to filenames from emoji.txt + File.read!(emoji_txt_path) + |> String.split("\n") + |> Enum.map(&String.trim/1) + |> Enum.map(fn line -> + case String.split(line, ~r/,\s*/) do + # This matches both strings with and without tags + # and we don't care about tags here + [name, file | _] -> {name, file} + _ -> nil + end + end) + |> Enum.filter(fn x -> not is_nil(x) end) + |> Enum.into(%{}) + else + # If there's no emoji.txt, assume all files + # that are of certain extensions from the config are emojis and import them all + pack_extensions = Pleroma.Config.get!([:emoji, :pack_extensions]) + Pleroma.Emoji.Loader.make_shortcode_to_file_map(dir_path, pack_extensions) + end + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex b/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex new file mode 100644 index 000000000..d71d72dd5 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex @@ -0,0 +1,41 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.MascotController do + use Pleroma.Web, :controller + + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + + plug(OAuthScopesPlug, %{scopes: ["read:accounts"]} when action == :show) + plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action != :show) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + @doc "GET /api/v1/pleroma/mascot" + def show(%{assigns: %{user: user}} = conn, _params) do + json(conn, User.get_mascot(user)) + end + + @doc "PUT /api/v1/pleroma/mascot" + def update(%{assigns: %{user: user}} = conn, %{"file" => file}) do + with {:ok, object} <- ActivityPub.upload(file, actor: User.ap_id(user)), + # Reject if not an image + %{type: "image"} = attachment <- render_attachment(object) do + # Sure! + # Save to the user's info + {:ok, _user} = User.update_info(user, &User.Info.mascot_update(&1, attachment)) + + json(conn, attachment) + else + %{type: _} -> render_error(conn, :unsupported_media_type, "mascots can only be images") + end + end + + defp render_attachment(object) do + attachment_data = Map.put(object.data, "id", object.id) + Pleroma.Web.MastodonAPI.StatusView.render("attachment.json", %{attachment: attachment_data}) + end +end diff --git a/lib/pleroma/web/pleroma_api/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index f4df3b024..9d50a7ca9 100644 --- a/lib/pleroma/web/pleroma_api/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -5,15 +5,30 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, only: [add_link_headers: 7] + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] alias Pleroma.Conversation.Participation alias Pleroma.Notification + alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.MastodonAPI.ConversationView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView + plug( + OAuthScopesPlug, + %{scopes: ["read:statuses"]} when action in [:conversation, :conversation_statuses] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:conversations"]} when action == :update_conversation + ) + + plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :read_notification) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + def conversation(%{assigns: %{user: user}} = conn, %{"id" => participation_id}) do with %Participation{} = participation <- Participation.get(participation_id), true <- user.id == participation.user_id do @@ -27,31 +42,22 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do %{assigns: %{user: user}} = conn, %{"id" => participation_id} = params ) do - params = - params - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) - - participation = - participation_id - |> Participation.get(preload: [:conversation]) + participation = Participation.get(participation_id, preload: [:conversation]) if user.id == participation.user_id do + params = + params + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("user", user) + activities = participation.conversation.ap_id |> ActivityPub.fetch_activities_for_context(params) |> Enum.reverse() conn - |> add_link_headers( - :conversation_statuses, - activities, - participation_id, - params, - nil, - &pleroma_api_url/4 - ) + |> add_link_headers(activities) |> put_view(StatusView) |> render("index.json", %{activities: activities, for: user, as: :activity}) end diff --git a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex new file mode 100644 index 000000000..b74b3debc --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex @@ -0,0 +1,58 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ScrobbleController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, fetch_integer_param: 2] + + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.StatusView + + plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :user_scrobbles) + plug(OAuthScopesPlug, %{scopes: ["write"]} when action != :user_scrobbles) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + def new_scrobble(%{assigns: %{user: user}} = conn, %{"title" => _} = params) do + params = + if !params["length"] do + params + else + params + |> Map.put("length", fetch_integer_param(params, "length")) + end + + with {:ok, activity} <- CommonAPI.listen(user, params) do + conn + |> put_view(StatusView) + |> render("listen.json", %{activity: activity, for: user}) + else + {:error, message} -> + conn + |> put_status(:bad_request) + |> json(%{"error" => message}) + end + end + + def user_scrobbles(%{assigns: %{user: reading_user}} = conn, params) do + with %User{} = user <- User.get_cached_by_nickname_or_id(params["id"], for: reading_user) do + params = Map.put(params, "type", ["Listen"]) + + activities = ActivityPub.fetch_user_abstract_activities(user, reading_user, params) + + conn + |> add_link_headers(activities) + |> put_view(StatusView) + |> render("listens.json", %{ + activities: activities, + for: reading_user, + as: :activity + }) + end + end +end diff --git a/lib/pleroma/web/push/push.ex b/lib/pleroma/web/push/push.ex index 729dad02a..7ef1532ac 100644 --- a/lib/pleroma/web/push/push.ex +++ b/lib/pleroma/web/push/push.ex @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Push do - alias Pleroma.Web.Push.Impl + alias Pleroma.Workers.WebPusherWorker require Logger @@ -31,6 +31,7 @@ defmodule Pleroma.Web.Push do end end - def send(notification), - do: PleromaJobQueue.enqueue(:web_push, Impl, [notification]) + def send(notification) do + WebPusherWorker.enqueue("web_push", %{"notification_id" => notification.id}) + end end diff --git a/lib/pleroma/web/push/subscription.ex b/lib/pleroma/web/push/subscription.ex index da301fbbc..988fabaeb 100644 --- a/lib/pleroma/web/push/subscription.ex +++ b/lib/pleroma/web/push/subscription.ex @@ -15,7 +15,7 @@ defmodule Pleroma.Web.Push.Subscription do @type t :: %__MODULE__{} schema "push_subscriptions" do - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:token, Token) field(:endpoint, :string) field(:key_p256dh, :string) diff --git a/lib/pleroma/web/rich_media/parser.ex b/lib/pleroma/web/rich_media/parser.ex index f5f9e358c..c06b0a0f2 100644 --- a/lib/pleroma/web/rich_media/parser.ex +++ b/lib/pleroma/web/rich_media/parser.ex @@ -81,6 +81,7 @@ defmodule Pleroma.Web.RichMedia.Parser do {:ok, %Tesla.Env{body: html}} = Pleroma.HTTP.get(url, [], adapter: @hackney_options) html + |> parse_html |> maybe_parse() |> Map.put(:url, url) |> clean_parsed_data() @@ -91,6 +92,8 @@ defmodule Pleroma.Web.RichMedia.Parser do end end + defp parse_html(html), do: Floki.parse(html) + defp maybe_parse(html) do Enum.reduce_while(parsers(), %{}, fn parser, acc -> case parser.parse(html, acc) do @@ -100,7 +103,8 @@ defmodule Pleroma.Web.RichMedia.Parser do end) end - defp check_parsed_data(%{title: title} = data) when is_binary(title) and byte_size(title) > 0 do + defp check_parsed_data(%{title: title} = data) + when is_binary(title) and byte_size(title) > 0 do {:ok, data} end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 44a4279f7..ae799b8ac 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -87,31 +87,6 @@ defmodule Pleroma.Web.Router do plug(Pleroma.Plugs.EnsureUserKeyPlug) end - pipeline :oauth_read_or_public do - plug(Pleroma.Plugs.OAuthScopesPlug, %{ - scopes: ["read"], - fallback: :proceed_unauthenticated - }) - - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - end - - pipeline :oauth_read do - plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["read"]}) - end - - pipeline :oauth_write do - plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["write"]}) - end - - pipeline :oauth_follow do - plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["follow"]}) - end - - pipeline :oauth_push do - plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["push"]}) - end - pipeline :well_known do plug(:accepts, ["json", "jrd+json", "xml", "xrd+xml"]) end @@ -135,6 +110,7 @@ defmodule Pleroma.Web.Router do pipeline :http_signature do plug(Pleroma.Web.Plugs.HTTPSignaturePlug) + plug(Pleroma.Web.Plugs.MappedSignatureToIdentityPlug) end scope "/api/pleroma", Pleroma.Web.TwitterAPI do @@ -153,7 +129,7 @@ defmodule Pleroma.Web.Router do end scope "/api/pleroma/admin", Pleroma.Web.AdminAPI do - pipe_through([:admin_api, :oauth_write]) + pipe_through(:admin_api) post("/users/follow", AdminAPIController, :user_follow) post("/users/unfollow", AdminAPIController, :user_unfollow) @@ -179,12 +155,13 @@ defmodule Pleroma.Web.Router do post("/relay", AdminAPIController, :relay_follow) delete("/relay", AdminAPIController, :relay_unfollow) - get("/users/invite_token", AdminAPIController, :get_invite_token) + post("/users/invite_token", AdminAPIController, :create_invite_token) get("/users/invites", AdminAPIController, :invites) post("/users/revoke_invite", AdminAPIController, :revoke_invite) post("/users/email_invite", AdminAPIController, :email_invite) get("/users/:nickname/password_reset", AdminAPIController, :get_password_reset) + patch("/users/:nickname/force_password_reset", AdminAPIController, :force_password_reset) get("/users", AdminAPIController, :list_users) get("/users/:nickname", AdminAPIController, :user_show) @@ -204,6 +181,30 @@ defmodule Pleroma.Web.Router do get("/config/migrate_from_db", AdminAPIController, :migrate_from_db) get("/moderation_log", AdminAPIController, :list_log) + + post("/reload_emoji", AdminAPIController, :reload_emoji) + end + + scope "/api/pleroma/emoji", Pleroma.Web.PleromaAPI do + scope "/packs" do + # Modifying packs + pipe_through(:admin_api) + + post("/import_from_fs", EmojiAPIController, :import_from_fs) + + post("/:pack_name/update_file", EmojiAPIController, :update_file) + post("/:pack_name/update_metadata", EmojiAPIController, :update_metadata) + put("/:name", EmojiAPIController, :create) + delete("/:name", EmojiAPIController, :delete) + post("/download_from", EmojiAPIController, :download_from) + post("/list_from", EmojiAPIController, :list_from) + end + + scope "/packs" do + # Pack info / downloading + get("/", EmojiAPIController, :list_packs) + get("/:name/download_shared/", EmojiAPIController, :download_shared) + end end scope "/", Pleroma.Web.TwitterAPI do @@ -212,30 +213,20 @@ defmodule Pleroma.Web.Router do post("/main/ostatus", UtilController, :remote_subscribe) get("/ostatus_subscribe", UtilController, :remote_follow) - scope [] do - pipe_through(:oauth_follow) - post("/ostatus_subscribe", UtilController, :do_remote_follow) - end + post("/ostatus_subscribe", UtilController, :do_remote_follow) end scope "/api/pleroma", Pleroma.Web.TwitterAPI do pipe_through(:authenticated_api) - scope [] do - pipe_through(:oauth_write) + post("/change_email", UtilController, :change_email) + post("/change_password", UtilController, :change_password) + post("/delete_account", UtilController, :delete_account) + put("/notification_settings", UtilController, :update_notificaton_settings) + post("/disable_account", UtilController, :disable_account) - post("/change_password", UtilController, :change_password) - post("/delete_account", UtilController, :delete_account) - put("/notification_settings", UtilController, :update_notificaton_settings) - post("/disable_account", UtilController, :disable_account) - end - - scope [] do - pipe_through(:oauth_follow) - - post("/blocks_import", UtilController, :blocks_import) - post("/follow_import", UtilController, :follow_import) - end + post("/blocks_import", UtilController, :blocks_import) + post("/follow_import", UtilController, :follow_import) end scope "/oauth", Pleroma.Web.OAuth do @@ -260,207 +251,197 @@ defmodule Pleroma.Web.Router do end scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do - pipe_through(:authenticated_api) - scope [] do - pipe_through(:oauth_read) + pipe_through(:authenticated_api) + get("/conversations/:id/statuses", PleromaAPIController, :conversation_statuses) get("/conversations/:id", PleromaAPIController, :conversation) end scope [] do - pipe_through(:oauth_write) + pipe_through(:authenticated_api) + patch("/conversations/:id", PleromaAPIController, :update_conversation) post("/notifications/read", PleromaAPIController, :read_notification) - end - end - scope "/api/v1", Pleroma.Web.MastodonAPI do - pipe_through(:authenticated_api) + patch("/accounts/update_avatar", AccountController, :update_avatar) + patch("/accounts/update_banner", AccountController, :update_banner) + patch("/accounts/update_background", AccountController, :update_background) - scope [] do - pipe_through(:oauth_read) + get("/mascot", MascotController, :show) + put("/mascot", MascotController, :update) - get("/accounts/verify_credentials", MastodonAPIController, :verify_credentials) + post("/scrobble", ScrobbleController, :new_scrobble) + end - get("/accounts/relationships", MastodonAPIController, :relationships) + scope [] do + pipe_through(:api) + get("/accounts/:id/favourites", AccountController, :favourites) + end - get("/accounts/:id/lists", MastodonAPIController, :account_lists) - get("/accounts/:id/identity_proofs", MastodonAPIController, :empty_array) + scope [] do + pipe_through(:authenticated_api) - get("/follow_requests", MastodonAPIController, :follow_requests) - get("/blocks", MastodonAPIController, :blocks) - get("/mutes", MastodonAPIController, :mutes) + post("/accounts/:id/subscribe", AccountController, :subscribe) + post("/accounts/:id/unsubscribe", AccountController, :unsubscribe) + end - get("/timelines/home", MastodonAPIController, :home_timeline) - get("/timelines/direct", MastodonAPIController, :dm_timeline) + post("/accounts/confirmation_resend", AccountController, :confirmation_resend) + end - get("/favourites", MastodonAPIController, :favourites) - get("/bookmarks", MastodonAPIController, :bookmarks) + scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do + pipe_through(:api) + get("/accounts/:id/scrobbles", ScrobbleController, :user_scrobbles) + end - post("/notifications/clear", MastodonAPIController, :clear_notifications) - post("/notifications/dismiss", MastodonAPIController, :dismiss_notification) - get("/notifications", MastodonAPIController, :notifications) - get("/notifications/:id", MastodonAPIController, :get_notification) - delete("/notifications/destroy_multiple", MastodonAPIController, :destroy_multiple) + scope "/api/v1", Pleroma.Web.MastodonAPI do + pipe_through(:authenticated_api) - get("/scheduled_statuses", MastodonAPIController, :scheduled_statuses) - get("/scheduled_statuses/:id", MastodonAPIController, :show_scheduled_status) + get("/accounts/verify_credentials", AccountController, :verify_credentials) - get("/lists", ListController, :index) - get("/lists/:id", ListController, :show) - get("/lists/:id/accounts", ListController, :list_accounts) + get("/accounts/relationships", AccountController, :relationships) - get("/domain_blocks", MastodonAPIController, :domain_blocks) + get("/accounts/:id/lists", AccountController, :lists) + get("/accounts/:id/identity_proofs", MastodonAPIController, :empty_array) - get("/filters", MastodonAPIController, :get_filters) + get("/follow_requests", FollowRequestController, :index) + get("/blocks", AccountController, :blocks) + get("/mutes", AccountController, :mutes) - get("/suggestions", MastodonAPIController, :suggestions) + get("/timelines/home", TimelineController, :home) + get("/timelines/direct", TimelineController, :direct) - get("/conversations", MastodonAPIController, :conversations) - post("/conversations/:id/read", MastodonAPIController, :conversation_read) + get("/favourites", StatusController, :favourites) + get("/bookmarks", StatusController, :bookmarks) - get("/endorsements", MastodonAPIController, :empty_array) - end + get("/notifications", NotificationController, :index) + get("/notifications/:id", NotificationController, :show) + post("/notifications/clear", NotificationController, :clear) + post("/notifications/dismiss", NotificationController, :dismiss) + delete("/notifications/destroy_multiple", NotificationController, :destroy_multiple) - scope [] do - pipe_through(:oauth_write) + get("/scheduled_statuses", ScheduledActivityController, :index) + get("/scheduled_statuses/:id", ScheduledActivityController, :show) - patch("/accounts/update_credentials", MastodonAPIController, :update_credentials) + get("/lists", ListController, :index) + get("/lists/:id", ListController, :show) + get("/lists/:id/accounts", ListController, :list_accounts) - post("/statuses", MastodonAPIController, :post_status) - delete("/statuses/:id", MastodonAPIController, :delete_status) + get("/domain_blocks", DomainBlockController, :index) - post("/statuses/:id/reblog", MastodonAPIController, :reblog_status) - post("/statuses/:id/unreblog", MastodonAPIController, :unreblog_status) - post("/statuses/:id/favourite", MastodonAPIController, :fav_status) - post("/statuses/:id/unfavourite", MastodonAPIController, :unfav_status) - post("/statuses/:id/pin", MastodonAPIController, :pin_status) - post("/statuses/:id/unpin", MastodonAPIController, :unpin_status) - post("/statuses/:id/bookmark", MastodonAPIController, :bookmark_status) - post("/statuses/:id/unbookmark", MastodonAPIController, :unbookmark_status) - post("/statuses/:id/mute", MastodonAPIController, :mute_conversation) - post("/statuses/:id/unmute", MastodonAPIController, :unmute_conversation) + get("/filters", FilterController, :index) - put("/scheduled_statuses/:id", MastodonAPIController, :update_scheduled_status) - delete("/scheduled_statuses/:id", MastodonAPIController, :delete_scheduled_status) + get("/suggestions", SuggestionController, :index) - post("/polls/:id/votes", MastodonAPIController, :poll_vote) + get("/conversations", ConversationController, :index) + post("/conversations/:id/read", ConversationController, :read) - post("/media", MastodonAPIController, :upload) - put("/media/:id", MastodonAPIController, :update_media) + get("/endorsements", AccountController, :endorsements) - delete("/lists/:id", ListController, :delete) - post("/lists", ListController, :create) - put("/lists/:id", ListController, :update) + patch("/accounts/update_credentials", AccountController, :update_credentials) - post("/lists/:id/accounts", ListController, :add_to_list) - delete("/lists/:id/accounts", ListController, :remove_from_list) + post("/statuses", StatusController, :create) + delete("/statuses/:id", StatusController, :delete) - post("/filters", MastodonAPIController, :create_filter) - get("/filters/:id", MastodonAPIController, :get_filter) - put("/filters/:id", MastodonAPIController, :update_filter) - delete("/filters/:id", MastodonAPIController, :delete_filter) + post("/statuses/:id/reblog", StatusController, :reblog) + post("/statuses/:id/unreblog", StatusController, :unreblog) + post("/statuses/:id/favourite", StatusController, :favourite) + post("/statuses/:id/unfavourite", StatusController, :unfavourite) + post("/statuses/:id/pin", StatusController, :pin) + post("/statuses/:id/unpin", StatusController, :unpin) + post("/statuses/:id/bookmark", StatusController, :bookmark) + post("/statuses/:id/unbookmark", StatusController, :unbookmark) + post("/statuses/:id/mute", StatusController, :mute_conversation) + post("/statuses/:id/unmute", StatusController, :unmute_conversation) - patch("/pleroma/accounts/update_avatar", MastodonAPIController, :update_avatar) - patch("/pleroma/accounts/update_banner", MastodonAPIController, :update_banner) - patch("/pleroma/accounts/update_background", MastodonAPIController, :update_background) + put("/scheduled_statuses/:id", ScheduledActivityController, :update) + delete("/scheduled_statuses/:id", ScheduledActivityController, :delete) - get("/pleroma/mascot", MastodonAPIController, :get_mascot) - put("/pleroma/mascot", MastodonAPIController, :set_mascot) + post("/polls/:id/votes", PollController, :vote) - post("/reports", MastodonAPIController, :reports) - end + post("/media", MediaController, :create) + put("/media/:id", MediaController, :update) - scope [] do - pipe_through(:oauth_follow) + delete("/lists/:id", ListController, :delete) + post("/lists", ListController, :create) + put("/lists/:id", ListController, :update) - post("/follows", MastodonAPIController, :follow) - post("/accounts/:id/follow", MastodonAPIController, :follow) + post("/lists/:id/accounts", ListController, :add_to_list) + delete("/lists/:id/accounts", ListController, :remove_from_list) - post("/accounts/:id/unfollow", MastodonAPIController, :unfollow) - post("/accounts/:id/block", MastodonAPIController, :block) - post("/accounts/:id/unblock", MastodonAPIController, :unblock) - post("/accounts/:id/mute", MastodonAPIController, :mute) - post("/accounts/:id/unmute", MastodonAPIController, :unmute) + post("/filters", FilterController, :create) + get("/filters/:id", FilterController, :show) + put("/filters/:id", FilterController, :update) + delete("/filters/:id", FilterController, :delete) - post("/follow_requests/:id/authorize", MastodonAPIController, :authorize_follow_request) - post("/follow_requests/:id/reject", MastodonAPIController, :reject_follow_request) + post("/reports", ReportController, :create) - post("/domain_blocks", MastodonAPIController, :block_domain) - delete("/domain_blocks", MastodonAPIController, :unblock_domain) + post("/follows", AccountController, :follows) + post("/accounts/:id/follow", AccountController, :follow) + post("/accounts/:id/unfollow", AccountController, :unfollow) + post("/accounts/:id/block", AccountController, :block) + post("/accounts/:id/unblock", AccountController, :unblock) + post("/accounts/:id/mute", AccountController, :mute) + post("/accounts/:id/unmute", AccountController, :unmute) - post("/pleroma/accounts/:id/subscribe", MastodonAPIController, :subscribe) - post("/pleroma/accounts/:id/unsubscribe", MastodonAPIController, :unsubscribe) - end + post("/follow_requests/:id/authorize", FollowRequestController, :authorize) + post("/follow_requests/:id/reject", FollowRequestController, :reject) - scope [] do - pipe_through(:oauth_push) + post("/domain_blocks", DomainBlockController, :create) + delete("/domain_blocks", DomainBlockController, :delete) - post("/push/subscription", SubscriptionController, :create) - get("/push/subscription", SubscriptionController, :get) - put("/push/subscription", SubscriptionController, :update) - delete("/push/subscription", SubscriptionController, :delete) - end + post("/push/subscription", SubscriptionController, :create) + get("/push/subscription", SubscriptionController, :get) + put("/push/subscription", SubscriptionController, :update) + delete("/push/subscription", SubscriptionController, :delete) end - scope "/api/web", Pleroma.Web.MastodonAPI do - pipe_through([:authenticated_api, :oauth_write]) + scope "/api/web", Pleroma.Web do + pipe_through(:authenticated_api) - put("/settings", MastodonAPIController, :put_settings) + put("/settings", MastoFEController, :put_settings) end scope "/api/v1", Pleroma.Web.MastodonAPI do pipe_through(:api) - post("/accounts", MastodonAPIController, :account_register) - - 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) + post("/accounts", AccountController, :create) + get("/accounts/search", SearchController, :account_search) - get("/statuses/:id/card", MastodonAPIController, :status_card) + get("/instance", InstanceController, :show) + get("/instance/peers", InstanceController, :peers) - get("/statuses/:id/favourited_by", MastodonAPIController, :favourited_by) - get("/statuses/:id/reblogged_by", MastodonAPIController, :reblogged_by) + post("/apps", AppController, :create) + get("/apps/verify_credentials", AppController, :verify_credentials) - get("/trends", MastodonAPIController, :empty_array) + get("/statuses/:id/card", StatusController, :card) + get("/statuses/:id/favourited_by", StatusController, :favourited_by) + get("/statuses/:id/reblogged_by", StatusController, :reblogged_by) - get("/accounts/search", SearchController, :account_search) + get("/custom_emojis", CustomEmojiController, :index) - post( - "/pleroma/accounts/confirmation_resend", - MastodonAPIController, - :account_confirmation_resend - ) - - scope [] do - pipe_through(:oauth_read_or_public) - - get("/timelines/public", MastodonAPIController, :public_timeline) - get("/timelines/tag/:tag", MastodonAPIController, :hashtag_timeline) - get("/timelines/list/:list_id", MastodonAPIController, :list_timeline) + get("/trends", MastodonAPIController, :empty_array) - get("/statuses/:id", MastodonAPIController, :get_status) - get("/statuses/:id/context", MastodonAPIController, :get_context) + get("/timelines/public", TimelineController, :public) + get("/timelines/tag/:tag", TimelineController, :hashtag) + get("/timelines/list/:list_id", TimelineController, :list) - get("/polls/:id", MastodonAPIController, :get_poll) + get("/statuses", StatusController, :index) + get("/statuses/:id", StatusController, :show) + get("/statuses/:id/context", StatusController, :context) - get("/accounts/:id/statuses", MastodonAPIController, :user_statuses) - get("/accounts/:id/followers", MastodonAPIController, :followers) - get("/accounts/:id/following", MastodonAPIController, :following) - get("/accounts/:id", MastodonAPIController, :user) + get("/polls/:id", PollController, :show) - get("/search", SearchController, :search) + get("/accounts/:id/statuses", AccountController, :statuses) + get("/accounts/:id/followers", AccountController, :followers) + get("/accounts/:id/following", AccountController, :following) + get("/accounts/:id", AccountController, :show) - get("/pleroma/accounts/:id/favourites", MastodonAPIController, :user_favourites) - end + get("/search", SearchController, :search) end scope "/api/v2", Pleroma.Web.MastodonAPI do - pipe_through([:api, :oauth_read_or_public]) + pipe_through(:api) get("/search", SearchController, :search2) end @@ -477,53 +458,12 @@ defmodule Pleroma.Web.Router do scope "/api", Pleroma.Web do pipe_through(:api) - post("/account/register", TwitterAPI.Controller, :register) - post("/account/password_reset", TwitterAPI.Controller, :password_reset) - - post("/account/resend_confirmation_email", TwitterAPI.Controller, :resend_confirmation_email) - get( "/account/confirm_email/:user_id/:token", TwitterAPI.Controller, :confirm_email, as: :confirm_email ) - - scope [] do - pipe_through(:oauth_read_or_public) - - get("/statuses/user_timeline", TwitterAPI.Controller, :user_timeline) - get("/qvitter/statuses/user_timeline", TwitterAPI.Controller, :user_timeline) - get("/users/show", TwitterAPI.Controller, :show_user) - - get("/statuses/followers", TwitterAPI.Controller, :followers) - get("/statuses/friends", TwitterAPI.Controller, :friends) - get("/statuses/blocks", TwitterAPI.Controller, :blocks) - get("/statuses/show/:id", TwitterAPI.Controller, :fetch_status) - get("/statusnet/conversation/:id", TwitterAPI.Controller, :fetch_conversation) - - get("/search", TwitterAPI.Controller, :search) - get("/statusnet/tags/timeline/:tag", TwitterAPI.Controller, :public_and_external_timeline) - end - end - - scope "/api", Pleroma.Web do - pipe_through([:api, :oauth_read_or_public]) - - get("/statuses/public_timeline", TwitterAPI.Controller, :public_timeline) - - get( - "/statuses/public_and_external_timeline", - TwitterAPI.Controller, - :public_and_external_timeline - ) - - get("/statuses/networkpublic_timeline", TwitterAPI.Controller, :public_and_external_timeline) - end - - scope "/api", Pleroma.Web, as: :twitter_api_search do - pipe_through([:api, :oauth_read_or_public]) - get("/pleroma/search_user", TwitterAPI.Controller, :search_user) end scope "/api", Pleroma.Web, as: :authenticated_twitter_api do @@ -532,70 +472,7 @@ defmodule Pleroma.Web.Router do get("/oauth_tokens", TwitterAPI.Controller, :oauth_tokens) delete("/oauth_tokens/:id", TwitterAPI.Controller, :revoke_token) - scope [] do - pipe_through(:oauth_read) - - get("/account/verify_credentials", TwitterAPI.Controller, :verify_credentials) - post("/account/verify_credentials", TwitterAPI.Controller, :verify_credentials) - - get("/statuses/home_timeline", TwitterAPI.Controller, :friends_timeline) - get("/statuses/friends_timeline", TwitterAPI.Controller, :friends_timeline) - get("/statuses/mentions", TwitterAPI.Controller, :mentions_timeline) - get("/statuses/mentions_timeline", TwitterAPI.Controller, :mentions_timeline) - get("/statuses/dm_timeline", TwitterAPI.Controller, :dm_timeline) - get("/qvitter/statuses/notifications", TwitterAPI.Controller, :notifications) - - get("/pleroma/friend_requests", TwitterAPI.Controller, :friend_requests) - - get("/friends/ids", TwitterAPI.Controller, :friends_ids) - get("/friendships/no_retweets/ids", TwitterAPI.Controller, :empty_array) - - get("/mutes/users/ids", TwitterAPI.Controller, :empty_array) - get("/qvitter/mutes", TwitterAPI.Controller, :raw_empty_array) - - get("/externalprofile/show", TwitterAPI.Controller, :external_profile) - - post("/qvitter/statuses/notifications/read", TwitterAPI.Controller, :notifications_read) - end - - scope [] do - pipe_through(:oauth_write) - - post("/account/update_profile", TwitterAPI.Controller, :update_profile) - post("/account/update_profile_banner", TwitterAPI.Controller, :update_banner) - post("/qvitter/update_background_image", TwitterAPI.Controller, :update_background) - - post("/statuses/update", TwitterAPI.Controller, :status_update) - post("/statuses/retweet/:id", TwitterAPI.Controller, :retweet) - post("/statuses/unretweet/:id", TwitterAPI.Controller, :unretweet) - post("/statuses/destroy/:id", TwitterAPI.Controller, :delete_post) - - post("/statuses/pin/:id", TwitterAPI.Controller, :pin) - post("/statuses/unpin/:id", TwitterAPI.Controller, :unpin) - - post("/statusnet/media/upload", TwitterAPI.Controller, :upload) - post("/media/upload", TwitterAPI.Controller, :upload_json) - post("/media/metadata/create", TwitterAPI.Controller, :update_media) - - post("/favorites/create/:id", TwitterAPI.Controller, :favorite) - post("/favorites/create", TwitterAPI.Controller, :favorite) - post("/favorites/destroy/:id", TwitterAPI.Controller, :unfavorite) - - post("/qvitter/update_avatar", TwitterAPI.Controller, :update_avatar) - end - - scope [] do - pipe_through(:oauth_follow) - - post("/pleroma/friendships/approve", TwitterAPI.Controller, :approve_friend_request) - post("/pleroma/friendships/deny", TwitterAPI.Controller, :deny_friend_request) - - post("/friendships/create", TwitterAPI.Controller, :follow) - post("/friendships/destroy", TwitterAPI.Controller, :unfollow) - - post("/blocks/create", TwitterAPI.Controller, :block) - post("/blocks/destroy", TwitterAPI.Controller, :unblock) - end + post("/qvitter/statuses/notifications/read", TwitterAPI.Controller, :notifications_read) end pipeline :ap_service_actor do @@ -612,13 +489,15 @@ defmodule Pleroma.Web.Router do scope "/", Pleroma.Web do pipe_through(:ostatus) + pipe_through(:http_signature) get("/objects/:uuid", OStatus.OStatusController, :object) get("/activities/:uuid", OStatus.OStatusController, :activity) get("/notice/:id", OStatus.OStatusController, :notice) get("/notice/:id/embed_player", OStatus.OStatusController, :notice_player) - get("/users/:nickname/feed", OStatus.OStatusController, :feed) - get("/users/:nickname", OStatus.OStatusController, :feed_redirect) + + get("/users/:nickname/feed", Feed.FeedController, :feed) + get("/users/:nickname", Feed.FeedController, :feed_redirect) post("/users/:nickname/salmon", OStatus.OStatusController, :salmon_incoming) post("/push/hub/:nickname", Websub.WebsubController, :websub_subscription_request) @@ -639,7 +518,6 @@ defmodule Pleroma.Web.Router do pipe_through(:ostatus) get("/users/:nickname/outbox", ActivityPubController, :outbox) - get("/objects/:uuid/likes", ActivityPubController, :object_likes) end pipeline :activitypub_client do @@ -659,22 +537,14 @@ defmodule Pleroma.Web.Router do scope "/", Pleroma.Web.ActivityPub do pipe_through([:activitypub_client]) - scope [] do - pipe_through(:oauth_read) - get("/api/ap/whoami", ActivityPubController, :whoami) - get("/users/:nickname/inbox", ActivityPubController, :read_inbox) - end + get("/api/ap/whoami", ActivityPubController, :whoami) + get("/users/:nickname/inbox", ActivityPubController, :read_inbox) - scope [] do - pipe_through(:oauth_write) - post("/users/:nickname/outbox", ActivityPubController, :update_outbox) - end + post("/users/:nickname/outbox", ActivityPubController, :update_outbox) + post("/api/ap/upload_media", ActivityPubController, :upload_media) - scope [] do - pipe_through(:oauth_read_or_public) - get("/users/:nickname/followers", ActivityPubController, :followers) - get("/users/:nickname/following", ActivityPubController, :following) - end + get("/users/:nickname/followers", ActivityPubController, :followers) + get("/users/:nickname/following", ActivityPubController, :following) end scope "/", Pleroma.Web.ActivityPub do @@ -716,18 +586,15 @@ 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) - end + get("/web/*path", MastoFEController, :index) end pipeline :remote_media do diff --git a/lib/pleroma/web/salmon/salmon.ex b/lib/pleroma/web/salmon/salmon.ex index 9b01ebcc6..0ffe903cd 100644 --- a/lib/pleroma/web/salmon/salmon.ex +++ b/lib/pleroma/web/salmon/salmon.ex @@ -170,6 +170,15 @@ defmodule Pleroma.Web.Salmon do end end + def publish_one(%{recipient_id: recipient_id} = params) do + recipient = User.get_cached_by_id(recipient_id) + + params + |> Map.delete(:recipient_id) + |> Map.put(:recipient, recipient) + |> publish_one() + end + def publish_one(_), do: :noop @supported_activities [ @@ -193,7 +202,7 @@ defmodule Pleroma.Web.Salmon do @spec publish(User.t(), Pleroma.Activity.t()) :: none def publish(user, activity) - def publish(%{info: %{keys: keys}} = user, %{data: %{"type" => type}} = activity) + def publish(%{keys: keys} = user, %{data: %{"type" => type}} = activity) when type in @supported_activities do feed = ActivityRepresenter.to_simple_form(activity, user, true) @@ -218,7 +227,7 @@ defmodule Pleroma.Web.Salmon do Logger.debug(fn -> "Sending Salmon to #{remote_user.ap_id}" end) Publisher.enqueue_one(__MODULE__, %{ - recipient: remote_user, + recipient_id: remote_user.id, feed: feed, unreachable_since: reachable_urls_metadata[remote_user.info.salmon] }) @@ -229,7 +238,7 @@ defmodule Pleroma.Web.Salmon do def publish(%{id: id}, _), do: Logger.debug(fn -> "Keys missing for user #{id}" end) def gather_webfinger_links(%User{} = user) do - {:ok, _private, public} = Keys.keys_from_pem(user.info.keys) + {:ok, _private, public} = Keys.keys_from_pem(user.keys) magic_key = encode_key(public) [ diff --git a/lib/pleroma/web/streamer.ex b/lib/pleroma/web/streamer.ex deleted file mode 100644 index 587c43f40..000000000 --- a/lib/pleroma/web/streamer.ex +++ /dev/null @@ -1,318 +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.Streamer do - use GenServer - require Logger - alias Pleroma.Activity - alias Pleroma.Config - alias Pleroma.Conversation.Participation - alias Pleroma.Notification - alias Pleroma.Object - alias Pleroma.User - alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.ActivityPub.Visibility - alias Pleroma.Web.CommonAPI - alias Pleroma.Web.MastodonAPI.NotificationView - - @keepalive_interval :timer.seconds(30) - - def start_link(_) do - GenServer.start_link(__MODULE__, %{}, name: __MODULE__) - end - - def add_socket(topic, socket) do - GenServer.cast(__MODULE__, %{action: :add, socket: socket, topic: topic}) - end - - def remove_socket(topic, socket) do - GenServer.cast(__MODULE__, %{action: :remove, socket: socket, topic: topic}) - end - - def stream(topic, item) do - GenServer.cast(__MODULE__, %{action: :stream, topic: topic, item: item}) - end - - def init(args) do - Process.send_after(self(), %{action: :ping}, @keepalive_interval) - - {:ok, args} - end - - def handle_info(%{action: :ping}, topics) do - topics - |> Map.values() - |> List.flatten() - |> Enum.each(fn socket -> - Logger.debug("Sending keepalive ping") - send(socket.transport_pid, {:text, ""}) - end) - - Process.send_after(self(), %{action: :ping}, @keepalive_interval) - - {:noreply, topics} - end - - def handle_cast(%{action: :stream, topic: "direct", item: item}, topics) do - recipient_topics = - User.get_recipients_from_activity(item) - |> Enum.map(fn %{id: id} -> "direct:#{id}" end) - - Enum.each(recipient_topics || [], fn user_topic -> - Logger.debug("Trying to push direct message to #{user_topic}\n\n") - push_to_socket(topics, user_topic, item) - end) - - {:noreply, topics} - end - - def handle_cast(%{action: :stream, topic: "participation", item: participation}, topics) do - user_topic = "direct:#{participation.user_id}" - Logger.debug("Trying to push a conversation participation to #{user_topic}\n\n") - - push_to_socket(topics, user_topic, participation) - - {:noreply, topics} - end - - def handle_cast(%{action: :stream, topic: "list", item: item}, topics) do - # filter the recipient list if the activity is not public, see #270. - recipient_lists = - case Visibility.is_public?(item) do - true -> - Pleroma.List.get_lists_from_activity(item) - - _ -> - Pleroma.List.get_lists_from_activity(item) - |> Enum.filter(fn list -> - owner = User.get_cached_by_id(list.user_id) - - Visibility.visible_for_user?(item, owner) - end) - end - - recipient_topics = - recipient_lists - |> Enum.map(fn %{id: id} -> "list:#{id}" end) - - Enum.each(recipient_topics || [], fn list_topic -> - Logger.debug("Trying to push message to #{list_topic}\n\n") - push_to_socket(topics, list_topic, item) - end) - - {:noreply, topics} - end - - def handle_cast( - %{action: :stream, topic: topic, item: %Notification{} = item}, - topics - ) - when topic in ["user", "user:notification"] do - topics - |> Map.get("#{topic}:#{item.user_id}", []) - |> Enum.each(fn socket -> - with %User{} = user <- User.get_cached_by_ap_id(socket.assigns[:user].ap_id), - true <- should_send?(user, item) do - send( - socket.transport_pid, - {:text, represent_notification(socket.assigns[:user], item)} - ) - end - end) - - {:noreply, topics} - end - - def handle_cast(%{action: :stream, topic: "user", item: item}, topics) do - Logger.debug("Trying to push to users") - - recipient_topics = - User.get_recipients_from_activity(item) - |> Enum.map(fn %{id: id} -> "user:#{id}" end) - - Enum.each(recipient_topics, fn topic -> - push_to_socket(topics, topic, item) - end) - - {:noreply, topics} - end - - def handle_cast(%{action: :stream, topic: topic, item: item}, topics) do - Logger.debug("Trying to push to #{topic}") - Logger.debug("Pushing item to #{topic}") - push_to_socket(topics, topic, item) - {:noreply, topics} - end - - def handle_cast(%{action: :add, topic: topic, socket: socket}, sockets) do - topic = internal_topic(topic, socket) - sockets_for_topic = sockets[topic] || [] - sockets_for_topic = Enum.uniq([socket | sockets_for_topic]) - sockets = Map.put(sockets, topic, sockets_for_topic) - Logger.debug("Got new conn for #{topic}") - {:noreply, sockets} - end - - def handle_cast(%{action: :remove, topic: topic, socket: socket}, sockets) do - topic = internal_topic(topic, socket) - sockets_for_topic = sockets[topic] || [] - sockets_for_topic = List.delete(sockets_for_topic, socket) - sockets = Map.put(sockets, topic, sockets_for_topic) - Logger.debug("Removed conn for #{topic}") - {:noreply, sockets} - end - - def handle_cast(m, state) do - Logger.info("Unknown: #{inspect(m)}, #{inspect(state)}") - {:noreply, state} - end - - defp represent_update(%Activity{} = activity, %User{} = user) do - %{ - event: "update", - payload: - Pleroma.Web.MastodonAPI.StatusView.render( - "status.json", - activity: activity, - for: user - ) - |> Jason.encode!() - } - |> Jason.encode!() - end - - defp represent_update(%Activity{} = activity) do - %{ - event: "update", - payload: - Pleroma.Web.MastodonAPI.StatusView.render( - "status.json", - activity: activity - ) - |> Jason.encode!() - } - |> Jason.encode!() - end - - def represent_conversation(%Participation{} = participation) do - %{ - event: "conversation", - payload: - Pleroma.Web.MastodonAPI.ConversationView.render("participation.json", %{ - participation: participation, - for: participation.user - }) - |> Jason.encode!() - } - |> Jason.encode!() - end - - @spec represent_notification(User.t(), Notification.t()) :: binary() - defp represent_notification(%User{} = user, %Notification{} = notify) do - %{ - event: "notification", - payload: - NotificationView.render( - "show.json", - %{notification: notify, for: user} - ) - |> Jason.encode!() - } - |> Jason.encode!() - end - - defp should_send?(%User{} = user, %Activity{} = item) do - blocks = user.info.blocks || [] - mutes = user.info.mutes || [] - reblog_mutes = user.info.muted_reblogs || [] - domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.info.domain_blocks) - - with parent when not is_nil(parent) <- Object.normalize(item), - true <- Enum.all?([blocks, mutes, reblog_mutes], &(item.actor not in &1)), - true <- Enum.all?([blocks, mutes], &(parent.data["actor"] not in &1)), - %{host: item_host} <- URI.parse(item.actor), - %{host: parent_host} <- URI.parse(parent.data["actor"]), - false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, item_host), - false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, parent_host), - true <- thread_containment(item, user), - false <- CommonAPI.thread_muted?(user, item) do - true - else - _ -> false - end - end - - defp should_send?(%User{} = user, %Notification{activity: activity}) do - should_send?(user, activity) - end - - def push_to_socket(topics, topic, %Activity{data: %{"type" => "Announce"}} = item) do - Enum.each(topics[topic] || [], fn socket -> - # Get the current user so we have up-to-date blocks etc. - if socket.assigns[:user] do - user = User.get_cached_by_ap_id(socket.assigns[:user].ap_id) - - if should_send?(user, item) do - send(socket.transport_pid, {:text, represent_update(item, user)}) - end - else - send(socket.transport_pid, {:text, represent_update(item)}) - end - end) - end - - def push_to_socket(topics, topic, %Participation{} = participation) do - Enum.each(topics[topic] || [], fn socket -> - send(socket.transport_pid, {:text, represent_conversation(participation)}) - end) - end - - def push_to_socket(topics, topic, %Activity{ - data: %{"type" => "Delete", "deleted_activity_id" => deleted_activity_id} - }) do - Enum.each(topics[topic] || [], fn socket -> - send( - socket.transport_pid, - {:text, %{event: "delete", payload: to_string(deleted_activity_id)} |> Jason.encode!()} - ) - end) - end - - def push_to_socket(_topics, _topic, %Activity{data: %{"type" => "Delete"}}), do: :noop - - def push_to_socket(topics, topic, item) do - Enum.each(topics[topic] || [], fn socket -> - # Get the current user so we have up-to-date blocks etc. - if socket.assigns[:user] do - user = User.get_cached_by_ap_id(socket.assigns[:user].ap_id) - blocks = user.info.blocks || [] - mutes = user.info.mutes || [] - - with true <- Enum.all?([blocks, mutes], &(item.actor not in &1)), - true <- thread_containment(item, user) do - send(socket.transport_pid, {:text, represent_update(item, user)}) - end - else - send(socket.transport_pid, {:text, represent_update(item)}) - end - end) - end - - defp internal_topic(topic, socket) when topic in ~w[user user:notification direct] do - "#{topic}:#{socket.assigns[:user].id}" - end - - defp internal_topic(topic, _), do: topic - - @spec thread_containment(Activity.t(), User.t()) :: boolean() - defp thread_containment(_activity, %User{info: %{skip_thread_containment: true}}), do: true - - defp thread_containment(activity, user) do - if Config.get([:instance, :skip_thread_containment]) do - true - else - ActivityPub.contain_activity(activity, user) - end - end -end diff --git a/lib/pleroma/web/streamer/ping.ex b/lib/pleroma/web/streamer/ping.ex new file mode 100644 index 000000000..db3e68abe --- /dev/null +++ b/lib/pleroma/web/streamer/ping.ex @@ -0,0 +1,37 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Streamer.Ping do + use GenServer + require Logger + + alias Pleroma.Web.Streamer.State + alias Pleroma.Web.Streamer.StreamerSocket + + @keepalive_interval :timer.seconds(30) + + def start_link(opts) do + ping_interval = Keyword.get(opts, :ping_interval, @keepalive_interval) + GenServer.start_link(__MODULE__, %{ping_interval: ping_interval}, name: __MODULE__) + end + + def init(%{ping_interval: ping_interval} = args) do + Process.send_after(self(), :ping, ping_interval) + {:ok, args} + end + + def handle_info(:ping, %{ping_interval: ping_interval} = state) do + State.get_sockets() + |> Map.values() + |> List.flatten() + |> Enum.each(fn %StreamerSocket{transport_pid: transport_pid} -> + Logger.debug("Sending keepalive ping") + send(transport_pid, {:text, ""}) + end) + + Process.send_after(self(), :ping, ping_interval) + + {:noreply, state} + end +end diff --git a/lib/pleroma/web/streamer/state.ex b/lib/pleroma/web/streamer/state.ex new file mode 100644 index 000000000..5ce3ebb8a --- /dev/null +++ b/lib/pleroma/web/streamer/state.ex @@ -0,0 +1,82 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Streamer.State do + use GenServer + require Logger + + alias Pleroma.Web.Streamer.StreamerSocket + + @env Mix.env() + + def start_link(_) do + GenServer.start_link(__MODULE__, %{sockets: %{}}, name: __MODULE__) + end + + def add_socket(topic, socket) do + GenServer.call(__MODULE__, {:add, topic, socket}) + end + + def remove_socket(topic, socket) do + do_remove_socket(@env, topic, socket) + end + + def get_sockets do + %{sockets: stream_sockets} = GenServer.call(__MODULE__, :get_state) + stream_sockets + end + + def init(init_arg) do + {:ok, init_arg} + end + + def handle_call(:get_state, _from, state) do + {:reply, state, state} + end + + def handle_call({:add, topic, socket}, _from, %{sockets: sockets} = state) do + internal_topic = internal_topic(topic, socket) + stream_socket = StreamerSocket.from_socket(socket) + + sockets_for_topic = + sockets + |> Map.get(internal_topic, []) + |> List.insert_at(0, stream_socket) + |> Enum.uniq() + + state = put_in(state, [:sockets, internal_topic], sockets_for_topic) + Logger.debug("Got new conn for #{topic}") + {:reply, state, state} + end + + def handle_call({:remove, topic, socket}, _from, %{sockets: sockets} = state) do + internal_topic = internal_topic(topic, socket) + stream_socket = StreamerSocket.from_socket(socket) + + sockets_for_topic = + sockets + |> Map.get(internal_topic, []) + |> List.delete(stream_socket) + + state = Kernel.put_in(state, [:sockets, internal_topic], sockets_for_topic) + {:reply, state, state} + end + + defp do_remove_socket(:test, _, _) do + :ok + end + + defp do_remove_socket(_env, topic, socket) do + GenServer.call(__MODULE__, {:remove, topic, socket}) + end + + defp internal_topic(topic, socket) + when topic in ~w[user user:notification direct] do + "#{topic}:#{socket.assigns[:user].id}" + end + + defp internal_topic(topic, _) do + topic + end +end diff --git a/lib/pleroma/web/streamer/streamer.ex b/lib/pleroma/web/streamer/streamer.ex new file mode 100644 index 000000000..8cf719277 --- /dev/null +++ b/lib/pleroma/web/streamer/streamer.ex @@ -0,0 +1,55 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Streamer do + alias Pleroma.Web.Streamer.State + alias Pleroma.Web.Streamer.Worker + + @timeout 60_000 + @mix_env Mix.env() + + def add_socket(topic, socket) do + State.add_socket(topic, socket) + end + + def remove_socket(topic, socket) do + State.remove_socket(topic, socket) + end + + def get_sockets do + State.get_sockets() + end + + def stream(topics, items) do + if should_send?() do + Task.async(fn -> + :poolboy.transaction( + :streamer_worker, + &Worker.stream(&1, topics, items), + @timeout + ) + end) + end + end + + def supervisor, do: Pleroma.Web.Streamer.Supervisor + + defp should_send? do + handle_should_send(@mix_env) + end + + defp handle_should_send(:test) do + case Process.whereis(:streamer_worker) do + nil -> + false + + pid -> + Process.alive?(pid) + end + end + + defp handle_should_send(_) do + true + end +end diff --git a/lib/pleroma/web/streamer/streamer_socket.ex b/lib/pleroma/web/streamer/streamer_socket.ex new file mode 100644 index 000000000..cf0fa3077 --- /dev/null +++ b/lib/pleroma/web/streamer/streamer_socket.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.Streamer.StreamerSocket do + defstruct transport_pid: nil, user: nil + + alias Pleroma.User + alias Pleroma.Web.Streamer.StreamerSocket + + def from_socket(%{ + transport_pid: transport_pid, + assigns: %{user: nil} + }) do + %StreamerSocket{ + transport_pid: transport_pid + } + end + + def from_socket(%{ + transport_pid: transport_pid, + assigns: %{user: %User{} = user} + }) do + %StreamerSocket{ + transport_pid: transport_pid, + user: user + } + end + + def from_socket(%{transport_pid: transport_pid}) do + %StreamerSocket{ + transport_pid: transport_pid + } + end +end diff --git a/lib/pleroma/web/streamer/supervisor.ex b/lib/pleroma/web/streamer/supervisor.ex new file mode 100644 index 000000000..ec5985085 --- /dev/null +++ b/lib/pleroma/web/streamer/supervisor.ex @@ -0,0 +1,37 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Streamer.Supervisor do + use Supervisor + + def start_link(opts) do + Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + end + + def init(args) do + children = [ + {Pleroma.Web.Streamer.State, args}, + {Pleroma.Web.Streamer.Ping, args}, + :poolboy.child_spec(:streamer_worker, poolboy_config()) + ] + + opts = [strategy: :one_for_one, name: Pleroma.Web.Streamer.Supervisor] + Supervisor.init(children, opts) + end + + defp poolboy_config do + opts = + Pleroma.Config.get(:streamer, + workers: 3, + overflow_workers: 2 + ) + + [ + {:name, {:local, :streamer_worker}}, + {:worker_module, Pleroma.Web.Streamer.Worker}, + {:size, opts[:workers]}, + {:max_overflow, opts[:overflow_workers]} + ] + end +end diff --git a/lib/pleroma/web/streamer/worker.ex b/lib/pleroma/web/streamer/worker.ex new file mode 100644 index 000000000..0ea224874 --- /dev/null +++ b/lib/pleroma/web/streamer/worker.ex @@ -0,0 +1,224 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Streamer.Worker do + use GenServer + + require Logger + + alias Pleroma.Activity + alias Pleroma.Config + alias Pleroma.Conversation.Participation + alias Pleroma.Notification + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Streamer.State + alias Pleroma.Web.Streamer.StreamerSocket + alias Pleroma.Web.StreamerView + + def start_link(_) do + GenServer.start_link(__MODULE__, %{}, []) + end + + def init(init_arg) do + {:ok, init_arg} + end + + def stream(pid, topics, items) do + GenServer.call(pid, {:stream, topics, items}) + end + + def handle_call({:stream, topics, item}, _from, state) when is_list(topics) do + Enum.each(topics, fn t -> + do_stream(%{topic: t, item: item}) + end) + + {:reply, state, state} + end + + def handle_call({:stream, topic, items}, _from, state) when is_list(items) do + Enum.each(items, fn i -> + do_stream(%{topic: topic, item: i}) + end) + + {:reply, state, state} + end + + def handle_call({:stream, topic, item}, _from, state) do + do_stream(%{topic: topic, item: item}) + + {:reply, state, state} + end + + defp do_stream(%{topic: "direct", item: item}) do + recipient_topics = + User.get_recipients_from_activity(item) + |> Enum.map(fn %{id: id} -> "direct:#{id}" end) + + Enum.each(recipient_topics, fn user_topic -> + Logger.debug("Trying to push direct message to #{user_topic}\n\n") + push_to_socket(State.get_sockets(), user_topic, item) + end) + end + + defp do_stream(%{topic: "participation", item: participation}) do + user_topic = "direct:#{participation.user_id}" + Logger.debug("Trying to push a conversation participation to #{user_topic}\n\n") + + push_to_socket(State.get_sockets(), user_topic, participation) + end + + defp do_stream(%{topic: "list", item: item}) do + # filter the recipient list if the activity is not public, see #270. + recipient_lists = + case Visibility.is_public?(item) do + true -> + Pleroma.List.get_lists_from_activity(item) + + _ -> + Pleroma.List.get_lists_from_activity(item) + |> Enum.filter(fn list -> + owner = User.get_cached_by_id(list.user_id) + + Visibility.visible_for_user?(item, owner) + end) + end + + recipient_topics = + recipient_lists + |> Enum.map(fn %{id: id} -> "list:#{id}" end) + + Enum.each(recipient_topics, fn list_topic -> + Logger.debug("Trying to push message to #{list_topic}\n\n") + push_to_socket(State.get_sockets(), list_topic, item) + end) + end + + defp do_stream(%{topic: topic, item: %Notification{} = item}) + when topic in ["user", "user:notification"] do + State.get_sockets() + |> Map.get("#{topic}:#{item.user_id}", []) + |> Enum.each(fn %StreamerSocket{transport_pid: transport_pid, user: socket_user} -> + with %User{} = user <- User.get_cached_by_ap_id(socket_user.ap_id), + true <- should_send?(user, item) do + send(transport_pid, {:text, StreamerView.render("notification.json", socket_user, item)}) + end + end) + end + + defp do_stream(%{topic: "user", item: item}) do + Logger.debug("Trying to push to users") + + recipient_topics = + User.get_recipients_from_activity(item) + |> Enum.map(fn %{id: id} -> "user:#{id}" end) + + Enum.each(recipient_topics, fn topic -> + push_to_socket(State.get_sockets(), topic, item) + end) + end + + defp do_stream(%{topic: topic, item: item}) do + Logger.debug("Trying to push to #{topic}") + Logger.debug("Pushing item to #{topic}") + push_to_socket(State.get_sockets(), topic, item) + end + + defp should_send?(%User{} = user, %Activity{} = item) do + blocks = user.info.blocks || [] + mutes = user.info.mutes || [] + reblog_mutes = user.info.muted_reblogs || [] + recipient_blocks = MapSet.new(blocks ++ mutes) + recipients = MapSet.new(item.recipients) + domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.info.domain_blocks) + + with parent when not is_nil(parent) <- Object.normalize(item), + true <- Enum.all?([blocks, mutes, reblog_mutes], &(item.actor not in &1)), + true <- Enum.all?([blocks, mutes], &(parent.data["actor"] not in &1)), + true <- MapSet.disjoint?(recipients, recipient_blocks), + %{host: item_host} <- URI.parse(item.actor), + %{host: parent_host} <- URI.parse(parent.data["actor"]), + false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, item_host), + false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, parent_host), + true <- thread_containment(item, user), + false <- CommonAPI.thread_muted?(user, item) do + true + else + _ -> false + end + end + + defp should_send?(%User{} = user, %Notification{activity: activity}) do + should_send?(user, activity) + end + + def push_to_socket(topics, topic, %Activity{data: %{"type" => "Announce"}} = item) do + Enum.each(topics[topic] || [], fn %StreamerSocket{ + transport_pid: transport_pid, + user: socket_user + } -> + # Get the current user so we have up-to-date blocks etc. + if socket_user do + user = User.get_cached_by_ap_id(socket_user.ap_id) + + if should_send?(user, item) do + send(transport_pid, {:text, StreamerView.render("update.json", item, user)}) + end + else + send(transport_pid, {:text, StreamerView.render("update.json", item)}) + end + end) + end + + def push_to_socket(topics, topic, %Participation{} = participation) do + Enum.each(topics[topic] || [], fn %StreamerSocket{transport_pid: transport_pid} -> + send(transport_pid, {:text, StreamerView.render("conversation.json", participation)}) + end) + end + + def push_to_socket(topics, topic, %Activity{ + data: %{"type" => "Delete", "deleted_activity_id" => deleted_activity_id} + }) do + Enum.each(topics[topic] || [], fn %StreamerSocket{transport_pid: transport_pid} -> + send( + transport_pid, + {:text, %{event: "delete", payload: to_string(deleted_activity_id)} |> Jason.encode!()} + ) + end) + end + + def push_to_socket(_topics, _topic, %Activity{data: %{"type" => "Delete"}}), do: :noop + + def push_to_socket(topics, topic, item) do + Enum.each(topics[topic] || [], fn %StreamerSocket{ + transport_pid: transport_pid, + user: socket_user + } -> + # Get the current user so we have up-to-date blocks etc. + if socket_user do + user = User.get_cached_by_ap_id(socket_user.ap_id) + + if should_send?(user, item) do + send(transport_pid, {:text, StreamerView.render("update.json", item, user)}) + end + else + send(transport_pid, {:text, StreamerView.render("update.json", item)}) + end + end) + end + + @spec thread_containment(Activity.t(), User.t()) :: boolean() + defp thread_containment(_activity, %User{info: %{skip_thread_containment: true}}), do: true + + defp thread_containment(activity, user) do + if Config.get([:instance, :skip_thread_containment]) do + true + else + ActivityPub.contain_activity(activity, user) + end + end +end diff --git a/lib/pleroma/web/templates/feed/feed/_activity.xml.eex b/lib/pleroma/web/templates/feed/feed/_activity.xml.eex new file mode 100644 index 000000000..d1f5e903c --- /dev/null +++ b/lib/pleroma/web/templates/feed/feed/_activity.xml.eex @@ -0,0 +1,48 @@ +<entry> + <activity:object-type>http://activitystrea.ms/schema/1.0/note</activity:object-type> + <activity:verb>http://activitystrea.ms/schema/1.0/post</activity:verb> + <id><%= @data["id"] %></id> + <title><%= "New note by #{@user.nickname}" %></title> + <content type="html"><%= activity_content(@activity) %></content> + <published><%= @data["published"] %></published> + <updated><%= @data["published"] %></updated> + <ostatus:conversation ref="<%= activity_context(@activity) %>"><%= activity_context(@activity) %></ostatus:conversation> + <link ref="<%= activity_context(@activity) %>" rel="ostatus:conversation"/> + + <%= if @data["summary"] do %> + <summary><%= @data["summary"] %></summary> + <% end %> + + <%= if @activity.local do %> + <link type="application/atom+xml" href='<%= @data["id"] %>' rel="self"/> + <link type="text/html" href='<%= @data["id"] %>' rel="alternate"/> + <% else %> + <link type="text/html" href='<%= @data["external_url"] %>' rel="alternate"/> + <% end %> + + <%= for tag <- @data["tag"] || [] do %> + <category term="<%= tag %>"></category> + <% end %> + + <%= for attachment <- @data["attachment"] || [] do %> + <link rel="enclosure" href="<%= attachment_href(attachment) %>" type="<%= attachment_type(attachment) %>"/> + <% end %> + + <%= if @data["inReplyTo"] do %> + <thr:in-reply-to ref='<%= @data["inReplyTo"] %>' href='<%= get_href(@data["inReplyTo"]) %>'/> + <% end %> + + <%= for id <- @activity.recipients do %> + <%= if id == Pleroma.Constants.as_public() do %> + <link rel="mentioned" ostatus:object-type="http://activitystrea.ms/schema/1.0/collection" href="http://activityschema.org/collection/public"/> + <% else %> + <%= unless Regex.match?(~r/^#{Pleroma.Web.base_url()}.+followers$/, id) do %> + <link rel="mentioned" ostatus:object-type="http://activitystrea.ms/schema/1.0/person" href="<%= id %>"/> + <% end %> + <% end %> + <% end %> + + <%= for {emoji, file} <- @data["emoji"] || %{} do %> + <link name="<%= emoji %>" rel="emoji" href="<%= file %>"/> + <% end %> +</entry> diff --git a/lib/pleroma/web/templates/feed/feed/_author.xml.eex b/lib/pleroma/web/templates/feed/feed/_author.xml.eex new file mode 100644 index 000000000..25cbffada --- /dev/null +++ b/lib/pleroma/web/templates/feed/feed/_author.xml.eex @@ -0,0 +1,17 @@ +<author> + <id><%= @user.ap_id %></id> + <activity:object>http://activitystrea.ms/schema/1.0/person</activity:object> + <uri><%= @user.ap_id %></uri> + <poco:preferredUsername><%= @user.nickname %></poco:preferredUsername> + <poco:displayName><%= @user.name %></poco:displayName> + <poco:note><%= escape(@user.bio) %></poco:note> + <summary><%= escape(@user.bio) %></summary> + <name><%= @user.nickname %></name> + <link rel="avatar" href="<%= User.avatar_url(@user) %>"/> + <%= if User.banner_url(@user) do %> + <link rel="header" href="<%= User.banner_url(@user) %>"/> + <% end %> + <%= if @user.local do %> + <ap_enabled>true</ap_enabled> + <% end %> +</author> diff --git a/lib/pleroma/web/templates/feed/feed/feed.xml.eex b/lib/pleroma/web/templates/feed/feed/feed.xml.eex new file mode 100644 index 000000000..fbfdc46b5 --- /dev/null +++ b/lib/pleroma/web/templates/feed/feed/feed.xml.eex @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<feed + xmlns="http://www.w3.org/2005/Atom" + xmlns:thr="http://purl.org/syndication/thread/1.0" + xmlns:activity="http://activitystrea.ms/spec/1.0/" + xmlns:poco="http://portablecontacts.net/spec/1.0" + xmlns:ostatus="http://ostatus.org/schema/1.0"> + + <id><%= feed_url(@conn, :feed, @user.nickname) <> ".atom" %></id> + <title><%= @user.nickname <> "'s timeline" %></title> + <updated><%= most_recent_update(@activities, @user) %></updated> + <logo><%= logo(@user) %></logo> + <link rel="hub" href="<%= websub_url(@conn, :websub_subscription_request, @user.nickname) %>"/> + <link rel="salmon" href="<%= o_status_url(@conn, :salmon_incoming, @user.nickname) %>"/> + <link rel="self" href="<%= '#{feed_url(@conn, :feed, @user.nickname)}.atom' %>" type="application/atom+xml"/> + + <%= render @view_module, "_author.xml", assigns %> + + <%= if last_activity(@activities) do %> + <link rel="next" href="<%= '#{feed_url(@conn, :feed, @user.nickname)}.atom?max_id=#{last_activity(@activities).id}' %>" type="application/atom+xml"/> + <% end %> + + <%= for activity <- @activities do %> + <%= render @view_module, "_activity.xml", Map.merge(assigns, %{activity: activity, data: activity_object_data(activity)}) %> + <% end %> +</feed> 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/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index 3405bd3b7..2305bb413 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -13,11 +13,34 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do alias Pleroma.Healthcheck alias Pleroma.Notification alias Pleroma.Plugs.AuthenticationPlug + alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User alias Pleroma.Web alias Pleroma.Web.CommonAPI alias Pleroma.Web.WebFinger + plug( + OAuthScopesPlug, + %{scopes: ["follow", "write:follows"]} + when action in [:do_remote_follow, :follow_import] + ) + + plug(OAuthScopesPlug, %{scopes: ["follow", "write:blocks"]} when action == :blocks_import) + + plug( + OAuthScopesPlug, + %{scopes: ["write:accounts"]} + when action in [ + :change_email, + :change_password, + :delete_account, + :update_notificaton_settings, + :disable_account + ] + ) + + plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :notifications_read) + plug(Pleroma.Plugs.SetFormatPlug when action in [:config, :version]) def help_test(conn, _params) do @@ -239,11 +262,9 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do def emoji(conn, _params) do emoji = - Emoji.get_all() - |> Enum.map(fn {short_code, path, tags} -> - {short_code, %{image_url: path, tags: tags}} + Enum.reduce(Emoji.get_all(), %{}, fn {code, %Emoji{file: file, tags: tags}}, acc -> + Map.put(acc, code, %{image_url: file, tags: tags}) end) - |> Enum.into(%{}) json(conn, emoji) end @@ -265,12 +286,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do String.split(line, ",") |> List.first() end) |> List.delete("Account address") do - PleromaJobQueue.enqueue(:background, User, [ - :follow_import, - follower, - followed_identifiers - ]) - + User.follow_import(follower, followed_identifiers) json(conn, "job started") end end @@ -281,12 +297,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do def blocks_import(%{assigns: %{user: blocker}} = conn, %{"list" => list}) do with blocked_identifiers <- String.split(list) do - PleromaJobQueue.enqueue(:background, User, [ - :blocks_import, - blocker, - blocked_identifiers - ]) - + User.blocks_import(blocker, blocked_identifiers) json(conn, "job started") end end @@ -314,6 +325,25 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do end end + def change_email(%{assigns: %{user: user}} = conn, params) do + case CommonAPI.Utils.confirm_current_password(user, params["password"]) do + {:ok, user} -> + with {:ok, _user} <- User.change_email(user, params["email"]) do + json(conn, %{status: "success"}) + else + {:error, changeset} -> + {_, {error, _}} = Enum.at(changeset.errors, 0) + json(conn, %{error: "Email #{error}."}) + + _ -> + json(conn, %{error: "Unable to change email."}) + end + + {:error, msg} -> + json(conn, %{error: msg}) + end + end + def delete_account(%{assigns: %{user: user}} = conn, params) do case CommonAPI.Utils.confirm_current_password(user, params["password"]) do {:ok, user} -> diff --git a/lib/pleroma/web/twitter_api/representers/base_representer.ex b/lib/pleroma/web/twitter_api/representers/base_representer.ex deleted file mode 100644 index 3d31e6079..000000000 --- a/lib/pleroma/web/twitter_api/representers/base_representer.ex +++ /dev/null @@ -1,38 +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.TwitterAPI.Representers.BaseRepresenter do - defmacro __using__(_opts) do - quote do - def to_json(object) do - to_json(object, %{}) - end - - def to_json(object, options) do - object - |> to_map(options) - |> Jason.encode!() - end - - def enum_to_list(enum, options) do - mapping = fn el -> to_map(el, options) end - Enum.map(enum, mapping) - end - - def to_map(object) do - to_map(object, %{}) - end - - def enum_to_json(enum) do - enum_to_json(enum, %{}) - end - - def enum_to_json(enum, options) do - enum - |> enum_to_list(options) - |> Jason.encode!() - end - end - end -end diff --git a/lib/pleroma/web/twitter_api/representers/object_representer.ex b/lib/pleroma/web/twitter_api/representers/object_representer.ex deleted file mode 100644 index 47130ba06..000000000 --- a/lib/pleroma/web/twitter_api/representers/object_representer.ex +++ /dev/null @@ -1,39 +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.TwitterAPI.Representers.ObjectRepresenter do - use Pleroma.Web.TwitterAPI.Representers.BaseRepresenter - alias Pleroma.Object - - def to_map(%Object{data: %{"url" => [url | _]}} = object, _opts) do - data = object.data - - %{ - url: url["href"] |> Pleroma.Web.MediaProxy.url(), - mimetype: url["mediaType"] || url["mimeType"], - id: data["uuid"], - oembed: false, - description: data["name"] - } - end - - def to_map(%Object{data: %{"url" => url} = data}, _opts) when is_binary(url) do - %{ - url: url |> Pleroma.Web.MediaProxy.url(), - mimetype: data["mediaType"] || data["mimeType"], - id: data["uuid"], - oembed: false, - description: data["name"] - } - end - - def to_map(%Object{}, _opts) do - %{} - end - - # If we only get the naked data, wrap in an object - def to_map(%{} = data, opts) do - to_map(%Object{data: data}, opts) - end -end diff --git a/lib/pleroma/web/twitter_api/twitter_api.ex b/lib/pleroma/web/twitter_api/twitter_api.ex index 80082ea84..bfd838902 100644 --- a/lib/pleroma/web/twitter_api/twitter_api.ex +++ b/lib/pleroma/web/twitter_api/twitter_api.ex @@ -3,133 +3,14 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.TwitterAPI.TwitterAPI do - alias Pleroma.Activity alias Pleroma.Emails.Mailer alias Pleroma.Emails.UserEmail alias Pleroma.Repo alias Pleroma.User alias Pleroma.UserInviteToken - alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.CommonAPI - alias Pleroma.Web.TwitterAPI.UserView - - import Ecto.Query require Pleroma.Constants - def create_status(%User{} = user, %{"status" => _} = data) do - CommonAPI.post(user, data) - end - - def delete(%User{} = user, id) do - with %Activity{data: %{"type" => _type}} <- Activity.get_by_id(id), - {:ok, activity} <- CommonAPI.delete(id, user) do - {:ok, activity} - end - end - - def follow(%User{} = follower, params) do - with {:ok, %User{} = followed} <- get_user(params) do - CommonAPI.follow(follower, followed) - end - end - - def unfollow(%User{} = follower, params) do - with {:ok, %User{} = unfollowed} <- get_user(params), - {:ok, follower} <- CommonAPI.unfollow(follower, unfollowed) do - {:ok, follower, unfollowed} - end - end - - def block(%User{} = blocker, params) do - with {:ok, %User{} = blocked} <- get_user(params), - {:ok, blocker} <- User.block(blocker, blocked), - {:ok, _activity} <- ActivityPub.block(blocker, blocked) do - {:ok, blocker, blocked} - else - err -> err - end - end - - def unblock(%User{} = blocker, params) do - with {:ok, %User{} = blocked} <- get_user(params), - {:ok, blocker} <- User.unblock(blocker, blocked), - {:ok, _activity} <- ActivityPub.unblock(blocker, blocked) do - {:ok, blocker, blocked} - else - err -> err - end - end - - def repeat(%User{} = user, ap_id_or_id) do - with {:ok, _announce, %{data: %{"id" => id}}} <- CommonAPI.repeat(ap_id_or_id, user), - %Activity{} = activity <- Activity.get_create_by_object_ap_id(id) do - {:ok, activity} - end - end - - def unrepeat(%User{} = user, 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(id) do - {:ok, activity} - end - end - - def pin(%User{} = user, ap_id_or_id) do - CommonAPI.pin(ap_id_or_id, user) - end - - def unpin(%User{} = user, ap_id_or_id) do - CommonAPI.unpin(ap_id_or_id, user) - end - - def fav(%User{} = user, 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 - {:ok, activity} - end - end - - def unfav(%User{} = user, ap_id_or_id) do - with {:ok, _unfav, _fav, %{data: %{"id" => id}}} <- CommonAPI.unfavorite(ap_id_or_id, user), - %Activity{} = activity <- Activity.get_create_by_object_ap_id(id) do - {:ok, activity} - end - end - - def upload(%Plug.Upload{} = file, %User{} = user, format \\ "xml") do - {:ok, object} = ActivityPub.upload(file, actor: User.ap_id(user)) - - url = List.first(object.data["url"]) - href = url["href"] - type = url["mediaType"] - - case format do - "xml" -> - # Fake this as good as possible... - """ - <?xml version="1.0" encoding="UTF-8"?> - <rsp stat="ok" xmlns:atom="http://www.w3.org/2005/Atom"> - <mediaid>#{object.id}</mediaid> - <media_id>#{object.id}</media_id> - <media_id_string>#{object.id}</media_id_string> - <media_url>#{href}</media_url> - <mediaurl>#{href}</mediaurl> - <atom:link rel="enclosure" href="#{href}" type="#{type}"></atom:link> - </rsp> - """ - - "json" -> - %{ - media_id: object.id, - media_id_string: "#{object.id}}", - media_url: href, - size: 0 - } - |> Jason.encode!() - end - end - def register_user(params, opts \\ []) do token = params["token"] @@ -148,7 +29,7 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do captcha_enabled = Pleroma.Config.get([Pleroma.Captcha, :enabled]) # true if captcha is disabled or enabled and valid, false otherwise captcha_ok = - if !captcha_enabled do + if not captcha_enabled do :ok else Pleroma.Captcha.validate( @@ -236,80 +117,4 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do {:error, "unknown user"} end end - - def get_user(user \\ nil, params) do - case params do - %{"user_id" => user_id} -> - case User.get_cached_by_nickname_or_id(user_id) do - nil -> - {:error, "No user with such user_id"} - - %User{info: %{deactivated: true}} -> - {:error, "User has been disabled"} - - user -> - {:ok, user} - end - - %{"screen_name" => nickname} -> - case User.get_cached_by_nickname(nickname) do - nil -> {:error, "No user with such screen_name"} - target -> {:ok, target} - end - - _ -> - if user do - {:ok, user} - else - {:error, "You need to specify screen_name or user_id"} - end - end - end - - defp parse_int(string, default) - - defp parse_int(string, default) when is_binary(string) do - with {n, _} <- Integer.parse(string) do - n - else - _e -> default - end - end - - defp parse_int(_, default), do: default - - # TODO: unify the search query with MastoAPI one and do only pagination here - def search(_user, %{"q" => query} = params) do - limit = parse_int(params["rpp"], 20) - page = parse_int(params["page"], 1) - offset = (page - 1) * limit - - q = - from( - [a, o] in Activity.with_preloaded_object(Activity), - where: fragment("?->>'type' = 'Create'", a.data), - where: ^Pleroma.Constants.as_public() in a.recipients, - where: - fragment( - "to_tsvector('english', ?->>'content') @@ plainto_tsquery('english', ?)", - o.data, - ^query - ), - limit: ^limit, - offset: ^offset, - # this one isn't indexed so psql won't take the wrong index. - order_by: [desc: :inserted_at] - ) - - _activities = Repo.all(q) - end - - def get_external_profile(for_user, uri) do - with {:ok, %User{} = user} <- User.get_or_fetch(uri) do - {:ok, UserView.render("show.json", %{user: user, for: for_user})} - else - _e -> - {:error, "Couldn't find user"} - end - end end diff --git a/lib/pleroma/web/twitter_api/twitter_api_controller.ex b/lib/pleroma/web/twitter_api/twitter_api_controller.ex index 5dfab6a6c..bf5a6ae42 100644 --- a/lib/pleroma/web/twitter_api/twitter_api_controller.ex +++ b/lib/pleroma/web/twitter_api/twitter_api_controller.ex @@ -5,599 +5,27 @@ defmodule Pleroma.Web.TwitterAPI.Controller do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, only: [json_response: 3] - - alias Ecto.Changeset - alias Pleroma.Activity - alias Pleroma.Formatter alias Pleroma.Notification - alias Pleroma.Object - alias Pleroma.Repo + alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User - alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.ActivityPub.Visibility - alias Pleroma.Web.CommonAPI - alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.OAuth.Token - alias Pleroma.Web.TwitterAPI.ActivityView - alias Pleroma.Web.TwitterAPI.NotificationView alias Pleroma.Web.TwitterAPI.TokenView - alias Pleroma.Web.TwitterAPI.TwitterAPI - alias Pleroma.Web.TwitterAPI.UserView require Logger - plug(Pleroma.Plugs.RateLimiter, :password_reset when action == :password_reset) - plug(:only_if_public_instance when action in [:public_timeline, :public_and_external_timeline]) - action_fallback(:errors) - - def verify_credentials(%{assigns: %{user: user}} = conn, _params) do - token = Phoenix.Token.sign(conn, "user socket", user.id) - - conn - |> put_view(UserView) - |> render("show.json", %{user: user, token: token, for: user}) - end - - def status_update(%{assigns: %{user: user}} = conn, %{"status" => _} = status_data) do - with media_ids <- extract_media_ids(status_data), - {:ok, activity} <- - TwitterAPI.create_status(user, Map.put(status_data, "media_ids", media_ids)) do - conn - |> json(ActivityView.render("activity.json", activity: activity, for: user)) - else - _ -> empty_status_reply(conn) - end - end - - def status_update(conn, _status_data) do - empty_status_reply(conn) - end - - defp empty_status_reply(conn) do - bad_request_reply(conn, "Client must provide a 'status' parameter with a value.") - end - - defp extract_media_ids(status_data) do - with media_ids when not is_nil(media_ids) <- status_data["media_ids"], - split_ids <- String.split(media_ids, ","), - clean_ids <- Enum.reject(split_ids, fn id -> String.length(id) == 0 end) do - clean_ids - else - _e -> [] - end - end - - def public_and_external_timeline(%{assigns: %{user: user}} = conn, params) do - params = - params - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("blocking_user", user) - - activities = ActivityPub.fetch_public_activities(params) - - conn - |> put_view(ActivityView) - |> render("index.json", %{activities: activities, for: user}) - end - - def public_timeline(%{assigns: %{user: user}} = conn, params) do - params = - params - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("local_only", true) - |> Map.put("blocking_user", user) - - activities = ActivityPub.fetch_public_activities(params) - - conn - |> put_view(ActivityView) - |> render("index.json", %{activities: activities, for: user}) - end - - def friends_timeline(%{assigns: %{user: user}} = conn, params) do - params = - params - |> Map.put("type", ["Create", "Announce", "Follow", "Like"]) - |> Map.put("blocking_user", user) - |> Map.put("user", user) - - activities = ActivityPub.fetch_activities([user.ap_id | user.following], params) - - conn - |> put_view(ActivityView) - |> render("index.json", %{activities: activities, for: user}) - end - - def show_user(conn, params) do - for_user = conn.assigns.user - - with {:ok, shown} <- TwitterAPI.get_user(params), - true <- - User.auth_active?(shown) || - (for_user && (for_user.id == shown.id || User.superuser?(for_user))) do - params = - if for_user do - %{user: shown, for: for_user} - else - %{user: shown} - end - - conn - |> put_view(UserView) - |> render("show.json", params) - else - {:error, msg} -> - bad_request_reply(conn, msg) - - false -> - conn - |> put_status(404) - |> json(%{error: "Unconfirmed user"}) - end - end - - def user_timeline(%{assigns: %{user: user}} = conn, params) do - case TwitterAPI.get_user(user, params) do - {:ok, target_user} -> - # Twitter and ActivityPub use a different name and sense for this parameter. - {include_rts, params} = Map.pop(params, "include_rts") - - params = - case include_rts do - x when x == "false" or x == "0" -> Map.put(params, "exclude_reblogs", "true") - _ -> params - end - - activities = ActivityPub.fetch_user_activities(target_user, user, params) - - conn - |> put_view(ActivityView) - |> render("index.json", %{activities: activities, for: user}) - - {:error, msg} -> - bad_request_reply(conn, msg) - end - end - - def mentions_timeline(%{assigns: %{user: user}} = conn, params) do - params = - params - |> Map.put("type", ["Create", "Announce", "Follow", "Like"]) - |> Map.put("blocking_user", user) - |> Map.put(:visibility, ~w[unlisted public private]) - - activities = ActivityPub.fetch_activities([user.ap_id], params) - - conn - |> put_view(ActivityView) - |> render("index.json", %{activities: activities, for: user}) - 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") - |> Map.put(:order, :desc) - - activities = - ActivityPub.fetch_activities_query([user.ap_id], params) - |> Repo.all() - - conn - |> put_view(ActivityView) - |> render("index.json", %{activities: activities, for: user}) - end - - def notifications(%{assigns: %{user: user}} = conn, params) do - params = - if Map.has_key?(params, "with_muted") do - Map.put(params, :with_muted, params["with_muted"] in [true, "True", "true", "1"]) - else - params - end - - notifications = Notification.for_user(user, params) - - conn - |> put_view(NotificationView) - |> render("notification.json", %{notifications: notifications, for: user}) - end - - def notifications_read(%{assigns: %{user: user}} = conn, %{"latest_id" => latest_id} = params) do - Notification.set_read_up_to(user, latest_id) + plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :notifications_read) - notifications = Notification.for_user(user, params) - - conn - |> put_view(NotificationView) - |> render("notification.json", %{notifications: notifications, for: user}) - end - - def notifications_read(%{assigns: %{user: _user}} = conn, _) do - bad_request_reply(conn, "You need to specify latest_id") - end - - def follow(%{assigns: %{user: user}} = conn, params) do - case TwitterAPI.follow(user, params) do - {:ok, user, followed, _activity} -> - conn - |> put_view(UserView) - |> render("show.json", %{user: followed, for: user}) - - {:error, msg} -> - forbidden_json_reply(conn, msg) - end - end - - def block(%{assigns: %{user: user}} = conn, params) do - case TwitterAPI.block(user, params) do - {:ok, user, blocked} -> - conn - |> put_view(UserView) - |> render("show.json", %{user: blocked, for: user}) - - {:error, msg} -> - forbidden_json_reply(conn, msg) - end - end - - def unblock(%{assigns: %{user: user}} = conn, params) do - case TwitterAPI.unblock(user, params) do - {:ok, user, blocked} -> - conn - |> put_view(UserView) - |> render("show.json", %{user: blocked, for: user}) - - {:error, msg} -> - forbidden_json_reply(conn, msg) - end - end - - def delete_post(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with {:ok, activity} <- TwitterAPI.delete(user, id) do - conn - |> put_view(ActivityView) - |> render("activity.json", %{activity: activity, for: user}) - end - end - - def unfollow(%{assigns: %{user: user}} = conn, params) do - case TwitterAPI.unfollow(user, params) do - {:ok, user, unfollowed} -> - conn - |> put_view(UserView) - |> render("show.json", %{user: unfollowed, for: user}) - - {:error, msg} -> - forbidden_json_reply(conn, msg) - end - end - - def fetch_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with %Activity{} = activity <- Activity.get_by_id(id), - true <- Visibility.visible_for_user?(activity, user) do - conn - |> put_view(ActivityView) - |> render("activity.json", %{activity: activity, for: user}) - end - end - - def fetch_conversation(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with context when is_binary(context) <- Utils.conversation_id_to_context(id), - activities <- - ActivityPub.fetch_activities_for_context(context, %{ - "blocking_user" => user, - "user" => user - }) do - conn - |> put_view(ActivityView) - |> render("index.json", %{activities: activities, for: user}) - end - end - - @doc """ - Updates metadata of uploaded media object. - Derived from [Twitter API endpoint](https://developer.twitter.com/en/docs/media/upload-media/api-reference/post-media-metadata-create). - """ - def update_media(%{assigns: %{user: user}} = conn, %{"media_id" => id} = data) do - object = Repo.get(Object, id) - description = get_in(data, ["alt_text", "text"]) || data["name"] || data["description"] - - {conn, status, response_body} = - cond do - !object -> - {halt(conn), :not_found, ""} - - !Object.authorize_mutation(object, user) -> - {halt(conn), :forbidden, "You can only update your own uploads."} - - !is_binary(description) -> - {conn, :not_modified, ""} - - true -> - new_data = Map.put(object.data, "name", description) - - {:ok, _} = - object - |> Object.change(%{data: new_data}) - |> Repo.update() - - {conn, :no_content, ""} - end - - conn - |> put_status(status) - |> json(response_body) - end - - def upload(%{assigns: %{user: user}} = conn, %{"media" => media}) do - response = TwitterAPI.upload(media, user) - - conn - |> put_resp_content_type("application/atom+xml") - |> send_resp(200, response) - end - - def upload_json(%{assigns: %{user: user}} = conn, %{"media" => media}) do - response = TwitterAPI.upload(media, user, "json") - - conn - |> json_reply(200, response) - end - - def get_by_id_or_ap_id(id) do - activity = Activity.get_by_id(id) || Activity.get_create_by_object_ap_id(id) - - if activity.data["type"] == "Create" do - activity - else - Activity.get_create_by_object_ap_id(activity.data["object"]) - end - end - - def favorite(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with {:ok, activity} <- TwitterAPI.fav(user, id) do - conn - |> put_view(ActivityView) - |> render("activity.json", %{activity: activity, for: user}) - else - _ -> json_reply(conn, 400, Jason.encode!(%{})) - end - end - - def unfavorite(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with {:ok, activity} <- TwitterAPI.unfav(user, id) do - conn - |> put_view(ActivityView) - |> render("activity.json", %{activity: activity, for: user}) - else - _ -> json_reply(conn, 400, Jason.encode!(%{})) - end - end - - def retweet(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with {:ok, activity} <- TwitterAPI.repeat(user, id) do - conn - |> put_view(ActivityView) - |> render("activity.json", %{activity: activity, for: user}) - else - _ -> json_reply(conn, 400, Jason.encode!(%{})) - end - end - - def unretweet(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with {:ok, activity} <- TwitterAPI.unrepeat(user, id) do - conn - |> put_view(ActivityView) - |> render("activity.json", %{activity: activity, for: user}) - else - _ -> json_reply(conn, 400, Jason.encode!(%{})) - end - end - - def pin(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with {:ok, activity} <- TwitterAPI.pin(user, id) do - conn - |> put_view(ActivityView) - |> render("activity.json", %{activity: activity, for: user}) - else - {:error, message} -> bad_request_reply(conn, message) - err -> err - end - end + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - def unpin(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with {:ok, activity} <- TwitterAPI.unpin(user, id) do - conn - |> put_view(ActivityView) - |> render("activity.json", %{activity: activity, for: user}) - else - {:error, message} -> bad_request_reply(conn, message) - err -> err - end - end - - def register(conn, params) do - with {:ok, user} <- TwitterAPI.register_user(params) do - conn - |> put_view(UserView) - |> render("show.json", %{user: user}) - else - {:error, errors} -> - conn - |> json_reply(400, Jason.encode!(errors)) - end - end - - def password_reset(conn, params) do - nickname_or_email = params["email"] || params["nickname"] - - with {:ok, _} <- TwitterAPI.password_reset(nickname_or_email) do - json_response(conn, :no_content, "") - else - {:error, "unknown user"} -> - send_resp(conn, :not_found, "") - - {:error, _} -> - send_resp(conn, :bad_request, "") - end - end + action_fallback(:errors) def confirm_email(conn, %{"user_id" => uid, "token" => token}) do - with %User{} = user <- User.get_cached_by_id(uid), - true <- user.local, - true <- user.info.confirmation_pending, - true <- user.info.confirmation_token == token, - info_change <- User.Info.confirmation_changeset(user.info, need_confirmation: false), - changeset <- Changeset.change(user) |> Changeset.put_embed(:info, info_change), - {:ok, _} <- User.update_and_set_cache(changeset) do - conn - |> redirect(to: "/") - end - end - - def resend_confirmation_email(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 - - 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) - - conn - |> put_view(UserView) - |> render("show.json", %{user: user, for: user}) - 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) - - conn - |> put_view(UserView) - |> render("show.json", %{user: user, for: user}) - 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 <- Ecto.Changeset.change(user) |> Ecto.Changeset.put_embed(:info, info_cng), - {:ok, user} <- User.update_and_set_cache(changeset) do - CommonAPI.update(user) - response = %{url: nil} |> Jason.encode!() + new_info = [need_confirmation: false] - conn - |> json_reply(200, response) - 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 <- Ecto.Changeset.change(user) |> Ecto.Changeset.put_embed(:info, info_cng), - {:ok, user} <- User.update_and_set_cache(changeset) do - CommonAPI.update(user) - %{"url" => [%{"href" => href} | _]} = object.data - response = %{url: href} |> Jason.encode!() - - conn - |> json_reply(200, response) - 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 <- Ecto.Changeset.change(user) |> Ecto.Changeset.put_embed(:info, info_cng), - {:ok, _user} <- User.update_and_set_cache(changeset) do - response = %{url: nil} |> Jason.encode!() - - conn - |> json_reply(200, response) - 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 <- Ecto.Changeset.change(user) |> Ecto.Changeset.put_embed(:info, info_cng), - {:ok, _user} <- User.update_and_set_cache(changeset) do - %{"url" => [%{"href" => href} | _]} = object.data - response = %{url: href} |> Jason.encode!() - - conn - |> json_reply(200, response) - end - end - - def external_profile(%{assigns: %{user: current_user}} = conn, %{"profileurl" => uri}) do - with {:ok, user_map} <- TwitterAPI.get_external_profile(current_user, uri), - response <- Jason.encode!(user_map) do - conn - |> json_reply(200, response) - else - _e -> - conn - |> put_status(404) - |> json(%{error: "Can't find user"}) - end - end - - def followers(%{assigns: %{user: for_user}} = conn, params) do - {:ok, page} = Ecto.Type.cast(:integer, params["page"] || 1) - - with {:ok, user} <- TwitterAPI.get_user(for_user, params), - {:ok, followers} <- User.get_followers(user, page) do - followers = - cond do - for_user && user.id == for_user.id -> followers - user.info.hide_followers -> [] - true -> followers - end - - conn - |> put_view(UserView) - |> render("index.json", %{users: followers, for: conn.assigns[:user]}) - else - _e -> bad_request_reply(conn, "Can't get followers") - end - end - - def friends(%{assigns: %{user: for_user}} = conn, params) do - {:ok, page} = Ecto.Type.cast(:integer, params["page"] || 1) - {:ok, export} = Ecto.Type.cast(:boolean, params["all"] || false) - - page = if export, do: nil, else: page - - with {:ok, user} <- TwitterAPI.get_user(conn.assigns[:user], params), - {:ok, friends} <- User.get_friends(user, page) do - friends = - cond do - for_user && user.id == for_user.id -> friends - user.info.hide_follows -> [] - true -> friends - end - - conn - |> put_view(UserView) - |> render("index.json", %{users: friends, for: conn.assigns[:user]}) - else - _e -> bad_request_reply(conn, "Can't get friends") + with %User{info: info} = user <- User.get_cached_by_id(uid), + true <- user.local and info.confirmation_pending and info.confirmation_token == token, + {:ok, _} <- User.update_info(user, &User.Info.confirmation_changeset(&1, new_info)) do + redirect(conn, to: "/") end end @@ -615,160 +43,16 @@ defmodule Pleroma.Web.TwitterAPI.Controller do json_reply(conn, 201, "") end - def blocks(%{assigns: %{user: user}} = conn, _params) do - with blocked_users <- User.blocked_users(user) do - conn - |> put_view(UserView) - |> render("index.json", %{users: blocked_users, for: user}) - end - end - - def friend_requests(conn, params) do - with {:ok, user} <- TwitterAPI.get_user(conn.assigns[:user], params), - {:ok, friend_requests} <- User.get_follow_requests(user) do - conn - |> put_view(UserView) - |> render("index.json", %{users: friend_requests, for: conn.assigns[:user]}) - else - _e -> bad_request_reply(conn, "Can't get friend requests") - end - end - - def approve_friend_request(conn, %{"user_id" => uid} = _params) do - with followed <- conn.assigns[:user], - %User{} = follower <- User.get_cached_by_id(uid), - {:ok, follower} <- CommonAPI.accept_follow_request(follower, followed) do - conn - |> put_view(UserView) - |> render("show.json", %{user: follower, for: followed}) - else - e -> bad_request_reply(conn, "Can't approve user: #{inspect(e)}") - end - end - - def deny_friend_request(conn, %{"user_id" => uid} = _params) do - with followed <- conn.assigns[:user], - %User{} = follower <- User.get_cached_by_id(uid), - {:ok, follower} <- CommonAPI.reject_follow_request(follower, followed) do - conn - |> put_view(UserView) - |> render("show.json", %{user: follower, for: followed}) - else - e -> bad_request_reply(conn, "Can't deny user: #{inspect(e)}") - end - end - - def friends_ids(%{assigns: %{user: user}} = conn, _params) do - with {:ok, friends} <- User.get_friends(user) do - ids = - friends - |> Enum.map(fn x -> x.id end) - |> Jason.encode!() - - json(conn, ids) - else - _e -> bad_request_reply(conn, "Can't get friends") - end - end - - def empty_array(conn, _params) do - json(conn, Jason.encode!([])) - end - - def raw_empty_array(conn, _params) do - json(conn, []) - end - - defp build_info_cng(user, params) do - info_params = - [ - "no_rich_text", - "locked", - "hide_followers", - "hide_follows", - "hide_favorites", - "show_role", - "skip_thread_containment" - ] - |> Enum.reduce(%{}, fn key, res -> - if value = params[key] do - Map.put(res, key, value == "true") - else - res - end - end) - - info_params = - if value = params["default_scope"] do - Map.put(info_params, "default_scope", value) - else - info_params - end - - User.Info.profile_update(user.info, info_params) - end - - defp parse_profile_bio(user, params) do - if bio = params["description"] do - emojis_text = (params["description"] || "") <> " " <> (params["name"] || "") - - emojis = - ((user.info.emoji || []) ++ Formatter.get_emoji_map(emojis_text)) - |> Enum.dedup() - - user_info = - user.info - |> Map.put( - "emoji", - emojis - ) - - params - |> Map.put("bio", User.parse_bio(bio, user)) - |> Map.put("info", user_info) - else - params - end - end - - def update_profile(%{assigns: %{user: user}} = conn, params) do - params = parse_profile_bio(user, params) - info_cng = build_info_cng(user, params) - - with changeset <- User.update_changeset(user, params), - changeset <- Ecto.Changeset.put_embed(changeset, :info, info_cng), - {:ok, user} <- User.update_and_set_cache(changeset) do - CommonAPI.update(user) - - conn - |> put_view(UserView) - |> render("user.json", %{user: user, for: user}) - else - error -> - Logger.debug("Can't update user: #{inspect(error)}") - bad_request_reply(conn, "Can't update user") - end - end - - def search(%{assigns: %{user: user}} = conn, %{"q" => _query} = params) do - activities = TwitterAPI.search(user, params) - + def errors(conn, {:param_cast, _}) do conn - |> put_view(ActivityView) - |> render("index.json", %{activities: activities, for: user}) + |> put_status(400) + |> json("Invalid parameters") end - def search_user(%{assigns: %{user: user}} = conn, %{"query" => query}) do - users = User.search(query, resolve: true, for_user: user) - + def errors(conn, _) do conn - |> put_view(UserView) - |> render("index.json", %{users: users, for: user}) - end - - defp bad_request_reply(conn, error_message) do - json = error_json(conn, error_message) - json_reply(conn, 400, json) + |> put_status(500) + |> json("Something went wrong") end defp json_reply(conn, status, json) do @@ -777,36 +61,27 @@ defmodule Pleroma.Web.TwitterAPI.Controller do |> send_resp(status, json) end - defp forbidden_json_reply(conn, error_message) do - json = error_json(conn, error_message) - json_reply(conn, 403, json) - end + def notifications_read(%{assigns: %{user: user}} = conn, %{"latest_id" => latest_id} = params) do + Notification.set_read_up_to(user, latest_id) - def only_if_public_instance(%{assigns: %{user: %User{}}} = conn, _), do: conn + notifications = Notification.for_user(user, params) - def only_if_public_instance(conn, _) do - if Pleroma.Config.get([:instance, :public]) do - conn - else - conn - |> forbidden_json_reply("Invalid credentials.") - |> halt() - end + conn + # XXX: This is a hack because pleroma-fe still uses that API. + |> put_view(Pleroma.Web.MastodonAPI.NotificationView) + |> render("index.json", %{notifications: notifications, for: user}) end - defp error_json(conn, error_message) do - %{"error" => error_message, "request" => conn.request_path} |> Jason.encode!() + def notifications_read(%{assigns: %{user: _user}} = conn, _) do + bad_request_reply(conn, "You need to specify latest_id") end - def errors(conn, {:param_cast, _}) do - conn - |> put_status(400) - |> json("Invalid parameters") + defp bad_request_reply(conn, error_message) do + json = error_json(conn, error_message) + json_reply(conn, 400, json) end - def errors(conn, _) do - conn - |> put_status(500) - |> json("Something went wrong") + defp error_json(conn, error_message) do + %{"error" => error_message, "request" => conn.request_path} |> Jason.encode!() end end diff --git a/lib/pleroma/web/twitter_api/views/activity_view.ex b/lib/pleroma/web/twitter_api/views/activity_view.ex deleted file mode 100644 index abae63877..000000000 --- a/lib/pleroma/web/twitter_api/views/activity_view.ex +++ /dev/null @@ -1,366 +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.TwitterAPI.ActivityView do - use Pleroma.Web, :view - alias Pleroma.Activity - alias Pleroma.Formatter - alias Pleroma.HTML - alias Pleroma.Object - alias Pleroma.Repo - alias Pleroma.User - alias Pleroma.Web.CommonAPI - alias Pleroma.Web.CommonAPI.Utils - alias Pleroma.Web.MastodonAPI.StatusView - alias Pleroma.Web.TwitterAPI.ActivityView - alias Pleroma.Web.TwitterAPI.Representers.ObjectRepresenter - alias Pleroma.Web.TwitterAPI.UserView - - import Ecto.Query - require Logger - require Pleroma.Constants - - defp query_context_ids([]), do: [] - - defp query_context_ids(contexts) do - query = from(o in Object, where: fragment("(?)->>'id' = ANY(?)", o.data, ^contexts)) - - Repo.all(query) - end - - defp query_users([]), do: [] - - defp query_users(user_ids) do - query = from(user in User, where: user.ap_id in ^user_ids) - - Repo.all(query) - end - - defp collect_context_ids(activities) do - _contexts = - activities - |> Enum.reject(& &1.data["context_id"]) - |> Enum.map(fn %{data: data} -> - data["context"] - end) - |> Enum.filter(& &1) - |> query_context_ids() - |> Enum.reduce(%{}, fn %{data: %{"id" => ap_id}, id: id}, acc -> - Map.put(acc, ap_id, id) - end) - end - - defp collect_users(activities) do - activities - |> Enum.map(fn activity -> - case activity.data do - data = %{"type" => "Follow"} -> - [data["actor"], data["object"]] - - data -> - [data["actor"]] - end ++ activity.recipients - end) - |> List.flatten() - |> Enum.uniq() - |> query_users() - |> Enum.reduce(%{}, fn user, acc -> - Map.put(acc, user.ap_id, user) - end) - end - - defp get_context_id(%{data: %{"context_id" => context_id}}, _) when not is_nil(context_id), - do: context_id - - defp get_context_id(%{data: %{"context" => nil}}, _), do: nil - - defp get_context_id(%{data: %{"context" => context}}, options) do - cond do - id = options[:context_ids][context] -> id - true -> Utils.context_to_conversation_id(context) - end - end - - defp get_context_id(_, _), do: nil - - defp get_user(ap_id, opts) do - cond do - user = opts[:users][ap_id] -> - user - - String.ends_with?(ap_id, "/followers") -> - nil - - ap_id == Pleroma.Constants.as_public() -> - nil - - user = User.get_cached_by_ap_id(ap_id) -> - user - - user = User.get_by_guessed_nickname(ap_id) -> - user - - true -> - User.error_user(ap_id) - end - end - - def render("index.json", opts) do - context_ids = collect_context_ids(opts.activities) - users = collect_users(opts.activities) - - opts = - opts - |> Map.put(:context_ids, context_ids) - |> Map.put(:users, users) - - safe_render_many( - opts.activities, - ActivityView, - "activity.json", - opts - ) - end - - def render("activity.json", %{activity: %{data: %{"type" => "Delete"}} = activity} = opts) do - user = get_user(activity.data["actor"], opts) - created_at = activity.data["published"] |> Utils.date_to_asctime() - - %{ - "id" => activity.id, - "uri" => activity.data["object"], - "user" => UserView.render("show.json", %{user: user, for: opts[:for]}), - "attentions" => [], - "statusnet_html" => "deleted notice {{tag", - "text" => "deleted notice {{tag", - "is_local" => activity.local, - "is_post_verb" => false, - "created_at" => created_at, - "in_reply_to_status_id" => nil, - "external_url" => activity.data["id"], - "activity_type" => "delete" - } - end - - def render("activity.json", %{activity: %{data: %{"type" => "Follow"}} = activity} = opts) do - user = get_user(activity.data["actor"], opts) - created_at = activity.data["published"] || DateTime.to_iso8601(activity.inserted_at) - created_at = created_at |> Utils.date_to_asctime() - - followed = get_user(activity.data["object"], opts) - text = "#{user.nickname} started following #{followed.nickname}" - - %{ - "id" => activity.id, - "user" => UserView.render("show.json", %{user: user, for: opts[:for]}), - "attentions" => [], - "statusnet_html" => text, - "text" => text, - "is_local" => activity.local, - "is_post_verb" => false, - "created_at" => created_at, - "in_reply_to_status_id" => nil, - "external_url" => activity.data["id"], - "activity_type" => "follow" - } - end - - def render("activity.json", %{activity: %{data: %{"type" => "Announce"}} = activity} = opts) do - user = get_user(activity.data["actor"], opts) - created_at = activity.data["published"] |> Utils.date_to_asctime() - announced_activity = Activity.get_create_by_object_ap_id(activity.data["object"]) - - text = "#{user.nickname} repeated a status." - - retweeted_status = render("activity.json", Map.merge(opts, %{activity: announced_activity})) - - %{ - "id" => activity.id, - "user" => UserView.render("show.json", %{user: user, for: opts[:for]}), - "statusnet_html" => text, - "text" => text, - "is_local" => activity.local, - "is_post_verb" => false, - "uri" => "tag:#{activity.data["id"]}:objectType=note", - "created_at" => created_at, - "retweeted_status" => retweeted_status, - "statusnet_conversation_id" => get_context_id(announced_activity, opts), - "external_url" => activity.data["id"], - "activity_type" => "repeat" - } - end - - def render("activity.json", %{activity: %{data: %{"type" => "Like"}} = activity} = opts) do - user = get_user(activity.data["actor"], opts) - liked_activity = Activity.get_create_by_object_ap_id(activity.data["object"]) - liked_activity_id = if liked_activity, do: liked_activity.id, else: nil - - created_at = - activity.data["published"] - |> Utils.date_to_asctime() - - text = "#{user.nickname} favorited a status." - - favorited_status = - if liked_activity, - do: render("activity.json", Map.merge(opts, %{activity: liked_activity})), - else: nil - - %{ - "id" => activity.id, - "user" => UserView.render("show.json", %{user: user, for: opts[:for]}), - "statusnet_html" => text, - "text" => text, - "is_local" => activity.local, - "is_post_verb" => false, - "uri" => "tag:#{activity.data["id"]}:objectType=Favourite", - "created_at" => created_at, - "favorited_status" => favorited_status, - "in_reply_to_status_id" => liked_activity_id, - "external_url" => activity.data["id"], - "activity_type" => "like" - } - end - - def render( - "activity.json", - %{activity: %{data: %{"type" => "Create", "object" => object_id}} = activity} = opts - ) do - user = get_user(activity.data["actor"], opts) - - object = Object.normalize(object_id) - - created_at = object.data["published"] |> Utils.date_to_asctime() - like_count = object.data["like_count"] || 0 - announcement_count = object.data["announcement_count"] || 0 - favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || []) - repeated = opts[:for] && opts[:for].ap_id in (object.data["announcements"] || []) - pinned = activity.id in user.info.pinned_activities - - attentions = - [] - |> Utils.maybe_notify_to_recipients(activity) - |> Utils.maybe_notify_mentioned_recipients(activity) - |> Enum.map(fn ap_id -> get_user(ap_id, opts) end) - |> Enum.filter(& &1) - |> Enum.map(fn user -> UserView.render("show.json", %{user: user, for: opts[:for]}) end) - - conversation_id = get_context_id(activity, opts) - - tags = object.data["tag"] || [] - possibly_sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw") - - tags = if possibly_sensitive, do: Enum.uniq(["nsfw" | tags]), else: tags - - {summary, content} = render_content(object.data) - - html = - content - |> HTML.get_cached_scrubbed_html_for_activity( - User.html_filter_policy(opts[:for]), - activity, - "twitterapi:content" - ) - |> Formatter.emojify(object.data["emoji"]) - - text = - if content do - content - |> String.replace(~r/<br\s?\/?>/, "\n") - |> HTML.get_cached_stripped_html_for_activity(activity, "twitterapi:content") - else - "" - end - - reply_parent = Activity.get_in_reply_to_activity(activity) - - reply_user = reply_parent && User.get_cached_by_ap_id(reply_parent.actor) - - summary = HTML.strip_tags(summary) - - card = - StatusView.render( - "card.json", - Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) - ) - - thread_muted? = - case activity.thread_muted? do - thread_muted? when is_boolean(thread_muted?) -> thread_muted? - nil -> CommonAPI.thread_muted?(user, activity) - end - - %{ - "id" => activity.id, - "uri" => object.data["id"], - "user" => UserView.render("show.json", %{user: user, for: opts[:for]}), - "statusnet_html" => html, - "text" => text, - "is_local" => activity.local, - "is_post_verb" => true, - "created_at" => created_at, - "in_reply_to_status_id" => reply_parent && reply_parent.id, - "in_reply_to_screen_name" => reply_user && reply_user.nickname, - "in_reply_to_profileurl" => User.profile_url(reply_user), - "in_reply_to_ostatus_uri" => reply_user && reply_user.ap_id, - "in_reply_to_user_id" => reply_user && reply_user.id, - "statusnet_conversation_id" => conversation_id, - "attachments" => (object.data["attachment"] || []) |> ObjectRepresenter.enum_to_list(opts), - "attentions" => attentions, - "fave_num" => like_count, - "repeat_num" => announcement_count, - "favorited" => !!favorited, - "repeated" => !!repeated, - "pinned" => pinned, - "external_url" => object.data["external_url"] || object.data["id"], - "tags" => tags, - "activity_type" => "post", - "possibly_sensitive" => possibly_sensitive, - "visibility" => Pleroma.Web.ActivityPub.Visibility.get_visibility(object), - "summary" => summary, - "summary_html" => summary |> Formatter.emojify(object.data["emoji"]), - "card" => card, - "muted" => thread_muted? || User.mutes?(opts[:for], user) - } - end - - def render("activity.json", %{activity: unhandled_activity}) do - Logger.warn("#{__MODULE__} unhandled activity: #{inspect(unhandled_activity)}") - nil - end - - def render_content(%{"type" => "Note"} = object) do - summary = object["summary"] - - content = - if !!summary and summary != "" do - "<p>#{summary}</p>#{object["content"]}" - else - object["content"] - end - - {summary, content} - end - - def render_content(%{"type" => object_type} = object) - when object_type in ["Article", "Page", "Video"] do - summary = object["name"] || object["summary"] - - content = - if !!summary and summary != "" and is_bitstring(object["url"]) do - "<p><a href=\"#{object["url"]}\">#{summary}</a></p>#{object["content"]}" - else - object["content"] - end - - {summary, content} - end - - def render_content(object) do - summary = object["summary"] || "Unhandled activity type: #{object["type"]}" - content = "<p>#{summary}</p>#{object["content"]}" - - {summary, content} - end -end diff --git a/lib/pleroma/web/twitter_api/views/notification_view.ex b/lib/pleroma/web/twitter_api/views/notification_view.ex deleted file mode 100644 index 085cd5aa3..000000000 --- a/lib/pleroma/web/twitter_api/views/notification_view.ex +++ /dev/null @@ -1,71 +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.TwitterAPI.NotificationView do - use Pleroma.Web, :view - alias Pleroma.Notification - alias Pleroma.User - alias Pleroma.Web.CommonAPI.Utils - alias Pleroma.Web.TwitterAPI.ActivityView - alias Pleroma.Web.TwitterAPI.UserView - - require Pleroma.Constants - - defp get_user(ap_id, opts) do - cond do - user = opts[:users][ap_id] -> - user - - String.ends_with?(ap_id, "/followers") -> - nil - - ap_id == Pleroma.Constants.as_public() -> - nil - - true -> - User.get_cached_by_ap_id(ap_id) - end - end - - def render("notification.json", %{notifications: notifications, for: user}) do - render_many( - notifications, - Pleroma.Web.TwitterAPI.NotificationView, - "notification.json", - for: user - ) - end - - def render( - "notification.json", - %{ - notification: %Notification{ - id: id, - seen: seen, - activity: activity, - inserted_at: created_at - }, - for: user - } = opts - ) do - ntype = - case activity.data["type"] do - "Create" -> "mention" - "Like" -> "like" - "Announce" -> "repeat" - "Follow" -> "follow" - end - - from = get_user(activity.data["actor"], opts) - - %{ - "id" => id, - "ntype" => ntype, - "notice" => ActivityView.render("activity.json", %{activity: activity, for: user}), - "from_profile" => UserView.render("show.json", %{user: from, for: user}), - "is_seen" => if(seen, do: 1, else: 0), - "created_at" => created_at |> Utils.format_naive_asctime() - } - end -end diff --git a/lib/pleroma/web/twitter_api/views/user_view.ex b/lib/pleroma/web/twitter_api/views/user_view.ex deleted file mode 100644 index 8a7d2fc72..000000000 --- a/lib/pleroma/web/twitter_api/views/user_view.ex +++ /dev/null @@ -1,191 +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.TwitterAPI.UserView do - use Pleroma.Web, :view - alias Pleroma.Formatter - alias Pleroma.HTML - alias Pleroma.User - alias Pleroma.Web.CommonAPI.Utils - alias Pleroma.Web.MediaProxy - - def render("show.json", %{user: user = %User{}} = assigns) do - render_one(user, Pleroma.Web.TwitterAPI.UserView, "user.json", assigns) - end - - def render("index.json", %{users: users, for: user}) do - users - |> render_many(Pleroma.Web.TwitterAPI.UserView, "user.json", for: user) - |> Enum.filter(&Enum.any?/1) - end - - def render("user.json", %{user: user = %User{}} = assigns) do - if User.visible_for?(user, assigns[:for]), - do: do_render("user.json", assigns), - else: %{} - end - - def render("short.json", %{ - user: %User{ - nickname: nickname, - id: id, - ap_id: ap_id, - name: name - } - }) do - %{ - "fullname" => name, - "id" => id, - "ostatus_uri" => ap_id, - "profile_url" => ap_id, - "screen_name" => nickname - } - end - - defp do_render("user.json", %{user: user = %User{}} = assigns) do - for_user = assigns[:for] - image = User.avatar_url(user) |> MediaProxy.url() - - {following, follows_you, statusnet_blocking} = - if for_user do - { - User.following?(for_user, user), - User.following?(user, for_user), - User.blocks?(for_user, user) - } - else - {false, false, false} - end - - user_info = User.get_cached_user_info(user) - - emoji = - (user.info.source_data["tag"] || []) - |> Enum.filter(fn %{"type" => t} -> t == "Emoji" end) - |> Enum.map(fn %{"icon" => %{"url" => url}, "name" => name} -> - {String.trim(name, ":"), url} - end) - - emoji = Enum.dedup(emoji ++ user.info.emoji) - - description_html = - (user.bio || "") - |> HTML.filter_tags(User.html_filter_policy(for_user)) - |> Formatter.emojify(emoji) - - fields = - user.info - |> User.Info.fields() - |> Enum.map(fn %{"name" => name, "value" => value} -> - %{ - "name" => Pleroma.HTML.strip_tags(name), - "value" => Pleroma.HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly) - } - end) - - data = - %{ - "created_at" => user.inserted_at |> Utils.format_naive_asctime(), - "description" => HTML.strip_tags((user.bio || "") |> String.replace("<br>", "\n")), - "description_html" => description_html, - "favourites_count" => 0, - "followers_count" => user_info[:follower_count], - "following" => following, - "follows_you" => follows_you, - "statusnet_blocking" => statusnet_blocking, - "friends_count" => user_info[:following_count], - "id" => user.id, - "name" => user.name || user.nickname, - "name_html" => - if(user.name, - do: HTML.strip_tags(user.name) |> Formatter.emojify(emoji), - else: user.nickname - ), - "profile_image_url" => image, - "profile_image_url_https" => image, - "profile_image_url_profile_size" => image, - "profile_image_url_original" => image, - "screen_name" => user.nickname, - "statuses_count" => user_info[:note_count], - "statusnet_profile_url" => user.ap_id, - "cover_photo" => User.banner_url(user) |> MediaProxy.url(), - "background_image" => image_url(user.info.background) |> MediaProxy.url(), - "is_local" => user.local, - "locked" => user.info.locked, - "hide_followers" => user.info.hide_followers, - "hide_follows" => user.info.hide_follows, - "fields" => fields, - - # Pleroma extension - "pleroma" => - %{ - "confirmation_pending" => user_info.confirmation_pending, - "tags" => user.tags, - "skip_thread_containment" => user.info.skip_thread_containment - } - |> maybe_with_activation_status(user, for_user) - |> with_notification_settings(user, for_user) - } - |> maybe_with_user_settings(user, for_user) - |> maybe_with_role(user, for_user) - - if assigns[:token] do - Map.put(data, "token", token_string(assigns[:token])) - else - data - end - end - - defp with_notification_settings(data, %User{id: user_id} = user, %User{id: user_id}) do - Map.put(data, "notification_settings", user.info.notification_settings) - end - - defp with_notification_settings(data, _, _), do: data - - defp maybe_with_activation_status(data, user, %User{info: %{is_admin: true}}) do - Map.put(data, "deactivated", user.info.deactivated) - end - - defp maybe_with_activation_status(data, _, _), do: data - - defp maybe_with_role(data, %User{id: id} = user, %User{id: id}) do - Map.merge(data, %{ - "role" => role(user), - "show_role" => user.info.show_role, - "rights" => %{ - "delete_others_notice" => !!user.info.is_moderator, - "admin" => !!user.info.is_admin - } - }) - end - - defp maybe_with_role(data, %User{info: %{show_role: true}} = user, _user) do - Map.merge(data, %{ - "role" => role(user), - "rights" => %{ - "delete_others_notice" => !!user.info.is_moderator, - "admin" => !!user.info.is_admin - } - }) - end - - defp maybe_with_role(data, _, _), do: data - - defp maybe_with_user_settings(data, %User{info: info, id: id} = _user, %User{id: id}) do - data - |> Kernel.put_in(["default_scope"], info.default_scope) - |> Kernel.put_in(["no_rich_text"], info.no_rich_text) - end - - defp maybe_with_user_settings(data, _, _), do: data - defp role(%User{info: %{:is_admin => true}}), do: "admin" - defp role(%User{info: %{:is_moderator => true}}), do: "moderator" - defp role(_), do: "member" - - defp image_url(%{"url" => [%{"href" => href} | _]}), do: href - defp image_url(_), do: nil - - defp token_string(%Pleroma.Web.OAuth.Token{token: token_str}), do: token_str - defp token_string(token), do: token -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 diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex new file mode 100644 index 000000000..a9f14d09a --- /dev/null +++ b/lib/pleroma/web/views/streamer_view.ex @@ -0,0 +1,66 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.StreamerView do + use Pleroma.Web, :view + + alias Pleroma.Activity + alias Pleroma.Conversation.Participation + alias Pleroma.Notification + alias Pleroma.User + alias Pleroma.Web.MastodonAPI.NotificationView + + def render("update.json", %Activity{} = activity, %User{} = user) do + %{ + event: "update", + payload: + Pleroma.Web.MastodonAPI.StatusView.render( + "show.json", + activity: activity, + for: user + ) + |> Jason.encode!() + } + |> Jason.encode!() + end + + def render("notification.json", %User{} = user, %Notification{} = notify) do + %{ + event: "notification", + payload: + NotificationView.render( + "show.json", + %{notification: notify, for: user} + ) + |> Jason.encode!() + } + |> Jason.encode!() + end + + def render("update.json", %Activity{} = activity) do + %{ + event: "update", + payload: + Pleroma.Web.MastodonAPI.StatusView.render( + "show.json", + activity: activity + ) + |> Jason.encode!() + } + |> Jason.encode!() + end + + def render("conversation.json", %Participation{} = participation) do + %{ + event: "conversation", + payload: + Pleroma.Web.MastodonAPI.ConversationView.render("participation.json", %{ + participation: participation, + for: participation.user + }) + |> Jason.encode!() + } + |> Jason.encode!() + end +end diff --git a/lib/pleroma/web/web.ex b/lib/pleroma/web/web.ex index bfb6c7287..687346554 100644 --- a/lib/pleroma/web/web.ex +++ b/lib/pleroma/web/web.ex @@ -66,23 +66,9 @@ defmodule Pleroma.Web do end @doc """ - Same as `render_many/4` but wrapped in rescue block and parallelized (unless disabled by passing false as a fifth argument). + Same as `render_many/4` but wrapped in rescue block. """ - def safe_render_many(collection, view, template, assigns \\ %{}, parallel \\ true) - - def safe_render_many(collection, view, template, assigns, true) do - Enum.map(collection, fn resource -> - Task.async(fn -> - as = Map.get(assigns, :as) || view.__resource__ - assigns = Map.put(assigns, as, resource) - safe_render(view, template, assigns) - end) - end) - |> Enum.map(&Task.await(&1, :infinity)) - |> Enum.filter(& &1) - end - - def safe_render_many(collection, view, template, assigns, false) do + def safe_render_many(collection, view, template, assigns \\ %{}) do Enum.map(collection, fn resource -> as = Map.get(assigns, :as) || view.__resource__ assigns = Map.put(assigns, as, resource) diff --git a/lib/pleroma/web/websub/websub_client_subscription.ex b/lib/pleroma/web/websub/websub_client_subscription.ex index 77703c496..23a04b87d 100644 --- a/lib/pleroma/web/websub/websub_client_subscription.ex +++ b/lib/pleroma/web/websub/websub_client_subscription.ex @@ -13,7 +13,7 @@ defmodule Pleroma.Web.Websub.WebsubClientSubscription do field(:state, :string) field(:subscribers, {:array, :string}, default: []) field(:hub, :string) - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) timestamps() end diff --git a/lib/pleroma/workers/activity_expiration_worker.ex b/lib/pleroma/workers/activity_expiration_worker.ex new file mode 100644 index 000000000..4e3e4195f --- /dev/null +++ b/lib/pleroma/workers/activity_expiration_worker.ex @@ -0,0 +1,18 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.ActivityExpirationWorker do + use Pleroma.Workers.WorkerHelper, queue: "activity_expiration" + + @impl Oban.Worker + def perform( + %{ + "op" => "activity_expiration", + "activity_expiration_id" => activity_expiration_id + }, + _job + ) do + Pleroma.Daemons.ActivityExpirationDaemon.perform(:execute, activity_expiration_id) + end +end diff --git a/lib/pleroma/workers/background_worker.ex b/lib/pleroma/workers/background_worker.ex new file mode 100644 index 000000000..7ffc8eabe --- /dev/null +++ b/lib/pleroma/workers/background_worker.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.Workers.BackgroundWorker do + alias Pleroma.Activity + alias Pleroma.User + alias Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy + alias Pleroma.Web.OAuth.Token.CleanWorker + + use Pleroma.Workers.WorkerHelper, queue: "background" + + @impl Oban.Worker + def perform(%{"op" => "fetch_initial_posts", "user_id" => user_id}, _job) do + user = User.get_cached_by_id(user_id) + User.perform(:fetch_initial_posts, user) + end + + def perform(%{"op" => "deactivate_user", "user_id" => user_id, "status" => status}, _job) do + user = User.get_cached_by_id(user_id) + User.perform(:deactivate_async, user, status) + end + + def perform(%{"op" => "delete_user", "user_id" => user_id}, _job) do + user = User.get_cached_by_id(user_id) + User.perform(:delete, user) + end + + def perform(%{"op" => "force_password_reset", "user_id" => user_id}, _job) do + user = User.get_cached_by_id(user_id) + User.perform(:force_password_reset, user) + end + + def perform( + %{ + "op" => "blocks_import", + "blocker_id" => blocker_id, + "blocked_identifiers" => blocked_identifiers + }, + _job + ) do + blocker = User.get_cached_by_id(blocker_id) + User.perform(:blocks_import, blocker, blocked_identifiers) + end + + def perform( + %{ + "op" => "follow_import", + "follower_id" => follower_id, + "followed_identifiers" => followed_identifiers + }, + _job + ) do + follower = User.get_cached_by_id(follower_id) + User.perform(:follow_import, follower, followed_identifiers) + end + + def perform(%{"op" => "clean_expired_tokens"}, _job) do + CleanWorker.perform(:clean) + end + + def perform(%{"op" => "media_proxy_preload", "message" => message}, _job) do + MediaProxyWarmingPolicy.perform(:preload, message) + end + + def perform(%{"op" => "media_proxy_prefetch", "url" => url}, _job) do + MediaProxyWarmingPolicy.perform(:prefetch, url) + end + + def perform(%{"op" => "fetch_data_for_activity", "activity_id" => activity_id}, _job) do + activity = Activity.get_by_id(activity_id) + Pleroma.Web.RichMedia.Helpers.perform(:fetch, activity) + end +end diff --git a/lib/pleroma/workers/digest_emails_worker.ex b/lib/pleroma/workers/digest_emails_worker.ex new file mode 100644 index 000000000..3e5a836d0 --- /dev/null +++ b/lib/pleroma/workers/digest_emails_worker.ex @@ -0,0 +1,16 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.DigestEmailsWorker do + alias Pleroma.User + + use Pleroma.Workers.WorkerHelper, queue: "digest_emails" + + @impl Oban.Worker + def perform(%{"op" => "digest_email", "user_id" => user_id}, _job) do + user_id + |> User.get_cached_by_id() + |> Pleroma.Daemons.DigestEmailDaemon.perform() + end +end diff --git a/lib/pleroma/workers/mailer_worker.ex b/lib/pleroma/workers/mailer_worker.ex new file mode 100644 index 000000000..1b7a0eb3e --- /dev/null +++ b/lib/pleroma/workers/mailer_worker.ex @@ -0,0 +1,15 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.MailerWorker do + use Pleroma.Workers.WorkerHelper, queue: "mailer" + + @impl Oban.Worker + def perform(%{"op" => "email", "encoded_email" => encoded_email, "config" => config}, _job) do + encoded_email + |> Base.decode64!() + |> :erlang.binary_to_term() + |> Pleroma.Emails.Mailer.deliver(config) + end +end diff --git a/lib/pleroma/workers/publisher_worker.ex b/lib/pleroma/workers/publisher_worker.ex new file mode 100644 index 000000000..455f7fc7e --- /dev/null +++ b/lib/pleroma/workers/publisher_worker.ex @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.PublisherWorker do + alias Pleroma.Activity + alias Pleroma.Web.Federator + + use Pleroma.Workers.WorkerHelper, queue: "federator_outgoing" + + def backoff(attempt) when is_integer(attempt) do + Pleroma.Workers.WorkerHelper.sidekiq_backoff(attempt, 5) + end + + @impl Oban.Worker + def perform(%{"op" => "publish", "activity_id" => activity_id}, _job) do + activity = Activity.get_by_id(activity_id) + Federator.perform(:publish, activity) + end + + def perform(%{"op" => "publish_one", "module" => module_name, "params" => params}, _job) do + params = Map.new(params, fn {k, v} -> {String.to_atom(k), v} end) + Federator.perform(:publish_one, String.to_atom(module_name), params) + end +end diff --git a/lib/pleroma/workers/receiver_worker.ex b/lib/pleroma/workers/receiver_worker.ex new file mode 100644 index 000000000..83d528a66 --- /dev/null +++ b/lib/pleroma/workers/receiver_worker.ex @@ -0,0 +1,18 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.ReceiverWorker do + alias Pleroma.Web.Federator + + use Pleroma.Workers.WorkerHelper, queue: "federator_incoming" + + @impl Oban.Worker + def perform(%{"op" => "incoming_doc", "body" => doc}, _job) do + Federator.perform(:incoming_doc, doc) + end + + def perform(%{"op" => "incoming_ap_doc", "params" => params}, _job) do + Federator.perform(:incoming_ap_doc, params) + end +end diff --git a/lib/pleroma/workers/scheduled_activity_worker.ex b/lib/pleroma/workers/scheduled_activity_worker.ex new file mode 100644 index 000000000..ca7d53af1 --- /dev/null +++ b/lib/pleroma/workers/scheduled_activity_worker.ex @@ -0,0 +1,12 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.ScheduledActivityWorker do + use Pleroma.Workers.WorkerHelper, queue: "scheduled_activities" + + @impl Oban.Worker + def perform(%{"op" => "execute", "activity_id" => activity_id}, _job) do + Pleroma.Daemons.ScheduledActivityDaemon.perform(:execute, activity_id) + end +end diff --git a/lib/pleroma/workers/subscriber_worker.ex b/lib/pleroma/workers/subscriber_worker.ex new file mode 100644 index 000000000..fc490e300 --- /dev/null +++ b/lib/pleroma/workers/subscriber_worker.ex @@ -0,0 +1,26 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.SubscriberWorker do + alias Pleroma.Repo + alias Pleroma.Web.Federator + alias Pleroma.Web.Websub + + use Pleroma.Workers.WorkerHelper, queue: "federator_outgoing" + + @impl Oban.Worker + def perform(%{"op" => "refresh_subscriptions"}, _job) do + Federator.perform(:refresh_subscriptions) + end + + def perform(%{"op" => "request_subscription", "websub_id" => websub_id}, _job) do + websub = Repo.get(Websub.WebsubClientSubscription, websub_id) + Federator.perform(:request_subscription, websub) + end + + def perform(%{"op" => "verify_websub", "websub_id" => websub_id}, _job) do + websub = Repo.get(Websub.WebsubServerSubscription, websub_id) + Federator.perform(:verify_websub, websub) + end +end diff --git a/lib/pleroma/workers/transmogrifier_worker.ex b/lib/pleroma/workers/transmogrifier_worker.ex new file mode 100644 index 000000000..b581a2f86 --- /dev/null +++ b/lib/pleroma/workers/transmogrifier_worker.ex @@ -0,0 +1,15 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.TransmogrifierWorker do + alias Pleroma.User + + use Pleroma.Workers.WorkerHelper, queue: "transmogrifier" + + @impl Oban.Worker + def perform(%{"op" => "user_upgrade", "user_id" => user_id}, _job) do + user = User.get_cached_by_id(user_id) + Pleroma.Web.ActivityPub.Transmogrifier.perform(:user_upgrade, user) + end +end diff --git a/lib/pleroma/workers/web_pusher_worker.ex b/lib/pleroma/workers/web_pusher_worker.ex new file mode 100644 index 000000000..61b451e3e --- /dev/null +++ b/lib/pleroma/workers/web_pusher_worker.ex @@ -0,0 +1,20 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.WebPusherWorker do + alias Pleroma.Notification + alias Pleroma.Repo + + use Pleroma.Workers.WorkerHelper, queue: "web_push" + + @impl Oban.Worker + def perform(%{"op" => "web_push", "notification_id" => notification_id}, _job) do + notification = + Notification + |> Repo.get(notification_id) + |> Repo.preload([:activity]) + + Pleroma.Web.Push.Impl.perform(notification) + end +end diff --git a/lib/pleroma/workers/worker_helper.ex b/lib/pleroma/workers/worker_helper.ex new file mode 100644 index 000000000..358efa14a --- /dev/null +++ b/lib/pleroma/workers/worker_helper.ex @@ -0,0 +1,46 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.WorkerHelper do + alias Pleroma.Config + alias Pleroma.Workers.WorkerHelper + + def worker_args(queue) do + case Config.get([:workers, :retries, queue]) do + nil -> [] + max_attempts -> [max_attempts: max_attempts] + end + end + + def sidekiq_backoff(attempt, pow \\ 4, base_backoff \\ 15) do + backoff = + :math.pow(attempt, pow) + + base_backoff + + :rand.uniform(2 * base_backoff) * attempt + + trunc(backoff) + end + + defmacro __using__(opts) do + caller_module = __CALLER__.module + queue = Keyword.fetch!(opts, :queue) + + quote do + # Note: `max_attempts` is intended to be overridden in `new/2` call + use Oban.Worker, + queue: unquote(queue), + max_attempts: 1 + + def enqueue(op, params, worker_args \\ []) do + params = Map.merge(%{"op" => op}, params) + queue_atom = String.to_atom(unquote(queue)) + worker_args = worker_args ++ WorkerHelper.worker_args(queue_atom) + + unquote(caller_module) + |> apply(:new, [params, worker_args]) + |> Pleroma.Repo.insert() + end + end + end +end |