From 241a3d744ae4e9d040247ad0aeb6287156acf920 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 11 Feb 2020 13:53:24 +0400 Subject: [PATCH 001/401] Add ActivityExpirationPolicy --- config/config.exs | 2 + lib/pleroma/web/activity_pub/mrf.ex | 7 +--- .../mrf/activity_expiration_policy.ex | 35 +++++++++++++++++ .../mrf/activity_expiration_policy_test.exs | 38 +++++++++++++++++++ 4 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex create mode 100644 test/web/activity_pub/mrf/activity_expiration_policy_test.exs diff --git a/config/config.exs b/config/config.exs index 41c1ff637..d5b298c16 100644 --- a/config/config.exs +++ b/config/config.exs @@ -361,6 +361,8 @@ config :pleroma, :mrf_subchain, match_actor: %{} +config :pleroma, :mrf_activity_expiration, days: 365 + config :pleroma, :mrf_vocabulary, accept: [], reject: [] diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index 263ed11af..b6e737de5 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -8,11 +8,8 @@ defmodule Pleroma.Web.ActivityPub.MRF do def filter(policies, %{} = object) do policies |> Enum.reduce({:ok, object}, fn - policy, {:ok, object} -> - policy.filter(object) - - _, error -> - error + policy, {:ok, object} -> policy.filter(object) + _, error -> error end) end diff --git a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex new file mode 100644 index 000000000..1b8860161 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex @@ -0,0 +1,35 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy do + @moduledoc "Adds expiration to all local activities" + @behaviour Pleroma.Web.ActivityPub.MRF + + @impl true + def filter(%{"id" => id} = activity) do + activity = + if String.starts_with?(id, Pleroma.Web.Endpoint.url()) do + maybe_add_expiration(activity) + else + activity + end + + {:ok, activity} + end + + @impl true + def describe, do: {:ok, %{}} + + defp maybe_add_expiration(activity) do + days = Pleroma.Config.get([:mrf_activity_expiration, :days], 365) + expires_at = NaiveDateTime.utc_now() |> Timex.shift(days: days) + + with %{"expires_at" => existing_expires_at} <- activity, + :lt <- NaiveDateTime.compare(existing_expires_at, expires_at) do + activity + else + _ -> Map.put(activity, "expires_at", expires_at) + end + end +end diff --git a/test/web/activity_pub/mrf/activity_expiration_policy_test.exs b/test/web/activity_pub/mrf/activity_expiration_policy_test.exs new file mode 100644 index 000000000..2e65048c0 --- /dev/null +++ b/test/web/activity_pub/mrf/activity_expiration_policy_test.exs @@ -0,0 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicyTest do + use ExUnit.Case, async: true + alias Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy + + @id Pleroma.Web.Endpoint.url() <> "/activities/cofe" + + test "adds `expires_at` property" do + assert {:ok, %{"expires_at" => expires_at}} = ActivityExpirationPolicy.filter(%{"id" => @id}) + + assert Timex.diff(expires_at, NaiveDateTime.utc_now(), :days) == 364 + end + + test "keeps existing `expires_at` if it less than the config setting" do + expires_at = NaiveDateTime.utc_now() |> Timex.shift(days: 1) + + assert {:ok, %{"expires_at" => ^expires_at}} = + ActivityExpirationPolicy.filter(%{"id" => @id, "expires_at" => expires_at}) + end + + test "owerwrites existing `expires_at` if it greater than the config setting" do + too_distant_future = NaiveDateTime.utc_now() |> Timex.shift(years: 2) + + assert {:ok, %{"expires_at" => expires_at}} = + ActivityExpirationPolicy.filter(%{"id" => @id, "expires_at" => too_distant_future}) + + assert Timex.diff(expires_at, NaiveDateTime.utc_now(), :days) == 364 + end + + test "ignores remote activities" do + assert {:ok, activity} = ActivityExpirationPolicy.filter(%{"id" => "https://example.com/123"}) + + refute Map.has_key?(activity, "expires_at") + end +end From 4d459b0e9906b2ebc0280b36c92007b2e680671f Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 12 Feb 2020 22:51:26 +0400 Subject: [PATCH 002/401] Move ActivityExpiration creation from CommonApi.post/2 to ActivityPub.insert/4 --- lib/pleroma/web/activity_pub/activity_pub.ex | 17 ++++++++++++++--- lib/pleroma/web/common_api/activity_draft.ex | 9 ++++++++- lib/pleroma/web/common_api/common_api.ex | 12 +----------- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 5c436941a..408f6c966 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1,10 +1,11 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ActivityPub do alias Pleroma.Activity alias Pleroma.Activity.Ir.Topics + alias Pleroma.ActivityExpiration alias Pleroma.Config alias Pleroma.Conversation alias Pleroma.Conversation.Participation @@ -135,12 +136,14 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when {:containment, :ok} <- {:containment, Containment.contain_child(map)}, {:ok, map, object} <- insert_full_object(map) do {:ok, activity} = - Repo.insert(%Activity{ + %Activity{ data: map, local: local, actor: map["actor"], recipients: recipients - }) + } + |> Repo.insert() + |> maybe_create_activity_expiration() # Splice in the child object if we have one. activity = @@ -180,6 +183,14 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when end end + defp maybe_create_activity_expiration({:ok, %{data: %{"expires_at" => expires_at}} = activity}) do + with {:ok, _} <- ActivityExpiration.create(activity, expires_at) do + {:ok, activity} + end + end + + defp maybe_create_activity_expiration(result), do: result + defp create_or_bump_conversation(activity, actor) do with {:ok, conversation} <- Conversation.create_or_bump_for(activity), %User{} = user <- User.get_cached_by_ap_id(actor), diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index f7da81b34..7a83cad9c 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -193,6 +193,13 @@ defp preview?(draft) do defp changes(draft) do direct? = draft.visibility == "direct" + additional = %{"cc" => draft.cc, "directMessage" => direct?} + + additional = + case draft.expires_at do + %NaiveDateTime{} = expires_at -> Map.put(additional, "expires_at", expires_at) + _ -> additional + end changes = %{ @@ -200,7 +207,7 @@ defp changes(draft) do actor: draft.user, context: draft.context, object: draft.object, - additional: %{"cc" => draft.cc, "directMessage" => direct?} + additional: additional } |> Utils.maybe_add_list_data(draft.user, draft.visibility) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 2a348dcf6..03921de27 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -277,20 +277,10 @@ def listen(user, %{"title" => _} = data) do 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) + ActivityPub.create(draft.changes, draft.preview?) end end - 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) From e2d358f1fb0babbdd2a318bad863e27afecbb3d1 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 14 Feb 2020 15:19:23 +0400 Subject: [PATCH 003/401] Fix typo --- test/web/activity_pub/mrf/activity_expiration_policy_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/activity_pub/mrf/activity_expiration_policy_test.exs b/test/web/activity_pub/mrf/activity_expiration_policy_test.exs index 2e65048c0..2f2f90b44 100644 --- a/test/web/activity_pub/mrf/activity_expiration_policy_test.exs +++ b/test/web/activity_pub/mrf/activity_expiration_policy_test.exs @@ -21,7 +21,7 @@ test "keeps existing `expires_at` if it less than the config setting" do ActivityExpirationPolicy.filter(%{"id" => @id, "expires_at" => expires_at}) end - test "owerwrites existing `expires_at` if it greater than the config setting" do + test "overwrites existing `expires_at` if it greater than the config setting" do too_distant_future = NaiveDateTime.utc_now() |> Timex.shift(years: 2) assert {:ok, %{"expires_at" => expires_at}} = From 57878f870879995f53227bb7a24b810531dd4217 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 14 Feb 2020 15:50:31 +0400 Subject: [PATCH 004/401] Improve readability --- .../web/activity_pub/mrf/activity_expiration_policy.ex | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex index 1b8860161..5d823f2c7 100644 --- a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex @@ -7,9 +7,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy do @behaviour Pleroma.Web.ActivityPub.MRF @impl true - def filter(%{"id" => id} = activity) do + def filter(activity) do activity = - if String.starts_with?(id, Pleroma.Web.Endpoint.url()) do + if local?(activity) do maybe_add_expiration(activity) else activity @@ -21,6 +21,10 @@ def filter(%{"id" => id} = activity) do @impl true def describe, do: {:ok, %{}} + defp local?(%{"id" => id}) do + String.starts_with?(id, Pleroma.Web.Endpoint.url()) + end + defp maybe_add_expiration(activity) do days = Pleroma.Config.get([:mrf_activity_expiration, :days], 365) expires_at = NaiveDateTime.utc_now() |> Timex.shift(days: days) From 3732b0ba729bb7443e338b5f6bcc7e018983aa4c Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 14 Feb 2020 16:39:02 +0400 Subject: [PATCH 005/401] Update CHANGELOG --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 150fd27cd..e4a641a7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Rate limiter is now disabled for localhost/socket (unless remoteip plug is enabled) - Logger: default log level changed from `warn` to `info`. - Config mix task `migrate_to_db` truncates `config` table before migrating the config file. +- MFR policy to set global expiration for every local activity +
API Changes From 0ddcd67d32eb40cb6cb2a3dfee4c55e930e7f37c Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 14 Feb 2020 16:53:53 +0400 Subject: [PATCH 006/401] Update `cheatsheet.md` and `config/description.exs` --- config/description.exs | 15 +++++++++++++++ docs/configuration/cheatsheet.md | 9 +++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/config/description.exs b/config/description.exs index e5bac9b3f..d86a4ccca 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1346,6 +1346,21 @@ } ] }, + %{ + group: :pleroma, + key: :mrf_activity_expiration, + label: "MRF Activity Expiration Policy", + type: :group, + description: "Adds expiration to all local activities", + children: [ + %{ + key: :days, + type: :integer, + description: "Default global expiration time for all local activities (in days)", + suggestions: [90, 365] + } + ] + }, %{ group: :pleroma, key: :mrf_subchain, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 2bd935983..bd03aec66 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -33,7 +33,7 @@ You shouldn't edit the base config directly to avoid breakages and merge conflic * `rewrite_policy`: Message Rewrite Policy, either one or a list. Here are the ones available by default: * `Pleroma.Web.ActivityPub.MRF.NoOpPolicy`: Doesn’t modify activities (default). * `Pleroma.Web.ActivityPub.MRF.DropPolicy`: Drops all activities. It generally doesn’t makes sense to use in production. - * `Pleroma.Web.ActivityPub.MRF.SimplePolicy`: Restrict the visibility of activities from certains instances (See [`:mrf_simple`](#mrf_simple)). + * `Pleroma.Web.ActivityPub.MRF.SimplePolicy`: Restrict the visibility of activities from certain instances (See [`:mrf_simple`](#mrf_simple)). * `Pleroma.Web.ActivityPub.MRF.TagPolicy`: Applies policies to individual users based on tags, which can be set using pleroma-fe/admin-fe/any other app that supports Pleroma Admin API. For example it allows marking posts from individual users nsfw (sensitive). * `Pleroma.Web.ActivityPub.MRF.SubchainPolicy`: Selectively runs other MRF policies when messages match (See [`:mrf_subchain`](#mrf_subchain)). * `Pleroma.Web.ActivityPub.MRF.RejectNonPublic`: Drops posts with non-public visibility settings (See [`:mrf_rejectnonpublic`](#mrf_rejectnonpublic)). @@ -43,7 +43,8 @@ You shouldn't edit the base config directly to avoid breakages and merge conflic * `Pleroma.Web.ActivityPub.MRF.MentionPolicy`: Drops posts mentioning configurable users. (See [`:mrf_mention`](#mrf_mention)). * `Pleroma.Web.ActivityPub.MRF.VocabularyPolicy`: Restricts activities to a configured set of vocabulary. (See [`:mrf_vocabulary`](#mrf_vocabulary)). * `Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy`: Rejects or delists posts based on their age when received. (See [`:mrf_object_age`](#mrf_object_age)). -* `public`: Makes the client API in authentificated mode-only except for user-profiles. Useful for disabling the Local Timeline and The Whole Known Network. + * `Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy`: Adds expiration to all local activities (see [`:mrf_activity_expiration`](#mrf_activity_expiration)). +* `public`: Makes the client API in authenticated mode-only except for user-profiles. Useful for disabling the Local Timeline and The Whole Known Network. * `quarantined_instances`: List of ActivityPub instances where private(DMs, followers-only) activities will not be send. * `managed_config`: Whenether the config for pleroma-fe is configured in [:frontend_configurations](#frontend_configurations) or in ``static/config.json``. * `allowed_post_formats`: MIME-type list of formats allowed to be posted (transformed into HTML). @@ -142,6 +143,10 @@ config :pleroma, :mrf_user_allowlist, * `:strip_followers` removes followers from the ActivityPub recipient list, ensuring they won't be delivered to home timelines * `:reject` rejects the message entirely +#### :mrf_activity_expiration + +* `days`: Default global expiration time for all local activities (in days) + ### :activitypub * ``unfollow_blocked``: Whether blocks result in people getting unfollowed * ``outgoing_blocks``: Whether to federate blocks to other instances From 819cd467170cb6dd1334cde0a0c79dbb785a22b6 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 20 Feb 2020 22:04:02 +0400 Subject: [PATCH 007/401] Auto-expire Create activities only --- .../mrf/activity_expiration_policy.ex | 2 +- test/web/activity_pub/activity_pub_test.exs | 16 +++++++++ .../mrf/activity_expiration_policy_test.exs | 35 +++++++++++++++---- .../purge_expired_activities_worker_test.exs | 30 ++++++++++++++++ 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex index 5d823f2c7..274bb9a5c 100644 --- a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy do @impl true def filter(activity) do activity = - if local?(activity) do + if activity["type"] == "Create" && local?(activity) do maybe_add_expiration(activity) else activity diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index ce68e7d0e..2cd908a87 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -1784,4 +1784,20 @@ test "old user must be in the new user's `also_known_as` list" do ActivityPub.move(old_user, new_user) end end + + describe "global activity expiration" do + clear_config([:instance, :rewrite_policy]) + + test "creates an activity expiration for local Create activities" do + Pleroma.Config.put( + [:instance, :rewrite_policy], + Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy + ) + + {:ok, %{id: id_create}} = ActivityBuilder.insert(%{"type" => "Create", "context" => "3hu"}) + {:ok, _follow} = ActivityBuilder.insert(%{"type" => "Follow", "context" => "3hu"}) + + assert [%{activity_id: ^id_create}] = Pleroma.ActivityExpiration |> Repo.all() + end + end end diff --git a/test/web/activity_pub/mrf/activity_expiration_policy_test.exs b/test/web/activity_pub/mrf/activity_expiration_policy_test.exs index 2f2f90b44..0d3bcc457 100644 --- a/test/web/activity_pub/mrf/activity_expiration_policy_test.exs +++ b/test/web/activity_pub/mrf/activity_expiration_policy_test.exs @@ -9,7 +9,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicyTest do @id Pleroma.Web.Endpoint.url() <> "/activities/cofe" test "adds `expires_at` property" do - assert {:ok, %{"expires_at" => expires_at}} = ActivityExpirationPolicy.filter(%{"id" => @id}) + assert {:ok, %{"type" => "Create", "expires_at" => expires_at}} = + ActivityExpirationPolicy.filter(%{"id" => @id, "type" => "Create"}) assert Timex.diff(expires_at, NaiveDateTime.utc_now(), :days) == 364 end @@ -17,21 +18,43 @@ test "adds `expires_at` property" do test "keeps existing `expires_at` if it less than the config setting" do expires_at = NaiveDateTime.utc_now() |> Timex.shift(days: 1) - assert {:ok, %{"expires_at" => ^expires_at}} = - ActivityExpirationPolicy.filter(%{"id" => @id, "expires_at" => expires_at}) + assert {:ok, %{"type" => "Create", "expires_at" => ^expires_at}} = + ActivityExpirationPolicy.filter(%{ + "id" => @id, + "type" => "Create", + "expires_at" => expires_at + }) end test "overwrites existing `expires_at` if it greater than the config setting" do too_distant_future = NaiveDateTime.utc_now() |> Timex.shift(years: 2) - assert {:ok, %{"expires_at" => expires_at}} = - ActivityExpirationPolicy.filter(%{"id" => @id, "expires_at" => too_distant_future}) + assert {:ok, %{"type" => "Create", "expires_at" => expires_at}} = + ActivityExpirationPolicy.filter(%{ + "id" => @id, + "type" => "Create", + "expires_at" => too_distant_future + }) assert Timex.diff(expires_at, NaiveDateTime.utc_now(), :days) == 364 end test "ignores remote activities" do - assert {:ok, activity} = ActivityExpirationPolicy.filter(%{"id" => "https://example.com/123"}) + assert {:ok, activity} = + ActivityExpirationPolicy.filter(%{ + "id" => "https://example.com/123", + "type" => "Create" + }) + + refute Map.has_key?(activity, "expires_at") + end + + test "ignores non-Create activities" do + assert {:ok, activity} = + ActivityExpirationPolicy.filter(%{ + "id" => "https://example.com/123", + "type" => "Follow" + }) refute Map.has_key?(activity, "expires_at") end diff --git a/test/workers/cron/purge_expired_activities_worker_test.exs b/test/workers/cron/purge_expired_activities_worker_test.exs index c2561683e..c6c7ff388 100644 --- a/test/workers/cron/purge_expired_activities_worker_test.exs +++ b/test/workers/cron/purge_expired_activities_worker_test.exs @@ -12,6 +12,7 @@ defmodule Pleroma.Workers.Cron.PurgeExpiredActivitiesWorkerTest do import ExUnit.CaptureLog clear_config([ActivityExpiration, :enabled]) + clear_config([:instance, :rewrite_policy]) test "deletes an expiration activity" do Pleroma.Config.put([ActivityExpiration, :enabled], true) @@ -36,6 +37,35 @@ test "deletes an expiration activity" do refute Pleroma.Repo.get(Pleroma.ActivityExpiration, expiration.id) end + test "works with ActivityExpirationPolicy" do + Pleroma.Config.put([ActivityExpiration, :enabled], true) + + Pleroma.Config.put( + [:instance, :rewrite_policy], + Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy + ) + + user = insert(:user) + + days = Pleroma.Config.get([:mrf_activity_expiration, :days], 365) + + {:ok, %{id: id} = activity} = Pleroma.Web.CommonAPI.post(user, %{"status" => "cofe"}) + + past_date = + NaiveDateTime.utc_now() |> Timex.shift(days: -days) |> NaiveDateTime.truncate(:second) + + activity + |> Repo.preload(:expiration) + |> Map.get(:expiration) + |> Ecto.Changeset.change(%{scheduled_at: past_date}) + |> Repo.update!() + + Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker.perform(:ops, :pid) + + assert [%{data: %{"type" => "Delete", "deleted_activity_id" => ^id}}] = + Pleroma.Repo.all(Pleroma.Activity) + end + describe "delete_activity/1" do test "adds log message if activity isn't find" do assert capture_log([level: :error], fn -> From 011ede45361096f55dda938078e24574cdf33b2b Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 21 Feb 2020 14:42:43 +0400 Subject: [PATCH 008/401] Update documentation --- CHANGELOG.md | 2 +- config/description.exs | 4 ++-- docs/configuration/cheatsheet.md | 4 ++-- .../web/activity_pub/mrf/activity_expiration_policy.ex | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4a641a7e..c5558e0c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Rate limiter is now disabled for localhost/socket (unless remoteip plug is enabled) - Logger: default log level changed from `warn` to `info`. - Config mix task `migrate_to_db` truncates `config` table before migrating the config file. -- MFR policy to set global expiration for every local activity +- MFR policy to set global expiration for all local Create activities
API Changes diff --git a/config/description.exs b/config/description.exs index d86a4ccca..f0c6e3377 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1351,12 +1351,12 @@ key: :mrf_activity_expiration, label: "MRF Activity Expiration Policy", type: :group, - description: "Adds expiration to all local activities", + description: "Adds expiration to all local Create activities", children: [ %{ key: :days, type: :integer, - description: "Default global expiration time for all local activities (in days)", + description: "Default global expiration time for all local Create activities (in days)", suggestions: [90, 365] } ] diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index bd03aec66..f50c8bab7 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -43,7 +43,7 @@ You shouldn't edit the base config directly to avoid breakages and merge conflic * `Pleroma.Web.ActivityPub.MRF.MentionPolicy`: Drops posts mentioning configurable users. (See [`:mrf_mention`](#mrf_mention)). * `Pleroma.Web.ActivityPub.MRF.VocabularyPolicy`: Restricts activities to a configured set of vocabulary. (See [`:mrf_vocabulary`](#mrf_vocabulary)). * `Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy`: Rejects or delists posts based on their age when received. (See [`:mrf_object_age`](#mrf_object_age)). - * `Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy`: Adds expiration to all local activities (see [`:mrf_activity_expiration`](#mrf_activity_expiration)). + * `Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy`: Adds expiration to all local Create activities (see [`:mrf_activity_expiration`](#mrf_activity_expiration)). * `public`: Makes the client API in authenticated mode-only except for user-profiles. Useful for disabling the Local Timeline and The Whole Known Network. * `quarantined_instances`: List of ActivityPub instances where private(DMs, followers-only) activities will not be send. * `managed_config`: Whenether the config for pleroma-fe is configured in [:frontend_configurations](#frontend_configurations) or in ``static/config.json``. @@ -145,7 +145,7 @@ config :pleroma, :mrf_user_allowlist, #### :mrf_activity_expiration -* `days`: Default global expiration time for all local activities (in days) +* `days`: Default global expiration time for all local Create activities (in days) ### :activitypub * ``unfollow_blocked``: Whether blocks result in people getting unfollowed diff --git a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex index 274bb9a5c..a9bdf3b69 100644 --- a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy do - @moduledoc "Adds expiration to all local activities" + @moduledoc "Adds expiration to all local Create activities" @behaviour Pleroma.Web.ActivityPub.MRF @impl true From cb8236cda62cddb72f4320af6347defae44b81ca Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 20 Mar 2020 21:19:34 +0400 Subject: [PATCH 009/401] Add embeddable posts --- lib/pleroma/web/embed_controller.ex | 42 +++++++++ lib/pleroma/web/endpoint.ex | 2 +- lib/pleroma/web/router.ex | 2 + .../web/templates/embed/_attachment.html.eex | 8 ++ lib/pleroma/web/templates/embed/show.html.eex | 76 ++++++++++++++++ .../web/templates/layout/embed.html.eex | 14 +++ lib/pleroma/web/views/embed_view.ex | 83 ++++++++++++++++++ priv/static/embed.css | Bin 0 -> 1408 bytes priv/static/embed.js | Bin 0 -> 942 bytes 9 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 lib/pleroma/web/embed_controller.ex create mode 100644 lib/pleroma/web/templates/embed/_attachment.html.eex create mode 100644 lib/pleroma/web/templates/embed/show.html.eex create mode 100644 lib/pleroma/web/templates/layout/embed.html.eex create mode 100644 lib/pleroma/web/views/embed_view.ex create mode 100644 priv/static/embed.css create mode 100644 priv/static/embed.js diff --git a/lib/pleroma/web/embed_controller.ex b/lib/pleroma/web/embed_controller.ex new file mode 100644 index 000000000..f6b8a5ee1 --- /dev/null +++ b/lib/pleroma/web/embed_controller.ex @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.EmbedController do + use Pleroma.Web, :controller + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.User + + alias Pleroma.Web.ActivityPub.Visibility + + plug(:put_layout, :embed) + + def show(conn, %{"id" => id}) do + with %Activity{local: true} = activity <- + Activity.get_by_id_with_object(id), + true <- Visibility.is_public?(activity.object) do + {:ok, author} = User.get_or_fetch(activity.object.data["actor"]) + + conn + |> delete_resp_header("x-frame-options") + |> delete_resp_header("content-security-policy") + |> render("show.html", + activity: activity, + author: User.sanitize_html(author), + counts: get_counts(activity) + ) + end + end + + defp get_counts(%Activity{} = activity) do + %Object{data: data} = Object.normalize(activity) + + %{ + likes: Map.get(data, "like_count", 0), + replies: Map.get(data, "repliesCount", 0), + announces: Map.get(data, "announcement_count", 0) + } + end +end diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index 72cb3ee27..4f665db12 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -35,7 +35,7 @@ defmodule Pleroma.Web.Endpoint do at: "/", from: :pleroma, only: - ~w(index.html robots.txt static finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc), + ~w(index.html robots.txt static finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc embed.js embed.css), # credo:disable-for-previous-line Credo.Check.Readability.MaxLineLength gzip: true, cache_control_for_etags: @static_cache_control, diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 3f36f6c1a..eef0a8023 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -637,6 +637,8 @@ defmodule Pleroma.Web.Router do post("/auth/password", MastodonAPI.AuthController, :password_reset) get("/web/*path", MastoFEController, :index) + + get("/embed/:id", EmbedController, :show) end pipeline :remote_media do diff --git a/lib/pleroma/web/templates/embed/_attachment.html.eex b/lib/pleroma/web/templates/embed/_attachment.html.eex new file mode 100644 index 000000000..7e04e9550 --- /dev/null +++ b/lib/pleroma/web/templates/embed/_attachment.html.eex @@ -0,0 +1,8 @@ +<%= case @mediaType do %> +<% "audio" -> %> + +<% "video" -> %> + +<% _ -> %> +<%= @name %> +<% end %> diff --git a/lib/pleroma/web/templates/embed/show.html.eex b/lib/pleroma/web/templates/embed/show.html.eex new file mode 100644 index 000000000..6bf8fac29 --- /dev/null +++ b/lib/pleroma/web/templates/embed/show.html.eex @@ -0,0 +1,76 @@ +
+ + +
+ <%= if status_title(@activity) != "" do %> +
open<% end %>> + <%= raw status_title(@activity) %> +
<%= activity_content(@activity) %>
+
+ <% else %> +
<%= activity_content(@activity) %>
+ <% end %> + <%= for %{"name" => name, "url" => [url | _]} <- attachments(@activity) do %> +
+ <%= if sensitive?(@activity) do %> +
+ <%= Gettext.gettext("sensitive media") %> +
+ <%= render("_attachment.html", %{name: name, url: url["href"], + mediaType: fetch_media_type(url)}) %> +
+
+ <% else %> + <%= render("_attachment.html", %{name: name, url: url["href"], + mediaType: fetch_media_type(url)}) %> + <% end %> +
+ <% end %> +
+ +
+
<%= Gettext.gettext("replies") %>
<%= @counts.replies %>
+
<%= Gettext.gettext("announces") %>
<%= @counts.announces %>
+
<%= Gettext.gettext("likes") %>
<%= @counts.likes %>
+
+ +

+ <%= link published(@activity), to: activity_url(@author, @activity) %> +

+
+ + diff --git a/lib/pleroma/web/templates/layout/embed.html.eex b/lib/pleroma/web/templates/layout/embed.html.eex new file mode 100644 index 000000000..57ae4f802 --- /dev/null +++ b/lib/pleroma/web/templates/layout/embed.html.eex @@ -0,0 +1,14 @@ + + + + + + <%= Pleroma.Config.get([:instance, :name]) %> + + <%= Phoenix.HTML.raw(assigns[:meta] || "") %> + + + + <%= render @view_module, @view_template, assigns %> + + diff --git a/lib/pleroma/web/views/embed_view.ex b/lib/pleroma/web/views/embed_view.ex new file mode 100644 index 000000000..77536835b --- /dev/null +++ b/lib/pleroma/web/views/embed_view.ex @@ -0,0 +1,83 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.EmbedView do + use Pleroma.Web, :view + + alias Calendar.Strftime + alias Pleroma.Activity + alias Pleroma.Emoji.Formatter + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.Gettext + alias Pleroma.Web.MediaProxy + alias Pleroma.Web.Metadata.Utils + alias Pleroma.Web.Router.Helpers + + use Phoenix.HTML + + @media_types ["image", "audio", "video"] + + defp emoji_for_user(%User{} = user) do + user.source_data + |> Map.get("tag", []) + |> Enum.filter(fn %{"type" => t} -> t == "Emoji" end) + |> Enum.map(fn %{"icon" => %{"url" => url}, "name" => name} -> + {String.trim(name, ":"), url} + end) + end + + defp fetch_media_type(%{"mediaType" => mediaType}) do + Utils.fetch_media_type(@media_types, mediaType) + end + + defp open_content? do + Pleroma.Config.get( + [:frontend_configurations, :collapse_message_with_subjects], + true + ) + end + + defp full_nickname(user) do + %{host: host} = URI.parse(user.ap_id) + "@" <> user.nickname <> "@" <> host + end + + defp status_title(%Activity{object: %Object{data: %{"name" => name}}}) when is_binary(name), + do: name + + defp status_title(%Activity{object: %Object{data: %{"summary" => summary}}}) + when is_binary(summary), + do: summary + + defp status_title(_), do: nil + + defp activity_content(%Activity{object: %Object{data: %{"content" => content}}}) do + content |> Pleroma.HTML.filter_tags() |> raw() + end + + defp activity_content(_), do: nil + + defp activity_url(%User{local: true}, activity) do + Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity) + end + + defp activity_url(%User{local: false}, %Activity{object: %Object{data: data}}) do + data["url"] || data["external_url"] || data["id"] + end + + defp attachments(%Activity{object: %Object{data: %{"attachment" => attachments}}}) do + attachments + end + + defp sensitive?(%Activity{object: %Object{data: %{"sensitive" => sensitive}}}) do + sensitive + end + + defp published(%Activity{object: %Object{data: %{"published" => published}}}) do + published + |> NaiveDateTime.from_iso8601!() + |> Strftime.strftime!("%B %d, %Y, %l:%M %p") + end +end diff --git a/priv/static/embed.css b/priv/static/embed.css new file mode 100644 index 0000000000000000000000000000000000000000..cc79ee7ab74981e6fe7047d5e80eda3b0e4e3880 GIT binary patch literal 1408 zcmb7E+iu%141Ld62pINKOm5P3Y2$tkZOJwlT?~oNWyAh`y18+XY(*bM9Z}>thit4k zv)?Pm8ff>uvy&0LwaU7heR+C)YQMj{h0D$w;vHyI=bCvio_p!Ai&q7F9FSx@Yj8c9 znyuqu1R>D$HQPwNIP=C5S)D+CR;vmQK;Tjt?c{v?e6(mty0_Kh9(A8Eow7hRQ?jF& zw6RV|#~lcqe9fN6)1?mXupa_81yib)@PKpRuCp(r>6O;(rLC>Jv*uODz zbsA3mh=PWYYQ6rNI}kvULHdm3iMWuhbwFqXQ^uOWT>LSk>cJjlX5$7MJ(UV>e7ZKCr!%Ba_<_D zNd{RWD}(L+Q;o$G^P~TfcyZ)7DV1kvX^u2hvyqg7(Pwh6X1~k;Ok@)js%8pPZ&ISh zo_H{2+6`rXLnw0jE(V(DbN;T$HOY4u*7Ii92obZ~-w8Q7Puy}nt+PYZupEnRf2`9E VyE{O0Gas{fx6thdwqh4)`3o~s!h!$* literal 0 HcmV?d00001 diff --git a/priv/static/embed.js b/priv/static/embed.js new file mode 100644 index 0000000000000000000000000000000000000000..f675f6417934e2c7cccb4012b499e16bcd8ef7fa GIT binary patch literal 942 zcmZ{j!EW0y42JJ{3ic#>Xaw}yB8OoMu)~TL=wXK-2i2lu0kV`ya?+snyN{IYxIi%< z44Wo@e!oap?ckbZyo0KS_Z5H`B0@~TG)b)J{iFf}RQduNSaPjb8g;1vFfCL&VO+wX zNbH2-7DVIwqs4?`FOAdq_S9C|H$#su$t?JiRKgl=HXB&q%~AkGx~i!+zzArGhr#%| z3Mj3&CsO)tVnTYr^ zidq1b-Aq`!Tw(NnX~TBX5L{-R3>N)t ztG@$=%L`g;k`LeMizqyjrpusf%%t__lPDPr=Ts4!;H@?8Kp?_-F=5YN)5W6nCk(Ci zXHMyi*68vYesU#`L+q-lMS(ACYBUvWiEqTKzTHLbh0VC z^ry`K=wo^;6R+kGGZByP9{w}C+sR%=*Y{xbH89fu@lBK!|1vgN2$ Dx91wg literal 0 HcmV?d00001 From fc2eb1fbd6a5b38a3cf72e557cce1029d6b7f16f Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 20 Mar 2020 22:16:57 +0400 Subject: [PATCH 010/401] Fix formatter warnings --- test/workers/cron/purge_expired_activities_worker_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/workers/cron/purge_expired_activities_worker_test.exs b/test/workers/cron/purge_expired_activities_worker_test.exs index 85ae1e5ef..beac55fb2 100644 --- a/test/workers/cron/purge_expired_activities_worker_test.exs +++ b/test/workers/cron/purge_expired_activities_worker_test.exs @@ -11,8 +11,8 @@ defmodule Pleroma.Workers.Cron.PurgeExpiredActivitiesWorkerTest do import Pleroma.Factory import ExUnit.CaptureLog - setup do - clear_config([ActivityExpiration, :enabled]) + setup do + clear_config([ActivityExpiration, :enabled]) clear_config([:instance, :rewrite_policy]) end From fd97b0e634d30dec3217efcf3d67610d1b54bf8b Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 9 Mar 2020 17:00:16 +0100 Subject: [PATCH 011/401] Chats: Basic implementation. --- lib/pleroma/chat.ex | 41 +++++++++++++++++ .../20200309123730_create_chats.exs | 16 +++++++ test/chat_test.exs | 44 +++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 lib/pleroma/chat.ex create mode 100644 priv/repo/migrations/20200309123730_create_chats.exs create mode 100644 test/chat_test.exs diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex new file mode 100644 index 000000000..e2a8b8eba --- /dev/null +++ b/lib/pleroma/chat.ex @@ -0,0 +1,41 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Chat do + use Ecto.Schema + import Ecto.Changeset + + alias Pleroma.User + alias Pleroma.Repo + + @moduledoc """ + Chat keeps a reference to DirectMessage conversations between a user and an recipient. The recipient can be a user (for now) or a group (not implemented yet). + + It is a helper only, to make it easy to display a list of chats with other people, ordered by last bump. The actual messages are retrieved by querying the recipients of the ChatMessages. + """ + + schema "chats" do + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + field(:recipient, :string) + field(:unread, :integer, default: 0) + + timestamps() + end + + def creation_cng(struct, params) do + struct + |> cast(params, [:user_id, :recipient]) + |> validate_required([:user_id, :recipient]) + |> unique_constraint(:user_id, name: :chats_user_id_recipient_index) + end + + def get_or_create(user_id, recipient) do + %__MODULE__{} + |> creation_cng(%{user_id: user_id, recipient: recipient}) + |> Repo.insert( + on_conflict: [set: [updated_at: NaiveDateTime.utc_now()]], + conflict_target: [:user_id, :recipient] + ) + end +end diff --git a/priv/repo/migrations/20200309123730_create_chats.exs b/priv/repo/migrations/20200309123730_create_chats.exs new file mode 100644 index 000000000..715d798ea --- /dev/null +++ b/priv/repo/migrations/20200309123730_create_chats.exs @@ -0,0 +1,16 @@ +defmodule Pleroma.Repo.Migrations.CreateChats do + use Ecto.Migration + + def change do + create table(:chats) do + add(:user_id, references(:users, type: :uuid)) + # Recipient is an ActivityPub id, to future-proof for group support. + add(:recipient, :string) + add(:unread, :integer, default: 0) + timestamps() + end + + # There's only one chat between a user and a recipient. + create(index(:chats, [:user_id, :recipient], unique: true)) + end +end diff --git a/test/chat_test.exs b/test/chat_test.exs new file mode 100644 index 000000000..ca9206802 --- /dev/null +++ b/test/chat_test.exs @@ -0,0 +1,44 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ChatTest do + use Pleroma.DataCase, async: true + + alias Pleroma.Chat + + import Pleroma.Factory + + describe "creation and getting" do + test "it creates a chat for a user and recipient" do + user = insert(:user) + other_user = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + + assert chat.id + end + + test "it returns a chat for a user and recipient if it already exists" do + user = insert(:user) + other_user = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + {:ok, chat_two} = Chat.get_or_create(user.id, other_user.ap_id) + + assert chat.id == chat_two.id + end + + test "a returning chat will have an updated `update_at` field" do + user = insert(:user) + other_user = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + :timer.sleep(1500) + {:ok, chat_two} = Chat.get_or_create(user.id, other_user.ap_id) + + assert chat.id == chat_two.id + assert chat.updated_at != chat_two.updated_at + end + end +end From 3775683a04e9b819f88bfba533b755bbd5b3c2df Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 8 Apr 2020 15:55:43 +0200 Subject: [PATCH 012/401] ChatMessage: Basic incoming handling. --- lib/pleroma/chat.ex | 2 +- lib/pleroma/web/activity_pub/activity_pub.ex | 1 + .../web/activity_pub/object_validator.ex | 30 +++++++++- .../chat_message_validator.ex | 58 +++++++++++++++++++ .../create_chat_message_validator.ex | 35 +++++++++++ ..._validator.ex => create_note_validator.ex} | 0 .../object_validators/types/recipients.ex | 23 ++++++++ .../web/activity_pub/transmogrifier.ex | 7 +++ .../transmogrifier/chat_message_handling.ex | 30 ++++++++++ test/fixtures/create-chat-message.json | 19 ++++++ .../types/recipients_test.exs | 15 +++++ .../transmogrifier/chat_message_test.exs | 32 ++++++++++ 12 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex rename lib/pleroma/web/activity_pub/object_validators/{create_validator.ex => create_note_validator.ex} (100%) create mode 100644 lib/pleroma/web/activity_pub/object_validators/types/recipients.ex create mode 100644 lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex create mode 100644 test/fixtures/create-chat-message.json create mode 100644 test/web/activity_pub/object_validators/types/recipients_test.exs create mode 100644 test/web/activity_pub/transmogrifier/chat_message_test.exs diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index e2a8b8eba..07ad62b97 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Chat do alias Pleroma.Repo @moduledoc """ - Chat keeps a reference to DirectMessage conversations between a user and an recipient. The recipient can be a user (for now) or a group (not implemented yet). + Chat keeps a reference to ChatMessage conversations between a user and an recipient. The recipient can be a user (for now) or a group (not implemented yet). It is a helper only, to make it easy to display a list of chats with other people, ordered by last bump. The actual messages are retrieved by querying the recipients of the ChatMessages. """ diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 19286fd01..0b4892501 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -397,6 +397,7 @@ defp do_unreact_with_emoji(user, reaction_id, options) do end end + # TODO: Is this even used now? # TODO: This is weird, maybe we shouldn't check here if we can make the activity. @spec like(User.t(), Object.t(), String.t() | nil, boolean()) :: {:ok, Activity.t(), Object.t()} | {:error, any()} diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index dc4bce059..49cc72561 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -12,18 +12,46 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) def validate(%{"type" => "Like"} = object, meta) do with {:ok, object} <- - object |> LikeValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do + object + |> LikeValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do object = stringify_keys(object |> Map.from_struct()) {:ok, object, meta} end end + def validate(%{"type" => "ChatMessage"} = object, meta) do + with {:ok, object} <- + object + |> ChatMessageValidator.cast_and_apply() do + object = stringify_keys(object) + {:ok, object, meta} + end + end + + def validate(%{"type" => "Create"} = object, meta) do + with {:ok, object} <- + object + |> CreateChatMessageValidator.cast_and_apply() do + object = stringify_keys(object) + {:ok, object, meta} + end + end + + def stringify_keys(%{__struct__: _} = object) do + object + |> Map.from_struct() + |> stringify_keys + end + def stringify_keys(object) do object |> Map.new(fn {key, val} -> {to_string(key), val} end) diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex new file mode 100644 index 000000000..ab5be3596 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -0,0 +1,58 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + + import Ecto.Changeset + + @primary_key false + @derive Jason.Encoder + + embedded_schema do + field(:id, Types.ObjectID, primary_key: true) + field(:to, Types.Recipients, default: []) + field(:type, :string) + field(:content, :string) + field(:actor, Types.ObjectID) + field(:published, Types.DateTime) + end + + def cast_and_apply(data) do + data + |> cast_data + |> apply_action(:insert) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + def fix(data) do + data + |> Map.put_new("actor", data["attributedTo"]) + end + + def changeset(struct, data) do + data = fix(data) + + struct + |> cast(data, __schema__(:fields)) + end + + def validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["ChatMessage"]) + |> validate_required([:id, :actor, :to, :type, :content]) + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex new file mode 100644 index 000000000..659311480 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex @@ -0,0 +1,35 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +# NOTES +# - Can probably be a generic create validator +# - doesn't embed, will only get the object id +# - object has to be validated first, maybe with some meta info from the surrounding create +defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + + import Ecto.Changeset + + @primary_key false + + embedded_schema do + field(:id, Types.ObjectID, primary_key: true) + field(:actor, Types.ObjectID) + field(:type, :string) + field(:to, Types.Recipients, default: []) + field(:object, Types.ObjectID) + end + + def cast_and_apply(data) do + data + |> cast_data + |> apply_action(:insert) + end + + def cast_data(data) do + cast(%__MODULE__{}, data, __schema__(:fields)) + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/create_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex similarity index 100% rename from lib/pleroma/web/activity_pub/object_validators/create_validator.ex rename to lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex diff --git a/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex new file mode 100644 index 000000000..5a3040842 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex @@ -0,0 +1,23 @@ +defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients do + use Ecto.Type + + def type, do: {:array, :string} + + def cast(object) when is_binary(object) do + cast([object]) + end + + def cast([_ | _] = data), do: {:ok, data} + + def cast(_) do + :error + end + + def dump(data) do + {:ok, data} + end + + def load(data) do + {:ok, data} + end +end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 0a8ad62ad..becc35ea3 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -16,6 +16,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.Pipeline + alias Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageHandling alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.Federator @@ -612,6 +613,12 @@ def handle_incoming( |> handle_incoming(options) end + def handle_incoming( + %{"type" => "Create", "object" => %{"type" => "ChatMessage"}} = data, + options + ), + do: ChatMessageHandling.handle_incoming(data, options) + def handle_incoming(%{"type" => "Like"} = data, _options) do with {_, {:ok, cast_data_sym}} <- {:casting_data, diff --git a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex new file mode 100644 index 000000000..b5843736f --- /dev/null +++ b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex @@ -0,0 +1,30 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageHandling do + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator + alias Pleroma.Web.ActivityPub.Pipeline + + def handle_incoming( + %{"type" => "Create", "object" => %{"type" => "ChatMessage"} = object_data} = data, + _options + ) do + with {_, {:ok, cast_data_sym}} <- + {:casting_data, data |> CreateChatMessageValidator.cast_and_apply()}, + cast_data = ObjectValidator.stringify_keys(cast_data_sym), + {_, {:ok, object_cast_data_sym}} <- + {:casting_object_data, object_data |> ChatMessageValidator.cast_and_apply()}, + object_cast_data = ObjectValidator.stringify_keys(object_cast_data_sym), + {_, {:ok, validated_object, _meta}} <- + {:validate_object, ObjectValidator.validate(object_cast_data, %{})}, + {_, {:ok, _created_object}} <- {:persist_object, Object.create(validated_object)}, + {_, {:ok, activity, _meta}} <- + {:common_pipeline, Pipeline.common_pipeline(cast_data, local: false)} do + {:ok, activity} + end + end +end diff --git a/test/fixtures/create-chat-message.json b/test/fixtures/create-chat-message.json new file mode 100644 index 000000000..4aa17f4a5 --- /dev/null +++ b/test/fixtures/create-chat-message.json @@ -0,0 +1,19 @@ +{ + "actor": "http://2hu.gensokyo/users/raymoo", + "id": "http://2hu.gensokyo/objects/1", + "object": { + "attributedTo": "http://2hu.gensokyo/users/raymoo", + "content": "You expected a cute girl? Too bad.", + "id": "http://2hu.gensokyo/objects/2", + "published": "2020-02-12T14:08:20Z", + "to": [ + "http://2hu.gensokyo/users/marisa" + ], + "type": "ChatMessage" + }, + "published": "2018-02-12T14:08:20Z", + "to": [ + "http://2hu.gensokyo/users/marisa" + ], + "type": "Create" +} diff --git a/test/web/activity_pub/object_validators/types/recipients_test.exs b/test/web/activity_pub/object_validators/types/recipients_test.exs new file mode 100644 index 000000000..2f9218774 --- /dev/null +++ b/test/web/activity_pub/object_validators/types/recipients_test.exs @@ -0,0 +1,15 @@ +defmodule Pleroma.Web.ObjectValidators.Types.RecipientsTest do + alias Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients + use Pleroma.DataCase + + test "it works with a list" do + list = ["https://lain.com/users/lain"] + assert {:ok, list} == Recipients.cast(list) + end + + test "it turns a single string into a list" do + recipient = "https://lain.com/users/lain" + + assert {:ok, [recipient]} == Recipients.cast(recipient) + end +end diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs new file mode 100644 index 000000000..aed62c520 --- /dev/null +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -0,0 +1,32 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageTest do + use Pleroma.DataCase + + import Pleroma.Factory + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.Transmogrifier + + describe "handle_incoming" do + test "it insert it" do + data = + File.read!("test/fixtures/create-chat-message.json") + |> Poison.decode!() + + author = insert(:user, ap_id: data["actor"], local: false) + recipient = insert(:user, ap_id: List.first(data["to"]), local: false) + + {:ok, %Activity{} = activity} = Transmogrifier.handle_incoming(data) + + assert activity.actor == author.ap_id + assert activity.recipients == [recipient.ap_id, author.ap_id] + + %Object{} = object = Object.get_by_ap_id(activity.data["object"]) + assert object + end + end +end From 2e78686686f04726ad73749ee744b8a9df91ffb8 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 9 Apr 2020 12:44:20 +0200 Subject: [PATCH 013/401] SideEffects: Handle ChatMessage creation. --- lib/pleroma/chat.ex | 15 ++++++---- lib/pleroma/web/activity_pub/builder.ex | 22 +++++++++++++++ lib/pleroma/web/activity_pub/side_effects.ex | 29 ++++++++++++++++++++ test/chat_test.exs | 14 ++++++---- test/web/activity_pub/side_effects_test.exs | 26 ++++++++++++++++++ 5 files changed, 95 insertions(+), 11 deletions(-) diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 07ad62b97..b61bc4c0e 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -18,23 +18,28 @@ defmodule Pleroma.Chat do schema "chats" do belongs_to(:user, User, type: FlakeId.Ecto.CompatType) field(:recipient, :string) - field(:unread, :integer, default: 0) + field(:unread, :integer, default: 0, read_after_writes: true) timestamps() end def creation_cng(struct, params) do struct - |> cast(params, [:user_id, :recipient]) + |> cast(params, [:user_id, :recipient, :unread]) |> validate_required([:user_id, :recipient]) |> unique_constraint(:user_id, name: :chats_user_id_recipient_index) end - def get_or_create(user_id, recipient) do + def get(user_id, recipient) do + __MODULE__ + |> Repo.get_by(user_id: user_id, recipient: recipient) + end + + def bump_or_create(user_id, recipient) do %__MODULE__{} - |> creation_cng(%{user_id: user_id, recipient: recipient}) + |> creation_cng(%{user_id: user_id, recipient: recipient, unread: 1}) |> Repo.insert( - on_conflict: [set: [updated_at: NaiveDateTime.utc_now()]], + on_conflict: [set: [updated_at: NaiveDateTime.utc_now()], inc: [unread: 1]], conflict_target: [:user_id, :recipient] ) end diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 429a510b8..f0a6c1e1b 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -10,6 +10,28 @@ defmodule Pleroma.Web.ActivityPub.Builder do alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility + def create(actor, object_id, recipients) do + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "actor" => actor.ap_id, + "to" => recipients, + "object" => object_id, + "type" => "Create" + }, []} + end + + def chat_message(actor, recipient, content) do + {:ok, + %{ + "id" => Utils.generate_object_id(), + "actor" => actor.ap_id, + "type" => "ChatMessage", + "to" => [recipient], + "content" => content + }, []} + end + @spec like(User.t(), Object.t()) :: {:ok, map(), keyword()} def like(actor, object) do object_actor = User.get_cached_by_ap_id(object.data["actor"]) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 666a4e310..594f32700 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -5,8 +5,10 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do liked object, a `Follow` activity will add the user to the follower collection, and so on. """ + alias Pleroma.Chat alias Pleroma.Notification alias Pleroma.Object + alias Pleroma.User alias Pleroma.Web.ActivityPub.Utils def handle(object, meta \\ []) @@ -21,8 +23,35 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do {:ok, object, meta} end + def handle(%{data: %{"type" => "Create", "object" => object_id}} = activity, meta) do + object = Object.get_by_ap_id(object_id) + + {:ok, _object} = handle_object_creation(object) + + {:ok, activity, meta} + end + # Nothing to do def handle(object, meta) do {:ok, object, meta} end + + def handle_object_creation(%{data: %{"type" => "ChatMessage"}} = object) do + actor = User.get_cached_by_ap_id(object.data["actor"]) + recipient = User.get_cached_by_ap_id(hd(object.data["to"])) + + [[actor, recipient], [recipient, actor]] + |> Enum.each(fn [user, other_user] -> + if user.local do + Chat.bump_or_create(user.id, other_user.ap_id) + end + end) + + {:ok, object} + end + + # Nothing to do + def handle_object_creation(object) do + {:ok, object} + end end diff --git a/test/chat_test.exs b/test/chat_test.exs index ca9206802..bb2b46d51 100644 --- a/test/chat_test.exs +++ b/test/chat_test.exs @@ -14,7 +14,7 @@ test "it creates a chat for a user and recipient" do user = insert(:user) other_user = insert(:user) - {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) assert chat.id end @@ -23,19 +23,21 @@ test "it returns a chat for a user and recipient if it already exists" do user = insert(:user) other_user = insert(:user) - {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) - {:ok, chat_two} = Chat.get_or_create(user.id, other_user.ap_id) + {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) + {:ok, chat_two} = Chat.bump_or_create(user.id, other_user.ap_id) assert chat.id == chat_two.id end - test "a returning chat will have an updated `update_at` field" do + test "a returning chat will have an updated `update_at` field and an incremented unread count" do user = insert(:user) other_user = insert(:user) - {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) + assert chat.unread == 1 :timer.sleep(1500) - {:ok, chat_two} = Chat.get_or_create(user.id, other_user.ap_id) + {:ok, chat_two} = Chat.bump_or_create(user.id, other_user.ap_id) + assert chat_two.unread == 2 assert chat.id == chat_two.id assert chat.updated_at != chat_two.updated_at diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index b67bd14b3..5fd8372b5 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do use Pleroma.DataCase + alias Pleroma.Chat alias Pleroma.Object alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder @@ -31,4 +32,29 @@ test "add the like to the original object", %{like: like, user: user} do assert user.ap_id in object.data["likes"] end end + + describe "creation of ChatMessages" do + test "it creates a Chat for the local users and bumps the unread count" do + author = insert(:user, local: false) + recipient = insert(:user, local: true) + + {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") + {:ok, chat_message_object} = Object.create(chat_message_data) + + {:ok, create_activity_data, _meta} = + Builder.create(author, chat_message_object.data["id"], [recipient.ap_id]) + + {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) + + {:ok, _create_activity, _meta} = SideEffects.handle(create_activity) + + # The remote user won't get a chat + chat = Chat.get(author.id, recipient.ap_id) + refute chat + + # The local user will get a chat + chat = Chat.get(recipient.id, author.ap_id) + assert chat + end + end end From 4b047850718086a6d2edb5b2d94c6f888eba3016 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 9 Apr 2020 12:46:33 +0200 Subject: [PATCH 014/401] SideEffects: Extend ChatMessage test. --- test/web/activity_pub/side_effects_test.exs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 5fd8372b5..b629d0d5d 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -55,6 +55,26 @@ test "it creates a Chat for the local users and bumps the unread count" do # The local user will get a chat chat = Chat.get(recipient.id, author.ap_id) assert chat + + author = insert(:user, local: true) + recipient = insert(:user, local: true) + + {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") + {:ok, chat_message_object} = Object.create(chat_message_data) + + {:ok, create_activity_data, _meta} = + Builder.create(author, chat_message_object.data["id"], [recipient.ap_id]) + + {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) + + {:ok, _create_activity, _meta} = SideEffects.handle(create_activity) + + # Both users are local and get the chat + chat = Chat.get(author.id, recipient.ap_id) + assert chat + + chat = Chat.get(recipient.id, author.ap_id) + assert chat end end end From 8e637ae1a7b75fa08679ae9cf424650fc105de85 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 9 Apr 2020 13:20:16 +0200 Subject: [PATCH 015/401] CommonAPI: Basic ChatMessage support. --- lib/pleroma/web/common_api/common_api.ex | 23 +++++++++++++++++++++++ test/web/common_api/common_api_test.exs | 21 +++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 636cf3301..39e15adbf 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.CommonAPI do alias Pleroma.Conversation.Participation alias Pleroma.FollowingRelationship alias Pleroma.Object + alias Pleroma.Repo alias Pleroma.ThreadMute alias Pleroma.User alias Pleroma.UserRelationship @@ -23,6 +24,28 @@ defmodule Pleroma.Web.CommonAPI do require Pleroma.Constants require Logger + def post_chat_message(user, recipient, content) do + transaction = + Repo.transaction(fn -> + with {_, {:ok, chat_message_data, _meta}} <- + {:build_object, Builder.chat_message(user, recipient.ap_id, content)}, + {_, {:ok, chat_message_object}} <- + {:create_object, Object.create(chat_message_data)}, + {_, {:ok, create_activity_data, _meta}} <- + {:build_create_activity, + Builder.create(user, chat_message_object.data["id"], [recipient.ap_id])}, + {_, {:ok, %Activity{} = activity, _meta}} <- + {:common_pipeline, Pipeline.common_pipeline(create_activity_data, local: true)} do + {:ok, activity} + end + end) + + case transaction do + {:ok, value} -> value + error -> error + end + end + def follow(follower, followed) do timeout = Pleroma.Config.get([:activitypub, :follow_handshake_timeout]) diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index f46ad0272..1aea06d24 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -5,6 +5,7 @@ defmodule Pleroma.Web.CommonAPITest do use Pleroma.DataCase alias Pleroma.Activity + alias Pleroma.Chat alias Pleroma.Conversation.Participation alias Pleroma.Object alias Pleroma.User @@ -21,6 +22,26 @@ defmodule Pleroma.Web.CommonAPITest do setup do: clear_config([:instance, :limit]) setup do: clear_config([:instance, :max_pinned_statuses]) + describe "posting chat messages" do + test "it posts a chat message" do + author = insert(:user) + recipient = insert(:user) + + {:ok, activity} = CommonAPI.post_chat_message(author, recipient, "a test message") + + assert activity.data["type"] == "Create" + assert activity.local + object = Object.normalize(activity) + + assert object.data["type"] == "ChatMessage" + assert object.data["to"] == [recipient.ap_id] + assert object.data["content"] == "a test message" + + assert Chat.get(author.id, recipient.ap_id) + assert Chat.get(recipient.id, author.ap_id) + end + end + test "when replying to a conversation / participation, it will set the correct context id even if no explicit reply_to is given" do user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"}) From 68abea313d0be49aa6b8d4b980aa361383f991a7 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 9 Apr 2020 15:13:55 +0200 Subject: [PATCH 016/401] ChatController: Add creation and return of chats. --- lib/pleroma/chat.ex | 10 ++++ .../controllers/chat_controller.ex | 47 ++++++++++++++++ lib/pleroma/web/router.ex | 7 +++ .../controllers/chat_controller_test.exs | 55 +++++++++++++++++++ 4 files changed, 119 insertions(+) create mode 100644 lib/pleroma/web/pleroma_api/controllers/chat_controller.ex create mode 100644 test/web/pleroma_api/controllers/chat_controller_test.exs diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index b61bc4c0e..2475019d1 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -35,6 +35,16 @@ def get(user_id, recipient) do |> Repo.get_by(user_id: user_id, recipient: recipient) end + def get_or_create(user_id, recipient) do + %__MODULE__{} + |> creation_cng(%{user_id: user_id, recipient: recipient}) + |> Repo.insert( + on_conflict: :nothing, + returning: true, + conflict_target: [:user_id, :recipient] + ) + end + def bump_or_create(user_id, recipient) do %__MODULE__{} |> creation_cng(%{user_id: user_id, recipient: recipient, unread: 1}) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex new file mode 100644 index 000000000..0ee8bea33 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -0,0 +1,47 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only +defmodule Pleroma.Web.PleromaAPI.ChatController do + use Pleroma.Web, :controller + + alias Pleroma.Chat + alias Pleroma.Repo + + import Ecto.Query + + def index(%{assigns: %{user: %{id: user_id}}} = conn, _params) do + chats = + from(c in Chat, + where: c.user_id == ^user_id, + order_by: [desc: c.updated_at] + ) + |> Repo.all() + + represented_chats = + Enum.map(chats, fn chat -> + %{ + id: chat.id, + recipient: chat.recipient, + unread: chat.unread + } + end) + + conn + |> json(represented_chats) + end + + def create(%{assigns: %{user: user}} = conn, params) do + recipient = params["ap_id"] |> URI.decode_www_form() + + with {:ok, %Chat{} = chat} <- Chat.get_or_create(user.id, recipient) do + represented_chat = %{ + id: chat.id, + recipient: chat.recipient, + unread: chat.unread + } + + conn + |> json(represented_chat) + end + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 3ecd59cd1..18ce9ee4b 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -284,6 +284,13 @@ defmodule Pleroma.Web.Router do end scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do + scope [] do + pipe_through(:authenticated_api) + + post("/chats/by-ap-id/:ap_id", ChatController, :create) + get("/chats", ChatController, :index) + end + scope [] do pipe_through(:authenticated_api) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs new file mode 100644 index 000000000..40c09d1cd --- /dev/null +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -0,0 +1,55 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only +defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do + use Pleroma.Web.ConnCase, async: true + + alias Pleroma.Chat + + import Pleroma.Factory + + describe "POST /api/v1/pleroma/chats/by-ap-id/:id" do + test "it creates or returns a chat", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + result = + conn + |> assign(:user, user) + |> post("/api/v1/pleroma/chats/by-ap-id/#{URI.encode_www_form(other_user.ap_id)}") + |> json_response(200) + + assert result["id"] + end + end + + describe "GET /api/v1/pleroma/chats" do + test "it return a list of chats the current user is participating in, in descending order of updates", + %{conn: conn} do + user = insert(:user) + har = insert(:user) + jafnhar = insert(:user) + tridi = insert(:user) + + {:ok, chat_1} = Chat.get_or_create(user.id, har.ap_id) + :timer.sleep(1000) + {:ok, _chat_2} = Chat.get_or_create(user.id, jafnhar.ap_id) + :timer.sleep(1000) + {:ok, chat_3} = Chat.get_or_create(user.id, tridi.ap_id) + :timer.sleep(1000) + + # bump the second one + {:ok, chat_2} = Chat.bump_or_create(user.id, jafnhar.ap_id) + + result = + conn + |> assign(:user, user) + |> get("/api/v1/pleroma/chats") + |> json_response(200) + + ids = Enum.map(result, & &1["id"]) + + assert ids == [chat_2.id, chat_3.id, chat_1.id] + end + end +end From e8fd0dd689be0c7bbca006f7267955329279da98 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 9 Apr 2020 16:59:49 +0200 Subject: [PATCH 017/401] ChatController: Basic support for returning messages. --- .../controllers/chat_controller.ex | 40 +++++++++++++++++++ lib/pleroma/web/router.ex | 1 + .../controllers/chat_controller_test.exs | 28 +++++++++++++ 3 files changed, 69 insertions(+) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 0ee8bea33..de23b9a22 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -5,10 +5,50 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do use Pleroma.Web, :controller alias Pleroma.Chat + alias Pleroma.Object alias Pleroma.Repo import Ecto.Query + def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => id}) do + with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id) do + messages = + from(o in Object, + where: fragment("?->>'type' = ?", o.data, "ChatMessage"), + where: + fragment( + """ + (?->>'actor' = ? and ?->'to' = ?) + OR (?->>'actor' = ? and ?->'to' = ?) + """, + o.data, + ^user.ap_id, + o.data, + ^[chat.recipient], + o.data, + ^chat.recipient, + o.data, + ^[user.ap_id] + ), + order_by: [desc: o.id] + ) + |> Repo.all() + + represented_messages = + messages + |> Enum.map(fn message -> + %{ + actor: message.data["actor"], + id: message.id, + content: message.data["content"] + } + end) + + conn + |> json(represented_messages) + end + end + def index(%{assigns: %{user: %{id: user_id}}} = conn, _params) do chats = from(c in Chat, diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 18ce9ee4b..368e77d3e 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -289,6 +289,7 @@ defmodule Pleroma.Web.Router do post("/chats/by-ap-id/:ap_id", ChatController, :create) get("/chats", ChatController, :index) + get("/chats/:id/messages", ChatController, :messages) end scope [] do diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 40c09d1cd..6b2db5064 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -5,9 +5,37 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do use Pleroma.Web.ConnCase, async: true alias Pleroma.Chat + alias Pleroma.Web.CommonAPI import Pleroma.Factory + describe "GET /api/v1/pleroma/chats/:id/messages" do + # TODO + # - Test that statuses don't show + # - Test the case where it's not the user's chat + # - Test the returned data + test "it returns the messages for a given chat", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + third_user = insert(:user) + + {:ok, _} = CommonAPI.post_chat_message(user, other_user, "hey") + {:ok, _} = CommonAPI.post_chat_message(user, third_user, "hey") + {:ok, _} = CommonAPI.post_chat_message(user, other_user, "how are you?") + {:ok, _} = CommonAPI.post_chat_message(other_user, user, "fine, how about you?") + + chat = Chat.get(user.id, other_user.ap_id) + + result = + conn + |> assign(:user, user) + |> get("/api/v1/pleroma/chats/#{chat.id}/messages") + |> json_response(200) + + assert length(result) == 3 + end + end + describe "POST /api/v1/pleroma/chats/by-ap-id/:id" do test "it creates or returns a chat", %{conn: conn} do user = insert(:user) From 2cc68414245805dc3b83c200798e424f139e71fc Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 9 Apr 2020 17:18:31 +0200 Subject: [PATCH 018/401] ChatController: Basic message posting. --- .../controllers/chat_controller.ex | 26 +++++++++++++++++++ lib/pleroma/web/router.ex | 1 + .../controllers/chat_controller_test.exs | 17 ++++++++++++ 3 files changed, 44 insertions(+) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index de23b9a22..972330f4e 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -7,9 +7,35 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do alias Pleroma.Chat alias Pleroma.Object alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.CommonAPI import Ecto.Query + # TODO + # - Oauth stuff + # - Views / Representers + # - Error handling + + def post_chat_message(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ + "id" => id, + "content" => content + }) do + with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), + %User{} = recipient <- User.get_cached_by_ap_id(chat.recipient), + {:ok, activity} <- CommonAPI.post_chat_message(user, recipient, content), + message <- Object.normalize(activity) do + represented_message = %{ + actor: message.data["actor"], + id: message.id, + content: message.data["content"] + } + + conn + |> json(represented_message) + end + end + def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => id}) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id) do messages = diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 368e77d3e..ce69725dc 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -290,6 +290,7 @@ defmodule Pleroma.Web.Router do post("/chats/by-ap-id/:ap_id", ChatController, :create) get("/chats", ChatController, :index) get("/chats/:id/messages", ChatController, :messages) + post("/chats/:id/messages", ChatController, :post_chat_message) end scope [] do diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 6b2db5064..b4230e5ad 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -9,6 +9,23 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do import Pleroma.Factory + describe "POST /api/v1/pleroma/chats/:id/messages" do + test "it posts a message to the chat", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + + result = + conn + |> assign(:user, user) + |> post("/api/v1/pleroma/chats/#{chat.id}/messages", %{"content" => "Hallo!!"}) + |> json_response(200) + + assert result["content"] == "Hallo!!" + end + end + describe "GET /api/v1/pleroma/chats/:id/messages" do # TODO # - Test that statuses don't show From 64c78581fe397b6d9356c52cf3f43becd2ff3b4e Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 10 Apr 2020 14:47:56 +0200 Subject: [PATCH 019/401] Chat: Only create them for valid users for now. --- lib/pleroma/chat.ex | 7 +++++++ test/chat_test.exs | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 2475019d1..c2044881f 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -26,6 +26,13 @@ defmodule Pleroma.Chat do def creation_cng(struct, params) do struct |> cast(params, [:user_id, :recipient, :unread]) + |> validate_change(:recipient, fn + :recipient, recipient -> + case User.get_cached_by_ap_id(recipient) do + nil -> [recipient: "must a an existing user"] + _ -> [] + end + end) |> validate_required([:user_id, :recipient]) |> unique_constraint(:user_id, name: :chats_user_id_recipient_index) end diff --git a/test/chat_test.exs b/test/chat_test.exs index bb2b46d51..952598c87 100644 --- a/test/chat_test.exs +++ b/test/chat_test.exs @@ -10,6 +10,13 @@ defmodule Pleroma.ChatTest do import Pleroma.Factory describe "creation and getting" do + test "it only works if the recipient is a valid user (for now)" do + user = insert(:user) + + assert {:error, _chat} = Chat.bump_or_create(user.id, "http://some/nonexisting/account") + assert {:error, _chat} = Chat.get_or_create(user.id, "http://some/nonexisting/account") + end + test "it creates a chat for a user and recipient" do user = insert(:user) other_user = insert(:user) From 6ace22b56a3ced833bd990de5715048d6bd32f80 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 15 Apr 2020 18:23:16 +0200 Subject: [PATCH 020/401] Chat: Add views, don't return them in timeline queries. --- lib/pleroma/web/activity_pub/activity_pub.ex | 13 +++ .../web/api_spec/operations/chat_operation.ex | 81 +++++++++++++++++++ lib/pleroma/web/common_api/common_api.ex | 2 +- .../controllers/chat_controller.ex | 47 +++-------- .../pleroma_api/views/chat_message_view.ex | 28 +++++++ .../web/pleroma_api/views/chat_view.ex | 21 +++++ .../controllers/timeline_controller_test.exs | 3 + .../controllers/chat_controller_test.exs | 8 +- .../views/chat_message_view_test.exs | 42 ++++++++++ 9 files changed, 207 insertions(+), 38 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/chat_operation.ex create mode 100644 lib/pleroma/web/pleroma_api/views/chat_message_view.ex create mode 100644 lib/pleroma/web/pleroma_api/views/chat_view.ex create mode 100644 test/web/pleroma_api/views/chat_message_view_test.exs diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 4a56beb73..b6ba91052 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1207,6 +1207,18 @@ defp exclude_poll_votes(query, _) do end end + defp exclude_chat_messages(query, %{"include_chat_messages" => true}), do: query + + defp exclude_chat_messages(query, _) do + if has_named_binding?(query, :object) do + from([activity, object: o] in query, + where: fragment("not(?->>'type' = ?)", o.data, "ChatMessage") + ) + else + query + end + end + defp exclude_id(query, %{"exclude_id" => id}) when is_binary(id) do from(activity in query, where: activity.id != ^id) end @@ -1312,6 +1324,7 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> restrict_instance(opts) |> Activity.restrict_deactivated_users() |> exclude_poll_votes(opts) + |> exclude_chat_messages(opts) |> exclude_visibility(opts) end diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex new file mode 100644 index 000000000..038ebb29d --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -0,0 +1,81 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.ChatOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + + @spec open_api_operation(atom) :: Operation.t() + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def create_operation do + %Operation{ + tags: ["chat"], + summary: "Create a chat", + responses: %{ + 200 => + Operation.response("Chat", "application/json", %Schema{ + type: :object, + description: "A created chat is returned", + properties: %{ + id: %Schema{type: :integer} + } + }) + } + } + end + + def index_operation do + %Operation{ + tags: ["chat"], + summary: "Get a list of chats that you participated in", + responses: %{ + 200 => + Operation.response("Chats", "application/json", %Schema{ + type: :array, + description: "A list of chats", + items: %Schema{ + type: :object, + description: "A chat" + } + }) + } + } + end + + def messages_operation do + %Operation{ + tags: ["chat"], + summary: "Get the most recent messages of the chat", + responses: %{ + 200 => + Operation.response("Messages", "application/json", %Schema{ + type: :array, + description: "A list of chat messages", + items: %Schema{ + type: :object, + description: "A chat message" + } + }) + } + } + end + + def post_chat_message_operation do + %Operation{ + tags: ["chat"], + summary: "Post a message to the chat", + responses: %{ + 200 => + Operation.response("Message", "application/json", %Schema{ + type: :object, + description: "A chat message" + }) + } + } + end +end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 2f13daf0c..c306c1e96 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -24,7 +24,7 @@ defmodule Pleroma.Web.CommonAPI do require Pleroma.Constants require Logger - def post_chat_message(user, recipient, content) do + def post_chat_message(%User{} = user, %User{} = recipient, content) do transaction = Repo.transaction(fn -> with {_, {:ok, chat_message_data, _meta}} <- diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 972330f4e..5ec546847 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -9,6 +9,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.CommonAPI + alias Pleroma.Web.PleromaAPI.ChatView + alias Pleroma.Web.PleromaAPI.ChatMessageView import Ecto.Query @@ -17,6 +19,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do # - Views / Representers # - Error handling + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ChatOperation + def post_chat_message(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ "id" => id, "content" => content @@ -25,14 +29,9 @@ def post_chat_message(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ %User{} = recipient <- User.get_cached_by_ap_id(chat.recipient), {:ok, activity} <- CommonAPI.post_chat_message(user, recipient, content), message <- Object.normalize(activity) do - represented_message = %{ - actor: message.data["actor"], - id: message.id, - content: message.data["content"] - } - conn - |> json(represented_message) + |> put_view(ChatMessageView) + |> render("show.json", for: user, object: message, chat: chat) end end @@ -60,18 +59,9 @@ def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => id}) d ) |> Repo.all() - represented_messages = - messages - |> Enum.map(fn message -> - %{ - actor: message.data["actor"], - id: message.id, - content: message.data["content"] - } - end) - conn - |> json(represented_messages) + |> put_view(ChatMessageView) + |> render("index.json", for: user, objects: messages, chat: chat) end end @@ -83,31 +73,18 @@ def index(%{assigns: %{user: %{id: user_id}}} = conn, _params) do ) |> Repo.all() - represented_chats = - Enum.map(chats, fn chat -> - %{ - id: chat.id, - recipient: chat.recipient, - unread: chat.unread - } - end) - conn - |> json(represented_chats) + |> put_view(ChatView) + |> render("index.json", chats: chats) end def create(%{assigns: %{user: user}} = conn, params) do recipient = params["ap_id"] |> URI.decode_www_form() with {:ok, %Chat{} = chat} <- Chat.get_or_create(user.id, recipient) do - represented_chat = %{ - id: chat.id, - recipient: chat.recipient, - unread: chat.unread - } - conn - |> json(represented_chat) + |> put_view(ChatView) + |> render("show.json", chat: chat) end end end diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex new file mode 100644 index 000000000..2df591358 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex @@ -0,0 +1,28 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ChatMessageView do + use Pleroma.Web, :view + + alias Pleroma.Chat + + def render( + "show.json", + %{ + object: %{id: id, data: %{"type" => "ChatMessage"} = chat_message}, + chat: %Chat{id: chat_id} + } + ) do + %{ + id: id, + content: chat_message["content"], + chat_id: chat_id, + actor: chat_message["actor"] + } + end + + def render("index.json", opts) do + render_many(opts[:objects], __MODULE__, "show.json", Map.put(opts, :as, :object)) + end +end diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex new file mode 100644 index 000000000..ee48385bf --- /dev/null +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -0,0 +1,21 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ChatView do + use Pleroma.Web, :view + + alias Pleroma.Chat + + def render("show.json", %{chat: %Chat{} = chat}) do + %{ + id: chat.id, + recipient: chat.recipient, + unread: chat.unread + } + end + + def render("index.json", %{chats: chats}) do + render_many(chats, __MODULE__, "show.json") + end +end diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index 06efdc901..a5c227991 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -51,6 +51,9 @@ test "the home timeline", %{user: user, conn: conn} do {:ok, activity} = CommonAPI.post(third_user, %{"status" => "repeated post"}) {:ok, _, _} = CommonAPI.repeat(activity.id, following) + # This one should not show up in the TL + {:ok, _activity} = CommonAPI.post_chat_message(third_user, user, ":gun:") + ret_conn = get(conn, uri) assert Enum.empty?(json_response(ret_conn, :ok)) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index b4230e5ad..dad37a889 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -23,14 +23,13 @@ test "it posts a message to the chat", %{conn: conn} do |> json_response(200) assert result["content"] == "Hallo!!" + assert result["chat_id"] == chat.id end end describe "GET /api/v1/pleroma/chats/:id/messages" do # TODO - # - Test that statuses don't show # - Test the case where it's not the user's chat - # - Test the returned data test "it returns the messages for a given chat", %{conn: conn} do user = insert(:user) other_user = insert(:user) @@ -49,6 +48,11 @@ test "it returns the messages for a given chat", %{conn: conn} do |> get("/api/v1/pleroma/chats/#{chat.id}/messages") |> json_response(200) + result + |> Enum.each(fn message -> + assert message["chat_id"] == chat.id + end) + assert length(result) == 3 end end diff --git a/test/web/pleroma_api/views/chat_message_view_test.exs b/test/web/pleroma_api/views/chat_message_view_test.exs new file mode 100644 index 000000000..e690da022 --- /dev/null +++ b/test/web/pleroma_api/views/chat_message_view_test.exs @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ChatMessageViewTest do + use Pleroma.DataCase + + alias Pleroma.Chat + alias Pleroma.Object + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.PleromaAPI.ChatMessageView + + import Pleroma.Factory + + test "it displays a chat message" do + user = insert(:user) + recipient = insert(:user) + {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "kippis") + + chat = Chat.get(user.id, recipient.ap_id) + + object = Object.normalize(activity) + + chat_message = ChatMessageView.render("show.json", object: object, for: user, chat: chat) + + assert chat_message[:id] == object.id + assert chat_message[:content] == "kippis" + assert chat_message[:actor] == user.ap_id + assert chat_message[:chat_id] + + {:ok, activity} = CommonAPI.post_chat_message(recipient, user, "gkgkgk") + + object = Object.normalize(activity) + + chat_message_two = ChatMessageView.render("show.json", object: object, for: user, chat: chat) + + assert chat_message_two[:id] == object.id + assert chat_message_two[:content] == "gkgkgk" + assert chat_message_two[:actor] == recipient.ap_id + assert chat_message_two[:chat_id] == chat_message[:chat_id] + end +end From 3d4eca5dd4be297f03c244497d78db03e82a9d81 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 16 Apr 2020 12:56:29 +0200 Subject: [PATCH 021/401] CommonAPI: Escape HTML for chat messages. --- lib/pleroma/web/common_api/common_api.ex | 8 +++++++- test/web/common_api/common_api_test.exs | 11 +++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index c306c1e96..2c25850db 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -17,6 +17,7 @@ defmodule Pleroma.Web.CommonAPI do alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Formatter import Pleroma.Web.Gettext import Pleroma.Web.CommonAPI.Utils @@ -28,7 +29,12 @@ def post_chat_message(%User{} = user, %User{} = recipient, content) do transaction = Repo.transaction(fn -> with {_, {:ok, chat_message_data, _meta}} <- - {:build_object, Builder.chat_message(user, recipient.ap_id, content)}, + {:build_object, + Builder.chat_message( + user, + recipient.ap_id, + content |> Formatter.html_escape("text/plain") + )}, {_, {:ok, chat_message_object}} <- {:create_object, Object.create(chat_message_data)}, {_, {:ok, create_activity_data, _meta}} <- diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 168721c81..abe3e6f8d 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -27,7 +27,12 @@ test "it posts a chat message" do author = insert(:user) recipient = insert(:user) - {:ok, activity} = CommonAPI.post_chat_message(author, recipient, "a test message") + {:ok, activity} = + CommonAPI.post_chat_message( + author, + recipient, + "a test message " + ) assert activity.data["type"] == "Create" assert activity.local @@ -35,7 +40,9 @@ test "it posts a chat message" do assert object.data["type"] == "ChatMessage" assert object.data["to"] == [recipient.ap_id] - assert object.data["content"] == "a test message" + + assert object.data["content"] == + "a test message <script>alert('uuu')</script>" assert Chat.get(author.id, recipient.ap_id) assert Chat.get(recipient.id, author.ap_id) From e2ced0491770d6260fe51d5144b81200fd97f268 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 16 Apr 2020 15:21:47 +0200 Subject: [PATCH 022/401] ChatMessages: Better validation. --- .../web/activity_pub/object_validator.ex | 6 ++- .../chat_message_validator.ex | 26 ++++++++++ .../object_validators/common_validations.ex | 6 ++- .../create_chat_message_validator.ex | 5 ++ .../transmogrifier/chat_message_handling.ex | 3 ++ test/fixtures/create-chat-message.json | 2 +- .../activity_pub/object_validator_test.exs | 52 +++++++++++++++++++ .../transmogrifier/chat_message_test.exs | 34 +++++++++++- 8 files changed, 128 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 49cc72561..259bbeb64 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -31,7 +31,8 @@ def validate(%{"type" => "Like"} = object, meta) do def validate(%{"type" => "ChatMessage"} = object, meta) do with {:ok, object} <- object - |> ChatMessageValidator.cast_and_apply() do + |> ChatMessageValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do object = stringify_keys(object) {:ok, object, meta} end @@ -40,7 +41,8 @@ def validate(%{"type" => "ChatMessage"} = object, meta) do def validate(%{"type" => "Create"} = object, meta) do with {:ok, object} <- object - |> CreateChatMessageValidator.cast_and_apply() do + |> CreateChatMessageValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do object = stringify_keys(object) {:ok, object, meta} end diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index ab5be3596..a4e4460cd 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do use Ecto.Schema alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.User import Ecto.Changeset @@ -54,5 +55,30 @@ def validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["ChatMessage"]) |> validate_required([:id, :actor, :to, :type, :content]) + |> validate_length(:to, is: 1) + |> validate_local_concern() + end + + @doc "Validates if at least one of the users in this ChatMessage is a local user, otherwise we don't want the message in our system. It also validates the presence of both users in our system." + def validate_local_concern(cng) do + with actor_ap <- get_field(cng, :actor), + {_, %User{} = actor} <- {:find_actor, User.get_cached_by_ap_id(actor_ap)}, + {_, %User{} = recipient} <- + {:find_recipient, User.get_cached_by_ap_id(get_field(cng, :to) |> hd())}, + {_, true} <- {:local?, Enum.any?([actor, recipient], & &1.local)} do + cng + else + {:local?, false} -> + cng + |> add_error(:actor, "actor and recipient are both remote") + + {:find_actor, _} -> + cng + |> add_error(:actor, "can't find user") + + {:find_recipient, _} -> + cng + |> add_error(:to, "can't find user") + end end end diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex index b479c3918..02f3a6438 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -8,7 +8,11 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do alias Pleroma.Object alias Pleroma.User - def validate_actor_presence(cng, field_name \\ :actor) do + def validate_actor_presence(cng) do + validate_user_presence(cng, :actor) + end + + def validate_user_presence(cng, field_name) do cng |> validate_change(field_name, fn field_name, actor -> if User.get_cached_by_ap_id(actor) do diff --git a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex index 659311480..ce52d5623 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex @@ -32,4 +32,9 @@ def cast_and_apply(data) do def cast_data(data) do cast(%__MODULE__{}, data, __schema__(:fields)) end + + # No validation yet + def cast_and_validate(data) do + cast_data(data) + end end diff --git a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex index b5843736f..815b866c9 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex @@ -25,6 +25,9 @@ def handle_incoming( {_, {:ok, activity, _meta}} <- {:common_pipeline, Pipeline.common_pipeline(cast_data, local: false)} do {:ok, activity} + else + e -> + {:error, e} end end end diff --git a/test/fixtures/create-chat-message.json b/test/fixtures/create-chat-message.json index 4aa17f4a5..2e4608f43 100644 --- a/test/fixtures/create-chat-message.json +++ b/test/fixtures/create-chat-message.json @@ -3,7 +3,7 @@ "id": "http://2hu.gensokyo/objects/1", "object": { "attributedTo": "http://2hu.gensokyo/users/raymoo", - "content": "You expected a cute girl? Too bad.", + "content": "You expected a cute girl? Too bad. ", "id": "http://2hu.gensokyo/objects/2", "published": "2020-02-12T14:08:20Z", "to": [ diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 3c5c3696e..bf0bfdfaf 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -5,9 +5,61 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI + alias Pleroma.Web.ActivityPub.Builder import Pleroma.Factory + describe "chat messages" do + setup do + user = insert(:user) + recipient = insert(:user, local: false) + + {:ok, valid_chat_message, _} = Builder.chat_message(user, recipient.ap_id, "hey") + + %{user: user, recipient: recipient, valid_chat_message: valid_chat_message} + end + + test "validates for a basic object we build", %{valid_chat_message: valid_chat_message} do + assert {:ok, _object, _meta} = ObjectValidator.validate(valid_chat_message, []) + end + + test "does not validate if the actor or the recipient is not in our system", %{ + valid_chat_message: valid_chat_message + } do + chat_message = + valid_chat_message + |> Map.put("actor", "https://raymoo.com/raymoo") + + {:error, _} = ObjectValidator.validate(chat_message, []) + + chat_message = + valid_chat_message + |> Map.put("to", ["https://raymoo.com/raymoo"]) + + {:error, _} = ObjectValidator.validate(chat_message, []) + end + + test "does not validate for a message with multiple recipients", %{ + valid_chat_message: valid_chat_message, + user: user, + recipient: recipient + } do + chat_message = + valid_chat_message + |> Map.put("to", [user.ap_id, recipient.ap_id]) + + assert {:error, _} = ObjectValidator.validate(chat_message, []) + end + + test "does not validate if it doesn't concern local users" do + user = insert(:user, local: false) + recipient = insert(:user, local: false) + + {:ok, valid_chat_message, _} = Builder.chat_message(user, recipient.ap_id, "hey") + assert {:error, _} = ObjectValidator.validate(valid_chat_message, []) + end + end + describe "likes" do setup do user = insert(:user) diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs index aed62c520..5b238f9c4 100644 --- a/test/web/activity_pub/transmogrifier/chat_message_test.exs +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -12,13 +12,43 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageTest do alias Pleroma.Web.ActivityPub.Transmogrifier describe "handle_incoming" do - test "it insert it" do + test "it rejects messages that don't contain content" do + data = + File.read!("test/fixtures/create-chat-message.json") + |> Poison.decode!() + + object = + data["object"] + |> Map.delete("content") + + data = + data + |> Map.put("object", object) + + _author = insert(:user, ap_id: data["actor"], local: false) + _recipient = insert(:user, ap_id: List.first(data["to"]), local: true) + + {:error, _} = Transmogrifier.handle_incoming(data) + end + + test "it rejects messages that don't concern local users" do + data = + File.read!("test/fixtures/create-chat-message.json") + |> Poison.decode!() + + _author = insert(:user, ap_id: data["actor"], local: false) + _recipient = insert(:user, ap_id: List.first(data["to"]), local: false) + + {:error, _} = Transmogrifier.handle_incoming(data) + end + + test "it inserts it and creates a chat" do data = File.read!("test/fixtures/create-chat-message.json") |> Poison.decode!() author = insert(:user, ap_id: data["actor"], local: false) - recipient = insert(:user, ap_id: List.first(data["to"]), local: false) + recipient = insert(:user, ap_id: List.first(data["to"]), local: true) {:ok, %Activity{} = activity} = Transmogrifier.handle_incoming(data) From ca598e9c27a7a66b014523845e62046d19364f2f Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 16 Apr 2020 15:27:35 +0200 Subject: [PATCH 023/401] AccountView: Return user ap_id. --- lib/pleroma/web/mastodon_api/views/account_view.ex | 1 + test/web/mastodon_api/views/account_view_test.exs | 3 +++ 2 files changed, 4 insertions(+) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 8fb96a22a..f20453744 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -234,6 +234,7 @@ defp do_render("show.json", %{user: user} = opts) do # Pleroma extension pleroma: %{ + ap_id: user.ap_id, confirmation_pending: user.confirmation_pending, tags: user.tags, hide_followers_count: user.hide_followers_count, diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 4435f69ff..2be0d8d0f 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -82,6 +82,7 @@ test "Represent a user account" do fields: [] }, pleroma: %{ + ap_id: user.ap_id, background_image: "https://example.com/images/asuka_hospital.png", confirmation_pending: false, tags: [], @@ -152,6 +153,7 @@ test "Represent a Service(bot) account" do fields: [] }, pleroma: %{ + ap_id: user.ap_id, background_image: nil, confirmation_pending: false, tags: [], @@ -351,6 +353,7 @@ test "represent an embedded relationship" do fields: [] }, pleroma: %{ + ap_id: user.ap_id, background_image: nil, confirmation_pending: false, tags: [], From e983f708846a5784e23b7e18734a61ed7f6e3636 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 16 Apr 2020 17:50:24 +0200 Subject: [PATCH 024/401] ChatMessagesHandling: Strip HTML of incoming messages. --- .../web/activity_pub/transmogrifier/chat_message_handling.ex | 3 +++ test/web/activity_pub/transmogrifier/chat_message_test.exs | 2 ++ 2 files changed, 5 insertions(+) diff --git a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex index 815b866c9..11bd10456 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex @@ -19,6 +19,9 @@ def handle_incoming( {_, {:ok, object_cast_data_sym}} <- {:casting_object_data, object_data |> ChatMessageValidator.cast_and_apply()}, object_cast_data = ObjectValidator.stringify_keys(object_cast_data_sym), + # For now, just strip HTML + stripped_content = Pleroma.HTML.strip_tags(object_cast_data["content"]), + object_cast_data = object_cast_data |> Map.put("content", stripped_content), {_, {:ok, validated_object, _meta}} <- {:validate_object, ObjectValidator.validate(object_cast_data, %{})}, {_, {:ok, _created_object}} <- {:persist_object, Object.create(validated_object)}, diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs index 5b238f9c4..7e7f9ebec 100644 --- a/test/web/activity_pub/transmogrifier/chat_message_test.exs +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -56,7 +56,9 @@ test "it inserts it and creates a chat" do assert activity.recipients == [recipient.ap_id, author.ap_id] %Object{} = object = Object.get_by_ap_id(activity.data["object"]) + assert object + assert object.data["content"] == "You expected a cute girl? Too bad. alert('XSS')" end end end From f8c3ae7a627817789776f11497041445bb273c19 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 16 Apr 2020 18:43:31 +0200 Subject: [PATCH 025/401] ChatController: Handle pagination. --- .../controllers/chat_controller.ex | 12 ++-- .../pleroma_api/views/chat_message_view.ex | 4 +- .../web/pleroma_api/views/chat_view.ex | 2 +- .../controllers/chat_controller_test.exs | 62 ++++++++++++++++++- 4 files changed, 68 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 5ec546847..8cf8d82e4 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do alias Pleroma.Web.CommonAPI alias Pleroma.Web.PleromaAPI.ChatView alias Pleroma.Web.PleromaAPI.ChatMessageView + alias Pleroma.Pagination import Ecto.Query @@ -35,7 +36,7 @@ def post_chat_message(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ end end - def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => id}) do + def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => id} = params) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id) do messages = from(o in Object, @@ -54,10 +55,9 @@ def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => id}) d ^chat.recipient, o.data, ^[user.ap_id] - ), - order_by: [desc: o.id] + ) ) - |> Repo.all() + |> Pagination.fetch_paginated(params) conn |> put_view(ChatMessageView) @@ -65,13 +65,13 @@ def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => id}) d end end - def index(%{assigns: %{user: %{id: user_id}}} = conn, _params) do + def index(%{assigns: %{user: %{id: user_id}}} = conn, params) do chats = from(c in Chat, where: c.user_id == ^user_id, order_by: [desc: c.updated_at] ) - |> Repo.all() + |> Pagination.fetch_paginated(params) conn |> put_view(ChatView) diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex index 2df591358..fdbb9ff1b 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex @@ -15,9 +15,9 @@ def render( } ) do %{ - id: id, + id: id |> to_string(), content: chat_message["content"], - chat_id: chat_id, + chat_id: chat_id |> to_string(), actor: chat_message["actor"] } end diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index ee48385bf..7b8c6450a 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatView do def render("show.json", %{chat: %Chat{} = chat}) do %{ - id: chat.id, + id: chat.id |> to_string(), recipient: chat.recipient, unread: chat.unread } diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index dad37a889..f30fd6615 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -23,11 +23,38 @@ test "it posts a message to the chat", %{conn: conn} do |> json_response(200) assert result["content"] == "Hallo!!" - assert result["chat_id"] == chat.id + assert result["chat_id"] == chat.id |> to_string() end end describe "GET /api/v1/pleroma/chats/:id/messages" do + test "it paginates", %{conn: conn} do + user = insert(:user) + recipient = insert(:user) + + Enum.each(1..30, fn _ -> + {:ok, _} = CommonAPI.post_chat_message(user, recipient, "hey") + end) + + chat = Chat.get(user.id, recipient.ap_id) + + result = + conn + |> assign(:user, user) + |> get("/api/v1/pleroma/chats/#{chat.id}/messages") + |> json_response(200) + + assert length(result) == 20 + + result = + conn + |> assign(:user, user) + |> get("/api/v1/pleroma/chats/#{chat.id}/messages", %{"max_id" => List.last(result)["id"]}) + |> json_response(200) + + assert length(result) == 10 + end + # TODO # - Test the case where it's not the user's chat test "it returns the messages for a given chat", %{conn: conn} do @@ -50,7 +77,7 @@ test "it returns the messages for a given chat", %{conn: conn} do result |> Enum.each(fn message -> - assert message["chat_id"] == chat.id + assert message["chat_id"] == chat.id |> to_string() end) assert length(result) == 3 @@ -73,6 +100,31 @@ test "it creates or returns a chat", %{conn: conn} do end describe "GET /api/v1/pleroma/chats" do + test "it paginates", %{conn: conn} do + user = insert(:user) + + Enum.each(1..30, fn _ -> + recipient = insert(:user) + {:ok, _} = Chat.get_or_create(user.id, recipient.ap_id) + end) + + result = + conn + |> assign(:user, user) + |> get("/api/v1/pleroma/chats") + |> json_response(200) + + assert length(result) == 20 + + result = + conn + |> assign(:user, user) + |> get("/api/v1/pleroma/chats", %{max_id: List.last(result)["id"]}) + |> json_response(200) + + assert length(result) == 10 + end + test "it return a list of chats the current user is participating in, in descending order of updates", %{conn: conn} do user = insert(:user) @@ -98,7 +150,11 @@ test "it return a list of chats the current user is participating in, in descend ids = Enum.map(result, & &1["id"]) - assert ids == [chat_2.id, chat_3.id, chat_1.id] + assert ids == [ + chat_2.id |> to_string(), + chat_3.id |> to_string(), + chat_1.id |> to_string() + ] end end end From d45ae6485811189e98f774ecdb46f0ccdfa8b2b3 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 17 Apr 2020 13:04:46 +0200 Subject: [PATCH 026/401] ChatController: Use OAuth scopes. --- .../controllers/chat_controller.ex | 18 +++++++- .../controllers/chat_controller_test.exs | 41 +++++++++---------- 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 8cf8d82e4..31c723426 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User + alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Web.CommonAPI alias Pleroma.Web.PleromaAPI.ChatView alias Pleroma.Web.PleromaAPI.ChatMessageView @@ -16,10 +17,18 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do import Ecto.Query # TODO - # - Oauth stuff - # - Views / Representers # - Error handling + plug( + OAuthScopesPlug, + %{scopes: ["write:statuses"]} when action in [:post_chat_message, :create] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["read:statuses"]} when action in [:messages, :index] + ) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ChatOperation def post_chat_message(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ @@ -62,6 +71,11 @@ def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => id} = conn |> put_view(ChatMessageView) |> render("index.json", for: user, objects: messages, chat: chat) + else + _ -> + conn + |> put_status(:not_found) + |> json(%{error: "not found"}) end end diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index f30fd6615..0750c7273 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -10,15 +10,15 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do import Pleroma.Factory describe "POST /api/v1/pleroma/chats/:id/messages" do - test "it posts a message to the chat", %{conn: conn} do - user = insert(:user) + setup do: oauth_access(["write:statuses"]) + + test "it posts a message to the chat", %{conn: conn, user: user} do other_user = insert(:user) {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) result = conn - |> assign(:user, user) |> post("/api/v1/pleroma/chats/#{chat.id}/messages", %{"content" => "Hallo!!"}) |> json_response(200) @@ -28,8 +28,9 @@ test "it posts a message to the chat", %{conn: conn} do end describe "GET /api/v1/pleroma/chats/:id/messages" do - test "it paginates", %{conn: conn} do - user = insert(:user) + setup do: oauth_access(["read:statuses"]) + + test "it paginates", %{conn: conn, user: user} do recipient = insert(:user) Enum.each(1..30, fn _ -> @@ -40,7 +41,6 @@ test "it paginates", %{conn: conn} do result = conn - |> assign(:user, user) |> get("/api/v1/pleroma/chats/#{chat.id}/messages") |> json_response(200) @@ -48,17 +48,13 @@ test "it paginates", %{conn: conn} do result = conn - |> assign(:user, user) |> get("/api/v1/pleroma/chats/#{chat.id}/messages", %{"max_id" => List.last(result)["id"]}) |> json_response(200) assert length(result) == 10 end - # TODO - # - Test the case where it's not the user's chat - test "it returns the messages for a given chat", %{conn: conn} do - user = insert(:user) + test "it returns the messages for a given chat", %{conn: conn, user: user} do other_user = insert(:user) third_user = insert(:user) @@ -71,7 +67,6 @@ test "it returns the messages for a given chat", %{conn: conn} do result = conn - |> assign(:user, user) |> get("/api/v1/pleroma/chats/#{chat.id}/messages") |> json_response(200) @@ -81,17 +76,25 @@ test "it returns the messages for a given chat", %{conn: conn} do end) assert length(result) == 3 + + # Trying to get the chat of a different user + result = + conn + |> assign(:user, other_user) + |> get("/api/v1/pleroma/chats/#{chat.id}/messages") + + assert result |> json_response(404) end end describe "POST /api/v1/pleroma/chats/by-ap-id/:id" do + setup do: oauth_access(["write:statuses"]) + test "it creates or returns a chat", %{conn: conn} do - user = insert(:user) other_user = insert(:user) result = conn - |> assign(:user, user) |> post("/api/v1/pleroma/chats/by-ap-id/#{URI.encode_www_form(other_user.ap_id)}") |> json_response(200) @@ -100,9 +103,9 @@ test "it creates or returns a chat", %{conn: conn} do end describe "GET /api/v1/pleroma/chats" do - test "it paginates", %{conn: conn} do - user = insert(:user) + setup do: oauth_access(["read:statuses"]) + test "it paginates", %{conn: conn, user: user} do Enum.each(1..30, fn _ -> recipient = insert(:user) {:ok, _} = Chat.get_or_create(user.id, recipient.ap_id) @@ -110,7 +113,6 @@ test "it paginates", %{conn: conn} do result = conn - |> assign(:user, user) |> get("/api/v1/pleroma/chats") |> json_response(200) @@ -118,7 +120,6 @@ test "it paginates", %{conn: conn} do result = conn - |> assign(:user, user) |> get("/api/v1/pleroma/chats", %{max_id: List.last(result)["id"]}) |> json_response(200) @@ -126,8 +127,7 @@ test "it paginates", %{conn: conn} do end test "it return a list of chats the current user is participating in, in descending order of updates", - %{conn: conn} do - user = insert(:user) + %{conn: conn, user: user} do har = insert(:user) jafnhar = insert(:user) tridi = insert(:user) @@ -144,7 +144,6 @@ test "it return a list of chats the current user is participating in, in descend result = conn - |> assign(:user, user) |> get("/api/v1/pleroma/chats") |> json_response(200) From 372614cfd3119b589c9c47619445714e8ae6c07e Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 17 Apr 2020 14:23:59 +0200 Subject: [PATCH 027/401] ChatView: Add a mastodon api representation of the recipient. --- .../transmogrifier/chat_message_handling.ex | 1 + .../web/pleroma_api/views/chat_view.ex | 7 ++++- .../transmogrifier/chat_message_test.exs | 19 ++++++++++++ test/web/pleroma_api/views/chat_view_test.exs | 29 +++++++++++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 test/web/pleroma_api/views/chat_view_test.exs diff --git a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex index 11bd10456..cfe3b767b 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex @@ -22,6 +22,7 @@ def handle_incoming( # For now, just strip HTML stripped_content = Pleroma.HTML.strip_tags(object_cast_data["content"]), object_cast_data = object_cast_data |> Map.put("content", stripped_content), + {_, true} <- {:to_fields_match, cast_data["to"] == object_cast_data["to"]}, {_, {:ok, validated_object, _meta}} <- {:validate_object, ObjectValidator.validate(object_cast_data, %{})}, {_, {:ok, _created_object}} <- {:persist_object, Object.create(validated_object)}, diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index 7b8c6450a..1e9ef4356 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -6,11 +6,16 @@ defmodule Pleroma.Web.PleromaAPI.ChatView do use Pleroma.Web, :view alias Pleroma.Chat + alias Pleroma.User + alias Pleroma.Web.MastodonAPI.AccountView + + def render("show.json", %{chat: %Chat{} = chat} = opts) do + recipient = User.get_cached_by_ap_id(chat.recipient) - def render("show.json", %{chat: %Chat{} = chat}) do %{ id: chat.id |> to_string(), recipient: chat.recipient, + recipient_account: AccountView.render("show.json", Map.put(opts, :user, recipient)), unread: chat.unread } end diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs index 7e7f9ebec..4d6f24609 100644 --- a/test/web/activity_pub/transmogrifier/chat_message_test.exs +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -8,6 +8,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageTest do import Pleroma.Factory alias Pleroma.Activity + alias Pleroma.Chat alias Pleroma.Object alias Pleroma.Web.ActivityPub.Transmogrifier @@ -42,6 +43,21 @@ test "it rejects messages that don't concern local users" do {:error, _} = Transmogrifier.handle_incoming(data) end + test "it rejects messages where the `to` field of activity and object don't match" do + data = + File.read!("test/fixtures/create-chat-message.json") + |> Poison.decode!() + + author = insert(:user, ap_id: data["actor"]) + _recipient = insert(:user, ap_id: List.first(data["to"])) + + data = + data + |> Map.put("to", author.ap_id) + + {:error, _} = Transmogrifier.handle_incoming(data) + end + test "it inserts it and creates a chat" do data = File.read!("test/fixtures/create-chat-message.json") @@ -59,6 +75,9 @@ test "it inserts it and creates a chat" do assert object assert object.data["content"] == "You expected a cute girl? Too bad. alert('XSS')" + + refute Chat.get(author.id, recipient.ap_id) + assert Chat.get(recipient.id, author.ap_id) end end end diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs new file mode 100644 index 000000000..1eb0c6241 --- /dev/null +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -0,0 +1,29 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ChatMessageViewTest do + use Pleroma.DataCase + + alias Pleroma.Chat + alias Pleroma.Web.PleromaAPI.ChatView + alias Pleroma.Web.MastodonAPI.AccountView + + import Pleroma.Factory + + test "it represents a chat" do + user = insert(:user) + recipient = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, recipient.ap_id) + + represented_chat = ChatView.render("show.json", chat: chat) + + assert represented_chat == %{ + id: "#{chat.id}", + recipient: recipient.ap_id, + recipient_account: AccountView.render("show.json", user: recipient), + unread: 0 + } + end +end From c8458209110ef65101f965e460329308e5843559 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 17 Apr 2020 16:55:01 +0200 Subject: [PATCH 028/401] Notifications: Create a chat notification. --- lib/pleroma/web/activity_pub/side_effects.ex | 2 ++ .../mastodon_api/views/notification_view.ex | 30 ++++++++++++++++++- test/web/activity_pub/side_effects_test.exs | 19 ++++++++++++ .../views/notification_view_test.exs | 26 ++++++++++++++++ 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 594f32700..f32a99ec4 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -28,6 +28,8 @@ def handle(%{data: %{"type" => "Create", "object" => object_id}} = activity, met {:ok, _object} = handle_object_creation(object) + Notification.create_notifications(activity) + {:ok, activity, meta} end diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 734ffbf39..5d231f0c4 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -8,11 +8,13 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do alias Pleroma.Activity alias Pleroma.Notification alias Pleroma.User + alias Pleroma.Object alias Pleroma.UserRelationship alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.PleromaAPI.ChatMessageView def render("index.json", %{notifications: notifications, for: reading_user} = opts) do activities = Enum.map(notifications, & &1.activity) @@ -81,7 +83,20 @@ def render( end end - mastodon_type = Activity.mastodon_notification_type(activity) + # This returns the notification type by activity, but both chats and statuses are in "Create" activities. + mastodon_type = + case Activity.mastodon_notification_type(activity) do + "mention" -> + object = Object.normalize(activity) + + case object do + %{data: %{"type" => "ChatMessage"}} -> "pleroma:chat_mention" + _ -> "mention" + end + + type -> + type + end render_opts = %{ relationships: opts[:relationships], @@ -125,6 +140,9 @@ def render( |> put_status(parent_activity_fn.(), reading_user, render_opts) |> put_emoji(activity) + "pleroma:chat_mention" -> + put_chat_message(response, activity, reading_user, render_opts) + _ -> nil end @@ -137,6 +155,16 @@ defp put_emoji(response, activity) do Map.put(response, :emoji, activity.data["content"]) end + defp put_chat_message(response, activity, reading_user, opts) do + object = Object.normalize(activity) + author = User.get_cached_by_ap_id(object.data["actor"]) + chat = Pleroma.Chat.get(reading_user.id, author.ap_id) + render_opts = Map.merge(opts, %{object: object, for: reading_user, chat: chat}) + chat_message_render = ChatMessageView.render("show.json", render_opts) + + Map.put(response, :chat_message, chat_message_render) + end + defp put_status(response, activity, reading_user, opts) do status_render_opts = Map.merge(opts, %{activity: activity, for: reading_user}) status_render = StatusView.render("show.json", status_render_opts) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index b629d0d5d..d3ad4866c 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -6,7 +6,9 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do use Pleroma.DataCase alias Pleroma.Chat + alias Pleroma.Notification alias Pleroma.Object + alias Pleroma.Repo alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.SideEffects @@ -34,6 +36,23 @@ test "add the like to the original object", %{like: like, user: user} do end describe "creation of ChatMessages" do + test "notifies the recipient" do + author = insert(:user, local: false) + recipient = insert(:user, local: true) + + {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") + {:ok, chat_message_object} = Object.create(chat_message_data) + + {:ok, create_activity_data, _meta} = + Builder.create(author, chat_message_object.data["id"], [recipient.ap_id]) + + {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) + + {:ok, _create_activity, _meta} = SideEffects.handle(create_activity) + + assert Repo.get_by(Notification, user_id: recipient.id, activity_id: create_activity.id) + end + test "it creates a Chat for the local users and bumps the unread count" do author = insert(:user, local: false) recipient = insert(:user, local: true) diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index c3ec9dfec..a48c298f2 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -6,7 +6,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do use Pleroma.DataCase alias Pleroma.Activity + alias Pleroma.Chat alias Pleroma.Notification + alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.CommonAPI @@ -14,6 +16,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.PleromaAPI.ChatMessageView import Pleroma.Factory defp test_notifications_rendering(notifications, user, expected_result) do @@ -31,6 +34,29 @@ defp test_notifications_rendering(notifications, user, expected_result) do assert expected_result == result end + test "ChatMessage notification" do + user = insert(:user) + recipient = insert(:user) + {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "what's up my dude") + + {:ok, [notification]} = Notification.create_notifications(activity) + + object = Object.normalize(activity) + chat = Chat.get(recipient.id, user.ap_id) + + expected = %{ + id: to_string(notification.id), + pleroma: %{is_seen: false}, + type: "pleroma:chat_mention", + account: AccountView.render("show.json", %{user: user, for: recipient}), + chat_message: + ChatMessageView.render("show.json", %{object: object, for: recipient, chat: chat}), + created_at: Utils.to_masto_date(notification.inserted_at) + } + + test_notifications_rendering([notification], recipient, [expected]) + end + test "Mention notification" do user = insert(:user) mentioned_user = insert(:user) From ce23673ca1539350802326c62d6e72bd040950f6 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 20 Apr 2020 11:45:11 +0200 Subject: [PATCH 029/401] ChatMessageValidator: Don't validate messages that are too long. --- .../object_validators/chat_message_validator.ex | 1 + test/web/activity_pub/object_validator_test.exs | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index a4e4460cd..caf2138a7 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -56,6 +56,7 @@ def validate_data(data_cng) do |> validate_inclusion(:type, ["ChatMessage"]) |> validate_required([:id, :actor, :to, :type, :content]) |> validate_length(:to, is: 1) + |> validate_length(:content, max: Pleroma.Config.get([:instance, :remote_limit])) |> validate_local_concern() end diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index bf0bfdfaf..e416e0808 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do describe "chat messages" do setup do + clear_config([:instance, :remote_limit]) user = insert(:user) recipient = insert(:user, local: false) @@ -23,6 +24,13 @@ test "validates for a basic object we build", %{valid_chat_message: valid_chat_m assert {:ok, _object, _meta} = ObjectValidator.validate(valid_chat_message, []) end + test "does not validate if the message is longer than the remote_limit", %{ + valid_chat_message: valid_chat_message + } do + Pleroma.Config.put([:instance, :remote_limit], 2) + refute match?({:ok, _object, _meta}, ObjectValidator.validate(valid_chat_message, [])) + end + test "does not validate if the actor or the recipient is not in our system", %{ valid_chat_message: valid_chat_message } do From 5b6818b3e5dc39e328f6f8d4b8f4587e5e1cef94 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 20 Apr 2020 12:08:47 +0200 Subject: [PATCH 030/401] CommonAPI: Obey local limit for chat messages. --- lib/pleroma/web/common_api/common_api.ex | 8 +++++++- test/web/common_api/common_api_test.exs | 18 ++++++++++++++++++ .../views/chat_message_view_test.exs | 4 ++-- test/web/pleroma_api/views/chat_view_test.exs | 2 +- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 2b8add2fa..fcb0af4e8 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -28,7 +28,10 @@ defmodule Pleroma.Web.CommonAPI do def post_chat_message(%User{} = user, %User{} = recipient, content) do transaction = Repo.transaction(fn -> - with {_, {:ok, chat_message_data, _meta}} <- + with {_, true} <- + {:content_length, + String.length(content) <= Pleroma.Config.get([:instance, :chat_limit])}, + {_, {:ok, chat_message_data, _meta}} <- {:build_object, Builder.chat_message( user, @@ -43,6 +46,9 @@ def post_chat_message(%User{} = user, %User{} = recipient, content) do {_, {:ok, %Activity{} = activity, _meta}} <- {:common_pipeline, Pipeline.common_pipeline(create_activity_data, local: true)} do {:ok, activity} + else + {:content_length, false} -> {:error, :content_too_long} + e -> e end end) diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 1984aac8d..c17e30210 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -23,6 +23,8 @@ defmodule Pleroma.Web.CommonAPITest do setup do: clear_config([:instance, :max_pinned_statuses]) describe "posting chat messages" do + setup do: clear_config([:instance, :chat_limit]) + test "it posts a chat message" do author = insert(:user) recipient = insert(:user) @@ -47,6 +49,22 @@ test "it posts a chat message" do assert Chat.get(author.id, recipient.ap_id) assert Chat.get(recipient.id, author.ap_id) end + + test "it reject messages over the local limit" do + Pleroma.Config.put([:instance, :chat_limit], 2) + + author = insert(:user) + recipient = insert(:user) + + {:error, message} = + CommonAPI.post_chat_message( + author, + recipient, + "123" + ) + + assert message == :content_too_long + end end test "when replying to a conversation / participation, it will set the correct context id even if no explicit reply_to is given" do diff --git a/test/web/pleroma_api/views/chat_message_view_test.exs b/test/web/pleroma_api/views/chat_message_view_test.exs index e690da022..ad8febee6 100644 --- a/test/web/pleroma_api/views/chat_message_view_test.exs +++ b/test/web/pleroma_api/views/chat_message_view_test.exs @@ -23,7 +23,7 @@ test "it displays a chat message" do chat_message = ChatMessageView.render("show.json", object: object, for: user, chat: chat) - assert chat_message[:id] == object.id + assert chat_message[:id] == object.id |> to_string() assert chat_message[:content] == "kippis" assert chat_message[:actor] == user.ap_id assert chat_message[:chat_id] @@ -34,7 +34,7 @@ test "it displays a chat message" do chat_message_two = ChatMessageView.render("show.json", object: object, for: user, chat: chat) - assert chat_message_two[:id] == object.id + assert chat_message_two[:id] == object.id |> to_string() assert chat_message_two[:content] == "gkgkgk" assert chat_message_two[:actor] == recipient.ap_id assert chat_message_two[:chat_id] == chat_message[:chat_id] diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index 1eb0c6241..3dca555e8 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -2,7 +2,7 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.PleromaAPI.ChatMessageViewTest do +defmodule Pleroma.Web.PleromaAPI.ChatViewTest do use Pleroma.DataCase alias Pleroma.Chat From 970b74383b43aa9a54c3cf59012944355e3eafbc Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 20 Apr 2020 12:29:19 +0200 Subject: [PATCH 031/401] Credo fixes. --- lib/pleroma/chat.ex | 2 +- lib/pleroma/web/activity_pub/object_validator.ex | 2 +- .../object_validators/chat_message_validator.ex | 2 +- lib/pleroma/web/common_api/common_api.ex | 2 +- lib/pleroma/web/mastodon_api/views/notification_view.ex | 5 +++-- lib/pleroma/web/pleroma_api/controllers/chat_controller.ex | 6 +++--- test/web/activity_pub/object_validator_test.exs | 2 +- test/web/pleroma_api/views/chat_view_test.exs | 2 +- 8 files changed, 12 insertions(+), 11 deletions(-) diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index c2044881f..b8545063a 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -6,8 +6,8 @@ defmodule Pleroma.Chat do use Ecto.Schema import Ecto.Changeset - alias Pleroma.User alias Pleroma.Repo + alias Pleroma.User @moduledoc """ Chat keeps a reference to ChatMessage conversations between a user and an recipient. The recipient can be a user (for now) or a group (not implemented yet). diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 259bbeb64..03db681ec 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -11,9 +11,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Object alias Pleroma.User - alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index caf2138a7..6e3477cd1 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -5,8 +5,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do use Ecto.Schema - alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.User + alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index fcb0af4e8..9e25f4c2f 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.CommonAPI do alias Pleroma.ActivityExpiration alias Pleroma.Conversation.Participation alias Pleroma.FollowingRelationship + alias Pleroma.Formatter alias Pleroma.Object alias Pleroma.Repo alias Pleroma.ThreadMute @@ -17,7 +18,6 @@ defmodule Pleroma.Web.CommonAPI do alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility - alias Pleroma.Formatter import Pleroma.Web.Gettext import Pleroma.Web.CommonAPI.Utils diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 5d231f0c4..0b05d178b 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -7,8 +7,8 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do alias Pleroma.Activity alias Pleroma.Notification - alias Pleroma.User alias Pleroma.Object + alias Pleroma.User alias Pleroma.UserRelationship alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView @@ -83,7 +83,8 @@ def render( end end - # This returns the notification type by activity, but both chats and statuses are in "Create" activities. + # This returns the notification type by activity, but both chats and statuses + # are in "Create" activities. mastodon_type = case Activity.mastodon_notification_type(activity) do "mention" -> diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 31c723426..9d8b9b3cf 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -6,13 +6,13 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do alias Pleroma.Chat alias Pleroma.Object + alias Pleroma.Pagination + alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Repo alias Pleroma.User - alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Web.CommonAPI - alias Pleroma.Web.PleromaAPI.ChatView alias Pleroma.Web.PleromaAPI.ChatMessageView - alias Pleroma.Pagination + alias Pleroma.Web.PleromaAPI.ChatView import Ecto.Query diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index e416e0808..3ac5ecaf4 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -1,11 +1,11 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do use Pleroma.DataCase + alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI - alias Pleroma.Web.ActivityPub.Builder import Pleroma.Factory diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index 3dca555e8..725da5ff8 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -6,8 +6,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatViewTest do use Pleroma.DataCase alias Pleroma.Chat - alias Pleroma.Web.PleromaAPI.ChatView alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.PleromaAPI.ChatView import Pleroma.Factory From b836d3d104f75841d71f9cf7c5c8cb5c07ba7294 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 20 Apr 2020 13:14:59 +0200 Subject: [PATCH 032/401] ChatMessageValidator: Require `published` field --- lib/pleroma/web/activity_pub/builder.ex | 6 ++++-- .../object_validators/chat_message_validator.ex | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index f0a6c1e1b..b67166a30 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -17,7 +17,8 @@ def create(actor, object_id, recipients) do "actor" => actor.ap_id, "to" => recipients, "object" => object_id, - "type" => "Create" + "type" => "Create", + "published" => DateTime.utc_now() |> DateTime.to_iso8601() }, []} end @@ -28,7 +29,8 @@ def chat_message(actor, recipient, content) do "actor" => actor.ap_id, "type" => "ChatMessage", "to" => [recipient], - "content" => content + "content" => content, + "published" => DateTime.utc_now() |> DateTime.to_iso8601() }, []} end diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index 6e3477cd1..9b8262553 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -54,7 +54,7 @@ def changeset(struct, data) do def validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["ChatMessage"]) - |> validate_required([:id, :actor, :to, :type, :content]) + |> validate_required([:id, :actor, :to, :type, :content, :published]) |> validate_length(:to, is: 1) |> validate_length(:content, max: Pleroma.Config.get([:instance, :remote_limit])) |> validate_local_concern() From 7e53da250e3b41e01073148efea0fc4f49dea9d5 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 20 Apr 2020 14:08:54 +0200 Subject: [PATCH 033/401] ChatMessage: Support emoji. --- lib/pleroma/web/activity_pub/builder.ex | 4 ++- .../chat_message_validator.ex | 1 + test/fixtures/create-chat-message.json | 30 +++++++++---------- .../activity_pub/object_validator_test.exs | 6 ++-- test/web/common_api/common_api_test.exs | 8 +++-- 5 files changed, 29 insertions(+), 20 deletions(-) diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index b67166a30..7576ed278 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ActivityPub.Builder do This module encodes our addressing policies and general shape of our objects. """ + alias Pleroma.Emoji alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.Utils @@ -30,7 +31,8 @@ def chat_message(actor, recipient, content) do "type" => "ChatMessage", "to" => [recipient], "content" => content, - "published" => DateTime.utc_now() |> DateTime.to_iso8601() + "published" => DateTime.utc_now() |> DateTime.to_iso8601(), + "emoji" => Emoji.Formatter.get_emoji_map(content) }, []} end diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index 9b8262553..2feb65f29 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -20,6 +20,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do field(:content, :string) field(:actor, Types.ObjectID) field(:published, Types.DateTime) + field(:emoji, :map, default: %{}) end def cast_and_apply(data) do diff --git a/test/fixtures/create-chat-message.json b/test/fixtures/create-chat-message.json index 2e4608f43..6db5b9f5c 100644 --- a/test/fixtures/create-chat-message.json +++ b/test/fixtures/create-chat-message.json @@ -1,19 +1,19 @@ { - "actor": "http://2hu.gensokyo/users/raymoo", - "id": "http://2hu.gensokyo/objects/1", - "object": { - "attributedTo": "http://2hu.gensokyo/users/raymoo", - "content": "You expected a cute girl? Too bad. ", - "id": "http://2hu.gensokyo/objects/2", - "published": "2020-02-12T14:08:20Z", - "to": [ - "http://2hu.gensokyo/users/marisa" - ], - "type": "ChatMessage" - }, - "published": "2018-02-12T14:08:20Z", + "actor": "http://2hu.gensokyo/users/raymoo", + "id": "http://2hu.gensokyo/objects/1", + "object": { + "attributedTo": "http://2hu.gensokyo/users/raymoo", + "content": "You expected a cute girl? Too bad. ", + "id": "http://2hu.gensokyo/objects/2", + "published": "2020-02-12T14:08:20Z", "to": [ - "http://2hu.gensokyo/users/marisa" + "http://2hu.gensokyo/users/marisa" ], - "type": "Create" + "type": "ChatMessage" + }, + "published": "2018-02-12T14:08:20Z", + "to": [ + "http://2hu.gensokyo/users/marisa" + ], + "type": "Create" } diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 3ac5ecaf4..8230ae0d9 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -15,13 +15,15 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do user = insert(:user) recipient = insert(:user, local: false) - {:ok, valid_chat_message, _} = Builder.chat_message(user, recipient.ap_id, "hey") + {:ok, valid_chat_message, _} = Builder.chat_message(user, recipient.ap_id, "hey :firefox:") %{user: user, recipient: recipient, valid_chat_message: valid_chat_message} end test "validates for a basic object we build", %{valid_chat_message: valid_chat_message} do - assert {:ok, _object, _meta} = ObjectValidator.validate(valid_chat_message, []) + assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) + + assert object == valid_chat_message end test "does not validate if the message is longer than the remote_limit", %{ diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index c17e30210..86b3648ac 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -33,7 +33,7 @@ test "it posts a chat message" do CommonAPI.post_chat_message( author, recipient, - "a test message " + "a test message :firefox:" ) assert activity.data["type"] == "Create" @@ -44,7 +44,11 @@ test "it posts a chat message" do assert object.data["to"] == [recipient.ap_id] assert object.data["content"] == - "a test message <script>alert('uuu')</script>" + "a test message <script>alert('uuu')</script> :firefox:" + + assert object.data["emoji"] == %{ + "firefox" => "http://localhost:4001/emoji/Firefox.gif" + } assert Chat.get(author.id, recipient.ap_id) assert Chat.get(recipient.id, author.ap_id) From b5df4a98e4044cf1360f03f7dc3a0b59932ec8f5 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 20 Apr 2020 14:38:53 +0200 Subject: [PATCH 034/401] ChatMessageView: Support emoji. --- lib/pleroma/web/pleroma_api/views/chat_message_view.ex | 6 +++++- test/web/pleroma_api/views/chat_message_view_test.exs | 6 ++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex index fdbb9ff1b..b40ab92a0 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex @@ -6,6 +6,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatMessageView do use Pleroma.Web, :view alias Pleroma.Chat + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.MastodonAPI.StatusView def render( "show.json", @@ -18,7 +20,9 @@ def render( id: id |> to_string(), content: chat_message["content"], chat_id: chat_id |> to_string(), - actor: chat_message["actor"] + actor: chat_message["actor"], + created_at: Utils.to_masto_date(chat_message["published"]), + emojis: StatusView.build_emojis(chat_message["emoji"]) } end diff --git a/test/web/pleroma_api/views/chat_message_view_test.exs b/test/web/pleroma_api/views/chat_message_view_test.exs index ad8febee6..115335f10 100644 --- a/test/web/pleroma_api/views/chat_message_view_test.exs +++ b/test/web/pleroma_api/views/chat_message_view_test.exs @@ -15,7 +15,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatMessageViewTest do test "it displays a chat message" do user = insert(:user) recipient = insert(:user) - {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "kippis") + {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "kippis :firefox:") chat = Chat.get(user.id, recipient.ap_id) @@ -24,9 +24,11 @@ test "it displays a chat message" do chat_message = ChatMessageView.render("show.json", object: object, for: user, chat: chat) assert chat_message[:id] == object.id |> to_string() - assert chat_message[:content] == "kippis" + assert chat_message[:content] == "kippis :firefox:" assert chat_message[:actor] == user.ap_id assert chat_message[:chat_id] + assert chat_message[:created_at] + assert match?([%{shortcode: "firefox"}], chat_message[:emojis]) {:ok, activity} = CommonAPI.post_chat_message(recipient, user, "gkgkgk") From 97ad0c45977261df3068ca4f0c3febce3173c058 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 21 Apr 2020 17:51:06 +0200 Subject: [PATCH 035/401] Chats: Add API specs. --- .../web/api_spec/operations/chat_operation.ex | 96 ++++++++++++------- .../schemas/chat_message_create_request.ex | 20 ++++ .../api_spec/schemas/chat_message_response.ex | 38 ++++++++ .../schemas/chat_messages_response.ex | 41 ++++++++ .../web/api_spec/schemas/chat_response.ex | 73 ++++++++++++++ .../web/api_spec/schemas/chats_response.ex | 69 +++++++++++++ .../controllers/chat_controller_test.exs | 42 ++++++++ 7 files changed, 346 insertions(+), 33 deletions(-) create mode 100644 lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex create mode 100644 lib/pleroma/web/api_spec/schemas/chat_message_response.ex create mode 100644 lib/pleroma/web/api_spec/schemas/chat_messages_response.ex create mode 100644 lib/pleroma/web/api_spec/schemas/chat_response.ex create mode 100644 lib/pleroma/web/api_spec/schemas/chats_response.ex diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 038ebb29d..5bd41ec4f 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -4,7 +4,12 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do alias OpenApiSpex.Operation - alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest + alias Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse + alias Pleroma.Web.ApiSpec.Schemas.ChatMessagesResponse + alias Pleroma.Web.ApiSpec.Schemas.ChatResponse + alias Pleroma.Web.ApiSpec.Schemas.ChatsResponse @spec open_api_operation(atom) :: Operation.t() def open_api_operation(action) do @@ -16,16 +21,25 @@ def create_operation do %Operation{ tags: ["chat"], summary: "Create a chat", + parameters: [ + Operation.parameter( + :ap_id, + :path, + :string, + "The ActivityPub id of the recipient of this chat.", + required: true, + example: "https://lain.com/users/lain" + ) + ], responses: %{ 200 => - Operation.response("Chat", "application/json", %Schema{ - type: :object, - description: "A created chat is returned", - properties: %{ - id: %Schema{type: :integer} - } - }) - } + Operation.response("The created or existing chat", "application/json", ChatResponse) + }, + security: [ + %{ + "oAuth" => ["write"] + } + ] } end @@ -33,17 +47,19 @@ def index_operation do %Operation{ tags: ["chat"], summary: "Get a list of chats that you participated in", + parameters: [ + Operation.parameter(:limit, :query, :integer, "How many results to return", example: 20), + Operation.parameter(:min_id, :query, :string, "Return only chats after this id"), + Operation.parameter(:max_id, :query, :string, "Return only chats before this id") + ], responses: %{ - 200 => - Operation.response("Chats", "application/json", %Schema{ - type: :array, - description: "A list of chats", - items: %Schema{ - type: :object, - description: "A chat" - } - }) - } + 200 => Operation.response("The chats of the user", "application/json", ChatsResponse) + }, + security: [ + %{ + "oAuth" => ["read"] + } + ] } end @@ -51,17 +67,21 @@ def messages_operation do %Operation{ tags: ["chat"], summary: "Get the most recent messages of the chat", + parameters: [ + Operation.parameter(:id, :path, :string, "The ID of the Chat"), + Operation.parameter(:limit, :query, :integer, "How many results to return", example: 20), + Operation.parameter(:min_id, :query, :string, "Return only messages after this id"), + Operation.parameter(:max_id, :query, :string, "Return only messages before this id") + ], responses: %{ 200 => - Operation.response("Messages", "application/json", %Schema{ - type: :array, - description: "A list of chat messages", - items: %Schema{ - type: :object, - description: "A chat message" - } - }) - } + Operation.response("The messages in the chat", "application/json", ChatMessagesResponse) + }, + security: [ + %{ + "oAuth" => ["read"] + } + ] } end @@ -69,13 +89,23 @@ def post_chat_message_operation do %Operation{ tags: ["chat"], summary: "Post a message to the chat", + parameters: [ + Operation.parameter(:id, :path, :string, "The ID of the Chat") + ], + requestBody: Helpers.request_body("Parameters", ChatMessageCreateRequest, required: true), responses: %{ 200 => - Operation.response("Message", "application/json", %Schema{ - type: :object, - description: "A chat message" - }) - } + Operation.response( + "The newly created ChatMessage", + "application/json", + ChatMessageResponse + ) + }, + security: [ + %{ + "oAuth" => ["write"] + } + ] } end end diff --git a/lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex b/lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex new file mode 100644 index 000000000..4dafcda43 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex @@ -0,0 +1,20 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest do + alias OpenApiSpex.Schema + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ChatMessageCreateRequest", + description: "POST body for creating an chat message", + type: :object, + properties: %{ + content: %Schema{type: :string, description: "The content of your message"} + }, + example: %{ + "content" => "Hey wanna buy feet pics?" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/chat_message_response.ex b/lib/pleroma/web/api_spec/schemas/chat_message_response.ex new file mode 100644 index 000000000..e94c00369 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/chat_message_response.ex @@ -0,0 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ChatMessageResponse", + description: "Response schema for a ChatMessage", + type: :object, + properties: %{ + id: %Schema{type: :string}, + actor: %Schema{type: :string, description: "The ActivityPub id of the actor"}, + chat_id: %Schema{type: :string}, + content: %Schema{type: :string}, + created_at: %Schema{type: :string, format: :datetime}, + emojis: %Schema{type: :array} + }, + example: %{ + "actor" => "https://dontbulling.me/users/lain", + "chat_id" => "1", + "content" => "hey you again", + "created_at" => "2020-04-21T15:06:45.000Z", + "emojis" => [ + %{ + "static_url" => "https://dontbulling.me/emoji/Firefox.gif", + "visible_in_picker" => false, + "shortcode" => "firefox", + "url" => "https://dontbulling.me/emoji/Firefox.gif" + } + ], + "id" => "14" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/chat_messages_response.ex b/lib/pleroma/web/api_spec/schemas/chat_messages_response.ex new file mode 100644 index 000000000..302bdec95 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/chat_messages_response.ex @@ -0,0 +1,41 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessagesResponse do + alias Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ChatMessagesResponse", + description: "Response schema for multiple ChatMessages", + type: :array, + items: ChatMessageResponse, + example: [ + %{ + "emojis" => [ + %{ + "static_url" => "https://dontbulling.me/emoji/Firefox.gif", + "visible_in_picker" => false, + "shortcode" => "firefox", + "url" => "https://dontbulling.me/emoji/Firefox.gif" + } + ], + "created_at" => "2020-04-21T15:11:46.000Z", + "content" => "Check this out :firefox:", + "id" => "13", + "chat_id" => "1", + "actor" => "https://dontbulling.me/users/lain" + }, + %{ + "actor" => "https://dontbulling.me/users/lain", + "content" => "Whats' up?", + "id" => "12", + "chat_id" => "1", + "emojis" => [], + "created_at" => "2020-04-21T15:06:45.000Z" + } + ] + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/chat_response.ex b/lib/pleroma/web/api_spec/schemas/chat_response.ex new file mode 100644 index 000000000..a80f4d173 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/chat_response.ex @@ -0,0 +1,73 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ChatResponse do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ChatResponse", + description: "Response schema for a Chat", + type: :object, + properties: %{ + id: %Schema{type: :string}, + recipient: %Schema{type: :string}, + # TODO: Make this reference the account structure. + recipient_account: %Schema{type: :object}, + unread: %Schema{type: :integer} + }, + example: %{ + "recipient" => "https://dontbulling.me/users/lain", + "recipient_account" => %{ + "pleroma" => %{ + "is_admin" => false, + "confirmation_pending" => false, + "hide_followers_count" => false, + "is_moderator" => false, + "hide_favorites" => true, + "ap_id" => "https://dontbulling.me/users/lain", + "hide_follows_count" => false, + "hide_follows" => false, + "background_image" => nil, + "skip_thread_containment" => false, + "hide_followers" => false, + "relationship" => %{}, + "tags" => [] + }, + "avatar" => + "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", + "following_count" => 0, + "header_static" => "https://originalpatchou.li/images/banner.png", + "source" => %{ + "sensitive" => false, + "note" => "lain", + "pleroma" => %{ + "discoverable" => false, + "actor_type" => "Person" + }, + "fields" => [] + }, + "statuses_count" => 1, + "locked" => false, + "created_at" => "2020-04-16T13:40:15.000Z", + "display_name" => "lain", + "fields" => [], + "acct" => "lain@dontbulling.me", + "id" => "9u6Qw6TAZANpqokMkK", + "emojis" => [], + "avatar_static" => + "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", + "username" => "lain", + "followers_count" => 0, + "header" => "https://originalpatchou.li/images/banner.png", + "bot" => false, + "note" => "lain", + "url" => "https://dontbulling.me/users/lain" + }, + "id" => "1", + "unread" => 2 + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/chats_response.ex b/lib/pleroma/web/api_spec/schemas/chats_response.ex new file mode 100644 index 000000000..3349e0691 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/chats_response.ex @@ -0,0 +1,69 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ChatsResponse do + alias Pleroma.Web.ApiSpec.Schemas.ChatResponse + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ChatsResponse", + description: "Response schema for multiple Chats", + type: :array, + items: ChatResponse, + example: [ + %{ + "recipient" => "https://dontbulling.me/users/lain", + "recipient_account" => %{ + "pleroma" => %{ + "is_admin" => false, + "confirmation_pending" => false, + "hide_followers_count" => false, + "is_moderator" => false, + "hide_favorites" => true, + "ap_id" => "https://dontbulling.me/users/lain", + "hide_follows_count" => false, + "hide_follows" => false, + "background_image" => nil, + "skip_thread_containment" => false, + "hide_followers" => false, + "relationship" => %{}, + "tags" => [] + }, + "avatar" => + "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", + "following_count" => 0, + "header_static" => "https://originalpatchou.li/images/banner.png", + "source" => %{ + "sensitive" => false, + "note" => "lain", + "pleroma" => %{ + "discoverable" => false, + "actor_type" => "Person" + }, + "fields" => [] + }, + "statuses_count" => 1, + "locked" => false, + "created_at" => "2020-04-16T13:40:15.000Z", + "display_name" => "lain", + "fields" => [], + "acct" => "lain@dontbulling.me", + "id" => "9u6Qw6TAZANpqokMkK", + "emojis" => [], + "avatar_static" => + "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", + "username" => "lain", + "followers_count" => 0, + "header" => "https://originalpatchou.li/images/banner.png", + "bot" => false, + "note" => "lain", + "url" => "https://dontbulling.me/users/lain" + }, + "id" => "1", + "unread" => 2 + } + ] + }) +end diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 0750c7273..52a34d23f 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -5,8 +5,14 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do use Pleroma.Web.ConnCase, async: true alias Pleroma.Chat + alias Pleroma.Web.ApiSpec + alias Pleroma.Web.ApiSpec.Schemas.ChatResponse + alias Pleroma.Web.ApiSpec.Schemas.ChatsResponse + alias Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse + alias Pleroma.Web.ApiSpec.Schemas.ChatMessagesResponse alias Pleroma.Web.CommonAPI + import OpenApiSpex.TestAssertions import Pleroma.Factory describe "POST /api/v1/pleroma/chats/:id/messages" do @@ -24,6 +30,7 @@ test "it posts a message to the chat", %{conn: conn, user: user} do assert result["content"] == "Hallo!!" assert result["chat_id"] == chat.id |> to_string() + assert_schema(result, "ChatMessageResponse", ApiSpec.spec()) end end @@ -45,6 +52,7 @@ test "it paginates", %{conn: conn, user: user} do |> json_response(200) assert length(result) == 20 + assert_schema(result, "ChatMessagesResponse", ApiSpec.spec()) result = conn @@ -52,6 +60,7 @@ test "it paginates", %{conn: conn, user: user} do |> json_response(200) assert length(result) == 10 + assert_schema(result, "ChatMessagesResponse", ApiSpec.spec()) end test "it returns the messages for a given chat", %{conn: conn, user: user} do @@ -76,6 +85,7 @@ test "it returns the messages for a given chat", %{conn: conn, user: user} do end) assert length(result) == 3 + assert_schema(result, "ChatMessagesResponse", ApiSpec.spec()) # Trying to get the chat of a different user result = @@ -99,6 +109,7 @@ test "it creates or returns a chat", %{conn: conn} do |> json_response(200) assert result["id"] + assert_schema(result, "ChatResponse", ApiSpec.spec()) end end @@ -117,6 +128,7 @@ test "it paginates", %{conn: conn, user: user} do |> json_response(200) assert length(result) == 20 + assert_schema(result, "ChatsResponse", ApiSpec.spec()) result = conn @@ -124,6 +136,8 @@ test "it paginates", %{conn: conn, user: user} do |> json_response(200) assert length(result) == 10 + + assert_schema(result, "ChatsResponse", ApiSpec.spec()) end test "it return a list of chats the current user is participating in, in descending order of updates", @@ -154,6 +168,34 @@ test "it return a list of chats the current user is participating in, in descend chat_3.id |> to_string(), chat_1.id |> to_string() ] + + assert_schema(result, "ChatsResponse", ApiSpec.spec()) + end + end + + describe "schemas" do + test "Chat example matches schema" do + api_spec = ApiSpec.spec() + schema = ChatResponse.schema() + assert_schema(schema.example, "ChatResponse", api_spec) + end + + test "Chats example matches schema" do + api_spec = ApiSpec.spec() + schema = ChatsResponse.schema() + assert_schema(schema.example, "ChatsResponse", api_spec) + end + + test "ChatMessage example matches schema" do + api_spec = ApiSpec.spec() + schema = ChatMessageResponse.schema() + assert_schema(schema.example, "ChatMessageResponse", api_spec) + end + + test "ChatsMessage example matches schema" do + api_spec = ApiSpec.spec() + schema = ChatMessagesResponse.schema() + assert_schema(schema.example, "ChatMessagesResponse", api_spec) end end end From 66c2eb670b273d808f0a9c1ae087df064718ca3d Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 21 Apr 2020 18:23:00 +0200 Subject: [PATCH 036/401] ChatController: Validate parameters. --- .../web/api_spec/operations/chat_operation.ex | 4 ++++ .../controllers/chat_controller.ex | 22 ++++++++++++------- .../controllers/chat_controller_test.exs | 5 +++-- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 5bd41ec4f..dc99bd773 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -21,6 +21,7 @@ def create_operation do %Operation{ tags: ["chat"], summary: "Create a chat", + operationId: "ChatController.create", parameters: [ Operation.parameter( :ap_id, @@ -47,6 +48,7 @@ def index_operation do %Operation{ tags: ["chat"], summary: "Get a list of chats that you participated in", + operationId: "ChatController.index", parameters: [ Operation.parameter(:limit, :query, :integer, "How many results to return", example: 20), Operation.parameter(:min_id, :query, :string, "Return only chats after this id"), @@ -67,6 +69,7 @@ def messages_operation do %Operation{ tags: ["chat"], summary: "Get the most recent messages of the chat", + operationId: "ChatController.messages", parameters: [ Operation.parameter(:id, :path, :string, "The ID of the Chat"), Operation.parameter(:limit, :query, :integer, "How many results to return", example: 20), @@ -89,6 +92,7 @@ def post_chat_message_operation do %Operation{ tags: ["chat"], summary: "Post a message to the chat", + operationId: "ChatController.post_chat_message", parameters: [ Operation.parameter(:id, :path, :string, "The ID of the Chat") ], diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 9d8b9b3cf..771ad6217 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -14,6 +14,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do alias Pleroma.Web.PleromaAPI.ChatMessageView alias Pleroma.Web.PleromaAPI.ChatView + import Pleroma.Web.ActivityPub.ObjectValidator, only: [stringify_keys: 1] + import Ecto.Query # TODO @@ -29,12 +31,16 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do %{scopes: ["read:statuses"]} when action in [:messages, :index] ) + plug(OpenApiSpex.Plug.CastAndValidate) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ChatOperation - def post_chat_message(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ - "id" => id, - "content" => content - }) do + def post_chat_message( + %{body_params: %{content: content}, assigns: %{user: %{id: user_id} = user}} = conn, + %{ + id: id + } + ) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), %User{} = recipient <- User.get_cached_by_ap_id(chat.recipient), {:ok, activity} <- CommonAPI.post_chat_message(user, recipient, content), @@ -45,7 +51,7 @@ def post_chat_message(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ end end - def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => id} = params) do + def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{id: id} = params) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id) do messages = from(o in Object, @@ -66,7 +72,7 @@ def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => id} = ^[user.ap_id] ) ) - |> Pagination.fetch_paginated(params) + |> Pagination.fetch_paginated(params |> stringify_keys()) conn |> put_view(ChatMessageView) @@ -85,7 +91,7 @@ def index(%{assigns: %{user: %{id: user_id}}} = conn, params) do where: c.user_id == ^user_id, order_by: [desc: c.updated_at] ) - |> Pagination.fetch_paginated(params) + |> Pagination.fetch_paginated(params |> stringify_keys) conn |> put_view(ChatView) @@ -93,7 +99,7 @@ def index(%{assigns: %{user: %{id: user_id}}} = conn, params) do end def create(%{assigns: %{user: user}} = conn, params) do - recipient = params["ap_id"] |> URI.decode_www_form() + recipient = params[:ap_id] with {:ok, %Chat{} = chat} <- Chat.get_or_create(user.id, recipient) do conn diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 52a34d23f..84610e511 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -25,6 +25,7 @@ test "it posts a message to the chat", %{conn: conn, user: user} do result = conn + |> put_req_header("content-type", "application/json") |> post("/api/v1/pleroma/chats/#{chat.id}/messages", %{"content" => "Hallo!!"}) |> json_response(200) @@ -56,7 +57,7 @@ test "it paginates", %{conn: conn, user: user} do result = conn - |> get("/api/v1/pleroma/chats/#{chat.id}/messages", %{"max_id" => List.last(result)["id"]}) + |> get("/api/v1/pleroma/chats/#{chat.id}/messages?max_id=#{List.last(result)["id"]}") |> json_response(200) assert length(result) == 10 @@ -132,7 +133,7 @@ test "it paginates", %{conn: conn, user: user} do result = conn - |> get("/api/v1/pleroma/chats", %{max_id: List.last(result)["id"]}) + |> get("/api/v1/pleroma/chats?max_id=#{List.last(result)["id"]}") |> json_response(200) assert length(result) == 10 From 6c8390fa4d47a86c34bcc71681ba30f04d14eae9 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 21 Apr 2020 18:32:30 +0200 Subject: [PATCH 037/401] ChatControllerTest: Credo fixes. --- test/web/pleroma_api/controllers/chat_controller_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 84610e511..07b698013 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -6,10 +6,10 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do alias Pleroma.Chat alias Pleroma.Web.ApiSpec - alias Pleroma.Web.ApiSpec.Schemas.ChatResponse - alias Pleroma.Web.ApiSpec.Schemas.ChatsResponse alias Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse alias Pleroma.Web.ApiSpec.Schemas.ChatMessagesResponse + alias Pleroma.Web.ApiSpec.Schemas.ChatResponse + alias Pleroma.Web.ApiSpec.Schemas.ChatsResponse alias Pleroma.Web.CommonAPI import OpenApiSpex.TestAssertions From 2e62a63749e040b108b8afe2c8839c470f89fa04 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 22 Apr 2020 12:48:52 +0200 Subject: [PATCH 038/401] ChatMessageValidator: Validation changes Don't validate if the recipient is blocking the actor. --- .../object_validators/chat_message_validator.ex | 12 +++++++++++- test/web/activity_pub/object_validator_test.exs | 9 +++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index 2feb65f29..8b5bb4fdc 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -61,15 +61,25 @@ def validate_data(data_cng) do |> validate_local_concern() end - @doc "Validates if at least one of the users in this ChatMessage is a local user, otherwise we don't want the message in our system. It also validates the presence of both users in our system." + @doc """ + Validates the following + - If both users are in our system + - If at least one of the users in this ChatMessage is a local user + - If the recipient is not blocking the actor + """ def validate_local_concern(cng) do with actor_ap <- get_field(cng, :actor), {_, %User{} = actor} <- {:find_actor, User.get_cached_by_ap_id(actor_ap)}, {_, %User{} = recipient} <- {:find_recipient, User.get_cached_by_ap_id(get_field(cng, :to) |> hd())}, + {_, false} <- {:blocking_actor?, User.blocks?(recipient, actor)}, {_, true} <- {:local?, Enum.any?([actor, recipient], & &1.local)} do cng else + {:blocking_actor?, true} -> + cng + |> add_error(:actor, "actor is blocked by recipient") + {:local?, false} -> cng |> add_error(:actor, "actor and recipient are both remote") diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 8230ae0d9..bc2317e55 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -33,6 +33,15 @@ test "does not validate if the message is longer than the remote_limit", %{ refute match?({:ok, _object, _meta}, ObjectValidator.validate(valid_chat_message, [])) end + test "does not validate if the recipient is blocking the actor", %{ + valid_chat_message: valid_chat_message, + user: user, + recipient: recipient + } do + Pleroma.User.block(recipient, user) + refute match?({:ok, _object, _meta}, ObjectValidator.validate(valid_chat_message, [])) + end + test "does not validate if the actor or the recipient is not in our system", %{ valid_chat_message: valid_chat_message } do From 1d6338f2d38284e94e17be58c21c7f34b5621ab7 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 22 Apr 2020 12:52:39 +0200 Subject: [PATCH 039/401] Litepub: Add ChatMessage. --- priv/static/schemas/litepub-0.1.jsonld | 1 + 1 file changed, 1 insertion(+) diff --git a/priv/static/schemas/litepub-0.1.jsonld b/priv/static/schemas/litepub-0.1.jsonld index 278ad2f96..7cc3fee40 100644 --- a/priv/static/schemas/litepub-0.1.jsonld +++ b/priv/static/schemas/litepub-0.1.jsonld @@ -30,6 +30,7 @@ "@type": "@id" }, "EmojiReact": "litepub:EmojiReact", + "ChatMessage": "litepub:ChatMessage", "alsoKnownAs": { "@id": "as:alsoKnownAs", "@type": "@id" From 1e28d34592a5fae0f3403763f1ff86cc393a52b0 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 23 Apr 2020 16:19:49 +0200 Subject: [PATCH 040/401] ChatMessage: Correctly ingest emoji tags. --- .../object_validators/chat_message_validator.ex | 2 ++ test/fixtures/create-chat-message.json | 12 ++++++++++++ .../transmogrifier/chat_message_test.exs | 1 + 3 files changed, 15 insertions(+) diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index 8b5bb4fdc..f07045d9d 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset + import Pleroma.Web.ActivityPub.Transmogrifier, only: [fix_emoji: 1] @primary_key false @derive Jason.Encoder @@ -42,6 +43,7 @@ def cast_data(data) do def fix(data) do data + |> fix_emoji() |> Map.put_new("actor", data["attributedTo"]) end diff --git a/test/fixtures/create-chat-message.json b/test/fixtures/create-chat-message.json index 6db5b9f5c..9c23a1c9b 100644 --- a/test/fixtures/create-chat-message.json +++ b/test/fixtures/create-chat-message.json @@ -9,6 +9,18 @@ "to": [ "http://2hu.gensokyo/users/marisa" ], + "tag": [ + { + "icon": { + "type": "Image", + "url": "http://2hu.gensokyo/emoji/Firefox.gif" + }, + "id": "http://2hu.gensokyo/emoji/Firefox.gif", + "name": ":firefox:", + "type": "Emoji", + "updated": "1970-01-01T00:00:00Z" + } + ], "type": "ChatMessage" }, "published": "2018-02-12T14:08:20Z", diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs index 4d6f24609..a63a31e6e 100644 --- a/test/web/activity_pub/transmogrifier/chat_message_test.exs +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -75,6 +75,7 @@ test "it inserts it and creates a chat" do assert object assert object.data["content"] == "You expected a cute girl? Too bad. alert('XSS')" + assert match?(%{"firefox" => _}, object.data["emoji"]) refute Chat.get(author.id, recipient.ap_id) assert Chat.get(recipient.id, author.ap_id) From a51cdafc0192b66ce75659b424a690f52c9b2a49 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 23 Apr 2020 16:55:00 +0200 Subject: [PATCH 041/401] Docs: Add documentation about chatmessages --- docs/ap_extensions.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/ap_extensions.md diff --git a/docs/ap_extensions.md b/docs/ap_extensions.md new file mode 100644 index 000000000..c4550a1ac --- /dev/null +++ b/docs/ap_extensions.md @@ -0,0 +1,35 @@ +# ChatMessages + +ChatMessages are the messages sent in 1-on-1 chats. They are similar to +`Note`s, but the addresing is done by having a single AP actor in the `to` +field. Addressing multiple actors is not allowed. These messages are always +private, there is no public version of them. They are created with a `Create` +activity. + +Example: + +```json +{ + "actor": "http://2hu.gensokyo/users/raymoo", + "id": "http://2hu.gensokyo/objects/1", + "object": { + "attributedTo": "http://2hu.gensokyo/users/raymoo", + "content": "You expected a cute girl? Too bad.", + "id": "http://2hu.gensokyo/objects/2", + "published": "2020-02-12T14:08:20Z", + "to": [ + "http://2hu.gensokyo/users/marisa" + ], + "type": "ChatMessage" + }, + "published": "2018-02-12T14:08:20Z", + "to": [ + "http://2hu.gensokyo/users/marisa" + ], + "type": "Create" +} +``` + +This setup does not prevent multi-user chats, but these will have to go through +a `Group`, which will be the recipient of the messages and then `Announce` them +to the users in the `Group`. From b429a49504b1df6fa085cccbb3e461cd378b15c4 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 23 Apr 2020 23:44:03 +0200 Subject: [PATCH 042/401] mix.exs: Fix for MacOS --- mix.exs | 43 +++++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/mix.exs b/mix.exs index b76aef180..021217400 100644 --- a/mix.exs +++ b/mix.exs @@ -220,32 +220,39 @@ defp aliases do defp version(version) do identifier_filter = ~r/[^0-9a-z\-]+/i - # Pre-release version, denoted from patch version with a hyphen - {tag, tag_err} = - System.cmd("git", ["describe", "--tags", "--abbrev=0"], stderr_to_stdout: true) - - {describe, describe_err} = System.cmd("git", ["describe", "--tags", "--abbrev=8"]) - {commit_hash, commit_hash_err} = System.cmd("git", ["rev-parse", "--short", "HEAD"]) + {_gitpath, git_present} = System.cmd("sh", ["-c", "command -v git"]) git_pre_release = - cond do - tag_err == 0 and describe_err == 0 -> - describe - |> String.trim() - |> String.replace(String.trim(tag), "") - |> String.trim_leading("-") - |> String.trim() + if git_present do + {tag, tag_err} = + System.cmd("git", ["describe", "--tags", "--abbrev=0"], stderr_to_stdout: true) - commit_hash_err == 0 -> - "0-g" <> String.trim(commit_hash) + {describe, describe_err} = System.cmd("git", ["describe", "--tags", "--abbrev=8"]) + {commit_hash, commit_hash_err} = System.cmd("git", ["rev-parse", "--short", "HEAD"]) - true -> - "" + # Pre-release version, denoted from patch version with a hyphen + cond do + tag_err == 0 and describe_err == 0 -> + describe + |> String.trim() + |> String.replace(String.trim(tag), "") + |> String.trim_leading("-") + |> String.trim() + + commit_hash_err == 0 -> + "0-g" <> String.trim(commit_hash) + + true -> + "" + end + else + "" end # Branch name as pre-release version component, denoted with a dot branch_name = - with {branch_name, 0} <- System.cmd("git", ["rev-parse", "--abbrev-ref", "HEAD"]), + with true <- git_present, + {branch_name, 0} <- System.cmd("git", ["rev-parse", "--abbrev-ref", "HEAD"]), branch_name <- String.trim(branch_name), branch_name <- System.get_env("PLEROMA_BUILD_BRANCH") || branch_name, true <- From c63d6ba0b2686db847f70cf251f92bfed57c4e5f Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 23 Apr 2020 23:44:30 +0200 Subject: [PATCH 043/401] mix.exs: branch_name fallbacks to "" --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 021217400..15a65c0fb 100644 --- a/mix.exs +++ b/mix.exs @@ -266,7 +266,7 @@ defp version(version) do branch_name else - _ -> "stable" + _ -> "" end build_name = From 053c46153076f351c5273c2d6b1fb0843e7b6a6d Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 24 Apr 2020 00:26:24 +0200 Subject: [PATCH 044/401] mix.exs: proper check on 0, remove else in git_pre_release --- mix.exs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/mix.exs b/mix.exs index 15a65c0fb..ebb8bdb08 100644 --- a/mix.exs +++ b/mix.exs @@ -220,10 +220,10 @@ defp aliases do defp version(version) do identifier_filter = ~r/[^0-9a-z\-]+/i - {_gitpath, git_present} = System.cmd("sh", ["-c", "command -v git"]) + {_cmdgit, cmdgit_err} = System.cmd("sh", ["-c", "command -v git"]) git_pre_release = - if git_present do + if cmdgit_err == 0 do {tag, tag_err} = System.cmd("git", ["describe", "--tags", "--abbrev=0"], stderr_to_stdout: true) @@ -243,15 +243,13 @@ defp version(version) do "0-g" <> String.trim(commit_hash) true -> - "" + nil end - else - "" end # Branch name as pre-release version component, denoted with a dot branch_name = - with true <- git_present, + with 0 <- cmdgit_err, {branch_name, 0} <- System.cmd("git", ["rev-parse", "--abbrev-ref", "HEAD"]), branch_name <- String.trim(branch_name), branch_name <- System.get_env("PLEROMA_BUILD_BRANCH") || branch_name, From d2bbea1a8076401645600ceb953dd66ec023b3ad Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 27 Apr 2020 12:19:27 +0200 Subject: [PATCH 045/401] ChatControllerTest: Use new schema testing functions. --- .../controllers/chat_controller_test.exs | 58 +++---------------- 1 file changed, 8 insertions(+), 50 deletions(-) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 07b698013..84d7b543e 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -5,14 +5,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do use Pleroma.Web.ConnCase, async: true alias Pleroma.Chat - alias Pleroma.Web.ApiSpec - alias Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse - alias Pleroma.Web.ApiSpec.Schemas.ChatMessagesResponse - alias Pleroma.Web.ApiSpec.Schemas.ChatResponse - alias Pleroma.Web.ApiSpec.Schemas.ChatsResponse alias Pleroma.Web.CommonAPI - import OpenApiSpex.TestAssertions import Pleroma.Factory describe "POST /api/v1/pleroma/chats/:id/messages" do @@ -27,11 +21,10 @@ test "it posts a message to the chat", %{conn: conn, user: user} do conn |> put_req_header("content-type", "application/json") |> post("/api/v1/pleroma/chats/#{chat.id}/messages", %{"content" => "Hallo!!"}) - |> json_response(200) + |> json_response_and_validate_schema(200) assert result["content"] == "Hallo!!" assert result["chat_id"] == chat.id |> to_string() - assert_schema(result, "ChatMessageResponse", ApiSpec.spec()) end end @@ -50,18 +43,16 @@ test "it paginates", %{conn: conn, user: user} do result = conn |> get("/api/v1/pleroma/chats/#{chat.id}/messages") - |> json_response(200) + |> json_response_and_validate_schema(200) assert length(result) == 20 - assert_schema(result, "ChatMessagesResponse", ApiSpec.spec()) result = conn |> get("/api/v1/pleroma/chats/#{chat.id}/messages?max_id=#{List.last(result)["id"]}") - |> json_response(200) + |> json_response_and_validate_schema(200) assert length(result) == 10 - assert_schema(result, "ChatMessagesResponse", ApiSpec.spec()) end test "it returns the messages for a given chat", %{conn: conn, user: user} do @@ -78,7 +69,7 @@ test "it returns the messages for a given chat", %{conn: conn, user: user} do result = conn |> get("/api/v1/pleroma/chats/#{chat.id}/messages") - |> json_response(200) + |> json_response_and_validate_schema(200) result |> Enum.each(fn message -> @@ -86,7 +77,6 @@ test "it returns the messages for a given chat", %{conn: conn, user: user} do end) assert length(result) == 3 - assert_schema(result, "ChatMessagesResponse", ApiSpec.spec()) # Trying to get the chat of a different user result = @@ -107,10 +97,9 @@ test "it creates or returns a chat", %{conn: conn} do result = conn |> post("/api/v1/pleroma/chats/by-ap-id/#{URI.encode_www_form(other_user.ap_id)}") - |> json_response(200) + |> json_response_and_validate_schema(200) assert result["id"] - assert_schema(result, "ChatResponse", ApiSpec.spec()) end end @@ -126,19 +115,16 @@ test "it paginates", %{conn: conn, user: user} do result = conn |> get("/api/v1/pleroma/chats") - |> json_response(200) + |> json_response_and_validate_schema(200) assert length(result) == 20 - assert_schema(result, "ChatsResponse", ApiSpec.spec()) result = conn |> get("/api/v1/pleroma/chats?max_id=#{List.last(result)["id"]}") - |> json_response(200) + |> json_response_and_validate_schema(200) assert length(result) == 10 - - assert_schema(result, "ChatsResponse", ApiSpec.spec()) end test "it return a list of chats the current user is participating in, in descending order of updates", @@ -160,7 +146,7 @@ test "it return a list of chats the current user is participating in, in descend result = conn |> get("/api/v1/pleroma/chats") - |> json_response(200) + |> json_response_and_validate_schema(200) ids = Enum.map(result, & &1["id"]) @@ -169,34 +155,6 @@ test "it return a list of chats the current user is participating in, in descend chat_3.id |> to_string(), chat_1.id |> to_string() ] - - assert_schema(result, "ChatsResponse", ApiSpec.spec()) - end - end - - describe "schemas" do - test "Chat example matches schema" do - api_spec = ApiSpec.spec() - schema = ChatResponse.schema() - assert_schema(schema.example, "ChatResponse", api_spec) - end - - test "Chats example matches schema" do - api_spec = ApiSpec.spec() - schema = ChatsResponse.schema() - assert_schema(schema.example, "ChatsResponse", api_spec) - end - - test "ChatMessage example matches schema" do - api_spec = ApiSpec.spec() - schema = ChatMessageResponse.schema() - assert_schema(schema.example, "ChatMessageResponse", api_spec) - end - - test "ChatsMessage example matches schema" do - api_spec = ApiSpec.spec() - schema = ChatMessagesResponse.schema() - assert_schema(schema.example, "ChatMessagesResponse", api_spec) end end end From 15ba3700af76c44e63bf8881021f3ee2a5a7dafd Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 27 Apr 2020 12:45:59 +0200 Subject: [PATCH 046/401] Chat Schemas: Inline unimportant Schemas. --- .../web/api_spec/operations/chat_operation.ex | 113 +++++++++++++++++- .../schemas/chat_messages_response.ex | 41 ------- .../web/api_spec/schemas/chats_response.ex | 69 ----------- 3 files changed, 108 insertions(+), 115 deletions(-) delete mode 100644 lib/pleroma/web/api_spec/schemas/chat_messages_response.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/chats_response.ex diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index dc99bd773..6f55cbd59 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -7,9 +7,8 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do alias Pleroma.Web.ApiSpec.Helpers alias Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest alias Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse - alias Pleroma.Web.ApiSpec.Schemas.ChatMessagesResponse alias Pleroma.Web.ApiSpec.Schemas.ChatResponse - alias Pleroma.Web.ApiSpec.Schemas.ChatsResponse + alias OpenApiSpex.Schema @spec open_api_operation(atom) :: Operation.t() def open_api_operation(action) do @@ -34,7 +33,11 @@ def create_operation do ], responses: %{ 200 => - Operation.response("The created or existing chat", "application/json", ChatResponse) + Operation.response( + "The created or existing chat", + "application/json", + ChatResponse + ) }, security: [ %{ @@ -55,7 +58,7 @@ def index_operation do Operation.parameter(:max_id, :query, :string, "Return only chats before this id") ], responses: %{ - 200 => Operation.response("The chats of the user", "application/json", ChatsResponse) + 200 => Operation.response("The chats of the user", "application/json", chats_response()) }, security: [ %{ @@ -78,7 +81,11 @@ def messages_operation do ], responses: %{ 200 => - Operation.response("The messages in the chat", "application/json", ChatMessagesResponse) + Operation.response( + "The messages in the chat", + "application/json", + chat_messages_response() + ) }, security: [ %{ @@ -112,4 +119,100 @@ def post_chat_message_operation do ] } end + + def chats_response() do + %Schema{ + title: "ChatsResponse", + description: "Response schema for multiple Chats", + type: :array, + items: ChatResponse, + example: [ + %{ + "recipient" => "https://dontbulling.me/users/lain", + "recipient_account" => %{ + "pleroma" => %{ + "is_admin" => false, + "confirmation_pending" => false, + "hide_followers_count" => false, + "is_moderator" => false, + "hide_favorites" => true, + "ap_id" => "https://dontbulling.me/users/lain", + "hide_follows_count" => false, + "hide_follows" => false, + "background_image" => nil, + "skip_thread_containment" => false, + "hide_followers" => false, + "relationship" => %{}, + "tags" => [] + }, + "avatar" => + "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", + "following_count" => 0, + "header_static" => "https://originalpatchou.li/images/banner.png", + "source" => %{ + "sensitive" => false, + "note" => "lain", + "pleroma" => %{ + "discoverable" => false, + "actor_type" => "Person" + }, + "fields" => [] + }, + "statuses_count" => 1, + "locked" => false, + "created_at" => "2020-04-16T13:40:15.000Z", + "display_name" => "lain", + "fields" => [], + "acct" => "lain@dontbulling.me", + "id" => "9u6Qw6TAZANpqokMkK", + "emojis" => [], + "avatar_static" => + "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", + "username" => "lain", + "followers_count" => 0, + "header" => "https://originalpatchou.li/images/banner.png", + "bot" => false, + "note" => "lain", + "url" => "https://dontbulling.me/users/lain" + }, + "id" => "1", + "unread" => 2 + } + ] + } + end + + def chat_messages_response() do + %Schema{ + title: "ChatMessagesResponse", + description: "Response schema for multiple ChatMessages", + type: :array, + items: ChatMessageResponse, + example: [ + %{ + "emojis" => [ + %{ + "static_url" => "https://dontbulling.me/emoji/Firefox.gif", + "visible_in_picker" => false, + "shortcode" => "firefox", + "url" => "https://dontbulling.me/emoji/Firefox.gif" + } + ], + "created_at" => "2020-04-21T15:11:46.000Z", + "content" => "Check this out :firefox:", + "id" => "13", + "chat_id" => "1", + "actor" => "https://dontbulling.me/users/lain" + }, + %{ + "actor" => "https://dontbulling.me/users/lain", + "content" => "Whats' up?", + "id" => "12", + "chat_id" => "1", + "emojis" => [], + "created_at" => "2020-04-21T15:06:45.000Z" + } + ] + } + end end diff --git a/lib/pleroma/web/api_spec/schemas/chat_messages_response.ex b/lib/pleroma/web/api_spec/schemas/chat_messages_response.ex deleted file mode 100644 index 302bdec95..000000000 --- a/lib/pleroma/web/api_spec/schemas/chat_messages_response.ex +++ /dev/null @@ -1,41 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessagesResponse do - alias Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse - - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "ChatMessagesResponse", - description: "Response schema for multiple ChatMessages", - type: :array, - items: ChatMessageResponse, - example: [ - %{ - "emojis" => [ - %{ - "static_url" => "https://dontbulling.me/emoji/Firefox.gif", - "visible_in_picker" => false, - "shortcode" => "firefox", - "url" => "https://dontbulling.me/emoji/Firefox.gif" - } - ], - "created_at" => "2020-04-21T15:11:46.000Z", - "content" => "Check this out :firefox:", - "id" => "13", - "chat_id" => "1", - "actor" => "https://dontbulling.me/users/lain" - }, - %{ - "actor" => "https://dontbulling.me/users/lain", - "content" => "Whats' up?", - "id" => "12", - "chat_id" => "1", - "emojis" => [], - "created_at" => "2020-04-21T15:06:45.000Z" - } - ] - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/chats_response.ex b/lib/pleroma/web/api_spec/schemas/chats_response.ex deleted file mode 100644 index 3349e0691..000000000 --- a/lib/pleroma/web/api_spec/schemas/chats_response.ex +++ /dev/null @@ -1,69 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.ChatsResponse do - alias Pleroma.Web.ApiSpec.Schemas.ChatResponse - - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "ChatsResponse", - description: "Response schema for multiple Chats", - type: :array, - items: ChatResponse, - example: [ - %{ - "recipient" => "https://dontbulling.me/users/lain", - "recipient_account" => %{ - "pleroma" => %{ - "is_admin" => false, - "confirmation_pending" => false, - "hide_followers_count" => false, - "is_moderator" => false, - "hide_favorites" => true, - "ap_id" => "https://dontbulling.me/users/lain", - "hide_follows_count" => false, - "hide_follows" => false, - "background_image" => nil, - "skip_thread_containment" => false, - "hide_followers" => false, - "relationship" => %{}, - "tags" => [] - }, - "avatar" => - "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", - "following_count" => 0, - "header_static" => "https://originalpatchou.li/images/banner.png", - "source" => %{ - "sensitive" => false, - "note" => "lain", - "pleroma" => %{ - "discoverable" => false, - "actor_type" => "Person" - }, - "fields" => [] - }, - "statuses_count" => 1, - "locked" => false, - "created_at" => "2020-04-16T13:40:15.000Z", - "display_name" => "lain", - "fields" => [], - "acct" => "lain@dontbulling.me", - "id" => "9u6Qw6TAZANpqokMkK", - "emojis" => [], - "avatar_static" => - "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", - "username" => "lain", - "followers_count" => 0, - "header" => "https://originalpatchou.li/images/banner.png", - "bot" => false, - "note" => "lain", - "url" => "https://dontbulling.me/users/lain" - }, - "id" => "1", - "unread" => 2 - } - ] - }) -end From e62f8542a1933ba71dfd236741ad3afc76b89f22 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 27 Apr 2020 13:48:09 +0200 Subject: [PATCH 047/401] Docs: Add chat motivation and api description. --- docs/API/chats.md | 197 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 docs/API/chats.md diff --git a/docs/API/chats.md b/docs/API/chats.md new file mode 100644 index 000000000..39f493b47 --- /dev/null +++ b/docs/API/chats.md @@ -0,0 +1,197 @@ +# Chats + +Chats are a way to represent an IM-style conversation between two actors. They are not the same as direct messages and they are not `Status`es, even though they have a lot in common. + +## Why Chats? + +There are no 'visibility levels' in ActivityPub, their definition is purely a Mastodon convention. Direct Messaging between users on the fediverse has mostly been modeled by using ActivityPub addressing following Mastodon conventions on normal `Note` objects. In this case, a 'direct message' would be a message that has no followers addressed and also does not address the special public actor, but just the recipients in the `to` field. It would still be a `Note` and is presented with other `Note`s as a `Status` in the API. + +This is an awkward setup for a few reasons: + +- As DMs generally still follow the usual `Status` conventions, it is easy to accidentally pull somebody into a DM thread by mentioning them. (e.g. "I hate @badguy so much") +- It is possible to go from a publicly addressed `Status` to a DM reply, back to public, then to a 'followers only' reply, and so on. This can be become very confusing, as it is unclear which user can see which part of the conversation. +- The standard `Status` format of implicit addressing also leads to rather ugly results if you try to display the messages as a chat, because all the recipients are always mentioned by name in the message. +- As direct messages are posted with the same api call (and usually same frontend component) as public messages, accidentally making a public message private or vice versa can happen easily. Client bugs can also lead to this, accidentally making private messages public. + +As a measure to improve this situation, the `Conversation` concept and related Pleroma extensions were introduced. While it made it possible to work around a few of the issues, many of the problems remained and it didn't see much adoption because it was too complicated to use correctly. + +## Chats explained +For this reasons, Chats are a new and different entity, both in the API as well as in ActivityPub. A quick overview: + +- Chats are meant to represent an instant message conversation between two actors. For now these are only 1-on-1 conversations, but the other actor can be a group in the future. +- Chat messages have the ActivityPub type `ChatMessage`. They are not `Note`s. Servers that don't understand them will just drop them. +- The only addressing allowed in `ChatMessage`s is one single ActivityPub actor in the `to` field. +- There's always only one Chat between two actors. If you start chatting with someone and later start a 'new' Chat, the old Chat will be continued. +- `ChatMessage`s are posted with a different api, making it very hard to accidentally send a message to the wrong person. +- `ChatMessage`s don't show up in the existing timelines. +- Chats can never go from private to public. They are always private between the two actors. + +## Caveats + +- Chats are NOT E2E encrypted (yet). Security is still the same as email. + +## API + +In general, the way to send a `ChatMessage` is to first create a `Chat`, then post a message to that `Chat`. The actors in the API are generally given by their ActivityPub id to make it easier to support later `Group` scenarios. + +This is the overview of using the API. The API is also documented via OpenAPI, so you can view it and play with it by pointing SwaggerUI or a similar OpenAPI tool to `https://yourinstance.tld/api/openapi`. + +### Creating or getting a chat. + +To create or get an existing Chat for a certain recipient (identified by AP ID) +you can call: + +`POST /api/v1/pleroma/chats/by-ap-id/{ap_id}` + +The ap_id of the recipients needs to be www-form encoded, so + +``` +https://originalpatchou.li/user/lambda +``` + +would become + +``` +https%3A%2F%2Foriginalpatchou.li%2Fuser%2Flambda +``` + +The full call would then be + +``` +POST /api/v1/pleroma/chats/by-ap-id/https%3A%2F%2Foriginalpatchou.li%2Fuser%2Flambda +``` + +There will only ever be ONE Chat for you and a given recipient, so this call +will return the same Chat if you already have one with that user. + +Returned data: + +```json +{ + "recipient" : "https://dontbulling.me/users/lain", + "recipient_account": { + "id": "someflakeid", + "username": "somenick", + ... + }, + "id" : "1", + "unread" : 2 +} +``` + +### Getting a list of Chats + +`GET /api/v1/pleroma/chats` + +This will return a list of chats that you have been involved in, sorted by their +last update (so new chats will be at the top). + +Returned data: + +```json +[ + { + "recipient" : "https://dontbulling.me/users/lain", + "recipient_account": { + "id": "someflakeid", + "username": "somenick", + ... + }, + "id" : "1", + "unread" : 2 + } +] +``` + +The recipient of messages that are sent to this chat is given by their AP ID. +The usual pagination options are implemented. + +### Getting the messages for a Chat + +For a given Chat id, you can get the associated messages with + +`GET /api/v1/pleroma/chats/{id}/messages` + +This will return all messages, sorted by most recent to least recent. The usual +pagination options are implemented + +Returned data: + +```json +[ + { + "actor": "https://dontbulling.me/users/lain", + "chat_id": "1", + "content": "Check this out :firefox:", + "created_at": "2020-04-21T15:11:46.000Z", + "emojis": [ + { + "shortcode": "firefox", + "static_url": "https://dontbulling.me/emoji/Firefox.gif", + "url": "https://dontbulling.me/emoji/Firefox.gif", + "visible_in_picker": false + } + ], + "id": "13" + }, + { + "actor": "https://dontbulling.me/users/lain", + "chat_id": "1", + "content": "Whats' up?", + "created_at": "2020-04-21T15:06:45.000Z", + "emojis": [], + "id": "12" + } +] +``` + +### Posting a chat message + +Posting a chat message for given Chat id works like this: + +`POST /api/v1/pleroma/chats/{id}/messages` + +Parameters: +- content: The text content of the message + +Currently, no formatting beyond basic escaping and emoji is implemented, as well as no +attachments. This will most probably change. + +Returned data: + +```json +{ + "actor": "https://dontbulling.me/users/lain", + "chat_id": "1", + "content": "Check this out :firefox:", + "created_at": "2020-04-21T15:11:46.000Z", + "emojis": [ + { + "shortcode": "firefox", + "static_url": "https://dontbulling.me/emoji/Firefox.gif", + "url": "https://dontbulling.me/emoji/Firefox.gif", + "visible_in_picker": false + } + ], + "id": "13" +} +``` + +### Notifications + +There's a new `pleroma:chat_mention` notification, which has this form: + +```json +{ + "id": "someid", + "type": "pleroma:chat_mention", + "account": { ... } // User account of the sender, + "chat_message": { + "chat_id": "1", + "id": "10", + "content": "Hello", + "actor": "https://dontbulling.me/users/lain" + }, + "created_at": "somedate" +} +``` From 00e956528b392689326d5f5527543a422a874bcc Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 27 Apr 2020 14:02:11 +0200 Subject: [PATCH 048/401] Credo fixes. --- lib/pleroma/web/api_spec/operations/chat_operation.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 6f55cbd59..546bc4d9b 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -4,11 +4,11 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do alias OpenApiSpex.Operation + alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Helpers alias Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest alias Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse alias Pleroma.Web.ApiSpec.Schemas.ChatResponse - alias OpenApiSpex.Schema @spec open_api_operation(atom) :: Operation.t() def open_api_operation(action) do @@ -120,7 +120,7 @@ def post_chat_message_operation do } end - def chats_response() do + def chats_response do %Schema{ title: "ChatsResponse", description: "Response schema for multiple Chats", @@ -182,7 +182,7 @@ def chats_response() do } end - def chat_messages_response() do + def chat_messages_response do %Schema{ title: "ChatMessagesResponse", description: "Response schema for multiple ChatMessages", From 49e673dfea0a0cc94bba9691ce171b60f8a2fd75 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 27 Apr 2020 16:08:03 +0200 Subject: [PATCH 049/401] ChatView: Add actor_account_id --- lib/pleroma/web/api_spec/schemas/chat_message_response.ex | 2 ++ lib/pleroma/web/pleroma_api/views/chat_message_view.ex | 2 ++ test/web/pleroma_api/views/chat_message_view_test.exs | 2 ++ 3 files changed, 6 insertions(+) diff --git a/lib/pleroma/web/api_spec/schemas/chat_message_response.ex b/lib/pleroma/web/api_spec/schemas/chat_message_response.ex index e94c00369..9459d210b 100644 --- a/lib/pleroma/web/api_spec/schemas/chat_message_response.ex +++ b/lib/pleroma/web/api_spec/schemas/chat_message_response.ex @@ -14,6 +14,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse do properties: %{ id: %Schema{type: :string}, actor: %Schema{type: :string, description: "The ActivityPub id of the actor"}, + actor_account_id: %Schema{type: :string, description: "The Mastodon API id of the actor"}, chat_id: %Schema{type: :string}, content: %Schema{type: :string}, created_at: %Schema{type: :string, format: :datetime}, @@ -21,6 +22,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse do }, example: %{ "actor" => "https://dontbulling.me/users/lain", + "actor_account_id" => "someflakeid", "chat_id" => "1", "content" => "hey you again", "created_at" => "2020-04-21T15:06:45.000Z", diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex index b40ab92a0..5b740cc44 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatMessageView do alias Pleroma.Chat alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.User def render( "show.json", @@ -21,6 +22,7 @@ def render( content: chat_message["content"], chat_id: chat_id |> to_string(), actor: chat_message["actor"], + actor_account_id: User.get_cached_by_ap_id(chat_message["actor"]).id, created_at: Utils.to_masto_date(chat_message["published"]), emojis: StatusView.build_emojis(chat_message["emoji"]) } diff --git a/test/web/pleroma_api/views/chat_message_view_test.exs b/test/web/pleroma_api/views/chat_message_view_test.exs index 115335f10..7e3aeefab 100644 --- a/test/web/pleroma_api/views/chat_message_view_test.exs +++ b/test/web/pleroma_api/views/chat_message_view_test.exs @@ -26,6 +26,7 @@ test "it displays a chat message" do assert chat_message[:id] == object.id |> to_string() assert chat_message[:content] == "kippis :firefox:" assert chat_message[:actor] == user.ap_id + assert chat_message[:actor_account_id] == user.id assert chat_message[:chat_id] assert chat_message[:created_at] assert match?([%{shortcode: "firefox"}], chat_message[:emojis]) @@ -39,6 +40,7 @@ test "it displays a chat message" do assert chat_message_two[:id] == object.id |> to_string() assert chat_message_two[:content] == "gkgkgk" assert chat_message_two[:actor] == recipient.ap_id + assert chat_message_two[:actor_account_id] == recipient.id assert chat_message_two[:chat_id] == chat_message[:chat_id] end end From ad82a216ff0676507a118e610209bd4259456b3c Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 27 Apr 2020 17:48:34 +0200 Subject: [PATCH 050/401] Chat API: Align more to Pleroma/Mastodon API. --- .../web/api_spec/operations/chat_operation.ex | 13 ++++++------- .../web/api_spec/schemas/chat_message_response.ex | 6 ++---- lib/pleroma/web/api_spec/schemas/chat_response.ex | 11 ++++------- .../web/pleroma_api/controllers/chat_controller.ex | 5 ++--- .../web/pleroma_api/views/chat_message_view.ex | 3 +-- lib/pleroma/web/pleroma_api/views/chat_view.ex | 3 +-- lib/pleroma/web/router.ex | 2 +- .../controllers/chat_controller_test.exs | 4 ++-- .../pleroma_api/views/chat_message_view_test.exs | 6 ++---- test/web/pleroma_api/views/chat_view_test.exs | 3 +-- 10 files changed, 22 insertions(+), 34 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 546bc4d9b..59539e890 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -23,12 +23,12 @@ def create_operation do operationId: "ChatController.create", parameters: [ Operation.parameter( - :ap_id, + :id, :path, :string, - "The ActivityPub id of the recipient of this chat.", + "The account id of the recipient of this chat", required: true, - example: "https://lain.com/users/lain" + example: "someflakeid" ) ], responses: %{ @@ -128,8 +128,7 @@ def chats_response do items: ChatResponse, example: [ %{ - "recipient" => "https://dontbulling.me/users/lain", - "recipient_account" => %{ + "account" => %{ "pleroma" => %{ "is_admin" => false, "confirmation_pending" => false, @@ -202,10 +201,10 @@ def chat_messages_response do "content" => "Check this out :firefox:", "id" => "13", "chat_id" => "1", - "actor" => "https://dontbulling.me/users/lain" + "actor_id" => "someflakeid" }, %{ - "actor" => "https://dontbulling.me/users/lain", + "actor_id" => "someflakeid", "content" => "Whats' up?", "id" => "12", "chat_id" => "1", diff --git a/lib/pleroma/web/api_spec/schemas/chat_message_response.ex b/lib/pleroma/web/api_spec/schemas/chat_message_response.ex index 9459d210b..b7a662cbb 100644 --- a/lib/pleroma/web/api_spec/schemas/chat_message_response.ex +++ b/lib/pleroma/web/api_spec/schemas/chat_message_response.ex @@ -13,16 +13,14 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse do type: :object, properties: %{ id: %Schema{type: :string}, - actor: %Schema{type: :string, description: "The ActivityPub id of the actor"}, - actor_account_id: %Schema{type: :string, description: "The Mastodon API id of the actor"}, + account_id: %Schema{type: :string, description: "The Mastodon API id of the actor"}, chat_id: %Schema{type: :string}, content: %Schema{type: :string}, created_at: %Schema{type: :string, format: :datetime}, emojis: %Schema{type: :array} }, example: %{ - "actor" => "https://dontbulling.me/users/lain", - "actor_account_id" => "someflakeid", + "account_id" => "someflakeid", "chat_id" => "1", "content" => "hey you again", "created_at" => "2020-04-21T15:06:45.000Z", diff --git a/lib/pleroma/web/api_spec/schemas/chat_response.ex b/lib/pleroma/web/api_spec/schemas/chat_response.ex index a80f4d173..aa435165d 100644 --- a/lib/pleroma/web/api_spec/schemas/chat_response.ex +++ b/lib/pleroma/web/api_spec/schemas/chat_response.ex @@ -12,15 +12,12 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatResponse do description: "Response schema for a Chat", type: :object, properties: %{ - id: %Schema{type: :string}, - recipient: %Schema{type: :string}, - # TODO: Make this reference the account structure. - recipient_account: %Schema{type: :object}, - unread: %Schema{type: :integer} + id: %Schema{type: :string, nullable: false}, + account: %Schema{type: :object, nullable: false}, + unread: %Schema{type: :integer, nullable: false} }, example: %{ - "recipient" => "https://dontbulling.me/users/lain", - "recipient_account" => %{ + "account" => %{ "pleroma" => %{ "is_admin" => false, "confirmation_pending" => false, diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 771ad6217..8654f4295 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -99,9 +99,8 @@ def index(%{assigns: %{user: %{id: user_id}}} = conn, params) do end def create(%{assigns: %{user: user}} = conn, params) do - recipient = params[:ap_id] - - with {:ok, %Chat{} = chat} <- Chat.get_or_create(user.id, recipient) do + with %User{ap_id: recipient} <- User.get_by_id(params[:id]), + {:ok, %Chat{} = chat} <- Chat.get_or_create(user.id, recipient) do conn |> put_view(ChatView) |> render("show.json", chat: chat) diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex index 5b740cc44..28f12d9b0 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex @@ -21,8 +21,7 @@ def render( id: id |> to_string(), content: chat_message["content"], chat_id: chat_id |> to_string(), - actor: chat_message["actor"], - actor_account_id: User.get_cached_by_ap_id(chat_message["actor"]).id, + account_id: User.get_cached_by_ap_id(chat_message["actor"]).id, created_at: Utils.to_masto_date(chat_message["published"]), emojis: StatusView.build_emojis(chat_message["emoji"]) } diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index 1e9ef4356..bc3af5ef5 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -14,8 +14,7 @@ def render("show.json", %{chat: %Chat{} = chat} = opts) do %{ id: chat.id |> to_string(), - recipient: chat.recipient, - recipient_account: AccountView.render("show.json", Map.put(opts, :user, recipient)), + account: AccountView.render("show.json", Map.put(opts, :user, recipient)), unread: chat.unread } end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 0c56318ee..aad2e3b98 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -275,7 +275,7 @@ defmodule Pleroma.Web.Router do scope [] do pipe_through(:authenticated_api) - post("/chats/by-ap-id/:ap_id", ChatController, :create) + post("/chats/by-account-id/:id", ChatController, :create) get("/chats", ChatController, :index) get("/chats/:id/messages", ChatController, :messages) post("/chats/:id/messages", ChatController, :post_chat_message) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 84d7b543e..b1044574b 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -88,7 +88,7 @@ test "it returns the messages for a given chat", %{conn: conn, user: user} do end end - describe "POST /api/v1/pleroma/chats/by-ap-id/:id" do + describe "POST /api/v1/pleroma/chats/by-account-id/:id" do setup do: oauth_access(["write:statuses"]) test "it creates or returns a chat", %{conn: conn} do @@ -96,7 +96,7 @@ test "it creates or returns a chat", %{conn: conn} do result = conn - |> post("/api/v1/pleroma/chats/by-ap-id/#{URI.encode_www_form(other_user.ap_id)}") + |> post("/api/v1/pleroma/chats/by-account-id/#{other_user.id}") |> json_response_and_validate_schema(200) assert result["id"] diff --git a/test/web/pleroma_api/views/chat_message_view_test.exs b/test/web/pleroma_api/views/chat_message_view_test.exs index 7e3aeefab..5c4c8b0d5 100644 --- a/test/web/pleroma_api/views/chat_message_view_test.exs +++ b/test/web/pleroma_api/views/chat_message_view_test.exs @@ -25,8 +25,7 @@ test "it displays a chat message" do assert chat_message[:id] == object.id |> to_string() assert chat_message[:content] == "kippis :firefox:" - assert chat_message[:actor] == user.ap_id - assert chat_message[:actor_account_id] == user.id + assert chat_message[:account_id] == user.id assert chat_message[:chat_id] assert chat_message[:created_at] assert match?([%{shortcode: "firefox"}], chat_message[:emojis]) @@ -39,8 +38,7 @@ test "it displays a chat message" do assert chat_message_two[:id] == object.id |> to_string() assert chat_message_two[:content] == "gkgkgk" - assert chat_message_two[:actor] == recipient.ap_id - assert chat_message_two[:actor_account_id] == recipient.id + assert chat_message_two[:account_id] == recipient.id assert chat_message_two[:chat_id] == chat_message[:chat_id] end end diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index 725da5ff8..1ac3483d1 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -21,8 +21,7 @@ test "it represents a chat" do assert represented_chat == %{ id: "#{chat.id}", - recipient: recipient.ap_id, - recipient_account: AccountView.render("show.json", user: recipient), + account: AccountView.render("show.json", user: recipient), unread: 0 } end From b550ef56119b9f735cf3fe279a5457e36ab92951 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 27 Apr 2020 17:52:16 +0200 Subject: [PATCH 051/401] Docs: Align chat api changes with docs. --- docs/API/chats.md | 36 +++++++++++------------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index 39f493b47..24c4b4d06 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -32,33 +32,21 @@ For this reasons, Chats are a new and different entity, both in the API as well ## API -In general, the way to send a `ChatMessage` is to first create a `Chat`, then post a message to that `Chat`. The actors in the API are generally given by their ActivityPub id to make it easier to support later `Group` scenarios. +In general, the way to send a `ChatMessage` is to first create a `Chat`, then post a message to that `Chat`. `Group`s will later be supported by making them a sub-type of `Account`. This is the overview of using the API. The API is also documented via OpenAPI, so you can view it and play with it by pointing SwaggerUI or a similar OpenAPI tool to `https://yourinstance.tld/api/openapi`. ### Creating or getting a chat. -To create or get an existing Chat for a certain recipient (identified by AP ID) +To create or get an existing Chat for a certain recipient (identified by Account ID) you can call: -`POST /api/v1/pleroma/chats/by-ap-id/{ap_id}` +`POST /api/v1/pleroma/chats/by-account-id/{account_id}` -The ap_id of the recipients needs to be www-form encoded, so +The account id is the normal FlakeId of the usre ``` -https://originalpatchou.li/user/lambda -``` - -would become - -``` -https%3A%2F%2Foriginalpatchou.li%2Fuser%2Flambda -``` - -The full call would then be - -``` -POST /api/v1/pleroma/chats/by-ap-id/https%3A%2F%2Foriginalpatchou.li%2Fuser%2Flambda +POST /api/v1/pleroma/chats/by-account-id/someflakeid ``` There will only ever be ONE Chat for you and a given recipient, so this call @@ -68,8 +56,7 @@ Returned data: ```json { - "recipient" : "https://dontbulling.me/users/lain", - "recipient_account": { + "account": { "id": "someflakeid", "username": "somenick", ... @@ -91,8 +78,7 @@ Returned data: ```json [ { - "recipient" : "https://dontbulling.me/users/lain", - "recipient_account": { + "account": { "id": "someflakeid", "username": "somenick", ... @@ -120,7 +106,7 @@ Returned data: ```json [ { - "actor": "https://dontbulling.me/users/lain", + "account_id": "someflakeid", "chat_id": "1", "content": "Check this out :firefox:", "created_at": "2020-04-21T15:11:46.000Z", @@ -135,7 +121,7 @@ Returned data: "id": "13" }, { - "actor": "https://dontbulling.me/users/lain", + "account_id": "someflakeid", "chat_id": "1", "content": "Whats' up?", "created_at": "2020-04-21T15:06:45.000Z", @@ -161,7 +147,7 @@ Returned data: ```json { - "actor": "https://dontbulling.me/users/lain", + "account_id": "someflakeid", "chat_id": "1", "content": "Check this out :firefox:", "created_at": "2020-04-21T15:11:46.000Z", @@ -190,7 +176,7 @@ There's a new `pleroma:chat_mention` notification, which has this form: "chat_id": "1", "id": "10", "content": "Hello", - "actor": "https://dontbulling.me/users/lain" + "account_id": "someflakeid" }, "created_at": "somedate" } From 3d040b1a87da66ed53a763f781477bd4f5a146d3 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 27 Apr 2020 17:55:29 +0200 Subject: [PATCH 052/401] Credo fixes. --- lib/pleroma/web/pleroma_api/views/chat_message_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex index 28f12d9b0..a821479ab 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex @@ -6,9 +6,9 @@ defmodule Pleroma.Web.PleromaAPI.ChatMessageView do use Pleroma.Web, :view alias Pleroma.Chat + alias Pleroma.User alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.StatusView - alias Pleroma.User def render( "show.json", From 906cf53ab94742327d073f56255f695c91339295 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 28 Apr 2020 13:38:02 +0200 Subject: [PATCH 053/401] Recipient Type: Cast all elements as ObjectIDs. --- .../object_validators/types/recipients.ex | 15 +++++++++++++-- .../object_validators/types/recipients_test.exs | 12 ++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex index 5a3040842..48fe61e1a 100644 --- a/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex +++ b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex @@ -1,13 +1,24 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients do use Ecto.Type - def type, do: {:array, :string} + alias Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID + + def type, do: {:array, ObjectID} def cast(object) when is_binary(object) do cast([object]) end - def cast([_ | _] = data), do: {:ok, data} + def cast(data) when is_list(data) do + data + |> Enum.reduce({:ok, []}, fn element, acc -> + case {acc, ObjectID.cast(element)} do + {:error, _} -> :error + {_, :error} -> :error + {{:ok, list}, {:ok, id}} -> {:ok, [id | list]} + end + end) + end def cast(_) do :error diff --git a/test/web/activity_pub/object_validators/types/recipients_test.exs b/test/web/activity_pub/object_validators/types/recipients_test.exs index 2f9218774..f278f039b 100644 --- a/test/web/activity_pub/object_validators/types/recipients_test.exs +++ b/test/web/activity_pub/object_validators/types/recipients_test.exs @@ -2,11 +2,23 @@ defmodule Pleroma.Web.ObjectValidators.Types.RecipientsTest do alias Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients use Pleroma.DataCase + test "it asserts that all elements of the list are object ids" do + list = ["https://lain.com/users/lain", "invalid"] + + assert :error == Recipients.cast(list) + end + test "it works with a list" do list = ["https://lain.com/users/lain"] assert {:ok, list} == Recipients.cast(list) end + test "it works with a list with whole objects" do + list = ["https://lain.com/users/lain", %{"id" => "https://gensokyo.2hu/users/raymoo"}] + resulting_list = ["https://gensokyo.2hu/users/raymoo", "https://lain.com/users/lain"] + assert {:ok, resulting_list} == Recipients.cast(list) + end + test "it turns a single string into a list" do recipient = "https://lain.com/users/lain" From f8e56d4271f8c495316d304dd0de7f0a63eb0645 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 28 Apr 2020 13:43:58 +0200 Subject: [PATCH 054/401] SideEffects: Use Object.normalize to get the object. --- lib/pleroma/web/activity_pub/side_effects.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index ebe3071b0..a2b4da8d6 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -30,8 +30,8 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do result end - def handle(%{data: %{"type" => "Create", "object" => object_id}} = activity, meta) do - object = Object.get_by_ap_id(object_id) + def handle(%{data: %{"type" => "Create"}} = activity, meta) do + object = Object.normalize(activity, false) {:ok, _object} = handle_object_creation(object) From 6aa116eca7d6ef6567dcef03b8c776bd2134bf3f Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 28 Apr 2020 16:26:19 +0200 Subject: [PATCH 055/401] Create activity handling: Flip it and reverse it Both objects and create activities will now go through the common pipeline and will be validated. Objects are now created as a side effect of the Create activity, rolling back a transaction if it's not possible to insert the object. --- lib/pleroma/notification.ex | 2 +- lib/pleroma/web/activity_pub/activity_pub.ex | 7 ++++ .../web/activity_pub/object_validator.ex | 4 +- .../chat_message_validator.ex | 2 +- .../create_chat_message_validator.ex | 27 +++++++++++- .../object_validators/types/safe_text.ex | 25 +++++++++++ lib/pleroma/web/activity_pub/pipeline.ex | 12 ++++-- lib/pleroma/web/activity_pub/side_effects.ex | 41 +++++++++++-------- .../transmogrifier/chat_message_handling.ex | 28 ++++++++----- lib/pleroma/web/common_api/common_api.ex | 10 +++-- lib/pleroma/web/common_api/utils.ex | 2 +- .../types/safe_text_test.exs | 23 +++++++++++ test/web/activity_pub/side_effects_test.exs | 21 ++++++---- .../transmogrifier/chat_message_test.exs | 3 +- 14 files changed, 155 insertions(+), 52 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex create mode 100644 test/web/activity_pub/object_validators/types/safe_text_test.exs diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 73e19bf97..d96c12440 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -275,7 +275,7 @@ def dismiss(%{id: user_id} = _user, id) do end def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity) do - object = Object.normalize(activity) + object = Object.normalize(activity, false) if object && object.data["type"] == "Answer" do {:ok, []} diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 69ac06f6b..ecb13d76a 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -126,7 +126,14 @@ def increase_poll_votes_if_vote(%{ def increase_poll_votes_if_vote(_create_data), do: :noop + @object_types ["ChatMessage"] @spec persist(map(), keyword()) :: {:ok, Activity.t() | Object.t()} + def persist(%{"type" => type} = object, meta) when type in @object_types do + with {:ok, object} <- Object.create(object) do + {:ok, object, meta} + end + end + def persist(object, meta) do with local <- Keyword.fetch!(meta, :local), {recipients, _, _} <- get_recipients(object), diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 03db681ec..a4da9242a 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -23,7 +23,7 @@ def validate(%{"type" => "Like"} = object, meta) do object |> LikeValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do - object = stringify_keys(object |> Map.from_struct()) + object = stringify_keys(object) {:ok, object, meta} end end @@ -41,7 +41,7 @@ def validate(%{"type" => "ChatMessage"} = object, meta) do def validate(%{"type" => "Create"} = object, meta) do with {:ok, object} <- object - |> CreateChatMessageValidator.cast_and_validate() + |> CreateChatMessageValidator.cast_and_validate(meta) |> Ecto.Changeset.apply_action(:insert) do object = stringify_keys(object) {:ok, object, meta} diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index f07045d9d..e87c1ac2e 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -18,7 +18,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do field(:id, Types.ObjectID, primary_key: true) field(:to, Types.Recipients, default: []) field(:type, :string) - field(:content, :string) + field(:content, Types.SafeText) field(:actor, Types.ObjectID) field(:published, Types.DateTime) field(:emoji, :map, default: %{}) diff --git a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex index ce52d5623..21c7a5ba4 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex @@ -33,8 +33,31 @@ def cast_data(data) do cast(%__MODULE__{}, data, __schema__(:fields)) end - # No validation yet - def cast_and_validate(data) do + def cast_and_validate(data, meta \\ []) do cast_data(data) + |> validate_data(meta) + end + + def validate_data(cng, meta \\ []) do + cng + |> validate_required([:id, :actor, :to, :type, :object]) + |> validate_inclusion(:type, ["Create"]) + |> validate_recipients_match(meta) + end + + def validate_recipients_match(cng, meta) do + object_recipients = meta[:object_data]["to"] || [] + + cng + |> validate_change(:to, fn :to, recipients -> + activity_set = MapSet.new(recipients) + object_set = MapSet.new(object_recipients) + + if MapSet.equal?(activity_set, object_set) do + [] + else + [{:to, "Recipients don't match with object recipients"}] + end + end) end end diff --git a/lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex b/lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex new file mode 100644 index 000000000..822e8d2c1 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.SafeText do + use Ecto.Type + + alias Pleroma.HTML + + def type, do: :string + + def cast(str) when is_binary(str) do + {:ok, HTML.strip_tags(str)} + end + + def cast(_), do: :error + + def dump(data) do + {:ok, data} + end + + def load(data) do + {:ok, data} + end +end diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 7ccee54c9..4213ba751 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -4,20 +4,22 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do alias Pleroma.Activity + alias Pleroma.Object alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.SideEffects alias Pleroma.Web.Federator - @spec common_pipeline(map(), keyword()) :: {:ok, Activity.t(), keyword()} | {:error, any()} + @spec common_pipeline(map(), keyword()) :: + {:ok, Activity.t() | Object.t(), keyword()} | {:error, any()} def common_pipeline(object, meta) do with {_, {:ok, validated_object, meta}} <- {:validate_object, ObjectValidator.validate(object, meta)}, {_, {:ok, mrfd_object}} <- {:mrf_object, MRF.filter(validated_object)}, - {_, {:ok, %Activity{} = activity, meta}} <- + {_, {:ok, activity, meta}} <- {:persist_object, ActivityPub.persist(mrfd_object, meta)}, - {_, {:ok, %Activity{} = activity, meta}} <- + {_, {:ok, activity, meta}} <- {:execute_side_effects, SideEffects.handle(activity, meta)}, {_, {:ok, _}} <- {:federation, maybe_federate(activity, meta)} do {:ok, activity, meta} @@ -27,7 +29,9 @@ def common_pipeline(object, meta) do end end - defp maybe_federate(activity, meta) do + defp maybe_federate(%Object{}, _), do: {:ok, :not_federated} + + defp maybe_federate(%Activity{} = activity, meta) do with {:ok, local} <- Keyword.fetch(meta, :local) do if local do Federator.publish(activity) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index a2b4da8d6..794a46267 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -8,7 +8,9 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Chat alias Pleroma.Notification alias Pleroma.Object + alias Pleroma.Repo alias Pleroma.User + alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils def handle(object, meta \\ []) @@ -30,14 +32,17 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do result end + # Tasks this handles + # - Actually create object + # - Rollback if we couldn't create it + # - Set up notifications def handle(%{data: %{"type" => "Create"}} = activity, meta) do - object = Object.normalize(activity, false) - - {:ok, _object} = handle_object_creation(object) - - Notification.create_notifications(activity) - - {:ok, activity, meta} + with {:ok, _object, _meta} <- handle_object_creation(meta[:object_data], meta) do + Notification.create_notifications(activity) + {:ok, activity, meta} + else + e -> Repo.rollback(e) + end end # Nothing to do @@ -45,18 +50,20 @@ def handle(object, meta) do {:ok, object, meta} end - def handle_object_creation(%{data: %{"type" => "ChatMessage"}} = object) do - actor = User.get_cached_by_ap_id(object.data["actor"]) - recipient = User.get_cached_by_ap_id(hd(object.data["to"])) + def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do + with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do + actor = User.get_cached_by_ap_id(object.data["actor"]) + recipient = User.get_cached_by_ap_id(hd(object.data["to"])) - [[actor, recipient], [recipient, actor]] - |> Enum.each(fn [user, other_user] -> - if user.local do - Chat.bump_or_create(user.id, other_user.ap_id) - end - end) + [[actor, recipient], [recipient, actor]] + |> Enum.each(fn [user, other_user] -> + if user.local do + Chat.bump_or_create(user.id, other_user.ap_id) + end + end) - {:ok, object} + {:ok, object, meta} + end end # Nothing to do diff --git a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex index cfe3b767b..043d847d1 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex @@ -3,31 +3,39 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageHandling do - alias Pleroma.Object + alias Pleroma.Repo alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator alias Pleroma.Web.ActivityPub.Pipeline def handle_incoming( - %{"type" => "Create", "object" => %{"type" => "ChatMessage"} = object_data} = data, + %{"type" => "Create", "object" => %{"type" => "ChatMessage"}} = data, _options ) do + # Create has to be run inside a transaction because the object is created as a side effect. + # If this does not work, we need to roll back creating the activity. + case Repo.transaction(fn -> do_handle_incoming(data) end) do + {:ok, value} -> + value + + {:error, e} -> + {:error, e} + end + end + + def do_handle_incoming( + %{"type" => "Create", "object" => %{"type" => "ChatMessage"} = object_data} = data + ) do with {_, {:ok, cast_data_sym}} <- {:casting_data, data |> CreateChatMessageValidator.cast_and_apply()}, cast_data = ObjectValidator.stringify_keys(cast_data_sym), {_, {:ok, object_cast_data_sym}} <- {:casting_object_data, object_data |> ChatMessageValidator.cast_and_apply()}, object_cast_data = ObjectValidator.stringify_keys(object_cast_data_sym), - # For now, just strip HTML - stripped_content = Pleroma.HTML.strip_tags(object_cast_data["content"]), - object_cast_data = object_cast_data |> Map.put("content", stripped_content), - {_, true} <- {:to_fields_match, cast_data["to"] == object_cast_data["to"]}, - {_, {:ok, validated_object, _meta}} <- - {:validate_object, ObjectValidator.validate(object_cast_data, %{})}, - {_, {:ok, _created_object}} <- {:persist_object, Object.create(validated_object)}, {_, {:ok, activity, _meta}} <- - {:common_pipeline, Pipeline.common_pipeline(cast_data, local: false)} do + {:common_pipeline, + Pipeline.common_pipeline(cast_data, local: false, object_data: object_cast_data)} do {:ok, activity} else e -> diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 5eb221668..c39d1cee6 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -38,13 +38,15 @@ def post_chat_message(%User{} = user, %User{} = recipient, content) do recipient.ap_id, content |> Formatter.html_escape("text/plain") )}, - {_, {:ok, chat_message_object}} <- - {:create_object, Object.create(chat_message_data)}, {_, {:ok, create_activity_data, _meta}} <- {:build_create_activity, - Builder.create(user, chat_message_object.data["id"], [recipient.ap_id])}, + Builder.create(user, chat_message_data["id"], [recipient.ap_id])}, {_, {:ok, %Activity{} = activity, _meta}} <- - {:common_pipeline, Pipeline.common_pipeline(create_activity_data, local: true)} do + {:common_pipeline, + Pipeline.common_pipeline(create_activity_data, + local: true, + object_data: chat_message_data + )} do {:ok, activity} else {:content_length, false} -> {:error, :content_too_long} diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 945e63e22..4afdf80af 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -425,7 +425,7 @@ def maybe_notify_mentioned_recipients( %Activity{data: %{"to" => _to, "type" => type} = data} = activity ) when type == "Create" do - object = Object.normalize(activity) + object = Object.normalize(activity, false) object_data = cond do diff --git a/test/web/activity_pub/object_validators/types/safe_text_test.exs b/test/web/activity_pub/object_validators/types/safe_text_test.exs new file mode 100644 index 000000000..59ed0a1fe --- /dev/null +++ b/test/web/activity_pub/object_validators/types/safe_text_test.exs @@ -0,0 +1,23 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.SafeTextTest do + use Pleroma.DataCase + + alias Pleroma.Web.ActivityPub.ObjectValidators.Types.SafeText + + test "it lets normal text go through" do + text = "hey how are you" + assert {:ok, text} == SafeText.cast(text) + end + + test "it removes html tags from text" do + text = "hey look xss " + assert {:ok, "hey look xss alert('foo')"} == SafeText.cast(text) + end + + test "errors for non-text" do + assert :error == SafeText.cast(1) + end +end diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 2889a577c..19abac6a6 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -47,14 +47,14 @@ test "notifies the recipient" do recipient = insert(:user, local: true) {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") - {:ok, chat_message_object} = Object.create(chat_message_data) {:ok, create_activity_data, _meta} = - Builder.create(author, chat_message_object.data["id"], [recipient.ap_id]) + Builder.create(author, chat_message_data["id"], [recipient.ap_id]) {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) - {:ok, _create_activity, _meta} = SideEffects.handle(create_activity) + {:ok, _create_activity, _meta} = + SideEffects.handle(create_activity, local: false, object_data: chat_message_data) assert Repo.get_by(Notification, user_id: recipient.id, activity_id: create_activity.id) end @@ -64,14 +64,17 @@ test "it creates a Chat for the local users and bumps the unread count" do recipient = insert(:user, local: true) {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") - {:ok, chat_message_object} = Object.create(chat_message_data) {:ok, create_activity_data, _meta} = - Builder.create(author, chat_message_object.data["id"], [recipient.ap_id]) + Builder.create(author, chat_message_data["id"], [recipient.ap_id]) {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) - {:ok, _create_activity, _meta} = SideEffects.handle(create_activity) + {:ok, _create_activity, _meta} = + SideEffects.handle(create_activity, local: false, object_data: chat_message_data) + + # An object is created + assert Object.get_by_ap_id(chat_message_data["id"]) # The remote user won't get a chat chat = Chat.get(author.id, recipient.ap_id) @@ -85,14 +88,14 @@ test "it creates a Chat for the local users and bumps the unread count" do recipient = insert(:user, local: true) {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") - {:ok, chat_message_object} = Object.create(chat_message_data) {:ok, create_activity_data, _meta} = - Builder.create(author, chat_message_object.data["id"], [recipient.ap_id]) + Builder.create(author, chat_message_data["id"], [recipient.ap_id]) {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) - {:ok, _create_activity, _meta} = SideEffects.handle(create_activity) + {:ok, _create_activity, _meta} = + SideEffects.handle(create_activity, local: false, object_data: chat_message_data) # Both users are local and get the chat chat = Chat.get(author.id, recipient.ap_id) diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs index a63a31e6e..ceaee614c 100644 --- a/test/web/activity_pub/transmogrifier/chat_message_test.exs +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -55,7 +55,8 @@ test "it rejects messages where the `to` field of activity and object don't matc data |> Map.put("to", author.ap_id) - {:error, _} = Transmogrifier.handle_incoming(data) + assert match?({:error, _}, Transmogrifier.handle_incoming(data)) + refute Object.get_by_ap_id(data["object"]["id"]) end test "it inserts it and creates a chat" do From abd09282292f7e902c77b158ae3d86e9bfd5b986 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 28 Apr 2020 16:45:28 +0200 Subject: [PATCH 056/401] CreateChatMessageValidator: Validate object existence --- .../create_chat_message_validator.ex | 14 +++++++++++++- test/web/activity_pub/object_validator_test.exs | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex index 21c7a5ba4..dfc91bf71 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex @@ -5,10 +5,10 @@ # NOTES # - Can probably be a generic create validator # - doesn't embed, will only get the object id -# - object has to be validated first, maybe with some meta info from the surrounding create defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator do use Ecto.Schema + alias Pleroma.Object alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset @@ -43,6 +43,18 @@ def validate_data(cng, meta \\ []) do |> validate_required([:id, :actor, :to, :type, :object]) |> validate_inclusion(:type, ["Create"]) |> validate_recipients_match(meta) + |> validate_object_nonexistence() + end + + def validate_object_nonexistence(cng) do + cng + |> validate_change(:object, fn :object, object_id -> + if Object.get_cached_by_ap_id(object_id) do + [{:object, "The object to create already exists"}] + else + [] + end + end) end def validate_recipients_match(cng, meta) do diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index bc2317e55..baa4b2585 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -1,6 +1,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do use Pleroma.DataCase + alias Pleroma.Object alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator @@ -9,6 +10,21 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do import Pleroma.Factory + describe "chat message create activities" do + test "it is invalid if the object already exists" do + user = insert(:user) + recipient = insert(:user) + {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "hey") + object = Object.normalize(activity, false) + + {:ok, create_data, _} = Builder.create(user, object.data["id"], [recipient.ap_id]) + + {:error, cng} = ObjectValidator.validate(create_data, []) + + assert {:object, {"The object to create already exists", []}} in cng.errors + end + end + describe "chat messages" do setup do clear_config([:instance, :remote_limit]) From dedffd100c231aa69d7a7f7cd7126b90a84fc1ec Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 28 Apr 2020 17:29:54 +0200 Subject: [PATCH 057/401] Pipeline: Unify, refactor, DRY. --- lib/pleroma/web/activity_pub/builder.ex | 4 +-- .../web/activity_pub/object_validator.ex | 18 ++++++++--- .../transmogrifier/chat_message_handling.ex | 31 ++++--------------- lib/pleroma/web/common_api/common_api.ex | 5 ++- .../activity_pub/object_validator_test.exs | 2 +- 5 files changed, 24 insertions(+), 36 deletions(-) diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 7576ed278..7f9c071b3 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -11,13 +11,13 @@ defmodule Pleroma.Web.ActivityPub.Builder do alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility - def create(actor, object_id, recipients) do + def create(actor, object, recipients) do {:ok, %{ "id" => Utils.generate_activity_id(), "actor" => actor.ap_id, "to" => recipients, - "object" => object_id, + "object" => object, "type" => "Create", "published" => DateTime.utc_now() |> DateTime.to_iso8601() }, []} diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index a4da9242a..bada3509d 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -38,16 +38,24 @@ def validate(%{"type" => "ChatMessage"} = object, meta) do end end - def validate(%{"type" => "Create"} = object, meta) do - with {:ok, object} <- - object + def validate(%{"type" => "Create", "object" => object} = create_activity, meta) do + with {:ok, object_data} <- cast_and_apply(object), + meta = Keyword.put(meta, :object_data, object_data |> stringify_keys), + {:ok, create_activity} <- + create_activity |> CreateChatMessageValidator.cast_and_validate(meta) |> Ecto.Changeset.apply_action(:insert) do - object = stringify_keys(object) - {:ok, object, meta} + create_activity = stringify_keys(create_activity) + {:ok, create_activity, meta} end end + def cast_and_apply(%{"type" => "ChatMessage"} = object) do + ChatMessageValidator.cast_and_apply(object) + end + + def cast_and_apply(o), do: {:error, {:validator_not_set, o}} + def stringify_keys(%{__struct__: _} = object) do object |> Map.from_struct() diff --git a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex index 043d847d1..d9c36e313 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex @@ -4,9 +4,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageHandling do alias Pleroma.Repo - alias Pleroma.Web.ActivityPub.ObjectValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator alias Pleroma.Web.ActivityPub.Pipeline def handle_incoming( @@ -15,31 +12,15 @@ def handle_incoming( ) do # Create has to be run inside a transaction because the object is created as a side effect. # If this does not work, we need to roll back creating the activity. - case Repo.transaction(fn -> do_handle_incoming(data) end) do - {:ok, value} -> - value + case Repo.transaction(fn -> Pipeline.common_pipeline(data, local: false) end) do + {:ok, {:ok, activity, _}} -> + {:ok, activity} + + {:ok, e} -> + e {:error, e} -> {:error, e} end end - - def do_handle_incoming( - %{"type" => "Create", "object" => %{"type" => "ChatMessage"} = object_data} = data - ) do - with {_, {:ok, cast_data_sym}} <- - {:casting_data, data |> CreateChatMessageValidator.cast_and_apply()}, - cast_data = ObjectValidator.stringify_keys(cast_data_sym), - {_, {:ok, object_cast_data_sym}} <- - {:casting_object_data, object_data |> ChatMessageValidator.cast_and_apply()}, - object_cast_data = ObjectValidator.stringify_keys(object_cast_data_sym), - {_, {:ok, activity, _meta}} <- - {:common_pipeline, - Pipeline.common_pipeline(cast_data, local: false, object_data: object_cast_data)} do - {:ok, activity} - else - e -> - {:error, e} - end - end end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index c39d1cee6..ef86ec1e4 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -40,12 +40,11 @@ def post_chat_message(%User{} = user, %User{} = recipient, content) do )}, {_, {:ok, create_activity_data, _meta}} <- {:build_create_activity, - Builder.create(user, chat_message_data["id"], [recipient.ap_id])}, + Builder.create(user, chat_message_data, [recipient.ap_id])}, {_, {:ok, %Activity{} = activity, _meta}} <- {:common_pipeline, Pipeline.common_pipeline(create_activity_data, - local: true, - object_data: chat_message_data + local: true )} do {:ok, activity} else diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index baa4b2585..41f67964a 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -17,7 +17,7 @@ test "it is invalid if the object already exists" do {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "hey") object = Object.normalize(activity, false) - {:ok, create_data, _} = Builder.create(user, object.data["id"], [recipient.ap_id]) + {:ok, create_data, _} = Builder.create(user, object.data, [recipient.ap_id]) {:error, cng} = ObjectValidator.validate(create_data, []) From 67659afe487def6bd4e0ccfbf8d015fda2a8ac61 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 13:34:43 +0200 Subject: [PATCH 058/401] ChatOperation: Refactor. --- .../web/api_spec/operations/chat_operation.ex | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 59539e890..88b9db048 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -5,11 +5,12 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema - alias Pleroma.Web.ApiSpec.Helpers alias Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest alias Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse alias Pleroma.Web.ApiSpec.Schemas.ChatResponse + import Pleroma.Web.ApiSpec.Helpers + @spec open_api_operation(atom) :: Operation.t() def open_api_operation(action) do operation = String.to_existing_atom("#{action}_operation") @@ -52,11 +53,7 @@ def index_operation do tags: ["chat"], summary: "Get a list of chats that you participated in", operationId: "ChatController.index", - parameters: [ - Operation.parameter(:limit, :query, :integer, "How many results to return", example: 20), - Operation.parameter(:min_id, :query, :string, "Return only chats after this id"), - Operation.parameter(:max_id, :query, :string, "Return only chats before this id") - ], + parameters: pagination_params(), responses: %{ 200 => Operation.response("The chats of the user", "application/json", chats_response()) }, @@ -73,12 +70,9 @@ def messages_operation do tags: ["chat"], summary: "Get the most recent messages of the chat", operationId: "ChatController.messages", - parameters: [ - Operation.parameter(:id, :path, :string, "The ID of the Chat"), - Operation.parameter(:limit, :query, :integer, "How many results to return", example: 20), - Operation.parameter(:min_id, :query, :string, "Return only messages after this id"), - Operation.parameter(:max_id, :query, :string, "Return only messages before this id") - ], + parameters: + [Operation.parameter(:id, :path, :string, "The ID of the Chat")] ++ + pagination_params(), responses: %{ 200 => Operation.response( @@ -103,7 +97,7 @@ def post_chat_message_operation do parameters: [ Operation.parameter(:id, :path, :string, "The ID of the Chat") ], - requestBody: Helpers.request_body("Parameters", ChatMessageCreateRequest, required: true), + requestBody: request_body("Parameters", ChatMessageCreateRequest, required: true), responses: %{ 200 => Operation.response( From e055b8d2036e18a95d84f6f1db08fc465fe9975d Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 13:45:50 +0200 Subject: [PATCH 059/401] Pipeline: Always run common_pipeline in a transaction for now. --- lib/pleroma/web/activity_pub/pipeline.ex | 11 ++++ .../transmogrifier/chat_message_handling.ex | 12 ++--- lib/pleroma/web/common_api/common_api.ex | 52 ++++++++----------- .../transmogrifier/chat_message_test.exs | 1 + 4 files changed, 36 insertions(+), 40 deletions(-) diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 4213ba751..d5abb7567 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do alias Pleroma.Activity alias Pleroma.Object + alias Pleroma.Repo alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.ActivityPub.ObjectValidator @@ -14,6 +15,16 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do @spec common_pipeline(map(), keyword()) :: {:ok, Activity.t() | Object.t(), keyword()} | {:error, any()} def common_pipeline(object, meta) do + case Repo.transaction(fn -> do_common_pipeline(object, meta) end) do + {:ok, value} -> + value + + {:error, e} -> + {:error, e} + end + end + + def do_common_pipeline(object, meta) do with {_, {:ok, validated_object, meta}} <- {:validate_object, ObjectValidator.validate(object, meta)}, {_, {:ok, mrfd_object}} <- {:mrf_object, MRF.filter(validated_object)}, diff --git a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex index d9c36e313..b1cc93481 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex @@ -3,24 +3,18 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageHandling do - alias Pleroma.Repo alias Pleroma.Web.ActivityPub.Pipeline def handle_incoming( %{"type" => "Create", "object" => %{"type" => "ChatMessage"}} = data, _options ) do - # Create has to be run inside a transaction because the object is created as a side effect. - # If this does not work, we need to roll back creating the activity. - case Repo.transaction(fn -> Pipeline.common_pipeline(data, local: false) end) do - {:ok, {:ok, activity, _}} -> + case Pipeline.common_pipeline(data, local: false) do + {:ok, activity, _} -> {:ok, activity} - {:ok, e} -> + e -> e - - {:error, e} -> - {:error, e} end end end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index ef86ec1e4..359045f48 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -9,7 +9,6 @@ defmodule Pleroma.Web.CommonAPI do alias Pleroma.FollowingRelationship alias Pleroma.Formatter alias Pleroma.Object - alias Pleroma.Repo alias Pleroma.ThreadMute alias Pleroma.User alias Pleroma.UserRelationship @@ -26,36 +25,27 @@ defmodule Pleroma.Web.CommonAPI do require Logger def post_chat_message(%User{} = user, %User{} = recipient, content) do - transaction = - Repo.transaction(fn -> - with {_, true} <- - {:content_length, - String.length(content) <= Pleroma.Config.get([:instance, :chat_limit])}, - {_, {:ok, chat_message_data, _meta}} <- - {:build_object, - Builder.chat_message( - user, - recipient.ap_id, - content |> Formatter.html_escape("text/plain") - )}, - {_, {:ok, create_activity_data, _meta}} <- - {:build_create_activity, - Builder.create(user, chat_message_data, [recipient.ap_id])}, - {_, {:ok, %Activity{} = activity, _meta}} <- - {:common_pipeline, - Pipeline.common_pipeline(create_activity_data, - local: true - )} do - {:ok, activity} - else - {:content_length, false} -> {:error, :content_too_long} - e -> e - end - end) - - case transaction do - {:ok, value} -> value - error -> error + with {_, true} <- + {:content_length, + String.length(content) <= Pleroma.Config.get([:instance, :chat_limit])}, + {_, {:ok, chat_message_data, _meta}} <- + {:build_object, + Builder.chat_message( + user, + recipient.ap_id, + content |> Formatter.html_escape("text/plain") + )}, + {_, {:ok, create_activity_data, _meta}} <- + {:build_create_activity, Builder.create(user, chat_message_data, [recipient.ap_id])}, + {_, {:ok, %Activity{} = activity, _meta}} <- + {:common_pipeline, + Pipeline.common_pipeline(create_activity_data, + local: true + )} do + {:ok, activity} + else + {:content_length, false} -> {:error, :content_too_long} + e -> e end end diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs index ceaee614c..c5600e84e 100644 --- a/test/web/activity_pub/transmogrifier/chat_message_test.exs +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -68,6 +68,7 @@ test "it inserts it and creates a chat" do recipient = insert(:user, ap_id: List.first(data["to"]), local: true) {:ok, %Activity{} = activity} = Transmogrifier.handle_incoming(data) + assert activity.local == false assert activity.actor == author.ap_id assert activity.recipients == [recipient.ap_id, author.ap_id] From 53e3063bd041409da83483e8f5c47030bf346123 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 13:52:23 +0200 Subject: [PATCH 060/401] Transmogrifier: Remove ChatMessageHandling module. --- lib/pleroma/web/activity_pub/side_effects.ex | 13 ++++-------- .../web/activity_pub/transmogrifier.ex | 14 +++++++++---- .../transmogrifier/chat_message_handling.ex | 20 ------------------- 3 files changed, 14 insertions(+), 33 deletions(-) delete mode 100644 lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 794a46267..e394c75d7 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -19,17 +19,12 @@ def handle(object, meta \\ []) # - Add like to object # - Set up notification def handle(%{data: %{"type" => "Like"}} = object, meta) do - {:ok, result} = - Pleroma.Repo.transaction(fn -> - liked_object = Object.get_by_ap_id(object.data["object"]) - Utils.add_like_to_object(object, liked_object) + liked_object = Object.get_by_ap_id(object.data["object"]) + Utils.add_like_to_object(object, liked_object) - Notification.create_notifications(object) + Notification.create_notifications(object) - {:ok, object, meta} - end) - - result + {:ok, object, meta} end # Tasks this handles diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 66975cf7d..3c2fe73a3 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -16,7 +16,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.Pipeline - alias Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageHandling alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.Federator @@ -646,9 +645,16 @@ def handle_incoming( def handle_incoming( %{"type" => "Create", "object" => %{"type" => "ChatMessage"}} = data, - options - ), - do: ChatMessageHandling.handle_incoming(data, options) + _options + ) do + case Pipeline.common_pipeline(data, local: false) do + {:ok, activity, _} -> + {:ok, activity} + + e -> + e + end + end def handle_incoming(%{"type" => "Like"} = data, _options) do with {_, {:ok, cast_data_sym}} <- diff --git a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex deleted file mode 100644 index b1cc93481..000000000 --- a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex +++ /dev/null @@ -1,20 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageHandling do - alias Pleroma.Web.ActivityPub.Pipeline - - def handle_incoming( - %{"type" => "Create", "object" => %{"type" => "ChatMessage"}} = data, - _options - ) do - case Pipeline.common_pipeline(data, local: false) do - {:ok, activity, _} -> - {:ok, activity} - - e -> - e - end - end -end From a88734a0a22810bcc47c17fc9120ef7881d670d8 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 14:25:33 +0200 Subject: [PATCH 061/401] Transmogrifier: Fetch missing actors for chatmessages. --- .../web/activity_pub/object_validator.ex | 9 ++++- .../create_chat_message_validator.ex | 2 + .../web/activity_pub/transmogrifier.ex | 8 ++-- .../transmogrifier/chat_message_test.exs | 40 ++++++++++++++++--- 4 files changed, 49 insertions(+), 10 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index bada3509d..50904ed59 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Object alias Pleroma.User + alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator @@ -67,8 +68,14 @@ def stringify_keys(object) do |> Map.new(fn {key, val} -> {to_string(key), val} end) end + def fetch_actor(object) do + with {:ok, actor} <- Types.ObjectID.cast(object["actor"]) do + User.get_or_fetch_by_ap_id(actor) + end + end + def fetch_actor_and_object(object) do - User.get_or_fetch_by_ap_id(object["actor"]) + fetch_actor(object) Object.normalize(object["object"]) :ok end diff --git a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex index dfc91bf71..88e903182 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex @@ -12,6 +12,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @primary_key false @@ -42,6 +43,7 @@ def validate_data(cng, meta \\ []) do cng |> validate_required([:id, :actor, :to, :type, :object]) |> validate_inclusion(:type, ["Create"]) + |> validate_actor_presence() |> validate_recipients_match(meta) |> validate_object_nonexistence() end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 3c2fe73a3..6dbd3f588 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -647,10 +647,10 @@ def handle_incoming( %{"type" => "Create", "object" => %{"type" => "ChatMessage"}} = data, _options ) do - case Pipeline.common_pipeline(data, local: false) do - {:ok, activity, _} -> - {:ok, activity} - + with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), + {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do + {:ok, activity} + else e -> e end diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs index c5600e84e..85644d787 100644 --- a/test/web/activity_pub/transmogrifier/chat_message_test.exs +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -26,8 +26,15 @@ test "it rejects messages that don't contain content" do data |> Map.put("object", object) - _author = insert(:user, ap_id: data["actor"], local: false) - _recipient = insert(:user, ap_id: List.first(data["to"]), local: true) + _author = + insert(:user, ap_id: data["actor"], local: false, last_refreshed_at: DateTime.utc_now()) + + _recipient = + insert(:user, + ap_id: List.first(data["to"]), + local: true, + last_refreshed_at: DateTime.utc_now() + ) {:error, _} = Transmogrifier.handle_incoming(data) end @@ -37,8 +44,15 @@ test "it rejects messages that don't concern local users" do File.read!("test/fixtures/create-chat-message.json") |> Poison.decode!() - _author = insert(:user, ap_id: data["actor"], local: false) - _recipient = insert(:user, ap_id: List.first(data["to"]), local: false) + _author = + insert(:user, ap_id: data["actor"], local: false, last_refreshed_at: DateTime.utc_now()) + + _recipient = + insert(:user, + ap_id: List.first(data["to"]), + local: false, + last_refreshed_at: DateTime.utc_now() + ) {:error, _} = Transmogrifier.handle_incoming(data) end @@ -59,12 +73,28 @@ test "it rejects messages where the `to` field of activity and object don't matc refute Object.get_by_ap_id(data["object"]["id"]) end + test "it fetches the actor if they aren't in our system" do + Tesla.Mock.mock(fn env -> apply(HttpRequestMock, :request, [env]) end) + + data = + File.read!("test/fixtures/create-chat-message.json") + |> Poison.decode!() + |> Map.put("actor", "http://mastodon.example.org/users/admin") + |> put_in(["object", "actor"], "http://mastodon.example.org/users/admin") + + _recipient = insert(:user, ap_id: List.first(data["to"]), local: true) + + {:ok, %Activity{} = _activity} = Transmogrifier.handle_incoming(data) + end + test "it inserts it and creates a chat" do data = File.read!("test/fixtures/create-chat-message.json") |> Poison.decode!() - author = insert(:user, ap_id: data["actor"], local: false) + author = + insert(:user, ap_id: data["actor"], local: false, last_refreshed_at: DateTime.utc_now()) + recipient = insert(:user, ap_id: List.first(data["to"]), local: true) {:ok, %Activity{} = activity} = Transmogrifier.handle_incoming(data) From 20587aa931262a5479c98f13450311a135c5d356 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 14:53:53 +0200 Subject: [PATCH 062/401] Chat message creation: Check actor. --- .../create_chat_message_validator.ex | 14 ++++++++++++++ test/web/activity_pub/object_validator_test.exs | 13 +++++++++++++ 2 files changed, 27 insertions(+) diff --git a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex index 88e903182..fc582400b 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex @@ -45,6 +45,7 @@ def validate_data(cng, meta \\ []) do |> validate_inclusion(:type, ["Create"]) |> validate_actor_presence() |> validate_recipients_match(meta) + |> validate_actors_match(meta) |> validate_object_nonexistence() end @@ -59,6 +60,19 @@ def validate_object_nonexistence(cng) do end) end + def validate_actors_match(cng, meta) do + object_actor = meta[:object_data]["actor"] + + cng + |> validate_change(:actor, fn :actor, actor -> + if actor == object_actor do + [] + else + [{:actor, "Actor doesn't match with object actor"}] + end + end) + end + def validate_recipients_match(cng, meta) do object_recipients = meta[:object_data]["to"] || [] diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 41f67964a..475b7bb21 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -23,6 +23,19 @@ test "it is invalid if the object already exists" do assert {:object, {"The object to create already exists", []}} in cng.errors end + + test "it is invalid if the object data has a different `to` or `actor` field" do + user = insert(:user) + recipient = insert(:user) + {:ok, object_data, _} = Builder.chat_message(recipient, user.ap_id, "Hey") + + {:ok, create_data, _} = Builder.create(user, object_data, [recipient.ap_id]) + + {:error, cng} = ObjectValidator.validate(create_data, []) + + assert {:to, {"Recipients don't match with object recipients", []}} in cng.errors + assert {:actor, {"Actor doesn't match with object actor", []}} in cng.errors + end end describe "chat messages" do From 528ea779a61d12f74ee9669bbd28783bf66aa5cc Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 17:56:24 +0000 Subject: [PATCH 063/401] Apply suggestion to docs/API/chats.md --- docs/API/chats.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index 24c4b4d06..26e83570e 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -99,7 +99,7 @@ For a given Chat id, you can get the associated messages with `GET /api/v1/pleroma/chats/{id}/messages` This will return all messages, sorted by most recent to least recent. The usual -pagination options are implemented +pagination options are implemented. Returned data: From 89a6c340812a53daf00a203dacd8e12a25eb7ad2 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 18:14:34 +0000 Subject: [PATCH 064/401] Apply suggestion to lib/pleroma/chat.ex --- lib/pleroma/chat.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index b8545063a..6b1f832ce 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -29,7 +29,7 @@ def creation_cng(struct, params) do |> validate_change(:recipient, fn :recipient, recipient -> case User.get_cached_by_ap_id(recipient) do - nil -> [recipient: "must a an existing user"] + nil -> [recipient: "must be an existing user"] _ -> [] end end) From 589ce1e96bcaba0bd2d864d3528992f10f4cf5f7 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 19:47:16 +0000 Subject: [PATCH 065/401] Apply suggestion to lib/pleroma/web/activity_pub/transmogrifier.ex --- lib/pleroma/web/activity_pub/transmogrifier.ex | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 6dbd3f588..d3a2e0362 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -650,9 +650,6 @@ def handle_incoming( with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do {:ok, activity} - else - e -> - e end end From 145d35ff70a59efcff881315d5f1f7a0248a34be Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 19:49:03 +0000 Subject: [PATCH 066/401] Apply suggestion to lib/pleroma/web/pleroma_api/controllers/chat_controller.ex --- lib/pleroma/web/pleroma_api/controllers/chat_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 8654f4295..175257921 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -31,7 +31,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do %{scopes: ["read:statuses"]} when action in [:messages, :index] ) - plug(OpenApiSpex.Plug.CastAndValidate) + plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ChatOperation From b68d56c8168f27f63e157d43558e22f7c221c0e2 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 19:49:13 +0000 Subject: [PATCH 067/401] Apply suggestion to lib/pleroma/web/api_spec/schemas/chat_message_response.ex --- lib/pleroma/web/api_spec/schemas/chat_message_response.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/schemas/chat_message_response.ex b/lib/pleroma/web/api_spec/schemas/chat_message_response.ex index b7a662cbb..707c9808b 100644 --- a/lib/pleroma/web/api_spec/schemas/chat_message_response.ex +++ b/lib/pleroma/web/api_spec/schemas/chat_message_response.ex @@ -16,7 +16,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse do account_id: %Schema{type: :string, description: "The Mastodon API id of the actor"}, chat_id: %Schema{type: :string}, content: %Schema{type: :string}, - created_at: %Schema{type: :string, format: :datetime}, + created_at: %Schema{type: :string, format: :"date-time"}, emojis: %Schema{type: :array} }, example: %{ From ad2182bbd231b475c5bfc70485f35ad1f8841912 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 11:38:26 +0000 Subject: [PATCH 068/401] Apply suggestion to lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex --- lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex b/lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex index 4dafcda43..8e1b7af14 100644 --- a/lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex +++ b/lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex @@ -13,6 +13,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest do properties: %{ content: %Schema{type: :string, description: "The content of your message"} }, + required: [:content], example: %{ "content" => "Hey wanna buy feet pics?" } From a35b76431ce7c7bd7ed62374d781778922f0fe2f Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 3 May 2020 14:58:24 +0200 Subject: [PATCH 069/401] Credo fixes. --- lib/pleroma/web/activity_pub/object_validator.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 50904ed59..20c7cceb6 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -11,10 +11,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Object alias Pleroma.User - alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.Types @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) From 9249742f13445f47167d4b352751c49caf48aa8f Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 3 May 2020 15:28:24 +0200 Subject: [PATCH 070/401] Types.Recipients: Simplify reducer. --- .../object_validators/types/recipients.ex | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex index 48fe61e1a..408e0f6ee 100644 --- a/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex +++ b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex @@ -11,11 +11,13 @@ def cast(object) when is_binary(object) do def cast(data) when is_list(data) do data - |> Enum.reduce({:ok, []}, fn element, acc -> - case {acc, ObjectID.cast(element)} do - {:error, _} -> :error - {_, :error} -> :error - {{:ok, list}, {:ok, id}} -> {:ok, [id | list]} + |> Enum.reduce_while({:ok, []}, fn element, {:ok, list} -> + case ObjectID.cast(element) do + {:ok, id} -> + {:cont, {:ok, [id | list]}} + + _ -> + {:halt, :error} end end) end From 651935f1379a1ed3c89e473803251310c13ea571 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 4 May 2020 11:08:00 +0200 Subject: [PATCH 071/401] Schemas: Refactor to our naming scheme. --- .../web/api_spec/operations/chat_operation.ex | 12 ++++++------ .../api_spec/schemas/{chat_response.ex => chat.ex} | 4 ++-- .../{chat_message_response.ex => chat_message.ex} | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) rename lib/pleroma/web/api_spec/schemas/{chat_response.ex => chat.ex} (96%) rename lib/pleroma/web/api_spec/schemas/{chat_message_response.ex => chat_message.ex} (91%) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 88b9db048..fc9d4608a 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -6,8 +6,8 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest - alias Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse - alias Pleroma.Web.ApiSpec.Schemas.ChatResponse + alias Pleroma.Web.ApiSpec.Schemas.ChatMessage + alias Pleroma.Web.ApiSpec.Schemas.Chat import Pleroma.Web.ApiSpec.Helpers @@ -37,7 +37,7 @@ def create_operation do Operation.response( "The created or existing chat", "application/json", - ChatResponse + Chat ) }, security: [ @@ -103,7 +103,7 @@ def post_chat_message_operation do Operation.response( "The newly created ChatMessage", "application/json", - ChatMessageResponse + ChatMessage ) }, security: [ @@ -119,7 +119,7 @@ def chats_response do title: "ChatsResponse", description: "Response schema for multiple Chats", type: :array, - items: ChatResponse, + items: Chat, example: [ %{ "account" => %{ @@ -180,7 +180,7 @@ def chat_messages_response do title: "ChatMessagesResponse", description: "Response schema for multiple ChatMessages", type: :array, - items: ChatMessageResponse, + items: ChatMessage, example: [ %{ "emojis" => [ diff --git a/lib/pleroma/web/api_spec/schemas/chat_response.ex b/lib/pleroma/web/api_spec/schemas/chat.ex similarity index 96% rename from lib/pleroma/web/api_spec/schemas/chat_response.ex rename to lib/pleroma/web/api_spec/schemas/chat.ex index aa435165d..4d385d6ab 100644 --- a/lib/pleroma/web/api_spec/schemas/chat_response.ex +++ b/lib/pleroma/web/api_spec/schemas/chat.ex @@ -2,13 +2,13 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.ApiSpec.Schemas.ChatResponse do +defmodule Pleroma.Web.ApiSpec.Schemas.Chat do alias OpenApiSpex.Schema require OpenApiSpex OpenApiSpex.schema(%{ - title: "ChatResponse", + title: "Chat", description: "Response schema for a Chat", type: :object, properties: %{ diff --git a/lib/pleroma/web/api_spec/schemas/chat_message_response.ex b/lib/pleroma/web/api_spec/schemas/chat_message.ex similarity index 91% rename from lib/pleroma/web/api_spec/schemas/chat_message_response.ex rename to lib/pleroma/web/api_spec/schemas/chat_message.ex index 707c9808b..7c93b0c83 100644 --- a/lib/pleroma/web/api_spec/schemas/chat_message_response.ex +++ b/lib/pleroma/web/api_spec/schemas/chat_message.ex @@ -2,13 +2,13 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse do +defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessage do alias OpenApiSpex.Schema require OpenApiSpex OpenApiSpex.schema(%{ - title: "ChatMessageResponse", + title: "ChatMessage", description: "Response schema for a ChatMessage", type: :object, properties: %{ From dcf535fe770b638b68928f238f4d8d1cfd410524 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 4 May 2020 11:32:11 +0200 Subject: [PATCH 072/401] Credo fixes. --- lib/pleroma/web/api_spec/operations/chat_operation.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index fc9d4608a..ad05f5ac7 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -5,9 +5,9 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema - alias Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest - alias Pleroma.Web.ApiSpec.Schemas.ChatMessage alias Pleroma.Web.ApiSpec.Schemas.Chat + alias Pleroma.Web.ApiSpec.Schemas.ChatMessage + alias Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest import Pleroma.Web.ApiSpec.Helpers From 57e6f2757afef8941fe3576dbe5e2014d2569c33 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 4 May 2020 12:47:23 +0200 Subject: [PATCH 073/401] ChatOperation: Make simple schema into inline schema --- .../web/api_spec/operations/chat_operation.ex | 18 ++++++++++++++-- .../schemas/chat_message_create_request.ex | 21 ------------------- 2 files changed, 16 insertions(+), 23 deletions(-) delete mode 100644 lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index ad05f5ac7..e8b5eff1f 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -7,7 +7,6 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Schemas.Chat alias Pleroma.Web.ApiSpec.Schemas.ChatMessage - alias Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest import Pleroma.Web.ApiSpec.Helpers @@ -97,7 +96,7 @@ def post_chat_message_operation do parameters: [ Operation.parameter(:id, :path, :string, "The ID of the Chat") ], - requestBody: request_body("Parameters", ChatMessageCreateRequest, required: true), + requestBody: request_body("Parameters", chat_message_create(), required: true), responses: %{ 200 => Operation.response( @@ -208,4 +207,19 @@ def chat_messages_response do ] } end + + def chat_message_create do + %Schema{ + title: "ChatMessageCreateRequest", + description: "POST body for creating an chat message", + type: :object, + properties: %{ + content: %Schema{type: :string, description: "The content of your message"} + }, + required: [:content], + example: %{ + "content" => "Hey wanna buy feet pics?" + } + } + end end diff --git a/lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex b/lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex deleted file mode 100644 index 8e1b7af14..000000000 --- a/lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex +++ /dev/null @@ -1,21 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest do - alias OpenApiSpex.Schema - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "ChatMessageCreateRequest", - description: "POST body for creating an chat message", - type: :object, - properties: %{ - content: %Schema{type: :string, description: "The content of your message"} - }, - required: [:content], - example: %{ - "content" => "Hey wanna buy feet pics?" - } - }) -end From 30590cf46b88d0008c9a7163b8339aa9376f2378 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 4 May 2020 12:53:40 +0200 Subject: [PATCH 074/401] CommonAPI: Refactor for readability --- lib/pleroma/web/common_api/common_api.ex | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 1eda0b2f2..e428cc17d 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -26,9 +26,7 @@ defmodule Pleroma.Web.CommonAPI do require Logger def post_chat_message(%User{} = user, %User{} = recipient, content) do - with {_, true} <- - {:content_length, - String.length(content) <= Pleroma.Config.get([:instance, :chat_limit])}, + with :ok <- validate_chat_content_length(content), {_, {:ok, chat_message_data, _meta}} <- {:build_object, Builder.chat_message( @@ -44,9 +42,14 @@ def post_chat_message(%User{} = user, %User{} = recipient, content) do local: true )} do {:ok, activity} + end + end + + defp validate_chat_content_length(content) do + if String.length(content) <= Pleroma.Config.get([:instance, :chat_limit]) do + :ok else - {:content_length, false} -> {:error, :content_too_long} - e -> e + {:error, :content_too_long} end end From b04328c3dec4812dbaf3cd89baa2b888d7bb7fbf Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 4 May 2020 13:10:36 +0200 Subject: [PATCH 075/401] ChatController: Add mark_as_read --- lib/pleroma/chat.ex | 6 +++++ .../web/api_spec/operations/chat_operation.ex | 22 ++++++++++++++++++ .../controllers/chat_controller.ex | 11 ++++++++- lib/pleroma/web/router.ex | 1 + .../controllers/chat_controller_test.exs | 23 +++++++++++++++++++ 5 files changed, 62 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 6b1f832ce..6008196e4 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -60,4 +60,10 @@ def bump_or_create(user_id, recipient) do conflict_target: [:user_id, :recipient] ) end + + def mark_as_read(chat) do + chat + |> change(%{unread: 0}) + |> Repo.update() + end end diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index e8b5eff1f..0fe0e07b2 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -16,6 +16,28 @@ def open_api_operation(action) do apply(__MODULE__, operation, []) end + def mark_as_read_operation do + %Operation{ + tags: ["chat"], + summary: "Mark all messages in the chat as read", + operationId: "ChatController.mark_as_read", + parameters: [Operation.parameter(:id, :path, :string, "The ID of the Chat")], + responses: %{ + 200 => + Operation.response( + "The updated chat", + "application/json", + Chat + ) + }, + security: [ + %{ + "oAuth" => ["write"] + } + ] + } + end + def create_operation do %Operation{ tags: ["chat"], diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 175257921..bedae73bd 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -23,7 +23,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do plug( OAuthScopesPlug, - %{scopes: ["write:statuses"]} when action in [:post_chat_message, :create] + %{scopes: ["write:statuses"]} when action in [:post_chat_message, :create, :mark_as_read] ) plug( @@ -51,6 +51,15 @@ def post_chat_message( end end + def mark_as_read(%{assigns: %{user: %{id: user_id}}} = conn, %{id: id}) do + with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), + {:ok, chat} <- Chat.mark_as_read(chat) do + conn + |> put_view(ChatView) + |> render("show.json", chat: chat) + end + end + def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{id: id} = params) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id) do messages = diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 3a5063d4a..d6803e8ac 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -293,6 +293,7 @@ defmodule Pleroma.Web.Router do get("/chats", ChatController, :index) get("/chats/:id/messages", ChatController, :messages) post("/chats/:id/messages", ChatController, :post_chat_message) + post("/chats/:id/read", ChatController, :mark_as_read) end scope [] do diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index b1044574b..cdb2683c8 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -9,6 +9,29 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do import Pleroma.Factory + describe "POST /api/v1/pleroma/chats/:id/read" do + setup do: oauth_access(["write:statuses"]) + + test "it marks all messages in a chat as read", %{conn: conn, user: user} do + other_user = insert(:user) + + {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) + + assert chat.unread == 1 + + result = + conn + |> post("/api/v1/pleroma/chats/#{chat.id}/read") + |> json_response_and_validate_schema(200) + + assert result["unread"] == 0 + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + + assert chat.unread == 0 + end + end + describe "POST /api/v1/pleroma/chats/:id/messages" do setup do: oauth_access(["write:statuses"]) From 7ff2a7dae2fa651cea579aeca40e2c030d19fcd5 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 4 May 2020 13:12:21 +0200 Subject: [PATCH 076/401] Docs: Add Chat mark_as_read docs --- docs/API/chats.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/API/chats.md b/docs/API/chats.md index 26e83570e..8d925989c 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -66,6 +66,27 @@ Returned data: } ``` +### Marking a chat as read + +To set the `unread` count of a chat to 0, call + +`POST /api/v1/pleroma/chats/:id/read` + +Returned data: + +```json +{ + "account": { + "id": "someflakeid", + "username": "somenick", + ... + }, + "id" : "1", + "unread" : 0 +} +``` + + ### Getting a list of Chats `GET /api/v1/pleroma/chats` From 9637cded21cef1e6c531dd46d5f5245c4c3ed03c Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 May 2020 20:07:47 +0200 Subject: [PATCH 077/401] Chat: Fix missing chat id on second 'get' --- lib/pleroma/chat.ex | 3 ++- test/chat_test.exs | 13 ++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 6008196e4..1a092b992 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -46,7 +46,8 @@ def get_or_create(user_id, recipient) do %__MODULE__{} |> creation_cng(%{user_id: user_id, recipient: recipient}) |> Repo.insert( - on_conflict: :nothing, + # Need to set something, otherwise we get nothing back at all + on_conflict: [set: [recipient: recipient]], returning: true, conflict_target: [:user_id, :recipient] ) diff --git a/test/chat_test.exs b/test/chat_test.exs index 952598c87..943e48111 100644 --- a/test/chat_test.exs +++ b/test/chat_test.exs @@ -26,13 +26,24 @@ test "it creates a chat for a user and recipient" do assert chat.id end - test "it returns a chat for a user and recipient if it already exists" do + test "it returns and bumps a chat for a user and recipient if it already exists" do user = insert(:user) other_user = insert(:user) {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) {:ok, chat_two} = Chat.bump_or_create(user.id, other_user.ap_id) + assert chat.id == chat_two.id + assert chat_two.unread == 2 + end + + test "it returns a chat for a user and recipient if it already exists" do + user = insert(:user) + other_user = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + {:ok, chat_two} = Chat.get_or_create(user.id, other_user.ap_id) + assert chat.id == chat_two.id end From 20baa2eaf04425cf0a2eebc84760be6c12ee7f51 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 6 May 2020 16:12:36 +0200 Subject: [PATCH 078/401] ChatMessages: Add attachments. --- lib/pleroma/web/activity_pub/builder.ex | 33 ++++++--- .../web/activity_pub/object_validator.ex | 11 ++- .../object_validators/attachment_validator.ex | 72 +++++++++++++++++++ .../chat_message_validator.ex | 6 +- .../object_validators/url_object_validator.ex | 20 ++++++ .../web/api_spec/operations/chat_operation.ex | 3 +- .../web/api_spec/schemas/chat_message.ex | 6 +- lib/pleroma/web/common_api/common_api.ex | 6 +- .../controllers/chat_controller.ex | 6 +- .../pleroma_api/views/chat_message_view.ex | 5 +- .../activity_pub/object_validator_test.exs | 50 ++++++++++++- .../types/object_id_test.exs | 4 ++ .../controllers/chat_controller_test.exs | 27 +++++++ .../views/chat_message_view_test.exs | 12 +++- 14 files changed, 237 insertions(+), 24 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 7f9c071b3..67e65c7b9 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -23,17 +23,28 @@ def create(actor, object, recipients) do }, []} end - def chat_message(actor, recipient, content) do - {:ok, - %{ - "id" => Utils.generate_object_id(), - "actor" => actor.ap_id, - "type" => "ChatMessage", - "to" => [recipient], - "content" => content, - "published" => DateTime.utc_now() |> DateTime.to_iso8601(), - "emoji" => Emoji.Formatter.get_emoji_map(content) - }, []} + def chat_message(actor, recipient, content, opts \\ []) do + basic = %{ + "id" => Utils.generate_object_id(), + "actor" => actor.ap_id, + "type" => "ChatMessage", + "to" => [recipient], + "content" => content, + "published" => DateTime.utc_now() |> DateTime.to_iso8601(), + "emoji" => Emoji.Formatter.get_emoji_map(content) + } + + case opts[:attachment] do + %Object{data: attachment_data} -> + { + :ok, + Map.put(basic, "attachment", attachment_data), + [] + } + + _ -> + {:ok, basic, []} + end end @spec like(User.t(), Object.t()) :: {:ok, map(), keyword()} diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 20c7cceb6..d6c14f7b8 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -63,11 +63,18 @@ def stringify_keys(%{__struct__: _} = object) do |> stringify_keys end - def stringify_keys(object) do + def stringify_keys(object) when is_map(object) do object - |> Map.new(fn {key, val} -> {to_string(key), val} end) + |> Map.new(fn {key, val} -> {to_string(key), stringify_keys(val)} end) end + def stringify_keys(object) when is_list(object) do + object + |> Enum.map(&stringify_keys/1) + end + + def stringify_keys(object), do: object + def fetch_actor(object) do with {:ok, actor} <- Types.ObjectID.cast(object["actor"]) do User.get_or_fetch_by_ap_id(actor) diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex new file mode 100644 index 000000000..16ed49051 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex @@ -0,0 +1,72 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.ObjectValidators.UrlObjectValidator + + import Ecto.Changeset + + @primary_key false + embedded_schema do + field(:type, :string) + field(:mediaType, :string) + field(:name, :string) + + embeds_many(:url, UrlObjectValidator) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + def changeset(struct, data) do + data = + data + |> fix_media_type() + |> fix_url() + + struct + |> cast(data, [:type, :mediaType, :name]) + |> cast_embed(:url, required: true) + end + + def fix_media_type(data) do + data + |> Map.put_new("mediaType", data["mimeType"]) + end + + def fix_url(data) do + case data["url"] do + url when is_binary(url) -> + data + |> Map.put( + "url", + [ + %{ + "href" => url, + "type" => "Link", + "mediaType" => data["mediaType"] + } + ] + ) + + _ -> + data + end + end + + def validate_data(cng) do + cng + |> validate_required([:mediaType, :url, :type]) + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index e87c1ac2e..99ffeba28 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator import Ecto.Changeset import Pleroma.Web.ActivityPub.Transmogrifier, only: [fix_emoji: 1] @@ -22,6 +23,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do field(:actor, Types.ObjectID) field(:published, Types.DateTime) field(:emoji, :map, default: %{}) + + embeds_one(:attachment, AttachmentValidator) end def cast_and_apply(data) do @@ -51,7 +54,8 @@ def changeset(struct, data) do data = fix(data) struct - |> cast(data, __schema__(:fields)) + |> cast(data, List.delete(__schema__(:fields), :attachment)) + |> cast_embed(:attachment) end def validate_data(data_cng) do diff --git a/lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex b/lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex new file mode 100644 index 000000000..47e231150 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex @@ -0,0 +1,20 @@ +defmodule Pleroma.Web.ActivityPub.ObjectValidators.UrlObjectValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + + import Ecto.Changeset + @primary_key false + + embedded_schema do + field(:type, :string) + field(:href, Types.Uri) + field(:mediaType, :string) + end + + def changeset(struct, data) do + struct + |> cast(data, __schema__(:fields)) + |> validate_required([:type, :href, :mediaType]) + end +end diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 0fe0e07b2..8b9dc2e44 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -236,7 +236,8 @@ def chat_message_create do description: "POST body for creating an chat message", type: :object, properties: %{ - content: %Schema{type: :string, description: "The content of your message"} + content: %Schema{type: :string, description: "The content of your message"}, + media_id: %Schema{type: :string, description: "The id of an upload"} }, required: [:content], example: %{ diff --git a/lib/pleroma/web/api_spec/schemas/chat_message.ex b/lib/pleroma/web/api_spec/schemas/chat_message.ex index 7c93b0c83..89e062ddd 100644 --- a/lib/pleroma/web/api_spec/schemas/chat_message.ex +++ b/lib/pleroma/web/api_spec/schemas/chat_message.ex @@ -17,7 +17,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessage do chat_id: %Schema{type: :string}, content: %Schema{type: :string}, created_at: %Schema{type: :string, format: :"date-time"}, - emojis: %Schema{type: :array} + emojis: %Schema{type: :array}, + attachment: %Schema{type: :object, nullable: true} }, example: %{ "account_id" => "someflakeid", @@ -32,7 +33,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessage do "url" => "https://dontbulling.me/emoji/Firefox.gif" } ], - "id" => "14" + "id" => "14", + "attachment" => nil } }) end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index e428cc17d..38b5c6f7c 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -25,14 +25,16 @@ defmodule Pleroma.Web.CommonAPI do require Pleroma.Constants require Logger - def post_chat_message(%User{} = user, %User{} = recipient, content) do + def post_chat_message(%User{} = user, %User{} = recipient, content, opts \\ []) do with :ok <- validate_chat_content_length(content), + maybe_attachment <- opts[:media_id] && Object.get_by_id(opts[:media_id]), {_, {:ok, chat_message_data, _meta}} <- {:build_object, Builder.chat_message( user, recipient.ap_id, - content |> Formatter.html_escape("text/plain") + content |> Formatter.html_escape("text/plain"), + attachment: maybe_attachment )}, {_, {:ok, create_activity_data, _meta}} <- {:build_create_activity, Builder.create(user, chat_message_data, [recipient.ap_id])}, diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index bedae73bd..450d85332 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -36,14 +36,16 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ChatOperation def post_chat_message( - %{body_params: %{content: content}, assigns: %{user: %{id: user_id} = user}} = conn, + %{body_params: %{content: content} = params, assigns: %{user: %{id: user_id} = user}} = + conn, %{ id: id } ) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), %User{} = recipient <- User.get_cached_by_ap_id(chat.recipient), - {:ok, activity} <- CommonAPI.post_chat_message(user, recipient, content), + {:ok, activity} <- + CommonAPI.post_chat_message(user, recipient, content, media_id: params[:media_id]), message <- Object.normalize(activity) do conn |> put_view(ChatMessageView) diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex index a821479ab..b088a8734 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex @@ -23,7 +23,10 @@ def render( chat_id: chat_id |> to_string(), account_id: User.get_cached_by_ap_id(chat_message["actor"]).id, created_at: Utils.to_masto_date(chat_message["published"]), - emojis: StatusView.build_emojis(chat_message["emoji"]) + emojis: StatusView.build_emojis(chat_message["emoji"]), + attachment: + chat_message["attachment"] && + StatusView.render("attachment.json", attachment: chat_message["attachment"]) } end diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 60db7187f..951ed7800 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -2,14 +2,41 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do use Pleroma.DataCase alias Pleroma.Object + alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI import Pleroma.Factory + describe "attachments" do + test "it turns mastodon attachments into our attachments" do + attachment = %{ + "url" => + "http://mastodon.example.org/system/media_attachments/files/000/000/002/original/334ce029e7bfb920.jpg", + "type" => "Document", + "name" => nil, + "mediaType" => "image/jpeg" + } + + {:ok, attachment} = + AttachmentValidator.cast_and_validate(attachment) + |> Ecto.Changeset.apply_action(:insert) + + assert [ + %{ + href: + "http://mastodon.example.org/system/media_attachments/files/000/000/002/original/334ce029e7bfb920.jpg", + type: "Link", + mediaType: "image/jpeg" + } + ] = attachment.url + end + end + describe "chat message create activities" do test "it is invalid if the object already exists" do user = insert(:user) @@ -52,7 +79,28 @@ test "it is invalid if the object data has a different `to` or `actor` field" do test "validates for a basic object we build", %{valid_chat_message: valid_chat_message} do assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) - assert object == valid_chat_message + assert Map.put(valid_chat_message, "attachment", nil) == object + end + + test "validates for a basic object with an attachment", %{ + valid_chat_message: valid_chat_message, + user: user + } do + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id) + + valid_chat_message = + valid_chat_message + |> Map.put("attachment", attachment.data) + + assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) + + assert object["attachment"] end test "does not validate if the message is longer than the remote_limit", %{ diff --git a/test/web/activity_pub/object_validators/types/object_id_test.exs b/test/web/activity_pub/object_validators/types/object_id_test.exs index 834213182..c8911948e 100644 --- a/test/web/activity_pub/object_validators/types/object_id_test.exs +++ b/test/web/activity_pub/object_validators/types/object_id_test.exs @@ -1,3 +1,7 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.ObjectValidators.Types.ObjectIDTest do alias Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID use Pleroma.DataCase diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index cdb2683c8..72a9a91ff 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -6,6 +6,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do alias Pleroma.Chat alias Pleroma.Web.CommonAPI + alias Pleroma.Web.ActivityPub.ActivityPub import Pleroma.Factory @@ -49,6 +50,32 @@ test "it posts a message to the chat", %{conn: conn, user: user} do assert result["content"] == "Hallo!!" assert result["chat_id"] == chat.id |> to_string() end + + test "it works with an attachment", %{conn: conn, user: user} do + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id) + + other_user = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + + result = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/pleroma/chats/#{chat.id}/messages", %{ + "content" => "Hallo!!", + "media_id" => to_string(upload.id) + }) + |> json_response_and_validate_schema(200) + + assert result["content"] == "Hallo!!" + assert result["chat_id"] == chat.id |> to_string() + end end describe "GET /api/v1/pleroma/chats/:id/messages" do diff --git a/test/web/pleroma_api/views/chat_message_view_test.exs b/test/web/pleroma_api/views/chat_message_view_test.exs index 5c4c8b0d5..a13a41daa 100644 --- a/test/web/pleroma_api/views/chat_message_view_test.exs +++ b/test/web/pleroma_api/views/chat_message_view_test.exs @@ -9,12 +9,21 @@ defmodule Pleroma.Web.PleromaAPI.ChatMessageViewTest do alias Pleroma.Object alias Pleroma.Web.CommonAPI alias Pleroma.Web.PleromaAPI.ChatMessageView + alias Pleroma.Web.ActivityPub.ActivityPub import Pleroma.Factory test "it displays a chat message" do user = insert(:user) recipient = insert(:user) + + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id) {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "kippis :firefox:") chat = Chat.get(user.id, recipient.ap_id) @@ -30,7 +39,7 @@ test "it displays a chat message" do assert chat_message[:created_at] assert match?([%{shortcode: "firefox"}], chat_message[:emojis]) - {:ok, activity} = CommonAPI.post_chat_message(recipient, user, "gkgkgk") + {:ok, activity} = CommonAPI.post_chat_message(recipient, user, "gkgkgk", media_id: upload.id) object = Object.normalize(activity) @@ -40,5 +49,6 @@ test "it displays a chat message" do assert chat_message_two[:content] == "gkgkgk" assert chat_message_two[:account_id] == recipient.id assert chat_message_two[:chat_id] == chat_message[:chat_id] + assert chat_message_two[:attachment] end end From fc9d0b6eec1b206a27f4ec19f7939b3318a209ef Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 6 May 2020 16:31:21 +0200 Subject: [PATCH 079/401] Credo fixes. --- .../activity_pub/object_validators/chat_message_validator.ex | 2 +- test/web/activity_pub/object_validator_test.exs | 2 +- test/web/pleroma_api/controllers/chat_controller_test.exs | 2 +- test/web/pleroma_api/views/chat_message_view_test.exs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index 99ffeba28..e40c80ab4 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -6,8 +6,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do use Ecto.Schema alias Pleroma.User - alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset import Pleroma.Web.ActivityPub.Transmogrifier, only: [fix_emoji: 1] diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 951ed7800..fcc54c8a1 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -5,8 +5,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 72a9a91ff..b4b73da90 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -5,8 +5,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do use Pleroma.Web.ConnCase, async: true alias Pleroma.Chat - alias Pleroma.Web.CommonAPI alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI import Pleroma.Factory diff --git a/test/web/pleroma_api/views/chat_message_view_test.exs b/test/web/pleroma_api/views/chat_message_view_test.exs index a13a41daa..d7a2d10a5 100644 --- a/test/web/pleroma_api/views/chat_message_view_test.exs +++ b/test/web/pleroma_api/views/chat_message_view_test.exs @@ -7,9 +7,9 @@ defmodule Pleroma.Web.PleromaAPI.ChatMessageViewTest do alias Pleroma.Chat alias Pleroma.Object + alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI alias Pleroma.Web.PleromaAPI.ChatMessageView - alias Pleroma.Web.ActivityPub.ActivityPub import Pleroma.Factory From d0bf8cfb8f852a16259af4b808565cdfd58f5e61 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 8 May 2020 14:11:58 +0200 Subject: [PATCH 080/401] Credo fixes. --- lib/pleroma/web/activity_pub/side_effects.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 28b519432..c8b675d54 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -5,8 +5,8 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do liked object, a `Follow` activity will add the user to the follower collection, and so on. """ - alias Pleroma.Chat alias Pleroma.Activity + alias Pleroma.Chat alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo From 03529f6a0528ed01c7a956bb80628910584a9580 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 8 May 2020 18:26:35 +0200 Subject: [PATCH 081/401] Transmogrifier: Don't modify attachments for chats. --- lib/pleroma/web/activity_pub/transmogrifier.ex | 3 +++ test/web/common_api/common_api_test.exs | 2 ++ 2 files changed, 5 insertions(+) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 29f668cad..f04dec6be 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -1114,6 +1114,9 @@ def add_attributed_to(object) do Map.put(object, "attributedTo", attributed_to) end + # TODO: Revisit this + def prepare_attachments(%{"type" => "ChatMessage"} = object), do: object + def prepare_attachments(object) do attachments = object diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 61affda5d..5501ba18b 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -54,6 +54,8 @@ test "it posts a chat message" do assert Chat.get(author.id, recipient.ap_id) assert Chat.get(recipient.id, author.ap_id) + + assert :ok == Pleroma.Web.Federator.perform(:publish, activity) end test "it reject messages over the local limit" do From 0c2b09a9ba771b3b04a0a08ed940823bd8601a9f Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Fri, 8 May 2020 22:08:11 +0300 Subject: [PATCH 082/401] Add migration for counter_cache table update --- ...00508092434_update_counter_cache_table.exs | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 priv/repo/migrations/20200508092434_update_counter_cache_table.exs diff --git a/priv/repo/migrations/20200508092434_update_counter_cache_table.exs b/priv/repo/migrations/20200508092434_update_counter_cache_table.exs new file mode 100644 index 000000000..81a8d6397 --- /dev/null +++ b/priv/repo/migrations/20200508092434_update_counter_cache_table.exs @@ -0,0 +1,144 @@ +defmodule Pleroma.Repo.Migrations.UpdateCounterCacheTable do + use Ecto.Migration + + @function_name "update_status_visibility_counter_cache" + @trigger_name "status_visibility_counter_cache_trigger" + + def up do + execute("drop trigger if exists #{@trigger_name} on activities") + execute("drop function if exists #{@function_name}()") + drop_if_exists(unique_index(:counter_cache, [:name])) + drop_if_exists(table(:counter_cache)) + + create_if_not_exists table(:counter_cache) do + add(:instance, :string, null: false) + add(:direct, :bigint, null: false, default: 0) + add(:private, :bigint, null: false, default: 0) + add(:unlisted, :bigint, null: false, default: 0) + add(:public, :bigint, null: false, default: 0) + end + + create_if_not_exists(unique_index(:counter_cache, [:instance])) + + """ + CREATE OR REPLACE FUNCTION #{@function_name}() + RETURNS TRIGGER AS + $$ + DECLARE + token_id smallint; + hostname character varying(255); + visibility_new character varying(64); + visibility_old character varying(64); + actor character varying(255); + BEGIN + SELECT "tokid" INTO "token_id" FROM ts_token_type('default') WHERE "alias" = 'host'; + IF TG_OP = 'DELETE' THEN + actor := OLD.actor; + ELSE + actor := NEW.actor; + END IF; + SELECT "token" INTO "hostname" FROM ts_parse('default', actor) WHERE "tokid" = token_id; + IF hostname IS NULL THEN + hostname := split_part(actor, '/', 3); + END IF; + IF TG_OP = 'INSERT' THEN + visibility_new := activity_visibility(NEW.actor, NEW.recipients, NEW.data); + IF NEW.data->>'type' = 'Create' THEN + EXECUTE format('INSERT INTO "counter_cache" ("instance", %1$I) VALUES ($1, 1) + ON CONFLICT ("instance") DO + UPDATE SET %1$I = "counter_cache".%1$I + 1', visibility_new) + USING hostname; + END IF; + RETURN NEW; + ELSIF TG_OP = 'UPDATE' THEN + visibility_new := activity_visibility(NEW.actor, NEW.recipients, NEW.data); + visibility_old := activity_visibility(OLD.actor, OLD.recipients, OLD.data); + IF (NEW.data->>'type' = 'Create') and (OLD.data->>'type' = 'Create') and visibility_new != visibility_old THEN + EXECUTE format('UPDATE "counter_cache" SET + %1$I = greatest("counter_cache".%1$I - 1, 0), + %2$I = "counter_cache".%2$I + 1 + WHERE "instance" = $1', visibility_old, visibility_new) + USING hostname; + END IF; + RETURN NEW; + ELSIF TG_OP = 'DELETE' THEN + IF OLD.data->>'type' = 'Create' THEN + visibility_old := activity_visibility(OLD.actor, OLD.recipients, OLD.data); + EXECUTE format('UPDATE "counter_cache" SET + %1$I = greatest("counter_cache".%1$I - 1, 0) + WHERE "instance" = $1', visibility_old) + USING hostname; + END IF; + RETURN OLD; + END IF; + END; + $$ + LANGUAGE 'plpgsql'; + """ + |> execute() + + execute("DROP TRIGGER IF EXISTS #{@trigger_name} ON activities") + + """ + CREATE TRIGGER #{@trigger_name} + BEFORE + INSERT + OR UPDATE of recipients, data + OR DELETE + ON activities + FOR EACH ROW + EXECUTE PROCEDURE #{@function_name}(); + """ + |> execute() + end + + def down do + execute("DROP TRIGGER IF EXISTS #{@trigger_name} ON activities") + execute("DROP FUNCTION IF EXISTS #{@function_name}()") + drop_if_exists(unique_index(:counter_cache, [:instance])) + drop_if_exists(table(:counter_cache)) + + create_if_not_exists table(:counter_cache) do + add(:name, :string, null: false) + add(:count, :bigint, null: false, default: 0) + end + + create_if_not_exists(unique_index(:counter_cache, [:name])) + + """ + CREATE OR REPLACE FUNCTION #{@function_name}() + RETURNS TRIGGER AS + $$ + DECLARE + BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW.data->>'type' = 'Create' THEN + EXECUTE 'INSERT INTO counter_cache (name, count) VALUES (''status_visibility_' || activity_visibility(NEW.actor, NEW.recipients, NEW.data) || ''', 1) ON CONFLICT (name) DO UPDATE SET count = counter_cache.count + 1'; + END IF; + RETURN NEW; + ELSIF TG_OP = 'UPDATE' THEN + IF (NEW.data->>'type' = 'Create') and (OLD.data->>'type' = 'Create') and activity_visibility(NEW.actor, NEW.recipients, NEW.data) != activity_visibility(OLD.actor, OLD.recipients, OLD.data) THEN + EXECUTE 'INSERT INTO counter_cache (name, count) VALUES (''status_visibility_' || activity_visibility(NEW.actor, NEW.recipients, NEW.data) || ''', 1) ON CONFLICT (name) DO UPDATE SET count = counter_cache.count + 1'; + EXECUTE 'update counter_cache SET count = counter_cache.count - 1 where count > 0 and name = ''status_visibility_' || activity_visibility(OLD.actor, OLD.recipients, OLD.data) || ''';'; + END IF; + RETURN NEW; + ELSIF TG_OP = 'DELETE' THEN + IF OLD.data->>'type' = 'Create' THEN + EXECUTE 'update counter_cache SET count = counter_cache.count - 1 where count > 0 and name = ''status_visibility_' || activity_visibility(OLD.actor, OLD.recipients, OLD.data) || ''';'; + END IF; + RETURN OLD; + END IF; + END; + $$ + LANGUAGE 'plpgsql'; + """ + |> execute() + + """ + CREATE TRIGGER #{@trigger_name} BEFORE INSERT OR UPDATE of recipients, data OR DELETE ON activities + FOR EACH ROW + EXECUTE PROCEDURE #{@function_name}(); + """ + |> execute() + end +end From 39d2f2118aed7906cb352d8a37f22da73f3a3aa3 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 9 May 2020 01:20:50 +0300 Subject: [PATCH 083/401] update counter_cache logic --- .../tasks/pleroma/refresh_counter_cache.ex | 42 ++++++++---- lib/pleroma/counter_cache.ex | 66 +++++++++++++++---- lib/pleroma/stats.ex | 8 +-- 3 files changed, 82 insertions(+), 34 deletions(-) diff --git a/lib/mix/tasks/pleroma/refresh_counter_cache.ex b/lib/mix/tasks/pleroma/refresh_counter_cache.ex index 15b4dbfa6..280201bef 100644 --- a/lib/mix/tasks/pleroma/refresh_counter_cache.ex +++ b/lib/mix/tasks/pleroma/refresh_counter_cache.ex @@ -17,30 +17,46 @@ defmodule Mix.Tasks.Pleroma.RefreshCounterCache do def run([]) do Mix.Pleroma.start_pleroma() - ["public", "unlisted", "private", "direct"] - |> Enum.each(fn visibility -> - count = status_visibility_count_query(visibility) - name = "status_visibility_#{visibility}" - CounterCache.set(name, count) - Mix.Pleroma.shell_info("Set #{name} to #{count}") + Activity + |> distinct([a], true) + |> select([a], fragment("split_part(?, '/', 3)", a.actor)) + |> Repo.all() + |> Enum.each(fn instance -> + counters = instance_counters(instance) + CounterCache.set(instance, counters) + Mix.Pleroma.shell_info("Setting #{instance} counters: #{inspect(counters)}") end) Mix.Pleroma.shell_info("Done") end - defp status_visibility_count_query(visibility) do + defp instance_counters(instance) do + counters = %{"public" => 0, "unlisted" => 0, "private" => 0, "direct" => 0} + Activity - |> where( + |> where([a], fragment("(? ->> 'type'::text) = 'Create'", a.data)) + |> where([a], like(a.actor, ^"%#{instance}%")) + |> select( + [a], + {fragment( + "activity_visibility(?, ?, ?)", + a.actor, + a.recipients, + a.data + ), count(a.id)} + ) + |> group_by( [a], fragment( - "activity_visibility(?, ?, ?) = ?", + "activity_visibility(?, ?, ?)", a.actor, a.recipients, - a.data, - ^visibility + a.data ) ) - |> where([a], fragment("(? ->> 'type'::text) = 'Create'", a.data)) - |> Repo.aggregate(:count, :id, timeout: :timer.minutes(30)) + |> Repo.all(timeout: :timer.minutes(30)) + |> Enum.reduce(counters, fn {visibility, count}, acc -> + Map.put(acc, visibility, count) + end) end end diff --git a/lib/pleroma/counter_cache.ex b/lib/pleroma/counter_cache.ex index 4d348a413..b469e7b50 100644 --- a/lib/pleroma/counter_cache.ex +++ b/lib/pleroma/counter_cache.ex @@ -10,32 +10,70 @@ defmodule Pleroma.CounterCache do import Ecto.Query schema "counter_cache" do - field(:name, :string) - field(:count, :integer) + field(:instance, :string) + field(:public, :integer) + field(:unlisted, :integer) + field(:private, :integer) + field(:direct, :integer) end def changeset(struct, params) do struct - |> cast(params, [:name, :count]) - |> validate_required([:name]) - |> unique_constraint(:name) + |> cast(params, [:instance, :public, :unlisted, :private, :direct]) + |> validate_required([:instance]) + |> unique_constraint(:instance) end - def get_as_map(names) when is_list(names) do + def get_by_instance(instance) do CounterCache - |> where([cc], cc.name in ^names) - |> Repo.all() - |> Enum.group_by(& &1.name, & &1.count) - |> Map.new(fn {k, v} -> {k, hd(v)} end) + |> select([c], %{ + "public" => c.public, + "unlisted" => c.unlisted, + "private" => c.private, + "direct" => c.direct + }) + |> where([c], c.instance == ^instance) + |> Repo.one() + |> case do + nil -> %{"public" => 0, "unlisted" => 0, "private" => 0, "direct" => 0} + val -> val + end end - def set(name, count) do + def get_as_map() do + CounterCache + |> select([c], %{ + "public" => sum(c.public), + "unlisted" => sum(c.unlisted), + "private" => sum(c.private), + "direct" => sum(c.direct) + }) + |> Repo.one() + end + + def set(instance, values) do + params = + Enum.reduce( + ["public", "private", "unlisted", "direct"], + %{"instance" => instance}, + fn param, acc -> + Map.put_new(acc, param, Map.get(values, param, 0)) + end + ) + %CounterCache{} - |> changeset(%{"name" => name, "count" => count}) + |> changeset(params) |> Repo.insert( - on_conflict: [set: [count: count]], + on_conflict: [ + set: [ + public: params["public"], + private: params["private"], + unlisted: params["unlisted"], + direct: params["direct"] + ] + ], returning: true, - conflict_target: :name + conflict_target: :instance ) end end diff --git a/lib/pleroma/stats.ex b/lib/pleroma/stats.ex index 6b3a8a41f..4e355bd5c 100644 --- a/lib/pleroma/stats.ex +++ b/lib/pleroma/stats.ex @@ -98,13 +98,7 @@ def calculate_stat_data do end def get_status_visibility_count do - counter_cache = - CounterCache.get_as_map([ - "status_visibility_public", - "status_visibility_private", - "status_visibility_unlisted", - "status_visibility_direct" - ]) + counter_cache = CounterCache.get_as_map() %{ public: counter_cache["status_visibility_public"] || 0, From cbe383ae832f13d5d2a20ee8fb5e85498205fbc3 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 9 May 2020 11:30:37 +0300 Subject: [PATCH 084/401] Update stats admin endpoint --- lib/pleroma/counter_cache.ex | 6 +- lib/pleroma/stats.ex | 15 ++--- .../web/admin_api/admin_api_controller.ex | 7 +-- ...00508092434_update_counter_cache_table.exs | 8 ++- test/stats_test.exs | 55 ++++++++++++++++--- .../admin_api/admin_api_controller_test.exs | 20 +++++++ 6 files changed, 87 insertions(+), 24 deletions(-) diff --git a/lib/pleroma/counter_cache.ex b/lib/pleroma/counter_cache.ex index b469e7b50..a940b5e50 100644 --- a/lib/pleroma/counter_cache.ex +++ b/lib/pleroma/counter_cache.ex @@ -40,7 +40,7 @@ def get_by_instance(instance) do end end - def get_as_map() do + def get_sum() do CounterCache |> select([c], %{ "public" => sum(c.public), @@ -49,6 +49,10 @@ def get_as_map() do "direct" => sum(c.direct) }) |> Repo.one() + |> Enum.map(fn {visibility, dec_count} -> + {visibility, Decimal.to_integer(dec_count)} + end) + |> Enum.into(%{}) end def set(instance, values) do diff --git a/lib/pleroma/stats.ex b/lib/pleroma/stats.ex index 4e355bd5c..9a03f01db 100644 --- a/lib/pleroma/stats.ex +++ b/lib/pleroma/stats.ex @@ -97,14 +97,11 @@ def calculate_stat_data do } end - def get_status_visibility_count do - counter_cache = CounterCache.get_as_map() - - %{ - public: counter_cache["status_visibility_public"] || 0, - unlisted: counter_cache["status_visibility_unlisted"] || 0, - private: counter_cache["status_visibility_private"] || 0, - direct: counter_cache["status_visibility_direct"] || 0 - } + def get_status_visibility_count(instance \\ nil) do + if is_nil(instance) do + CounterCache.get_sum() + else + CounterCache.get_by_instance(instance) + end end end diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 9f1fd3aeb..4db9f4cac 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -1122,11 +1122,10 @@ def oauth_app_delete(conn, params) do end end - def stats(conn, _) do - count = Stats.get_status_visibility_count() + def stats(conn, params) do + counters = Stats.get_status_visibility_count(params["instance"]) - conn - |> json(%{"status_visibility" => count}) + json(conn, %{"status_visibility" => counters}) end defp errors(conn, {:error, :not_found}) do diff --git a/priv/repo/migrations/20200508092434_update_counter_cache_table.exs b/priv/repo/migrations/20200508092434_update_counter_cache_table.exs index 81a8d6397..3d9bfc877 100644 --- a/priv/repo/migrations/20200508092434_update_counter_cache_table.exs +++ b/priv/repo/migrations/20200508092434_update_counter_cache_table.exs @@ -43,7 +43,8 @@ def up do END IF; IF TG_OP = 'INSERT' THEN visibility_new := activity_visibility(NEW.actor, NEW.recipients, NEW.data); - IF NEW.data->>'type' = 'Create' THEN + IF NEW.data->>'type' = 'Create' + AND visibility_new IN ('public', 'unlisted', 'private', 'direct') THEN EXECUTE format('INSERT INTO "counter_cache" ("instance", %1$I) VALUES ($1, 1) ON CONFLICT ("instance") DO UPDATE SET %1$I = "counter_cache".%1$I + 1', visibility_new) @@ -53,7 +54,10 @@ def up do ELSIF TG_OP = 'UPDATE' THEN visibility_new := activity_visibility(NEW.actor, NEW.recipients, NEW.data); visibility_old := activity_visibility(OLD.actor, OLD.recipients, OLD.data); - IF (NEW.data->>'type' = 'Create') and (OLD.data->>'type' = 'Create') and visibility_new != visibility_old THEN + IF (NEW.data->>'type' = 'Create') + AND (OLD.data->>'type' = 'Create') + AND visibility_new != visibility_old + AND visibility_new IN ('public', 'unlisted', 'private', 'direct') THEN EXECUTE format('UPDATE "counter_cache" SET %1$I = greatest("counter_cache".%1$I - 1, 0), %2$I = "counter_cache".%2$I + 1 diff --git a/test/stats_test.exs b/test/stats_test.exs index c1aeb2c7f..33ed0b7dd 100644 --- a/test/stats_test.exs +++ b/test/stats_test.exs @@ -17,10 +17,11 @@ test "it ignores internal users" do end end - describe "status visibility count" do + describe "status visibility sum count" do test "on new status" do + instance2 = "instance2.tld" user = insert(:user) - other_user = insert(:user) + other_user = insert(:user, %{ap_id: "https://#{instance2}/@actor"}) CommonAPI.post(user, %{"visibility" => "public", "status" => "hey"}) @@ -45,24 +46,24 @@ test "on new status" do }) end) - assert %{direct: 3, private: 4, public: 1, unlisted: 2} = + assert %{"direct" => 3, "private" => 4, "public" => 1, "unlisted" => 2} = Pleroma.Stats.get_status_visibility_count() end test "on status delete" do user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{"visibility" => "public", "status" => "hey"}) - assert %{public: 1} = Pleroma.Stats.get_status_visibility_count() + assert %{"public" => 1} = Pleroma.Stats.get_status_visibility_count() CommonAPI.delete(activity.id, user) - assert %{public: 0} = Pleroma.Stats.get_status_visibility_count() + assert %{"public" => 0} = Pleroma.Stats.get_status_visibility_count() end test "on status visibility update" do user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{"visibility" => "public", "status" => "hey"}) - assert %{public: 1, private: 0} = Pleroma.Stats.get_status_visibility_count() + assert %{"public" => 1, "private" => 0} = Pleroma.Stats.get_status_visibility_count() {:ok, _} = CommonAPI.update_activity_scope(activity.id, %{"visibility" => "private"}) - assert %{public: 0, private: 1} = Pleroma.Stats.get_status_visibility_count() + assert %{"public" => 0, "private" => 1} = Pleroma.Stats.get_status_visibility_count() end test "doesn't count unrelated activities" do @@ -73,8 +74,46 @@ test "doesn't count unrelated activities" do CommonAPI.favorite(other_user, activity.id) CommonAPI.repeat(activity.id, other_user) - assert %{direct: 0, private: 0, public: 1, unlisted: 0} = + assert %{"direct" => 0, "private" => 0, "public" => 1, "unlisted" => 0} = Pleroma.Stats.get_status_visibility_count() end end + + describe "status visibility by instance count" do + test "single instance" do + local_instance = Pleroma.Web.Endpoint.url() |> String.split("//") |> Enum.at(1) + instance2 = "instance2.tld" + user1 = insert(:user) + user2 = insert(:user, %{ap_id: "https://#{instance2}/@actor"}) + + CommonAPI.post(user1, %{"visibility" => "public", "status" => "hey"}) + + Enum.each(1..5, fn _ -> + CommonAPI.post(user1, %{ + "visibility" => "unlisted", + "status" => "hey" + }) + end) + + Enum.each(1..10, fn _ -> + CommonAPI.post(user1, %{ + "visibility" => "direct", + "status" => "hey @#{user2.nickname}" + }) + end) + + Enum.each(1..20, fn _ -> + CommonAPI.post(user2, %{ + "visibility" => "private", + "status" => "hey" + }) + end) + + assert %{"direct" => 10, "private" => 0, "public" => 1, "unlisted" => 5} = + Pleroma.Stats.get_status_visibility_count(local_instance) + + assert %{"direct" => 0, "private" => 20, "public" => 0, "unlisted" => 0} = + Pleroma.Stats.get_status_visibility_count(instance2) + end + end end diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 4697af50e..c3de89ac0 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -3612,6 +3612,26 @@ test "status visibility count", %{conn: conn} do assert %{"direct" => 0, "private" => 0, "public" => 1, "unlisted" => 2} = response["status_visibility"] end + + test "by instance", %{conn: conn} do + admin = insert(:user, is_admin: true) + user1 = insert(:user) + instance2 = "instance2.tld" + user2 = insert(:user, %{ap_id: "https://#{instance2}/@actor"}) + + CommonAPI.post(user1, %{"visibility" => "public", "status" => "hey"}) + CommonAPI.post(user2, %{"visibility" => "unlisted", "status" => "hey"}) + CommonAPI.post(user2, %{"visibility" => "private", "status" => "hey"}) + + response = + conn + |> assign(:user, admin) + |> get("/api/pleroma/admin/stats", instance: instance2) + |> json_response(200) + + assert %{"direct" => 0, "private" => 1, "public" => 0, "unlisted" => 1} = + response["status_visibility"] + end end describe "POST /api/pleroma/admin/oauth_app" do From 01b06d6dbfdeff7e1733d575fb94eee4dafbb56a Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 9 May 2020 11:43:31 +0300 Subject: [PATCH 085/401] Show progress in refresh_counter_cache task --- .../tasks/pleroma/refresh_counter_cache.ex | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/lib/mix/tasks/pleroma/refresh_counter_cache.ex b/lib/mix/tasks/pleroma/refresh_counter_cache.ex index 280201bef..b44e2545d 100644 --- a/lib/mix/tasks/pleroma/refresh_counter_cache.ex +++ b/lib/mix/tasks/pleroma/refresh_counter_cache.ex @@ -17,14 +17,21 @@ defmodule Mix.Tasks.Pleroma.RefreshCounterCache do def run([]) do Mix.Pleroma.start_pleroma() - Activity - |> distinct([a], true) - |> select([a], fragment("split_part(?, '/', 3)", a.actor)) - |> Repo.all() - |> Enum.each(fn instance -> + instances = + Activity + |> distinct([a], true) + |> select([a], fragment("split_part(?, '/', 3)", a.actor)) + |> Repo.all() + + instances + |> Enum.with_index(1) + |> Enum.each(fn {instance, i} -> counters = instance_counters(instance) CounterCache.set(instance, counters) - Mix.Pleroma.shell_info("Setting #{instance} counters: #{inspect(counters)}") + + Mix.Pleroma.shell_info( + "[#{i}/#{length(instances)}] Setting #{instance} counters: #{inspect(counters)}" + ) end) Mix.Pleroma.shell_info("Done") From 5c368b004b1a736836d4bc9f68c54714a33056cd Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 9 May 2020 11:49:54 +0300 Subject: [PATCH 086/401] Fix refresh_counter_cache test --- test/tasks/refresh_counter_cache_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/tasks/refresh_counter_cache_test.exs b/test/tasks/refresh_counter_cache_test.exs index b63f44c08..378664148 100644 --- a/test/tasks/refresh_counter_cache_test.exs +++ b/test/tasks/refresh_counter_cache_test.exs @@ -37,7 +37,7 @@ test "counts statuses" do assert capture_io(fn -> Mix.Tasks.Pleroma.RefreshCounterCache.run([]) end) =~ "Done\n" - assert %{direct: 3, private: 4, public: 1, unlisted: 2} = + assert %{"direct" => 3, "private" => 4, "public" => 1, "unlisted" => 2} = Pleroma.Stats.get_status_visibility_count() end end From 4f265397179e7286f27fafaf8365a0edc6972448 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 9 May 2020 11:59:49 +0300 Subject: [PATCH 087/401] Fix credo warning --- lib/pleroma/counter_cache.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/counter_cache.ex b/lib/pleroma/counter_cache.ex index a940b5e50..aa6d38687 100644 --- a/lib/pleroma/counter_cache.ex +++ b/lib/pleroma/counter_cache.ex @@ -40,7 +40,7 @@ def get_by_instance(instance) do end end - def get_sum() do + def get_sum do CounterCache |> select([c], %{ "public" => sum(c.public), From 56819f7f0604e5d4eb69edba1d6828256acbc7fe Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 9 May 2020 13:13:26 +0300 Subject: [PATCH 088/401] Use index on refresh_counter_cache --- lib/mix/tasks/pleroma/refresh_counter_cache.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mix/tasks/pleroma/refresh_counter_cache.ex b/lib/mix/tasks/pleroma/refresh_counter_cache.ex index b44e2545d..efcbaa3b1 100644 --- a/lib/mix/tasks/pleroma/refresh_counter_cache.ex +++ b/lib/mix/tasks/pleroma/refresh_counter_cache.ex @@ -42,7 +42,7 @@ defp instance_counters(instance) do Activity |> where([a], fragment("(? ->> 'type'::text) = 'Create'", a.data)) - |> where([a], like(a.actor, ^"%#{instance}%")) + |> where([a], fragment("split_part(?, '/', 3) = ?", a.actor, ^instance)) |> select( [a], {fragment( From 4c197023903a183790fb2dc67b5637bfabd53bcb Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 9 May 2020 14:32:08 +0300 Subject: [PATCH 089/401] Add docs --- CHANGELOG.md | 12 ++++++++++++ docs/API/admin_api.md | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d469793f0..f2c9e106e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed
API Changes + - **Breaking:** Emoji API: changed methods and renamed routes.
+
+ Admin API Changes + +- Status visibility stats: now can return stats per instance. + +- Mix task to refresh counter cache (`mix pleroma.refresh_counter_cache`) +
+ ### Removed - **Breaking:** removed `with_move` parameter from notifications timeline. @@ -76,6 +85,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 2. Run database migrations (inside Pleroma directory): - OTP: `./bin/pleroma_ctl migrate` - From Source: `mix ecto.migrate` +3. Reset status visibility counters (inside Pleroma directory): + - OTP: `./bin/pleroma_ctl refresh_counter_cache` + - From Source: `mix pleroma.refresh_counter_cache` ## [2.0.2] - 2020-04-08 diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index c455047cc..fa74e7460 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -1096,6 +1096,10 @@ Loads json generated from `config/descriptions.exs`. ### Stats +- Query Params: + - *optional* `instance`: **string** instance hostname (without protocol) to get stats for +- Example: `https://mypleroma.org/api/pleroma/admin/stats?instance=lain.com` + - Response: ```json From f3f8ed9e19f1ab8863141ba8b31773c54f4771fb Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sun, 10 May 2020 09:13:24 +0300 Subject: [PATCH 090/401] Set sum types in query --- lib/pleroma/counter_cache.ex | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/counter_cache.ex b/lib/pleroma/counter_cache.ex index aa6d38687..ebd1f603d 100644 --- a/lib/pleroma/counter_cache.ex +++ b/lib/pleroma/counter_cache.ex @@ -43,16 +43,12 @@ def get_by_instance(instance) do def get_sum do CounterCache |> select([c], %{ - "public" => sum(c.public), - "unlisted" => sum(c.unlisted), - "private" => sum(c.private), - "direct" => sum(c.direct) + "public" => type(sum(c.public), :integer), + "unlisted" => type(sum(c.unlisted), :integer), + "private" => type(sum(c.private), :integer), + "direct" => type(sum(c.direct), :integer) }) |> Repo.one() - |> Enum.map(fn {visibility, dec_count} -> - {visibility, Decimal.to_integer(dec_count)} - end) - |> Enum.into(%{}) end def set(instance, values) do From 1054e897622d0a0727f30169d64c83a253a3d11e Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 10 May 2020 12:30:24 +0200 Subject: [PATCH 091/401] ChatOperation: Add media id to example --- lib/pleroma/web/api_spec/operations/chat_operation.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 8b9dc2e44..16d3d5e22 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -241,7 +241,8 @@ def chat_message_create do }, required: [:content], example: %{ - "content" => "Hey wanna buy feet pics?" + "content" => "Hey wanna buy feet pics?", + "media_id" => "134234" } } end From e297d8c649a03510023cff61dc6e0c7131bc29dc Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 10 May 2020 12:34:12 +0200 Subject: [PATCH 092/401] Documentation: Add attachment docs --- docs/API/chats.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/API/chats.md b/docs/API/chats.md index 8d925989c..3ddc13541 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -160,6 +160,7 @@ Posting a chat message for given Chat id works like this: Parameters: - content: The text content of the message +- media_id: The id of an upload that will be attached to the message. Currently, no formatting beyond basic escaping and emoji is implemented, as well as no attachments. This will most probably change. From f335e1404a9cd19451b531e32e3591aa323761ff Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 10 May 2020 13:00:01 +0200 Subject: [PATCH 093/401] ChatView: Add the last message to the view. --- lib/pleroma/chat.ex | 36 ++++++++++++++++++- .../controllers/chat_controller.ex | 23 ++---------- .../web/pleroma_api/views/chat_view.ex | 7 +++- test/web/pleroma_api/views/chat_view_test.exs | 17 ++++++++- 4 files changed, 60 insertions(+), 23 deletions(-) diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 1a092b992..6a03ee3c1 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -4,8 +4,11 @@ defmodule Pleroma.Chat do use Ecto.Schema - import Ecto.Changeset + import Ecto.Changeset + import Ecto.Query + + alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User @@ -23,6 +26,37 @@ defmodule Pleroma.Chat do timestamps() end + def last_message_for_chat(chat) do + messages_for_chat_query(chat) + |> order_by(desc: :id) + |> Repo.one() + end + + def messages_for_chat_query(chat) do + chat = + chat + |> Repo.preload(:user) + + from(o in Object, + where: fragment("?->>'type' = ?", o.data, "ChatMessage"), + where: + fragment( + """ + (?->>'actor' = ? and ?->'to' = ?) + OR (?->>'actor' = ? and ?->'to' = ?) + """, + o.data, + ^chat.user.ap_id, + o.data, + ^[chat.recipient], + o.data, + ^chat.recipient, + o.data, + ^[chat.user.ap_id] + ) + ) + end + def creation_cng(struct, params) do struct |> cast(params, [:user_id, :recipient, :unread]) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 450d85332..1ef3477c8 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -14,9 +14,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do alias Pleroma.Web.PleromaAPI.ChatMessageView alias Pleroma.Web.PleromaAPI.ChatView - import Pleroma.Web.ActivityPub.ObjectValidator, only: [stringify_keys: 1] - import Ecto.Query + import Pleroma.Web.ActivityPub.ObjectValidator, only: [stringify_keys: 1] # TODO # - Error handling @@ -65,24 +64,8 @@ def mark_as_read(%{assigns: %{user: %{id: user_id}}} = conn, %{id: id}) do def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{id: id} = params) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id) do messages = - from(o in Object, - where: fragment("?->>'type' = ?", o.data, "ChatMessage"), - where: - fragment( - """ - (?->>'actor' = ? and ?->'to' = ?) - OR (?->>'actor' = ? and ?->'to' = ?) - """, - o.data, - ^user.ap_id, - o.data, - ^[chat.recipient], - o.data, - ^chat.recipient, - o.data, - ^[user.ap_id] - ) - ) + chat + |> Chat.messages_for_chat_query() |> Pagination.fetch_paginated(params |> stringify_keys()) conn diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index bc3af5ef5..21f0612ff 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -8,14 +8,19 @@ defmodule Pleroma.Web.PleromaAPI.ChatView do alias Pleroma.Chat alias Pleroma.User alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.PleromaAPI.ChatMessageView def render("show.json", %{chat: %Chat{} = chat} = opts) do recipient = User.get_cached_by_ap_id(chat.recipient) + last_message = Chat.last_message_for_chat(chat) + %{ id: chat.id |> to_string(), account: AccountView.render("show.json", Map.put(opts, :user, recipient)), - unread: chat.unread + unread: chat.unread, + last_message: + last_message && ChatMessageView.render("show.json", chat: chat, object: last_message) } end diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index 1ac3483d1..8568d98c6 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -6,8 +6,11 @@ defmodule Pleroma.Web.PleromaAPI.ChatViewTest do use Pleroma.DataCase alias Pleroma.Chat + alias Pleroma.Object + alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.PleromaAPI.ChatView + alias Pleroma.Web.PleromaAPI.ChatMessageView import Pleroma.Factory @@ -22,7 +25,19 @@ test "it represents a chat" do assert represented_chat == %{ id: "#{chat.id}", account: AccountView.render("show.json", user: recipient), - unread: 0 + unread: 0, + last_message: nil } + + {:ok, chat_message_creation} = CommonAPI.post_chat_message(user, recipient, "hello") + + chat_message = Object.normalize(chat_message_creation, false) + + {:ok, chat} = Chat.get_or_create(user.id, recipient.ap_id) + + represented_chat = ChatView.render("show.json", chat: chat) + + assert represented_chat[:last_message] == + ChatMessageView.render("show.json", chat: chat, object: chat_message) end end From 17be3ff669865102848df034045eb2889eed3976 Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 10 May 2020 13:01:20 +0200 Subject: [PATCH 094/401] Documentation: Add last_message to chat docs. --- docs/API/chats.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index 3ddc13541..1f6175f77 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -62,7 +62,8 @@ Returned data: ... }, "id" : "1", - "unread" : 2 + "unread" : 2, + "last_message" : {...} // The last message in that chat } ``` @@ -105,7 +106,8 @@ Returned data: ... }, "id" : "1", - "unread" : 2 + "unread" : 2, + "last_message" : {...} // The last message in that chat } ] ``` From 172d9b11936bb029093eac430c58c89a81592c08 Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 10 May 2020 13:08:01 +0200 Subject: [PATCH 095/401] Chat: Add last_message to schema. --- lib/pleroma/web/api_spec/schemas/chat.ex | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/api_spec/schemas/chat.ex b/lib/pleroma/web/api_spec/schemas/chat.ex index 4d385d6ab..8aaa4a792 100644 --- a/lib/pleroma/web/api_spec/schemas/chat.ex +++ b/lib/pleroma/web/api_spec/schemas/chat.ex @@ -4,6 +4,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Chat do alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ChatMessage require OpenApiSpex @@ -12,9 +13,10 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Chat do description: "Response schema for a Chat", type: :object, properties: %{ - id: %Schema{type: :string, nullable: false}, - account: %Schema{type: :object, nullable: false}, - unread: %Schema{type: :integer, nullable: false} + id: %Schema{type: :string}, + account: %Schema{type: :object}, + unread: %Schema{type: :integer}, + last_message: %Schema{type: ChatMessage, nullable: true} }, example: %{ "account" => %{ @@ -64,7 +66,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Chat do "url" => "https://dontbulling.me/users/lain" }, "id" => "1", - "unread" => 2 + "unread" => 2, + "last_message" => ChatMessage.schema().example() } }) end From 8d5597ff68de22ee7b126730467649ada248aaf7 Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 10 May 2020 13:26:14 +0200 Subject: [PATCH 096/401] ChatController: Add GET /chats/:id --- .../web/api_spec/operations/chat_operation.ex | 31 +++++++++++++++++++ lib/pleroma/web/api_spec/schemas/chat.ex | 2 +- .../web/api_spec/schemas/chat_message.ex | 1 + .../controllers/chat_controller.ex | 10 +++++- lib/pleroma/web/router.ex | 1 + .../controllers/chat_controller_test.exs | 17 ++++++++++ 6 files changed, 60 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 16d3d5e22..fe6c2f52f 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -38,6 +38,37 @@ def mark_as_read_operation do } end + def show_operation do + %Operation{ + tags: ["chat"], + summary: "Create a chat", + operationId: "ChatController.show", + parameters: [ + Operation.parameter( + :id, + :path, + :string, + "The id of the chat", + required: true, + example: "1234" + ) + ], + responses: %{ + 200 => + Operation.response( + "The existing chat", + "application/json", + Chat + ) + }, + security: [ + %{ + "oAuth" => ["read"] + } + ] + } + end + def create_operation do %Operation{ tags: ["chat"], diff --git a/lib/pleroma/web/api_spec/schemas/chat.ex b/lib/pleroma/web/api_spec/schemas/chat.ex index 8aaa4a792..c6ec07c88 100644 --- a/lib/pleroma/web/api_spec/schemas/chat.ex +++ b/lib/pleroma/web/api_spec/schemas/chat.ex @@ -16,7 +16,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Chat do id: %Schema{type: :string}, account: %Schema{type: :object}, unread: %Schema{type: :integer}, - last_message: %Schema{type: ChatMessage, nullable: true} + last_message: ChatMessage }, example: %{ "account" => %{ diff --git a/lib/pleroma/web/api_spec/schemas/chat_message.ex b/lib/pleroma/web/api_spec/schemas/chat_message.ex index 89e062ddd..6e8f1a10a 100644 --- a/lib/pleroma/web/api_spec/schemas/chat_message.ex +++ b/lib/pleroma/web/api_spec/schemas/chat_message.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessage do OpenApiSpex.schema(%{ title: "ChatMessage", description: "Response schema for a ChatMessage", + nullable: true, type: :object, properties: %{ id: %Schema{type: :string}, diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 1ef3477c8..04f136dcd 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -27,7 +27,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do plug( OAuthScopesPlug, - %{scopes: ["read:statuses"]} when action in [:messages, :index] + %{scopes: ["read:statuses"]} when action in [:messages, :index, :show] ) plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError) @@ -100,4 +100,12 @@ def create(%{assigns: %{user: user}} = conn, params) do |> render("show.json", chat: chat) end end + + def show(%{assigns: %{user: user}} = conn, params) do + with %Chat{} = chat <- Repo.get_by(Chat, user_id: user.id, id: params[:id]) do + conn + |> put_view(ChatView) + |> render("show.json", chat: chat) + end + end end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 4b264c43e..3b1834d97 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -307,6 +307,7 @@ defmodule Pleroma.Web.Router do post("/chats/by-account-id/:id", ChatController, :create) get("/chats", ChatController, :index) + get("/chats/:id", ChatController, :show) get("/chats/:id/messages", ChatController, :messages) post("/chats/:id/messages", ChatController, :post_chat_message) post("/chats/:id/read", ChatController, :mark_as_read) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index b4b73da90..dda4f9e5b 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -153,6 +153,23 @@ test "it creates or returns a chat", %{conn: conn} do end end + describe "GET /api/v1/pleroma/chats/:id" do + setup do: oauth_access(["read:statuses"]) + + test "it returns a chat", %{conn: conn, user: user} do + other_user = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + + result = + conn + |> get("/api/v1/pleroma/chats/#{chat.id}") + |> json_response_and_validate_schema(200) + + assert result["id"] == to_string(chat.id) + end + end + describe "GET /api/v1/pleroma/chats" do setup do: oauth_access(["read:statuses"]) From 8cc8d960af87cdc1e2398a8470155b0930f43f5c Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 10 May 2020 13:27:40 +0200 Subject: [PATCH 097/401] Documentation: Add GET /chats/:id --- docs/API/chats.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index 1f6175f77..ed160abd9 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -43,12 +43,17 @@ you can call: `POST /api/v1/pleroma/chats/by-account-id/{account_id}` -The account id is the normal FlakeId of the usre - +The account id is the normal FlakeId of the user ``` POST /api/v1/pleroma/chats/by-account-id/someflakeid ``` +If you already have the id of a chat, you can also use + +``` +GET /api/v1/pleroma/chats/:id +``` + There will only ever be ONE Chat for you and a given recipient, so this call will return the same Chat if you already have one with that user. From 1b1dfb54eb092921fe9dab2c49928e5b04fa049b Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 10 May 2020 13:28:05 +0200 Subject: [PATCH 098/401] Credo fixes. --- test/web/pleroma_api/views/chat_view_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index 8568d98c6..e24e29835 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -9,8 +9,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatViewTest do alias Pleroma.Object alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView - alias Pleroma.Web.PleromaAPI.ChatView alias Pleroma.Web.PleromaAPI.ChatMessageView + alias Pleroma.Web.PleromaAPI.ChatView import Pleroma.Factory From fdb98715b8e6ced7c4037b1292fb10980a994803 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 11 May 2020 10:58:14 +0200 Subject: [PATCH 099/401] Chat: Fix wrong query. --- lib/pleroma/chat.ex | 1 + test/chat_test.exs | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 6a03ee3c1..4c92a58c7 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -29,6 +29,7 @@ defmodule Pleroma.Chat do def last_message_for_chat(chat) do messages_for_chat_query(chat) |> order_by(desc: :id) + |> limit(1) |> Repo.one() end diff --git a/test/chat_test.exs b/test/chat_test.exs index 943e48111..dfcb6422e 100644 --- a/test/chat_test.exs +++ b/test/chat_test.exs @@ -6,9 +6,26 @@ defmodule Pleroma.ChatTest do use Pleroma.DataCase, async: true alias Pleroma.Chat + alias Pleroma.Web.CommonAPI import Pleroma.Factory + describe "messages" do + test "it returns the last message in a chat" do + user = insert(:user) + recipient = insert(:user) + + {:ok, _message_1} = CommonAPI.post_chat_message(user, recipient, "hey") + {:ok, _message_2} = CommonAPI.post_chat_message(recipient, user, "ho") + + {:ok, chat} = Chat.get_or_create(user.id, recipient.ap_id) + + message = Chat.last_message_for_chat(chat) + + assert message.data["content"] == "ho" + end + end + describe "creation and getting" do test "it only works if the recipient is a valid user (for now)" do user = insert(:user) From b5aa204eb8bf3f737d3d807a9924c0153d1b6d3e Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 12 May 2020 13:13:03 +0200 Subject: [PATCH 100/401] ChatController: Support deletion of chat messages. --- .../object_validators/delete_validator.ex | 3 ++- .../web/api_spec/operations/chat_operation.ex | 25 +++++++++++++++++++ .../controllers/chat_controller.ex | 24 +++++++++++++++++- lib/pleroma/web/router.ex | 1 + .../controllers/chat_controller_test.exs | 24 ++++++++++++++++++ 5 files changed, 75 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex index f42c03510..e5d08eb5c 100644 --- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex @@ -46,12 +46,13 @@ def add_deleted_activity_id(cng) do Answer Article Audio + ChatMessage Event Note Page Question - Video Tombstone + Video } def validate_data(cng) do cng diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index fe6c2f52f..8ba10c603 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -166,6 +166,31 @@ def post_chat_message_operation do } end + def delete_message_operation do + %Operation{ + tags: ["chat"], + summary: "delete_message", + operationId: "ChatController.delete_message", + parameters: [ + Operation.parameter(:id, :path, :string, "The ID of the Chat"), + Operation.parameter(:message_id, :path, :string, "The ID of the message") + ], + responses: %{ + 200 => + Operation.response( + "The deleted ChatMessage", + "application/json", + ChatMessage + ) + }, + security: [ + %{ + "oAuth" => ["write"] + } + ] + } + end + def chats_response do %Schema{ title: "ChatsResponse", diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 04f136dcd..8eed88752 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -4,6 +4,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do use Pleroma.Web, :controller + alias Pleroma.Activity alias Pleroma.Chat alias Pleroma.Object alias Pleroma.Pagination @@ -22,7 +23,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do plug( OAuthScopesPlug, - %{scopes: ["write:statuses"]} when action in [:post_chat_message, :create, :mark_as_read] + %{scopes: ["write:statuses"]} + when action in [:post_chat_message, :create, :mark_as_read, :delete_message] ) plug( @@ -34,6 +36,26 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ChatOperation + def delete_message(%{assigns: %{user: %{ap_id: actor} = user}} = conn, %{ + message_id: id + }) do + with %Object{ + data: %{ + "actor" => ^actor, + "id" => object, + "to" => [recipient], + "type" => "ChatMessage" + } + } = message <- Object.get_by_id(id), + %Chat{} = chat <- Chat.get(user.id, recipient), + %Activity{} = activity <- Activity.get_create_by_object_ap_id(object), + {:ok, _delete} <- CommonAPI.delete(activity.id, user) do + conn + |> put_view(ChatMessageView) + |> render("show.json", for: user, object: message, chat: chat) + end + end + def post_chat_message( %{body_params: %{content: content} = params, assigns: %{user: %{id: user_id} = user}} = conn, diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 3b1834d97..0e4f45869 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -310,6 +310,7 @@ defmodule Pleroma.Web.Router do get("/chats/:id", ChatController, :show) get("/chats/:id/messages", ChatController, :messages) post("/chats/:id/messages", ChatController, :post_chat_message) + delete("/chats/:id/messages/:message_id", ChatController, :delete_message) post("/chats/:id/read", ChatController, :mark_as_read) end diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index dda4f9e5b..86ccbb117 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -4,6 +4,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do use Pleroma.Web.ConnCase, async: true + alias Pleroma.Object alias Pleroma.Chat alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI @@ -78,6 +79,29 @@ test "it works with an attachment", %{conn: conn, user: user} do end end + describe "DELETE /api/v1/pleroma/chats/:id/messages/:message_id" do + setup do: oauth_access(["write:statuses"]) + + test "it deletes a message for the author of the message", %{conn: conn, user: user} do + recipient = insert(:user) + + {:ok, message} = + CommonAPI.post_chat_message(user, recipient, "Hello darkness my old friend") + + object = Object.normalize(message, false) + + chat = Chat.get(user.id, recipient.ap_id) + + result = + conn + |> put_req_header("content-type", "application/json") + |> delete("/api/v1/pleroma/chats/#{chat.id}/messages/#{object.id}") + |> json_response_and_validate_schema(200) + + assert result["id"] == to_string(object.id) + end + end + describe "GET /api/v1/pleroma/chats/:id/messages" do setup do: oauth_access(["read:statuses"]) From ec72cba43ec4f45faadf1b06a6d014cd4136707e Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 12 May 2020 13:23:09 +0200 Subject: [PATCH 101/401] Chat Controller: Add basic error handling. --- .../web/pleroma_api/controllers/chat_controller.ex | 5 +++-- .../pleroma_api/controllers/chat_controller_test.exs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 8eed88752..4ce3e7419 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -18,8 +18,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do import Ecto.Query import Pleroma.Web.ActivityPub.ObjectValidator, only: [stringify_keys: 1] - # TODO - # - Error handling + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) plug( OAuthScopesPlug, @@ -53,6 +52,8 @@ def delete_message(%{assigns: %{user: %{ap_id: actor} = user}} = conn, %{ conn |> put_view(ChatMessageView) |> render("show.json", for: user, object: message, chat: chat) + else + _e -> {:error, :could_not_delete} end end diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 86ccbb117..75d4903ed 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -88,6 +88,8 @@ test "it deletes a message for the author of the message", %{conn: conn, user: u {:ok, message} = CommonAPI.post_chat_message(user, recipient, "Hello darkness my old friend") + {:ok, other_message} = CommonAPI.post_chat_message(recipient, user, "nico nico ni") + object = Object.normalize(message, false) chat = Chat.get(user.id, recipient.ap_id) @@ -99,6 +101,16 @@ test "it deletes a message for the author of the message", %{conn: conn, user: u |> json_response_and_validate_schema(200) assert result["id"] == to_string(object.id) + + object = Object.normalize(other_message, false) + + result = + conn + |> put_req_header("content-type", "application/json") + |> delete("/api/v1/pleroma/chats/#{chat.id}/messages/#{object.id}") + |> json_response(400) + + assert result == %{"error" => "could_not_delete"} end end From a61120f497e5d17be1207eacd11525edb7b6db36 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 12 May 2020 13:25:25 +0200 Subject: [PATCH 102/401] Documention: Add chat message deletion docs --- docs/API/chats.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/API/chats.md b/docs/API/chats.md index ed160abd9..ad36961ae 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -192,6 +192,14 @@ Returned data: } ``` +### Deleting a chat message + +Deleting a chat message for given Chat id works like this: + +`DELETE /api/v1/pleroma/chats/{chat_id}/messages/{message_id}` + +Returned data is the deleted message. + ### Notifications There's a new `pleroma:chat_mention` notification, which has this form: From e44166b510f95bfb2e679b2d64bbf7e0facd0dd2 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 12 May 2020 14:44:11 +0200 Subject: [PATCH 103/401] Credo fixes. --- test/web/pleroma_api/controllers/chat_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 75d4903ed..861ef10b0 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -4,8 +4,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do use Pleroma.Web.ConnCase, async: true - alias Pleroma.Object alias Pleroma.Chat + alias Pleroma.Object alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI From c0ea5c60e4e709d3d4415de42a65f878b55dc3bb Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 12 May 2020 16:43:04 +0200 Subject: [PATCH 104/401] ChatController: Don't return chats for user you've blocked. --- .../controllers/chat_controller.ex | 5 +++- .../controllers/chat_controller_test.exs | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 4ce3e7419..496cb8e87 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -102,10 +102,13 @@ def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{id: id} = para end end - def index(%{assigns: %{user: %{id: user_id}}} = conn, params) do + def index(%{assigns: %{user: %{id: user_id} = user}} = conn, params) do + blocked_ap_ids = User.blocked_users_ap_ids(user) + chats = from(c in Chat, where: c.user_id == ^user_id, + where: c.recipient not in ^blocked_ap_ids, order_by: [desc: c.updated_at] ) |> Pagination.fetch_paginated(params |> stringify_keys) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 861ef10b0..037dd2297 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -6,6 +6,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do alias Pleroma.Chat alias Pleroma.Object + alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI @@ -209,6 +210,28 @@ test "it returns a chat", %{conn: conn, user: user} do describe "GET /api/v1/pleroma/chats" do setup do: oauth_access(["read:statuses"]) + test "it does not return chats with users you blocked", %{conn: conn, user: user} do + recipient = insert(:user) + + {:ok, _} = Chat.get_or_create(user.id, recipient.ap_id) + + result = + conn + |> get("/api/v1/pleroma/chats") + |> json_response_and_validate_schema(200) + + assert length(result) == 1 + + User.block(user, recipient) + + result = + conn + |> get("/api/v1/pleroma/chats") + |> json_response_and_validate_schema(200) + + assert length(result) == 0 + end + test "it paginates", %{conn: conn, user: user} do Enum.each(1..30, fn _ -> recipient = insert(:user) From 06cad239e50cada3aec4fc3b4c494a70d328672c Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 13 May 2020 14:05:22 +0200 Subject: [PATCH 105/401] InstanceView: Add pleroma chat messages to nodeinfo --- lib/pleroma/web/mastodon_api/views/instance_view.ex | 3 ++- test/web/node_info_test.exs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index a329ffc28..17cfc4fcf 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -68,7 +68,8 @@ def features do if Config.get([:instance, :safe_dm_mentions]) do "safe_dm_mentions" end, - "pleroma_emoji_reactions" + "pleroma_emoji_reactions", + "pleroma_chat_messages" ] |> Enum.filter(& &1) end diff --git a/test/web/node_info_test.exs b/test/web/node_info_test.exs index 9bcc07b37..00925caad 100644 --- a/test/web/node_info_test.exs +++ b/test/web/node_info_test.exs @@ -145,7 +145,8 @@ test "it shows default features flags", %{conn: conn} do "shareable_emoji_packs", "multifetch", "pleroma_emoji_reactions", - "pleroma:api/v1/notifications:include_types_filter" + "pleroma:api/v1/notifications:include_types_filter", + "pleroma_chat_messages" ] assert MapSet.subset?( From 0f0acc740d30c47d093f27875d4decf0693b2845 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 13 May 2020 15:31:28 +0200 Subject: [PATCH 106/401] Chat: Allow posting without content if an attachment is present. --- docs/API/chats.md | 5 ++- .../chat_message_validator.ex | 14 +++++++- .../web/api_spec/operations/chat_operation.ex | 12 ++++--- .../web/api_spec/schemas/chat_message.ex | 2 +- lib/pleroma/web/common_api/common_api.ex | 17 +++++++--- .../controllers/chat_controller.ex | 7 ++-- .../activity_pub/object_validator_test.exs | 32 +++++++++++++++++++ test/web/common_api/common_api_test.exs | 23 +++++++++++++ .../controllers/chat_controller_test.exs | 18 +++++++++-- 9 files changed, 111 insertions(+), 19 deletions(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index ad36961ae..1ea18ff5f 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -166,11 +166,10 @@ Posting a chat message for given Chat id works like this: `POST /api/v1/pleroma/chats/{id}/messages` Parameters: -- content: The text content of the message +- content: The text content of the message. Optional if media is attached. - media_id: The id of an upload that will be attached to the message. -Currently, no formatting beyond basic escaping and emoji is implemented, as well as no -attachments. This will most probably change. +Currently, no formatting beyond basic escaping and emoji is implemented. Returned data: diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index e40c80ab4..9c20c188a 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -61,12 +61,24 @@ def changeset(struct, data) do def validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["ChatMessage"]) - |> validate_required([:id, :actor, :to, :type, :content, :published]) + |> validate_required([:id, :actor, :to, :type, :published]) + |> validate_content_or_attachment() |> validate_length(:to, is: 1) |> validate_length(:content, max: Pleroma.Config.get([:instance, :remote_limit])) |> validate_local_concern() end + def validate_content_or_attachment(cng) do + attachment = get_field(cng, :attachment) + + if attachment do + cng + else + cng + |> validate_required([:content]) + end + end + @doc """ Validates the following - If both users are in our system diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 8ba10c603..a1c5db5dc 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError alias Pleroma.Web.ApiSpec.Schemas.Chat alias Pleroma.Web.ApiSpec.Schemas.ChatMessage @@ -149,14 +150,15 @@ def post_chat_message_operation do parameters: [ Operation.parameter(:id, :path, :string, "The ID of the Chat") ], - requestBody: request_body("Parameters", chat_message_create(), required: true), + requestBody: request_body("Parameters", chat_message_create()), responses: %{ 200 => Operation.response( "The newly created ChatMessage", "application/json", ChatMessage - ) + ), + 400 => Operation.response("Bad Request", "application/json", ApiError) }, security: [ %{ @@ -292,10 +294,12 @@ def chat_message_create do description: "POST body for creating an chat message", type: :object, properties: %{ - content: %Schema{type: :string, description: "The content of your message"}, + content: %Schema{ + type: :string, + description: "The content of your message. Optional if media_id is present" + }, media_id: %Schema{type: :string, description: "The id of an upload"} }, - required: [:content], example: %{ "content" => "Hey wanna buy feet pics?", "media_id" => "134234" diff --git a/lib/pleroma/web/api_spec/schemas/chat_message.ex b/lib/pleroma/web/api_spec/schemas/chat_message.ex index 6e8f1a10a..3ee85aa76 100644 --- a/lib/pleroma/web/api_spec/schemas/chat_message.ex +++ b/lib/pleroma/web/api_spec/schemas/chat_message.ex @@ -16,7 +16,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessage do id: %Schema{type: :string}, account_id: %Schema{type: :string, description: "The Mastodon API id of the actor"}, chat_id: %Schema{type: :string}, - content: %Schema{type: :string}, + content: %Schema{type: :string, nullable: true}, created_at: %Schema{type: :string, format: :"date-time"}, emojis: %Schema{type: :array}, attachment: %Schema{type: :object, nullable: true} diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 664175a4f..7008cea44 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -26,14 +26,14 @@ defmodule Pleroma.Web.CommonAPI do require Logger def post_chat_message(%User{} = user, %User{} = recipient, content, opts \\ []) do - with :ok <- validate_chat_content_length(content), - maybe_attachment <- opts[:media_id] && Object.get_by_id(opts[:media_id]), + with maybe_attachment <- opts[:media_id] && Object.get_by_id(opts[:media_id]), + :ok <- validate_chat_content_length(content, !!maybe_attachment), {_, {:ok, chat_message_data, _meta}} <- {:build_object, Builder.chat_message( user, recipient.ap_id, - content |> Formatter.html_escape("text/plain"), + content |> format_chat_content, attachment: maybe_attachment )}, {_, {:ok, create_activity_data, _meta}} <- @@ -47,7 +47,16 @@ def post_chat_message(%User{} = user, %User{} = recipient, content, opts \\ []) end end - defp validate_chat_content_length(content) do + defp format_chat_content(nil), do: nil + + defp format_chat_content(content) do + content |> Formatter.html_escape("text/plain") + end + + defp validate_chat_content_length(_, true), do: :ok + defp validate_chat_content_length(nil, false), do: {:error, :no_content} + + defp validate_chat_content_length(content, _) do if String.length(content) <= Pleroma.Config.get([:instance, :chat_limit]) do :ok else diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 496cb8e87..210c8ec4a 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -58,8 +58,7 @@ def delete_message(%{assigns: %{user: %{ap_id: actor} = user}} = conn, %{ end def post_chat_message( - %{body_params: %{content: content} = params, assigns: %{user: %{id: user_id} = user}} = - conn, + %{body_params: params, assigns: %{user: %{id: user_id} = user}} = conn, %{ id: id } @@ -67,7 +66,9 @@ def post_chat_message( with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), %User{} = recipient <- User.get_cached_by_ap_id(chat.recipient), {:ok, activity} <- - CommonAPI.post_chat_message(user, recipient, content, media_id: params[:media_id]), + CommonAPI.post_chat_message(user, recipient, params[:content], + media_id: params[:media_id] + ), message <- Object.normalize(activity) do conn |> put_view(ChatMessageView) diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index d9f5a8fac..da33d3dbc 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -103,6 +103,38 @@ test "validates for a basic object with an attachment", %{ assert object["attachment"] end + test "validates for a basic object with an attachment but without content", %{ + valid_chat_message: valid_chat_message, + user: user + } do + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id) + + valid_chat_message = + valid_chat_message + |> Map.put("attachment", attachment.data) + |> Map.delete("content") + + assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) + + assert object["attachment"] + end + + test "does not validate if the message has no content", %{ + valid_chat_message: valid_chat_message + } do + contentless = + valid_chat_message + |> Map.delete("content") + + refute match?({:ok, _object, _meta}, ObjectValidator.validate(contentless, [])) + end + test "does not validate if the message is longer than the remote_limit", %{ valid_chat_message: valid_chat_message } do diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index fd2c486a1..46ffd2888 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -27,6 +27,29 @@ defmodule Pleroma.Web.CommonAPITest do describe "posting chat messages" do setup do: clear_config([:instance, :chat_limit]) + test "it posts a chat message without content but with an attachment" do + author = insert(:user) + recipient = insert(:user) + + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, upload} = ActivityPub.upload(file, actor: author.ap_id) + + {:ok, activity} = + CommonAPI.post_chat_message( + author, + recipient, + nil, + media_id: upload.id + ) + + assert activity + end + test "it posts a chat message" do author = insert(:user) recipient = insert(:user) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 037dd2297..d79aa3148 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -53,6 +53,20 @@ test "it posts a message to the chat", %{conn: conn, user: user} do assert result["chat_id"] == chat.id |> to_string() end + test "it fails if there is no content", %{conn: conn, user: user} do + other_user = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + + result = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/pleroma/chats/#{chat.id}/messages") + |> json_response_and_validate_schema(400) + + assert result + end + test "it works with an attachment", %{conn: conn, user: user} do file = %Plug.Upload{ content_type: "image/jpg", @@ -70,13 +84,11 @@ test "it works with an attachment", %{conn: conn, user: user} do conn |> put_req_header("content-type", "application/json") |> post("/api/v1/pleroma/chats/#{chat.id}/messages", %{ - "content" => "Hallo!!", "media_id" => to_string(upload.id) }) |> json_response_and_validate_schema(200) - assert result["content"] == "Hallo!!" - assert result["chat_id"] == chat.id |> to_string() + assert result["attachment"] end end From 3342846ac2bbd48e985cfeff26ba4593f4815879 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 14 May 2020 13:20:28 +0200 Subject: [PATCH 107/401] ChatView: Add update_at field. --- lib/pleroma/web/pleroma_api/views/chat_view.ex | 4 +++- test/web/pleroma_api/views/chat_view_test.exs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index 21f0612ff..08d5110c3 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatView do alias Pleroma.Chat alias Pleroma.User + alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.PleromaAPI.ChatMessageView @@ -20,7 +21,8 @@ def render("show.json", %{chat: %Chat{} = chat} = opts) do account: AccountView.render("show.json", Map.put(opts, :user, recipient)), unread: chat.unread, last_message: - last_message && ChatMessageView.render("show.json", chat: chat, object: last_message) + last_message && ChatMessageView.render("show.json", chat: chat, object: last_message), + updated_at: Utils.to_masto_date(chat.updated_at) } end diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index e24e29835..6062a0cfe 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -8,6 +8,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatViewTest do alias Pleroma.Chat alias Pleroma.Object alias Pleroma.Web.CommonAPI + alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.PleromaAPI.ChatMessageView alias Pleroma.Web.PleromaAPI.ChatView @@ -26,7 +27,8 @@ test "it represents a chat" do id: "#{chat.id}", account: AccountView.render("show.json", user: recipient), unread: 0, - last_message: nil + last_message: nil, + updated_at: Utils.to_masto_date(chat.updated_at) } {:ok, chat_message_creation} = CommonAPI.post_chat_message(user, recipient, "hello") From 1d18721a3c60aa0acc7d1ba858a92277e544a54a Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 15 May 2020 13:18:41 +0200 Subject: [PATCH 108/401] Chats: Add updated_at to Schema and docs. --- docs/API/chats.md | 9 ++++++--- lib/pleroma/web/api_spec/schemas/chat.ex | 6 ++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index 1ea18ff5f..2e415e4da 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -68,7 +68,8 @@ Returned data: }, "id" : "1", "unread" : 2, - "last_message" : {...} // The last message in that chat + "last_message" : {...}, // The last message in that chat + "updated_at": "2020-04-21T15:11:46.000Z" } ``` @@ -88,7 +89,8 @@ Returned data: ... }, "id" : "1", - "unread" : 0 + "unread" : 0, + "updated_at": "2020-04-21T15:11:46.000Z" } ``` @@ -112,7 +114,8 @@ Returned data: }, "id" : "1", "unread" : 2, - "last_message" : {...} // The last message in that chat + "last_message" : {...}, // The last message in that chat + "updated_at": "2020-04-21T15:11:46.000Z" } ] ``` diff --git a/lib/pleroma/web/api_spec/schemas/chat.ex b/lib/pleroma/web/api_spec/schemas/chat.ex index c6ec07c88..b4986b734 100644 --- a/lib/pleroma/web/api_spec/schemas/chat.ex +++ b/lib/pleroma/web/api_spec/schemas/chat.ex @@ -16,7 +16,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Chat do id: %Schema{type: :string}, account: %Schema{type: :object}, unread: %Schema{type: :integer}, - last_message: ChatMessage + last_message: ChatMessage, + updated_at: %Schema{type: :string, format: :"date-time"} }, example: %{ "account" => %{ @@ -67,7 +68,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Chat do }, "id" => "1", "unread" => 2, - "last_message" => ChatMessage.schema().example() + "last_message" => ChatMessage.schema().example(), + "updated_at" => "2020-04-21T15:06:45.000Z" } }) end From baf051a59e8bfcb2e55b5e28e46e80d6961b9bb4 Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 17 May 2020 12:22:26 +0200 Subject: [PATCH 109/401] SideEffects: Don't update unread count for actor in chatmessages. --- lib/pleroma/web/activity_pub/side_effects.ex | 6 +++++- test/web/activity_pub/side_effects_test.exs | 21 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index c8b675d54..8e64b4615 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -117,7 +117,11 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do [[actor, recipient], [recipient, actor]] |> Enum.each(fn [user, other_user] -> if user.local do - Chat.bump_or_create(user.id, other_user.ap_id) + if user.ap_id == actor.ap_id do + Chat.get_or_create(user.id, other_user.ap_id) + else + Chat.bump_or_create(user.id, other_user.ap_id) + end end end) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 148fa4442..37d7491ca 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -284,6 +284,27 @@ test "notifies the recipient" do assert Repo.get_by(Notification, user_id: recipient.id, activity_id: create_activity.id) end + test "it creates a Chat for the local users and bumps the unread count, except for the author" do + author = insert(:user, local: true) + recipient = insert(:user, local: true) + + {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") + + {:ok, create_activity_data, _meta} = + Builder.create(author, chat_message_data["id"], [recipient.ap_id]) + + {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) + + {:ok, _create_activity, _meta} = + SideEffects.handle(create_activity, local: false, object_data: chat_message_data) + + chat = Chat.get(author.id, recipient.ap_id) + assert chat.unread == 0 + + chat = Chat.get(recipient.id, author.ap_id) + assert chat.unread == 1 + end + test "it creates a Chat for the local users and bumps the unread count" do author = insert(:user, local: false) recipient = insert(:user, local: true) From e7bc2f980cce170731960e024614c497b821fe90 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 7 May 2020 13:44:38 +0300 Subject: [PATCH 110/401] account visibility --- lib/pleroma/user.ex | 52 ++++++++++------ .../api_spec/operations/account_operation.ex | 8 ++- .../controllers/account_controller.ex | 21 +++++-- .../web/mastodon_api/views/account_view.ex | 2 +- test/user_test.exs | 10 ++-- .../controllers/account_controller_test.exs | 59 ++++++++++++++----- 6 files changed, 105 insertions(+), 47 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index cba391072..7a2558c29 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -262,37 +262,51 @@ def account_status(%User{deactivated: true}), do: :deactivated def account_status(%User{password_reset_pending: true}), do: :password_reset_pending def account_status(%User{confirmation_pending: true}) do - case Config.get([:instance, :account_activation_required]) do - true -> :confirmation_pending - _ -> :active + if Config.get([:instance, :account_activation_required]) do + :confirmation_pending + else + :active end end def account_status(%User{}), do: :active - @spec visible_for?(User.t(), User.t() | nil) :: boolean() - def visible_for?(user, for_user \\ nil) + @spec visible_for(User.t(), User.t() | nil) :: + boolean() + | :invisible + | :restricted_unauthenticated + | :deactivated + | :confirmation_pending + def visible_for(user, for_user \\ nil) - def visible_for?(%User{invisible: true}, _), do: false + def visible_for(%User{invisible: true}, _), do: :invisible - def visible_for?(%User{id: user_id}, %User{id: user_id}), do: true + def visible_for(%User{id: user_id}, %User{id: user_id}), do: true - def visible_for?(%User{local: local} = user, nil) do - cfg_key = - if local, - do: :local, - else: :remote - - if Config.get([:restrict_unauthenticated, :profiles, cfg_key]), - do: false, - else: account_status(user) == :active + def visible_for(%User{} = user, nil) do + if restrict_unauthenticated?(user) do + :restrict_unauthenticated + else + visible_account_status(user) + end end - def visible_for?(%User{} = user, for_user) do - account_status(user) == :active || superuser?(for_user) + def visible_for(%User{} = user, for_user) do + superuser?(for_user) || visible_account_status(user) end - def visible_for?(_, _), do: false + def visible_for(_, _), do: false + + defp restrict_unauthenticated?(%User{local: local}) do + config_key = if local, do: :local, else: :remote + + Config.get([:restrict_unauthenticated, :profiles, config_key], false) + end + + defp visible_account_status(user) do + status = account_status(user) + status in [:active, :password_reset_pending] || status + end @spec superuser?(User.t()) :: boolean() def superuser?(%User{local: true, is_admin: true}), do: true diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 934f6038e..43168acf7 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -102,7 +102,9 @@ def show_operation do parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], responses: %{ 200 => Operation.response("Account", "application/json", Account), - 404 => Operation.response("Error", "application/json", ApiError) + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError), + 410 => Operation.response("Error", "application/json", ApiError) } } end @@ -142,7 +144,9 @@ def statuses_operation do ] ++ pagination_params(), responses: %{ 200 => Operation.response("Statuses", "application/json", array_of_statuses()), - 404 => Operation.response("Error", "application/json", ApiError) + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError), + 410 => Operation.response("Error", "application/json", ApiError) } } end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index ef41f9e96..ffa82731f 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -221,17 +221,17 @@ 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.visible_for?(user, for_user) do + true <- User.visible_for(user, for_user) do render(conn, "show.json", user: user, for: for_user) else - _e -> render_error(conn, :not_found, "Can't find user") + error -> user_visibility_error(conn, error) 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), - true <- User.visible_for?(user, reading_user) do + true <- User.visible_for(user, reading_user) do params = params |> Map.delete(:tagged) @@ -250,7 +250,20 @@ def statuses(%{assigns: %{user: reading_user}} = conn, params) do as: :activity ) else - _e -> render_error(conn, :not_found, "Can't find user") + error -> user_visibility_error(conn, error) + end + end + + defp user_visibility_error(conn, error) do + case error do + :deactivated -> + render_error(conn, :gone, "") + + :restrict_unauthenticated -> + render_error(conn, :unauthorized, "This API requires an authenticated user") + + _ -> + render_error(conn, :not_found, "Can't find user") 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 45fffaad2..8e723d013 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -35,7 +35,7 @@ def render("index.json", %{users: users} = opts) do end def render("show.json", %{user: user} = opts) do - if User.visible_for?(user, opts[:for]) do + if User.visible_for(user, opts[:for]) == true do do_render("show.json", opts) else %{} diff --git a/test/user_test.exs b/test/user_test.exs index 6b9df60a4..3bfcfd10c 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1289,11 +1289,11 @@ test "returns false for a non-invisible user" do end end - describe "visible_for?/2" do + describe "visible_for/2" do test "returns true when the account is itself" do user = insert(:user, local: true) - assert User.visible_for?(user, user) + assert User.visible_for(user, user) end test "returns false when the account is unauthenticated and auth is required" do @@ -1302,14 +1302,14 @@ test "returns false when the account is unauthenticated and auth is required" do user = insert(:user, local: true, confirmation_pending: true) other_user = insert(:user, local: true) - refute User.visible_for?(user, other_user) + refute User.visible_for(user, other_user) == true end test "returns true when the account is unauthenticated and auth is not required" do user = insert(:user, local: true, confirmation_pending: true) other_user = insert(:user, local: true) - assert User.visible_for?(user, other_user) + assert User.visible_for(user, other_user) end test "returns true when the account is unauthenticated and being viewed by a privileged account (auth required)" do @@ -1318,7 +1318,7 @@ test "returns true when the account is unauthenticated and being viewed by a pri user = insert(:user, local: true, confirmation_pending: true) other_user = insert(:user, local: true, is_admin: true) - assert User.visible_for?(user, other_user) + assert User.visible_for(user, other_user) end end diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 280bd6aca..7dfea2f9e 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -127,6 +127,15 @@ test "returns 404 for internal.fetch actor", %{conn: conn} do |> get("/api/v1/accounts/internal.fetch") |> json_response_and_validate_schema(404) end + + test "returns 401 for deactivated user", %{conn: conn} do + user = insert(:user, deactivated: true) + + assert %{} = + conn + |> get("/api/v1/accounts/#{user.id}") + |> json_response_and_validate_schema(:gone) + end end defp local_and_remote_users do @@ -143,15 +152,15 @@ defp local_and_remote_users do setup do: clear_config([:restrict_unauthenticated, :profiles, :remote], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do - assert %{"error" => "Can't find user"} == + assert %{"error" => "This API requires an authenticated user"} == conn |> get("/api/v1/accounts/#{local.id}") - |> json_response_and_validate_schema(:not_found) + |> json_response_and_validate_schema(:unauthorized) - assert %{"error" => "Can't find user"} == + assert %{"error" => "This API requires an authenticated user"} == conn |> get("/api/v1/accounts/#{remote.id}") - |> json_response_and_validate_schema(:not_found) + |> json_response_and_validate_schema(:unauthorized) end test "if user is authenticated", %{local: local, remote: remote} do @@ -173,8 +182,8 @@ test "if user is authenticated", %{local: local, remote: remote} do test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do res_conn = get(conn, "/api/v1/accounts/#{local.id}") - assert json_response_and_validate_schema(res_conn, :not_found) == %{ - "error" => "Can't find user" + assert json_response_and_validate_schema(res_conn, :unauthorized) == %{ + "error" => "This API requires an authenticated user" } res_conn = get(conn, "/api/v1/accounts/#{remote.id}") @@ -203,8 +212,8 @@ test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} d res_conn = get(conn, "/api/v1/accounts/#{remote.id}") - assert json_response_and_validate_schema(res_conn, :not_found) == %{ - "error" => "Can't find user" + assert json_response_and_validate_schema(res_conn, :unauthorized) == %{ + "error" => "This API requires an authenticated user" } end @@ -249,6 +258,24 @@ test "works with announces that are just addressed to public", %{conn: conn} do assert id == announce.id end + test "deactivated user", %{conn: conn} do + user = insert(:user, deactivated: true) + + assert %{} == + conn + |> get("/api/v1/accounts/#{user.id}/statuses") + |> json_response_and_validate_schema(:gone) + end + + test "returns 404 when user is invisible", %{conn: conn} do + user = insert(:user, %{invisible: true}) + + assert %{"error" => "Can't find user"} = + conn + |> get("/api/v1/accounts/#{user.id}") + |> json_response_and_validate_schema(404) + end + test "respects blocks", %{user: user_one, conn: conn} do user_two = insert(:user) user_three = insert(:user) @@ -422,15 +449,15 @@ defp local_and_remote_activities(%{local: local, remote: remote}) do setup do: clear_config([:restrict_unauthenticated, :profiles, :remote], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do - assert %{"error" => "Can't find user"} == + assert %{"error" => "This API requires an authenticated user"} == conn |> get("/api/v1/accounts/#{local.id}/statuses") - |> json_response_and_validate_schema(:not_found) + |> json_response_and_validate_schema(:unauthorized) - assert %{"error" => "Can't find user"} == + assert %{"error" => "This API requires an authenticated user"} == conn |> get("/api/v1/accounts/#{remote.id}/statuses") - |> json_response_and_validate_schema(:not_found) + |> json_response_and_validate_schema(:unauthorized) end test "if user is authenticated", %{local: local, remote: remote} do @@ -451,10 +478,10 @@ test "if user is authenticated", %{local: local, remote: remote} do setup do: clear_config([:restrict_unauthenticated, :profiles, :local], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do - assert %{"error" => "Can't find user"} == + assert %{"error" => "This API requires an authenticated user"} == conn |> get("/api/v1/accounts/#{local.id}/statuses") - |> json_response_and_validate_schema(:not_found) + |> json_response_and_validate_schema(:unauthorized) res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses") assert length(json_response_and_validate_schema(res_conn, 200)) == 1 @@ -481,10 +508,10 @@ test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} d res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses") assert length(json_response_and_validate_schema(res_conn, 200)) == 1 - assert %{"error" => "Can't find user"} == + assert %{"error" => "This API requires an authenticated user"} == conn |> get("/api/v1/accounts/#{remote.id}/statuses") - |> json_response_and_validate_schema(:not_found) + |> json_response_and_validate_schema(:unauthorized) end test "if user is authenticated", %{local: local, remote: remote} do From b1aa402229b6422a5ab1aa7102c7a104e218d0e3 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 13 May 2020 11:11:10 +0300 Subject: [PATCH 111/401] removing 410 status --- lib/pleroma/web/api_spec/operations/account_operation.ex | 6 ++---- .../web/mastodon_api/controllers/account_controller.ex | 3 --- .../mastodon_api/controllers/account_controller_test.exs | 8 ++++---- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 43168acf7..74b395dfe 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -103,8 +103,7 @@ def show_operation do responses: %{ 200 => Operation.response("Account", "application/json", Account), 401 => Operation.response("Error", "application/json", ApiError), - 404 => Operation.response("Error", "application/json", ApiError), - 410 => Operation.response("Error", "application/json", ApiError) + 404 => Operation.response("Error", "application/json", ApiError) } } end @@ -145,8 +144,7 @@ def statuses_operation do responses: %{ 200 => Operation.response("Statuses", "application/json", array_of_statuses()), 401 => Operation.response("Error", "application/json", ApiError), - 404 => Operation.response("Error", "application/json", ApiError), - 410 => Operation.response("Error", "application/json", ApiError) + 404 => Operation.response("Error", "application/json", ApiError) } } end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index ffa82731f..1edc0d96a 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -256,9 +256,6 @@ def statuses(%{assigns: %{user: reading_user}} = conn, params) do defp user_visibility_error(conn, error) do case error do - :deactivated -> - render_error(conn, :gone, "") - :restrict_unauthenticated -> render_error(conn, :unauthorized, "This API requires an authenticated user") diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 7dfea2f9e..8700ab2f5 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -131,10 +131,10 @@ test "returns 404 for internal.fetch actor", %{conn: conn} do test "returns 401 for deactivated user", %{conn: conn} do user = insert(:user, deactivated: true) - assert %{} = + assert %{"error" => "Can't find user"} = conn |> get("/api/v1/accounts/#{user.id}") - |> json_response_and_validate_schema(:gone) + |> json_response_and_validate_schema(:not_found) end end @@ -261,10 +261,10 @@ test "works with announces that are just addressed to public", %{conn: conn} do test "deactivated user", %{conn: conn} do user = insert(:user, deactivated: true) - assert %{} == + assert %{"error" => "Can't find user"} == conn |> get("/api/v1/accounts/#{user.id}/statuses") - |> json_response_and_validate_schema(:gone) + |> json_response_and_validate_schema(:not_found) end test "returns 404 when user is invisible", %{conn: conn} do From 1671864d886bf63d11bbf3d7303719e8744bfc32 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 15 May 2020 20:29:09 +0300 Subject: [PATCH 112/401] return :visible instead of boolean --- lib/pleroma/user.ex | 19 ++++++++++++++----- .../controllers/account_controller.ex | 4 ++-- .../web/mastodon_api/views/account_view.ex | 2 +- test/user_test.exs | 8 ++++---- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 7a2558c29..5052f7b97 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -272,7 +272,7 @@ def account_status(%User{confirmation_pending: true}) do def account_status(%User{}), do: :active @spec visible_for(User.t(), User.t() | nil) :: - boolean() + :visible | :invisible | :restricted_unauthenticated | :deactivated @@ -281,7 +281,7 @@ def visible_for(user, for_user \\ nil) def visible_for(%User{invisible: true}, _), do: :invisible - def visible_for(%User{id: user_id}, %User{id: user_id}), do: true + def visible_for(%User{id: user_id}, %User{id: user_id}), do: :visible def visible_for(%User{} = user, nil) do if restrict_unauthenticated?(user) do @@ -292,10 +292,14 @@ def visible_for(%User{} = user, nil) do end def visible_for(%User{} = user, for_user) do - superuser?(for_user) || visible_account_status(user) + if superuser?(for_user) do + :visible + else + visible_account_status(user) + end end - def visible_for(_, _), do: false + def visible_for(_, _), do: :invisible defp restrict_unauthenticated?(%User{local: local}) do config_key = if local, do: :local, else: :remote @@ -305,7 +309,12 @@ defp restrict_unauthenticated?(%User{local: local}) do defp visible_account_status(user) do status = account_status(user) - status in [:active, :password_reset_pending] || status + + if status in [:active, :password_reset_pending] do + :visible + else + status + end end @spec superuser?(User.t()) :: boolean() diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 1edc0d96a..8727faab7 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -221,7 +221,7 @@ 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.visible_for(user, for_user) do + :visible <- User.visible_for(user, for_user) do render(conn, "show.json", user: user, for: for_user) else error -> user_visibility_error(conn, error) @@ -231,7 +231,7 @@ def show(%{assigns: %{user: for_user}} = conn, %{id: nickname_or_id}) do @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), - true <- User.visible_for(user, reading_user) do + :visible <- User.visible_for(user, reading_user) do params = params |> Map.delete(:tagged) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 8e723d013..4a1508b22 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -35,7 +35,7 @@ def render("index.json", %{users: users} = opts) do end def render("show.json", %{user: user} = opts) do - if User.visible_for(user, opts[:for]) == true do + if User.visible_for(user, opts[:for]) == :visible do do_render("show.json", opts) else %{} diff --git a/test/user_test.exs b/test/user_test.exs index 3bfcfd10c..6865bd9be 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1293,7 +1293,7 @@ test "returns false for a non-invisible user" do test "returns true when the account is itself" do user = insert(:user, local: true) - assert User.visible_for(user, user) + assert User.visible_for(user, user) == :visible end test "returns false when the account is unauthenticated and auth is required" do @@ -1302,14 +1302,14 @@ test "returns false when the account is unauthenticated and auth is required" do user = insert(:user, local: true, confirmation_pending: true) other_user = insert(:user, local: true) - refute User.visible_for(user, other_user) == true + refute User.visible_for(user, other_user) == :visible end test "returns true when the account is unauthenticated and auth is not required" do user = insert(:user, local: true, confirmation_pending: true) other_user = insert(:user, local: true) - assert User.visible_for(user, other_user) + assert User.visible_for(user, other_user) == :visible end test "returns true when the account is unauthenticated and being viewed by a privileged account (auth required)" do @@ -1318,7 +1318,7 @@ test "returns true when the account is unauthenticated and being viewed by a pri user = insert(:user, local: true, confirmation_pending: true) other_user = insert(:user, local: true, is_admin: true) - assert User.visible_for(user, other_user) + assert User.visible_for(user, other_user) == :visible end end From 0321a3e07814c3f225f19e0372b69a7813cef15e Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 18 May 2020 10:34:34 +0300 Subject: [PATCH 113/401] test naming fix --- test/web/mastodon_api/controllers/account_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 8700ab2f5..3008970af 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -128,7 +128,7 @@ test "returns 404 for internal.fetch actor", %{conn: conn} do |> json_response_and_validate_schema(404) end - test "returns 401 for deactivated user", %{conn: conn} do + test "returns 404 for deactivated user", %{conn: conn} do user = insert(:user, deactivated: true) assert %{"error" => "Can't find user"} = From 1be6b3056e97654612f377eaf3c8d80de6d8d77f Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Mon, 18 May 2020 12:38:16 +0300 Subject: [PATCH 114/401] Use indexed split_part/3 to get a hostname rather than ts_ functions --- .../20200508092434_update_counter_cache_table.exs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/priv/repo/migrations/20200508092434_update_counter_cache_table.exs b/priv/repo/migrations/20200508092434_update_counter_cache_table.exs index 3d9bfc877..738344868 100644 --- a/priv/repo/migrations/20200508092434_update_counter_cache_table.exs +++ b/priv/repo/migrations/20200508092434_update_counter_cache_table.exs @@ -25,22 +25,17 @@ def up do RETURNS TRIGGER AS $$ DECLARE - token_id smallint; hostname character varying(255); visibility_new character varying(64); visibility_old character varying(64); actor character varying(255); BEGIN - SELECT "tokid" INTO "token_id" FROM ts_token_type('default') WHERE "alias" = 'host'; IF TG_OP = 'DELETE' THEN actor := OLD.actor; ELSE actor := NEW.actor; END IF; - SELECT "token" INTO "hostname" FROM ts_parse('default', actor) WHERE "tokid" = token_id; - IF hostname IS NULL THEN - hostname := split_part(actor, '/', 3); - END IF; + hostname := split_part(actor, '/', 3); IF TG_OP = 'INSERT' THEN visibility_new := activity_visibility(NEW.actor, NEW.recipients, NEW.data); IF NEW.data->>'type' = 'Create' From be4db41d713f981cc464e5fa7bc7191d3ff776d6 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 18 May 2020 18:45:33 +0200 Subject: [PATCH 115/401] ChatMessageValidator: Allow one message in an array, too. --- .../chat_message_validator.ex | 9 +++++ .../activity_pub/object_validator_test.exs | 35 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index 9c20c188a..138736f23 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -47,9 +47,18 @@ def cast_data(data) do def fix(data) do data |> fix_emoji() + |> fix_attachment() |> Map.put_new("actor", data["attributedTo"]) end + # Throws everything but the first one away + def fix_attachment(%{"attachment" => [attachment | _]} = data) do + data + |> Map.put("attachment", attachment) + end + + def fix_attachment(data), do: data + def changeset(struct, data) do data = fix(data) diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index da33d3dbc..a79e50a29 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -13,6 +13,20 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do import Pleroma.Factory describe "attachments" do + test "works with honkerific attachments" do + attachment = %{ + "mediaType" => "image/jpeg", + "name" => "298p3RG7j27tfsZ9RQ.jpg", + "summary" => "298p3RG7j27tfsZ9RQ.jpg", + "type" => "Document", + "url" => "https://honk.tedunangst.com/d/298p3RG7j27tfsZ9RQ.jpg" + } + + assert {:ok, attachment} = + AttachmentValidator.cast_and_validate(attachment) + |> Ecto.Changeset.apply_action(:insert) + end + test "it turns mastodon attachments into our attachments" do attachment = %{ "url" => @@ -103,6 +117,27 @@ test "validates for a basic object with an attachment", %{ assert object["attachment"] end + test "validates for a basic object with an attachment in an array", %{ + valid_chat_message: valid_chat_message, + user: user + } do + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id) + + valid_chat_message = + valid_chat_message + |> Map.put("attachment", [attachment.data]) + + assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) + + assert object["attachment"] + end + test "validates for a basic object with an attachment but without content", %{ valid_chat_message: valid_chat_message, user: user From d19c7167704308df093f060082639c0a15996af7 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 18 May 2020 20:17:28 +0200 Subject: [PATCH 116/401] AttachmentValidator: Handle empty mediatypes --- .../object_validators/attachment_validator.ex | 14 +++++-- .../activity_pub/object_validator_test.exs | 4 +- .../transmogrifier/chat_message_test.exs | 37 +++++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex index 16ed49051..c4b502cb9 100644 --- a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex @@ -12,7 +12,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do @primary_key false embedded_schema do field(:type, :string) - field(:mediaType, :string) + field(:mediaType, :string, default: "application/octet-stream") field(:name, :string) embeds_many(:url, UrlObjectValidator) @@ -41,8 +41,16 @@ def changeset(struct, data) do end def fix_media_type(data) do - data - |> Map.put_new("mediaType", data["mimeType"]) + data = + data + |> Map.put_new("mediaType", data["mimeType"]) + + if data["mediaType"] == "" do + data + |> Map.put("mediaType", "application/octet-stream") + else + data + end end def fix_url(data) do diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index a79e50a29..ed6b84e8e 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -15,8 +15,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do describe "attachments" do test "works with honkerific attachments" do attachment = %{ - "mediaType" => "image/jpeg", - "name" => "298p3RG7j27tfsZ9RQ.jpg", + "mediaType" => "", + "name" => "", "summary" => "298p3RG7j27tfsZ9RQ.jpg", "type" => "Document", "url" => "https://honk.tedunangst.com/d/298p3RG7j27tfsZ9RQ.jpg" diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs index 85644d787..820090de3 100644 --- a/test/web/activity_pub/transmogrifier/chat_message_test.exs +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -13,6 +13,43 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageTest do alias Pleroma.Web.ActivityPub.Transmogrifier describe "handle_incoming" do + test "handles this" do + data = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "actor" => "https://honk.tedunangst.com/u/tedu", + "id" => "https://honk.tedunangst.com/u/tedu/honk/x6gt8X8PcyGkQcXxzg1T", + "object" => %{ + "attachment" => [ + %{ + "mediaType" => "image/jpeg", + "name" => "298p3RG7j27tfsZ9RQ.jpg", + "summary" => "298p3RG7j27tfsZ9RQ.jpg", + "type" => "Document", + "url" => "https://honk.tedunangst.com/d/298p3RG7j27tfsZ9RQ.jpg" + } + ], + "attributedTo" => "https://honk.tedunangst.com/u/tedu", + "content" => "", + "id" => "https://honk.tedunangst.com/u/tedu/chonk/26L4wl5yCbn4dr4y1b", + "published" => "2020-05-18T01:13:03Z", + "to" => [ + "https://dontbulling.me/users/lain" + ], + "type" => "ChatMessage" + }, + "published" => "2020-05-18T01:13:03Z", + "to" => [ + "https://dontbulling.me/users/lain" + ], + "type" => "Create" + } + + _user = insert(:user, ap_id: data["actor"]) + _user = insert(:user, ap_id: hd(data["to"])) + + assert {:ok, _activity} = Transmogrifier.handle_incoming(data) + end + test "it rejects messages that don't contain content" do data = File.read!("test/fixtures/create-chat-message.json") From cc0d462e91dd29c834c56b82e02022e1babda369 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 21 May 2020 15:08:56 +0200 Subject: [PATCH 117/401] Attachments: Have the mediaType on the root, too. --- lib/pleroma/upload.ex | 1 + .../activity_pub/object_validator_test.exs | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex index 1be1a3a5b..797555bff 100644 --- a/lib/pleroma/upload.ex +++ b/lib/pleroma/upload.ex @@ -67,6 +67,7 @@ def store(upload, opts \\ []) do {:ok, %{ "type" => opts.activity_type, + "mediaType" => upload.content_type, "url" => [ %{ "type" => "Link", diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index ed6b84e8e..f9990bd2c 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -25,6 +25,8 @@ test "works with honkerific attachments" do assert {:ok, attachment} = AttachmentValidator.cast_and_validate(attachment) |> Ecto.Changeset.apply_action(:insert) + + assert attachment.mediaType == "application/octet-stream" end test "it turns mastodon attachments into our attachments" do @@ -48,6 +50,27 @@ test "it turns mastodon attachments into our attachments" do mediaType: "image/jpeg" } ] = attachment.url + + assert attachment.mediaType == "image/jpeg" + end + + test "it handles our own uploads" do + user = insert(:user) + + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id) + + {:ok, attachment} = + attachment.data + |> AttachmentValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) + + assert attachment.mediaType == "image/jpeg" end end From c4a5cead51770f0d54cb77805b7e2bd705f251d9 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 21 May 2020 15:17:39 +0200 Subject: [PATCH 118/401] UploadTest: Fix test. --- test/upload_test.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/upload_test.exs b/test/upload_test.exs index 060a940bb..2abf0edec 100644 --- a/test/upload_test.exs +++ b/test/upload_test.exs @@ -54,6 +54,7 @@ test "it returns file" do %{ "name" => "image.jpg", "type" => "Document", + "mediaType" => "image/jpeg", "url" => [ %{ "href" => "http://localhost:4001/media/post-process-file.jpg", From cbcd592300673582e38d0bf539dcdb9a2c1985a1 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 22 May 2020 17:52:26 +0400 Subject: [PATCH 119/401] Add OpenAPI spec for AdminAPI.RelayController --- .../controllers/admin_api_controller.ex | 48 +--------- .../admin_api/controllers/relay_controller.ex | 67 ++++++++++++++ .../operations/admin/relay_operation.ex | 83 +++++++++++++++++ lib/pleroma/web/router.ex | 6 +- .../controllers/admin_api_controller_test.exs | 51 ---------- .../controllers/relay_controller_test.exs | 92 +++++++++++++++++++ 6 files changed, 246 insertions(+), 101 deletions(-) create mode 100644 lib/pleroma/web/admin_api/controllers/relay_controller.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/relay_operation.ex create mode 100644 test/web/admin_api/controllers/relay_controller_test.exs diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index 6b1d64a2e..b73701f5e 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -20,7 +20,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.Pipeline - alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI.AccountView @@ -80,7 +79,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do plug( OAuthScopesPlug, %{scopes: ["write:follows"], admin: true} - when action in [:user_follow, :user_unfollow, :relay_follow, :relay_unfollow] + when action in [:user_follow, :user_unfollow] ) plug( @@ -108,7 +107,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do :config_show, :list_log, :stats, - :relay_list, :config_descriptions, :need_reboot ] @@ -531,50 +529,6 @@ def right_delete(%{assigns: %{user: %{nickname: nickname}}} = conn, %{"nickname" render_error(conn, :forbidden, "You can't revoke your own admin status.") end - def relay_list(conn, _params) do - with {:ok, list} <- Relay.list() do - json(conn, %{relays: list}) - else - _ -> - conn - |> put_status(500) - end - end - - def relay_follow(%{assigns: %{user: admin}} = conn, %{"relay_url" => target}) do - with {:ok, _message} <- Relay.follow(target) do - ModerationLog.insert_log(%{ - action: "relay_follow", - actor: admin, - target: target - }) - - json(conn, target) - else - _ -> - conn - |> put_status(500) - |> json(target) - end - end - - def relay_unfollow(%{assigns: %{user: admin}} = conn, %{"relay_url" => target}) do - with {:ok, _message} <- Relay.unfollow(target) do - ModerationLog.insert_log(%{ - action: "relay_unfollow", - actor: admin, - target: target - }) - - json(conn, target) - else - _ -> - conn - |> put_status(500) - |> json(target) - end - end - @doc "Sends registration invite via email" def email_invite(%{assigns: %{user: user}} = conn, %{"email" => email} = params) do with {_, false} <- {:registrations_open, Config.get([:instance, :registrations_open])}, diff --git a/lib/pleroma/web/admin_api/controllers/relay_controller.ex b/lib/pleroma/web/admin_api/controllers/relay_controller.ex new file mode 100644 index 000000000..cf9f3a14b --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/relay_controller.ex @@ -0,0 +1,67 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.RelayController do + use Pleroma.Web, :controller + + alias Pleroma.ModerationLog + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Web.ActivityPub.Relay + + require Logger + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + OAuthScopesPlug, + %{scopes: ["write:follows"], admin: true} + when action in [:follow, :unfollow] + ) + + plug(OAuthScopesPlug, %{scopes: ["read"], admin: true} when action == :index) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.RelayOperation + + def index(conn, _params) do + with {:ok, list} <- Relay.list() do + json(conn, %{relays: list}) + end + end + + def follow(%{assigns: %{user: admin}, body_params: %{relay_url: target}} = conn, _) do + with {:ok, _message} <- Relay.follow(target) do + ModerationLog.insert_log(%{ + action: "relay_follow", + actor: admin, + target: target + }) + + json(conn, target) + else + _ -> + conn + |> put_status(500) + |> json(target) + end + end + + def unfollow(%{assigns: %{user: admin}, body_params: %{relay_url: target}} = conn, _) do + with {:ok, _message} <- Relay.unfollow(target) do + ModerationLog.insert_log(%{ + action: "relay_unfollow", + actor: admin, + target: target + }) + + json(conn, target) + else + _ -> + conn + |> put_status(500) + |> json(target) + end + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/relay_operation.ex b/lib/pleroma/web/api_spec/operations/admin/relay_operation.ex new file mode 100644 index 000000000..7672cb467 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/relay_operation.ex @@ -0,0 +1,83 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.RelayOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Admin", "Relays"], + summary: "List Relays", + operationId: "AdminAPI.RelayController.index", + security: [%{"oAuth" => ["read"]}], + responses: %{ + 200 => + Operation.response("Response", "application/json", %Schema{ + type: :object, + properties: %{ + relays: %Schema{ + type: :array, + items: %Schema{type: :string}, + example: ["lain.com", "mstdn.io"] + } + } + }) + } + } + end + + def follow_operation do + %Operation{ + tags: ["Admin", "Relays"], + summary: "Follow a Relay", + operationId: "AdminAPI.RelayController.follow", + security: [%{"oAuth" => ["write:follows"]}], + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + relay_url: %Schema{type: :string, format: :uri} + } + }), + responses: %{ + 200 => + Operation.response("Status", "application/json", %Schema{ + type: :string, + example: "http://mastodon.example.org/users/admin" + }) + } + } + end + + def unfollow_operation do + %Operation{ + tags: ["Admin", "Relays"], + summary: "Unfollow a Relay", + operationId: "AdminAPI.RelayController.unfollow", + security: [%{"oAuth" => ["write:follows"]}], + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + relay_url: %Schema{type: :string, format: :uri} + } + }), + responses: %{ + 200 => + Operation.response("Status", "application/json", %Schema{ + type: :string, + example: "http://mastodon.example.org/users/admin" + }) + } + } + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index e493a4153..269bbabde 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -160,9 +160,9 @@ defmodule Pleroma.Web.Router do :right_delete_multiple ) - get("/relay", AdminAPIController, :relay_list) - post("/relay", AdminAPIController, :relay_follow) - delete("/relay", AdminAPIController, :relay_unfollow) + get("/relay", RelayController, :index) + post("/relay", RelayController, :follow) + delete("/relay", RelayController, :unfollow) post("/users/invite_token", AdminAPIController, :create_invite_token) get("/users/invites", AdminAPIController, :invites) diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index 321840a8c..82825473c 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -3254,57 +3254,6 @@ test "sets password_reset_pending to true", %{conn: conn} do end end - describe "relays" do - test "POST /relay", %{conn: conn, admin: admin} do - conn = - post(conn, "/api/pleroma/admin/relay", %{ - relay_url: "http://mastodon.example.org/users/admin" - }) - - assert json_response(conn, 200) == "http://mastodon.example.org/users/admin" - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} followed relay: http://mastodon.example.org/users/admin" - end - - test "GET /relay", %{conn: conn} do - relay_user = Pleroma.Web.ActivityPub.Relay.get_actor() - - ["http://mastodon.example.org/users/admin", "https://mstdn.io/users/mayuutann"] - |> Enum.each(fn ap_id -> - {:ok, user} = User.get_or_fetch_by_ap_id(ap_id) - User.follow(relay_user, user) - end) - - conn = get(conn, "/api/pleroma/admin/relay") - - assert json_response(conn, 200)["relays"] -- ["mastodon.example.org", "mstdn.io"] == [] - end - - test "DELETE /relay", %{conn: conn, admin: admin} do - post(conn, "/api/pleroma/admin/relay", %{ - relay_url: "http://mastodon.example.org/users/admin" - }) - - conn = - delete(conn, "/api/pleroma/admin/relay", %{ - relay_url: "http://mastodon.example.org/users/admin" - }) - - assert json_response(conn, 200) == "http://mastodon.example.org/users/admin" - - [log_entry_one, log_entry_two] = Repo.all(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry_one) == - "@#{admin.nickname} followed relay: http://mastodon.example.org/users/admin" - - assert ModerationLog.get_log_entry_message(log_entry_two) == - "@#{admin.nickname} unfollowed relay: http://mastodon.example.org/users/admin" - end - end - describe "instances" do test "GET /instances/:instance/statuses", %{conn: conn} do user = insert(:user, local: false, nickname: "archaeme@archae.me") diff --git a/test/web/admin_api/controllers/relay_controller_test.exs b/test/web/admin_api/controllers/relay_controller_test.exs new file mode 100644 index 000000000..64086adc5 --- /dev/null +++ b/test/web/admin_api/controllers/relay_controller_test.exs @@ -0,0 +1,92 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.RelayControllerTest do + use Pleroma.Web.ConnCase + + import Pleroma.Factory + + alias Pleroma.Config + alias Pleroma.ModerationLog + alias Pleroma.Repo + alias Pleroma.User + + setup_all do + Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) + + :ok + end + + setup do + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + + {:ok, %{admin: admin, token: token, conn: conn}} + end + + describe "relays" do + test "POST /relay", %{conn: conn, admin: admin} do + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/relay", %{ + relay_url: "http://mastodon.example.org/users/admin" + }) + + assert json_response_and_validate_schema(conn, 200) == + "http://mastodon.example.org/users/admin" + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} followed relay: http://mastodon.example.org/users/admin" + end + + test "GET /relay", %{conn: conn} do + relay_user = Pleroma.Web.ActivityPub.Relay.get_actor() + + ["http://mastodon.example.org/users/admin", "https://mstdn.io/users/mayuutann"] + |> Enum.each(fn ap_id -> + {:ok, user} = User.get_or_fetch_by_ap_id(ap_id) + User.follow(relay_user, user) + end) + + conn = get(conn, "/api/pleroma/admin/relay") + + assert json_response_and_validate_schema(conn, 200)["relays"] -- + ["mastodon.example.org", "mstdn.io"] == [] + end + + test "DELETE /relay", %{conn: conn, admin: admin} do + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/relay", %{ + relay_url: "http://mastodon.example.org/users/admin" + }) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> delete("/api/pleroma/admin/relay", %{ + relay_url: "http://mastodon.example.org/users/admin" + }) + + assert json_response_and_validate_schema(conn, 200) == + "http://mastodon.example.org/users/admin" + + [log_entry_one, log_entry_two] = Repo.all(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry_one) == + "@#{admin.nickname} followed relay: http://mastodon.example.org/users/admin" + + assert ModerationLog.get_log_entry_message(log_entry_two) == + "@#{admin.nickname} unfollowed relay: http://mastodon.example.org/users/admin" + end + end +end From dbd07d29a358a446d87078d60b993a59b757ad1d Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 25 May 2020 17:27:45 +0200 Subject: [PATCH 120/401] Streamer: Don't crash on streaming chat notifications --- lib/pleroma/web/common_api/common_api.ex | 9 +++++---- test/web/streamer/streamer_test.exs | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index c08edbc5f..764fa4f4f 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -467,12 +467,13 @@ def remove_mute(user, activity) do {:ok, activity} end - def thread_muted?(%{id: nil} = _user, _activity), do: false - - def thread_muted?(user, activity) do - ThreadMute.exists?(user.id, activity.data["context"]) + def thread_muted?(%User{id: user_id}, %{data: %{"context" => context}}) + when is_binary("context") do + ThreadMute.exists?(user_id, context) end + def thread_muted?(_, _), do: false + def report(user, data) do with {:ok, account} <- get_reported_account(data.account_id), {:ok, {content_html, _, _}} <- make_report_content_html(data[:comment]), diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index cb4595bb6..115ba4703 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -126,6 +126,21 @@ test "it sends notify to in the 'user:notification' stream", %{user: user, notif refute Streamer.filtered_by_user?(user, notify) end + test "it sends chat message notifications to the 'user:notification' stream", %{user: user} do + other_user = insert(:user) + + {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey") + + notify = + Repo.get_by(Pleroma.Notification, user_id: user.id, activity_id: create_activity.id) + |> Repo.preload(:activity) + + Streamer.get_topic_and_add_socket("user:notification", user) + Streamer.stream("user:notification", notify) + assert_receive {:render_with_user, _, _, ^notify} + refute Streamer.filtered_by_user?(user, notify) + end + test "it doesn't send notify to the 'user:notification' stream when a user is blocked", %{ user: user } do From 0ba1f2631a09cc0a40f8a0bc2f81ff2c83beedfb Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 25 May 2020 22:02:22 +0400 Subject: [PATCH 121/401] Add OpenAPI spec for AdminAPI.OAuthAppContoller --- .../controllers/admin_api_controller.ex | 83 ------- .../controllers/oauth_app_controller.ex | 87 +++++++ .../operations/admin/oauth_app_operation.ex | 215 +++++++++++++++++ lib/pleroma/web/oauth/app.ex | 29 +-- lib/pleroma/web/router.ex | 8 +- .../controllers/admin_api_controller_test.exs | 185 --------------- .../controllers/oauth_app_controller_test.exs | 220 ++++++++++++++++++ 7 files changed, 541 insertions(+), 286 deletions(-) create mode 100644 lib/pleroma/web/admin_api/controllers/oauth_app_controller.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/oauth_app_operation.ex create mode 100644 test/web/admin_api/controllers/oauth_app_controller_test.exs diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index 6b1d64a2e..4f10bd947 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -32,8 +32,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Web.CommonAPI alias Pleroma.Web.Endpoint alias Pleroma.Web.MastodonAPI - alias Pleroma.Web.MastodonAPI.AppView - alias Pleroma.Web.OAuth.App alias Pleroma.Web.Router require Logger @@ -122,10 +120,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do :config_update, :resend_confirmation_email, :confirm_email, - :oauth_app_create, - :oauth_app_list, - :oauth_app_update, - :oauth_app_delete, :reload_emoji ] ) @@ -995,83 +989,6 @@ def resend_confirmation_email(%{assigns: %{user: admin}} = conn, %{"nicknames" = conn |> json("") end - def oauth_app_create(conn, params) do - params = - if params["name"] do - Map.put(params, "client_name", params["name"]) - else - params - end - - result = - case App.create(params) do - {:ok, app} -> - AppView.render("show.json", %{app: app, admin: true}) - - {:error, changeset} -> - App.errors(changeset) - end - - json(conn, result) - end - - def oauth_app_update(conn, params) do - params = - if params["name"] do - Map.put(params, "client_name", params["name"]) - else - params - end - - with {:ok, app} <- App.update(params) do - json(conn, AppView.render("show.json", %{app: app, admin: true})) - else - {:error, changeset} -> - json(conn, App.errors(changeset)) - - nil -> - json_response(conn, :bad_request, "") - end - end - - def oauth_app_list(conn, params) do - {page, page_size} = page_params(params) - - search_params = %{ - client_name: params["name"], - client_id: params["client_id"], - page: page, - page_size: page_size - } - - search_params = - if Map.has_key?(params, "trusted") do - Map.put(search_params, :trusted, params["trusted"]) - else - search_params - end - - with {:ok, apps, count} <- App.search(search_params) do - json( - conn, - AppView.render("index.json", - apps: apps, - count: count, - page_size: page_size, - admin: true - ) - ) - end - end - - def oauth_app_delete(conn, params) do - with {:ok, _app} <- App.destroy(params["id"]) do - json_response(conn, :no_content, "") - else - _ -> json_response(conn, :bad_request, "") - end - end - def stats(conn, _) do count = Stats.get_status_visibility_count() diff --git a/lib/pleroma/web/admin_api/controllers/oauth_app_controller.ex b/lib/pleroma/web/admin_api/controllers/oauth_app_controller.ex new file mode 100644 index 000000000..04e629fc1 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/oauth_app_controller.ex @@ -0,0 +1,87 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.OAuthAppController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [json_response: 3] + + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Web.OAuth.App + + require Logger + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(:put_view, Pleroma.Web.MastodonAPI.AppView) + + plug( + OAuthScopesPlug, + %{scopes: ["write"], admin: true} + when action in [:create, :index, :update, :delete] + ) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.OAuthAppOperation + + def index(conn, params) do + search_params = + params + |> Map.take([:client_id, :page, :page_size, :trusted]) + |> Map.put(:client_name, params[:name]) + + with {:ok, apps, count} <- App.search(search_params) do + render(conn, "index.json", + apps: apps, + count: count, + page_size: params.page_size, + admin: true + ) + end + end + + def create(%{body_params: params} = conn, _) do + params = + if params[:name] do + Map.put(params, :client_name, params[:name]) + else + params + end + + case App.create(params) do + {:ok, app} -> + render(conn, "show.json", app: app, admin: true) + + {:error, changeset} -> + json(conn, App.errors(changeset)) + end + end + + def update(%{body_params: params} = conn, %{id: id}) do + params = + if params[:name] do + Map.put(params, :client_name, params.name) + else + params + end + + with {:ok, app} <- App.update(id, params) do + render(conn, "show.json", app: app, admin: true) + else + {:error, changeset} -> + json(conn, App.errors(changeset)) + + nil -> + json_response(conn, :bad_request, "") + end + end + + def delete(conn, params) do + with {:ok, _app} <- App.destroy(params.id) do + json_response(conn, :no_content, "") + else + _ -> json_response(conn, :bad_request, "") + end + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/oauth_app_operation.ex b/lib/pleroma/web/api_spec/operations/admin/oauth_app_operation.ex new file mode 100644 index 000000000..fbc9f80d7 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/oauth_app_operation.ex @@ -0,0 +1,215 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.OAuthAppOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + summary: "List OAuth apps", + tags: ["Admin", "oAuth Apps"], + operationId: "AdminAPI.OAuthAppController.index", + security: [%{"oAuth" => ["write"]}], + parameters: [ + Operation.parameter(:name, :query, %Schema{type: :string}, "App name"), + Operation.parameter(:client_id, :query, %Schema{type: :string}, "Client ID"), + Operation.parameter(:page, :query, %Schema{type: :integer, default: 1}, "Page"), + Operation.parameter( + :trusted, + :query, + %Schema{type: :boolean, default: false}, + "Trusted apps" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 50}, + "Number of apps to return" + ) + ], + responses: %{ + 200 => + Operation.response("List of apps", "application/json", %Schema{ + type: :object, + properties: %{ + apps: %Schema{type: :array, items: oauth_app()}, + count: %Schema{type: :integer}, + page_size: %Schema{type: :integer} + }, + example: %{ + "apps" => [ + %{ + "id" => 1, + "name" => "App name", + "client_id" => "yHoDSiWYp5mPV6AfsaVOWjdOyt5PhWRiafi6MRd1lSk", + "client_secret" => "nLmis486Vqrv2o65eM9mLQx_m_4gH-Q6PcDpGIMl6FY", + "redirect_uri" => "https://example.com/oauth-callback", + "website" => "https://example.com", + "trusted" => true + } + ], + "count" => 1, + "page_size" => 50 + } + }) + } + } + end + + def create_operation do + %Operation{ + tags: ["Admin", "oAuth Apps"], + summary: "Create OAuth App", + operationId: "AdminAPI.OAuthAppController.create", + requestBody: request_body("Parameters", create_request()), + security: [%{"oAuth" => ["write"]}], + responses: %{ + 200 => Operation.response("App", "application/json", oauth_app()), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Admin", "oAuth Apps"], + summary: "Update OAuth App", + operationId: "AdminAPI.OAuthAppController.update", + parameters: [id_param()], + security: [%{"oAuth" => ["write"]}], + requestBody: request_body("Parameters", update_request()), + responses: %{ + 200 => Operation.response("App", "application/json", oauth_app()), + 400 => + Operation.response("Bad Request", "application/json", %Schema{ + oneOf: [ApiError, %Schema{type: :string}] + }) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Admin", "oAuth Apps"], + summary: "Delete OAuth App", + operationId: "AdminAPI.OAuthAppController.delete", + parameters: [id_param()], + security: [%{"oAuth" => ["write"]}], + responses: %{ + 204 => no_content_response(), + 400 => no_content_response() + } + } + end + + defp create_request do + %Schema{ + title: "oAuthAppCreateRequest", + type: :object, + required: [:name, :redirect_uris], + properties: %{ + name: %Schema{type: :string, description: "Application Name"}, + scopes: %Schema{type: :array, items: %Schema{type: :string}, description: "oAuth scopes"}, + redirect_uris: %Schema{ + type: :string, + description: + "Where the user should be redirected after authorization. To display the authorization code to the user instead of redirecting to a web page, use `urn:ietf:wg:oauth:2.0:oob` in this parameter." + }, + website: %Schema{ + type: :string, + nullable: true, + description: "A URL to the homepage of the app" + }, + trusted: %Schema{ + type: :boolean, + nullable: true, + default: false, + description: "Is the app trusted?" + } + }, + example: %{ + "name" => "My App", + "redirect_uris" => "https://myapp.com/auth/callback", + "website" => "https://myapp.com/", + "scopes" => ["read", "write"], + "trusted" => true + } + } + end + + defp update_request do + %Schema{ + title: "oAuthAppUpdateRequest", + type: :object, + properties: %{ + name: %Schema{type: :string, description: "Application Name"}, + scopes: %Schema{type: :array, items: %Schema{type: :string}, description: "oAuth scopes"}, + redirect_uris: %Schema{ + type: :string, + description: + "Where the user should be redirected after authorization. To display the authorization code to the user instead of redirecting to a web page, use `urn:ietf:wg:oauth:2.0:oob` in this parameter." + }, + website: %Schema{ + type: :string, + nullable: true, + description: "A URL to the homepage of the app" + }, + trusted: %Schema{ + type: :boolean, + nullable: true, + default: false, + description: "Is the app trusted?" + } + }, + example: %{ + "name" => "My App", + "redirect_uris" => "https://myapp.com/auth/callback", + "website" => "https://myapp.com/", + "scopes" => ["read", "write"], + "trusted" => true + } + } + end + + defp oauth_app do + %Schema{ + title: "oAuthApp", + type: :object, + properties: %{ + id: %Schema{type: :integer}, + name: %Schema{type: :string}, + client_id: %Schema{type: :string}, + client_secret: %Schema{type: :string}, + redirect_uri: %Schema{type: :string}, + website: %Schema{type: :string, nullable: true}, + trusted: %Schema{type: :boolean} + }, + example: %{ + "id" => 123, + "name" => "My App", + "client_id" => "TWhM-tNSuncnqN7DBJmoyeLnk6K3iJJ71KKXxgL1hPM", + "client_secret" => "ZEaFUFmF0umgBX1qKJDjaU99Q31lDkOU8NutzTOoliw", + "redirect_uri" => "https://myapp.com/oauth-callback", + "website" => "https://myapp.com/", + "trusted" => false + } + } + end + + def id_param do + Operation.parameter(:id, :path, :integer, "App ID", + example: 1337, + required: true + ) + end +end diff --git a/lib/pleroma/web/oauth/app.ex b/lib/pleroma/web/oauth/app.ex index 6a6d5f2e2..df99472e1 100644 --- a/lib/pleroma/web/oauth/app.ex +++ b/lib/pleroma/web/oauth/app.ex @@ -25,12 +25,12 @@ defmodule Pleroma.Web.OAuth.App do timestamps() end - @spec changeset(App.t(), map()) :: Ecto.Changeset.t() + @spec changeset(t(), map()) :: Ecto.Changeset.t() def changeset(struct, params) do cast(struct, params, [:client_name, :redirect_uris, :scopes, :website, :trusted]) end - @spec register_changeset(App.t(), map()) :: Ecto.Changeset.t() + @spec register_changeset(t(), map()) :: Ecto.Changeset.t() def register_changeset(struct, params \\ %{}) do changeset = struct @@ -52,18 +52,19 @@ def register_changeset(struct, params \\ %{}) do end end - @spec create(map()) :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} + @spec create(map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} def create(params) do - with changeset <- __MODULE__.register_changeset(%__MODULE__{}, params) do - Repo.insert(changeset) - end + %__MODULE__{} + |> register_changeset(params) + |> Repo.insert() end - @spec update(map()) :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} - def update(params) do - with %__MODULE__{} = app <- Repo.get(__MODULE__, params["id"]), - changeset <- changeset(app, params) do - Repo.update(changeset) + @spec update(pos_integer(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} + def update(id, params) do + with %__MODULE__{} = app <- Repo.get(__MODULE__, id) do + app + |> changeset(params) + |> Repo.update() end end @@ -71,7 +72,7 @@ def update(params) do 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()} + @spec get_or_make(map(), list(String.t())) :: {:ok, 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) @@ -92,7 +93,7 @@ defp update_scopes(%__MODULE__{} = app, scopes) do |> Repo.update() end - @spec search(map()) :: {:ok, [App.t()], non_neg_integer()} + @spec search(map()) :: {:ok, [t()], non_neg_integer()} def search(params) do query = from(a in __MODULE__) @@ -128,7 +129,7 @@ def search(params) do {:ok, Repo.all(query), count} end - @spec destroy(pos_integer()) :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} + @spec destroy(pos_integer()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} def destroy(id) do with %__MODULE__{} = app <- Repo.get(__MODULE__, id) do Repo.delete(app) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index e493a4153..46f03cdfd 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -205,10 +205,10 @@ defmodule Pleroma.Web.Router do post("/reload_emoji", AdminAPIController, :reload_emoji) get("/stats", AdminAPIController, :stats) - get("/oauth_app", AdminAPIController, :oauth_app_list) - post("/oauth_app", AdminAPIController, :oauth_app_create) - patch("/oauth_app/:id", AdminAPIController, :oauth_app_update) - delete("/oauth_app/:id", AdminAPIController, :oauth_app_delete) + get("/oauth_app", OAuthAppController, :index) + post("/oauth_app", OAuthAppController, :create) + patch("/oauth_app/:id", OAuthAppController, :update) + delete("/oauth_app/:id", OAuthAppController, :delete) end scope "/api/pleroma/emoji", Pleroma.Web.PleromaAPI do diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index 321840a8c..f704cdd3a 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -3522,191 +3522,6 @@ test "status visibility count", %{conn: conn} do response["status_visibility"] end end - - describe "POST /api/pleroma/admin/oauth_app" do - test "errors", %{conn: conn} do - response = conn |> post("/api/pleroma/admin/oauth_app", %{}) |> json_response(200) - - assert response == %{"name" => "can't be blank", "redirect_uris" => "can't be blank"} - end - - test "success", %{conn: conn} do - base_url = Web.base_url() - app_name = "Trusted app" - - response = - conn - |> post("/api/pleroma/admin/oauth_app", %{ - name: app_name, - redirect_uris: base_url - }) - |> json_response(200) - - assert %{ - "client_id" => _, - "client_secret" => _, - "name" => ^app_name, - "redirect_uri" => ^base_url, - "trusted" => false - } = response - end - - test "with trusted", %{conn: conn} do - base_url = Web.base_url() - app_name = "Trusted app" - - response = - conn - |> post("/api/pleroma/admin/oauth_app", %{ - name: app_name, - redirect_uris: base_url, - trusted: true - }) - |> json_response(200) - - assert %{ - "client_id" => _, - "client_secret" => _, - "name" => ^app_name, - "redirect_uri" => ^base_url, - "trusted" => true - } = response - end - end - - describe "GET /api/pleroma/admin/oauth_app" do - setup do - app = insert(:oauth_app) - {:ok, app: app} - end - - test "list", %{conn: conn} do - response = - conn - |> get("/api/pleroma/admin/oauth_app") - |> json_response(200) - - assert %{"apps" => apps, "count" => count, "page_size" => _} = response - - assert length(apps) == count - end - - test "with page size", %{conn: conn} do - insert(:oauth_app) - page_size = 1 - - response = - conn - |> get("/api/pleroma/admin/oauth_app", %{page_size: to_string(page_size)}) - |> json_response(200) - - assert %{"apps" => apps, "count" => _, "page_size" => ^page_size} = response - - assert length(apps) == page_size - end - - test "search by client name", %{conn: conn, app: app} do - response = - conn - |> get("/api/pleroma/admin/oauth_app", %{name: app.client_name}) - |> json_response(200) - - assert %{"apps" => [returned], "count" => _, "page_size" => _} = response - - assert returned["client_id"] == app.client_id - assert returned["name"] == app.client_name - end - - test "search by client id", %{conn: conn, app: app} do - response = - conn - |> get("/api/pleroma/admin/oauth_app", %{client_id: app.client_id}) - |> json_response(200) - - assert %{"apps" => [returned], "count" => _, "page_size" => _} = response - - assert returned["client_id"] == app.client_id - assert returned["name"] == app.client_name - end - - test "only trusted", %{conn: conn} do - app = insert(:oauth_app, trusted: true) - - response = - conn - |> get("/api/pleroma/admin/oauth_app", %{trusted: true}) - |> json_response(200) - - assert %{"apps" => [returned], "count" => _, "page_size" => _} = response - - assert returned["client_id"] == app.client_id - assert returned["name"] == app.client_name - end - end - - describe "DELETE /api/pleroma/admin/oauth_app/:id" do - test "with id", %{conn: conn} do - app = insert(:oauth_app) - - response = - conn - |> delete("/api/pleroma/admin/oauth_app/" <> to_string(app.id)) - |> json_response(:no_content) - - assert response == "" - end - - test "with non existance id", %{conn: conn} do - response = - conn - |> delete("/api/pleroma/admin/oauth_app/0") - |> json_response(:bad_request) - - assert response == "" - end - end - - describe "PATCH /api/pleroma/admin/oauth_app/:id" do - test "with id", %{conn: conn} do - app = insert(:oauth_app) - - name = "another name" - url = "https://example.com" - scopes = ["admin"] - id = app.id - website = "http://website.com" - - response = - conn - |> patch("/api/pleroma/admin/oauth_app/" <> to_string(app.id), %{ - name: name, - trusted: true, - redirect_uris: url, - scopes: scopes, - website: website - }) - |> json_response(200) - - assert %{ - "client_id" => _, - "client_secret" => _, - "id" => ^id, - "name" => ^name, - "redirect_uri" => ^url, - "trusted" => true, - "website" => ^website - } = response - end - - test "without id", %{conn: conn} do - response = - conn - |> patch("/api/pleroma/admin/oauth_app/0") - |> json_response(:bad_request) - - assert response == "" - end - end end # Needed for testing diff --git a/test/web/admin_api/controllers/oauth_app_controller_test.exs b/test/web/admin_api/controllers/oauth_app_controller_test.exs new file mode 100644 index 000000000..ed7c4172c --- /dev/null +++ b/test/web/admin_api/controllers/oauth_app_controller_test.exs @@ -0,0 +1,220 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.OAuthAppControllerTest do + use Pleroma.Web.ConnCase, async: true + use Oban.Testing, repo: Pleroma.Repo + + import Pleroma.Factory + + alias Pleroma.Config + alias Pleroma.Web + + setup do + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + + {:ok, %{admin: admin, token: token, conn: conn}} + end + + describe "POST /api/pleroma/admin/oauth_app" do + test "errors", %{conn: conn} do + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/oauth_app", %{}) + |> json_response_and_validate_schema(400) + + assert %{ + "error" => "Missing field: name. Missing field: redirect_uris." + } = response + end + + test "success", %{conn: conn} do + base_url = Web.base_url() + app_name = "Trusted app" + + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/oauth_app", %{ + name: app_name, + redirect_uris: base_url + }) + |> json_response_and_validate_schema(200) + + assert %{ + "client_id" => _, + "client_secret" => _, + "name" => ^app_name, + "redirect_uri" => ^base_url, + "trusted" => false + } = response + end + + test "with trusted", %{conn: conn} do + base_url = Web.base_url() + app_name = "Trusted app" + + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/oauth_app", %{ + name: app_name, + redirect_uris: base_url, + trusted: true + }) + |> json_response_and_validate_schema(200) + + assert %{ + "client_id" => _, + "client_secret" => _, + "name" => ^app_name, + "redirect_uri" => ^base_url, + "trusted" => true + } = response + end + end + + describe "GET /api/pleroma/admin/oauth_app" do + setup do + app = insert(:oauth_app) + {:ok, app: app} + end + + test "list", %{conn: conn} do + response = + conn + |> get("/api/pleroma/admin/oauth_app") + |> json_response_and_validate_schema(200) + + assert %{"apps" => apps, "count" => count, "page_size" => _} = response + + assert length(apps) == count + end + + test "with page size", %{conn: conn} do + insert(:oauth_app) + page_size = 1 + + response = + conn + |> get("/api/pleroma/admin/oauth_app?page_size=#{page_size}") + |> json_response_and_validate_schema(200) + + assert %{"apps" => apps, "count" => _, "page_size" => ^page_size} = response + + assert length(apps) == page_size + end + + test "search by client name", %{conn: conn, app: app} do + response = + conn + |> get("/api/pleroma/admin/oauth_app?name=#{app.client_name}") + |> json_response_and_validate_schema(200) + + assert %{"apps" => [returned], "count" => _, "page_size" => _} = response + + assert returned["client_id"] == app.client_id + assert returned["name"] == app.client_name + end + + test "search by client id", %{conn: conn, app: app} do + response = + conn + |> get("/api/pleroma/admin/oauth_app?client_id=#{app.client_id}") + |> json_response_and_validate_schema(200) + + assert %{"apps" => [returned], "count" => _, "page_size" => _} = response + + assert returned["client_id"] == app.client_id + assert returned["name"] == app.client_name + end + + test "only trusted", %{conn: conn} do + app = insert(:oauth_app, trusted: true) + + response = + conn + |> get("/api/pleroma/admin/oauth_app?trusted=true") + |> json_response_and_validate_schema(200) + + assert %{"apps" => [returned], "count" => _, "page_size" => _} = response + + assert returned["client_id"] == app.client_id + assert returned["name"] == app.client_name + end + end + + describe "DELETE /api/pleroma/admin/oauth_app/:id" do + test "with id", %{conn: conn} do + app = insert(:oauth_app) + + response = + conn + |> delete("/api/pleroma/admin/oauth_app/" <> to_string(app.id)) + |> json_response_and_validate_schema(:no_content) + + assert response == "" + end + + test "with non existance id", %{conn: conn} do + response = + conn + |> delete("/api/pleroma/admin/oauth_app/0") + |> json_response_and_validate_schema(:bad_request) + + assert response == "" + end + end + + describe "PATCH /api/pleroma/admin/oauth_app/:id" do + test "with id", %{conn: conn} do + app = insert(:oauth_app) + + name = "another name" + url = "https://example.com" + scopes = ["admin"] + id = app.id + website = "http://website.com" + + response = + conn + |> put_req_header("content-type", "application/json") + |> patch("/api/pleroma/admin/oauth_app/#{id}", %{ + name: name, + trusted: true, + redirect_uris: url, + scopes: scopes, + website: website + }) + |> json_response_and_validate_schema(200) + + assert %{ + "client_id" => _, + "client_secret" => _, + "id" => ^id, + "name" => ^name, + "redirect_uri" => ^url, + "trusted" => true, + "website" => ^website + } = response + end + + test "without id", %{conn: conn} do + response = + conn + |> put_req_header("content-type", "application/json") + |> patch("/api/pleroma/admin/oauth_app/0") + |> json_response_and_validate_schema(:bad_request) + + assert response == "" + end + end +end From 8f08384d8058f61753c28d37c90b47a2886f348c Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 18 May 2020 10:09:21 +0300 Subject: [PATCH 122/401] another view for account in admin-fe status_show --- .../web/admin_api/controllers/status_controller.ex | 2 +- test/web/admin_api/controllers/status_controller_test.exs | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/admin_api/controllers/status_controller.ex b/lib/pleroma/web/admin_api/controllers/status_controller.ex index 08cb9c10b..c91fbc771 100644 --- a/lib/pleroma/web/admin_api/controllers/status_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/status_controller.ex @@ -42,7 +42,7 @@ def index(%{assigns: %{user: _admin}} = conn, params) do def show(conn, %{id: id}) do with %Activity{} = activity <- Activity.get_by_id(id) do conn - |> put_view(MastodonAPI.StatusView) + |> put_view(Pleroma.Web.AdminAPI.StatusView) |> render("show.json", %{activity: activity}) else nil -> {:error, :not_found} diff --git a/test/web/admin_api/controllers/status_controller_test.exs b/test/web/admin_api/controllers/status_controller_test.exs index 124d8dc2e..eff78fb0a 100644 --- a/test/web/admin_api/controllers/status_controller_test.exs +++ b/test/web/admin_api/controllers/status_controller_test.exs @@ -42,6 +42,14 @@ test "shows activity", %{conn: conn} do |> json_response_and_validate_schema(200) assert response["id"] == activity.id + + account = response["account"] + actor = User.get_by_ap_id(activity.actor) + + assert account["id"] == actor.id + assert account["nickname"] == actor.nickname + assert account["deactivated"] == actor.deactivated + assert account["confirmation_pending"] == actor.confirmation_pending end end From 95ebfb9190e6e7d446213ca57e8c99aa3116ed0a Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 26 May 2020 13:13:39 +0400 Subject: [PATCH 123/401] Move invite actions to AdminAPI.InviteTokenController --- .../controllers/admin_api_controller.ex | 72 ----- .../controllers/invite_token_controller.ex | 88 +++++++ .../admin/invite_token_operation.ex | 165 ++++++++++++ lib/pleroma/web/router.ex | 8 +- .../controllers/admin_api_controller_test.exs | 223 ---------------- .../invite_token_controller_test.exs | 247 ++++++++++++++++++ 6 files changed, 504 insertions(+), 299 deletions(-) create mode 100644 lib/pleroma/web/admin_api/controllers/invite_token_controller.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex create mode 100644 test/web/admin_api/controllers/invite_token_controller_test.exs diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index 6b1d64a2e..95582b008 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -16,7 +16,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.ReportNote alias Pleroma.Stats alias Pleroma.User - alias Pleroma.UserInviteToken alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.Pipeline @@ -69,14 +68,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do ] ) - plug(OAuthScopesPlug, %{scopes: ["read:invites"], admin: true} when action == :invites) - - plug( - OAuthScopesPlug, - %{scopes: ["write:invites"], admin: true} - when action in [:create_invite_token, :revoke_invite, :email_invite] - ) - plug( OAuthScopesPlug, %{scopes: ["write:follows"], admin: true} @@ -575,69 +566,6 @@ def relay_unfollow(%{assigns: %{user: admin}} = conn, %{"relay_url" => target}) end end - @doc "Sends registration invite via email" - def email_invite(%{assigns: %{user: user}} = conn, %{"email" => email} = params) do - with {_, false} <- {:registrations_open, Config.get([:instance, :registrations_open])}, - {_, true} <- {:invites_enabled, Config.get([:instance, :invites_enabled])}, - {:ok, invite_token} <- UserInviteToken.create_invite(), - email <- - Pleroma.Emails.UserEmail.user_invitation_email( - user, - invite_token, - email, - params["name"] - ), - {:ok, _} <- Pleroma.Emails.Mailer.deliver(email) do - json_response(conn, :no_content, "") - else - {:registrations_open, _} -> - {:error, "To send invites you need to set the `registrations_open` option to false."} - - {:invites_enabled, _} -> - {:error, "To send invites you need to set the `invites_enabled` option to true."} - end - end - - @doc "Create an account registration invite token" - def create_invite_token(conn, params) do - opts = %{} - - 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" - def invites(conn, _params) do - invites = UserInviteToken.list_invites() - - conn - |> put_view(AccountView) - |> render("invites.json", %{invites: invites}) - end - - @doc "Revokes invite by token" - def revoke_invite(conn, %{"token" => token}) do - with {:ok, invite} <- UserInviteToken.find_by_token(token), - {:ok, updated_invite} = UserInviteToken.update_invite(invite, %{used: true}) do - conn - |> put_view(AccountView) - |> render("invite.json", %{invite: updated_invite}) - else - nil -> {:error, :not_found} - end - end - @doc "Get a password reset token (base64 string) for given nickname" def get_password_reset(conn, %{"nickname" => nickname}) do (%User{local: true} = user) = User.get_cached_by_nickname(nickname) diff --git a/lib/pleroma/web/admin_api/controllers/invite_token_controller.ex b/lib/pleroma/web/admin_api/controllers/invite_token_controller.ex new file mode 100644 index 000000000..a0291e9c3 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/invite_token_controller.ex @@ -0,0 +1,88 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.InviteTokenController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [json_response: 3] + + alias Pleroma.Config + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.UserInviteToken + alias Pleroma.Web.AdminAPI.AccountView + + require Logger + + plug(OAuthScopesPlug, %{scopes: ["read:invites"], admin: true} when action == :index) + + plug( + OAuthScopesPlug, + %{scopes: ["write:invites"], admin: true} when action in [:create, :revoke, :email] + ) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + @doc "Get list of created invites" + def index(conn, _params) do + invites = UserInviteToken.list_invites() + + conn + |> put_view(AccountView) + |> render("invites.json", %{invites: invites}) + end + + @doc "Create an account registration invite token" + def create(conn, params) do + opts = %{} + + 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 "Revokes invite by token" + def revoke(conn, %{"token" => token}) do + with {:ok, invite} <- UserInviteToken.find_by_token(token), + {:ok, updated_invite} = UserInviteToken.update_invite(invite, %{used: true}) do + conn + |> put_view(AccountView) + |> render("invite.json", %{invite: updated_invite}) + else + nil -> {:error, :not_found} + end + end + + @doc "Sends registration invite via email" + def email(%{assigns: %{user: user}} = conn, %{"email" => email} = params) do + with {_, false} <- {:registrations_open, Config.get([:instance, :registrations_open])}, + {_, true} <- {:invites_enabled, Config.get([:instance, :invites_enabled])}, + {:ok, invite_token} <- UserInviteToken.create_invite(), + email <- + Pleroma.Emails.UserEmail.user_invitation_email( + user, + invite_token, + email, + params["name"] + ), + {:ok, _} <- Pleroma.Emails.Mailer.deliver(email) do + json_response(conn, :no_content, "") + else + {:registrations_open, _} -> + {:error, "To send invites you need to set the `registrations_open` option to false."} + + {:invites_enabled, _} -> + {:error, "To send invites you need to set the `invites_enabled` option to true."} + end + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex b/lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex new file mode 100644 index 000000000..09a7735d1 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex @@ -0,0 +1,165 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.InviteTokenOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + import Pleroma.Web.ApiSpec.Helpers + import Pleroma.Web.ApiSpec.StatusOperation, only: [id_param: 0] + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Admin", "Statuses"], + operationId: "AdminAPI.StatusController.index", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + Operation.parameter( + :godmode, + :query, + %Schema{type: :boolean, default: false}, + "Allows to see private statuses" + ), + Operation.parameter( + :local_only, + :query, + %Schema{type: :boolean, default: false}, + "Excludes remote statuses" + ), + Operation.parameter( + :with_reblogs, + :query, + %Schema{type: :boolean, default: false}, + "Allows to see reblogs" + ), + Operation.parameter( + :page, + :query, + %Schema{type: :integer, default: 1}, + "Page" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 50}, + "Number of statuses to return" + ) + ], + responses: %{ + 200 => + Operation.response("Array of statuses", "application/json", %Schema{ + type: :array, + items: status() + }) + } + } + end + + def show_operation do + %Operation{ + tags: ["Admin", "Statuses"], + summary: "Show Status", + operationId: "AdminAPI.StatusController.show", + parameters: [id_param()], + security: [%{"oAuth" => ["read:statuses"]}], + responses: %{ + 200 => Operation.response("Status", "application/json", Status), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Admin", "Statuses"], + summary: "Change the scope of an individual reported status", + operationId: "AdminAPI.StatusController.update", + parameters: [id_param()], + security: [%{"oAuth" => ["write:statuses"]}], + requestBody: request_body("Parameters", update_request(), required: true), + responses: %{ + 200 => Operation.response("Status", "application/json", Status), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Admin", "Statuses"], + summary: "Delete an individual reported status", + operationId: "AdminAPI.StatusController.delete", + parameters: [id_param()], + security: [%{"oAuth" => ["write:statuses"]}], + responses: %{ + 200 => empty_object_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + defp status do + %Schema{ + anyOf: [ + Status, + %Schema{ + type: :object, + properties: %{ + account: %Schema{allOf: [Account, admin_account()]} + } + } + ] + } + end + + defp admin_account do + %Schema{ + type: :object, + properties: %{ + id: FlakeID, + avatar: %Schema{type: :string}, + nickname: %Schema{type: :string}, + display_name: %Schema{type: :string}, + deactivated: %Schema{type: :boolean}, + local: %Schema{type: :boolean}, + roles: %Schema{ + type: :object, + properties: %{ + admin: %Schema{type: :boolean}, + moderator: %Schema{type: :boolean} + } + }, + tags: %Schema{type: :string}, + confirmation_pending: %Schema{type: :string} + } + } + end + + defp update_request do + %Schema{ + type: :object, + properties: %{ + sensitive: %Schema{ + type: :boolean, + description: "Mark status and attached media as sensitive?" + }, + visibility: VisibilityScope + }, + example: %{ + "visibility" => "private", + "sensitive" => "false" + } + } + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index e493a4153..fe36f0189 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -164,10 +164,10 @@ defmodule Pleroma.Web.Router do post("/relay", AdminAPIController, :relay_follow) delete("/relay", AdminAPIController, :relay_unfollow) - 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) + post("/users/invite_token", InviteTokenController, :create) + get("/users/invites", InviteTokenController, :index) + post("/users/revoke_invite", InviteTokenController, :revoke) + post("/users/email_invite", InviteTokenController, :email) get("/users/:nickname/password_reset", AdminAPIController, :get_password_reset) patch("/users/force_password_reset", AdminAPIController, :force_password_reset) diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index 321840a8c..f7e163f57 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -20,7 +20,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do alias Pleroma.ReportNote alias Pleroma.Tests.ObanHelpers alias Pleroma.User - alias Pleroma.UserInviteToken alias Pleroma.Web alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.CommonAPI @@ -588,122 +587,6 @@ test "/:right DELETE, can remove from a permission group (multiple)", %{ end end - describe "POST /api/pleroma/admin/email_invite, with valid config" do - setup do: clear_config([:instance, :registrations_open], false) - setup do: clear_config([:instance, :invites_enabled], true) - - test "sends invitation and returns 204", %{admin: admin, conn: conn} do - recipient_email = "foo@bar.com" - recipient_name = "J. D." - - conn = - post( - conn, - "/api/pleroma/admin/users/email_invite?email=#{recipient_email}&name=#{recipient_name}" - ) - - assert json_response(conn, :no_content) - - token_record = List.last(Repo.all(Pleroma.UserInviteToken)) - assert token_record - refute token_record.used - - notify_email = Config.get([:instance, :notify_email]) - instance_name = Config.get([:instance, :name]) - - email = - Pleroma.Emails.UserEmail.user_invitation_email( - admin, - token_record, - recipient_email, - recipient_name - ) - - Swoosh.TestAssertions.assert_email_sent( - from: {instance_name, notify_email}, - to: {recipient_name, recipient_email}, - html_body: email.html_body - ) - end - - test "it returns 403 if requested by a non-admin" do - non_admin_user = insert(:user) - token = insert(:oauth_token, user: non_admin_user) - - conn = - build_conn() - |> assign(:user, non_admin_user) - |> assign(:token, token) - |> post("/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") - - assert json_response(conn, :forbidden) - end - - test "email with +", %{conn: conn, admin: admin} do - recipient_email = "foo+bar@baz.com" - - conn - |> put_req_header("content-type", "application/json;charset=utf-8") - |> post("/api/pleroma/admin/users/email_invite", %{email: recipient_email}) - |> json_response(:no_content) - - token_record = - Pleroma.UserInviteToken - |> Repo.all() - |> List.last() - - assert token_record - refute token_record.used - - notify_email = Config.get([:instance, :notify_email]) - instance_name = Config.get([:instance, :name]) - - email = - Pleroma.Emails.UserEmail.user_invitation_email( - admin, - token_record, - recipient_email - ) - - Swoosh.TestAssertions.assert_email_sent( - from: {instance_name, notify_email}, - to: recipient_email, - html_body: email.html_body - ) - end - end - - describe "POST /api/pleroma/admin/users/email_invite, with invalid config" do - setup do: clear_config([:instance, :registrations_open]) - setup do: clear_config([:instance, :invites_enabled]) - - test "it returns 500 if `invites_enabled` is not enabled", %{conn: conn} do - Config.put([:instance, :registrations_open], false) - Config.put([:instance, :invites_enabled], false) - - conn = post(conn, "/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") - - assert json_response(conn, :bad_request) == - %{ - "error" => - "To send invites you need to set the `invites_enabled` option to true." - } - end - - test "it returns 500 if `registrations_open` is enabled", %{conn: conn} do - Config.put([:instance, :registrations_open], true) - Config.put([:instance, :invites_enabled], true) - - conn = post(conn, "/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") - - assert json_response(conn, :bad_request) == - %{ - "error" => - "To send invites you need to set the `registrations_open` option to false." - } - end - end - test "/api/pleroma/admin/users/:nickname/password_reset", %{conn: conn} do user = insert(:user) @@ -1318,112 +1201,6 @@ test "returns 404 if user not found", %{conn: conn} do end end - describe "POST /api/pleroma/admin/users/invite_token" do - test "without options", %{conn: conn} do - conn = post(conn, "/api/pleroma/admin/users/invite_token") - - invite_json = json_response(conn, 200) - invite = UserInviteToken.find_by_token!(invite_json["token"]) - refute invite.used - refute invite.expires_at - refute invite.max_use - assert invite.invite_type == "one_time" - end - - test "with expires_at", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/users/invite_token", %{ - "expires_at" => Date.to_string(Date.utc_today()) - }) - - invite_json = json_response(conn, 200) - invite = UserInviteToken.find_by_token!(invite_json["token"]) - - refute invite.used - assert invite.expires_at == Date.utc_today() - refute invite.max_use - assert invite.invite_type == "date_limited" - end - - test "with max_use", %{conn: conn} do - conn = post(conn, "/api/pleroma/admin/users/invite_token", %{"max_use" => 150}) - - invite_json = json_response(conn, 200) - invite = UserInviteToken.find_by_token!(invite_json["token"]) - refute invite.used - refute invite.expires_at - assert invite.max_use == 150 - assert invite.invite_type == "reusable" - end - - test "with max use and expires_at", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/users/invite_token", %{ - "max_use" => 150, - "expires_at" => Date.to_string(Date.utc_today()) - }) - - invite_json = json_response(conn, 200) - invite = UserInviteToken.find_by_token!(invite_json["token"]) - refute invite.used - assert invite.expires_at == Date.utc_today() - assert invite.max_use == 150 - assert invite.invite_type == "reusable_date_limited" - end - end - - describe "GET /api/pleroma/admin/users/invites" do - test "no invites", %{conn: conn} do - conn = get(conn, "/api/pleroma/admin/users/invites") - - assert json_response(conn, 200) == %{"invites" => []} - end - - test "with invite", %{conn: conn} do - {:ok, invite} = UserInviteToken.create_invite() - - conn = get(conn, "/api/pleroma/admin/users/invites") - - assert json_response(conn, 200) == %{ - "invites" => [ - %{ - "expires_at" => nil, - "id" => invite.id, - "invite_type" => "one_time", - "max_use" => nil, - "token" => invite.token, - "used" => false, - "uses" => 0 - } - ] - } - end - end - - describe "POST /api/pleroma/admin/users/revoke_invite" do - test "with token", %{conn: conn} do - {:ok, invite} = UserInviteToken.create_invite() - - conn = post(conn, "/api/pleroma/admin/users/revoke_invite", %{"token" => invite.token}) - - assert json_response(conn, 200) == %{ - "expires_at" => nil, - "id" => invite.id, - "invite_type" => "one_time", - "max_use" => nil, - "token" => invite.token, - "used" => true, - "uses" => 0 - } - end - - test "with invalid token", %{conn: conn} do - conn = post(conn, "/api/pleroma/admin/users/revoke_invite", %{"token" => "foo"}) - - assert json_response(conn, :not_found) == %{"error" => "Not found"} - end - end - describe "GET /api/pleroma/admin/reports/:id" do test "returns report by its id", %{conn: conn} do [reporter, target_user] = insert_pair(:user) diff --git a/test/web/admin_api/controllers/invite_token_controller_test.exs b/test/web/admin_api/controllers/invite_token_controller_test.exs new file mode 100644 index 000000000..eb57b4d44 --- /dev/null +++ b/test/web/admin_api/controllers/invite_token_controller_test.exs @@ -0,0 +1,247 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.InviteTokenControllerTest do + use Pleroma.Web.ConnCase, async: true + + import Pleroma.Factory + + alias Pleroma.Config + alias Pleroma.Repo + alias Pleroma.UserInviteToken + + setup do + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + + {:ok, %{admin: admin, token: token, conn: conn}} + end + + describe "POST /api/pleroma/admin/users/email_invite, with valid config" do + setup do: clear_config([:instance, :registrations_open], false) + setup do: clear_config([:instance, :invites_enabled], true) + + test "sends invitation and returns 204", %{admin: admin, conn: conn} do + recipient_email = "foo@bar.com" + recipient_name = "J. D." + + conn = + post( + conn, + "/api/pleroma/admin/users/email_invite?email=#{recipient_email}&name=#{recipient_name}" + ) + + assert json_response(conn, :no_content) + + token_record = List.last(Repo.all(Pleroma.UserInviteToken)) + assert token_record + refute token_record.used + + notify_email = Config.get([:instance, :notify_email]) + instance_name = Config.get([:instance, :name]) + + email = + Pleroma.Emails.UserEmail.user_invitation_email( + admin, + token_record, + recipient_email, + recipient_name + ) + + Swoosh.TestAssertions.assert_email_sent( + from: {instance_name, notify_email}, + to: {recipient_name, recipient_email}, + html_body: email.html_body + ) + end + + test "it returns 403 if requested by a non-admin" do + non_admin_user = insert(:user) + token = insert(:oauth_token, user: non_admin_user) + + conn = + build_conn() + |> assign(:user, non_admin_user) + |> assign(:token, token) + |> post("/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") + + assert json_response(conn, :forbidden) + end + + test "email with +", %{conn: conn, admin: admin} do + recipient_email = "foo+bar@baz.com" + + conn + |> put_req_header("content-type", "application/json;charset=utf-8") + |> post("/api/pleroma/admin/users/email_invite", %{email: recipient_email}) + |> json_response(:no_content) + + token_record = + Pleroma.UserInviteToken + |> Repo.all() + |> List.last() + + assert token_record + refute token_record.used + + notify_email = Config.get([:instance, :notify_email]) + instance_name = Config.get([:instance, :name]) + + email = + Pleroma.Emails.UserEmail.user_invitation_email( + admin, + token_record, + recipient_email + ) + + Swoosh.TestAssertions.assert_email_sent( + from: {instance_name, notify_email}, + to: recipient_email, + html_body: email.html_body + ) + end + end + + describe "POST /api/pleroma/admin/users/email_invite, with invalid config" do + setup do: clear_config([:instance, :registrations_open]) + setup do: clear_config([:instance, :invites_enabled]) + + test "it returns 500 if `invites_enabled` is not enabled", %{conn: conn} do + Config.put([:instance, :registrations_open], false) + Config.put([:instance, :invites_enabled], false) + + conn = post(conn, "/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") + + assert json_response(conn, :bad_request) == + %{ + "error" => + "To send invites you need to set the `invites_enabled` option to true." + } + end + + test "it returns 500 if `registrations_open` is enabled", %{conn: conn} do + Config.put([:instance, :registrations_open], true) + Config.put([:instance, :invites_enabled], true) + + conn = post(conn, "/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") + + assert json_response(conn, :bad_request) == + %{ + "error" => + "To send invites you need to set the `registrations_open` option to false." + } + end + end + + describe "POST /api/pleroma/admin/users/invite_token" do + test "without options", %{conn: conn} do + conn = post(conn, "/api/pleroma/admin/users/invite_token") + + invite_json = json_response(conn, 200) + invite = UserInviteToken.find_by_token!(invite_json["token"]) + refute invite.used + refute invite.expires_at + refute invite.max_use + assert invite.invite_type == "one_time" + end + + test "with expires_at", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/users/invite_token", %{ + "expires_at" => Date.to_string(Date.utc_today()) + }) + + invite_json = json_response(conn, 200) + invite = UserInviteToken.find_by_token!(invite_json["token"]) + + refute invite.used + assert invite.expires_at == Date.utc_today() + refute invite.max_use + assert invite.invite_type == "date_limited" + end + + test "with max_use", %{conn: conn} do + conn = post(conn, "/api/pleroma/admin/users/invite_token", %{"max_use" => 150}) + + invite_json = json_response(conn, 200) + invite = UserInviteToken.find_by_token!(invite_json["token"]) + refute invite.used + refute invite.expires_at + assert invite.max_use == 150 + assert invite.invite_type == "reusable" + end + + test "with max use and expires_at", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/users/invite_token", %{ + "max_use" => 150, + "expires_at" => Date.to_string(Date.utc_today()) + }) + + invite_json = json_response(conn, 200) + invite = UserInviteToken.find_by_token!(invite_json["token"]) + refute invite.used + assert invite.expires_at == Date.utc_today() + assert invite.max_use == 150 + assert invite.invite_type == "reusable_date_limited" + end + end + + describe "GET /api/pleroma/admin/users/invites" do + test "no invites", %{conn: conn} do + conn = get(conn, "/api/pleroma/admin/users/invites") + + assert json_response(conn, 200) == %{"invites" => []} + end + + test "with invite", %{conn: conn} do + {:ok, invite} = UserInviteToken.create_invite() + + conn = get(conn, "/api/pleroma/admin/users/invites") + + assert json_response(conn, 200) == %{ + "invites" => [ + %{ + "expires_at" => nil, + "id" => invite.id, + "invite_type" => "one_time", + "max_use" => nil, + "token" => invite.token, + "used" => false, + "uses" => 0 + } + ] + } + end + end + + describe "POST /api/pleroma/admin/users/revoke_invite" do + test "with token", %{conn: conn} do + {:ok, invite} = UserInviteToken.create_invite() + + conn = post(conn, "/api/pleroma/admin/users/revoke_invite", %{"token" => invite.token}) + + assert json_response(conn, 200) == %{ + "expires_at" => nil, + "id" => invite.id, + "invite_type" => "one_time", + "max_use" => nil, + "token" => invite.token, + "used" => true, + "uses" => 0 + } + end + + test "with invalid token", %{conn: conn} do + conn = post(conn, "/api/pleroma/admin/users/revoke_invite", %{"token" => "foo"}) + + assert json_response(conn, :not_found) == %{"error" => "Not found"} + end + end +end From 2a4f965191af6ec6ab953569898acff55bd1502b Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 26 May 2020 15:02:51 +0400 Subject: [PATCH 124/401] Add OpenAPI spec for AdminAPI.InviteTokenController --- .../controllers/invite_token_controller.ex | 25 +- .../admin/invite_token_operation.ex | 241 ++++++++---------- .../invite_token_controller_test.exs | 84 ++++-- 3 files changed, 179 insertions(+), 171 deletions(-) diff --git a/lib/pleroma/web/admin_api/controllers/invite_token_controller.ex b/lib/pleroma/web/admin_api/controllers/invite_token_controller.ex index a0291e9c3..a09966e5c 100644 --- a/lib/pleroma/web/admin_api/controllers/invite_token_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/invite_token_controller.ex @@ -14,6 +14,7 @@ defmodule Pleroma.Web.AdminAPI.InviteTokenController do require Logger + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(OAuthScopesPlug, %{scopes: ["read:invites"], admin: true} when action == :index) plug( @@ -23,6 +24,8 @@ defmodule Pleroma.Web.AdminAPI.InviteTokenController do action_fallback(Pleroma.Web.AdminAPI.FallbackController) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.InviteTokenOperation + @doc "Get list of created invites" def index(conn, _params) do invites = UserInviteToken.list_invites() @@ -33,26 +36,14 @@ def index(conn, _params) do end @doc "Create an account registration invite token" - def create(conn, params) do - opts = %{} - - 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) + def create(%{body_params: params} = conn, _) do + {:ok, invite} = UserInviteToken.create_invite(params) json(conn, AccountView.render("invite.json", %{invite: invite})) end @doc "Revokes invite by token" - def revoke(conn, %{"token" => token}) do + def revoke(%{body_params: %{token: token}} = conn, _) do with {:ok, invite} <- UserInviteToken.find_by_token(token), {:ok, updated_invite} = UserInviteToken.update_invite(invite, %{used: true}) do conn @@ -64,7 +55,7 @@ def revoke(conn, %{"token" => token}) do end @doc "Sends registration invite via email" - def email(%{assigns: %{user: user}} = conn, %{"email" => email} = params) do + def email(%{assigns: %{user: user}, body_params: %{email: email} = params} = conn, _) do with {_, false} <- {:registrations_open, Config.get([:instance, :registrations_open])}, {_, true} <- {:invites_enabled, Config.get([:instance, :invites_enabled])}, {:ok, invite_token} <- UserInviteToken.create_invite(), @@ -73,7 +64,7 @@ def email(%{assigns: %{user: user}} = conn, %{"email" => email} = params) do user, invite_token, email, - params["name"] + params[:name] ), {:ok, _} <- Pleroma.Emails.Mailer.deliver(email) do json_response(conn, :no_content, "") diff --git a/lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex b/lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex index 09a7735d1..0f7403f26 100644 --- a/lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex @@ -5,14 +5,9 @@ defmodule Pleroma.Web.ApiSpec.Admin.InviteTokenOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema - alias Pleroma.Web.ApiSpec.Schemas.Account alias Pleroma.Web.ApiSpec.Schemas.ApiError - alias Pleroma.Web.ApiSpec.Schemas.FlakeID - alias Pleroma.Web.ApiSpec.Schemas.Status - alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope import Pleroma.Web.ApiSpec.Helpers - import Pleroma.Web.ApiSpec.StatusOperation, only: [id_param: 0] def open_api_operation(action) do operation = String.to_existing_atom("#{action}_operation") @@ -21,144 +16,132 @@ def open_api_operation(action) do def index_operation do %Operation{ - tags: ["Admin", "Statuses"], - operationId: "AdminAPI.StatusController.index", - security: [%{"oAuth" => ["read:statuses"]}], - parameters: [ - Operation.parameter( - :godmode, - :query, - %Schema{type: :boolean, default: false}, - "Allows to see private statuses" - ), - Operation.parameter( - :local_only, - :query, - %Schema{type: :boolean, default: false}, - "Excludes remote statuses" - ), - Operation.parameter( - :with_reblogs, - :query, - %Schema{type: :boolean, default: false}, - "Allows to see reblogs" - ), - Operation.parameter( - :page, - :query, - %Schema{type: :integer, default: 1}, - "Page" - ), - Operation.parameter( - :page_size, - :query, - %Schema{type: :integer, default: 50}, - "Number of statuses to return" - ) - ], + tags: ["Admin", "Invites"], + summary: "Get a list of generated invites", + operationId: "AdminAPI.InviteTokenController.index", + security: [%{"oAuth" => ["read:invites"]}], responses: %{ 200 => - Operation.response("Array of statuses", "application/json", %Schema{ - type: :array, - items: status() + Operation.response("Intites", "application/json", %Schema{ + type: :object, + properties: %{ + invites: %Schema{type: :array, items: invite()} + }, + example: %{ + "invites" => [ + %{ + "id" => 123, + "token" => "kSQtDj_GNy2NZsL9AQDFIsHN5qdbguB6qRg3WHw6K1U=", + "used" => true, + "expires_at" => nil, + "uses" => 0, + "max_use" => nil, + "invite_type" => "one_time" + } + ] + } }) } } end - def show_operation do + def create_operation do %Operation{ - tags: ["Admin", "Statuses"], - summary: "Show Status", - operationId: "AdminAPI.StatusController.show", - parameters: [id_param()], - security: [%{"oAuth" => ["read:statuses"]}], - responses: %{ - 200 => Operation.response("Status", "application/json", Status), - 404 => Operation.response("Not Found", "application/json", ApiError) - } - } - end - - def update_operation do - %Operation{ - tags: ["Admin", "Statuses"], - summary: "Change the scope of an individual reported status", - operationId: "AdminAPI.StatusController.update", - parameters: [id_param()], - security: [%{"oAuth" => ["write:statuses"]}], - requestBody: request_body("Parameters", update_request(), required: true), - responses: %{ - 200 => Operation.response("Status", "application/json", Status), - 400 => Operation.response("Error", "application/json", ApiError) - } - } - end - - def delete_operation do - %Operation{ - tags: ["Admin", "Statuses"], - summary: "Delete an individual reported status", - operationId: "AdminAPI.StatusController.delete", - parameters: [id_param()], - security: [%{"oAuth" => ["write:statuses"]}], - responses: %{ - 200 => empty_object_response(), - 404 => Operation.response("Not Found", "application/json", ApiError) - } - } - end - - defp status do - %Schema{ - anyOf: [ - Status, - %Schema{ + tags: ["Admin", "Invites"], + summary: "Create an account registration invite token", + operationId: "AdminAPI.InviteTokenController.create", + security: [%{"oAuth" => ["write:invites"]}], + requestBody: + request_body("Parameters", %Schema{ type: :object, properties: %{ - account: %Schema{allOf: [Account, admin_account()]} + max_use: %Schema{type: :integer}, + expires_at: %Schema{type: :string, format: :date, example: "2020-04-20"} } + }), + responses: %{ + 200 => Operation.response("Invite", "application/json", invite()) + } + } + end + + def revoke_operation do + %Operation{ + tags: ["Admin", "Invites"], + summary: "Revoke invite by token", + operationId: "AdminAPI.InviteTokenController.revoke", + security: [%{"oAuth" => ["write:invites"]}], + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + required: [:token], + properties: %{ + token: %Schema{type: :string} + } + }, + required: true + ), + responses: %{ + 200 => Operation.response("Invite", "application/json", invite()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def email_operation do + %Operation{ + tags: ["Admin", "Invites"], + summary: "Sends registration invite via email", + operationId: "AdminAPI.InviteTokenController.email", + security: [%{"oAuth" => ["write:invites"]}], + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + required: [:email], + properties: %{ + email: %Schema{type: :string, format: :email}, + name: %Schema{type: :string} + } + }, + required: true + ), + responses: %{ + 204 => no_content_response(), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + defp invite do + %Schema{ + title: "Invite", + type: :object, + properties: %{ + id: %Schema{type: :integer}, + token: %Schema{type: :string}, + used: %Schema{type: :boolean}, + expires_at: %Schema{type: :string, format: :date, nullable: true}, + uses: %Schema{type: :integer}, + max_use: %Schema{type: :integer, nullable: true}, + invite_type: %Schema{ + type: :string, + enum: ["one_time", "reusable", "date_limited", "reusable_date_limited"] } - ] - } - end - - defp admin_account do - %Schema{ - type: :object, - properties: %{ - id: FlakeID, - avatar: %Schema{type: :string}, - nickname: %Schema{type: :string}, - display_name: %Schema{type: :string}, - deactivated: %Schema{type: :boolean}, - local: %Schema{type: :boolean}, - roles: %Schema{ - type: :object, - properties: %{ - admin: %Schema{type: :boolean}, - moderator: %Schema{type: :boolean} - } - }, - tags: %Schema{type: :string}, - confirmation_pending: %Schema{type: :string} - } - } - end - - defp update_request do - %Schema{ - type: :object, - properties: %{ - sensitive: %Schema{ - type: :boolean, - description: "Mark status and attached media as sensitive?" - }, - visibility: VisibilityScope }, example: %{ - "visibility" => "private", - "sensitive" => "false" + "id" => 123, + "token" => "kSQtDj_GNy2NZsL9AQDFIsHN5qdbguB6qRg3WHw6K1U=", + "used" => true, + "expires_at" => nil, + "uses" => 0, + "max_use" => nil, + "invite_type" => "one_time" } } end diff --git a/test/web/admin_api/controllers/invite_token_controller_test.exs b/test/web/admin_api/controllers/invite_token_controller_test.exs index eb57b4d44..cb486f4d1 100644 --- a/test/web/admin_api/controllers/invite_token_controller_test.exs +++ b/test/web/admin_api/controllers/invite_token_controller_test.exs @@ -32,12 +32,14 @@ test "sends invitation and returns 204", %{admin: admin, conn: conn} do recipient_name = "J. D." conn = - post( - conn, - "/api/pleroma/admin/users/email_invite?email=#{recipient_email}&name=#{recipient_name}" - ) + conn + |> put_req_header("content-type", "application/json;charset=utf-8") + |> post("/api/pleroma/admin/users/email_invite", %{ + email: recipient_email, + name: recipient_name + }) - assert json_response(conn, :no_content) + assert json_response_and_validate_schema(conn, :no_content) token_record = List.last(Repo.all(Pleroma.UserInviteToken)) assert token_record @@ -69,7 +71,11 @@ test "it returns 403 if requested by a non-admin" do build_conn() |> assign(:user, non_admin_user) |> assign(:token, token) - |> post("/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") + |> put_req_header("content-type", "application/json;charset=utf-8") + |> post("/api/pleroma/admin/users/email_invite", %{ + email: "foo@bar.com", + name: "JD" + }) assert json_response(conn, :forbidden) end @@ -80,7 +86,7 @@ test "email with +", %{conn: conn, admin: admin} do conn |> put_req_header("content-type", "application/json;charset=utf-8") |> post("/api/pleroma/admin/users/email_invite", %{email: recipient_email}) - |> json_response(:no_content) + |> json_response_and_validate_schema(:no_content) token_record = Pleroma.UserInviteToken @@ -116,9 +122,15 @@ test "it returns 500 if `invites_enabled` is not enabled", %{conn: conn} do Config.put([:instance, :registrations_open], false) Config.put([:instance, :invites_enabled], false) - conn = post(conn, "/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/email_invite", %{ + email: "foo@bar.com", + name: "JD" + }) - assert json_response(conn, :bad_request) == + assert json_response_and_validate_schema(conn, :bad_request) == %{ "error" => "To send invites you need to set the `invites_enabled` option to true." @@ -129,9 +141,15 @@ test "it returns 500 if `registrations_open` is enabled", %{conn: conn} do Config.put([:instance, :registrations_open], true) Config.put([:instance, :invites_enabled], true) - conn = post(conn, "/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/email_invite", %{ + email: "foo@bar.com", + name: "JD" + }) - assert json_response(conn, :bad_request) == + assert json_response_and_validate_schema(conn, :bad_request) == %{ "error" => "To send invites you need to set the `registrations_open` option to false." @@ -141,9 +159,12 @@ test "it returns 500 if `registrations_open` is enabled", %{conn: conn} do describe "POST /api/pleroma/admin/users/invite_token" do test "without options", %{conn: conn} do - conn = post(conn, "/api/pleroma/admin/users/invite_token") + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/invite_token") - invite_json = json_response(conn, 200) + invite_json = json_response_and_validate_schema(conn, 200) invite = UserInviteToken.find_by_token!(invite_json["token"]) refute invite.used refute invite.expires_at @@ -153,11 +174,13 @@ test "without options", %{conn: conn} do test "with expires_at", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/users/invite_token", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/invite_token", %{ "expires_at" => Date.to_string(Date.utc_today()) }) - invite_json = json_response(conn, 200) + invite_json = json_response_and_validate_schema(conn, 200) invite = UserInviteToken.find_by_token!(invite_json["token"]) refute invite.used @@ -167,9 +190,12 @@ test "with expires_at", %{conn: conn} do end test "with max_use", %{conn: conn} do - conn = post(conn, "/api/pleroma/admin/users/invite_token", %{"max_use" => 150}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/invite_token", %{"max_use" => 150}) - invite_json = json_response(conn, 200) + invite_json = json_response_and_validate_schema(conn, 200) invite = UserInviteToken.find_by_token!(invite_json["token"]) refute invite.used refute invite.expires_at @@ -179,12 +205,14 @@ test "with max_use", %{conn: conn} do test "with max use and expires_at", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/users/invite_token", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/invite_token", %{ "max_use" => 150, "expires_at" => Date.to_string(Date.utc_today()) }) - invite_json = json_response(conn, 200) + invite_json = json_response_and_validate_schema(conn, 200) invite = UserInviteToken.find_by_token!(invite_json["token"]) refute invite.used assert invite.expires_at == Date.utc_today() @@ -197,7 +225,7 @@ test "with max use and expires_at", %{conn: conn} do test "no invites", %{conn: conn} do conn = get(conn, "/api/pleroma/admin/users/invites") - assert json_response(conn, 200) == %{"invites" => []} + assert json_response_and_validate_schema(conn, 200) == %{"invites" => []} end test "with invite", %{conn: conn} do @@ -205,7 +233,7 @@ test "with invite", %{conn: conn} do conn = get(conn, "/api/pleroma/admin/users/invites") - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "invites" => [ %{ "expires_at" => nil, @@ -225,9 +253,12 @@ test "with invite", %{conn: conn} do test "with token", %{conn: conn} do {:ok, invite} = UserInviteToken.create_invite() - conn = post(conn, "/api/pleroma/admin/users/revoke_invite", %{"token" => invite.token}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/revoke_invite", %{"token" => invite.token}) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "expires_at" => nil, "id" => invite.id, "invite_type" => "one_time", @@ -239,9 +270,12 @@ test "with token", %{conn: conn} do end test "with invalid token", %{conn: conn} do - conn = post(conn, "/api/pleroma/admin/users/revoke_invite", %{"token" => "foo"}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/revoke_invite", %{"token" => "foo"}) - assert json_response(conn, :not_found) == %{"error" => "Not found"} + assert json_response_and_validate_schema(conn, :not_found) == %{"error" => "Not found"} end end end From fca48154a23c0b38d514b2bc4d49a74274e02a8f Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 26 May 2020 15:21:33 +0400 Subject: [PATCH 125/401] Add AdminAPI.InviteView --- ...ken_controller.ex => invite_controller.ex} | 29 +++++++++---------- .../web/admin_api/views/account_view.ex | 18 ------------ .../web/admin_api/views/invite_view.ex | 25 ++++++++++++++++ ...token_operation.ex => invite_operation.ex} | 10 +++---- lib/pleroma/web/router.ex | 8 ++--- ...er_test.exs => invite_controller_test.exs} | 2 +- 6 files changed, 49 insertions(+), 43 deletions(-) rename lib/pleroma/web/admin_api/controllers/{invite_token_controller.ex => invite_controller.ex} (79%) create mode 100644 lib/pleroma/web/admin_api/views/invite_view.ex rename lib/pleroma/web/api_spec/operations/admin/{invite_token_operation.ex => invite_operation.ex} (93%) rename test/web/admin_api/controllers/{invite_token_controller_test.exs => invite_controller_test.exs} (99%) diff --git a/lib/pleroma/web/admin_api/controllers/invite_token_controller.ex b/lib/pleroma/web/admin_api/controllers/invite_controller.ex similarity index 79% rename from lib/pleroma/web/admin_api/controllers/invite_token_controller.ex rename to lib/pleroma/web/admin_api/controllers/invite_controller.ex index a09966e5c..7d169b8d2 100644 --- a/lib/pleroma/web/admin_api/controllers/invite_token_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/invite_controller.ex @@ -2,7 +2,7 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.AdminAPI.InviteTokenController do +defmodule Pleroma.Web.AdminAPI.InviteController do use Pleroma.Web, :controller import Pleroma.Web.ControllerHelper, only: [json_response: 3] @@ -10,7 +10,6 @@ defmodule Pleroma.Web.AdminAPI.InviteTokenController do alias Pleroma.Config alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.UserInviteToken - alias Pleroma.Web.AdminAPI.AccountView require Logger @@ -24,33 +23,30 @@ defmodule Pleroma.Web.AdminAPI.InviteTokenController do action_fallback(Pleroma.Web.AdminAPI.FallbackController) - defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.InviteTokenOperation + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.InviteOperation @doc "Get list of created invites" def index(conn, _params) do invites = UserInviteToken.list_invites() - conn - |> put_view(AccountView) - |> render("invites.json", %{invites: invites}) + render(conn, "index.json", invites: invites) end @doc "Create an account registration invite token" def create(%{body_params: params} = conn, _) do {:ok, invite} = UserInviteToken.create_invite(params) - json(conn, AccountView.render("invite.json", %{invite: invite})) + render(conn, "show.json", invite: invite) end @doc "Revokes invite by token" def revoke(%{body_params: %{token: token}} = conn, _) do with {:ok, invite} <- UserInviteToken.find_by_token(token), {:ok, updated_invite} = UserInviteToken.update_invite(invite, %{used: true}) do - conn - |> put_view(AccountView) - |> render("invite.json", %{invite: updated_invite}) + render(conn, "show.json", invite: updated_invite) else nil -> {:error, :not_found} + error -> error end end @@ -59,14 +55,14 @@ def email(%{assigns: %{user: user}, body_params: %{email: email} = params} = con with {_, false} <- {:registrations_open, Config.get([:instance, :registrations_open])}, {_, true} <- {:invites_enabled, Config.get([:instance, :invites_enabled])}, {:ok, invite_token} <- UserInviteToken.create_invite(), - email <- - Pleroma.Emails.UserEmail.user_invitation_email( - user, + {:ok, _} <- + user + |> Pleroma.Emails.UserEmail.user_invitation_email( invite_token, email, params[:name] - ), - {:ok, _} <- Pleroma.Emails.Mailer.deliver(email) do + ) + |> Pleroma.Emails.Mailer.deliver() do json_response(conn, :no_content, "") else {:registrations_open, _} -> @@ -74,6 +70,9 @@ def email(%{assigns: %{user: user}, body_params: %{email: email} = params} = con {:invites_enabled, _} -> {:error, "To send invites you need to set the `invites_enabled` option to true."} + + {:error, error} -> + {:error, error} end end end diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index 46dadb5ee..120159527 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -80,24 +80,6 @@ def render("show.json", %{user: user}) do } end - def render("invite.json", %{invite: invite}) do - %{ - "id" => invite.id, - "token" => invite.token, - "used" => invite.used, - "expires_at" => invite.expires_at, - "uses" => invite.uses, - "max_use" => invite.max_use, - "invite_type" => invite.invite_type - } - end - - def render("invites.json", %{invites: invites}) do - %{ - invites: render_many(invites, AccountView, "invite.json", as: :invite) - } - end - def render("created.json", %{user: user}) do %{ type: "success", diff --git a/lib/pleroma/web/admin_api/views/invite_view.ex b/lib/pleroma/web/admin_api/views/invite_view.ex new file mode 100644 index 000000000..f93cb6916 --- /dev/null +++ b/lib/pleroma/web/admin_api/views/invite_view.ex @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.InviteView do + use Pleroma.Web, :view + + def render("index.json", %{invites: invites}) do + %{ + invites: render_many(invites, __MODULE__, "show.json", as: :invite) + } + end + + def render("show.json", %{invite: invite}) do + %{ + "id" => invite.id, + "token" => invite.token, + "used" => invite.used, + "expires_at" => invite.expires_at, + "uses" => invite.uses, + "max_use" => invite.max_use, + "invite_type" => invite.invite_type + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex b/lib/pleroma/web/api_spec/operations/admin/invite_operation.ex similarity index 93% rename from lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex rename to lib/pleroma/web/api_spec/operations/admin/invite_operation.ex index 0f7403f26..4ae44fff6 100644 --- a/lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/invite_operation.ex @@ -2,7 +2,7 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.ApiSpec.Admin.InviteTokenOperation do +defmodule Pleroma.Web.ApiSpec.Admin.InviteOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Schemas.ApiError @@ -18,7 +18,7 @@ def index_operation do %Operation{ tags: ["Admin", "Invites"], summary: "Get a list of generated invites", - operationId: "AdminAPI.InviteTokenController.index", + operationId: "AdminAPI.InviteController.index", security: [%{"oAuth" => ["read:invites"]}], responses: %{ 200 => @@ -49,7 +49,7 @@ def create_operation do %Operation{ tags: ["Admin", "Invites"], summary: "Create an account registration invite token", - operationId: "AdminAPI.InviteTokenController.create", + operationId: "AdminAPI.InviteController.create", security: [%{"oAuth" => ["write:invites"]}], requestBody: request_body("Parameters", %Schema{ @@ -69,7 +69,7 @@ def revoke_operation do %Operation{ tags: ["Admin", "Invites"], summary: "Revoke invite by token", - operationId: "AdminAPI.InviteTokenController.revoke", + operationId: "AdminAPI.InviteController.revoke", security: [%{"oAuth" => ["write:invites"]}], requestBody: request_body( @@ -95,7 +95,7 @@ def email_operation do %Operation{ tags: ["Admin", "Invites"], summary: "Sends registration invite via email", - operationId: "AdminAPI.InviteTokenController.email", + operationId: "AdminAPI.InviteController.email", security: [%{"oAuth" => ["write:invites"]}], requestBody: request_body( diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index fe36f0189..9b7c7ee3d 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -164,10 +164,10 @@ defmodule Pleroma.Web.Router do post("/relay", AdminAPIController, :relay_follow) delete("/relay", AdminAPIController, :relay_unfollow) - post("/users/invite_token", InviteTokenController, :create) - get("/users/invites", InviteTokenController, :index) - post("/users/revoke_invite", InviteTokenController, :revoke) - post("/users/email_invite", InviteTokenController, :email) + post("/users/invite_token", InviteController, :create) + get("/users/invites", InviteController, :index) + post("/users/revoke_invite", InviteController, :revoke) + post("/users/email_invite", InviteController, :email) get("/users/:nickname/password_reset", AdminAPIController, :get_password_reset) patch("/users/force_password_reset", AdminAPIController, :force_password_reset) diff --git a/test/web/admin_api/controllers/invite_token_controller_test.exs b/test/web/admin_api/controllers/invite_controller_test.exs similarity index 99% rename from test/web/admin_api/controllers/invite_token_controller_test.exs rename to test/web/admin_api/controllers/invite_controller_test.exs index cb486f4d1..ab186c5e7 100644 --- a/test/web/admin_api/controllers/invite_token_controller_test.exs +++ b/test/web/admin_api/controllers/invite_controller_test.exs @@ -2,7 +2,7 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.AdminAPI.InviteTokenControllerTest do +defmodule Pleroma.Web.AdminAPI.InviteControllerTest do use Pleroma.Web.ConnCase, async: true import Pleroma.Factory From c6290be682bd12b1772153d421f36e5ddb9d664b Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 27 May 2020 14:42:21 +0400 Subject: [PATCH 126/401] Fix typo --- lib/pleroma/web/api_spec/operations/admin/invite_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/admin/invite_operation.ex b/lib/pleroma/web/api_spec/operations/admin/invite_operation.ex index 4ae44fff6..d3af9db49 100644 --- a/lib/pleroma/web/api_spec/operations/admin/invite_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/invite_operation.ex @@ -22,7 +22,7 @@ def index_operation do security: [%{"oAuth" => ["read:invites"]}], responses: %{ 200 => - Operation.response("Intites", "application/json", %Schema{ + Operation.response("Invites", "application/json", %Schema{ type: :object, properties: %{ invites: %Schema{type: :array, items: invite()} From d4a18d44feb4ae67f6476b30fac96c0e6aa511dd Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 28 May 2020 00:49:49 -0500 Subject: [PATCH 127/401] Update default instance description --- config/config.exs | 2 +- lib/pleroma/web/api_spec/operations/instance_operation.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/config.exs b/config/config.exs index d15998715..3729526ea 100644 --- a/config/config.exs +++ b/config/config.exs @@ -183,7 +183,7 @@ name: "Pleroma", email: "example@example.com", notify_email: "noreply@example.com", - description: "A Pleroma instance, an alternative fediverse server", + description: "Pleroma: An efficient and flexible fediverse server", background_image: "/images/city.jpg", limit: 5_000, chat_limit: 5_000, diff --git a/lib/pleroma/web/api_spec/operations/instance_operation.ex b/lib/pleroma/web/api_spec/operations/instance_operation.ex index d5c335d0c..bf39ae643 100644 --- a/lib/pleroma/web/api_spec/operations/instance_operation.ex +++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex @@ -137,7 +137,7 @@ defp instance do "background_upload_limit" => 4_000_000, "background_image" => "/static/image.png", "banner_upload_limit" => 4_000_000, - "description" => "A Pleroma instance, an alternative fediverse server", + "description" => "Pleroma: An efficient and flexible fediverse server", "email" => "lain@lain.com", "languages" => ["en"], "max_toot_chars" => 5000, From d1ee3527ef8062c34e222a1c7084c207b80fe4db Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 28 May 2020 22:23:15 +0400 Subject: [PATCH 128/401] Move config actions to AdminAPI.ConfigController --- .../controllers/admin_api_controller.ex | 127 -- .../controllers/config_controller.ex | 150 ++ lib/pleroma/web/router.ex | 6 +- .../controllers/admin_api_controller_test.exs | 1220 ---------------- .../controllers/config_controller_test.exs | 1244 +++++++++++++++++ 5 files changed, 1397 insertions(+), 1350 deletions(-) create mode 100644 lib/pleroma/web/admin_api/controllers/config_controller.ex create mode 100644 test/web/admin_api/controllers/config_controller_test.exs diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index 783203c07..52900026f 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -9,7 +9,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Activity alias Pleroma.Config - alias Pleroma.ConfigDB alias Pleroma.MFA alias Pleroma.ModerationLog alias Pleroma.Plugs.OAuthScopesPlug @@ -24,7 +23,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI.AccountView - alias Pleroma.Web.AdminAPI.ConfigView alias Pleroma.Web.AdminAPI.ModerationLogView alias Pleroma.Web.AdminAPI.Report alias Pleroma.Web.AdminAPI.ReportView @@ -38,7 +36,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do require Logger - @descriptions Pleroma.Docs.JSON.compile() @users_page_size 50 plug( @@ -105,11 +102,9 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do OAuthScopesPlug, %{scopes: ["read"], admin: true} when action in [ - :config_show, :list_log, :stats, :relay_list, - :config_descriptions, :need_reboot ] ) @@ -119,7 +114,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do %{scopes: ["write"], admin: true} when action in [ :restart, - :config_update, :resend_confirmation_email, :confirm_email, :oauth_app_create, @@ -821,105 +815,6 @@ def list_log(conn, params) do |> render("index.json", %{log: log}) end - def config_descriptions(conn, _params) do - descriptions = Enum.filter(@descriptions, &whitelisted_config?/1) - - json(conn, descriptions) - end - - def config_show(conn, %{"only_db" => true}) do - with :ok <- configurable_from_database() do - configs = Pleroma.Repo.all(ConfigDB) - - conn - |> put_view(ConfigView) - |> render("index.json", %{configs: configs}) - end - end - - def config_show(conn, _params) do - with :ok <- configurable_from_database() do - configs = ConfigDB.get_all_as_keyword() - - merged = - Config.Holder.default_config() - |> ConfigDB.merge(configs) - |> Enum.map(fn {group, values} -> - Enum.map(values, fn {key, value} -> - db = - if configs[group][key] do - ConfigDB.get_db_keys(configs[group][key], key) - end - - db_value = configs[group][key] - - merged_value = - if !is_nil(db_value) and Keyword.keyword?(db_value) and - ConfigDB.sub_key_full_update?(group, key, Keyword.keys(db_value)) do - ConfigDB.merge_group(group, key, value, db_value) - else - value - end - - setting = %{ - group: ConfigDB.convert(group), - key: ConfigDB.convert(key), - value: ConfigDB.convert(merged_value) - } - - if db, do: Map.put(setting, :db, db), else: setting - end) - end) - |> List.flatten() - - json(conn, %{configs: merged, need_reboot: Restarter.Pleroma.need_reboot?()}) - end - end - - def config_update(conn, %{"configs" => configs}) do - with :ok <- configurable_from_database() do - {_errors, results} = - configs - |> Enum.filter(&whitelisted_config?/1) - |> Enum.map(fn - %{"group" => group, "key" => key, "delete" => true} = params -> - ConfigDB.delete(%{group: group, key: key, subkeys: params["subkeys"]}) - - %{"group" => group, "key" => key, "value" => value} -> - ConfigDB.update_or_create(%{group: group, key: key, value: value}) - end) - |> Enum.split_with(fn result -> elem(result, 0) == :error end) - - {deleted, updated} = - results - |> Enum.map(fn {:ok, config} -> - Map.put(config, :db, ConfigDB.get_db_keys(config)) - end) - |> Enum.split_with(fn config -> - Ecto.get_meta(config, :state) == :deleted - end) - - Config.TransferTask.load_and_update_env(deleted, false) - - if !Restarter.Pleroma.need_reboot?() do - changed_reboot_settings? = - (updated ++ deleted) - |> Enum.any?(fn config -> - group = ConfigDB.from_string(config.group) - key = ConfigDB.from_string(config.key) - value = ConfigDB.from_binary(config.value) - Config.TransferTask.pleroma_need_restart?(group, key, value) - end) - - if changed_reboot_settings?, do: Restarter.Pleroma.need_reboot() - end - - conn - |> put_view(ConfigView) - |> render("index.json", %{configs: updated, need_reboot: Restarter.Pleroma.need_reboot?()}) - end - end - def restart(conn, _params) do with :ok <- configurable_from_database() do Restarter.Pleroma.restart(Config.get(:env), 50) @@ -940,28 +835,6 @@ defp configurable_from_database do end end - defp whitelisted_config?(group, key) do - if whitelisted_configs = Config.get(:database_config_whitelist) do - Enum.any?(whitelisted_configs, fn - {whitelisted_group} -> - group == inspect(whitelisted_group) - - {whitelisted_group, whitelisted_key} -> - group == inspect(whitelisted_group) && key == inspect(whitelisted_key) - end) - else - true - end - end - - defp whitelisted_config?(%{"group" => group, "key" => key}) do - whitelisted_config?(group, key) - end - - defp whitelisted_config?(%{:group => group} = config) do - whitelisted_config?(group, config[:key]) - end - def reload_emoji(conn, _params) do Pleroma.Emoji.reload() diff --git a/lib/pleroma/web/admin_api/controllers/config_controller.ex b/lib/pleroma/web/admin_api/controllers/config_controller.ex new file mode 100644 index 000000000..742980976 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/config_controller.ex @@ -0,0 +1,150 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.ConfigController do + use Pleroma.Web, :controller + + alias Pleroma.Config + alias Pleroma.ConfigDB + alias Pleroma.Plugs.OAuthScopesPlug + + @descriptions Pleroma.Docs.JSON.compile() + + plug( + OAuthScopesPlug, + %{scopes: ["read"], admin: true} + when action in [:show, :descriptions] + ) + + plug(OAuthScopesPlug, %{scopes: ["write"], admin: true} when action == :update) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + def descriptions(conn, _params) do + descriptions = Enum.filter(@descriptions, &whitelisted_config?/1) + + json(conn, descriptions) + end + + def show(conn, %{"only_db" => true}) do + with :ok <- configurable_from_database() do + configs = Pleroma.Repo.all(ConfigDB) + render(conn, "index.json", %{configs: configs}) + end + end + + def show(conn, _params) do + with :ok <- configurable_from_database() do + configs = ConfigDB.get_all_as_keyword() + + merged = + Config.Holder.default_config() + |> ConfigDB.merge(configs) + |> Enum.map(fn {group, values} -> + Enum.map(values, fn {key, value} -> + db = + if configs[group][key] do + ConfigDB.get_db_keys(configs[group][key], key) + end + + db_value = configs[group][key] + + merged_value = + if not is_nil(db_value) and Keyword.keyword?(db_value) and + ConfigDB.sub_key_full_update?(group, key, Keyword.keys(db_value)) do + ConfigDB.merge_group(group, key, value, db_value) + else + value + end + + setting = %{ + group: ConfigDB.convert(group), + key: ConfigDB.convert(key), + value: ConfigDB.convert(merged_value) + } + + if db, do: Map.put(setting, :db, db), else: setting + end) + end) + |> List.flatten() + + json(conn, %{configs: merged, need_reboot: Restarter.Pleroma.need_reboot?()}) + end + end + + def update(conn, %{"configs" => configs}) do + with :ok <- configurable_from_database() do + results = + configs + |> Enum.filter(&whitelisted_config?/1) + |> Enum.map(fn + %{"group" => group, "key" => key, "delete" => true} = params -> + ConfigDB.delete(%{group: group, key: key, subkeys: params["subkeys"]}) + + %{"group" => group, "key" => key, "value" => value} -> + ConfigDB.update_or_create(%{group: group, key: key, value: value}) + end) + |> Enum.reject(fn {result, _} -> result == :error end) + + {deleted, updated} = + results + |> Enum.map(fn {:ok, config} -> + Map.put(config, :db, ConfigDB.get_db_keys(config)) + end) + |> Enum.split_with(fn config -> + Ecto.get_meta(config, :state) == :deleted + end) + + Config.TransferTask.load_and_update_env(deleted, false) + + if not Restarter.Pleroma.need_reboot?() do + changed_reboot_settings? = + (updated ++ deleted) + |> Enum.any?(fn config -> + group = ConfigDB.from_string(config.group) + key = ConfigDB.from_string(config.key) + value = ConfigDB.from_binary(config.value) + Config.TransferTask.pleroma_need_restart?(group, key, value) + end) + + if changed_reboot_settings?, do: Restarter.Pleroma.need_reboot() + end + + render(conn, "index.json", %{ + configs: updated, + need_reboot: Restarter.Pleroma.need_reboot?() + }) + end + end + + defp configurable_from_database do + if Config.get(:configurable_from_database) do + :ok + else + {:error, "To use this endpoint you need to enable configuration from database."} + end + end + + defp whitelisted_config?(group, key) do + if whitelisted_configs = Config.get(:database_config_whitelist) do + Enum.any?(whitelisted_configs, fn + {whitelisted_group} -> + group == inspect(whitelisted_group) + + {whitelisted_group, whitelisted_key} -> + group == inspect(whitelisted_group) && key == inspect(whitelisted_key) + end) + else + true + end + end + + defp whitelisted_config?(%{"group" => group, "key" => key}) do + whitelisted_config?(group, key) + end + + defp whitelisted_config?(%{:group => group} = config) do + whitelisted_config?(group, config[:key]) + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index e493a4153..b683a4ff3 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -194,9 +194,9 @@ defmodule Pleroma.Web.Router do delete("/statuses/:id", StatusController, :delete) get("/statuses", StatusController, :index) - get("/config", AdminAPIController, :config_show) - post("/config", AdminAPIController, :config_update) - get("/config/descriptions", AdminAPIController, :config_descriptions) + get("/config", ConfigController, :show) + post("/config", ConfigController, :update) + get("/config/descriptions", ConfigController, :descriptions) get("/need_reboot", AdminAPIController, :need_reboot) get("/restart", AdminAPIController, :restart) diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index ead840186..bd44ffed3 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -12,7 +12,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do alias Pleroma.Activity alias Pleroma.Config - alias Pleroma.ConfigDB alias Pleroma.HTML alias Pleroma.MFA alias Pleroma.ModerationLog @@ -1704,1175 +1703,6 @@ test "returns 403 when requested by anonymous" do end end - describe "GET /api/pleroma/admin/config" do - setup do: clear_config(:configurable_from_database, true) - - test "when configuration from database is off", %{conn: conn} do - Config.put(:configurable_from_database, false) - conn = get(conn, "/api/pleroma/admin/config") - - assert json_response(conn, 400) == - %{ - "error" => "To use this endpoint you need to enable configuration from database." - } - end - - test "with settings only in db", %{conn: conn} do - config1 = insert(:config) - config2 = insert(:config) - - conn = get(conn, "/api/pleroma/admin/config", %{"only_db" => true}) - - %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => key1, - "value" => _ - }, - %{ - "group" => ":pleroma", - "key" => key2, - "value" => _ - } - ] - } = json_response(conn, 200) - - assert key1 == config1.key - assert key2 == config2.key - end - - test "db is added to settings that are in db", %{conn: conn} do - _config = insert(:config, key: ":instance", value: ConfigDB.to_binary(name: "Some name")) - - %{"configs" => configs} = - conn - |> get("/api/pleroma/admin/config") - |> json_response(200) - - [instance_config] = - Enum.filter(configs, fn %{"group" => group, "key" => key} -> - group == ":pleroma" and key == ":instance" - end) - - assert instance_config["db"] == [":name"] - end - - test "merged default setting with db settings", %{conn: conn} do - config1 = insert(:config) - config2 = insert(:config) - - config3 = - insert(:config, - value: ConfigDB.to_binary(k1: :v1, k2: :v2) - ) - - %{"configs" => configs} = - conn - |> get("/api/pleroma/admin/config") - |> json_response(200) - - assert length(configs) > 3 - - received_configs = - Enum.filter(configs, fn %{"group" => group, "key" => key} -> - group == ":pleroma" and key in [config1.key, config2.key, config3.key] - end) - - assert length(received_configs) == 3 - - db_keys = - config3.value - |> ConfigDB.from_binary() - |> Keyword.keys() - |> ConfigDB.convert() - - Enum.each(received_configs, fn %{"value" => value, "db" => db} -> - assert db in [[config1.key], [config2.key], db_keys] - - assert value in [ - ConfigDB.from_binary_with_convert(config1.value), - ConfigDB.from_binary_with_convert(config2.value), - ConfigDB.from_binary_with_convert(config3.value) - ] - end) - end - - test "subkeys with full update right merge", %{conn: conn} do - config1 = - insert(:config, - key: ":emoji", - value: ConfigDB.to_binary(groups: [a: 1, b: 2], key: [a: 1]) - ) - - config2 = - insert(:config, - key: ":assets", - value: ConfigDB.to_binary(mascots: [a: 1, b: 2], key: [a: 1]) - ) - - %{"configs" => configs} = - conn - |> get("/api/pleroma/admin/config") - |> json_response(200) - - vals = - Enum.filter(configs, fn %{"group" => group, "key" => key} -> - group == ":pleroma" and key in [config1.key, config2.key] - end) - - emoji = Enum.find(vals, fn %{"key" => key} -> key == ":emoji" end) - assets = Enum.find(vals, fn %{"key" => key} -> key == ":assets" end) - - emoji_val = ConfigDB.transform_with_out_binary(emoji["value"]) - assets_val = ConfigDB.transform_with_out_binary(assets["value"]) - - assert emoji_val[:groups] == [a: 1, b: 2] - assert assets_val[:mascots] == [a: 1, b: 2] - end - end - - test "POST /api/pleroma/admin/config error", %{conn: conn} do - conn = post(conn, "/api/pleroma/admin/config", %{"configs" => []}) - - assert json_response(conn, 400) == - %{"error" => "To use this endpoint you need to enable configuration from database."} - end - - describe "POST /api/pleroma/admin/config" do - setup do - http = Application.get_env(:pleroma, :http) - - on_exit(fn -> - Application.delete_env(:pleroma, :key1) - Application.delete_env(:pleroma, :key2) - Application.delete_env(:pleroma, :key3) - Application.delete_env(:pleroma, :key4) - Application.delete_env(:pleroma, :keyaa1) - Application.delete_env(:pleroma, :keyaa2) - Application.delete_env(:pleroma, Pleroma.Web.Endpoint.NotReal) - Application.delete_env(:pleroma, Pleroma.Captcha.NotReal) - Application.put_env(:pleroma, :http, http) - Application.put_env(:tesla, :adapter, Tesla.Mock) - Restarter.Pleroma.refresh() - end) - end - - setup do: clear_config(:configurable_from_database, true) - - @tag capture_log: true - test "create new config setting in db", %{conn: conn} do - ueberauth = Application.get_env(:ueberauth, Ueberauth) - on_exit(fn -> Application.put_env(:ueberauth, Ueberauth, ueberauth) end) - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{group: ":pleroma", key: ":key1", value: "value1"}, - %{ - group: ":ueberauth", - key: "Ueberauth", - value: [%{"tuple" => [":consumer_secret", "aaaa"]}] - }, - %{ - group: ":pleroma", - key: ":key2", - value: %{ - ":nested_1" => "nested_value1", - ":nested_2" => [ - %{":nested_22" => "nested_value222"}, - %{":nested_33" => %{":nested_44" => "nested_444"}} - ] - } - }, - %{ - group: ":pleroma", - key: ":key3", - value: [ - %{"nested_3" => ":nested_3", "nested_33" => "nested_33"}, - %{"nested_4" => true} - ] - }, - %{ - group: ":pleroma", - key: ":key4", - value: %{":nested_5" => ":upload", "endpoint" => "https://example.com"} - }, - %{ - group: ":idna", - key: ":key5", - value: %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]} - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => "value1", - "db" => [":key1"] - }, - %{ - "group" => ":ueberauth", - "key" => "Ueberauth", - "value" => [%{"tuple" => [":consumer_secret", "aaaa"]}], - "db" => [":consumer_secret"] - }, - %{ - "group" => ":pleroma", - "key" => ":key2", - "value" => %{ - ":nested_1" => "nested_value1", - ":nested_2" => [ - %{":nested_22" => "nested_value222"}, - %{":nested_33" => %{":nested_44" => "nested_444"}} - ] - }, - "db" => [":key2"] - }, - %{ - "group" => ":pleroma", - "key" => ":key3", - "value" => [ - %{"nested_3" => ":nested_3", "nested_33" => "nested_33"}, - %{"nested_4" => true} - ], - "db" => [":key3"] - }, - %{ - "group" => ":pleroma", - "key" => ":key4", - "value" => %{"endpoint" => "https://example.com", ":nested_5" => ":upload"}, - "db" => [":key4"] - }, - %{ - "group" => ":idna", - "key" => ":key5", - "value" => %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]}, - "db" => [":key5"] - } - ] - } - - assert Application.get_env(:pleroma, :key1) == "value1" - - assert Application.get_env(:pleroma, :key2) == %{ - nested_1: "nested_value1", - nested_2: [ - %{nested_22: "nested_value222"}, - %{nested_33: %{nested_44: "nested_444"}} - ] - } - - assert Application.get_env(:pleroma, :key3) == [ - %{"nested_3" => :nested_3, "nested_33" => "nested_33"}, - %{"nested_4" => true} - ] - - assert Application.get_env(:pleroma, :key4) == %{ - "endpoint" => "https://example.com", - nested_5: :upload - } - - assert Application.get_env(:idna, :key5) == {"string", Pleroma.Captcha.NotReal, []} - end - - test "save configs setting without explicit key", %{conn: conn} do - level = Application.get_env(:quack, :level) - meta = Application.get_env(:quack, :meta) - webhook_url = Application.get_env(:quack, :webhook_url) - - on_exit(fn -> - Application.put_env(:quack, :level, level) - Application.put_env(:quack, :meta, meta) - Application.put_env(:quack, :webhook_url, webhook_url) - end) - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - group: ":quack", - key: ":level", - value: ":info" - }, - %{ - group: ":quack", - key: ":meta", - value: [":none"] - }, - %{ - group: ":quack", - key: ":webhook_url", - value: "https://hooks.slack.com/services/KEY" - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":quack", - "key" => ":level", - "value" => ":info", - "db" => [":level"] - }, - %{ - "group" => ":quack", - "key" => ":meta", - "value" => [":none"], - "db" => [":meta"] - }, - %{ - "group" => ":quack", - "key" => ":webhook_url", - "value" => "https://hooks.slack.com/services/KEY", - "db" => [":webhook_url"] - } - ] - } - - assert Application.get_env(:quack, :level) == :info - assert Application.get_env(:quack, :meta) == [:none] - assert Application.get_env(:quack, :webhook_url) == "https://hooks.slack.com/services/KEY" - end - - test "saving config with partial update", %{conn: conn} do - config = insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: 2)) - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{group: config.group, key: config.key, value: [%{"tuple" => [":key3", 3]}]} - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => [ - %{"tuple" => [":key1", 1]}, - %{"tuple" => [":key2", 2]}, - %{"tuple" => [":key3", 3]} - ], - "db" => [":key1", ":key2", ":key3"] - } - ] - } - end - - test "saving config which need pleroma reboot", %{conn: conn} do - chat = Config.get(:chat) - on_exit(fn -> Config.put(:chat, chat) end) - - assert post( - conn, - "/api/pleroma/admin/config", - %{ - configs: [ - %{group: ":pleroma", key: ":chat", value: [%{"tuple" => [":enabled", true]}]} - ] - } - ) - |> json_response(200) == %{ - "configs" => [ - %{ - "db" => [":enabled"], - "group" => ":pleroma", - "key" => ":chat", - "value" => [%{"tuple" => [":enabled", true]}] - } - ], - "need_reboot" => true - } - - configs = - conn - |> get("/api/pleroma/admin/config") - |> json_response(200) - - assert configs["need_reboot"] - - capture_log(fn -> - assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == %{} - end) =~ "pleroma restarted" - - configs = - conn - |> get("/api/pleroma/admin/config") - |> json_response(200) - - assert configs["need_reboot"] == false - end - - test "update setting which need reboot, don't change reboot flag until reboot", %{conn: conn} do - chat = Config.get(:chat) - on_exit(fn -> Config.put(:chat, chat) end) - - assert post( - conn, - "/api/pleroma/admin/config", - %{ - configs: [ - %{group: ":pleroma", key: ":chat", value: [%{"tuple" => [":enabled", true]}]} - ] - } - ) - |> json_response(200) == %{ - "configs" => [ - %{ - "db" => [":enabled"], - "group" => ":pleroma", - "key" => ":chat", - "value" => [%{"tuple" => [":enabled", true]}] - } - ], - "need_reboot" => true - } - - assert post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{group: ":pleroma", key: ":key1", value: [%{"tuple" => [":key3", 3]}]} - ] - }) - |> json_response(200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => [ - %{"tuple" => [":key3", 3]} - ], - "db" => [":key3"] - } - ], - "need_reboot" => true - } - - capture_log(fn -> - assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == %{} - end) =~ "pleroma restarted" - - configs = - conn - |> get("/api/pleroma/admin/config") - |> json_response(200) - - assert configs["need_reboot"] == false - end - - test "saving config with nested merge", %{conn: conn} do - config = - insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: [k1: 1, k2: 2])) - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - group: config.group, - key: config.key, - value: [ - %{"tuple" => [":key3", 3]}, - %{ - "tuple" => [ - ":key2", - [ - %{"tuple" => [":k2", 1]}, - %{"tuple" => [":k3", 3]} - ] - ] - } - ] - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => [ - %{"tuple" => [":key1", 1]}, - %{"tuple" => [":key3", 3]}, - %{ - "tuple" => [ - ":key2", - [ - %{"tuple" => [":k1", 1]}, - %{"tuple" => [":k2", 1]}, - %{"tuple" => [":k3", 3]} - ] - ] - } - ], - "db" => [":key1", ":key3", ":key2"] - } - ] - } - end - - test "saving special atoms", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => [ - %{ - "tuple" => [ - ":ssl_options", - [%{"tuple" => [":versions", [":tlsv1", ":tlsv1.1", ":tlsv1.2"]]}] - ] - } - ] - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => [ - %{ - "tuple" => [ - ":ssl_options", - [%{"tuple" => [":versions", [":tlsv1", ":tlsv1.1", ":tlsv1.2"]]}] - ] - } - ], - "db" => [":ssl_options"] - } - ] - } - - assert Application.get_env(:pleroma, :key1) == [ - ssl_options: [versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"]] - ] - end - - test "saving full setting if value is in full_key_update list", %{conn: conn} do - backends = Application.get_env(:logger, :backends) - on_exit(fn -> Application.put_env(:logger, :backends, backends) end) - - config = - insert(:config, - group: ":logger", - key: ":backends", - value: :erlang.term_to_binary([]) - ) - - Pleroma.Config.TransferTask.load_and_update_env([], false) - - assert Application.get_env(:logger, :backends) == [] - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - group: config.group, - key: config.key, - value: [":console"] - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":logger", - "key" => ":backends", - "value" => [ - ":console" - ], - "db" => [":backends"] - } - ] - } - - assert Application.get_env(:logger, :backends) == [ - :console - ] - end - - test "saving full setting if value is not keyword", %{conn: conn} do - config = - insert(:config, - group: ":tesla", - key: ":adapter", - value: :erlang.term_to_binary(Tesla.Adapter.Hackey) - ) - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{group: config.group, key: config.key, value: "Tesla.Adapter.Httpc"} - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":tesla", - "key" => ":adapter", - "value" => "Tesla.Adapter.Httpc", - "db" => [":adapter"] - } - ] - } - end - - test "update config setting & delete with fallback to default value", %{ - conn: conn, - admin: admin, - token: token - } do - ueberauth = Application.get_env(:ueberauth, Ueberauth) - config1 = insert(:config, key: ":keyaa1") - config2 = insert(:config, key: ":keyaa2") - - config3 = - insert(:config, - group: ":ueberauth", - key: "Ueberauth" - ) - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{group: config1.group, key: config1.key, value: "another_value"}, - %{group: config2.group, key: config2.key, value: "another_value"} - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => config1.key, - "value" => "another_value", - "db" => [":keyaa1"] - }, - %{ - "group" => ":pleroma", - "key" => config2.key, - "value" => "another_value", - "db" => [":keyaa2"] - } - ] - } - - assert Application.get_env(:pleroma, :keyaa1) == "another_value" - assert Application.get_env(:pleroma, :keyaa2) == "another_value" - assert Application.get_env(:ueberauth, Ueberauth) == ConfigDB.from_binary(config3.value) - - conn = - build_conn() - |> assign(:user, admin) - |> assign(:token, token) - |> post("/api/pleroma/admin/config", %{ - configs: [ - %{group: config2.group, key: config2.key, delete: true}, - %{ - group: ":ueberauth", - key: "Ueberauth", - delete: true - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [] - } - - assert Application.get_env(:ueberauth, Ueberauth) == ueberauth - refute Keyword.has_key?(Application.get_all_env(:pleroma), :keyaa2) - end - - test "common config example", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - "group" => ":pleroma", - "key" => "Pleroma.Captcha.NotReal", - "value" => [ - %{"tuple" => [":enabled", false]}, - %{"tuple" => [":method", "Pleroma.Captcha.Kocaptcha"]}, - %{"tuple" => [":seconds_valid", 60]}, - %{"tuple" => [":path", ""]}, - %{"tuple" => [":key1", nil]}, - %{"tuple" => [":partial_chain", "&:hackney_connect.partial_chain/1"]}, - %{"tuple" => [":regex1", "~r/https:\/\/example.com/"]}, - %{"tuple" => [":regex2", "~r/https:\/\/example.com/u"]}, - %{"tuple" => [":regex3", "~r/https:\/\/example.com/i"]}, - %{"tuple" => [":regex4", "~r/https:\/\/example.com/s"]}, - %{"tuple" => [":name", "Pleroma"]} - ] - } - ] - }) - - assert Config.get([Pleroma.Captcha.NotReal, :name]) == "Pleroma" - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => "Pleroma.Captcha.NotReal", - "value" => [ - %{"tuple" => [":enabled", false]}, - %{"tuple" => [":method", "Pleroma.Captcha.Kocaptcha"]}, - %{"tuple" => [":seconds_valid", 60]}, - %{"tuple" => [":path", ""]}, - %{"tuple" => [":key1", nil]}, - %{"tuple" => [":partial_chain", "&:hackney_connect.partial_chain/1"]}, - %{"tuple" => [":regex1", "~r/https:\\/\\/example.com/"]}, - %{"tuple" => [":regex2", "~r/https:\\/\\/example.com/u"]}, - %{"tuple" => [":regex3", "~r/https:\\/\\/example.com/i"]}, - %{"tuple" => [":regex4", "~r/https:\\/\\/example.com/s"]}, - %{"tuple" => [":name", "Pleroma"]} - ], - "db" => [ - ":enabled", - ":method", - ":seconds_valid", - ":path", - ":key1", - ":partial_chain", - ":regex1", - ":regex2", - ":regex3", - ":regex4", - ":name" - ] - } - ] - } - end - - test "tuples with more than two values", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - "group" => ":pleroma", - "key" => "Pleroma.Web.Endpoint.NotReal", - "value" => [ - %{ - "tuple" => [ - ":http", - [ - %{ - "tuple" => [ - ":key2", - [ - %{ - "tuple" => [ - ":_", - [ - %{ - "tuple" => [ - "/api/v1/streaming", - "Pleroma.Web.MastodonAPI.WebsocketHandler", - [] - ] - }, - %{ - "tuple" => [ - "/websocket", - "Phoenix.Endpoint.CowboyWebSocket", - %{ - "tuple" => [ - "Phoenix.Transports.WebSocket", - %{ - "tuple" => [ - "Pleroma.Web.Endpoint", - "Pleroma.Web.UserSocket", - [] - ] - } - ] - } - ] - }, - %{ - "tuple" => [ - ":_", - "Phoenix.Endpoint.Cowboy2Handler", - %{"tuple" => ["Pleroma.Web.Endpoint", []]} - ] - } - ] - ] - } - ] - ] - } - ] - ] - } - ] - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => "Pleroma.Web.Endpoint.NotReal", - "value" => [ - %{ - "tuple" => [ - ":http", - [ - %{ - "tuple" => [ - ":key2", - [ - %{ - "tuple" => [ - ":_", - [ - %{ - "tuple" => [ - "/api/v1/streaming", - "Pleroma.Web.MastodonAPI.WebsocketHandler", - [] - ] - }, - %{ - "tuple" => [ - "/websocket", - "Phoenix.Endpoint.CowboyWebSocket", - %{ - "tuple" => [ - "Phoenix.Transports.WebSocket", - %{ - "tuple" => [ - "Pleroma.Web.Endpoint", - "Pleroma.Web.UserSocket", - [] - ] - } - ] - } - ] - }, - %{ - "tuple" => [ - ":_", - "Phoenix.Endpoint.Cowboy2Handler", - %{"tuple" => ["Pleroma.Web.Endpoint", []]} - ] - } - ] - ] - } - ] - ] - } - ] - ] - } - ], - "db" => [":http"] - } - ] - } - end - - test "settings with nesting map", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => [ - %{"tuple" => [":key2", "some_val"]}, - %{ - "tuple" => [ - ":key3", - %{ - ":max_options" => 20, - ":max_option_chars" => 200, - ":min_expiration" => 0, - ":max_expiration" => 31_536_000, - "nested" => %{ - ":max_options" => 20, - ":max_option_chars" => 200, - ":min_expiration" => 0, - ":max_expiration" => 31_536_000 - } - } - ] - } - ] - } - ] - }) - - assert json_response(conn, 200) == - %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => [ - %{"tuple" => [":key2", "some_val"]}, - %{ - "tuple" => [ - ":key3", - %{ - ":max_expiration" => 31_536_000, - ":max_option_chars" => 200, - ":max_options" => 20, - ":min_expiration" => 0, - "nested" => %{ - ":max_expiration" => 31_536_000, - ":max_option_chars" => 200, - ":max_options" => 20, - ":min_expiration" => 0 - } - } - ] - } - ], - "db" => [":key2", ":key3"] - } - ] - } - end - - test "value as map", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => %{"key" => "some_val"} - } - ] - }) - - assert json_response(conn, 200) == - %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => %{"key" => "some_val"}, - "db" => [":key1"] - } - ] - } - end - - test "queues key as atom", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - "group" => ":oban", - "key" => ":queues", - "value" => [ - %{"tuple" => [":federator_incoming", 50]}, - %{"tuple" => [":federator_outgoing", 50]}, - %{"tuple" => [":web_push", 50]}, - %{"tuple" => [":mailer", 10]}, - %{"tuple" => [":transmogrifier", 20]}, - %{"tuple" => [":scheduled_activities", 10]}, - %{"tuple" => [":background", 5]} - ] - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":oban", - "key" => ":queues", - "value" => [ - %{"tuple" => [":federator_incoming", 50]}, - %{"tuple" => [":federator_outgoing", 50]}, - %{"tuple" => [":web_push", 50]}, - %{"tuple" => [":mailer", 10]}, - %{"tuple" => [":transmogrifier", 20]}, - %{"tuple" => [":scheduled_activities", 10]}, - %{"tuple" => [":background", 5]} - ], - "db" => [ - ":federator_incoming", - ":federator_outgoing", - ":web_push", - ":mailer", - ":transmogrifier", - ":scheduled_activities", - ":background" - ] - } - ] - } - end - - test "delete part of settings by atom subkeys", %{conn: conn} do - config = - insert(:config, - key: ":keyaa1", - value: :erlang.term_to_binary(subkey1: "val1", subkey2: "val2", subkey3: "val3") - ) - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - group: config.group, - key: config.key, - subkeys: [":subkey1", ":subkey3"], - delete: true - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":keyaa1", - "value" => [%{"tuple" => [":subkey2", "val2"]}], - "db" => [":subkey2"] - } - ] - } - end - - test "proxy tuple localhost", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - group: ":pleroma", - key: ":http", - value: [ - %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]} - ] - } - ] - }) - - assert %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":http", - "value" => value, - "db" => db - } - ] - } = json_response(conn, 200) - - assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]} in value - assert ":proxy_url" in db - end - - test "proxy tuple domain", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - group: ":pleroma", - key: ":http", - value: [ - %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]} - ] - } - ] - }) - - assert %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":http", - "value" => value, - "db" => db - } - ] - } = json_response(conn, 200) - - assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]} in value - assert ":proxy_url" in db - end - - test "proxy tuple ip", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - group: ":pleroma", - key: ":http", - value: [ - %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]} - ] - } - ] - }) - - assert %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":http", - "value" => value, - "db" => db - } - ] - } = json_response(conn, 200) - - assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]} in value - assert ":proxy_url" in db - end - - @tag capture_log: true - test "doesn't set keys not in the whitelist", %{conn: conn} do - clear_config(:database_config_whitelist, [ - {:pleroma, :key1}, - {:pleroma, :key2}, - {:pleroma, Pleroma.Captcha.NotReal}, - {:not_real} - ]) - - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{group: ":pleroma", key: ":key1", value: "value1"}, - %{group: ":pleroma", key: ":key2", value: "value2"}, - %{group: ":pleroma", key: ":key3", value: "value3"}, - %{group: ":pleroma", key: "Pleroma.Web.Endpoint.NotReal", value: "value4"}, - %{group: ":pleroma", key: "Pleroma.Captcha.NotReal", value: "value5"}, - %{group: ":not_real", key: ":anything", value: "value6"} - ] - }) - - assert Application.get_env(:pleroma, :key1) == "value1" - assert Application.get_env(:pleroma, :key2) == "value2" - assert Application.get_env(:pleroma, :key3) == nil - assert Application.get_env(:pleroma, Pleroma.Web.Endpoint.NotReal) == nil - assert Application.get_env(:pleroma, Pleroma.Captcha.NotReal) == "value5" - assert Application.get_env(:not_real, :anything) == "value6" - end - end - describe "GET /api/pleroma/admin/restart" do setup do: clear_config(:configurable_from_database, true) @@ -3481,56 +2311,6 @@ test "it deletes the note", %{conn: conn, report_id: report_id} do end end - describe "GET /api/pleroma/admin/config/descriptions" do - test "structure", %{conn: conn} do - admin = insert(:user, is_admin: true) - - conn = - assign(conn, :user, admin) - |> get("/api/pleroma/admin/config/descriptions") - - assert [child | _others] = json_response(conn, 200) - - assert child["children"] - assert child["key"] - assert String.starts_with?(child["group"], ":") - assert child["description"] - end - - test "filters by database configuration whitelist", %{conn: conn} do - clear_config(:database_config_whitelist, [ - {:pleroma, :instance}, - {:pleroma, :activitypub}, - {:pleroma, Pleroma.Upload}, - {:esshd} - ]) - - admin = insert(:user, is_admin: true) - - conn = - assign(conn, :user, admin) - |> get("/api/pleroma/admin/config/descriptions") - - children = json_response(conn, 200) - - assert length(children) == 4 - - assert Enum.count(children, fn c -> c["group"] == ":pleroma" end) == 3 - - instance = Enum.find(children, fn c -> c["key"] == ":instance" end) - assert instance["children"] - - activitypub = Enum.find(children, fn c -> c["key"] == ":activitypub" end) - assert activitypub["children"] - - web_endpoint = Enum.find(children, fn c -> c["key"] == "Pleroma.Upload" end) - assert web_endpoint["children"] - - esshd = Enum.find(children, fn c -> c["group"] == ":esshd" end) - assert esshd["children"] - end - end - describe "/api/pleroma/admin/stats" do test "status visibility count", %{conn: conn} do admin = insert(:user, is_admin: true) diff --git a/test/web/admin_api/controllers/config_controller_test.exs b/test/web/admin_api/controllers/config_controller_test.exs new file mode 100644 index 000000000..9bc6fd91c --- /dev/null +++ b/test/web/admin_api/controllers/config_controller_test.exs @@ -0,0 +1,1244 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.ConfigControllerTest do + use Pleroma.Web.ConnCase, async: true + + import ExUnit.CaptureLog + import Pleroma.Factory + + alias Pleroma.Config + alias Pleroma.ConfigDB + + setup do + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + + {:ok, %{admin: admin, token: token, conn: conn}} + end + + describe "GET /api/pleroma/admin/config" do + setup do: clear_config(:configurable_from_database, true) + + test "when configuration from database is off", %{conn: conn} do + Config.put(:configurable_from_database, false) + conn = get(conn, "/api/pleroma/admin/config") + + assert json_response(conn, 400) == + %{ + "error" => "To use this endpoint you need to enable configuration from database." + } + end + + test "with settings only in db", %{conn: conn} do + config1 = insert(:config) + config2 = insert(:config) + + conn = get(conn, "/api/pleroma/admin/config", %{"only_db" => true}) + + %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => key1, + "value" => _ + }, + %{ + "group" => ":pleroma", + "key" => key2, + "value" => _ + } + ] + } = json_response(conn, 200) + + assert key1 == config1.key + assert key2 == config2.key + end + + test "db is added to settings that are in db", %{conn: conn} do + _config = insert(:config, key: ":instance", value: ConfigDB.to_binary(name: "Some name")) + + %{"configs" => configs} = + conn + |> get("/api/pleroma/admin/config") + |> json_response(200) + + [instance_config] = + Enum.filter(configs, fn %{"group" => group, "key" => key} -> + group == ":pleroma" and key == ":instance" + end) + + assert instance_config["db"] == [":name"] + end + + test "merged default setting with db settings", %{conn: conn} do + config1 = insert(:config) + config2 = insert(:config) + + config3 = + insert(:config, + value: ConfigDB.to_binary(k1: :v1, k2: :v2) + ) + + %{"configs" => configs} = + conn + |> get("/api/pleroma/admin/config") + |> json_response(200) + + assert length(configs) > 3 + + received_configs = + Enum.filter(configs, fn %{"group" => group, "key" => key} -> + group == ":pleroma" and key in [config1.key, config2.key, config3.key] + end) + + assert length(received_configs) == 3 + + db_keys = + config3.value + |> ConfigDB.from_binary() + |> Keyword.keys() + |> ConfigDB.convert() + + Enum.each(received_configs, fn %{"value" => value, "db" => db} -> + assert db in [[config1.key], [config2.key], db_keys] + + assert value in [ + ConfigDB.from_binary_with_convert(config1.value), + ConfigDB.from_binary_with_convert(config2.value), + ConfigDB.from_binary_with_convert(config3.value) + ] + end) + end + + test "subkeys with full update right merge", %{conn: conn} do + config1 = + insert(:config, + key: ":emoji", + value: ConfigDB.to_binary(groups: [a: 1, b: 2], key: [a: 1]) + ) + + config2 = + insert(:config, + key: ":assets", + value: ConfigDB.to_binary(mascots: [a: 1, b: 2], key: [a: 1]) + ) + + %{"configs" => configs} = + conn + |> get("/api/pleroma/admin/config") + |> json_response(200) + + vals = + Enum.filter(configs, fn %{"group" => group, "key" => key} -> + group == ":pleroma" and key in [config1.key, config2.key] + end) + + emoji = Enum.find(vals, fn %{"key" => key} -> key == ":emoji" end) + assets = Enum.find(vals, fn %{"key" => key} -> key == ":assets" end) + + emoji_val = ConfigDB.transform_with_out_binary(emoji["value"]) + assets_val = ConfigDB.transform_with_out_binary(assets["value"]) + + assert emoji_val[:groups] == [a: 1, b: 2] + assert assets_val[:mascots] == [a: 1, b: 2] + end + end + + test "POST /api/pleroma/admin/config error", %{conn: conn} do + conn = post(conn, "/api/pleroma/admin/config", %{"configs" => []}) + + assert json_response(conn, 400) == + %{"error" => "To use this endpoint you need to enable configuration from database."} + end + + describe "POST /api/pleroma/admin/config" do + setup do + http = Application.get_env(:pleroma, :http) + + on_exit(fn -> + Application.delete_env(:pleroma, :key1) + Application.delete_env(:pleroma, :key2) + Application.delete_env(:pleroma, :key3) + Application.delete_env(:pleroma, :key4) + Application.delete_env(:pleroma, :keyaa1) + Application.delete_env(:pleroma, :keyaa2) + Application.delete_env(:pleroma, Pleroma.Web.Endpoint.NotReal) + Application.delete_env(:pleroma, Pleroma.Captcha.NotReal) + Application.put_env(:pleroma, :http, http) + Application.put_env(:tesla, :adapter, Tesla.Mock) + Restarter.Pleroma.refresh() + end) + end + + setup do: clear_config(:configurable_from_database, true) + + @tag capture_log: true + test "create new config setting in db", %{conn: conn} do + ueberauth = Application.get_env(:ueberauth, Ueberauth) + on_exit(fn -> Application.put_env(:ueberauth, Ueberauth, ueberauth) end) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: ":pleroma", key: ":key1", value: "value1"}, + %{ + group: ":ueberauth", + key: "Ueberauth", + value: [%{"tuple" => [":consumer_secret", "aaaa"]}] + }, + %{ + group: ":pleroma", + key: ":key2", + value: %{ + ":nested_1" => "nested_value1", + ":nested_2" => [ + %{":nested_22" => "nested_value222"}, + %{":nested_33" => %{":nested_44" => "nested_444"}} + ] + } + }, + %{ + group: ":pleroma", + key: ":key3", + value: [ + %{"nested_3" => ":nested_3", "nested_33" => "nested_33"}, + %{"nested_4" => true} + ] + }, + %{ + group: ":pleroma", + key: ":key4", + value: %{":nested_5" => ":upload", "endpoint" => "https://example.com"} + }, + %{ + group: ":idna", + key: ":key5", + value: %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]} + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => "value1", + "db" => [":key1"] + }, + %{ + "group" => ":ueberauth", + "key" => "Ueberauth", + "value" => [%{"tuple" => [":consumer_secret", "aaaa"]}], + "db" => [":consumer_secret"] + }, + %{ + "group" => ":pleroma", + "key" => ":key2", + "value" => %{ + ":nested_1" => "nested_value1", + ":nested_2" => [ + %{":nested_22" => "nested_value222"}, + %{":nested_33" => %{":nested_44" => "nested_444"}} + ] + }, + "db" => [":key2"] + }, + %{ + "group" => ":pleroma", + "key" => ":key3", + "value" => [ + %{"nested_3" => ":nested_3", "nested_33" => "nested_33"}, + %{"nested_4" => true} + ], + "db" => [":key3"] + }, + %{ + "group" => ":pleroma", + "key" => ":key4", + "value" => %{"endpoint" => "https://example.com", ":nested_5" => ":upload"}, + "db" => [":key4"] + }, + %{ + "group" => ":idna", + "key" => ":key5", + "value" => %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]}, + "db" => [":key5"] + } + ] + } + + assert Application.get_env(:pleroma, :key1) == "value1" + + assert Application.get_env(:pleroma, :key2) == %{ + nested_1: "nested_value1", + nested_2: [ + %{nested_22: "nested_value222"}, + %{nested_33: %{nested_44: "nested_444"}} + ] + } + + assert Application.get_env(:pleroma, :key3) == [ + %{"nested_3" => :nested_3, "nested_33" => "nested_33"}, + %{"nested_4" => true} + ] + + assert Application.get_env(:pleroma, :key4) == %{ + "endpoint" => "https://example.com", + nested_5: :upload + } + + assert Application.get_env(:idna, :key5) == {"string", Pleroma.Captcha.NotReal, []} + end + + test "save configs setting without explicit key", %{conn: conn} do + level = Application.get_env(:quack, :level) + meta = Application.get_env(:quack, :meta) + webhook_url = Application.get_env(:quack, :webhook_url) + + on_exit(fn -> + Application.put_env(:quack, :level, level) + Application.put_env(:quack, :meta, meta) + Application.put_env(:quack, :webhook_url, webhook_url) + end) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: ":quack", + key: ":level", + value: ":info" + }, + %{ + group: ":quack", + key: ":meta", + value: [":none"] + }, + %{ + group: ":quack", + key: ":webhook_url", + value: "https://hooks.slack.com/services/KEY" + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":quack", + "key" => ":level", + "value" => ":info", + "db" => [":level"] + }, + %{ + "group" => ":quack", + "key" => ":meta", + "value" => [":none"], + "db" => [":meta"] + }, + %{ + "group" => ":quack", + "key" => ":webhook_url", + "value" => "https://hooks.slack.com/services/KEY", + "db" => [":webhook_url"] + } + ] + } + + assert Application.get_env(:quack, :level) == :info + assert Application.get_env(:quack, :meta) == [:none] + assert Application.get_env(:quack, :webhook_url) == "https://hooks.slack.com/services/KEY" + end + + test "saving config with partial update", %{conn: conn} do + config = insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: 2)) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: config.group, key: config.key, value: [%{"tuple" => [":key3", 3]}]} + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{"tuple" => [":key1", 1]}, + %{"tuple" => [":key2", 2]}, + %{"tuple" => [":key3", 3]} + ], + "db" => [":key1", ":key2", ":key3"] + } + ] + } + end + + test "saving config which need pleroma reboot", %{conn: conn} do + chat = Config.get(:chat) + on_exit(fn -> Config.put(:chat, chat) end) + + assert post( + conn, + "/api/pleroma/admin/config", + %{ + configs: [ + %{group: ":pleroma", key: ":chat", value: [%{"tuple" => [":enabled", true]}]} + ] + } + ) + |> json_response(200) == %{ + "configs" => [ + %{ + "db" => [":enabled"], + "group" => ":pleroma", + "key" => ":chat", + "value" => [%{"tuple" => [":enabled", true]}] + } + ], + "need_reboot" => true + } + + configs = + conn + |> get("/api/pleroma/admin/config") + |> json_response(200) + + assert configs["need_reboot"] + + capture_log(fn -> + assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == %{} + end) =~ "pleroma restarted" + + configs = + conn + |> get("/api/pleroma/admin/config") + |> json_response(200) + + assert configs["need_reboot"] == false + end + + test "update setting which need reboot, don't change reboot flag until reboot", %{conn: conn} do + chat = Config.get(:chat) + on_exit(fn -> Config.put(:chat, chat) end) + + assert post( + conn, + "/api/pleroma/admin/config", + %{ + configs: [ + %{group: ":pleroma", key: ":chat", value: [%{"tuple" => [":enabled", true]}]} + ] + } + ) + |> json_response(200) == %{ + "configs" => [ + %{ + "db" => [":enabled"], + "group" => ":pleroma", + "key" => ":chat", + "value" => [%{"tuple" => [":enabled", true]}] + } + ], + "need_reboot" => true + } + + assert post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: ":pleroma", key: ":key1", value: [%{"tuple" => [":key3", 3]}]} + ] + }) + |> json_response(200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{"tuple" => [":key3", 3]} + ], + "db" => [":key3"] + } + ], + "need_reboot" => true + } + + capture_log(fn -> + assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == %{} + end) =~ "pleroma restarted" + + configs = + conn + |> get("/api/pleroma/admin/config") + |> json_response(200) + + assert configs["need_reboot"] == false + end + + test "saving config with nested merge", %{conn: conn} do + config = + insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: [k1: 1, k2: 2])) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: config.group, + key: config.key, + value: [ + %{"tuple" => [":key3", 3]}, + %{ + "tuple" => [ + ":key2", + [ + %{"tuple" => [":k2", 1]}, + %{"tuple" => [":k3", 3]} + ] + ] + } + ] + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{"tuple" => [":key1", 1]}, + %{"tuple" => [":key3", 3]}, + %{ + "tuple" => [ + ":key2", + [ + %{"tuple" => [":k1", 1]}, + %{"tuple" => [":k2", 1]}, + %{"tuple" => [":k3", 3]} + ] + ] + } + ], + "db" => [":key1", ":key3", ":key2"] + } + ] + } + end + + test "saving special atoms", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{ + "tuple" => [ + ":ssl_options", + [%{"tuple" => [":versions", [":tlsv1", ":tlsv1.1", ":tlsv1.2"]]}] + ] + } + ] + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{ + "tuple" => [ + ":ssl_options", + [%{"tuple" => [":versions", [":tlsv1", ":tlsv1.1", ":tlsv1.2"]]}] + ] + } + ], + "db" => [":ssl_options"] + } + ] + } + + assert Application.get_env(:pleroma, :key1) == [ + ssl_options: [versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"]] + ] + end + + test "saving full setting if value is in full_key_update list", %{conn: conn} do + backends = Application.get_env(:logger, :backends) + on_exit(fn -> Application.put_env(:logger, :backends, backends) end) + + config = + insert(:config, + group: ":logger", + key: ":backends", + value: :erlang.term_to_binary([]) + ) + + Pleroma.Config.TransferTask.load_and_update_env([], false) + + assert Application.get_env(:logger, :backends) == [] + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: config.group, + key: config.key, + value: [":console"] + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":logger", + "key" => ":backends", + "value" => [ + ":console" + ], + "db" => [":backends"] + } + ] + } + + assert Application.get_env(:logger, :backends) == [ + :console + ] + end + + test "saving full setting if value is not keyword", %{conn: conn} do + config = + insert(:config, + group: ":tesla", + key: ":adapter", + value: :erlang.term_to_binary(Tesla.Adapter.Hackey) + ) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: config.group, key: config.key, value: "Tesla.Adapter.Httpc"} + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":tesla", + "key" => ":adapter", + "value" => "Tesla.Adapter.Httpc", + "db" => [":adapter"] + } + ] + } + end + + test "update config setting & delete with fallback to default value", %{ + conn: conn, + admin: admin, + token: token + } do + ueberauth = Application.get_env(:ueberauth, Ueberauth) + config1 = insert(:config, key: ":keyaa1") + config2 = insert(:config, key: ":keyaa2") + + config3 = + insert(:config, + group: ":ueberauth", + key: "Ueberauth" + ) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: config1.group, key: config1.key, value: "another_value"}, + %{group: config2.group, key: config2.key, value: "another_value"} + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => config1.key, + "value" => "another_value", + "db" => [":keyaa1"] + }, + %{ + "group" => ":pleroma", + "key" => config2.key, + "value" => "another_value", + "db" => [":keyaa2"] + } + ] + } + + assert Application.get_env(:pleroma, :keyaa1) == "another_value" + assert Application.get_env(:pleroma, :keyaa2) == "another_value" + assert Application.get_env(:ueberauth, Ueberauth) == ConfigDB.from_binary(config3.value) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + |> post("/api/pleroma/admin/config", %{ + configs: [ + %{group: config2.group, key: config2.key, delete: true}, + %{ + group: ":ueberauth", + key: "Ueberauth", + delete: true + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [] + } + + assert Application.get_env(:ueberauth, Ueberauth) == ueberauth + refute Keyword.has_key?(Application.get_all_env(:pleroma), :keyaa2) + end + + test "common config example", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + "group" => ":pleroma", + "key" => "Pleroma.Captcha.NotReal", + "value" => [ + %{"tuple" => [":enabled", false]}, + %{"tuple" => [":method", "Pleroma.Captcha.Kocaptcha"]}, + %{"tuple" => [":seconds_valid", 60]}, + %{"tuple" => [":path", ""]}, + %{"tuple" => [":key1", nil]}, + %{"tuple" => [":partial_chain", "&:hackney_connect.partial_chain/1"]}, + %{"tuple" => [":regex1", "~r/https:\/\/example.com/"]}, + %{"tuple" => [":regex2", "~r/https:\/\/example.com/u"]}, + %{"tuple" => [":regex3", "~r/https:\/\/example.com/i"]}, + %{"tuple" => [":regex4", "~r/https:\/\/example.com/s"]}, + %{"tuple" => [":name", "Pleroma"]} + ] + } + ] + }) + + assert Config.get([Pleroma.Captcha.NotReal, :name]) == "Pleroma" + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => "Pleroma.Captcha.NotReal", + "value" => [ + %{"tuple" => [":enabled", false]}, + %{"tuple" => [":method", "Pleroma.Captcha.Kocaptcha"]}, + %{"tuple" => [":seconds_valid", 60]}, + %{"tuple" => [":path", ""]}, + %{"tuple" => [":key1", nil]}, + %{"tuple" => [":partial_chain", "&:hackney_connect.partial_chain/1"]}, + %{"tuple" => [":regex1", "~r/https:\\/\\/example.com/"]}, + %{"tuple" => [":regex2", "~r/https:\\/\\/example.com/u"]}, + %{"tuple" => [":regex3", "~r/https:\\/\\/example.com/i"]}, + %{"tuple" => [":regex4", "~r/https:\\/\\/example.com/s"]}, + %{"tuple" => [":name", "Pleroma"]} + ], + "db" => [ + ":enabled", + ":method", + ":seconds_valid", + ":path", + ":key1", + ":partial_chain", + ":regex1", + ":regex2", + ":regex3", + ":regex4", + ":name" + ] + } + ] + } + end + + test "tuples with more than two values", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + "group" => ":pleroma", + "key" => "Pleroma.Web.Endpoint.NotReal", + "value" => [ + %{ + "tuple" => [ + ":http", + [ + %{ + "tuple" => [ + ":key2", + [ + %{ + "tuple" => [ + ":_", + [ + %{ + "tuple" => [ + "/api/v1/streaming", + "Pleroma.Web.MastodonAPI.WebsocketHandler", + [] + ] + }, + %{ + "tuple" => [ + "/websocket", + "Phoenix.Endpoint.CowboyWebSocket", + %{ + "tuple" => [ + "Phoenix.Transports.WebSocket", + %{ + "tuple" => [ + "Pleroma.Web.Endpoint", + "Pleroma.Web.UserSocket", + [] + ] + } + ] + } + ] + }, + %{ + "tuple" => [ + ":_", + "Phoenix.Endpoint.Cowboy2Handler", + %{"tuple" => ["Pleroma.Web.Endpoint", []]} + ] + } + ] + ] + } + ] + ] + } + ] + ] + } + ] + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => "Pleroma.Web.Endpoint.NotReal", + "value" => [ + %{ + "tuple" => [ + ":http", + [ + %{ + "tuple" => [ + ":key2", + [ + %{ + "tuple" => [ + ":_", + [ + %{ + "tuple" => [ + "/api/v1/streaming", + "Pleroma.Web.MastodonAPI.WebsocketHandler", + [] + ] + }, + %{ + "tuple" => [ + "/websocket", + "Phoenix.Endpoint.CowboyWebSocket", + %{ + "tuple" => [ + "Phoenix.Transports.WebSocket", + %{ + "tuple" => [ + "Pleroma.Web.Endpoint", + "Pleroma.Web.UserSocket", + [] + ] + } + ] + } + ] + }, + %{ + "tuple" => [ + ":_", + "Phoenix.Endpoint.Cowboy2Handler", + %{"tuple" => ["Pleroma.Web.Endpoint", []]} + ] + } + ] + ] + } + ] + ] + } + ] + ] + } + ], + "db" => [":http"] + } + ] + } + end + + test "settings with nesting map", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{"tuple" => [":key2", "some_val"]}, + %{ + "tuple" => [ + ":key3", + %{ + ":max_options" => 20, + ":max_option_chars" => 200, + ":min_expiration" => 0, + ":max_expiration" => 31_536_000, + "nested" => %{ + ":max_options" => 20, + ":max_option_chars" => 200, + ":min_expiration" => 0, + ":max_expiration" => 31_536_000 + } + } + ] + } + ] + } + ] + }) + + assert json_response(conn, 200) == + %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{"tuple" => [":key2", "some_val"]}, + %{ + "tuple" => [ + ":key3", + %{ + ":max_expiration" => 31_536_000, + ":max_option_chars" => 200, + ":max_options" => 20, + ":min_expiration" => 0, + "nested" => %{ + ":max_expiration" => 31_536_000, + ":max_option_chars" => 200, + ":max_options" => 20, + ":min_expiration" => 0 + } + } + ] + } + ], + "db" => [":key2", ":key3"] + } + ] + } + end + + test "value as map", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => %{"key" => "some_val"} + } + ] + }) + + assert json_response(conn, 200) == + %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => %{"key" => "some_val"}, + "db" => [":key1"] + } + ] + } + end + + test "queues key as atom", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + "group" => ":oban", + "key" => ":queues", + "value" => [ + %{"tuple" => [":federator_incoming", 50]}, + %{"tuple" => [":federator_outgoing", 50]}, + %{"tuple" => [":web_push", 50]}, + %{"tuple" => [":mailer", 10]}, + %{"tuple" => [":transmogrifier", 20]}, + %{"tuple" => [":scheduled_activities", 10]}, + %{"tuple" => [":background", 5]} + ] + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":oban", + "key" => ":queues", + "value" => [ + %{"tuple" => [":federator_incoming", 50]}, + %{"tuple" => [":federator_outgoing", 50]}, + %{"tuple" => [":web_push", 50]}, + %{"tuple" => [":mailer", 10]}, + %{"tuple" => [":transmogrifier", 20]}, + %{"tuple" => [":scheduled_activities", 10]}, + %{"tuple" => [":background", 5]} + ], + "db" => [ + ":federator_incoming", + ":federator_outgoing", + ":web_push", + ":mailer", + ":transmogrifier", + ":scheduled_activities", + ":background" + ] + } + ] + } + end + + test "delete part of settings by atom subkeys", %{conn: conn} do + config = + insert(:config, + key: ":keyaa1", + value: :erlang.term_to_binary(subkey1: "val1", subkey2: "val2", subkey3: "val3") + ) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: config.group, + key: config.key, + subkeys: [":subkey1", ":subkey3"], + delete: true + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":keyaa1", + "value" => [%{"tuple" => [":subkey2", "val2"]}], + "db" => [":subkey2"] + } + ] + } + end + + test "proxy tuple localhost", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: ":pleroma", + key: ":http", + value: [ + %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]} + ] + } + ] + }) + + assert %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":http", + "value" => value, + "db" => db + } + ] + } = json_response(conn, 200) + + assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]} in value + assert ":proxy_url" in db + end + + test "proxy tuple domain", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: ":pleroma", + key: ":http", + value: [ + %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]} + ] + } + ] + }) + + assert %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":http", + "value" => value, + "db" => db + } + ] + } = json_response(conn, 200) + + assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]} in value + assert ":proxy_url" in db + end + + test "proxy tuple ip", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: ":pleroma", + key: ":http", + value: [ + %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]} + ] + } + ] + }) + + assert %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":http", + "value" => value, + "db" => db + } + ] + } = json_response(conn, 200) + + assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]} in value + assert ":proxy_url" in db + end + + @tag capture_log: true + test "doesn't set keys not in the whitelist", %{conn: conn} do + clear_config(:database_config_whitelist, [ + {:pleroma, :key1}, + {:pleroma, :key2}, + {:pleroma, Pleroma.Captcha.NotReal}, + {:not_real} + ]) + + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: ":pleroma", key: ":key1", value: "value1"}, + %{group: ":pleroma", key: ":key2", value: "value2"}, + %{group: ":pleroma", key: ":key3", value: "value3"}, + %{group: ":pleroma", key: "Pleroma.Web.Endpoint.NotReal", value: "value4"}, + %{group: ":pleroma", key: "Pleroma.Captcha.NotReal", value: "value5"}, + %{group: ":not_real", key: ":anything", value: "value6"} + ] + }) + + assert Application.get_env(:pleroma, :key1) == "value1" + assert Application.get_env(:pleroma, :key2) == "value2" + assert Application.get_env(:pleroma, :key3) == nil + assert Application.get_env(:pleroma, Pleroma.Web.Endpoint.NotReal) == nil + assert Application.get_env(:pleroma, Pleroma.Captcha.NotReal) == "value5" + assert Application.get_env(:not_real, :anything) == "value6" + end + end + + describe "GET /api/pleroma/admin/config/descriptions" do + test "structure", %{conn: conn} do + admin = insert(:user, is_admin: true) + + conn = + assign(conn, :user, admin) + |> get("/api/pleroma/admin/config/descriptions") + + assert [child | _others] = json_response(conn, 200) + + assert child["children"] + assert child["key"] + assert String.starts_with?(child["group"], ":") + assert child["description"] + end + + test "filters by database configuration whitelist", %{conn: conn} do + clear_config(:database_config_whitelist, [ + {:pleroma, :instance}, + {:pleroma, :activitypub}, + {:pleroma, Pleroma.Upload}, + {:esshd} + ]) + + admin = insert(:user, is_admin: true) + + conn = + assign(conn, :user, admin) + |> get("/api/pleroma/admin/config/descriptions") + + children = json_response(conn, 200) + + assert length(children) == 4 + + assert Enum.count(children, fn c -> c["group"] == ":pleroma" end) == 3 + + instance = Enum.find(children, fn c -> c["key"] == ":instance" end) + assert instance["children"] + + activitypub = Enum.find(children, fn c -> c["key"] == ":activitypub" end) + assert activitypub["children"] + + web_endpoint = Enum.find(children, fn c -> c["key"] == "Pleroma.Upload" end) + assert web_endpoint["children"] + + esshd = Enum.find(children, fn c -> c["group"] == ":esshd" end) + assert esshd["children"] + end + end +end From 06f20e918129b1f434783b64d59b5ae6b4b4ed51 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 28 May 2020 23:11:12 +0400 Subject: [PATCH 129/401] Add OpenApi spec to AdminAPI.ConfigController --- .../controllers/config_controller.ex | 21 ++- .../operations/admin/config_operation.ex | 142 +++++++++++++++ .../controllers/config_controller_test.exs | 164 +++++++++++------- 3 files changed, 259 insertions(+), 68 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/admin/config_operation.ex diff --git a/lib/pleroma/web/admin_api/controllers/config_controller.ex b/lib/pleroma/web/admin_api/controllers/config_controller.ex index 742980976..e221d9418 100644 --- a/lib/pleroma/web/admin_api/controllers/config_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/config_controller.ex @@ -11,23 +11,26 @@ defmodule Pleroma.Web.AdminAPI.ConfigController do @descriptions Pleroma.Docs.JSON.compile() + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(OAuthScopesPlug, %{scopes: ["write"], admin: true} when action == :update) + plug( OAuthScopesPlug, %{scopes: ["read"], admin: true} when action in [:show, :descriptions] ) - plug(OAuthScopesPlug, %{scopes: ["write"], admin: true} when action == :update) - action_fallback(Pleroma.Web.AdminAPI.FallbackController) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.ConfigOperation + def descriptions(conn, _params) do descriptions = Enum.filter(@descriptions, &whitelisted_config?/1) json(conn, descriptions) end - def show(conn, %{"only_db" => true}) do + def show(conn, %{only_db: true}) do with :ok <- configurable_from_database() do configs = Pleroma.Repo.all(ConfigDB) render(conn, "index.json", %{configs: configs}) @@ -73,16 +76,16 @@ def show(conn, _params) do end end - def update(conn, %{"configs" => configs}) do + def update(%{body_params: %{configs: configs}} = conn, _) do with :ok <- configurable_from_database() do results = configs |> Enum.filter(&whitelisted_config?/1) |> Enum.map(fn - %{"group" => group, "key" => key, "delete" => true} = params -> - ConfigDB.delete(%{group: group, key: key, subkeys: params["subkeys"]}) + %{group: group, key: key, delete: true} = params -> + ConfigDB.delete(%{group: group, key: key, subkeys: params[:subkeys]}) - %{"group" => group, "key" => key, "value" => value} -> + %{group: group, key: key, value: value} -> ConfigDB.update_or_create(%{group: group, key: key, value: value}) end) |> Enum.reject(fn {result, _} -> result == :error end) @@ -140,11 +143,11 @@ defp whitelisted_config?(group, key) do end end - defp whitelisted_config?(%{"group" => group, "key" => key}) do + defp whitelisted_config?(%{group: group, key: key}) do whitelisted_config?(group, key) end - defp whitelisted_config?(%{:group => group} = config) do + defp whitelisted_config?(%{group: group} = config) do whitelisted_config?(group, config[:key]) end end diff --git a/lib/pleroma/web/api_spec/operations/admin/config_operation.ex b/lib/pleroma/web/api_spec/operations/admin/config_operation.ex new file mode 100644 index 000000000..7b38a2ef4 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/config_operation.ex @@ -0,0 +1,142 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.ConfigOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def show_operation do + %Operation{ + tags: ["Admin", "Config"], + summary: "Get list of merged default settings with saved in database", + operationId: "AdminAPI.ConfigController.show", + parameters: [ + Operation.parameter( + :only_db, + :query, + %Schema{type: :boolean, default: false}, + "Get only saved in database settings" + ) + ], + security: [%{"oAuth" => ["read"]}], + responses: %{ + 200 => Operation.response("Config", "application/json", config_response()), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Admin", "Config"], + summary: "Update config settings", + operationId: "AdminAPI.ConfigController.update", + security: [%{"oAuth" => ["write"]}], + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + configs: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + group: %Schema{type: :string}, + key: %Schema{type: :string}, + value: any(), + delete: %Schema{type: :boolean}, + subkeys: %Schema{type: :array, items: %Schema{type: :string}} + } + } + } + } + }), + responses: %{ + 200 => Operation.response("Config", "application/json", config_response()), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def descriptions_operation do + %Operation{ + tags: ["Admin", "Config"], + summary: "Get JSON with config descriptions.", + operationId: "AdminAPI.ConfigController.descriptions", + security: [%{"oAuth" => ["read"]}], + responses: %{ + 200 => + Operation.response("Config Descriptions", "application/json", %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + group: %Schema{type: :string}, + key: %Schema{type: :string}, + type: %Schema{oneOf: [%Schema{type: :string}, %Schema{type: :array}]}, + description: %Schema{type: :string}, + children: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + key: %Schema{type: :string}, + type: %Schema{oneOf: [%Schema{type: :string}, %Schema{type: :array}]}, + description: %Schema{type: :string}, + suggestions: %Schema{type: :array} + } + } + } + } + } + }), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + defp any do + %Schema{ + oneOf: [ + %Schema{type: :array}, + %Schema{type: :object}, + %Schema{type: :string}, + %Schema{type: :integer}, + %Schema{type: :boolean} + ] + } + end + + defp config_response do + %Schema{ + type: :object, + properties: %{ + configs: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + group: %Schema{type: :string}, + key: %Schema{type: :string}, + value: any() + } + } + }, + need_reboot: %Schema{ + type: :boolean, + description: + "If `need_reboot` is `true`, instance must be restarted, so reboot time settings can take effect" + } + } + } + end +end diff --git a/test/web/admin_api/controllers/config_controller_test.exs b/test/web/admin_api/controllers/config_controller_test.exs index 9bc6fd91c..780de8d18 100644 --- a/test/web/admin_api/controllers/config_controller_test.exs +++ b/test/web/admin_api/controllers/config_controller_test.exs @@ -30,7 +30,7 @@ test "when configuration from database is off", %{conn: conn} do Config.put(:configurable_from_database, false) conn = get(conn, "/api/pleroma/admin/config") - assert json_response(conn, 400) == + assert json_response_and_validate_schema(conn, 400) == %{ "error" => "To use this endpoint you need to enable configuration from database." } @@ -40,7 +40,7 @@ test "with settings only in db", %{conn: conn} do config1 = insert(:config) config2 = insert(:config) - conn = get(conn, "/api/pleroma/admin/config", %{"only_db" => true}) + conn = get(conn, "/api/pleroma/admin/config?only_db=true") %{ "configs" => [ @@ -55,7 +55,7 @@ test "with settings only in db", %{conn: conn} do "value" => _ } ] - } = json_response(conn, 200) + } = json_response_and_validate_schema(conn, 200) assert key1 == config1.key assert key2 == config2.key @@ -67,7 +67,7 @@ test "db is added to settings that are in db", %{conn: conn} do %{"configs" => configs} = conn |> get("/api/pleroma/admin/config") - |> json_response(200) + |> json_response_and_validate_schema(200) [instance_config] = Enum.filter(configs, fn %{"group" => group, "key" => key} -> @@ -89,7 +89,7 @@ test "merged default setting with db settings", %{conn: conn} do %{"configs" => configs} = conn |> get("/api/pleroma/admin/config") - |> json_response(200) + |> json_response_and_validate_schema(200) assert length(configs) > 3 @@ -133,7 +133,7 @@ test "subkeys with full update right merge", %{conn: conn} do %{"configs" => configs} = conn |> get("/api/pleroma/admin/config") - |> json_response(200) + |> json_response_and_validate_schema(200) vals = Enum.filter(configs, fn %{"group" => group, "key" => key} -> @@ -152,9 +152,12 @@ test "subkeys with full update right merge", %{conn: conn} do end test "POST /api/pleroma/admin/config error", %{conn: conn} do - conn = post(conn, "/api/pleroma/admin/config", %{"configs" => []}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{"configs" => []}) - assert json_response(conn, 400) == + assert json_response_and_validate_schema(conn, 400) == %{"error" => "To use this endpoint you need to enable configuration from database."} end @@ -185,7 +188,9 @@ test "create new config setting in db", %{conn: conn} do on_exit(fn -> Application.put_env(:ueberauth, Ueberauth, ueberauth) end) conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{group: ":pleroma", key: ":key1", value: "value1"}, %{ @@ -225,7 +230,7 @@ test "create new config setting in db", %{conn: conn} do ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":pleroma", @@ -310,7 +315,9 @@ test "save configs setting without explicit key", %{conn: conn} do end) conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ group: ":quack", @@ -330,7 +337,7 @@ test "save configs setting without explicit key", %{conn: conn} do ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":quack", @@ -362,13 +369,15 @@ test "saving config with partial update", %{conn: conn} do config = insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: 2)) conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{group: config.group, key: config.key, value: [%{"tuple" => [":key3", 3]}]} ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":pleroma", @@ -388,8 +397,9 @@ test "saving config which need pleroma reboot", %{conn: conn} do chat = Config.get(:chat) on_exit(fn -> Config.put(:chat, chat) end) - assert post( - conn, + assert conn + |> put_req_header("content-type", "application/json") + |> post( "/api/pleroma/admin/config", %{ configs: [ @@ -397,7 +407,7 @@ test "saving config which need pleroma reboot", %{conn: conn} do ] } ) - |> json_response(200) == %{ + |> json_response_and_validate_schema(200) == %{ "configs" => [ %{ "db" => [":enabled"], @@ -412,18 +422,19 @@ test "saving config which need pleroma reboot", %{conn: conn} do configs = conn |> get("/api/pleroma/admin/config") - |> json_response(200) + |> json_response_and_validate_schema(200) assert configs["need_reboot"] capture_log(fn -> - assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == %{} + assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == + %{} end) =~ "pleroma restarted" configs = conn |> get("/api/pleroma/admin/config") - |> json_response(200) + |> json_response_and_validate_schema(200) assert configs["need_reboot"] == false end @@ -432,8 +443,9 @@ test "update setting which need reboot, don't change reboot flag until reboot", chat = Config.get(:chat) on_exit(fn -> Config.put(:chat, chat) end) - assert post( - conn, + assert conn + |> put_req_header("content-type", "application/json") + |> post( "/api/pleroma/admin/config", %{ configs: [ @@ -441,7 +453,7 @@ test "update setting which need reboot, don't change reboot flag until reboot", ] } ) - |> json_response(200) == %{ + |> json_response_and_validate_schema(200) == %{ "configs" => [ %{ "db" => [":enabled"], @@ -453,12 +465,14 @@ test "update setting which need reboot, don't change reboot flag until reboot", "need_reboot" => true } - assert post(conn, "/api/pleroma/admin/config", %{ + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{group: ":pleroma", key: ":key1", value: [%{"tuple" => [":key3", 3]}]} ] }) - |> json_response(200) == %{ + |> json_response_and_validate_schema(200) == %{ "configs" => [ %{ "group" => ":pleroma", @@ -473,13 +487,14 @@ test "update setting which need reboot, don't change reboot flag until reboot", } capture_log(fn -> - assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == %{} + assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == + %{} end) =~ "pleroma restarted" configs = conn |> get("/api/pleroma/admin/config") - |> json_response(200) + |> json_response_and_validate_schema(200) assert configs["need_reboot"] == false end @@ -489,7 +504,9 @@ test "saving config with nested merge", %{conn: conn} do insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: [k1: 1, k2: 2])) conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ group: config.group, @@ -510,7 +527,7 @@ test "saving config with nested merge", %{conn: conn} do ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":pleroma", @@ -537,7 +554,9 @@ test "saving config with nested merge", %{conn: conn} do test "saving special atoms", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ "configs" => [ %{ "group" => ":pleroma", @@ -554,7 +573,7 @@ test "saving special atoms", %{conn: conn} do ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":pleroma", @@ -593,7 +612,9 @@ test "saving full setting if value is in full_key_update list", %{conn: conn} do assert Application.get_env(:logger, :backends) == [] conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ group: config.group, @@ -603,7 +624,7 @@ test "saving full setting if value is in full_key_update list", %{conn: conn} do ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":logger", @@ -630,13 +651,15 @@ test "saving full setting if value is not keyword", %{conn: conn} do ) conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{group: config.group, key: config.key, value: "Tesla.Adapter.Httpc"} ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":tesla", @@ -664,14 +687,16 @@ test "update config setting & delete with fallback to default value", %{ ) conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{group: config1.group, key: config1.key, value: "another_value"}, %{group: config2.group, key: config2.key, value: "another_value"} ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":pleroma", @@ -696,6 +721,7 @@ test "update config setting & delete with fallback to default value", %{ build_conn() |> assign(:user, admin) |> assign(:token, token) + |> put_req_header("content-type", "application/json") |> post("/api/pleroma/admin/config", %{ configs: [ %{group: config2.group, key: config2.key, delete: true}, @@ -707,7 +733,7 @@ test "update config setting & delete with fallback to default value", %{ ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [] } @@ -717,7 +743,9 @@ test "update config setting & delete with fallback to default value", %{ test "common config example", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ "group" => ":pleroma", @@ -741,7 +769,7 @@ test "common config example", %{conn: conn} do assert Config.get([Pleroma.Captcha.NotReal, :name]) == "Pleroma" - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":pleroma", @@ -779,7 +807,9 @@ test "common config example", %{conn: conn} do test "tuples with more than two values", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ "group" => ":pleroma", @@ -843,7 +873,7 @@ test "tuples with more than two values", %{conn: conn} do ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":pleroma", @@ -911,7 +941,9 @@ test "tuples with more than two values", %{conn: conn} do test "settings with nesting map", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ "group" => ":pleroma", @@ -940,7 +972,7 @@ test "settings with nesting map", %{conn: conn} do ] }) - assert json_response(conn, 200) == + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ @@ -974,7 +1006,9 @@ test "settings with nesting map", %{conn: conn} do test "value as map", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ "group" => ":pleroma", @@ -984,7 +1018,7 @@ test "value as map", %{conn: conn} do ] }) - assert json_response(conn, 200) == + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ @@ -999,7 +1033,9 @@ test "value as map", %{conn: conn} do test "queues key as atom", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ "group" => ":oban", @@ -1017,7 +1053,7 @@ test "queues key as atom", %{conn: conn} do ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":oban", @@ -1053,7 +1089,9 @@ test "delete part of settings by atom subkeys", %{conn: conn} do ) conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ group: config.group, @@ -1064,7 +1102,7 @@ test "delete part of settings by atom subkeys", %{conn: conn} do ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":pleroma", @@ -1078,7 +1116,9 @@ test "delete part of settings by atom subkeys", %{conn: conn} do test "proxy tuple localhost", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ group: ":pleroma", @@ -1099,7 +1139,7 @@ test "proxy tuple localhost", %{conn: conn} do "db" => db } ] - } = json_response(conn, 200) + } = json_response_and_validate_schema(conn, 200) assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]} in value assert ":proxy_url" in db @@ -1107,7 +1147,9 @@ test "proxy tuple localhost", %{conn: conn} do test "proxy tuple domain", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ group: ":pleroma", @@ -1128,7 +1170,7 @@ test "proxy tuple domain", %{conn: conn} do "db" => db } ] - } = json_response(conn, 200) + } = json_response_and_validate_schema(conn, 200) assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]} in value assert ":proxy_url" in db @@ -1136,7 +1178,9 @@ test "proxy tuple domain", %{conn: conn} do test "proxy tuple ip", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ group: ":pleroma", @@ -1157,7 +1201,7 @@ test "proxy tuple ip", %{conn: conn} do "db" => db } ] - } = json_response(conn, 200) + } = json_response_and_validate_schema(conn, 200) assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]} in value assert ":proxy_url" in db @@ -1172,7 +1216,9 @@ test "doesn't set keys not in the whitelist", %{conn: conn} do {:not_real} ]) - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{group: ":pleroma", key: ":key1", value: "value1"}, %{group: ":pleroma", key: ":key2", value: "value2"}, @@ -1200,7 +1246,7 @@ test "structure", %{conn: conn} do assign(conn, :user, admin) |> get("/api/pleroma/admin/config/descriptions") - assert [child | _others] = json_response(conn, 200) + assert [child | _others] = json_response_and_validate_schema(conn, 200) assert child["children"] assert child["key"] @@ -1222,7 +1268,7 @@ test "filters by database configuration whitelist", %{conn: conn} do assign(conn, :user, admin) |> get("/api/pleroma/admin/config/descriptions") - children = json_response(conn, 200) + children = json_response_and_validate_schema(conn, 200) assert length(children) == 4 From 394258d548d20d1bea50166bc31f8e48462080dd Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 28 May 2020 16:10:06 -0500 Subject: [PATCH 130/401] Docs: Attachement limitations in MastoAPI differences --- docs/API/differences_in_mastoapi_responses.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index e65fd5da4..434ade9a4 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -6,10 +6,6 @@ A Pleroma instance can be identified by " (compatible; Pleroma Pleroma uses 128-bit ids as opposed to Mastodon's 64 bits. However just like Mastodon's ids they are lexically sortable strings -## Attachment cap - -Some apps operate under the assumption that no more than 4 attachments can be returned or uploaded. Pleroma however does not enforce any limits on attachment count neither when returning the status object nor when posting. - ## Timelines Adding the parameter `with_muted=true` to the timeline queries will also return activities by muted (not by blocked!) users. @@ -32,12 +28,20 @@ Has these additional fields under the `pleroma` object: - `thread_muted`: true if the thread the post belongs to is muted - `emoji_reactions`: A list with emoji / reaction maps. The format is `{name: "☕", count: 1, me: true}`. Contains no information about the reacting users, for that use the `/statuses/:id/reactions` endpoint. -## Attachments +## Media Attachments Has these additional fields under the `pleroma` object: - `mime_type`: mime type of the attachment. +### Attachment cap + +Some apps operate under the assumption that no more than 4 attachments can be returned or uploaded. Pleroma however does not enforce any limits on attachment count neither when returning the status object nor when posting. + +### Limitations + +Pleroma does not process remote images and therefore cannot include fields such as `meta` and `blurhash`. It does not support focal points or aspect ratios. The frontend is expected to handle it. + ## Accounts The `id` parameter can also be the `nickname` of the user. This only works in these endpoints, not the deeper nested ones for following etc. From c86a88edec75223f650faa2bb442c09aa95ad694 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 29 May 2020 15:24:41 +0200 Subject: [PATCH 131/401] Streamer: Add a chat message stream. --- lib/pleroma/web/streamer/streamer.ex | 23 ++++++++++++++++++++++- lib/pleroma/web/views/streamer_view.ex | 19 +++++++++++++++++++ test/web/streamer/streamer_test.exs | 24 ++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/streamer/streamer.ex b/lib/pleroma/web/streamer/streamer.ex index 49a400df7..331490a78 100644 --- a/lib/pleroma/web/streamer/streamer.ex +++ b/lib/pleroma/web/streamer/streamer.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.Streamer do alias Pleroma.Conversation.Participation alias Pleroma.Notification alias Pleroma.Object + alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Visibility @@ -22,7 +23,7 @@ defmodule Pleroma.Web.Streamer do def registry, do: @registry @public_streams ["public", "public:local", "public:media", "public:local:media"] - @user_streams ["user", "user:notification", "direct"] + @user_streams ["user", "user:notification", "direct", "user:pleroma_chat"] @doc "Expands and authorizes a stream, and registers the process for streaming." @spec get_topic_and_add_socket(stream :: String.t(), User.t() | nil, Map.t() | nil) :: @@ -200,6 +201,26 @@ defp do_stream(topic, %Notification{} = item) end) end + defp do_stream(topic, %{data: %{"type" => "ChatMessage"}} = object) + when topic in ["user", "user:pleroma_chat"] do + recipients = [object.data["actor"] | object.data["to"]] + + topics = + %{ap_id: recipients, local: true} + |> Pleroma.User.Query.build() + |> Repo.all() + |> Enum.map(fn %{id: id} = user -> {user, "#{topic}:#{id}"} end) + + Enum.each(topics, fn {user, topic} -> + Registry.dispatch(@registry, topic, fn list -> + Enum.each(list, fn {pid, _auth} -> + text = StreamerView.render("chat_update.json", object, user, recipients) + send(pid, {:text, text}) + end) + end) + end) + end + defp do_stream("user", item) do Logger.debug("Trying to push to users") diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index 237b29ded..949e2ed37 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -6,11 +6,30 @@ defmodule Pleroma.Web.StreamerView do use Pleroma.Web, :view alias Pleroma.Activity + alias Pleroma.Chat alias Pleroma.Conversation.Participation alias Pleroma.Notification alias Pleroma.User alias Pleroma.Web.MastodonAPI.NotificationView + def render("chat_update.json", object, user, recipients) do + chat = Chat.get(user.id, hd(recipients -- [user.ap_id])) + + representation = + Pleroma.Web.PleromaAPI.ChatMessageView.render( + "show.json", + %{object: object, chat: chat} + ) + + %{ + event: "pleroma:chat_update", + payload: + representation + |> Jason.encode!() + } + |> Jason.encode!() + end + def render("update.json", %Activity{} = activity, %User{} = user) do %{ event: "update", diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index 115ba4703..ffbff35ca 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -9,9 +9,11 @@ defmodule Pleroma.Web.StreamerTest do alias Pleroma.Conversation.Participation alias Pleroma.List + alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.CommonAPI alias Pleroma.Web.Streamer + alias Pleroma.Web.StreamerView @moduletag needs_streamer: true, capture_log: true @@ -126,6 +128,28 @@ test "it sends notify to in the 'user:notification' stream", %{user: user, notif refute Streamer.filtered_by_user?(user, notify) end + test "it sends chat messages to the 'user:pleroma_chat' stream", %{user: user} do + other_user = insert(:user) + + {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey") + object = Object.normalize(create_activity, false) + Streamer.get_topic_and_add_socket("user:pleroma_chat", user) + Streamer.stream("user:pleroma_chat", object) + text = StreamerView.render("chat_update.json", object, user, [user.ap_id, other_user.ap_id]) + assert_receive {:text, ^text} + end + + test "it sends chat messages to the 'user' stream", %{user: user} do + other_user = insert(:user) + + {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey") + object = Object.normalize(create_activity, false) + Streamer.get_topic_and_add_socket("user", user) + Streamer.stream("user", object) + text = StreamerView.render("chat_update.json", object, user, [user.ap_id, other_user.ap_id]) + assert_receive {:text, ^text} + end + test "it sends chat message notifications to the 'user:notification' stream", %{user: user} do other_user = insert(:user) From 863c02b25d1c6128fab88c33d2c4c3565a6c378f Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 29 May 2020 15:44:03 +0200 Subject: [PATCH 132/401] SideEffects: Stream out chat messages. --- lib/pleroma/web/activity_pub/side_effects.ex | 2 ++ test/web/activity_pub/side_effects_test.exs | 21 ++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index f0f0659c2..a4de8691e 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -14,6 +14,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.Streamer def handle(object, meta \\ []) @@ -126,6 +127,7 @@ def handle(object, meta) do def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do + Streamer.stream(["user", "user:pleroma_chat"], object) actor = User.get_cached_by_ap_id(object.data["actor"]) recipient = User.get_cached_by_ap_id(hd(object.data["to"])) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index fb4411c07..210ba6ef0 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -309,6 +309,27 @@ test "notifies the recipient" do assert Repo.get_by(Notification, user_id: recipient.id, activity_id: create_activity.id) end + test "it streams the created ChatMessage" do + author = insert(:user, local: true) + recipient = insert(:user, local: true) + + {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") + + {:ok, create_activity_data, _meta} = + Builder.create(author, chat_message_data["id"], [recipient.ap_id]) + + {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) + + with_mock Pleroma.Web.Streamer, [], stream: fn _, _ -> nil end do + {:ok, _create_activity, _meta} = + SideEffects.handle(create_activity, local: false, object_data: chat_message_data) + + object = Object.normalize(create_activity, false) + + assert called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], object)) + end + end + test "it creates a Chat for the local users and bumps the unread count, except for the author" do author = insert(:user, local: true) recipient = insert(:user, local: true) From 767ce8b8030562935ccd9f7c3d9ed83af0735db0 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 29 May 2020 16:02:45 +0200 Subject: [PATCH 133/401] StreamerView: Actually send Chats, not ChatMessages. --- lib/pleroma/web/pleroma_api/views/chat_view.ex | 2 +- lib/pleroma/web/views/streamer_view.ex | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index 08d5110c3..223b64987 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -14,7 +14,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatView do def render("show.json", %{chat: %Chat{} = chat} = opts) do recipient = User.get_cached_by_ap_id(chat.recipient) - last_message = Chat.last_message_for_chat(chat) + last_message = opts[:message] || Chat.last_message_for_chat(chat) %{ id: chat.id |> to_string(), diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index 949e2ed37..5e953d770 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -16,9 +16,9 @@ def render("chat_update.json", object, user, recipients) do chat = Chat.get(user.id, hd(recipients -- [user.ap_id])) representation = - Pleroma.Web.PleromaAPI.ChatMessageView.render( + Pleroma.Web.PleromaAPI.ChatView.render( "show.json", - %{object: object, chat: chat} + %{message: object, chat: chat} ) %{ From b08baf905b09ac49ed908eff8b43593d890612dd Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 29 May 2020 16:03:55 +0200 Subject: [PATCH 134/401] Docs: Document streaming differences --- docs/API/differences_in_mastoapi_responses.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index e65fd5da4..a9d1f2f38 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -226,3 +226,7 @@ Has theses additional parameters (which are the same as in Pleroma-API): Has these additional fields under the `pleroma` object: - `unread_count`: contains number unread notifications + +## Streaming + +There is an additional `user:pleroma_chat` stream. Incoming chat messages will make the current chat be sent to this `user` stream. The `event` of an incoming chat message is `pleroma:chat_update`. The payload is the updated chat with the incoming chat message in the `last_message` field. From 3898dd69a69d3af9793f2e1d442b409c84b319a8 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 29 May 2020 16:05:02 +0200 Subject: [PATCH 135/401] SideEffects: Ensure a chat is present before streaming something out. --- lib/pleroma/web/activity_pub/side_effects.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index a4de8691e..02296b210 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -127,7 +127,6 @@ def handle(object, meta) do def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do - Streamer.stream(["user", "user:pleroma_chat"], object) actor = User.get_cached_by_ap_id(object.data["actor"]) recipient = User.get_cached_by_ap_id(hd(object.data["to"])) @@ -142,6 +141,7 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do end end) + Streamer.stream(["user", "user:pleroma_chat"], object) {:ok, object, meta} end end From 32431ad1ee88d260b720fab05fce76eb75bfe107 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 29 May 2020 16:07:40 +0200 Subject: [PATCH 136/401] Docs: Also add the streaming docs to the Chat api doc. --- docs/API/chats.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/API/chats.md b/docs/API/chats.md index 2e415e4da..2eca5adf6 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -220,3 +220,7 @@ There's a new `pleroma:chat_mention` notification, which has this form: "created_at": "somedate" } ``` + +### Streaming + +There is an additional `user:pleroma_chat` stream. Incoming chat messages will make the current chat be sent to this `user` stream. The `event` of an incoming chat message is `pleroma:chat_update`. The payload is the updated chat with the incoming chat message in the `last_message` field. From b3b367b894d1605202625310e7d8b1ed6ed5eb13 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 7 May 2020 21:52:45 +0200 Subject: [PATCH 137/401] Bugfix: Reuse Controller.Helper pagination for APC2S --- .../activity_pub/activity_pub_controller.ex | 3 ++ .../web/activity_pub/views/user_view.ex | 34 +++++-------- lib/pleroma/web/controller_helper.ex | 48 +++++++++++------- .../controllers/timeline_controller.ex | 4 +- .../activity_pub_controller_test.exs | 50 ++++++++++++++++++- .../web/activity_pub/views/user_view_test.exs | 31 ------------ 6 files changed, 94 insertions(+), 76 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 28727d619..b624d4255 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -21,6 +21,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do alias Pleroma.Web.ActivityPub.UserView alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.ControllerHelper alias Pleroma.Web.Endpoint alias Pleroma.Web.FederatingPlug alias Pleroma.Web.Federator @@ -251,6 +252,7 @@ def outbox( |> put_view(UserView) |> render("activity_collection_page.json", %{ activities: activities, + pagination: ControllerHelper.get_pagination_fields(conn, activities, %{"limit" => "10"}), iri: "#{user.ap_id}/outbox" }) end @@ -368,6 +370,7 @@ def read_inbox( |> put_view(UserView) |> render("activity_collection_page.json", %{ activities: activities, + pagination: ControllerHelper.get_pagination_fields(conn, activities, %{"limit" => "10"}), iri: "#{user.ap_id}/inbox" }) end diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index 34590b16d..4a02b09a1 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -213,34 +213,24 @@ def render("activity_collection.json", %{iri: iri}) do |> Map.merge(Utils.make_json_ld_header()) end - 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(activities, 0).id, - Enum.at(Enum.reverse(activities), 0).id, - Enum.map(activities, fn act -> - {:ok, data} = Transmogrifier.prepare_outgoing(act.data) - data - end) - } - else - { - 0, - 0, - [] - } - end + def render("activity_collection_page.json", %{ + activities: activities, + iri: iri, + pagination: pagination + }) do + collection = + Enum.map(activities, fn activity -> + {:ok, data} = Transmogrifier.prepare_outgoing(activity.data) + data + end) %{ - "id" => "#{iri}?max_id=#{max_id}&page=true", "type" => "OrderedCollectionPage", "partOf" => iri, - "orderedItems" => collection, - "next" => "#{iri}?max_id=#{min_id}&page=true" + "orderedItems" => collection } |> Map.merge(Utils.make_json_ld_header()) + |> Map.merge(pagination) end defp maybe_put_total_items(map, false, _total), do: map diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 5a1316a5f..2d35bb56c 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -5,6 +5,8 @@ defmodule Pleroma.Web.ControllerHelper do use Pleroma.Web, :controller + alias Pleroma.Pagination + # As in Mastodon API, per https://api.rubyonrails.org/classes/ActiveModel/Type/Boolean.html @falsy_param_values [false, 0, "0", "f", "F", "false", "False", "FALSE", "off", "OFF"] @@ -46,6 +48,16 @@ def add_link_headers(%{assigns: %{skip_link_headers: true}} = conn, _activities, do: conn def add_link_headers(conn, activities, extra_params) do + case get_pagination_fields(conn, activities, extra_params) do + %{"next" => next_url, "prev" => prev_url} -> + put_resp_header(conn, "link", "<#{next_url}>; rel=\"next\", <#{prev_url}>; rel=\"prev\"") + + _ -> + conn + end + end + + def get_pagination_fields(conn, activities, extra_params \\ %{}) do case List.last(activities) do %{id: max_id} -> params = @@ -54,29 +66,29 @@ def add_link_headers(conn, activities, extra_params) do |> 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 + activities + |> List.first() + |> Map.get(:id) - next_url = current_url(conn, Map.merge(params, %{max_id: max_id})) - prev_url = current_url(conn, Map.merge(params, %{min_id: min_id})) + fields = %{ + "next" => current_url(conn, Map.put(params, :max_id, max_id)), + "prev" => current_url(conn, Map.put(params, :min_id, min_id)) + } - put_resp_header(conn, "link", "<#{next_url}>; rel=\"next\", <#{prev_url}>; rel=\"prev\"") + # Generating an `id` without already present pagination keys would + # need a query-restriction with an `q.id >= ^id` or `q.id <= ^id` + # instead of the `q.id > ^min_id` and `q.id < ^max_id`. + # This is because we only have ids present inside of the page, while + # `min_id`, `since_id` and `max_id` requires to know one outside of it. + if Map.take(conn.params, Pagination.page_keys() -- ["limit", "order"]) != [] do + Map.put(fields, "id", current_url(conn, conn.params)) + else + fields + end _ -> - conn + %{} end end diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 958567510..c852082a5 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -51,10 +51,8 @@ def home(%{assigns: %{user: user}} = conn, params) do |> Map.put("reply_filtering_user", user) |> Map.put("user", user) - recipients = [user.ap_id | User.following(user)] - activities = - recipients + [user.ap_id | User.following(user)] |> ActivityPub.fetch_activities(params) |> Enum.reverse() diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index 24edab41a..3f48553c9 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -804,17 +804,63 @@ test "it requires authentication", %{conn: conn} do end describe "GET /users/:nickname/outbox" do + test "it paginates correctly", %{conn: conn} do + user = insert(:user) + conn = assign(conn, :user, user) + outbox_endpoint = user.ap_id <> "/outbox" + + _posts = + for i <- 0..15 do + {:ok, activity} = CommonAPI.post(user, %{status: "post #{i}"}) + activity + end + + result = + conn + |> put_req_header("accept", "application/activity+json") + |> get(outbox_endpoint <> "?page=true") + |> json_response(200) + + result_ids = Enum.map(result["orderedItems"], fn x -> x["id"] end) + assert length(result["orderedItems"]) == 10 + assert length(result_ids) == 10 + assert result["next"] + assert String.starts_with?(result["next"], outbox_endpoint) + + result_next = + conn + |> put_req_header("accept", "application/activity+json") + |> get(result["next"]) + |> json_response(200) + + result_next_ids = Enum.map(result_next["orderedItems"], fn x -> x["id"] end) + assert length(result_next["orderedItems"]) == 6 + assert length(result_next_ids) == 6 + refute Enum.find(result_next_ids, fn x -> x in result_ids end) + refute Enum.find(result_ids, fn x -> x in result_next_ids end) + assert String.starts_with?(result["id"], outbox_endpoint) + + result_next_again = + conn + |> put_req_header("accept", "application/activity+json") + |> get(result_next["id"]) + |> json_response(200) + + assert result_next == result_next_again + end + test "it returns 200 even if there're no activities", %{conn: conn} do user = insert(:user) + outbox_endpoint = user.ap_id <> "/outbox" conn = conn |> assign(:user, user) |> put_req_header("accept", "application/activity+json") - |> get("/users/#{user.nickname}/outbox") + |> get(outbox_endpoint) result = json_response(conn, 200) - assert user.ap_id <> "/outbox" == result["id"] + assert outbox_endpoint == result["id"] end test "it returns a note activity in a collection", %{conn: conn} do diff --git a/test/web/activity_pub/views/user_view_test.exs b/test/web/activity_pub/views/user_view_test.exs index 20b0f223c..bec15a996 100644 --- a/test/web/activity_pub/views/user_view_test.exs +++ b/test/web/activity_pub/views/user_view_test.exs @@ -158,35 +158,4 @@ test "sets correct totalItems when follows are hidden but the follow counter is assert %{"totalItems" => 1} = UserView.render("following.json", %{user: user}) end end - - test "activity collection page aginates correctly" do - user = insert(:user) - - posts = - for i <- 0..25 do - {:ok, activity} = CommonAPI.post(user, %{status: "post #{i}"}) - activity - end - - # outbox sorts chronologically, newest first, with ten per page - posts = Enum.reverse(posts) - - %{"next" => next_url} = - UserView.render("activity_collection_page.json", %{ - iri: "#{user.ap_id}/outbox", - activities: Enum.take(posts, 10) - }) - - next_id = Enum.at(posts, 9).id - assert next_url =~ next_id - - %{"next" => next_url} = - UserView.render("activity_collection_page.json", %{ - iri: "#{user.ap_id}/outbox", - activities: Enum.take(Enum.drop(posts, 10), 10) - }) - - next_id = Enum.at(posts, 19).id - assert next_url =~ next_id - end end From 2c18830d0dbd7f63cd20dcf5167254fede538930 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 8 May 2020 03:08:11 +0200 Subject: [PATCH 138/401] Bugfix: router: allow basic_auth for outbox --- lib/pleroma/web/router.ex | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index e493a4153..d65af23d9 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -571,13 +571,6 @@ defmodule Pleroma.Web.Router do get("/mailer/unsubscribe/:token", Mailer.SubscriptionController, :unsubscribe) end - scope "/", Pleroma.Web.ActivityPub do - # XXX: not really ostatus - pipe_through(:ostatus) - - get("/users/:nickname/outbox", ActivityPubController, :outbox) - end - pipeline :ap_service_actor do plug(:accepts, ["activity+json", "json"]) end @@ -602,6 +595,7 @@ defmodule Pleroma.Web.Router do get("/api/ap/whoami", ActivityPubController, :whoami) get("/users/:nickname/inbox", ActivityPubController, :read_inbox) + get("/users/:nickname/outbox", ActivityPubController, :outbox) post("/users/:nickname/outbox", ActivityPubController, :update_outbox) post("/api/ap/upload_media", ActivityPubController, :upload_media) From a43b435c0ad8a1198241fbd18e1a5f1be830f4b5 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 8 May 2020 03:05:56 +0200 Subject: [PATCH 139/401] AP C2S: allow limit & order on outbox & read_inbox --- .../activity_pub/activity_pub_controller.ex | 45 +++++++++---------- lib/pleroma/web/controller_helper.ex | 2 +- .../activity_pub_controller_test.exs | 6 +-- 3 files changed, 24 insertions(+), 29 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index b624d4255..5b8441384 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -231,28 +231,22 @@ def outbox( 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, for_user, %{ - "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, for_user, %{ - "limit" => 10, - "include_poll_votes" => true - }) - end + # "include_poll_votes" 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 + params = + params + |> Map.drop(["nickname", "page"]) + |> Map.put("include_poll_votes", true) + + activities = ActivityPub.fetch_user_activities(user, for_user, params) conn |> put_resp_content_type("application/activity+json") |> put_view(UserView) |> render("activity_collection_page.json", %{ activities: activities, - pagination: ControllerHelper.get_pagination_fields(conn, activities, %{"limit" => "10"}), + pagination: ControllerHelper.get_pagination_fields(conn, activities), iri: "#{user.ap_id}/outbox" }) end @@ -355,22 +349,23 @@ def read_inbox( %{"nickname" => nickname, "page" => page?} = params ) when page? in [true, "true"] do + params = + params + |> Map.drop(["nickname", "page"]) + |> Map.put("blocking_user", user) + |> Map.put("user", user) + activities = - if params["max_id"] do - ActivityPub.fetch_activities([user.ap_id | User.following(user)], %{ - "max_id" => params["max_id"], - "limit" => 10 - }) - else - ActivityPub.fetch_activities([user.ap_id | User.following(user)], %{"limit" => 10}) - end + [user.ap_id | User.following(user)] + |> ActivityPub.fetch_activities(params) + |> Enum.reverse() conn |> put_resp_content_type("application/activity+json") |> put_view(UserView) |> render("activity_collection_page.json", %{ activities: activities, - pagination: ControllerHelper.get_pagination_fields(conn, activities, %{"limit" => "10"}), + pagination: ControllerHelper.get_pagination_fields(conn, activities), iri: "#{user.ap_id}/inbox" }) end diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 2d35bb56c..9e5444817 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -63,8 +63,8 @@ def get_pagination_fields(conn, activities, extra_params \\ %{}) do params = conn.params |> Map.drop(Map.keys(conn.path_params)) - |> Map.drop(["since_id", "max_id", "min_id"]) |> Map.merge(extra_params) + |> Map.drop(Pagination.page_keys() -- ["limit", "order"]) min_id = activities diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index 3f48553c9..e490a5744 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -810,7 +810,7 @@ test "it paginates correctly", %{conn: conn} do outbox_endpoint = user.ap_id <> "/outbox" _posts = - for i <- 0..15 do + for i <- 0..25 do {:ok, activity} = CommonAPI.post(user, %{status: "post #{i}"}) activity end @@ -822,8 +822,8 @@ test "it paginates correctly", %{conn: conn} do |> json_response(200) result_ids = Enum.map(result["orderedItems"], fn x -> x["id"] end) - assert length(result["orderedItems"]) == 10 - assert length(result_ids) == 10 + assert length(result["orderedItems"]) == 20 + assert length(result_ids) == 20 assert result["next"] assert String.starts_with?(result["next"], outbox_endpoint) From da1e31fae3f7a7e0063c3a6fb4315e1578d72daa Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 29 May 2020 17:17:02 +0200 Subject: [PATCH 140/401] http_security_plug.ex: Fix non-proxied media --- lib/pleroma/plugs/http_security_plug.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index 2208d1d6c..4b926e867 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -75,7 +75,7 @@ defp csp_string do sources = get_proxy_and_attachment_sources() {[img_src, sources], [media_src, sources]} else - {img_src, media_src} + {img_src <> " https:", media_src <> " https:"} end connect_src = ["connect-src 'self' ", static_url, ?\s, websocket_url] From d67b302810c53d92ace7c347c77eecc10be6bcd6 Mon Sep 17 00:00:00 2001 From: stwf Date: Tue, 12 May 2020 11:08:00 -0400 Subject: [PATCH 141/401] preload data into index.html --- config/config.exs | 25 ++-- .../web/fallback_redirect_controller.ex | 82 +++++++---- lib/pleroma/web/nodeinfo/nodeinfo.ex | 130 ++++++++++++++++++ .../web/nodeinfo/nodeinfo_controller.ex | 112 ++------------- lib/pleroma/web/preload.ex | 30 ++++ lib/pleroma/web/preload/instance.ex | 49 +++++++ lib/pleroma/web/preload/provider.ex | 7 + lib/pleroma/web/preload/timelines.ex | 42 ++++++ lib/pleroma/web/preload/user.ex | 25 ++++ lib/pleroma/web/router.ex | 2 +- test/plugs/instance_static_test.exs | 2 +- test/web/fallback_test.exs | 38 +++-- test/web/preload/instance_test.exs | 37 +++++ test/web/preload/timeline_test.exs | 74 ++++++++++ test/web/preload/user_test.exs | 33 +++++ 15 files changed, 532 insertions(+), 156 deletions(-) create mode 100644 lib/pleroma/web/nodeinfo/nodeinfo.ex create mode 100644 lib/pleroma/web/preload.ex create mode 100644 lib/pleroma/web/preload/instance.ex create mode 100644 lib/pleroma/web/preload/provider.ex create mode 100644 lib/pleroma/web/preload/timelines.ex create mode 100644 lib/pleroma/web/preload/user.ex create mode 100644 test/web/preload/instance_test.exs create mode 100644 test/web/preload/timeline_test.exs create mode 100644 test/web/preload/user_test.exs diff --git a/config/config.exs b/config/config.exs index d15998715..1539b15c6 100644 --- a/config/config.exs +++ b/config/config.exs @@ -240,18 +240,7 @@ account_field_value_length: 2048, external_user_synchronization: true, extended_nickname_format: true, - cleanup_attachments: false, - multi_factor_authentication: [ - totp: [ - # digits 6 or 8 - digits: 6, - period: 30 - ], - backup_codes: [ - number: 5, - length: 16 - ] - ] + cleanup_attachments: false config :pleroma, :feed, post_title: %{ @@ -360,8 +349,7 @@ reject: [], accept: [], avatar_removal: [], - banner_removal: [], - reject_deletes: [] + banner_removal: [] config :pleroma, :mrf_keyword, reject: [], @@ -427,6 +415,13 @@ ], unfurl_nsfw: false +config :pleroma, Pleroma.Web.Preload, + providers: [ + Pleroma.Web.Preload.Providers.Instance, + Pleroma.Web.Preload.Providers.User, + Pleroma.Web.Preload.Providers.Timelines + ] + config :pleroma, :http_security, enabled: true, sts: false, @@ -681,8 +676,6 @@ profiles: %{local: false, remote: false}, activities: %{local: false, remote: false} -config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false - # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/lib/pleroma/web/fallback_redirect_controller.ex b/lib/pleroma/web/fallback_redirect_controller.ex index 0d9d578fc..932fb8d7e 100644 --- a/lib/pleroma/web/fallback_redirect_controller.ex +++ b/lib/pleroma/web/fallback_redirect_controller.ex @@ -4,11 +4,10 @@ defmodule Fallback.RedirectController do use Pleroma.Web, :controller - require Logger - alias Pleroma.User alias Pleroma.Web.Metadata + alias Pleroma.Web.Preload def api_not_implemented(conn, _params) do conn @@ -16,16 +15,7 @@ def api_not_implemented(conn, _params) do |> json(%{error: "Not implemented"}) end - def redirector(conn, _params, code \\ 200) - - # redirect to admin section - # /pleroma/admin -> /pleroma/admin/ - # - def redirector(conn, %{"path" => ["pleroma", "admin"]} = _, _code) do - redirect(conn, to: "/pleroma/admin/") - end - - def redirector(conn, _params, code) do + def redirector(conn, _params, code \\ 200) do conn |> put_resp_content_type("text/html") |> send_file(code, index_file_path()) @@ -43,28 +33,34 @@ def redirector_with_meta(conn, %{"maybe_nickname_or_id" => maybe_nickname_or_id} def redirector_with_meta(conn, params) do {:ok, index_content} = File.read(index_file_path()) - tags = - try do - Metadata.build_tags(params) - rescue - e -> - Logger.error( - "Metadata rendering for #{conn.request_path} failed.\n" <> - Exception.format(:error, e, __STACKTRACE__) - ) + tags = build_tags(conn, params) + preloads = preload_data(conn, params) - "" - end - - response = String.replace(index_content, "", tags) + response = + index_content + |> String.replace("", tags) + |> String.replace("", preloads) conn |> put_resp_content_type("text/html") |> send_resp(200, response) end - def index_file_path do - Pleroma.Plugs.InstanceStatic.file_path("index.html") + def redirector_with_preload(conn, %{"path" => ["pleroma", "admin"]}) do + redirect(conn, to: "/pleroma/admin/") + end + + def redirector_with_preload(conn, params) do + {:ok, index_content} = File.read(index_file_path()) + preloads = preload_data(conn, params) + + response = + index_content + |> String.replace("", preloads) + + conn + |> put_resp_content_type("text/html") + |> send_resp(200, response) end def registration_page(conn, params) do @@ -76,4 +72,36 @@ def empty(conn, _params) do |> put_status(204) |> text("") end + + defp index_file_path do + Pleroma.Plugs.InstanceStatic.file_path("index.html") + end + + defp build_tags(conn, params) do + try do + Metadata.build_tags(params) + rescue + e -> + Logger.error( + "Metadata rendering for #{conn.request_path} failed.\n" <> + Exception.format(:error, e, __STACKTRACE__) + ) + + "" + end + end + + defp preload_data(conn, params) do + try do + Preload.build_tags(conn, params) + rescue + e -> + Logger.error( + "Preloading for #{conn.request_path} failed.\n" <> + Exception.format(:error, e, __STACKTRACE__) + ) + + "" + end + end end diff --git a/lib/pleroma/web/nodeinfo/nodeinfo.ex b/lib/pleroma/web/nodeinfo/nodeinfo.ex new file mode 100644 index 000000000..d26b7c938 --- /dev/null +++ b/lib/pleroma/web/nodeinfo/nodeinfo.ex @@ -0,0 +1,130 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Nodeinfo.Nodeinfo do + alias Pleroma.Config + alias Pleroma.Stats + alias Pleroma.User + alias Pleroma.Web.ActivityPub.MRF + alias Pleroma.Web.Federator.Publisher + + # returns a nodeinfo 2.0 map, since 2.1 just adds a repository field + # under software. + def get_nodeinfo("2.0") do + stats = Stats.get_stats() + + quarantined = Config.get([:instance, :quarantined_instances], []) + + staff_accounts = + User.all_superusers() + |> Enum.map(fn u -> u.ap_id end) + + federation_response = + if Config.get([:instance, :mrf_transparency]) do + {:ok, data} = MRF.describe() + + data + |> Map.merge(%{quarantined_instances: quarantined}) + else + %{} + end + |> Map.put(:enabled, Config.get([:instance, :federating])) + + features = + [ + "pleroma_api", + "mastodon_api", + "mastodon_api_streaming", + "polls", + "pleroma_explicit_addressing", + "shareable_emoji_packs", + "multifetch", + "pleroma:api/v1/notifications:include_types_filter", + if Config.get([:media_proxy, :enabled]) do + "media_proxy" + end, + if Config.get([:gopher, :enabled]) do + "gopher" + end, + if Config.get([:chat, :enabled]) do + "chat" + end, + if Config.get([:instance, :allow_relay]) do + "relay" + end, + if Config.get([:instance, :safe_dm_mentions]) do + "safe_dm_mentions" + end, + "pleroma_emoji_reactions" + ] + |> Enum.filter(& &1) + + %{ + version: "2.0", + software: %{ + name: Pleroma.Application.name() |> String.downcase(), + version: Pleroma.Application.version() + }, + protocols: Publisher.gather_nodeinfo_protocol_names(), + services: %{ + inbound: [], + outbound: [] + }, + openRegistrations: Config.get([:instance, :registrations_open]), + usage: %{ + users: %{ + total: Map.get(stats, :user_count, 0) + }, + localPosts: Map.get(stats, :status_count, 0) + }, + metadata: %{ + nodeName: Config.get([:instance, :name]), + nodeDescription: Config.get([:instance, :description]), + private: !Config.get([:instance, :public], true), + suggestions: %{ + enabled: false + }, + staffAccounts: staff_accounts, + federation: federation_response, + pollLimits: Config.get([:instance, :poll_limits]), + postFormats: Config.get([:instance, :allowed_post_formats]), + uploadLimits: %{ + general: Config.get([:instance, :upload_limit]), + avatar: Config.get([:instance, :avatar_upload_limit]), + banner: Config.get([:instance, :banner_upload_limit]), + background: Config.get([:instance, :background_upload_limit]) + }, + fieldsLimits: %{ + maxFields: Config.get([:instance, :max_account_fields]), + maxRemoteFields: Config.get([:instance, :max_remote_account_fields]), + nameLength: Config.get([:instance, :account_field_name_length]), + valueLength: Config.get([:instance, :account_field_value_length]) + }, + accountActivationRequired: Config.get([:instance, :account_activation_required], false), + invitesEnabled: Config.get([:instance, :invites_enabled], false), + mailerEnabled: Config.get([Pleroma.Emails.Mailer, :enabled], false), + features: features, + restrictedNicknames: Config.get([Pleroma.User, :restricted_nicknames]), + skipThreadContainment: Config.get([:instance, :skip_thread_containment], false) + } + } + end + + def get_nodeinfo("2.1") do + raw_response = get_nodeinfo("2.0") + + updated_software = + raw_response + |> Map.get(:software) + |> Map.put(:repository, Pleroma.Application.repository()) + + raw_response + |> Map.put(:software, updated_software) + |> Map.put(:version, "2.1") + end + + def get_nodeinfo(_version) do + {:error, :missing} + end +end diff --git a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex index 721b599d4..8c7a9e565 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex @@ -5,12 +5,8 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do use Pleroma.Web, :controller - alias Pleroma.Config - alias Pleroma.Stats - alias Pleroma.User alias Pleroma.Web - alias Pleroma.Web.Federator.Publisher - alias Pleroma.Web.MastodonAPI.InstanceView + alias Pleroma.Web.Nodeinfo.Nodeinfo def schemas(conn, _params) do response = %{ @@ -29,102 +25,20 @@ def schemas(conn, _params) do json(conn, response) end - # returns a nodeinfo 2.0 map, since 2.1 just adds a repository field - # under software. - def raw_nodeinfo do - stats = Stats.get_stats() - - staff_accounts = - User.all_superusers() - |> Enum.map(fn u -> u.ap_id end) - - features = InstanceView.features() - federation = InstanceView.federation() - - %{ - version: "2.0", - software: %{ - name: Pleroma.Application.name() |> String.downcase(), - version: Pleroma.Application.version() - }, - protocols: Publisher.gather_nodeinfo_protocol_names(), - services: %{ - inbound: [], - outbound: [] - }, - openRegistrations: Config.get([:instance, :registrations_open]), - usage: %{ - users: %{ - total: Map.get(stats, :user_count, 0) - }, - localPosts: Map.get(stats, :status_count, 0) - }, - metadata: %{ - nodeName: Config.get([:instance, :name]), - nodeDescription: Config.get([:instance, :description]), - private: !Config.get([:instance, :public], true), - suggestions: %{ - enabled: false - }, - staffAccounts: staff_accounts, - federation: federation, - pollLimits: Config.get([:instance, :poll_limits]), - postFormats: Config.get([:instance, :allowed_post_formats]), - uploadLimits: %{ - general: Config.get([:instance, :upload_limit]), - avatar: Config.get([:instance, :avatar_upload_limit]), - banner: Config.get([:instance, :banner_upload_limit]), - background: Config.get([:instance, :background_upload_limit]) - }, - fieldsLimits: %{ - maxFields: Config.get([:instance, :max_account_fields]), - maxRemoteFields: Config.get([:instance, :max_remote_account_fields]), - nameLength: Config.get([:instance, :account_field_name_length]), - valueLength: Config.get([:instance, :account_field_value_length]) - }, - accountActivationRequired: Config.get([:instance, :account_activation_required], false), - invitesEnabled: Config.get([:instance, :invites_enabled], false), - mailerEnabled: Config.get([Pleroma.Emails.Mailer, :enabled], false), - features: features, - restrictedNicknames: Config.get([Pleroma.User, :restricted_nicknames]), - skipThreadContainment: Config.get([:instance, :skip_thread_containment], false) - } - } - end - # Schema definition: https://github.com/jhass/nodeinfo/blob/master/schemas/2.0/schema.json # and https://github.com/jhass/nodeinfo/blob/master/schemas/2.1/schema.json - def nodeinfo(conn, %{"version" => "2.0"}) do - conn - |> put_resp_header( - "content-type", - "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" - ) - |> json(raw_nodeinfo()) - end + def nodeinfo(conn, %{"version" => version}) do + case Nodeinfo.get_nodeinfo(version) do + {:error, :missing} -> + render_error(conn, :not_found, "Nodeinfo schema version not handled") - def nodeinfo(conn, %{"version" => "2.1"}) do - raw_response = raw_nodeinfo() - - updated_software = - raw_response - |> Map.get(:software) - |> Map.put(:repository, Pleroma.Application.repository()) - - response = - raw_response - |> Map.put(:software, updated_software) - |> Map.put(:version, "2.1") - - conn - |> put_resp_header( - "content-type", - "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.1#; charset=utf-8" - ) - |> json(response) - end - - def nodeinfo(conn, _) do - render_error(conn, :not_found, "Nodeinfo schema version not handled") + node_info -> + conn + |> put_resp_header( + "content-type", + "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" + ) + |> json(node_info) + end end end diff --git a/lib/pleroma/web/preload.ex b/lib/pleroma/web/preload.ex new file mode 100644 index 000000000..c2211c597 --- /dev/null +++ b/lib/pleroma/web/preload.ex @@ -0,0 +1,30 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload do + alias Phoenix.HTML + require Logger + + def build_tags(_conn, params) do + preload_data = + Enum.reduce(Pleroma.Config.get([__MODULE__, :providers], []), %{}, fn parser, acc -> + Map.merge(acc, parser.generate_terms(params)) + end) + + rendered_html = + preload_data + |> Jason.encode!() + |> build_script_tag() + |> HTML.safe_to_string() + + rendered_html + end + + def build_script_tag(content) do + HTML.Tag.content_tag(:script, HTML.raw(content), + id: "initial-results", + type: "application/json" + ) + end +end diff --git a/lib/pleroma/web/preload/instance.ex b/lib/pleroma/web/preload/instance.ex new file mode 100644 index 000000000..0b6fd3313 --- /dev/null +++ b/lib/pleroma/web/preload/instance.ex @@ -0,0 +1,49 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.Instance do + alias Pleroma.Web.MastodonAPI.InstanceView + alias Pleroma.Web.Nodeinfo.Nodeinfo + alias Pleroma.Web.Preload.Providers.Provider + + @behaviour Provider + @instance_url :"/api/v1/instance" + @panel_url :"/instance/panel.html" + @nodeinfo_url :"/nodeinfo/2.0" + + @impl Provider + def generate_terms(_params) do + %{} + |> build_info_tag() + |> build_panel_tag() + |> build_nodeinfo_tag() + end + + defp build_info_tag(acc) do + info_data = InstanceView.render("show.json", %{}) + + Map.put(acc, @instance_url, info_data) + end + + defp build_panel_tag(acc) do + instance_path = Path.join(:code.priv_dir(:pleroma), "static/instance/panel.html") + + if File.exists?(instance_path) do + panel_data = File.read!(instance_path) + Map.put(acc, @panel_url, panel_data) + else + acc + end + end + + defp build_nodeinfo_tag(acc) do + case Nodeinfo.get_nodeinfo("2.0") do + {:error, _} -> + acc + + nodeinfo_data -> + Map.put(acc, @nodeinfo_url, nodeinfo_data) + end + end +end diff --git a/lib/pleroma/web/preload/provider.ex b/lib/pleroma/web/preload/provider.ex new file mode 100644 index 000000000..7ef595a34 --- /dev/null +++ b/lib/pleroma/web/preload/provider.ex @@ -0,0 +1,7 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.Provider do + @callback generate_terms(map()) :: map() +end diff --git a/lib/pleroma/web/preload/timelines.ex b/lib/pleroma/web/preload/timelines.ex new file mode 100644 index 000000000..dbd7db407 --- /dev/null +++ b/lib/pleroma/web/preload/timelines.ex @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.Timelines do + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.Preload.Providers.Provider + + @behaviour Provider + @public_url :"/api/v1/timelines/public" + + @impl Provider + def generate_terms(_params) do + build_public_tag(%{}) + end + + def build_public_tag(acc) do + if Pleroma.Config.get([:restrict_unauthenticated, :timelines, :federated], true) do + acc + else + Map.put(acc, @public_url, public_timeline(nil)) + end + end + + defp public_timeline(user) do + activities = + create_timeline_params(user) + |> Map.put("local_only", false) + |> ActivityPub.fetch_public_activities() + + StatusView.render("index.json", activities: activities, for: user, as: :activity) + end + + defp create_timeline_params(user) do + %{} + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("user", user) + end +end diff --git a/lib/pleroma/web/preload/user.ex b/lib/pleroma/web/preload/user.ex new file mode 100644 index 000000000..3a244845b --- /dev/null +++ b/lib/pleroma/web/preload/user.ex @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.User do + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.Preload.Providers.Provider + + @behaviour Provider + @account_url :"/api/v1/accounts" + + @impl Provider + def generate_terms(%{user: user}) do + build_accounts_tag(%{}, user) + end + + def generate_terms(_params), do: %{} + + def build_accounts_tag(acc, nil), do: acc + + def build_accounts_tag(acc, user) do + account_data = AccountView.render("show.json", %{user: user, for: user}) + Map.put(acc, @account_url, account_data) + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index e493a4153..a1ef2633d 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -716,7 +716,7 @@ defmodule Pleroma.Web.Router do get("/registration/:token", RedirectController, :registration_page) get("/:maybe_nickname_or_id", RedirectController, :redirector_with_meta) get("/api*path", RedirectController, :api_not_implemented) - get("/*path", RedirectController, :redirector) + get("/*path", RedirectController, :redirector_with_preload) options("/*path", RedirectController, :empty) end diff --git a/test/plugs/instance_static_test.exs b/test/plugs/instance_static_test.exs index b8f070d6a..be2613ad0 100644 --- a/test/plugs/instance_static_test.exs +++ b/test/plugs/instance_static_test.exs @@ -16,7 +16,7 @@ defmodule Pleroma.Web.RuntimeStaticPlugTest do test "overrides index" do bundled_index = get(build_conn(), "/") - assert html_response(bundled_index, 200) == File.read!("priv/static/index.html") + refute html_response(bundled_index, 200) == "hello world" File.write!(@dir <> "/index.html", "hello world") diff --git a/test/web/fallback_test.exs b/test/web/fallback_test.exs index 3919ef93a..3b7a51d5e 100644 --- a/test/web/fallback_test.exs +++ b/test/web/fallback_test.exs @@ -6,22 +6,36 @@ defmodule Pleroma.Web.FallbackTest do use Pleroma.Web.ConnCase import Pleroma.Factory - test "GET /registration/:token", %{conn: conn} do - assert conn - |> get("/registration/foo") - |> html_response(200) =~ "" + describe "neither preloaded data nor metadata attached to" do + test "GET /registration/:token", %{conn: conn} do + response = get(conn, "/registration/foo") + + assert html_response(response, 200) =~ "" + assert html_response(response, 200) =~ "" + end end - test "GET /:maybe_nickname_or_id", %{conn: conn} do - user = insert(:user) + describe "preloaded data and metadata attached to" do + test "GET /:maybe_nickname_or_id", %{conn: conn} do + user = insert(:user) + user_missing = get(conn, "/foo") + user_present = get(conn, "/#{user.nickname}") - assert conn - |> get("/foo") - |> html_response(200) =~ "" + assert html_response(user_missing, 200) =~ "" + refute html_response(user_present, 200) =~ "" - refute conn - |> get("/" <> user.nickname) - |> html_response(200) =~ "" + assert html_response(user_missing, 200) =~ "" + refute html_response(user_present, 200) =~ "" + end + end + + describe "preloaded data only attached to" do + test "GET /*path", %{conn: conn} do + public_page = get(conn, "/main/public") + + assert html_response(public_page, 200) =~ "" + refute html_response(public_page, 200) =~ "" + end end test "GET /api*path", %{conn: conn} do diff --git a/test/web/preload/instance_test.exs b/test/web/preload/instance_test.exs new file mode 100644 index 000000000..52f9bab3b --- /dev/null +++ b/test/web/preload/instance_test.exs @@ -0,0 +1,37 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.InstanceTest do + use Pleroma.DataCase + alias Pleroma.Web.Preload.Providers.Instance + + setup do: {:ok, Instance.generate_terms(nil)} + + test "it renders the info", %{"/api/v1/instance": info} do + assert %{ + description: description, + email: "admin@example.com", + registrations: true + } = info + + assert String.equivalent?(description, "A Pleroma instance, an alternative fediverse server") + end + + test "it renders the panel", %{"/instance/panel.html": panel} do + assert String.contains?( + panel, + "

Welcome to Pleroma!

" + ) + end + + test "it renders the node_info", %{"/nodeinfo/2.0": nodeinfo} do + %{ + metadata: metadata, + version: "2.0" + } = nodeinfo + + assert metadata.private == false + assert metadata.suggestions == %{enabled: false} + end +end diff --git a/test/web/preload/timeline_test.exs b/test/web/preload/timeline_test.exs new file mode 100644 index 000000000..00b10d0ab --- /dev/null +++ b/test/web/preload/timeline_test.exs @@ -0,0 +1,74 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.TimelineTest do + use Pleroma.DataCase + import Pleroma.Factory + + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Preload.Providers.Timelines + + @public_url :"/api/v1/timelines/public" + + describe "unauthenticated timeliness when restricted" do + setup do + svd_config = Pleroma.Config.get([:restrict_unauthenticated, :timelines]) + Pleroma.Config.put([:restrict_unauthenticated, :timelines], %{local: true, federated: true}) + + on_exit(fn -> + Pleroma.Config.put([:restrict_unauthenticated, :timelines], svd_config) + end) + + :ok + end + + test "return nothing" do + tl_data = Timelines.generate_terms(%{}) + + refute Map.has_key?(tl_data, "/api/v1/timelines/public") + end + end + + describe "unauthenticated timeliness when unrestricted" do + setup do + svd_config = Pleroma.Config.get([:restrict_unauthenticated, :timelines]) + + Pleroma.Config.put([:restrict_unauthenticated, :timelines], %{ + local: false, + federated: false + }) + + on_exit(fn -> + Pleroma.Config.put([:restrict_unauthenticated, :timelines], svd_config) + end) + + {:ok, user: insert(:user)} + end + + test "returns the timeline when not restricted" do + assert Timelines.generate_terms(%{}) + |> Map.has_key?(@public_url) + end + + test "returns public items", %{user: user} do + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 1!"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 2!"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 3!"}) + + assert Timelines.generate_terms(%{}) + |> Map.fetch!(@public_url) + |> Enum.count() == 3 + end + + test "does not return non-public items", %{user: user} do + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 1!", "visibility" => "unlisted"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 2!", "visibility" => "direct"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 3!"}) + + assert Timelines.generate_terms(%{}) + |> Map.fetch!(@public_url) + |> Enum.count() == 1 + end + end +end diff --git a/test/web/preload/user_test.exs b/test/web/preload/user_test.exs new file mode 100644 index 000000000..99232cdfa --- /dev/null +++ b/test/web/preload/user_test.exs @@ -0,0 +1,33 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.UserTest do + use Pleroma.DataCase + import Pleroma.Factory + alias Pleroma.Web.Preload.Providers.User + + describe "returns empty when user doesn't exist" do + test "nil user specified" do + refute User.generate_terms(%{user: nil}) + |> Map.has_key?("/api/v1/accounts") + end + + test "missing user specified" do + refute User.generate_terms(%{user: :not_a_user}) + |> Map.has_key?("/api/v1/accounts") + end + end + + describe "specified user exists" do + setup do + user = insert(:user) + + {:ok, User.generate_terms(%{user: user})} + end + + test "account is rendered", %{"/api/v1/accounts": accounts} do + assert %{acct: user, username: user} = accounts + end + end +end From c181e555db4a90f770418af67b1073ec958adb4d Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 29 May 2020 22:03:14 +0300 Subject: [PATCH 142/401] [#1794] Improvements to hashtags extraction from search query. --- .../controllers/search_controller.ex | 40 ++++++++++++++----- .../controllers/search_controller_test.exs | 36 ++++++++++++++++- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index 77e2224e4..23fe378a6 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -113,22 +113,44 @@ defp resource_search(:v2, "hashtags", query, _options) do query |> prepare_tags() |> Enum.map(fn tag -> - tag = String.trim_leading(tag, "#") %{name: tag, url: tags_path <> tag} end) end defp resource_search(:v1, "hashtags", query, _options) do - query - |> prepare_tags() - |> Enum.map(fn tag -> String.trim_leading(tag, "#") end) + prepare_tags(query) end - defp prepare_tags(query) do - query - |> String.split() - |> Enum.uniq() - |> Enum.filter(fn tag -> String.starts_with?(tag, "#") end) + defp prepare_tags(query, add_joined_tag \\ true) do + tags = + query + |> String.split(~r/[^#\w]+/, trim: true) + |> Enum.uniq_by(&String.downcase/1) + + explicit_tags = Enum.filter(tags, fn tag -> String.starts_with?(tag, "#") end) + + tags = + if Enum.any?(explicit_tags) do + explicit_tags + else + tags + end + + tags = Enum.map(tags, fn tag -> String.trim_leading(tag, "#") end) + + if Enum.empty?(explicit_tags) && add_joined_tag do + tags + |> Kernel.++([joined_tag(tags)]) + |> Enum.uniq_by(&String.downcase/1) + else + tags + end + end + + defp joined_tag(tags) do + tags + |> Enum.map(fn tag -> String.capitalize(tag) end) + |> Enum.join() end defp with_fallback(f, fallback \\ []) do diff --git a/test/web/mastodon_api/controllers/search_controller_test.exs b/test/web/mastodon_api/controllers/search_controller_test.exs index 7d0cafccc..498290377 100644 --- a/test/web/mastodon_api/controllers/search_controller_test.exs +++ b/test/web/mastodon_api/controllers/search_controller_test.exs @@ -75,6 +75,40 @@ test "search", %{conn: conn} do assert status["id"] == to_string(activity.id) end + test "constructs hashtags from search query", %{conn: conn} do + results = + conn + |> get("/api/v2/search?#{URI.encode_query(%{q: "some text with #explicit #hashtags"})}") + |> json_response_and_validate_schema(200) + + assert results["hashtags"] == [ + %{"name" => "explicit", "url" => "#{Web.base_url()}/tag/explicit"}, + %{"name" => "hashtags", "url" => "#{Web.base_url()}/tag/hashtags"} + ] + + results = + conn + |> get("/api/v2/search?#{URI.encode_query(%{q: "john doe JOHN DOE"})}") + |> json_response_and_validate_schema(200) + + assert results["hashtags"] == [ + %{"name" => "john", "url" => "#{Web.base_url()}/tag/john"}, + %{"name" => "doe", "url" => "#{Web.base_url()}/tag/doe"}, + %{"name" => "JohnDoe", "url" => "#{Web.base_url()}/tag/JohnDoe"} + ] + + results = + conn + |> get("/api/v2/search?#{URI.encode_query(%{q: "accident-prone"})}") + |> json_response_and_validate_schema(200) + + assert results["hashtags"] == [ + %{"name" => "accident", "url" => "#{Web.base_url()}/tag/accident"}, + %{"name" => "prone", "url" => "#{Web.base_url()}/tag/prone"}, + %{"name" => "AccidentProne", "url" => "#{Web.base_url()}/tag/AccidentProne"} + ] + end + test "excludes a blocked users from search results", %{conn: conn} do user = insert(:user) user_smith = insert(:user, %{nickname: "Agent", name: "I love 2hu"}) @@ -179,7 +213,7 @@ test "search", %{conn: conn} do [account | _] = results["accounts"] assert account["id"] == to_string(user_three.id) - assert results["hashtags"] == [] + assert results["hashtags"] == ["2hu"] [status] = results["statuses"] assert status["id"] == to_string(activity.id) From 0a83af330b7f33601848bca79bd1651b45eaea87 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Fri, 29 May 2020 23:05:03 +0300 Subject: [PATCH 143/401] fix unused var warning --- test/web/streamer/streamer_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index 4cf640ce8..3f012259a 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -124,7 +124,7 @@ test "it streams boosts of mastodon user in the 'user' stream", %{user: user} do |> Map.put("object", activity.data["object"]) |> Map.put("actor", user.ap_id) - {:ok, %Pleroma.Activity{data: data, local: false} = announce} = + {:ok, %Pleroma.Activity{data: _data, local: false} = announce} = Pleroma.Web.ActivityPub.Transmogrifier.handle_incoming(data) assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce} From 109af93227f65d308641e345c68c3884addb0181 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 29 May 2020 21:15:07 +0000 Subject: [PATCH 144/401] Apply suggestion to lib/pleroma/plugs/http_security_plug.ex --- lib/pleroma/plugs/http_security_plug.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index 4b926e867..589072535 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -75,7 +75,7 @@ defp csp_string do sources = get_proxy_and_attachment_sources() {[img_src, sources], [media_src, sources]} else - {img_src <> " https:", media_src <> " https:"} + {[img_src, " https:"], [media_src, " https:"]} end connect_src = ["connect-src 'self' ", static_url, ?\s, websocket_url] From d2a1975e565e2e83859a607af29320226877cc4d Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sat, 30 May 2020 00:18:17 +0300 Subject: [PATCH 145/401] mix.lock: update hackney to 1.16.0 Closes #1612 --- mix.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mix.lock b/mix.lock index 470b401a3..5383c2c6e 100644 --- a/mix.lock +++ b/mix.lock @@ -12,7 +12,7 @@ "calendar": {:hex, :calendar, "0.17.6", "ec291cb2e4ba499c2e8c0ef5f4ace974e2f9d02ae9e807e711a9b0c7850b9aee", [:mix], [{:tzdata, "~> 0.5.20 or ~> 0.1.201603 or ~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "738d0e17a93c2ccfe4ddc707bdc8e672e9074c8569498483feb1c4530fb91b2b"}, "captcha": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha.git", "e0f16822d578866e186a0974d65ad58cddc1e2ab", [ref: "e0f16822d578866e186a0974d65ad58cddc1e2ab"]}, "castore": {:hex, :castore, "0.1.5", "591c763a637af2cc468a72f006878584bc6c306f8d111ef8ba1d4c10e0684010", [:mix], [], "hexpm", "6db356b2bc6cc22561e051ff545c20ad064af57647e436650aa24d7d06cd941a"}, - "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"}, + "certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm", "d6222483060c17f0977fad1b7401ef0c5863c985a64352755f366aee3799c245"}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, @@ -50,12 +50,12 @@ "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"}, "gettext": {:hex, :gettext, "0.17.4", "f13088e1ec10ce01665cf25f5ff779e7df3f2dc71b37084976cf89d1aa124d5c", [:mix], [], "hexpm", "3c75b5ea8288e2ee7ea503ff9e30dfe4d07ad3c054576a6e60040e79a801e14d"}, "gun": {:git, "https://github.com/ninenines/gun.git", "e1a69b36b180a574c0ac314ced9613fdd52312cc", [ref: "e1a69b36b180a574c0ac314ced9613fdd52312cc"]}, - "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"}, + "hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"}, "html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.3.0", "f005ad692b717691203f940c686208aa3d8ffd9dd4bb3699240096a51fa9564e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, "http_signatures": {:git, "https://git.pleroma.social/pleroma/http_signatures.git", "293d77bb6f4a67ac8bde1428735c3b42f22cbb30", [ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"]}, "httpoison": {:hex, :httpoison, "1.6.2", "ace7c8d3a361cebccbed19c283c349b3d26991eff73a1eaaa8abae2e3c8089b6", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "aa2c74bd271af34239a3948779612f87df2422c2fdcfdbcec28d9c105f0773fe"}, - "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, + "idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"}, "inet_cidr": {:hex, :inet_cidr, "1.0.4", "a05744ab7c221ca8e395c926c3919a821eb512e8f36547c062f62c4ca0cf3d6e", [:mix], [], "hexpm", "64a2d30189704ae41ca7dbdd587f5291db5d1dda1414e0774c29ffc81088c1bc"}, "jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b659b8571deedf60f79c5a608e15414085fa141344e2716fbd6988a084b5f993"}, "joken": {:hex, :joken, "2.2.0", "2daa1b12be05184aff7b5ace1d43ca1f81345962285fff3f88db74927c954d3a", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "b4f92e30388206f869dd25d1af628a1d99d7586e5cf0672f64d4df84c4d2f5e9"}, @@ -102,7 +102,7 @@ "recon": {:hex, :recon, "2.5.0", "2f7fcbec2c35034bade2f9717f77059dc54eb4e929a3049ca7ba6775c0bd66cd", [:mix, :rebar3], [], "hexpm", "72f3840fedd94f06315c523f6cecf5b4827233bed7ae3fe135b2a0ebeab5e196"}, "remote_ip": {:git, "https://git.pleroma.social/pleroma/remote_ip.git", "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8", [ref: "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8"]}, "sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"}, "swoosh": {:hex, :swoosh, "0.23.5", "bfd9404bbf5069b1be2ffd317923ce57e58b332e25dbca2a35dedd7820dfee5a", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "e3928e1d2889a308aaf3e42755809ac21cffd77cb58eef01cbfdab4ce2fd1e21"}, "syslog": {:hex, :syslog, "1.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"}, @@ -112,7 +112,7 @@ "trailing_format_plug": {:hex, :trailing_format_plug, "0.0.7", "64b877f912cf7273bed03379936df39894149e35137ac9509117e59866e10e45", [:mix], [{:plug, "> 0.12.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bd4fde4c15f3e993a999e019d64347489b91b7a9096af68b2bdadd192afa693f"}, "tzdata": {:hex, :tzdata, "0.5.22", "f2ba9105117ee0360eae2eca389783ef7db36d533899b2e84559404dbc77ebb8", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "cd66c8a1e6a9e121d1f538b01bef459334bb4029a1ffb4eeeb5e4eae0337e7b6"}, "ueberauth": {:hex, :ueberauth, "0.6.2", "25a31111249d60bad8b65438b2306a4dc91f3208faa62f5a8c33e8713989b2e8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "db9fbfb5ac707bc4f85a297758406340bf0358b4af737a88113c1a9eee120ac7"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"}, "unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm", "6c7729a2d214806450d29766abc2afaa7a2cbecf415be64f36a6691afebb50e5"}, "web_push_encryption": {:hex, :web_push_encryption, "0.2.3", "a0ceab85a805a30852f143d22d71c434046fbdbafbc7292e7887cec500826a80", [:mix], [{:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:poison, "~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm", "9315c8f37c108835cf3f8e9157d7a9b8f420a34f402d1b1620a31aed5b93ecdf"}, "websocket_client": {:git, "https://github.com/jeremyong/websocket_client.git", "9a6f65d05ebf2725d62fb19262b21f1805a59fbf", []}, From 24f40b8a26f95ee7f50b6023176d361660ceb35c Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 30 May 2020 10:29:08 +0300 Subject: [PATCH 146/401] [#1794] Fixed search query splitting regex to deal with Unicode. Adjusted a test. --- lib/pleroma/web/mastodon_api/controllers/search_controller.ex | 2 +- test/web/mastodon_api/controllers/search_controller_test.exs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index 23fe378a6..8840fc19c 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -124,7 +124,7 @@ defp resource_search(:v1, "hashtags", query, _options) do defp prepare_tags(query, add_joined_tag \\ true) do tags = query - |> String.split(~r/[^#\w]+/, trim: true) + |> String.split(~r/[^#\w]+/u, trim: true) |> Enum.uniq_by(&String.downcase/1) explicit_tags = Enum.filter(tags, fn tag -> String.starts_with?(tag, "#") end) diff --git a/test/web/mastodon_api/controllers/search_controller_test.exs b/test/web/mastodon_api/controllers/search_controller_test.exs index 498290377..84d46895e 100644 --- a/test/web/mastodon_api/controllers/search_controller_test.exs +++ b/test/web/mastodon_api/controllers/search_controller_test.exs @@ -71,6 +71,10 @@ test "search", %{conn: conn} do get(conn, "/api/v2/search?q=天子") |> json_response_and_validate_schema(200) + assert results["hashtags"] == [ + %{"name" => "天子", "url" => "#{Web.base_url()}/tag/天子"} + ] + [status] = results["statuses"] assert status["id"] == to_string(activity.id) end From 6d4b80822b15f5958518f4c6006862fb1f92354a Mon Sep 17 00:00:00 2001 From: Steven Fuchs Date: Sat, 30 May 2020 10:02:37 +0000 Subject: [PATCH 147/401] Conversation pagination --- .../controllers/conversation_controller.ex | 17 ++ .../conversation_controller_test.exs | 165 +++++++++--------- 2 files changed, 100 insertions(+), 82 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex index f35ec3596..69f0e3846 100644 --- a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex @@ -21,6 +21,7 @@ defmodule Pleroma.Web.MastodonAPI.ConversationController do @doc "GET /api/v1/conversations" def index(%{assigns: %{user: user}} = conn, params) do + params = stringify_pagination_params(params) participations = Participation.for_user_with_last_activity_id(user, params) conn @@ -36,4 +37,20 @@ def mark_as_read(%{assigns: %{user: user}} = conn, %{id: participation_id}) do render(conn, "participation.json", participation: participation, for: user) end end + + defp stringify_pagination_params(params) do + atom_keys = + Pleroma.Pagination.page_keys() + |> Enum.map(&String.to_atom(&1)) + + str_keys = + params + |> Map.take(atom_keys) + |> Enum.map(fn {key, value} -> {to_string(key), value} end) + |> Enum.into(%{}) + + params + |> Map.delete(atom_keys) + |> Map.merge(str_keys) + end end diff --git a/test/web/mastodon_api/controllers/conversation_controller_test.exs b/test/web/mastodon_api/controllers/conversation_controller_test.exs index 693ba51e5..3e21e6bf1 100644 --- a/test/web/mastodon_api/controllers/conversation_controller_test.exs +++ b/test/web/mastodon_api/controllers/conversation_controller_test.exs @@ -12,84 +12,88 @@ defmodule Pleroma.Web.MastodonAPI.ConversationControllerTest do setup do: oauth_access(["read:statuses"]) - test "returns a list of conversations", %{user: user_one, conn: conn} do - user_two = insert(:user) - user_three = insert(:user) + describe "returns a list of conversations" do + setup(%{user: user_one, conn: conn}) do + user_two = insert(:user) + user_three = insert(:user) - {:ok, user_two} = User.follow(user_two, user_one) + {:ok, user_two} = User.follow(user_two, user_one) - assert User.get_cached_by_id(user_two.id).unread_conversation_count == 0 + {:ok, %{user: user_one, user_two: user_two, user_three: user_three, conn: conn}} + end - {:ok, direct} = - CommonAPI.post(user_one, %{ - status: "Hi @#{user_two.nickname}, @#{user_three.nickname}!", - visibility: "direct" - }) + test "returns correct conversations", %{ + user: user_one, + user_two: user_two, + user_three: user_three, + conn: conn + } do + assert User.get_cached_by_id(user_two.id).unread_conversation_count == 0 + {:ok, direct} = create_direct_message(user_one, [user_two, user_three]) - assert User.get_cached_by_id(user_two.id).unread_conversation_count == 1 + assert User.get_cached_by_id(user_two.id).unread_conversation_count == 1 - {:ok, _follower_only} = - CommonAPI.post(user_one, %{ - status: "Hi @#{user_two.nickname}!", - visibility: "private" - }) + {:ok, _follower_only} = + CommonAPI.post(user_one, %{ + status: "Hi @#{user_two.nickname}!", + visibility: "private" + }) - res_conn = get(conn, "/api/v1/conversations") + res_conn = get(conn, "/api/v1/conversations") - assert response = json_response_and_validate_schema(res_conn, 200) + assert response = json_response_and_validate_schema(res_conn, 200) - assert [ - %{ - "id" => res_id, - "accounts" => res_accounts, - "last_status" => res_last_status, - "unread" => unread - } - ] = response + assert [ + %{ + "id" => res_id, + "accounts" => res_accounts, + "last_status" => res_last_status, + "unread" => unread + } + ] = response - account_ids = Enum.map(res_accounts, & &1["id"]) - assert length(res_accounts) == 2 - assert user_two.id in account_ids - assert user_three.id in account_ids - assert is_binary(res_id) - assert unread == false - assert res_last_status["id"] == direct.id - assert User.get_cached_by_id(user_one.id).unread_conversation_count == 0 + account_ids = Enum.map(res_accounts, & &1["id"]) + assert length(res_accounts) == 2 + assert user_two.id in account_ids + assert user_three.id in account_ids + assert is_binary(res_id) + assert unread == false + assert res_last_status["id"] == direct.id + assert User.get_cached_by_id(user_one.id).unread_conversation_count == 0 + end + + test "observes limit params", %{ + user: user_one, + user_two: user_two, + user_three: user_three, + conn: conn + } do + {:ok, _} = create_direct_message(user_one, [user_two, user_three]) + {:ok, _} = create_direct_message(user_two, [user_one, user_three]) + {:ok, _} = create_direct_message(user_three, [user_two, user_one]) + + res_conn = get(conn, "/api/v1/conversations?limit=1") + + assert response = json_response_and_validate_schema(res_conn, 200) + + assert Enum.count(response) == 1 + + res_conn = get(conn, "/api/v1/conversations?limit=2") + + assert response = json_response_and_validate_schema(res_conn, 200) + + assert Enum.count(response) == 2 + end end test "filters conversations by recipients", %{user: user_one, conn: conn} do user_two = insert(:user) user_three = insert(:user) - - {:ok, direct1} = - CommonAPI.post(user_one, %{ - status: "Hi @#{user_two.nickname}!", - visibility: "direct" - }) - - {:ok, _direct2} = - CommonAPI.post(user_one, %{ - status: "Hi @#{user_three.nickname}!", - visibility: "direct" - }) - - {:ok, direct3} = - CommonAPI.post(user_one, %{ - status: "Hi @#{user_two.nickname}, @#{user_three.nickname}!", - visibility: "direct" - }) - - {:ok, _direct4} = - CommonAPI.post(user_two, %{ - status: "Hi @#{user_three.nickname}!", - visibility: "direct" - }) - - {:ok, direct5} = - CommonAPI.post(user_two, %{ - status: "Hi @#{user_one.nickname}!", - visibility: "direct" - }) + {:ok, direct1} = create_direct_message(user_one, [user_two]) + {:ok, _direct2} = create_direct_message(user_one, [user_three]) + {:ok, direct3} = create_direct_message(user_one, [user_two, user_three]) + {:ok, _direct4} = create_direct_message(user_two, [user_three]) + {:ok, direct5} = create_direct_message(user_two, [user_one]) assert [conversation1, conversation2] = conn @@ -109,12 +113,7 @@ test "filters conversations by recipients", %{user: user_one, conn: conn} do test "updates the last_status on reply", %{user: user_one, conn: conn} do user_two = insert(:user) - - {:ok, direct} = - CommonAPI.post(user_one, %{ - status: "Hi @#{user_two.nickname}", - visibility: "direct" - }) + {:ok, direct} = create_direct_message(user_one, [user_two]) {:ok, direct_reply} = CommonAPI.post(user_two, %{ @@ -133,12 +132,7 @@ test "updates the last_status on reply", %{user: user_one, conn: conn} do test "the user marks a conversation as read", %{user: user_one, conn: conn} do user_two = insert(:user) - - {:ok, direct} = - CommonAPI.post(user_one, %{ - status: "Hi @#{user_two.nickname}", - visibility: "direct" - }) + {:ok, direct} = create_direct_message(user_one, [user_two]) assert User.get_cached_by_id(user_one.id).unread_conversation_count == 0 assert User.get_cached_by_id(user_two.id).unread_conversation_count == 1 @@ -194,15 +188,22 @@ test "the user marks a conversation as read", %{user: user_one, conn: conn} do test "(vanilla) Mastodon frontend behaviour", %{user: user_one, conn: conn} do user_two = insert(:user) - - {:ok, direct} = - CommonAPI.post(user_one, %{ - status: "Hi @#{user_two.nickname}!", - visibility: "direct" - }) + {:ok, direct} = create_direct_message(user_one, [user_two]) res_conn = get(conn, "/api/v1/statuses/#{direct.id}/context") assert %{"ancestors" => [], "descendants" => []} == json_response(res_conn, 200) end + + defp create_direct_message(sender, recips) do + hellos = + recips + |> Enum.map(fn s -> "@#{s.nickname}" end) + |> Enum.join(", ") + + CommonAPI.post(sender, %{ + status: "Hi #{hellos}!", + visibility: "direct" + }) + end end From 2c9465cc51160546ae054d1a1912fbb8e9add8e8 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 30 May 2020 12:17:18 +0200 Subject: [PATCH 148/401] SafeText: Let through basic html. --- .../object_validators/types/safe_text.ex | 2 +- test/web/activity_pub/object_validator_test.exs | 14 ++++++++++++++ .../object_validators/types/safe_text_test.exs | 7 +++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex b/lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex index 822e8d2c1..95c948123 100644 --- a/lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex +++ b/lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.SafeText do def type, do: :string def cast(str) when is_binary(str) do - {:ok, HTML.strip_tags(str)} + {:ok, HTML.filter_tags(str)} end def cast(_), do: :error diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 929fdbc9b..31224abe0 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -113,6 +113,20 @@ test "it is invalid if the object data has a different `to` or `actor` field" do %{user: user, recipient: recipient, valid_chat_message: valid_chat_message} end + test "let's through some basic html", %{user: user, recipient: recipient} do + {:ok, valid_chat_message, _} = + Builder.chat_message( + user, + recipient.ap_id, + "hey example " + ) + + assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) + + assert object["content"] == + "hey example alert('uguu')" + end + test "validates for a basic object we build", %{valid_chat_message: valid_chat_message} do assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) diff --git a/test/web/activity_pub/object_validators/types/safe_text_test.exs b/test/web/activity_pub/object_validators/types/safe_text_test.exs index 59ed0a1fe..d4a574554 100644 --- a/test/web/activity_pub/object_validators/types/safe_text_test.exs +++ b/test/web/activity_pub/object_validators/types/safe_text_test.exs @@ -17,6 +17,13 @@ test "it removes html tags from text" do assert {:ok, "hey look xss alert('foo')"} == SafeText.cast(text) end + test "it keeps basic html tags" do + text = "hey look xss " + + assert {:ok, "hey look xss alert('foo')"} == + SafeText.cast(text) + end + test "errors for non-text" do assert :error == SafeText.cast(1) end From 8bdf18d7c10f0e740b2f5e0fa5063c522b8b3872 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 30 May 2020 12:30:31 +0200 Subject: [PATCH 149/401] CommonAPI: Linkify chat messages. --- lib/pleroma/web/common_api/common_api.ex | 7 ++++++- test/web/common_api/common_api_test.exs | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 764fa4f4f..173353aa5 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -50,7 +50,12 @@ def post_chat_message(%User{} = user, %User{} = recipient, content, opts \\ []) defp format_chat_content(nil), do: nil defp format_chat_content(content) do - content |> Formatter.html_escape("text/plain") + {text, _, _} = + content + |> Formatter.html_escape("text/plain") + |> Formatter.linkify() + + text end defp validate_chat_content_length(_, true), do: :ok diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 9e129e5a7..41c6909de 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -50,6 +50,29 @@ test "it posts a chat message without content but with an attachment" do assert activity end + test "it linkifies" do + author = insert(:user) + recipient = insert(:user) + + other_user = insert(:user) + + {:ok, activity} = + CommonAPI.post_chat_message( + author, + recipient, + "https://example.org is the site of @#{other_user.nickname} #2hu" + ) + + assert other_user.ap_id not in activity.recipients + + object = Object.normalize(activity, false) + + assert object.data["content"] == + "https://example.org is the site of @#{other_user.nickname} #2hu" + end + test "it posts a chat message" do author = insert(:user) recipient = insert(:user) From 0cb7b0ea8477bdd7af2e5e9071843be5b8623dff Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sat, 30 May 2020 13:59:04 +0300 Subject: [PATCH 150/401] hackney adapter helper: support tlsv1.3 and remove custom opts - partitial_chain is no longer exported, but it seems to be the default anyway. - The bug that caused sni to not be sent automatically seems to be fixed - https://github.com/benoitc/hackney/issues/612 --- lib/pleroma/http/adapter_helper/hackney.ex | 17 +---------------- test/http/adapter_helper/hackney_test.exs | 12 ------------ 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/lib/pleroma/http/adapter_helper/hackney.ex b/lib/pleroma/http/adapter_helper/hackney.ex index dcb4cac71..3972a03a9 100644 --- a/lib/pleroma/http/adapter_helper/hackney.ex +++ b/lib/pleroma/http/adapter_helper/hackney.ex @@ -22,22 +22,7 @@ def options(connection_opts \\ [], %URI{} = uri) do |> Pleroma.HTTP.AdapterHelper.maybe_add_proxy(proxy) end - defp add_scheme_opts(opts, %URI{scheme: "http"}), do: opts - - defp add_scheme_opts(opts, %URI{scheme: "https", host: host}) do - ssl_opts = [ - ssl_options: [ - # Workaround for remote server certificate chain issues - partial_chain: &:hackney_connect.partial_chain/1, - - # We don't support TLS v1.3 yet - versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"], - server_name_indication: to_charlist(host) - ] - ] - - Keyword.merge(opts, ssl_opts) - end + defp add_scheme_opts(opts, _), do: opts def after_request(_), do: :ok end diff --git a/test/http/adapter_helper/hackney_test.exs b/test/http/adapter_helper/hackney_test.exs index 3f7e708e0..f2361ff0b 100644 --- a/test/http/adapter_helper/hackney_test.exs +++ b/test/http/adapter_helper/hackney_test.exs @@ -31,17 +31,5 @@ test "respect connection opts and no proxy", %{uri: uri} do assert opts[:b] == 1 refute Keyword.has_key?(opts, :proxy) end - - test "add opts for https" do - uri = URI.parse("https://domain.com") - - opts = Hackney.options(uri) - - assert opts[:ssl_options] == [ - partial_chain: &:hackney_connect.partial_chain/1, - versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"], - server_name_indication: 'domain.com' - ] - end end end From b973d0b2f0809e7a96c39f6eef1d86050c9d421b Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 30 May 2020 16:47:09 +0300 Subject: [PATCH 151/401] Fix config setting to not affect other tests --- test/user_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/user_test.exs b/test/user_test.exs index 3556ef1b4..6b344158d 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1802,7 +1802,7 @@ test "avatar fallback" do user = insert(:user) assert User.avatar_url(user) =~ "/images/avi.png" - Pleroma.Config.put([:assets, :default_user_avatar], "avatar.png") + clear_config([:assets, :default_user_avatar], "avatar.png") user = User.get_cached_by_nickname_or_id(user.nickname) assert User.avatar_url(user) =~ "avatar.png" From 9460983032257022ff29c063901f6b714e4fbf59 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 1 Jun 2020 13:03:22 +0200 Subject: [PATCH 152/401] AccountController: Federate user account changes. Hotfixy commit, will be moved to the pipeline. --- .../controllers/account_controller.ex | 23 +++++++++-- .../update_credentials_test.exs | 38 +++++++++++-------- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 47649d41d..97295a52f 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -139,9 +139,7 @@ def verify_credentials(%{assigns: %{user: user}} = conn, _) do end @doc "PATCH /api/v1/accounts/update_credentials" - def update_credentials(%{assigns: %{user: original_user}, body_params: params} = conn, _params) do - user = original_user - + def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _params) do params = params |> Enum.filter(fn {_, value} -> not is_nil(value) end) @@ -183,12 +181,31 @@ def update_credentials(%{assigns: %{user: original_user}, body_params: params} = changeset = User.update_changeset(user, user_params) with {:ok, user} <- User.update_and_set_cache(changeset) do + user + |> build_update_activity_params() + |> ActivityPub.update() + render(conn, "show.json", user: user, for: user, with_pleroma_settings: true) else _e -> render_error(conn, :forbidden, "Invalid request") end end + # Hotfix, handling will be redone with the pipeline + defp build_update_activity_params(user) do + object = + Pleroma.Web.ActivityPub.UserView.render("user.json", user: user) + |> Map.delete("@context") + + %{ + local: true, + to: [user.follower_address], + cc: [], + object: object, + actor: user.ap_id + } + end + defp add_if_present(map, params, params_field, map_field, value_function \\ &{:ok, &1}) do with true <- is_map(params), true <- Map.has_key?(params, params_field), diff --git a/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs index 696228203..7c420985d 100644 --- a/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs +++ b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs @@ -8,6 +8,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController.UpdateCredentialsTest do use Pleroma.Web.ConnCase + import Mock import Pleroma.Factory setup do: clear_config([:instance, :max_account_fields]) @@ -52,24 +53,31 @@ test "sets user settings in a generic way", %{conn: conn} do user = Repo.get(User, user_data["id"]) - res_conn = - conn - |> assign(:user, user) - |> patch("/api/v1/accounts/update_credentials", %{ - "pleroma_settings_store" => %{ - masto_fe: %{ - theme: "blub" + clear_config([:instance, :federating], true) + + with_mock Pleroma.Web.Federator, + publish: fn _activity -> :ok end do + res_conn = + conn + |> assign(:user, user) + |> patch("/api/v1/accounts/update_credentials", %{ + "pleroma_settings_store" => %{ + masto_fe: %{ + theme: "blub" + } } - } - }) + }) - assert user_data = json_response_and_validate_schema(res_conn, 200) + assert user_data = json_response_and_validate_schema(res_conn, 200) - assert user_data["pleroma"]["settings_store"] == - %{ - "pleroma_fe" => %{"theme" => "bla"}, - "masto_fe" => %{"theme" => "blub"} - } + assert user_data["pleroma"]["settings_store"] == + %{ + "pleroma_fe" => %{"theme" => "bla"}, + "masto_fe" => %{"theme" => "blub"} + } + + assert_called(Pleroma.Web.Federator.publish(:_)) + end end test "updates the user's bio", %{conn: conn} do From d4d4b92f758979fbc22cd56a9f30435df5c40ab6 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 1 Jun 2020 13:17:56 +0200 Subject: [PATCH 153/401] TimelineController: Only return `Create` in public timelines. --- .../mastodon_api/controllers/timeline_controller.ex | 2 +- .../controllers/timeline_controller_test.exs | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 958567510..f67f75430 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -111,7 +111,7 @@ def public(%{assigns: %{user: user}} = conn, params) do else activities = params - |> Map.put("type", ["Create", "Announce"]) + |> Map.put("type", ["Create"]) |> Map.put("local_only", local_only) |> Map.put("blocking_user", user) |> Map.put("muting_user", user) diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index 2375ac8e8..65b4079fe 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -60,9 +60,9 @@ test "the home timeline when the direct messages are excluded", %{user: user, co describe "public" do @tag capture_log: true test "the public timeline", %{conn: conn} do - following = insert(:user) + user = insert(:user) - {:ok, _activity} = CommonAPI.post(following, %{status: "test"}) + {:ok, activity} = CommonAPI.post(user, %{status: "test"}) _activity = insert(:note_activity, local: false) @@ -77,6 +77,13 @@ test "the public timeline", %{conn: conn} do conn = get(build_conn(), "/api/v1/timelines/public?local=1") assert [%{"content" => "test"}] = json_response_and_validate_schema(conn, :ok) + + # does not contain repeats + {:ok, _} = CommonAPI.repeat(activity.id, user) + + conn = get(build_conn(), "/api/v1/timelines/public?local=true") + + assert [_] = json_response_and_validate_schema(conn, :ok) end test "the public timeline includes only public statuses for an authenticated user" do From ac31f687c0fbe06251257acb72b67146b472d22f Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 1 Jun 2020 13:35:39 +0200 Subject: [PATCH 154/401] Config: Default to Hackney again Gun needs some server setting changes (files) and has problems with OTP 23 (wildcards), so use Hackney as a default again for now. --- config/config.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/config.exs b/config/config.exs index d15998715..9a9fbb436 100644 --- a/config/config.exs +++ b/config/config.exs @@ -171,7 +171,8 @@ "application/ld+json" => ["activity+json"] } -config :tesla, adapter: Tesla.Adapter.Gun +config :tesla, adapter: Tesla.Adapter.Hackney + # Configures http settings, upstream proxy etc. config :pleroma, :http, proxy_url: nil, From af9090238e1f71e6b081fbd09c09a5975d2ed99e Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 1 Jun 2020 15:14:22 +0200 Subject: [PATCH 155/401] CommonAPI: Newlines -> br for chat messages. --- lib/pleroma/web/common_api/common_api.ex | 3 +++ test/web/common_api/common_api_test.exs | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 173353aa5..e0987b1a7 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -54,6 +54,9 @@ defp format_chat_content(content) do content |> Formatter.html_escape("text/plain") |> Formatter.linkify() + |> (fn {text, mentions, tags} -> + {String.replace(text, ~r/\r?\n/, "
"), mentions, tags} + end).() text end diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 41c6909de..611a9ae66 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -50,6 +50,26 @@ test "it posts a chat message without content but with an attachment" do assert activity end + test "it adds html newlines" do + author = insert(:user) + recipient = insert(:user) + + other_user = insert(:user) + + {:ok, activity} = + CommonAPI.post_chat_message( + author, + recipient, + "uguu\nuguuu" + ) + + assert other_user.ap_id not in activity.recipients + + object = Object.normalize(activity, false) + + assert object.data["content"] == "uguu
uguuu" + end + test "it linkifies" do author = insert(:user) recipient = insert(:user) From 7e6ec778d965419ed4083428d4d39b2a689f7619 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 20 May 2020 17:45:06 +0300 Subject: [PATCH 156/401] exclude replies on blocked domains --- benchmarks/load_testing/activities.ex | 365 ++++++++++-------- benchmarks/load_testing/fetcher.ex | 71 ++++ benchmarks/load_testing/users.ex | 22 +- .../mix/tasks/pleroma/benchmarks/tags.ex | 38 +- lib/pleroma/web/activity_pub/activity_pub.ex | 27 ++ .../api_spec/operations/timeline_operation.ex | 7 + .../controllers/timeline_controller.ex | 13 +- ...ients_contain_blocked_domains_function.exs | 33 ++ .../controllers/timeline_controller_test.exs | 68 ++++ 9 files changed, 469 insertions(+), 175 deletions(-) create mode 100644 priv/repo/migrations/20200520155351_add_recipients_contain_blocked_domains_function.exs diff --git a/benchmarks/load_testing/activities.ex b/benchmarks/load_testing/activities.ex index ff0d481a8..074ded457 100644 --- a/benchmarks/load_testing/activities.ex +++ b/benchmarks/load_testing/activities.ex @@ -22,8 +22,21 @@ defmodule Pleroma.LoadTesting.Activities do @max_concurrency 10 @visibility ~w(public private direct unlisted) - @types ~w(simple emoji mentions hell_thread attachment tag like reblog simple_thread remote) - @groups ~w(user friends non_friends) + @types [ + :simple, + :emoji, + :mentions, + :hell_thread, + :attachment, + :tag, + :like, + :reblog, + :simple_thread + ] + @groups [:friends_local, :friends_remote, :non_friends_local, :non_friends_local] + @remote_groups [:friends_remote, :non_friends_remote] + @friends_groups [:friends_local, :friends_remote] + @non_friends_groups [:non_friends_local, :non_friends_remote] @spec generate(User.t(), keyword()) :: :ok def generate(user, opts \\ []) do @@ -34,33 +47,24 @@ def generate(user, opts \\ []) do opts = Keyword.merge(@defaults, opts) - friends = - user - |> Users.get_users(limit: opts[:friends_used], local: :local, friends?: true) - |> Enum.shuffle() + users = Users.prepare_users(user, opts) - non_friends = - user - |> Users.get_users(limit: opts[:non_friends_used], local: :local, friends?: false) - |> Enum.shuffle() + {:ok, _} = Agent.start_link(fn -> users[:non_friends_remote] end, name: :non_friends_remote) task_data = for visibility <- @visibility, type <- @types, - group <- @groups, + group <- [:user | @groups], do: {visibility, type, group} IO.puts("Starting generating #{opts[:iterations]} iterations of activities...") - friends_thread = Enum.take(friends, 5) - non_friends_thread = Enum.take(friends, 5) - public_long_thread = fn -> - generate_long_thread("public", user, friends_thread, non_friends_thread, opts) + generate_long_thread("public", users, opts) end private_long_thread = fn -> - generate_long_thread("private", user, friends_thread, non_friends_thread, opts) + generate_long_thread("private", users, opts) end iterations = opts[:iterations] @@ -73,10 +77,10 @@ def generate(user, opts \\ []) do i when i == iterations - 2 -> spawn(public_long_thread) spawn(private_long_thread) - generate_activities(user, friends, non_friends, Enum.shuffle(task_data), opts) + generate_activities(users, Enum.shuffle(task_data), opts) _ -> - generate_activities(user, friends, non_friends, Enum.shuffle(task_data), opts) + generate_activities(users, Enum.shuffle(task_data), opts) end ) end) @@ -127,16 +131,16 @@ def generate_tagged_activities(opts \\ []) do end) end - defp generate_long_thread(visibility, user, friends, non_friends, _opts) do + defp generate_long_thread(visibility, users, _opts) do group = if visibility == "public", - do: "friends", - else: "user" + do: :friends_local, + else: :user tasks = get_reply_tasks(visibility, group) |> Stream.cycle() |> Enum.take(50) {:ok, activity} = - CommonAPI.post(user, %{ + CommonAPI.post(users[:user], %{ status: "Start of #{visibility} long thread", visibility: visibility }) @@ -150,31 +154,28 @@ defp generate_long_thread(visibility, user, friends, non_friends, _opts) do Map.put(state, key, activity) end) - acc = {activity.id, ["@" <> user.nickname, "reply to long thread"]} - insert_replies_for_long_thread(tasks, visibility, user, friends, non_friends, acc) + acc = {activity.id, ["@" <> users[:user].nickname, "reply to long thread"]} + insert_replies_for_long_thread(tasks, visibility, users, acc) IO.puts("Generating #{visibility} long thread ended\n") end - defp insert_replies_for_long_thread(tasks, visibility, user, friends, non_friends, acc) do + defp insert_replies_for_long_thread(tasks, visibility, users, acc) do Enum.reduce(tasks, acc, fn - "friend", {id, data} -> - friend = Enum.random(friends) - insert_reply(friend, List.delete(data, "@" <> friend.nickname), id, visibility) - - "non_friend", {id, data} -> - non_friend = Enum.random(non_friends) - insert_reply(non_friend, List.delete(data, "@" <> non_friend.nickname), id, visibility) - - "user", {id, data} -> + :user, {id, data} -> + user = users[:user] insert_reply(user, List.delete(data, "@" <> user.nickname), id, visibility) + + group, {id, data} -> + replier = Enum.random(users[group]) + insert_reply(replier, List.delete(data, "@" <> replier.nickname), id, visibility) end) end - defp generate_activities(user, friends, non_friends, task_data, opts) do + defp generate_activities(users, task_data, opts) do Task.async_stream( task_data, fn {visibility, type, group} -> - insert_activity(type, visibility, group, user, friends, non_friends, opts) + insert_activity(type, visibility, group, users, opts) end, max_concurrency: @max_concurrency, timeout: 30_000 @@ -182,67 +183,104 @@ defp generate_activities(user, friends, non_friends, task_data, opts) do |> Stream.run() end - defp insert_activity("simple", visibility, group, user, friends, non_friends, _opts) do - {:ok, _activity} = + defp insert_local_activity(visibility, group, users, status) do + {:ok, _} = group - |> get_actor(user, friends, non_friends) - |> CommonAPI.post(%{status: "Simple status", visibility: visibility}) + |> get_actor(users) + |> CommonAPI.post(%{status: status, visibility: visibility}) end - defp insert_activity("emoji", visibility, group, user, friends, non_friends, _opts) do - {:ok, _activity} = - group - |> get_actor(user, friends, non_friends) - |> CommonAPI.post(%{ - status: "Simple status with emoji :firefox:", - visibility: visibility - }) + defp insert_remote_activity(visibility, group, users, status) do + actor = get_actor(group, users) + {act_data, obj_data} = prepare_activity_data(actor, visibility, users[:user]) + {activity_data, object_data} = other_data(actor, status) + + activity_data + |> Map.merge(act_data) + |> Map.put("object", Map.merge(object_data, obj_data)) + |> Pleroma.Web.ActivityPub.ActivityPub.insert(false) end - defp insert_activity("mentions", visibility, group, user, friends, non_friends, _opts) do + defp user_mentions(users) do user_mentions = - get_random_mentions(friends, Enum.random(0..3)) ++ - get_random_mentions(non_friends, Enum.random(0..3)) + Enum.reduce( + @groups, + [], + fn group, acc -> + acc ++ get_random_mentions(users[group], Enum.random(0..2)) + end + ) - user_mentions = - if Enum.random([true, false]), - do: ["@" <> user.nickname | user_mentions], - else: user_mentions - - {:ok, _activity} = - group - |> get_actor(user, friends, non_friends) - |> CommonAPI.post(%{ - status: Enum.join(user_mentions, ", ") <> " simple status with mentions", - visibility: visibility - }) + if Enum.random([true, false]), + do: ["@" <> users[:user].nickname | user_mentions], + else: user_mentions end - defp insert_activity("hell_thread", visibility, group, user, friends, non_friends, _opts) do - mentions = - with {:ok, nil} <- Cachex.get(:user_cache, "hell_thread_mentions") do - cached = - ([user | Enum.take(friends, 10)] ++ Enum.take(non_friends, 10)) - |> Enum.map(&"@#{&1.nickname}") - |> Enum.join(", ") + defp hell_thread_mentions(users) do + with {:ok, nil} <- Cachex.get(:user_cache, "hell_thread_mentions") do + cached = + @groups + |> Enum.reduce([users[:user]], fn group, acc -> + acc ++ Enum.take(users[group], 5) + end) + |> Enum.map(&"@#{&1.nickname}") + |> Enum.join(", ") - Cachex.put(:user_cache, "hell_thread_mentions", cached) - cached - else - {:ok, cached} -> cached - end - - {:ok, _activity} = - group - |> get_actor(user, friends, non_friends) - |> CommonAPI.post(%{ - status: mentions <> " hell thread status", - visibility: visibility - }) + Cachex.put(:user_cache, "hell_thread_mentions", cached) + cached + else + {:ok, cached} -> cached + end end - defp insert_activity("attachment", visibility, group, user, friends, non_friends, _opts) do - actor = get_actor(group, user, friends, non_friends) + defp insert_activity(:simple, visibility, group, users, _opts) + when group in @remote_groups do + insert_remote_activity(visibility, group, users, "Remote status") + end + + defp insert_activity(:simple, visibility, group, users, _opts) do + insert_local_activity(visibility, group, users, "Simple status") + end + + defp insert_activity(:emoji, visibility, group, users, _opts) + when group in @remote_groups do + insert_remote_activity(visibility, group, users, "Remote status with emoji :firefox:") + end + + defp insert_activity(:emoji, visibility, group, users, _opts) do + insert_local_activity(visibility, group, users, "Simple status with emoji :firefox:") + end + + defp insert_activity(:mentions, visibility, group, users, _opts) + when group in @remote_groups do + mentions = user_mentions(users) + + status = Enum.join(mentions, ", ") <> " remote status with mentions" + + insert_remote_activity(visibility, group, users, status) + end + + defp insert_activity(:mentions, visibility, group, users, _opts) do + mentions = user_mentions(users) + + status = Enum.join(mentions, ", ") <> " simple status with mentions" + insert_remote_activity(visibility, group, users, status) + end + + defp insert_activity(:hell_thread, visibility, group, users, _) + when group in @remote_groups do + mentions = hell_thread_mentions(users) + insert_remote_activity(visibility, group, users, mentions <> " remote hell thread status") + end + + defp insert_activity(:hell_thread, visibility, group, users, _opts) do + mentions = hell_thread_mentions(users) + + insert_local_activity(visibility, group, users, mentions <> " hell thread status") + end + + defp insert_activity(:attachment, visibility, group, users, _opts) do + actor = get_actor(group, users) obj_data = %{ "actor" => actor.ap_id, @@ -268,67 +306,54 @@ defp insert_activity("attachment", visibility, group, user, friends, non_friends }) end - defp insert_activity("tag", visibility, group, user, friends, non_friends, _opts) do - {:ok, _activity} = - group - |> get_actor(user, friends, non_friends) - |> CommonAPI.post(%{status: "Status with #tag", visibility: visibility}) + defp insert_activity(:tag, visibility, group, users, _opts) do + insert_local_activity(visibility, group, users, "Status with #tag") end - defp insert_activity("like", visibility, group, user, friends, non_friends, opts) do - actor = get_actor(group, user, friends, non_friends) + defp insert_activity(:like, visibility, group, users, opts) do + actor = get_actor(group, users) with activity_id when not is_nil(activity_id) <- get_random_create_activity_id(), {:ok, _activity} <- CommonAPI.favorite(actor, activity_id) do :ok else {:error, _} -> - insert_activity("like", visibility, group, user, friends, non_friends, opts) + insert_activity(:like, visibility, group, users, opts) nil -> Process.sleep(15) - insert_activity("like", visibility, group, user, friends, non_friends, opts) + insert_activity(:like, visibility, group, users, opts) end end - defp insert_activity("reblog", visibility, group, user, friends, non_friends, opts) do - actor = get_actor(group, user, friends, non_friends) + defp insert_activity(:reblog, visibility, group, users, opts) do + actor = get_actor(group, users) with activity_id when not is_nil(activity_id) <- get_random_create_activity_id(), - {:ok, _activity, _object} <- CommonAPI.repeat(activity_id, actor) do + {:ok, _activity} <- CommonAPI.repeat(activity_id, actor) do :ok else {:error, _} -> - insert_activity("reblog", visibility, group, user, friends, non_friends, opts) + insert_activity(:reblog, visibility, group, users, opts) nil -> Process.sleep(15) - insert_activity("reblog", visibility, group, user, friends, non_friends, opts) + insert_activity(:reblog, visibility, group, users, opts) end end - defp insert_activity("simple_thread", visibility, group, user, friends, non_friends, _opts) - when visibility in ["public", "unlisted", "private"] do - actor = get_actor(group, user, friends, non_friends) - tasks = get_reply_tasks(visibility, group) - - {:ok, activity} = CommonAPI.post(user, %{status: "Simple status", visibility: visibility}) - - acc = {activity.id, ["@" <> actor.nickname, "reply to status"]} - insert_replies(tasks, visibility, user, friends, non_friends, acc) - end - - defp insert_activity("simple_thread", "direct", group, user, friends, non_friends, _opts) do - actor = get_actor(group, user, friends, non_friends) + defp insert_activity(:simple_thread, "direct", group, users, _opts) do + actor = get_actor(group, users) tasks = get_reply_tasks("direct", group) list = case group do - "non_friends" -> - Enum.take(non_friends, 3) + :user -> + group = Enum.random(@friends_groups) + Enum.take(users[group], 3) _ -> - Enum.take(friends, 3) + Enum.take(users[group], 3) end data = Enum.map(list, &("@" <> &1.nickname)) @@ -339,40 +364,30 @@ defp insert_activity("simple_thread", "direct", group, user, friends, non_friend visibility: "direct" }) - acc = {activity.id, ["@" <> user.nickname | data] ++ ["reply to status"]} - insert_direct_replies(tasks, user, list, acc) + acc = {activity.id, ["@" <> users[:user].nickname | data] ++ ["reply to status"]} + insert_direct_replies(tasks, users[:user], list, acc) end - defp insert_activity("remote", _, "user", _, _, _, _), do: :ok + defp insert_activity(:simple_thread, visibility, group, users, _opts) do + actor = get_actor(group, users) + tasks = get_reply_tasks(visibility, group) - defp insert_activity("remote", visibility, group, user, _friends, _non_friends, opts) do - remote_friends = - Users.get_users(user, limit: opts[:friends_used], local: :external, friends?: true) + {:ok, activity} = + CommonAPI.post(users[:user], %{status: "Simple status", visibility: visibility}) - remote_non_friends = - Users.get_users(user, limit: opts[:non_friends_used], local: :external, friends?: false) - - actor = get_actor(group, user, remote_friends, remote_non_friends) - - {act_data, obj_data} = prepare_activity_data(actor, visibility, user) - {activity_data, object_data} = other_data(actor) - - activity_data - |> Map.merge(act_data) - |> Map.put("object", Map.merge(object_data, obj_data)) - |> Pleroma.Web.ActivityPub.ActivityPub.insert(false) + acc = {activity.id, ["@" <> actor.nickname, "reply to status"]} + insert_replies(tasks, visibility, users, acc) end - defp get_actor("user", user, _friends, _non_friends), do: user - defp get_actor("friends", _user, friends, _non_friends), do: Enum.random(friends) - defp get_actor("non_friends", _user, _friends, non_friends), do: Enum.random(non_friends) + defp get_actor(:user, %{user: user}), do: user + defp get_actor(group, users), do: Enum.random(users[group]) - defp other_data(actor) do + defp other_data(actor, content) do %{host: host} = URI.parse(actor.ap_id) datetime = DateTime.utc_now() - context_id = "http://#{host}:4000/contexts/#{UUID.generate()}" - activity_id = "http://#{host}:4000/activities/#{UUID.generate()}" - object_id = "http://#{host}:4000/objects/#{UUID.generate()}" + context_id = "https://#{host}/contexts/#{UUID.generate()}" + activity_id = "https://#{host}/activities/#{UUID.generate()}" + object_id = "https://#{host}/objects/#{UUID.generate()}" activity_data = %{ "actor" => actor.ap_id, @@ -389,7 +404,7 @@ defp other_data(actor) do "attributedTo" => actor.ap_id, "bcc" => [], "bto" => [], - "content" => "Remote post", + "content" => content, "context" => context_id, "conversation" => context_id, "emoji" => %{}, @@ -475,51 +490,65 @@ defp prepare_activity_data(_actor, "direct", mention) do {act_data, obj_data} end - defp get_reply_tasks("public", "user"), do: ~w(friend non_friend user) - defp get_reply_tasks("public", "friends"), do: ~w(non_friend user friend) - defp get_reply_tasks("public", "non_friends"), do: ~w(user friend non_friend) + defp get_reply_tasks("public", :user) do + [:friends_local, :friends_remote, :non_friends_local, :non_friends_remote, :user] + end - defp get_reply_tasks(visibility, "user") when visibility in ["unlisted", "private"], - do: ~w(friend user friend) + defp get_reply_tasks("public", group) when group in @friends_groups do + [:non_friends_local, :non_friends_remote, :user, :friends_local, :friends_remote] + end - defp get_reply_tasks(visibility, "friends") when visibility in ["unlisted", "private"], - do: ~w(user friend user) + defp get_reply_tasks("public", group) when group in @non_friends_groups do + [:user, :friends_local, :friends_remote, :non_friends_local, :non_friends_remote] + end - defp get_reply_tasks(visibility, "non_friends") when visibility in ["unlisted", "private"], - do: [] + defp get_reply_tasks(visibility, :user) when visibility in ["unlisted", "private"] do + [:friends_local, :friends_remote, :user, :friends_local, :friends_remote] + end - defp get_reply_tasks("direct", "user"), do: ~w(friend user friend) - defp get_reply_tasks("direct", "friends"), do: ~w(user friend user) - defp get_reply_tasks("direct", "non_friends"), do: ~w(user non_friend user) + defp get_reply_tasks(visibility, group) + when visibility in ["unlisted", "private"] and group in @friends_groups do + [:user, :friends_remote, :friends_local, :user] + end - defp insert_replies(tasks, visibility, user, friends, non_friends, acc) do + defp get_reply_tasks(visibility, group) + when visibility in ["unlisted", "private"] and + group in @non_friends_groups, + do: [] + + defp get_reply_tasks("direct", :user), do: [:friends_local, :user, :friends_remote] + + defp get_reply_tasks("direct", group) when group in @friends_groups, + do: [:user, group, :user] + + defp get_reply_tasks("direct", group) when group in @non_friends_groups do + [:user, :non_friends_remote, :user, :non_friends_local] + end + + defp insert_replies(tasks, visibility, users, acc) do Enum.reduce(tasks, acc, fn - "friend", {id, data} -> - friend = Enum.random(friends) - insert_reply(friend, data, id, visibility) + :user, {id, data} -> + insert_reply(users[:user], data, id, visibility) - "non_friend", {id, data} -> - non_friend = Enum.random(non_friends) - insert_reply(non_friend, data, id, visibility) - - "user", {id, data} -> - insert_reply(user, data, id, visibility) + group, {id, data} -> + replier = Enum.random(users[group]) + insert_reply(replier, data, id, visibility) end) end defp insert_direct_replies(tasks, user, list, acc) do Enum.reduce(tasks, acc, fn - group, {id, data} when group in ["friend", "non_friend"] -> + :user, {id, data} -> + {reply_id, _} = insert_reply(user, List.delete(data, "@" <> user.nickname), id, "direct") + {reply_id, data} + + _, {id, data} -> actor = Enum.random(list) {reply_id, _} = insert_reply(actor, List.delete(data, "@" <> actor.nickname), id, "direct") {reply_id, data} - - "user", {id, data} -> - {reply_id, _} = insert_reply(user, List.delete(data, "@" <> user.nickname), id, "direct") - {reply_id, data} end) end diff --git a/benchmarks/load_testing/fetcher.ex b/benchmarks/load_testing/fetcher.ex index 0de4924bc..b278faf9f 100644 --- a/benchmarks/load_testing/fetcher.ex +++ b/benchmarks/load_testing/fetcher.ex @@ -36,6 +36,7 @@ defp fetch_timelines(user) do fetch_home_timeline(user) fetch_direct_timeline(user) fetch_public_timeline(user) + fetch_public_timeline(user, :with_blocks) fetch_public_timeline(user, :local) fetch_public_timeline(user, :tag) fetch_notifications(user) @@ -227,6 +228,76 @@ defp fetch_public_timeline(user, :only_media) do fetch_public_timeline(opts, "public timeline only media") end + # TODO: remove using `:method` after benchmarks + defp fetch_public_timeline(user, :with_blocks) do + opts = opts_for_public_timeline(user) + + remote_non_friends = Agent.get(:non_friends_remote, & &1) + + Benchee.run( + %{ + "public timeline without blocks" => fn opts -> + ActivityPub.fetch_public_activities(opts) + end + }, + inputs: %{ + "old filtering" => Map.delete(opts, :method), + "with psql fun" => Map.put(opts, :method, :fun), + "with unnest" => Map.put(opts, :method, :unnest) + } + ) + + Enum.each(remote_non_friends, fn non_friend -> + {:ok, _} = User.block(user, non_friend) + end) + + user = User.get_by_id(user.id) + + opts = Map.put(opts, "blocking_user", user) + + Benchee.run( + %{ + "public timeline with user block" => fn opts -> + ActivityPub.fetch_public_activities(opts) + end + }, + inputs: %{ + "old filtering" => Map.delete(opts, :method), + "with psql fun" => Map.put(opts, :method, :fun), + "with unnest" => Map.put(opts, :method, :unnest) + } + ) + + domains = + Enum.reduce(remote_non_friends, [], fn non_friend, domains -> + {:ok, _user} = User.unblock(user, non_friend) + %{host: host} = URI.parse(non_friend.ap_id) + [host | domains] + end) + + domains = Enum.uniq(domains) + + Enum.each(domains, fn domain -> + {:ok, _} = User.block_domain(user, domain) + end) + + user = User.get_by_id(user.id) + opts = Map.put(opts, "blocking_user", user) + + Benchee.run( + %{ + "public timeline with domain block" => fn opts -> + ActivityPub.fetch_public_activities(opts) + end + }, + inputs: %{ + "old filtering" => Map.delete(opts, :method), + "with psql fun" => Map.put(opts, :method, :fun), + "with unnest" => Map.put(opts, :method, :unnest) + } + ) + end + defp fetch_public_timeline(opts, title) when is_binary(title) do first_page_last = ActivityPub.fetch_public_activities(opts) |> List.last() diff --git a/benchmarks/load_testing/users.ex b/benchmarks/load_testing/users.ex index e4d0b22ff..6cf3958c1 100644 --- a/benchmarks/load_testing/users.ex +++ b/benchmarks/load_testing/users.ex @@ -27,7 +27,7 @@ def generate(opts \\ []) do make_friends(main_user, opts[:friends]) - Repo.get(User, main_user.id) + User.get_by_id(main_user.id) end def generate_users(max) do @@ -166,4 +166,24 @@ defp run_stream(users, main_user) do ) |> Stream.run() end + + @spec prepare_users(User.t(), keyword()) :: map() + def prepare_users(user, opts) do + friends_limit = opts[:friends_used] + non_friends_limit = opts[:non_friends_used] + + %{ + user: user, + friends_local: fetch_users(user, friends_limit, :local, true), + friends_remote: fetch_users(user, friends_limit, :external, true), + non_friends_local: fetch_users(user, non_friends_limit, :local, false), + non_friends_remote: fetch_users(user, non_friends_limit, :external, false) + } + end + + defp fetch_users(user, limit, local, friends?) do + user + |> get_users(limit: limit, local: local, friends?: friends?) + |> Enum.shuffle() + end end diff --git a/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex b/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex index 657403202..1162b2e06 100644 --- a/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex +++ b/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex @@ -5,7 +5,6 @@ defmodule Mix.Tasks.Pleroma.Benchmarks.Tags do import Ecto.Query alias Pleroma.Repo - alias Pleroma.Web.MastodonAPI.TimelineController def run(_args) do Mix.Pleroma.start_pleroma() @@ -37,7 +36,7 @@ def run(_args) do Benchee.run( %{ "Hashtag fetching, any" => fn tags -> - TimelineController.hashtag_fetching( + hashtag_fetching( %{ "any" => tags }, @@ -47,7 +46,7 @@ def run(_args) do end, # Will always return zero results because no overlapping hashtags are generated. "Hashtag fetching, all" => fn tags -> - TimelineController.hashtag_fetching( + hashtag_fetching( %{ "all" => tags }, @@ -67,7 +66,7 @@ def run(_args) do Benchee.run( %{ "Hashtag fetching" => fn tag -> - TimelineController.hashtag_fetching( + hashtag_fetching( %{ "tag" => tag }, @@ -80,4 +79,35 @@ def run(_args) do time: 5 ) end + + defp hashtag_fetching(params, user, local_only) do + 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) + |> Pleroma.Web.ActivityPub.ActivityPub.fetch_public_activities() + end end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index b8a2873d8..e7958f7a8 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -932,6 +932,33 @@ defp restrict_blocked(query, %{"blocking_user" => %User{} = user} = opts) do query = if has_named_binding?(query, :object), do: query, else: Activity.with_joined_object(query) + # TODO: update after benchmarks + query = + case opts[:method] do + :fun -> + from(a in query, + where: + fragment( + "recipients_contain_blocked_domains(?, ?) = false", + a.recipients, + ^domain_blocks + ) + ) + + :unnest -> + from(a in query, + where: + fragment( + "NOT ? && (SELECT ARRAY(SELECT split_part(UNNEST(?), '/', 3)))", + ^domain_blocks, + a.recipients + ) + ) + + _ -> + query + end + from( [activity, object: o] in query, where: fragment("not (? = ANY(?))", activity.actor, ^blocked_ap_ids), diff --git a/lib/pleroma/web/api_spec/operations/timeline_operation.ex b/lib/pleroma/web/api_spec/operations/timeline_operation.ex index 8e19bace7..375b441a1 100644 --- a/lib/pleroma/web/api_spec/operations/timeline_operation.ex +++ b/lib/pleroma/web/api_spec/operations/timeline_operation.ex @@ -62,6 +62,13 @@ def public_operation do only_media_param(), with_muted_param(), exclude_visibilities_param(), + # TODO: remove after benchmarks + Operation.parameter( + :method, + :query, + %Schema{type: :string}, + "Temp parameter" + ), reply_visibility_param() | pagination_params() ], operationId: "TimelineController.public", diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 958567510..1734df4b5 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -109,14 +109,23 @@ def public(%{assigns: %{user: user}} = conn, params) do if restrict? and is_nil(user) do render_error(conn, :unauthorized, "authorization required for timeline view") else - activities = + # TODO: return back after benchmarks + params = params |> Map.put("type", ["Create", "Announce"]) |> Map.put("local_only", local_only) |> Map.put("blocking_user", user) |> Map.put("muting_user", user) |> Map.put("reply_filtering_user", user) - |> ActivityPub.fetch_public_activities() + + params = + if params["method"] do + Map.put(params, :method, String.to_existing_atom(params["method"])) + else + params + end + + activities = ActivityPub.fetch_public_activities(params) conn |> add_link_headers(activities, %{"local" => local_only}) diff --git a/priv/repo/migrations/20200520155351_add_recipients_contain_blocked_domains_function.exs b/priv/repo/migrations/20200520155351_add_recipients_contain_blocked_domains_function.exs new file mode 100644 index 000000000..14e873125 --- /dev/null +++ b/priv/repo/migrations/20200520155351_add_recipients_contain_blocked_domains_function.exs @@ -0,0 +1,33 @@ +defmodule Pleroma.Repo.Migrations.AddRecipientsContainBlockedDomainsFunction do + use Ecto.Migration + @disable_ddl_transaction true + + def up do + statement = """ + CREATE OR REPLACE FUNCTION recipients_contain_blocked_domains(recipients varchar[], blocked_domains varchar[]) RETURNS boolean AS $$ + DECLARE + recipient_domain varchar; + recipient varchar; + BEGIN + FOREACH recipient IN ARRAY recipients LOOP + recipient_domain = split_part(recipient, '/', 3)::varchar; + + IF recipient_domain = ANY(blocked_domains) THEN + RETURN TRUE; + END IF; + END LOOP; + + RETURN FALSE; + END; + $$ LANGUAGE plpgsql; + """ + + execute(statement) + end + + def down do + execute( + "drop function if exists recipients_contain_blocked_domains(recipients varchar[], blocked_domains varchar[])" + ) + end +end diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index 2375ac8e8..3474c0cf9 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -90,6 +90,74 @@ test "the public timeline includes only public statuses for an authenticated use res_conn = get(conn, "/api/v1/timelines/public") assert length(json_response_and_validate_schema(res_conn, 200)) == 1 end + + test "doesn't return replies if follower is posting with blocked user" do + %{conn: conn, user: blocker} = oauth_access(["read:statuses"]) + [blockee, friend] = insert_list(2, :user) + {:ok, blocker} = User.follow(blocker, friend) + {:ok, _} = User.block(blocker, blockee) + + conn = assign(conn, :user, blocker) + + {:ok, %{id: activity_id} = activity} = CommonAPI.post(friend, %{status: "hey!"}) + + {:ok, reply_from_blockee} = + CommonAPI.post(blockee, %{status: "heya", in_reply_to_status_id: activity}) + + {:ok, _reply_from_friend} = + CommonAPI.post(friend, %{status: "status", in_reply_to_status_id: reply_from_blockee}) + + res_conn = get(conn, "/api/v1/timelines/public") + [%{"id" => ^activity_id}] = json_response_and_validate_schema(res_conn, 200) + end + + # TODO: update after benchmarks + test "doesn't return replies if follow is posting with users from blocked domain" do + %{conn: conn, user: blocker} = oauth_access(["read:statuses"]) + friend = insert(:user) + blockee = insert(:user, ap_id: "https://example.com/users/blocked") + {:ok, blocker} = User.follow(blocker, friend) + {:ok, blocker} = User.block_domain(blocker, "example.com") + + conn = assign(conn, :user, blocker) + + {:ok, %{id: activity_id} = activity} = CommonAPI.post(friend, %{status: "hey!"}) + + {:ok, reply_from_blockee} = + CommonAPI.post(blockee, %{status: "heya", in_reply_to_status_id: activity}) + + {:ok, _reply_from_friend} = + CommonAPI.post(friend, %{status: "status", in_reply_to_status_id: reply_from_blockee}) + + res_conn = get(conn, "/api/v1/timelines/public?method=fun") + + activities = json_response_and_validate_schema(res_conn, 200) + [%{"id" => ^activity_id}] = activities + end + + # TODO: update after benchmarks + test "doesn't return replies if follow is posting with users from blocked domain with unnest param" do + %{conn: conn, user: blocker} = oauth_access(["read:statuses"]) + friend = insert(:user) + blockee = insert(:user, ap_id: "https://example.com/users/blocked") + {:ok, blocker} = User.follow(blocker, friend) + {:ok, blocker} = User.block_domain(blocker, "example.com") + + conn = assign(conn, :user, blocker) + + {:ok, %{id: activity_id} = activity} = CommonAPI.post(friend, %{status: "hey!"}) + + {:ok, reply_from_blockee} = + CommonAPI.post(blockee, %{status: "heya", in_reply_to_status_id: activity}) + + {:ok, _reply_from_friend} = + CommonAPI.post(friend, %{status: "status", in_reply_to_status_id: reply_from_blockee}) + + res_conn = get(conn, "/api/v1/timelines/public?method=unnest") + + activities = json_response_and_validate_schema(res_conn, 200) + [%{"id" => ^activity_id}] = activities + end end defp local_and_remote_activities do From 19f468c5bc230d6790b00aa87e509a07e709aaa7 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 2 Jun 2020 08:50:24 +0300 Subject: [PATCH 157/401] replies filtering for blocked domains --- benchmarks/load_testing/fetcher.ex | 30 ++++------------- lib/pleroma/web/activity_pub/activity_pub.ex | 33 ++++--------------- .../api_spec/operations/timeline_operation.ex | 7 ---- .../controllers/timeline_controller.ex | 13 ++------ .../controllers/timeline_controller_test.exs | 27 +-------------- 5 files changed, 15 insertions(+), 95 deletions(-) diff --git a/benchmarks/load_testing/fetcher.ex b/benchmarks/load_testing/fetcher.ex index b278faf9f..22a06e472 100644 --- a/benchmarks/load_testing/fetcher.ex +++ b/benchmarks/load_testing/fetcher.ex @@ -228,24 +228,16 @@ defp fetch_public_timeline(user, :only_media) do fetch_public_timeline(opts, "public timeline only media") end - # TODO: remove using `:method` after benchmarks defp fetch_public_timeline(user, :with_blocks) do opts = opts_for_public_timeline(user) remote_non_friends = Agent.get(:non_friends_remote, & &1) - Benchee.run( - %{ - "public timeline without blocks" => fn opts -> - ActivityPub.fetch_public_activities(opts) - end - }, - inputs: %{ - "old filtering" => Map.delete(opts, :method), - "with psql fun" => Map.put(opts, :method, :fun), - "with unnest" => Map.put(opts, :method, :unnest) - } - ) + Benchee.run(%{ + "public timeline without blocks" => fn -> + ActivityPub.fetch_public_activities(opts) + end + }) Enum.each(remote_non_friends, fn non_friend -> {:ok, _} = User.block(user, non_friend) @@ -257,15 +249,10 @@ defp fetch_public_timeline(user, :with_blocks) do Benchee.run( %{ - "public timeline with user block" => fn opts -> + "public timeline with user block" => fn -> ActivityPub.fetch_public_activities(opts) end }, - inputs: %{ - "old filtering" => Map.delete(opts, :method), - "with psql fun" => Map.put(opts, :method, :fun), - "with unnest" => Map.put(opts, :method, :unnest) - } ) domains = @@ -289,11 +276,6 @@ defp fetch_public_timeline(user, :with_blocks) do "public timeline with domain block" => fn opts -> ActivityPub.fetch_public_activities(opts) end - }, - inputs: %{ - "old filtering" => Map.delete(opts, :method), - "with psql fun" => Map.put(opts, :method, :fun), - "with unnest" => Map.put(opts, :method, :unnest) } ) end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index e7958f7a8..673b10b22 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -932,37 +932,16 @@ defp restrict_blocked(query, %{"blocking_user" => %User{} = user} = opts) do query = if has_named_binding?(query, :object), do: query, else: Activity.with_joined_object(query) - # TODO: update after benchmarks - query = - case opts[:method] do - :fun -> - from(a in query, - where: - fragment( - "recipients_contain_blocked_domains(?, ?) = false", - a.recipients, - ^domain_blocks - ) - ) - - :unnest -> - from(a in query, - where: - fragment( - "NOT ? && (SELECT ARRAY(SELECT split_part(UNNEST(?), '/', 3)))", - ^domain_blocks, - a.recipients - ) - ) - - _ -> - query - end - from( [activity, object: o] in query, where: fragment("not (? = ANY(?))", activity.actor, ^blocked_ap_ids), where: fragment("not (? && ?)", activity.recipients, ^blocked_ap_ids), + where: + fragment( + "recipients_contain_blocked_domains(?, ?) = false", + activity.recipients, + ^domain_blocks + ), where: fragment( "not (?->>'type' = 'Announce' and ?->'to' \\?| ?)", diff --git a/lib/pleroma/web/api_spec/operations/timeline_operation.ex b/lib/pleroma/web/api_spec/operations/timeline_operation.ex index 375b441a1..8e19bace7 100644 --- a/lib/pleroma/web/api_spec/operations/timeline_operation.ex +++ b/lib/pleroma/web/api_spec/operations/timeline_operation.ex @@ -62,13 +62,6 @@ def public_operation do only_media_param(), with_muted_param(), exclude_visibilities_param(), - # TODO: remove after benchmarks - Operation.parameter( - :method, - :query, - %Schema{type: :string}, - "Temp parameter" - ), reply_visibility_param() | pagination_params() ], operationId: "TimelineController.public", diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 1734df4b5..958567510 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -109,23 +109,14 @@ def public(%{assigns: %{user: user}} = conn, params) do if restrict? and is_nil(user) do render_error(conn, :unauthorized, "authorization required for timeline view") else - # TODO: return back after benchmarks - params = + 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("reply_filtering_user", user) - - params = - if params["method"] do - Map.put(params, :method, String.to_existing_atom(params["method"])) - else - params - end - - activities = ActivityPub.fetch_public_activities(params) + |> ActivityPub.fetch_public_activities() conn |> add_link_headers(activities, %{"local" => local_only}) diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index 3474c0cf9..2ad6828ad 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -111,7 +111,6 @@ test "doesn't return replies if follower is posting with blocked user" do [%{"id" => ^activity_id}] = json_response_and_validate_schema(res_conn, 200) end - # TODO: update after benchmarks test "doesn't return replies if follow is posting with users from blocked domain" do %{conn: conn, user: blocker} = oauth_access(["read:statuses"]) friend = insert(:user) @@ -129,31 +128,7 @@ test "doesn't return replies if follow is posting with users from blocked domain {:ok, _reply_from_friend} = CommonAPI.post(friend, %{status: "status", in_reply_to_status_id: reply_from_blockee}) - res_conn = get(conn, "/api/v1/timelines/public?method=fun") - - activities = json_response_and_validate_schema(res_conn, 200) - [%{"id" => ^activity_id}] = activities - end - - # TODO: update after benchmarks - test "doesn't return replies if follow is posting with users from blocked domain with unnest param" do - %{conn: conn, user: blocker} = oauth_access(["read:statuses"]) - friend = insert(:user) - blockee = insert(:user, ap_id: "https://example.com/users/blocked") - {:ok, blocker} = User.follow(blocker, friend) - {:ok, blocker} = User.block_domain(blocker, "example.com") - - conn = assign(conn, :user, blocker) - - {:ok, %{id: activity_id} = activity} = CommonAPI.post(friend, %{status: "hey!"}) - - {:ok, reply_from_blockee} = - CommonAPI.post(blockee, %{status: "heya", in_reply_to_status_id: activity}) - - {:ok, _reply_from_friend} = - CommonAPI.post(friend, %{status: "status", in_reply_to_status_id: reply_from_blockee}) - - res_conn = get(conn, "/api/v1/timelines/public?method=unnest") + res_conn = get(conn, "/api/v1/timelines/public") activities = json_response_and_validate_schema(res_conn, 200) [%{"id" => ^activity_id}] = activities From 81fb45a71ba1d606e6a522ac746f3c7a7dd8136b Mon Sep 17 00:00:00 2001 From: Fristi Date: Mon, 1 Jun 2020 16:25:57 +0000 Subject: [PATCH 158/401] Translated using Weblate (Dutch) Currently translated at 29.2% (31 of 106 strings) Translation: Pleroma/Pleroma backend Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma/nl/ --- priv/gettext/nl/LC_MESSAGES/errors.po | 84 ++++++++++++++------------- 1 file changed, 43 insertions(+), 41 deletions(-) diff --git a/priv/gettext/nl/LC_MESSAGES/errors.po b/priv/gettext/nl/LC_MESSAGES/errors.po index 7e12ff96c..3118f6b5d 100644 --- a/priv/gettext/nl/LC_MESSAGES/errors.po +++ b/priv/gettext/nl/LC_MESSAGES/errors.po @@ -3,14 +3,16 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-05-15 09:37+0000\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" +"PO-Revision-Date: 2020-06-02 07:36+0000\n" +"Last-Translator: Fristi \n" +"Language-Team: Dutch \n" "Language: nl\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Translate Toolkit 2.5.1\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.0.4\n" ## This file is a PO Template file. ## @@ -23,142 +25,142 @@ msgstr "" ## effect: edit them in PO (`.po`) files instead. ## From Ecto.Changeset.cast/4 msgid "can't be blank" -msgstr "" +msgstr "kan niet leeg zijn" ## From Ecto.Changeset.unique_constraint/3 msgid "has already been taken" -msgstr "" +msgstr "is al bezet" ## From Ecto.Changeset.put_change/3 msgid "is invalid" -msgstr "" +msgstr "is ongeldig" ## From Ecto.Changeset.validate_format/3 msgid "has invalid format" -msgstr "" +msgstr "heeft een ongeldig formaat" ## From Ecto.Changeset.validate_subset/3 msgid "has an invalid entry" -msgstr "" +msgstr "heeft een ongeldige entry" ## From Ecto.Changeset.validate_exclusion/3 msgid "is reserved" -msgstr "" +msgstr "is gereserveerd" ## From Ecto.Changeset.validate_confirmation/3 msgid "does not match confirmation" -msgstr "" +msgstr "komt niet overeen met bevestiging" ## From Ecto.Changeset.no_assoc_constraint/3 msgid "is still associated with this entry" -msgstr "" +msgstr "is nog geassocieerd met deze entry" msgid "are still associated with this entry" -msgstr "" +msgstr "zijn nog geassocieerd met deze entry" ## From Ecto.Changeset.validate_length/3 msgid "should be %{count} character(s)" msgid_plural "should be %{count} character(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "dient %{count} karakter te bevatten" +msgstr[1] "dient %{count} karakters te bevatten" msgid "should have %{count} item(s)" msgid_plural "should have %{count} item(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "dient %{count} item te bevatten" +msgstr[1] "dient %{count} items te bevatten" msgid "should be at least %{count} character(s)" msgid_plural "should be at least %{count} character(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "dient ten minste %{count} karakter te bevatten" +msgstr[1] "dient ten minste %{count} karakters te bevatten" msgid "should have at least %{count} item(s)" msgid_plural "should have at least %{count} item(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "dient ten minste %{count} item te bevatten" +msgstr[1] "dient ten minste %{count} items te bevatten" msgid "should be at most %{count} character(s)" msgid_plural "should be at most %{count} character(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "dient niet meer dan %{count} karakter te bevatten" +msgstr[1] "dient niet meer dan %{count} karakters te bevatten" msgid "should have at most %{count} item(s)" msgid_plural "should have at most %{count} item(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "dient niet meer dan %{count} item te bevatten" +msgstr[1] "dient niet meer dan %{count} items te bevatten" ## From Ecto.Changeset.validate_number/3 msgid "must be less than %{number}" -msgstr "" +msgstr "dient kleiner te zijn dan %{number}" msgid "must be greater than %{number}" -msgstr "" +msgstr "dient groter te zijn dan %{number}" msgid "must be less than or equal to %{number}" -msgstr "" +msgstr "dient kleiner dan of gelijk te zijn aan %{number}" msgid "must be greater than or equal to %{number}" -msgstr "" +msgstr "dient groter dan of gelijk te zijn aan %{number}" msgid "must be equal to %{number}" -msgstr "" +msgstr "dient gelijk te zijn aan %{number}" #: lib/pleroma/web/common_api/common_api.ex:421 #, elixir-format msgid "Account not found" -msgstr "" +msgstr "Account niet gevonden" #: lib/pleroma/web/common_api/common_api.ex:249 #, elixir-format msgid "Already voted" -msgstr "" +msgstr "Al gestemd" #: lib/pleroma/web/oauth/oauth_controller.ex:360 #, elixir-format msgid "Bad request" -msgstr "" +msgstr "Bad request" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:425 #, elixir-format msgid "Can't delete object" -msgstr "" +msgstr "Object kan niet verwijderd worden" #: lib/pleroma/web/mastodon_api/controllers/status_controller.ex:196 #, elixir-format msgid "Can't delete this post" -msgstr "" +msgstr "Bericht kan niet verwijderd worden" #: lib/pleroma/web/controller_helper.ex:95 #: lib/pleroma/web/controller_helper.ex:101 #, elixir-format msgid "Can't display this activity" -msgstr "" +msgstr "Activiteit kan niet worden getoond" #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:227 #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:254 #, elixir-format msgid "Can't find user" -msgstr "" +msgstr "Gebruiker kan niet gevonden worden" #: lib/pleroma/web/pleroma_api/controllers/account_controller.ex:114 #, elixir-format msgid "Can't get favorites" -msgstr "" +msgstr "Favorieten konden niet opgehaald worden" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:437 #, elixir-format msgid "Can't like object" -msgstr "" +msgstr "Object kan niet geliked worden" #: lib/pleroma/web/common_api/utils.ex:556 #, elixir-format msgid "Cannot post an empty status without attachments" -msgstr "" +msgstr "Status kan niet geplaatst worden zonder tekst of bijlagen" #: lib/pleroma/web/common_api/utils.ex:504 #, elixir-format msgid "Comment must be up to %{max_size} characters" -msgstr "" +msgstr "Opmerking dient maximaal %{max_size} karakters te bevatten" #: lib/pleroma/config/config_db.ex:222 #, elixir-format From 165a4b2a690ff7809ebbae65cddff3221d52489a Mon Sep 17 00:00:00 2001 From: rinpatch Date: Mon, 1 Jun 2020 22:18:20 +0300 Subject: [PATCH 159/401] Do not include activities of invisible users unless explicitly requested Closes #1833 --- lib/pleroma/user/query.ex | 6 +++--- lib/pleroma/web/activity_pub/activity_pub.ex | 12 ++++++++++++ lib/pleroma/web/admin_api/search.ex | 3 +-- test/tasks/relay_test.exs | 3 ++- .../controllers/admin_api_controller_test.exs | 13 +++++-------- 5 files changed, 23 insertions(+), 14 deletions(-) diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex index 293bbc082..66ffe9090 100644 --- a/lib/pleroma/user/query.ex +++ b/lib/pleroma/user/query.ex @@ -45,7 +45,7 @@ defmodule Pleroma.User.Query do is_admin: boolean(), is_moderator: boolean(), super_users: boolean(), - exclude_service_users: boolean(), + invisible: boolean(), followers: User.t(), friends: User.t(), recipients_from_activity: [String.t()], @@ -89,8 +89,8 @@ defp compose_query({key, value}, query) where(query, [u], ilike(field(u, ^key), ^"%#{value}%")) end - defp compose_query({:exclude_service_users, _}, query) do - where(query, [u], not like(u.ap_id, "%/relay") and not like(u.ap_id, "%/internal/fetch")) + defp compose_query({:invisible, bool}, query) when is_boolean(bool) do + where(query, [u], u.invisible == ^bool) end defp compose_query({key, value}, query) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index b8a2873d8..a38f9a3c8 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1030,6 +1030,17 @@ defp exclude_poll_votes(query, _) do end end + defp exclude_invisible_actors(query, %{"invisible_actors" => true}), do: query + + defp exclude_invisible_actors(query, _opts) do + invisible_ap_ids = + User.Query.build(%{invisible: true, select: [:ap_id]}) + |> Repo.all() + |> Enum.map(fn %{ap_id: ap_id} -> ap_id end) + + from([activity] in query, where: activity.actor not in ^invisible_ap_ids) + end + defp exclude_id(query, %{"exclude_id" => id}) when is_binary(id) do from(activity in query, where: activity.id != ^id) end @@ -1135,6 +1146,7 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> restrict_instance(opts) |> Activity.restrict_deactivated_users() |> exclude_poll_votes(opts) + |> exclude_invisible_actors(opts) |> exclude_visibility(opts) end diff --git a/lib/pleroma/web/admin_api/search.ex b/lib/pleroma/web/admin_api/search.ex index c28efadd5..0bfb8f022 100644 --- a/lib/pleroma/web/admin_api/search.ex +++ b/lib/pleroma/web/admin_api/search.ex @@ -21,7 +21,7 @@ def user(params \\ %{}) do query = params |> Map.drop([:page, :page_size]) - |> Map.put(:exclude_service_users, true) + |> Map.put(:invisible, false) |> User.Query.build() |> order_by([u], u.nickname) @@ -31,7 +31,6 @@ def user(params \\ %{}) do count = Repo.aggregate(query, :count, :id) results = Repo.all(paginated_query) - {:ok, results, count} end end diff --git a/test/tasks/relay_test.exs b/test/tasks/relay_test.exs index d3d88467d..678288854 100644 --- a/test/tasks/relay_test.exs +++ b/test/tasks/relay_test.exs @@ -65,7 +65,8 @@ test "relay is unfollowed" do "type" => "Undo", "actor_id" => follower_id, "limit" => 1, - "skip_preload" => true + "skip_preload" => true, + "invisible_actors" => true }) assert undo_activity.data["type"] == "Undo" diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index ead840186..193690469 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -757,8 +757,8 @@ test "renders users array for the first page", %{conn: conn, admin: admin} do end test "pagination works correctly with service users", %{conn: conn} do - service1 = insert(:user, ap_id: Web.base_url() <> "/relay") - service2 = insert(:user, ap_id: Web.base_url() <> "/internal/fetch") + service1 = User.get_or_create_service_actor_by_ap_id(Web.base_url() <> "/meido", "meido") + insert_list(25, :user) assert %{"count" => 26, "page_size" => 10, "users" => users1} = @@ -767,8 +767,7 @@ test "pagination works correctly with service users", %{conn: conn} do |> json_response(200) assert Enum.count(users1) == 10 - assert service1 not in [users1] - assert service2 not in [users1] + assert service1 not in users1 assert %{"count" => 26, "page_size" => 10, "users" => users2} = conn @@ -776,8 +775,7 @@ test "pagination works correctly with service users", %{conn: conn} do |> json_response(200) assert Enum.count(users2) == 10 - assert service1 not in [users2] - assert service2 not in [users2] + assert service1 not in users2 assert %{"count" => 26, "page_size" => 10, "users" => users3} = conn @@ -785,8 +783,7 @@ test "pagination works correctly with service users", %{conn: conn} do |> json_response(200) assert Enum.count(users3) == 6 - assert service1 not in [users3] - assert service2 not in [users3] + assert service1 not in users3 end test "renders empty array for the second page", %{conn: conn} do From 805ab86933d90d4284c83e4a8ebfd6bf4b0395b3 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 2 Jun 2020 13:24:34 +0200 Subject: [PATCH 160/401] Notifications: Make notifications save their type. --- lib/pleroma/following_relationship.ex | 11 ++-- lib/pleroma/notification.ex | 61 ++++++++++++++++++- .../web/activity_pub/transmogrifier.ex | 3 + .../operations/notification_operation.ex | 1 + lib/pleroma/web/common_api/common_api.ex | 1 + .../mastodon_api/views/notification_view.ex | 20 +----- ...200602094828_add_type_to_notifications.exs | 9 +++ test/notification_test.exs | 11 +++- 8 files changed, 90 insertions(+), 27 deletions(-) create mode 100644 priv/repo/migrations/20200602094828_add_type_to_notifications.exs diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index 3a3082e72..0343a20d4 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -62,10 +62,13 @@ def update(%User{} = follower, %User{} = following, state) do follow(follower, following, state) following_relationship -> - following_relationship - |> cast(%{state: state}, [:state]) - |> validate_required([:state]) - |> Repo.update() + {:ok, relationship} = + following_relationship + |> cast(%{state: state}, [:state]) + |> validate_required([:state]) + |> Repo.update() + + {:ok, relationship} end end diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index efafbce48..41ac53505 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -30,12 +30,26 @@ defmodule Pleroma.Notification do schema "notifications" do field(:seen, :boolean, default: false) + field(:type, :string) belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType) timestamps() end + def update_notification_type(user, activity) do + with %__MODULE__{} = notification <- + Repo.get_by(__MODULE__, user_id: user.id, activity_id: activity.id) do + type = + activity + |> type_from_activity() + + notification + |> changeset(%{type: type}) + |> Repo.update() + end + end + @spec unread_notifications_count(User.t()) :: integer() def unread_notifications_count(%User{id: user_id}) do from(q in __MODULE__, @@ -46,7 +60,7 @@ def unread_notifications_count(%User{id: user_id}) do def changeset(%Notification{} = notification, attrs) do notification - |> cast(attrs, [:seen]) + |> cast(attrs, [:seen, :type]) end @spec last_read_query(User.t()) :: Ecto.Queryable.t() @@ -330,12 +344,55 @@ defp do_create_notifications(%Activity{} = activity) do {:ok, notifications} end + defp type_from_activity(%{data: %{"type" => type}} = activity) do + case type do + "Follow" -> + if Activity.follow_accepted?(activity) do + "follow" + else + "follow_request" + end + + "Announce" -> + "reblog" + + "Like" -> + "favourite" + + "Move" -> + "move" + + "EmojiReact" -> + "pleroma:emoji_reaction" + + "Create" -> + activity + |> type_from_activity_object() + + t -> + raise "No notification type for activity type #{t}" + end + end + + defp type_from_activity_object(%{data: %{"type" => "Create"}} = activity) do + object = Object.normalize(activity, false) + + case object.data["type"] do + "ChatMessage" -> "pleroma:chat_mention" + _ -> "mention" + end + end + # TODO move to sql, too. def create_notification(%Activity{} = activity, %User{} = user, do_send \\ true) do unless skip?(activity, user) do {:ok, %{notification: notification}} = Multi.new() - |> Multi.insert(:notification, %Notification{user_id: user.id, activity: activity}) + |> Multi.insert(:notification, %Notification{ + user_id: user.id, + activity: activity, + type: type_from_activity(activity) + }) |> Marker.multi_set_last_read_id(user, "notifications") |> Repo.transaction() diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 4ac0d43fc..886403fcd 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do alias Pleroma.Activity alias Pleroma.EarmarkRenderer alias Pleroma.FollowingRelationship + alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Object.Containment alias Pleroma.Repo @@ -595,6 +596,8 @@ def handle_incoming( User.update_follower_count(followed) User.update_following_count(follower) + Notification.update_notification_type(followed, follow_activity) + ActivityPub.accept(%{ to: follow_activity.data["to"], type: "Accept", diff --git a/lib/pleroma/web/api_spec/operations/notification_operation.ex b/lib/pleroma/web/api_spec/operations/notification_operation.ex index 46e72f8bf..c966b553a 100644 --- a/lib/pleroma/web/api_spec/operations/notification_operation.ex +++ b/lib/pleroma/web/api_spec/operations/notification_operation.ex @@ -185,6 +185,7 @@ defp notification_type do "mention", "poll", "pleroma:emoji_reaction", + "pleroma:chat_mention", "move", "follow_request" ], diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index e0987b1a7..5a194910d 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -121,6 +121,7 @@ def accept_follow_request(follower, followed) do object: follow_activity.data["id"], type: "Accept" }) do + Notification.update_notification_type(followed, follow_activity) {:ok, follower} 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 07d55a3e9..c090be8ad 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -81,22 +81,6 @@ def render( end end - # This returns the notification type by activity, but both chats and statuses - # are in "Create" activities. - mastodon_type = - case Activity.mastodon_notification_type(activity) do - "mention" -> - object = Object.normalize(activity) - - case object do - %{data: %{"type" => "ChatMessage"}} -> "pleroma:chat_mention" - _ -> "mention" - end - - type -> - type - end - # Note: :relationships contain user mutes (needed for :muted flag in :status) status_render_opts = %{relationships: opts[:relationships]} @@ -107,7 +91,7 @@ def render( ) do response = %{ id: to_string(notification.id), - type: mastodon_type, + type: notification.type, created_at: CommonAPI.Utils.to_masto_date(notification.inserted_at), account: account, pleroma: %{ @@ -115,7 +99,7 @@ def render( } } - case mastodon_type do + case notification.type do "mention" -> put_status(response, activity, reading_user, status_render_opts) diff --git a/priv/repo/migrations/20200602094828_add_type_to_notifications.exs b/priv/repo/migrations/20200602094828_add_type_to_notifications.exs new file mode 100644 index 000000000..19c733628 --- /dev/null +++ b/priv/repo/migrations/20200602094828_add_type_to_notifications.exs @@ -0,0 +1,9 @@ +defmodule Pleroma.Repo.Migrations.AddTypeToNotifications do + use Ecto.Migration + + def change do + alter table(:notifications) do + add(:type, :string) + end + end +end diff --git a/test/notification_test.exs b/test/notification_test.exs index 37c255fee..421b7fc40 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -31,6 +31,7 @@ test "creates a notification for an emoji reaction" do {:ok, [notification]} = Notification.create_notifications(activity) assert notification.user_id == user.id + assert notification.type == "pleroma:emoji_reaction" end test "notifies someone when they are directly addressed" do @@ -48,6 +49,7 @@ test "notifies someone when they are directly addressed" do notified_ids = Enum.sort([notification.user_id, other_notification.user_id]) assert notified_ids == [other_user.id, third_user.id] assert notification.activity_id == activity.id + assert notification.type == "mention" assert other_notification.activity_id == activity.id assert [%Pleroma.Marker{unread_count: 2}] = @@ -335,9 +337,12 @@ test "it creates `follow_request` notification for pending Follow activity" do # After request is accepted, the same notification is rendered with type "follow": assert {:ok, _} = CommonAPI.accept_follow_request(user, followed_user) - notification_id = notification.id - assert [%{id: ^notification_id}] = Notification.for_user(followed_user) - assert %{type: "follow"} = NotificationView.render("show.json", render_opts) + notification = + Repo.get(Notification, notification.id) + |> Repo.preload(:activity) + + assert %{type: "follow"} = + NotificationView.render("show.json", notification: notification, for: followed_user) end test "it doesn't create a notification for follow-unfollow-follow chains" do From 127ccc4e1c76c2782b26a0cfbb154bc1317f31b3 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 2 Jun 2020 14:05:53 +0200 Subject: [PATCH 161/401] NotificationController: Don't return chat_mentions by default. --- docs/API/chats.md | 2 +- .../controllers/notification_controller.ex | 14 ++++++++++++- lib/pleroma/web/mastodon_api/mastodon_api.ex | 15 ++----------- .../notification_controller_test.exs | 21 +++++++++++++++++++ 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index 2eca5adf6..d1d39f495 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -204,7 +204,7 @@ Returned data is the deleted message. ### Notifications -There's a new `pleroma:chat_mention` notification, which has this form: +There's a new `pleroma:chat_mention` notification, which has this form. It is not given out in the notifications endpoint by default, you need to explicitly request it with `include_types[]=pleroma:chat_mention`: ```json { diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex index bcd12c73f..e25cef30b 100644 --- a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex @@ -42,8 +42,20 @@ def index(conn, %{account_id: account_id} = params) do end end + @default_notification_types ~w{ + mention + follow + follow_request + reblog + favourite + move + pleroma:emoji_reaction + } def index(%{assigns: %{user: user}} = conn, params) do - params = Map.new(params, fn {k, v} -> {to_string(k), v} end) + params = + Map.new(params, fn {k, v} -> {to_string(k), v} end) + |> Map.put_new("include_types", @default_notification_types) + notifications = MastodonAPI.get_notifications(user, params) conn diff --git a/lib/pleroma/web/mastodon_api/mastodon_api.ex b/lib/pleroma/web/mastodon_api/mastodon_api.ex index 70da64a7a..694bf5ca8 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api.ex @@ -6,7 +6,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do import Ecto.Query import Ecto.Changeset - alias Pleroma.Activity alias Pleroma.Notification alias Pleroma.Pagination alias Pleroma.ScheduledActivity @@ -82,15 +81,11 @@ defp cast_params(params) do end defp restrict(query, :include_types, %{include_types: mastodon_types = [_ | _]}) do - ap_types = convert_and_filter_mastodon_types(mastodon_types) - - where(query, [q, a], fragment("? @> ARRAY[?->>'type']::varchar[]", ^ap_types, a.data)) + where(query, [n], n.type in ^mastodon_types) end defp restrict(query, :exclude_types, %{exclude_types: mastodon_types = [_ | _]}) do - ap_types = convert_and_filter_mastodon_types(mastodon_types) - - where(query, [q, a], not fragment("? @> ARRAY[?->>'type']::varchar[]", ^ap_types, a.data)) + where(query, [n], n.type not in ^mastodon_types) end defp restrict(query, :account_ap_id, %{account_ap_id: account_ap_id}) do @@ -98,10 +93,4 @@ defp restrict(query, :account_ap_id, %{account_ap_id: account_ap_id}) do end defp restrict(query, _, _), do: query - - defp convert_and_filter_mastodon_types(types) do - types - |> Enum.map(&Activity.from_mastodon_notification_type/1) - |> Enum.filter(& &1) - end end diff --git a/test/web/mastodon_api/controllers/notification_controller_test.exs b/test/web/mastodon_api/controllers/notification_controller_test.exs index e278d61f5..698c99711 100644 --- a/test/web/mastodon_api/controllers/notification_controller_test.exs +++ b/test/web/mastodon_api/controllers/notification_controller_test.exs @@ -54,6 +54,27 @@ test "list of notifications" do assert response == expected_response end + test "by default, does not contain pleroma:chat_mention" do + %{user: user, conn: conn} = oauth_access(["read:notifications"]) + other_user = insert(:user) + + {:ok, _activity} = CommonAPI.post_chat_message(other_user, user, "hey") + + result = + conn + |> get("/api/v1/notifications") + |> json_response_and_validate_schema(200) + + assert [] == result + + result = + conn + |> get("/api/v1/notifications?include_types[]=pleroma:chat_mention") + |> json_response_and_validate_schema(200) + + assert [_] = result + end + test "getting a single notification" do %{user: user, conn: conn} = oauth_access(["read:notifications"]) other_user = insert(:user) From 37542a9dfa99cc4324f211b45254acea758ac1ae Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 2 Jun 2020 14:22:16 +0200 Subject: [PATCH 162/401] Activity: Remove notifications-related functions. --- lib/pleroma/activity.ex | 36 ------------------- .../mastodon_api/views/notification_view.ex | 15 ++++---- lib/pleroma/web/push/impl.ex | 14 +++----- test/web/push/impl_test.exs | 25 +++++++------ 4 files changed, 26 insertions(+), 64 deletions(-) diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 6213d0eb7..da1be20b3 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -24,16 +24,6 @@ defmodule Pleroma.Activity do @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} - # https://github.com/tootsuite/mastodon/blob/master/app/models/notification.rb#L19 - @mastodon_notification_types %{ - "Create" => "mention", - "Follow" => ["follow", "follow_request"], - "Announce" => "reblog", - "Like" => "favourite", - "Move" => "move", - "EmojiReact" => "pleroma:emoji_reaction" - } - schema "activities" do field(:data, :map) field(:local, :boolean, default: true) @@ -300,32 +290,6 @@ def follow_accepted?( def follow_accepted?(_), do: false - @spec mastodon_notification_type(Activity.t()) :: String.t() | nil - - for {ap_type, type} <- @mastodon_notification_types, not is_list(type) do - def mastodon_notification_type(%Activity{data: %{"type" => unquote(ap_type)}}), - do: unquote(type) - end - - def mastodon_notification_type(%Activity{data: %{"type" => "Follow"}} = activity) do - if follow_accepted?(activity) do - "follow" - else - "follow_request" - end - end - - def mastodon_notification_type(%Activity{}), do: nil - - @spec from_mastodon_notification_type(String.t()) :: String.t() | nil - @doc "Converts Mastodon notification type to AR activity type" - def from_mastodon_notification_type(type) do - with {k, _v} <- - Enum.find(@mastodon_notification_types, fn {_k, v} -> type in List.wrap(v) end) do - k - end - end - def all_by_actor_and_id(actor, status_ids \\ []) def all_by_actor_and_id(_actor, []), do: [] diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index c090be8ad..af15bba48 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -16,18 +16,17 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.PleromaAPI.ChatMessageView + @parent_types ~w{Like Announce EmojiReact} + def render("index.json", %{notifications: notifications, for: reading_user} = opts) do activities = Enum.map(notifications, & &1.activity) parent_activities = activities - |> Enum.filter( - &(Activity.mastodon_notification_type(&1) in [ - "favourite", - "reblog", - "pleroma:emoji_reaction" - ]) - ) + |> Enum.filter(fn + %{data: %{"type" => type}} -> + type in @parent_types + end) |> Enum.map(& &1.data["object"]) |> Activity.create_by_object_ap_id() |> Activity.with_preloaded_object(:left) @@ -44,7 +43,7 @@ def render("index.json", %{notifications: notifications, for: reading_user} = op true -> move_activities_targets = activities - |> Enum.filter(&(Activity.mastodon_notification_type(&1) == "move")) + |> Enum.filter(&(&1.data["type"] == "Move")) |> Enum.map(&User.get_cached_by_ap_id(&1.data["target"])) actors = diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index 691725702..125f33755 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -16,8 +16,6 @@ defmodule Pleroma.Web.Push.Impl do require Logger import Ecto.Query - defdelegate mastodon_notification_type(activity), to: Activity - @types ["Create", "Follow", "Announce", "Like", "Move"] @doc "Performs sending notifications for user subscriptions" @@ -31,7 +29,7 @@ def perform( when activity_type in @types do actor = User.get_cached_by_ap_id(notification.activity.data["actor"]) - mastodon_type = mastodon_notification_type(notification.activity) + mastodon_type = notification.type gcm_api_key = Application.get_env(:web_push_encryption, :gcm_api_key) avatar_url = User.avatar_url(actor) object = Object.normalize(activity) @@ -116,7 +114,7 @@ def build_content( end def build_content(notification, actor, object, mastodon_type) do - mastodon_type = mastodon_type || mastodon_notification_type(notification.activity) + mastodon_type = mastodon_type || notification.type %{ title: format_title(notification, mastodon_type), @@ -151,7 +149,7 @@ def format_body( mastodon_type ) when type in ["Follow", "Like"] do - mastodon_type = mastodon_type || mastodon_notification_type(notification.activity) + mastodon_type = mastodon_type || notification.type case mastodon_type do "follow" -> "@#{actor.nickname} has followed you" @@ -166,10 +164,8 @@ def format_title(%{activity: %{data: %{"directMessage" => true}}}, _mastodon_typ "New Direct Message" end - def format_title(%{activity: activity}, mastodon_type) do - mastodon_type = mastodon_type || mastodon_notification_type(activity) - - case mastodon_type do + def format_title(%{type: type}, mastodon_type) do + case mastodon_type || type do "mention" -> "New Mention" "follow" -> "New Follower" "follow_request" -> "New Follow Request" diff --git a/test/web/push/impl_test.exs b/test/web/push/impl_test.exs index a826b24c9..26c65bc82 100644 --- a/test/web/push/impl_test.exs +++ b/test/web/push/impl_test.exs @@ -60,7 +60,8 @@ test "performs sending notifications" do notif = insert(:notification, user: user, - activity: activity + activity: activity, + type: "mention" ) assert Impl.perform(notif) == {:ok, [:ok, :ok]} @@ -126,7 +127,7 @@ test "renders title and body for create activity" do ) == "@Bob: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce sagittis fini..." - assert Impl.format_title(%{activity: activity}) == + assert Impl.format_title(%{activity: activity, type: "mention"}) == "New Mention" end @@ -136,9 +137,10 @@ test "renders title and body for follow activity" do {:ok, _, _, activity} = CommonAPI.follow(user, other_user) object = Object.normalize(activity, false) - assert Impl.format_body(%{activity: activity}, user, object) == "@Bob has followed you" + assert Impl.format_body(%{activity: activity, type: "follow"}, user, object) == + "@Bob has followed you" - assert Impl.format_title(%{activity: activity}) == + assert Impl.format_title(%{activity: activity, type: "follow"}) == "New Follower" end @@ -157,7 +159,7 @@ test "renders title and body for announce activity" do assert Impl.format_body(%{activity: announce_activity}, user, object) == "@#{user.nickname} repeated: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce sagittis fini..." - assert Impl.format_title(%{activity: announce_activity}) == + assert Impl.format_title(%{activity: announce_activity, type: "reblog"}) == "New Repeat" end @@ -173,9 +175,10 @@ test "renders title and body for like activity" do {:ok, activity} = CommonAPI.favorite(user, activity.id) object = Object.normalize(activity) - assert Impl.format_body(%{activity: activity}, user, object) == "@Bob has favorited your post" + assert Impl.format_body(%{activity: activity, type: "favourite"}, user, object) == + "@Bob has favorited your post" - assert Impl.format_title(%{activity: activity}) == + assert Impl.format_title(%{activity: activity, type: "favourite"}) == "New Favorite" end @@ -218,7 +221,7 @@ test "hides details for notifications when privacy option enabled" do status: "Lorem ipsum dolor sit amet, consectetur :firefox: adipiscing elit. Fusce sagittis finibus turpis." }) - notif = insert(:notification, user: user2, activity: activity) + notif = insert(:notification, user: user2, activity: activity, type: "mention") actor = User.get_cached_by_ap_id(notif.activity.data["actor"]) object = Object.normalize(activity) @@ -281,7 +284,7 @@ test "returns regular content for notifications with privacy option disabled" do {:ok, activity} = CommonAPI.favorite(user, activity.id) - notif = insert(:notification, user: user2, activity: activity) + notif = insert(:notification, user: user2, activity: activity, type: "favourite") actor = User.get_cached_by_ap_id(notif.activity.data["actor"]) object = Object.normalize(activity) From 38dce485c47e9315663c5c9cfd67dab4164b1bbe Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 2 Jun 2020 14:49:56 +0200 Subject: [PATCH 163/401] Notification: Add function to backfill notification types --- lib/pleroma/notification.ex | 20 ++++++++++++++++++++ test/notification_test.exs | 28 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 41ac53505..c8b964400 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -37,6 +37,26 @@ defmodule Pleroma.Notification do timestamps() end + def fill_in_notification_types() do + query = + from(n in __MODULE__, + where: is_nil(n.type), + preload: :activity + ) + + query + |> Repo.all() + |> Enum.each(fn notification -> + type = + notification.activity + |> type_from_activity() + + notification + |> changeset(%{type: type}) + |> Repo.update() + end) + end + def update_notification_type(user, activity) do with %__MODULE__{} = notification <- Repo.get_by(__MODULE__, user_id: user.id, activity_id: activity.id) do diff --git a/test/notification_test.exs b/test/notification_test.exs index 421b7fc40..6bc2b6904 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -20,6 +20,34 @@ defmodule Pleroma.NotificationTest do alias Pleroma.Web.Push alias Pleroma.Web.Streamer + describe "fill_in_notification_types" do + test "it fills in missing notification types" do + user = insert(:user) + other_user = insert(:user) + + {:ok, post} = CommonAPI.post(user, %{status: "yeah, @#{other_user.nickname}"}) + {:ok, chat} = CommonAPI.post_chat_message(user, other_user, "yo") + {:ok, react} = CommonAPI.react_with_emoji(post.id, other_user, "☕") + {:ok, like} = CommonAPI.favorite(other_user, post.id) + + assert {4, nil} = Repo.update_all(Notification, set: [type: nil]) + + Notification.fill_in_notification_types() + + assert %{type: "mention"} = + Repo.get_by(Notification, user_id: other_user.id, activity_id: post.id) + + assert %{type: "favourite"} = + Repo.get_by(Notification, user_id: user.id, activity_id: like.id) + + assert %{type: "pleroma:emoji_reaction"} = + Repo.get_by(Notification, user_id: user.id, activity_id: react.id) + + assert %{type: "pleroma:chat_mention"} = + Repo.get_by(Notification, user_id: other_user.id, activity_id: chat.id) + end + end + describe "create_notifications" do test "creates a notification for an emoji reaction" do user = insert(:user) From 6cd2fa2a4cbffaaab7c911f1051d4917e8a06c78 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 2 Jun 2020 15:13:19 +0200 Subject: [PATCH 164/401] Migrations: Add a migration to backfill notification types. --- lib/pleroma/notification.ex | 23 +++++++++++++++---- ...0602125218_backfill_notification_types.exs | 10 ++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 priv/repo/migrations/20200602125218_backfill_notification_types.exs diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index c8b964400..d89ee4645 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -49,7 +49,7 @@ def fill_in_notification_types() do |> Enum.each(fn notification -> type = notification.activity - |> type_from_activity() + |> type_from_activity(no_cachex: true) notification |> changeset(%{type: type}) @@ -364,10 +364,23 @@ defp do_create_notifications(%Activity{} = activity) do {:ok, notifications} end - defp type_from_activity(%{data: %{"type" => type}} = activity) do + defp type_from_activity(%{data: %{"type" => type}} = activity, opts \\ []) do case type do "Follow" -> - if Activity.follow_accepted?(activity) do + accepted_function = + if Keyword.get(opts, :no_cachex, false) do + # A special function to make this usable in a migration. + fn activity -> + with %User{} = follower <- User.get_by_ap_id(activity.data["actor"]), + %User{} = followed <- User.get_by_ap_id(activity.data["object"]) do + Pleroma.FollowingRelationship.following?(follower, followed) + end + end + else + &Activity.follow_accepted?/1 + end + + if accepted_function.(activity) do "follow" else "follow_request" @@ -394,8 +407,10 @@ defp type_from_activity(%{data: %{"type" => type}} = activity) do end end + defp type_from_activity_object(%{data: %{"type" => "Create", "object" => %{}}}), do: "mention" + defp type_from_activity_object(%{data: %{"type" => "Create"}} = activity) do - object = Object.normalize(activity, false) + object = Object.get_by_ap_id(activity.data["object"]) case object.data["type"] do "ChatMessage" -> "pleroma:chat_mention" diff --git a/priv/repo/migrations/20200602125218_backfill_notification_types.exs b/priv/repo/migrations/20200602125218_backfill_notification_types.exs new file mode 100644 index 000000000..493c0280c --- /dev/null +++ b/priv/repo/migrations/20200602125218_backfill_notification_types.exs @@ -0,0 +1,10 @@ +defmodule Pleroma.Repo.Migrations.BackfillNotificationTypes do + use Ecto.Migration + + def up do + Pleroma.Notification.fill_in_notification_types() + end + + def down do + end +end From 2c6ebe709a9fb84bedb5d50c24715fd4532272f9 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 2 Jun 2020 15:14:52 +0200 Subject: [PATCH 165/401] Credo fixes --- lib/pleroma/notification.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index d89ee4645..0f33d282d 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -37,7 +37,7 @@ defmodule Pleroma.Notification do timestamps() end - def fill_in_notification_types() do + def fill_in_notification_types do query = from(n in __MODULE__, where: is_nil(n.type), From f73b2063f484e83c0972527c00c42d4fbdd11a0c Mon Sep 17 00:00:00 2001 From: stwf Date: Tue, 2 Jun 2020 10:18:06 -0400 Subject: [PATCH 166/401] encode data properly --- .../web/fallback_redirect_controller.ex | 7 +-- lib/pleroma/web/preload.ex | 2 + lib/pleroma/web/preload/timelines.ex | 31 ++++++------- test/web/fallback_test.exs | 46 +++++++++++-------- test/web/preload/timeline_test.exs | 12 ++--- test/web/streamer/streamer_test.exs | 2 +- 6 files changed, 55 insertions(+), 45 deletions(-) diff --git a/lib/pleroma/web/fallback_redirect_controller.ex b/lib/pleroma/web/fallback_redirect_controller.ex index 932fb8d7e..431ad5485 100644 --- a/lib/pleroma/web/fallback_redirect_controller.ex +++ b/lib/pleroma/web/fallback_redirect_controller.ex @@ -4,7 +4,9 @@ defmodule Fallback.RedirectController do use Pleroma.Web, :controller + require Logger + alias Pleroma.User alias Pleroma.Web.Metadata alias Pleroma.Web.Preload @@ -38,8 +40,7 @@ def redirector_with_meta(conn, params) do response = index_content - |> String.replace("", tags) - |> String.replace("", preloads) + |> String.replace("", tags <> preloads) conn |> put_resp_content_type("text/html") @@ -56,7 +57,7 @@ def redirector_with_preload(conn, params) do response = index_content - |> String.replace("", preloads) + |> String.replace("", preloads) conn |> put_resp_content_type("text/html") diff --git a/lib/pleroma/web/preload.ex b/lib/pleroma/web/preload.ex index c2211c597..f13932b89 100644 --- a/lib/pleroma/web/preload.ex +++ b/lib/pleroma/web/preload.ex @@ -22,6 +22,8 @@ def build_tags(_conn, params) do end def build_script_tag(content) do + content = Base.encode64(content) + HTML.Tag.content_tag(:script, HTML.raw(content), id: "initial-results", type: "application/json" diff --git a/lib/pleroma/web/preload/timelines.ex b/lib/pleroma/web/preload/timelines.ex index dbd7db407..2bb57567b 100644 --- a/lib/pleroma/web/preload/timelines.ex +++ b/lib/pleroma/web/preload/timelines.ex @@ -11,32 +11,29 @@ defmodule Pleroma.Web.Preload.Providers.Timelines do @public_url :"/api/v1/timelines/public" @impl Provider - def generate_terms(_params) do - build_public_tag(%{}) + def generate_terms(params) do + build_public_tag(%{}, params) end - def build_public_tag(acc) do + def build_public_tag(acc, params) do if Pleroma.Config.get([:restrict_unauthenticated, :timelines, :federated], true) do acc else - Map.put(acc, @public_url, public_timeline(nil)) + Map.put(acc, @public_url, public_timeline(params)) end end - defp public_timeline(user) do + defp public_timeline(%{"path" => ["main", "all"]}), do: get_public_timeline(false) + + defp public_timeline(_params), do: get_public_timeline(true) + + defp get_public_timeline(local_only) do activities = - create_timeline_params(user) - |> Map.put("local_only", false) - |> ActivityPub.fetch_public_activities() + ActivityPub.fetch_public_activities(%{ + "type" => ["Create"], + "local_only" => local_only + }) - StatusView.render("index.json", activities: activities, for: user, as: :activity) - end - - defp create_timeline_params(user) do - %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) + StatusView.render("index.json", activities: activities, for: nil, as: :activity) end end diff --git a/test/web/fallback_test.exs b/test/web/fallback_test.exs index 3b7a51d5e..a65865860 100644 --- a/test/web/fallback_test.exs +++ b/test/web/fallback_test.exs @@ -11,7 +11,12 @@ test "GET /registration/:token", %{conn: conn} do response = get(conn, "/registration/foo") assert html_response(response, 200) =~ "" - assert html_response(response, 200) =~ "" + end + + test "GET /*path", %{conn: conn} do + assert conn + |> get("/foo") + |> html_response(200) =~ "" end end @@ -21,20 +26,35 @@ test "GET /:maybe_nickname_or_id", %{conn: conn} do user_missing = get(conn, "/foo") user_present = get(conn, "/#{user.nickname}") - assert html_response(user_missing, 200) =~ "" + assert(html_response(user_missing, 200) =~ "") refute html_response(user_present, 200) =~ "" + assert html_response(user_present, 200) =~ "initial-results" + end - assert html_response(user_missing, 200) =~ "" - refute html_response(user_present, 200) =~ "" + test "GET /*path", %{conn: conn} do + assert conn + |> get("/foo") + |> html_response(200) =~ "" + + refute conn + |> get("/foo/bar") + |> html_response(200) =~ "" end end - describe "preloaded data only attached to" do - test "GET /*path", %{conn: conn} do + describe "preloaded data is attached to" do + test "GET /main/public", %{conn: conn} do public_page = get(conn, "/main/public") - assert html_response(public_page, 200) =~ "" - refute html_response(public_page, 200) =~ "" + refute html_response(public_page, 200) =~ "" + assert html_response(public_page, 200) =~ "initial-results" + end + + test "GET /main/all", %{conn: conn} do + public_page = get(conn, "/main/all") + + refute html_response(public_page, 200) =~ "" + assert html_response(public_page, 200) =~ "initial-results" end end @@ -48,16 +68,6 @@ test "GET /pleroma/admin -> /pleroma/admin/", %{conn: conn} do assert redirected_to(get(conn, "/pleroma/admin")) =~ "/pleroma/admin/" end - test "GET /*path", %{conn: conn} do - assert conn - |> get("/foo") - |> html_response(200) =~ "" - - assert conn - |> get("/foo/bar") - |> html_response(200) =~ "" - end - test "OPTIONS /*path", %{conn: conn} do assert conn |> options("/foo") diff --git a/test/web/preload/timeline_test.exs b/test/web/preload/timeline_test.exs index 00b10d0ab..da6a3aded 100644 --- a/test/web/preload/timeline_test.exs +++ b/test/web/preload/timeline_test.exs @@ -52,9 +52,9 @@ test "returns the timeline when not restricted" do end test "returns public items", %{user: user} do - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 1!"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 2!"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 3!"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 1!"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 2!"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 3!"}) assert Timelines.generate_terms(%{}) |> Map.fetch!(@public_url) @@ -62,9 +62,9 @@ test "returns public items", %{user: user} do end test "does not return non-public items", %{user: user} do - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 1!", "visibility" => "unlisted"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 2!", "visibility" => "direct"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 3!"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 1!", visibility: "unlisted"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 2!", visibility: "direct"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 3!"}) assert Timelines.generate_terms(%{}) |> Map.fetch!(@public_url) diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index 4cf640ce8..3f012259a 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -124,7 +124,7 @@ test "it streams boosts of mastodon user in the 'user' stream", %{user: user} do |> Map.put("object", activity.data["object"]) |> Map.put("actor", user.ap_id) - {:ok, %Pleroma.Activity{data: data, local: false} = announce} = + {:ok, %Pleroma.Activity{data: _data, local: false} = announce} = Pleroma.Web.ActivityPub.Transmogrifier.handle_incoming(data) assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce} From 7922e63825e2e25ccb52ae6e0a6c0011207a598d Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 2 Jun 2020 19:07:17 +0400 Subject: [PATCH 167/401] Update OpenAPI spec for AdminAPI.StatusController --- lib/pleroma/web/admin_api/controllers/status_controller.ex | 4 +--- lib/pleroma/web/api_spec/operations/admin/status_operation.ex | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/admin_api/controllers/status_controller.ex b/lib/pleroma/web/admin_api/controllers/status_controller.ex index c91fbc771..574196be8 100644 --- a/lib/pleroma/web/admin_api/controllers/status_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/status_controller.ex @@ -41,9 +41,7 @@ def index(%{assigns: %{user: _admin}} = conn, params) do def show(conn, %{id: id}) do with %Activity{} = activity <- Activity.get_by_id(id) do - conn - |> put_view(Pleroma.Web.AdminAPI.StatusView) - |> render("show.json", %{activity: activity}) + render(conn, "show.json", %{activity: activity}) else nil -> {:error, :not_found} end diff --git a/lib/pleroma/web/api_spec/operations/admin/status_operation.ex b/lib/pleroma/web/api_spec/operations/admin/status_operation.ex index 0b138dc79..2947e6b34 100644 --- a/lib/pleroma/web/api_spec/operations/admin/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/status_operation.ex @@ -74,7 +74,7 @@ def show_operation do parameters: [id_param()], security: [%{"oAuth" => ["read:statuses"]}], responses: %{ - 200 => Operation.response("Status", "application/json", Status), + 200 => Operation.response("Status", "application/json", status()), 404 => Operation.response("Not Found", "application/json", ApiError) } } From 030240ee8f80472c8dab0c1f9bb2f30f4271272f Mon Sep 17 00:00:00 2001 From: Alibek Omarov Date: Wed, 3 Jun 2020 04:36:09 +0000 Subject: [PATCH 168/401] docs: clients.md: Add Husky --- docs/clients.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/clients.md b/docs/clients.md index 7f98dc7b1..ea751637e 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -42,6 +42,12 @@ Feel free to contact us to be added to this list! - Platforms: SailfishOS - Features: No Streaming +### Husky +- Source code: +- Contact: [@Husky@enigmatic.observer](https://enigmatic.observer/users/Husky) +- Platforms: Android +- Features: No Streaming, Emoji Reactions, Text Formatting, FE Stickers + ### Nekonium - Homepage: [F-Droid Repository](https://repo.gdgd.jp.net/), [Google Play](https://play.google.com/store/apps/details?id=com.apps.nekonium), [Amazon](https://www.amazon.co.jp/dp/B076FXPRBC/) - Source: From aa22fce8f46cf2e7f871b3584fbfff7ac2ebe4c2 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 12:30:12 +0200 Subject: [PATCH 169/401] ChatMessageReference: Introduce and switch in chat controller. --- lib/pleroma/chat.ex | 5 ++ lib/pleroma/chat_message_reference.ex | 80 +++++++++++++++++++ lib/pleroma/web/activity_pub/side_effects.ex | 9 ++- .../controllers/chat_controller.ex | 60 ++++++++------ ...view.ex => chat_message_reference_view.ex} | 21 +++-- ...02150528_create_chat_message_reference.exs | 20 +++++ test/web/activity_pub/side_effects_test.exs | 13 ++- .../controllers/chat_controller_test.exs | 22 +++-- ...s => chat_message_reference_view_test.exs} | 20 +++-- 9 files changed, 205 insertions(+), 45 deletions(-) create mode 100644 lib/pleroma/chat_message_reference.ex rename lib/pleroma/web/pleroma_api/views/{chat_message_view.ex => chat_message_reference_view.ex} (67%) create mode 100644 priv/repo/migrations/20200602150528_create_chat_message_reference.exs rename test/web/pleroma_api/views/{chat_message_view_test.exs => chat_message_reference_view_test.exs} (67%) diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 4c92a58c7..211b872f9 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -72,6 +72,11 @@ def creation_cng(struct, params) do |> unique_constraint(:user_id, name: :chats_user_id_recipient_index) end + def get_by_id(id) do + __MODULE__ + |> Repo.get(id) + end + def get(user_id, recipient) do __MODULE__ |> Repo.get_by(user_id: user_id, recipient: recipient) diff --git a/lib/pleroma/chat_message_reference.ex b/lib/pleroma/chat_message_reference.ex new file mode 100644 index 000000000..e9ca3dfe8 --- /dev/null +++ b/lib/pleroma/chat_message_reference.ex @@ -0,0 +1,80 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ChatMessageReference do + @moduledoc """ + A reference that builds a relation between an AP chat message that a user can see and whether it has been seen + by them, or should be displayed to them. Used to build the chat view that is presented to the user. + """ + + use Ecto.Schema + + alias Pleroma.Chat + alias Pleroma.Object + alias Pleroma.Repo + + import Ecto.Changeset + import Ecto.Query + + @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} + + schema "chat_message_references" do + belongs_to(:object, Object) + belongs_to(:chat, Chat) + + field(:seen, :boolean, default: false) + + timestamps() + end + + def changeset(struct, params) do + struct + |> cast(params, [:object_id, :chat_id, :seen]) + |> validate_required([:object_id, :chat_id, :seen]) + end + + def get_by_id(id) do + __MODULE__ + |> Repo.get(id) + |> Repo.preload(:object) + end + + def delete(cm_ref) do + cm_ref + |> Repo.delete() + end + + def delete_for_object(%{id: object_id}) do + from(cr in __MODULE__, + where: cr.object_id == ^object_id + ) + |> Repo.delete_all() + end + + def for_chat_and_object(%{id: chat_id}, %{id: object_id}) do + __MODULE__ + |> Repo.get_by(chat_id: chat_id, object_id: object_id) + |> Repo.preload(:object) + end + + def for_chat_query(chat) do + from(cr in __MODULE__, + where: cr.chat_id == ^chat.id, + order_by: [desc: :id], + preload: [:object] + ) + end + + def create(chat, object, seen) do + params = %{ + chat_id: chat.id, + object_id: object.id, + seen: seen + } + + %__MODULE__{} + |> changeset(params) + |> Repo.insert() + end +end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index a34bf6a05..cda52b00e 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do """ alias Pleroma.Activity alias Pleroma.Chat + alias Pleroma.ChatMessageReference alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo @@ -104,6 +105,8 @@ def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, Object.decrease_replies_count(in_reply_to) end + ChatMessageReference.delete_for_object(deleted_object) + ActivityPub.stream_out(object) ActivityPub.stream_out_participations(deleted_object, user) :ok @@ -137,9 +140,11 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do |> Enum.each(fn [user, other_user] -> if user.local do if user.ap_id == actor.ap_id do - Chat.get_or_create(user.id, other_user.ap_id) + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + ChatMessageReference.create(chat, object, true) else - Chat.bump_or_create(user.id, other_user.ap_id) + {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) + ChatMessageReference.create(chat, object, false) end end end) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 210c8ec4a..c54681054 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -6,14 +6,15 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do alias Pleroma.Activity alias Pleroma.Chat + alias Pleroma.ChatMessageReference alias Pleroma.Object alias Pleroma.Pagination alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.CommonAPI - alias Pleroma.Web.PleromaAPI.ChatMessageView alias Pleroma.Web.PleromaAPI.ChatView + alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView import Ecto.Query import Pleroma.Web.ActivityPub.ObjectValidator, only: [stringify_keys: 1] @@ -35,28 +36,38 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ChatOperation - def delete_message(%{assigns: %{user: %{ap_id: actor} = user}} = conn, %{ - message_id: id + def delete_message(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ + message_id: message_id, + id: chat_id }) do - with %Object{ - data: %{ - "actor" => ^actor, - "id" => object, - "to" => [recipient], - "type" => "ChatMessage" - } - } = message <- Object.get_by_id(id), - %Chat{} = chat <- Chat.get(user.id, recipient), - %Activity{} = activity <- Activity.get_create_by_object_ap_id(object), - {:ok, _delete} <- CommonAPI.delete(activity.id, user) do + with %ChatMessageReference{} = cm_ref <- + ChatMessageReference.get_by_id(message_id), + ^chat_id <- cm_ref.chat_id |> to_string(), + %Chat{user_id: ^user_id} <- Chat.get_by_id(chat_id), + {:ok, _} <- remove_or_delete(cm_ref, user) do conn - |> put_view(ChatMessageView) - |> render("show.json", for: user, object: message, chat: chat) + |> put_view(ChatMessageReferenceView) + |> render("show.json", chat_message_reference: cm_ref) else - _e -> {:error, :could_not_delete} + _e -> + {:error, :could_not_delete} end end + defp remove_or_delete( + %{object: %{data: %{"actor" => actor, "id" => id}}}, + %{ap_id: actor} = user + ) do + with %Activity{} = activity <- Activity.get_create_by_object_ap_id(id) do + CommonAPI.delete(activity.id, user) + end + end + + defp remove_or_delete(cm_ref, _) do + cm_ref + |> ChatMessageReference.delete() + end + def post_chat_message( %{body_params: params, assigns: %{user: %{id: user_id} = user}} = conn, %{ @@ -69,10 +80,11 @@ def post_chat_message( CommonAPI.post_chat_message(user, recipient, params[:content], media_id: params[:media_id] ), - message <- Object.normalize(activity) do + message <- Object.normalize(activity, false), + cm_ref <- ChatMessageReference.for_chat_and_object(chat, message) do conn - |> put_view(ChatMessageView) - |> render("show.json", for: user, object: message, chat: chat) + |> put_view(ChatMessageReferenceView) + |> render("show.json", for: user, chat_message_reference: cm_ref) end end @@ -87,14 +99,14 @@ def mark_as_read(%{assigns: %{user: %{id: user_id}}} = conn, %{id: id}) do def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{id: id} = params) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id) do - messages = + cm_refs = chat - |> Chat.messages_for_chat_query() + |> ChatMessageReference.for_chat_query() |> Pagination.fetch_paginated(params |> stringify_keys()) conn - |> put_view(ChatMessageView) - |> render("index.json", for: user, objects: messages, chat: chat) + |> put_view(ChatMessageReferenceView) + |> render("index.json", for: user, chat_message_references: cm_refs) else _ -> conn diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex similarity index 67% rename from lib/pleroma/web/pleroma_api/views/chat_message_view.ex rename to lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex index b088a8734..ff170e162 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex @@ -2,10 +2,9 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.PleromaAPI.ChatMessageView do +defmodule Pleroma.Web.PleromaAPI.ChatMessageReferenceView do use Pleroma.Web, :view - alias Pleroma.Chat alias Pleroma.User alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.StatusView @@ -13,8 +12,12 @@ defmodule Pleroma.Web.PleromaAPI.ChatMessageView do def render( "show.json", %{ - object: %{id: id, data: %{"type" => "ChatMessage"} = chat_message}, - chat: %Chat{id: chat_id} + chat_message_reference: %{ + id: id, + object: %{data: chat_message}, + chat_id: chat_id, + seen: seen + } } ) do %{ @@ -26,11 +29,17 @@ def render( emojis: StatusView.build_emojis(chat_message["emoji"]), attachment: chat_message["attachment"] && - StatusView.render("attachment.json", attachment: chat_message["attachment"]) + StatusView.render("attachment.json", attachment: chat_message["attachment"]), + seen: seen } end def render("index.json", opts) do - render_many(opts[:objects], __MODULE__, "show.json", Map.put(opts, :as, :object)) + render_many( + opts[:chat_message_references], + __MODULE__, + "show.json", + Map.put(opts, :as, :chat_message_reference) + ) end end diff --git a/priv/repo/migrations/20200602150528_create_chat_message_reference.exs b/priv/repo/migrations/20200602150528_create_chat_message_reference.exs new file mode 100644 index 000000000..6f9148b7c --- /dev/null +++ b/priv/repo/migrations/20200602150528_create_chat_message_reference.exs @@ -0,0 +1,20 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Repo.Migrations.CreateChatMessageReference do + use Ecto.Migration + + def change do + create table(:chat_message_references, primary_key: false) do + add(:id, :uuid, primary_key: true) + add(:chat_id, references(:chats, on_delete: :delete_all), null: false) + add(:object_id, references(:objects, on_delete: :delete_all), null: false) + add(:seen, :boolean, default: false, null: false) + + timestamps() + end + + create(index(:chat_message_references, [:chat_id, "id desc"])) + end +end diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 210ba6ef0..ff6b3ac15 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -8,6 +8,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do alias Pleroma.Activity alias Pleroma.Chat + alias Pleroma.ChatMessageReference alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo @@ -330,7 +331,7 @@ test "it streams the created ChatMessage" do end end - test "it creates a Chat for the local users and bumps the unread count, except for the author" do + test "it creates a Chat and ChatMessageReferences for the local users and bumps the unread count, except for the author" do author = insert(:user, local: true) recipient = insert(:user, local: true) @@ -347,8 +348,18 @@ test "it creates a Chat for the local users and bumps the unread count, except f chat = Chat.get(author.id, recipient.ap_id) assert chat.unread == 0 + [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() + + assert cm_ref.object.data["content"] == "hey" + assert cm_ref.seen == true + chat = Chat.get(recipient.id, author.ap_id) assert chat.unread == 1 + + [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() + + assert cm_ref.object.data["content"] == "hey" + assert cm_ref.seen == false end test "it creates a Chat for the local users and bumps the unread count" do diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index d79aa3148..bd4024c09 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -5,6 +5,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do use Pleroma.Web.ConnCase, async: true alias Pleroma.Chat + alias Pleroma.ChatMessageReference alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub @@ -95,7 +96,7 @@ test "it works with an attachment", %{conn: conn, user: user} do describe "DELETE /api/v1/pleroma/chats/:id/messages/:message_id" do setup do: oauth_access(["write:statuses"]) - test "it deletes a message for the author of the message", %{conn: conn, user: user} do + test "it deletes a message from the chat", %{conn: conn, user: user} do recipient = insert(:user) {:ok, message} = @@ -107,23 +108,32 @@ test "it deletes a message for the author of the message", %{conn: conn, user: u chat = Chat.get(user.id, recipient.ap_id) + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + + # Deleting your own message removes the message and the reference result = conn |> put_req_header("content-type", "application/json") - |> delete("/api/v1/pleroma/chats/#{chat.id}/messages/#{object.id}") + |> delete("/api/v1/pleroma/chats/#{chat.id}/messages/#{cm_ref.id}") |> json_response_and_validate_schema(200) - assert result["id"] == to_string(object.id) + assert result["id"] == cm_ref.id + refute ChatMessageReference.get_by_id(cm_ref.id) + assert %{data: %{"type" => "Tombstone"}} = Object.get_by_id(object.id) + # Deleting other people's messages just removes the reference object = Object.normalize(other_message, false) + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) result = conn |> put_req_header("content-type", "application/json") - |> delete("/api/v1/pleroma/chats/#{chat.id}/messages/#{object.id}") - |> json_response(400) + |> delete("/api/v1/pleroma/chats/#{chat.id}/messages/#{cm_ref.id}") + |> json_response_and_validate_schema(200) - assert result == %{"error" => "could_not_delete"} + assert result["id"] == cm_ref.id + refute ChatMessageReference.get_by_id(cm_ref.id) + assert Object.get_by_id(object.id) end end diff --git a/test/web/pleroma_api/views/chat_message_view_test.exs b/test/web/pleroma_api/views/chat_message_reference_view_test.exs similarity index 67% rename from test/web/pleroma_api/views/chat_message_view_test.exs rename to test/web/pleroma_api/views/chat_message_reference_view_test.exs index d7a2d10a5..00024d52c 100644 --- a/test/web/pleroma_api/views/chat_message_view_test.exs +++ b/test/web/pleroma_api/views/chat_message_reference_view_test.exs @@ -2,14 +2,15 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.PleromaAPI.ChatMessageViewTest do +defmodule Pleroma.Web.PleromaAPI.ChatMessageReferenceViewTest do use Pleroma.DataCase alias Pleroma.Chat + alias Pleroma.ChatMessageReference alias Pleroma.Object alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI - alias Pleroma.Web.PleromaAPI.ChatMessageView + alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView import Pleroma.Factory @@ -30,25 +31,32 @@ test "it displays a chat message" do object = Object.normalize(activity) - chat_message = ChatMessageView.render("show.json", object: object, for: user, chat: chat) + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) - assert chat_message[:id] == object.id |> to_string() + chat_message = ChatMessageReferenceView.render("show.json", chat_message_reference: cm_ref) + + assert chat_message[:id] == cm_ref.id assert chat_message[:content] == "kippis :firefox:" assert chat_message[:account_id] == user.id assert chat_message[:chat_id] assert chat_message[:created_at] + assert chat_message[:seen] == true assert match?([%{shortcode: "firefox"}], chat_message[:emojis]) {:ok, activity} = CommonAPI.post_chat_message(recipient, user, "gkgkgk", media_id: upload.id) object = Object.normalize(activity) - chat_message_two = ChatMessageView.render("show.json", object: object, for: user, chat: chat) + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) - assert chat_message_two[:id] == object.id |> to_string() + chat_message_two = + ChatMessageReferenceView.render("show.json", chat_message_reference: cm_ref) + + assert chat_message_two[:id] == cm_ref.id assert chat_message_two[:content] == "gkgkgk" assert chat_message_two[:account_id] == recipient.id assert chat_message_two[:chat_id] == chat_message[:chat_id] assert chat_message_two[:attachment] + assert chat_message_two[:seen] == false end end From f3ccd50a33c9eec3661bf2116fe38542f04986aa Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 12:49:53 +0200 Subject: [PATCH 170/401] ChatMessageReferences: Adjust views --- lib/pleroma/chat_message_reference.ex | 7 +++++++ lib/pleroma/web/mastodon_api/views/notification_view.ex | 8 +++++--- lib/pleroma/web/pleroma_api/views/chat_view.ex | 8 +++++--- lib/pleroma/web/views/streamer_view.ex | 8 +++++++- test/web/mastodon_api/views/notification_view_test.exs | 7 +++++-- test/web/pleroma_api/views/chat_view_test.exs | 7 +++++-- 6 files changed, 34 insertions(+), 11 deletions(-) diff --git a/lib/pleroma/chat_message_reference.ex b/lib/pleroma/chat_message_reference.ex index e9ca3dfe8..6808d1365 100644 --- a/lib/pleroma/chat_message_reference.ex +++ b/lib/pleroma/chat_message_reference.ex @@ -66,6 +66,13 @@ def for_chat_query(chat) do ) end + def last_message_for_chat(chat) do + chat + |> for_chat_query() + |> limit(1) + |> Repo.one() + end + def create(chat, object, seen) do params = %{ chat_id: chat.id, diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index af15bba48..2ae82eb2d 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do use Pleroma.Web, :view alias Pleroma.Activity + alias Pleroma.ChatMessageReference alias Pleroma.Notification alias Pleroma.Object alias Pleroma.User @@ -14,7 +15,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView - alias Pleroma.Web.PleromaAPI.ChatMessageView + alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView @parent_types ~w{Like Announce EmojiReact} @@ -138,8 +139,9 @@ defp put_chat_message(response, activity, reading_user, opts) do object = Object.normalize(activity) author = User.get_cached_by_ap_id(object.data["actor"]) chat = Pleroma.Chat.get(reading_user.id, author.ap_id) - render_opts = Map.merge(opts, %{object: object, for: reading_user, chat: chat}) - chat_message_render = ChatMessageView.render("show.json", render_opts) + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + render_opts = Map.merge(opts, %{for: reading_user, chat_message_reference: cm_ref}) + chat_message_render = ChatMessageReferenceView.render("show.json", render_opts) Map.put(response, :chat_message, chat_message_render) end diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index 223b64987..331c1d282 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -6,22 +6,24 @@ defmodule Pleroma.Web.PleromaAPI.ChatView do use Pleroma.Web, :view alias Pleroma.Chat + alias Pleroma.ChatMessageReference alias Pleroma.User alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView - alias Pleroma.Web.PleromaAPI.ChatMessageView + alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView def render("show.json", %{chat: %Chat{} = chat} = opts) do recipient = User.get_cached_by_ap_id(chat.recipient) - last_message = opts[:message] || Chat.last_message_for_chat(chat) + last_message = opts[:last_message] || ChatMessageReference.last_message_for_chat(chat) %{ id: chat.id |> to_string(), account: AccountView.render("show.json", Map.put(opts, :user, recipient)), unread: chat.unread, last_message: - last_message && ChatMessageView.render("show.json", chat: chat, object: last_message), + last_message && + ChatMessageReferenceView.render("show.json", chat_message_reference: last_message), updated_at: Utils.to_masto_date(chat.updated_at) } end diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index 5e953d770..616e0c4f2 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.StreamerView do alias Pleroma.Activity alias Pleroma.Chat + alias Pleroma.ChatMessageReference alias Pleroma.Conversation.Participation alias Pleroma.Notification alias Pleroma.User @@ -15,10 +16,15 @@ defmodule Pleroma.Web.StreamerView do def render("chat_update.json", object, user, recipients) do chat = Chat.get(user.id, hd(recipients -- [user.ap_id])) + # Explicitly giving the cmr for the object here, so we don't accidentally + # send a later 'last_message' that was inserted between inserting this and + # streaming it out + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + representation = Pleroma.Web.PleromaAPI.ChatView.render( "show.json", - %{message: object, chat: chat} + %{last_message: cm_ref, chat: chat} ) %{ diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index 384fe7253..c5691341a 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -7,6 +7,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do alias Pleroma.Activity alias Pleroma.Chat + alias Pleroma.ChatMessageReference alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo @@ -16,7 +17,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView - alias Pleroma.Web.PleromaAPI.ChatMessageView + alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView import Pleroma.Factory defp test_notifications_rendering(notifications, user, expected_result) do @@ -44,13 +45,15 @@ test "ChatMessage notification" do object = Object.normalize(activity) chat = Chat.get(recipient.id, user.ap_id) + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + expected = %{ id: to_string(notification.id), pleroma: %{is_seen: false}, type: "pleroma:chat_mention", account: AccountView.render("show.json", %{user: user, for: recipient}), chat_message: - ChatMessageView.render("show.json", %{object: object, for: recipient, chat: chat}), + ChatMessageReferenceView.render("show.json", %{chat_message_reference: cm_ref}), created_at: Utils.to_masto_date(notification.inserted_at) } diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index 6062a0cfe..f3bd12616 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -6,11 +6,12 @@ defmodule Pleroma.Web.PleromaAPI.ChatViewTest do use Pleroma.DataCase alias Pleroma.Chat + alias Pleroma.ChatMessageReference alias Pleroma.Object alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView - alias Pleroma.Web.PleromaAPI.ChatMessageView + alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView alias Pleroma.Web.PleromaAPI.ChatView import Pleroma.Factory @@ -39,7 +40,9 @@ test "it represents a chat" do represented_chat = ChatView.render("show.json", chat: chat) + cm_ref = ChatMessageReference.for_chat_and_object(chat, chat_message) + assert represented_chat[:last_message] == - ChatMessageView.render("show.json", chat: chat, object: chat_message) + ChatMessageReferenceView.render("show.json", chat_message_reference: cm_ref) end end From 8a43611e01cef670c6eac8457be95c5d20efcbc8 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 3 Jun 2020 14:53:46 +0400 Subject: [PATCH 171/401] Use AdminAPI.StatusView in api/admin/users --- lib/pleroma/web/admin_api/controllers/admin_api_controller.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index 9f499e202..cc93fb509 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -30,7 +30,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Web.AdminAPI.Search alias Pleroma.Web.CommonAPI alias Pleroma.Web.Endpoint - alias Pleroma.Web.MastodonAPI alias Pleroma.Web.Router require Logger @@ -279,7 +278,7 @@ def list_user_statuses(conn, %{"nickname" => nickname} = params) do }) conn - |> put_view(MastodonAPI.StatusView) + |> put_view(AdminAPI.StatusView) |> render("index.json", %{activities: activities, as: :activity}) else _ -> {:error, :not_found} From 2591745fc2417771f96340ed3f36177c0da194c3 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 12:56:39 +0200 Subject: [PATCH 172/401] ChatMessageReferences: Move tests --- lib/pleroma/chat.ex | 34 ---------------------------- test/chat_message_reference_test.exs | 29 ++++++++++++++++++++++++ test/chat_test.exs | 16 ------------- 3 files changed, 29 insertions(+), 50 deletions(-) create mode 100644 test/chat_message_reference_test.exs diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 211b872f9..65938c7a4 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -6,9 +6,7 @@ defmodule Pleroma.Chat do use Ecto.Schema import Ecto.Changeset - import Ecto.Query - alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User @@ -26,38 +24,6 @@ defmodule Pleroma.Chat do timestamps() end - def last_message_for_chat(chat) do - messages_for_chat_query(chat) - |> order_by(desc: :id) - |> limit(1) - |> Repo.one() - end - - def messages_for_chat_query(chat) do - chat = - chat - |> Repo.preload(:user) - - from(o in Object, - where: fragment("?->>'type' = ?", o.data, "ChatMessage"), - where: - fragment( - """ - (?->>'actor' = ? and ?->'to' = ?) - OR (?->>'actor' = ? and ?->'to' = ?) - """, - o.data, - ^chat.user.ap_id, - o.data, - ^[chat.recipient], - o.data, - ^chat.recipient, - o.data, - ^[chat.user.ap_id] - ) - ) - end - def creation_cng(struct, params) do struct |> cast(params, [:user_id, :recipient, :unread]) diff --git a/test/chat_message_reference_test.exs b/test/chat_message_reference_test.exs new file mode 100644 index 000000000..963a0e225 --- /dev/null +++ b/test/chat_message_reference_test.exs @@ -0,0 +1,29 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ChatMessageReferencTest do + use Pleroma.DataCase, async: true + + alias Pleroma.Chat + alias Pleroma.ChatMessageReference + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + describe "messages" do + test "it returns the last message in a chat" do + user = insert(:user) + recipient = insert(:user) + + {:ok, _message_1} = CommonAPI.post_chat_message(user, recipient, "hey") + {:ok, _message_2} = CommonAPI.post_chat_message(recipient, user, "ho") + + {:ok, chat} = Chat.get_or_create(user.id, recipient.ap_id) + + message = ChatMessageReference.last_message_for_chat(chat) + + assert message.object.data["content"] == "ho" + end + end +end diff --git a/test/chat_test.exs b/test/chat_test.exs index dfcb6422e..42e01fe27 100644 --- a/test/chat_test.exs +++ b/test/chat_test.exs @@ -10,22 +10,6 @@ defmodule Pleroma.ChatTest do import Pleroma.Factory - describe "messages" do - test "it returns the last message in a chat" do - user = insert(:user) - recipient = insert(:user) - - {:ok, _message_1} = CommonAPI.post_chat_message(user, recipient, "hey") - {:ok, _message_2} = CommonAPI.post_chat_message(recipient, user, "ho") - - {:ok, chat} = Chat.get_or_create(user.id, recipient.ap_id) - - message = Chat.last_message_for_chat(chat) - - assert message.data["content"] == "ho" - end - end - describe "creation and getting" do test "it only works if the recipient is a valid user (for now)" do user = insert(:user) From 6413e06a861bd383196c79d7754a67d96cd5e2a4 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 13:13:44 +0200 Subject: [PATCH 173/401] Migrations: Add unique index to ChatMessageReferences. --- ...add_unique_index_to_chat_message_references.exs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs diff --git a/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs b/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs new file mode 100644 index 000000000..1101be94f --- /dev/null +++ b/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs @@ -0,0 +1,14 @@ +defmodule Pleroma.Repo.Migrations.BackfillChatMessageReferences do + use Ecto.Migration + + alias Pleroma.Chat + alias Pleroma.ChatMessageReference + alias Pleroma.Object + alias Pleroma.Repo + + import Ecto.Query + + def change do + create(unique_index(:chat_message_references, [:object_id, :chat_id])) + end +end From 73127cff750736c5ebe15606db2f928a8924499a Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 13:17:29 +0200 Subject: [PATCH 174/401] Credo fixes. --- lib/pleroma/web/pleroma_api/controllers/chat_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index c54681054..f22f33de9 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -13,8 +13,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.CommonAPI - alias Pleroma.Web.PleromaAPI.ChatView alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView + alias Pleroma.Web.PleromaAPI.ChatView import Ecto.Query import Pleroma.Web.ActivityPub.ObjectValidator, only: [stringify_keys: 1] From 8edead7c1dc33457dc30b301b544d71482ef0f28 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 13:19:38 +0200 Subject: [PATCH 175/401] Migration: Remove superfluous imports --- ...3105113_add_unique_index_to_chat_message_references.exs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs b/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs index 1101be94f..623ac6c85 100644 --- a/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs +++ b/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs @@ -1,13 +1,6 @@ defmodule Pleroma.Repo.Migrations.BackfillChatMessageReferences do use Ecto.Migration - alias Pleroma.Chat - alias Pleroma.ChatMessageReference - alias Pleroma.Object - alias Pleroma.Repo - - import Ecto.Query - def change do create(unique_index(:chat_message_references, [:object_id, :chat_id])) end From 7f5c5b11a5baeddec36ccc01b4954ac8aa9f8590 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 14:26:50 +0200 Subject: [PATCH 176/401] Chats: Remove `unread` from the db, calculate from unseen messages. --- lib/pleroma/chat.ex | 13 +++---------- lib/pleroma/chat_message_reference.ex | 16 ++++++++++++++++ lib/pleroma/web/activity_pub/side_effects.ex | 9 ++------- .../pleroma_api/controllers/chat_controller.ex | 2 +- lib/pleroma/web/pleroma_api/views/chat_view.ex | 2 +- .../20200603120448_remove_unread_from_chats.exs | 9 +++++++++ test/chat_test.exs | 6 +----- test/web/activity_pub/side_effects_test.exs | 2 -- .../controllers/chat_controller_test.exs | 11 +++++++---- 9 files changed, 40 insertions(+), 30 deletions(-) create mode 100644 priv/repo/migrations/20200603120448_remove_unread_from_chats.exs diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 65938c7a4..5aefddc5e 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -19,14 +19,13 @@ defmodule Pleroma.Chat do schema "chats" do belongs_to(:user, User, type: FlakeId.Ecto.CompatType) field(:recipient, :string) - field(:unread, :integer, default: 0, read_after_writes: true) timestamps() end def creation_cng(struct, params) do struct - |> cast(params, [:user_id, :recipient, :unread]) + |> cast(params, [:user_id, :recipient]) |> validate_change(:recipient, fn :recipient, recipient -> case User.get_cached_by_ap_id(recipient) do @@ -61,16 +60,10 @@ def get_or_create(user_id, recipient) do def bump_or_create(user_id, recipient) do %__MODULE__{} - |> creation_cng(%{user_id: user_id, recipient: recipient, unread: 1}) + |> creation_cng(%{user_id: user_id, recipient: recipient}) |> Repo.insert( - on_conflict: [set: [updated_at: NaiveDateTime.utc_now()], inc: [unread: 1]], + on_conflict: [set: [updated_at: NaiveDateTime.utc_now()]], conflict_target: [:user_id, :recipient] ) end - - def mark_as_read(chat) do - chat - |> change(%{unread: 0}) - |> Repo.update() - end end diff --git a/lib/pleroma/chat_message_reference.ex b/lib/pleroma/chat_message_reference.ex index 6808d1365..ad174b294 100644 --- a/lib/pleroma/chat_message_reference.ex +++ b/lib/pleroma/chat_message_reference.ex @@ -84,4 +84,20 @@ def create(chat, object, seen) do |> changeset(params) |> Repo.insert() end + + def unread_count_for_chat(chat) do + chat + |> for_chat_query() + |> where([cmr], cmr.seen == false) + |> Repo.aggregate(:count) + end + + def set_all_seen_for_chat(chat) do + chat + |> for_chat_query() + |> exclude(:order_by) + |> exclude(:preload) + |> where([cmr], cmr.seen == false) + |> Repo.update_all(set: [seen: true]) + end end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index cda52b00e..884d399d0 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -139,13 +139,8 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do [[actor, recipient], [recipient, actor]] |> Enum.each(fn [user, other_user] -> if user.local do - if user.ap_id == actor.ap_id do - {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) - ChatMessageReference.create(chat, object, true) - else - {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) - ChatMessageReference.create(chat, object, false) - end + {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) + ChatMessageReference.create(chat, object, user.ap_id == actor.ap_id) end end) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index f22f33de9..29922da99 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -90,7 +90,7 @@ def post_chat_message( def mark_as_read(%{assigns: %{user: %{id: user_id}}} = conn, %{id: id}) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), - {:ok, chat} <- Chat.mark_as_read(chat) do + {_n, _} <- ChatMessageReference.set_all_seen_for_chat(chat) do conn |> put_view(ChatView) |> render("show.json", chat: chat) diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index 331c1d282..c903a71fd 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -20,7 +20,7 @@ def render("show.json", %{chat: %Chat{} = chat} = opts) do %{ id: chat.id |> to_string(), account: AccountView.render("show.json", Map.put(opts, :user, recipient)), - unread: chat.unread, + unread: ChatMessageReference.unread_count_for_chat(chat), last_message: last_message && ChatMessageReferenceView.render("show.json", chat_message_reference: last_message), diff --git a/priv/repo/migrations/20200603120448_remove_unread_from_chats.exs b/priv/repo/migrations/20200603120448_remove_unread_from_chats.exs new file mode 100644 index 000000000..6322137d5 --- /dev/null +++ b/priv/repo/migrations/20200603120448_remove_unread_from_chats.exs @@ -0,0 +1,9 @@ +defmodule Pleroma.Repo.Migrations.RemoveUnreadFromChats do + use Ecto.Migration + + def change do + alter table(:chats) do + remove(:unread, :integer, default: 0) + end + end +end diff --git a/test/chat_test.exs b/test/chat_test.exs index 42e01fe27..332f2180a 100644 --- a/test/chat_test.exs +++ b/test/chat_test.exs @@ -6,7 +6,6 @@ defmodule Pleroma.ChatTest do use Pleroma.DataCase, async: true alias Pleroma.Chat - alias Pleroma.Web.CommonAPI import Pleroma.Factory @@ -35,7 +34,6 @@ test "it returns and bumps a chat for a user and recipient if it already exists" {:ok, chat_two} = Chat.bump_or_create(user.id, other_user.ap_id) assert chat.id == chat_two.id - assert chat_two.unread == 2 end test "it returns a chat for a user and recipient if it already exists" do @@ -48,15 +46,13 @@ test "it returns a chat for a user and recipient if it already exists" do assert chat.id == chat_two.id end - test "a returning chat will have an updated `update_at` field and an incremented unread count" do + test "a returning chat will have an updated `update_at` field" do user = insert(:user) other_user = insert(:user) {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) - assert chat.unread == 1 :timer.sleep(1500) {:ok, chat_two} = Chat.bump_or_create(user.id, other_user.ap_id) - assert chat_two.unread == 2 assert chat.id == chat_two.id assert chat.updated_at != chat_two.updated_at diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index ff6b3ac15..f2fa062b4 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -346,7 +346,6 @@ test "it creates a Chat and ChatMessageReferences for the local users and bumps SideEffects.handle(create_activity, local: false, object_data: chat_message_data) chat = Chat.get(author.id, recipient.ap_id) - assert chat.unread == 0 [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() @@ -354,7 +353,6 @@ test "it creates a Chat and ChatMessageReferences for the local users and bumps assert cm_ref.seen == true chat = Chat.get(recipient.id, author.ap_id) - assert chat.unread == 1 [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index bd4024c09..e62b71799 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -19,9 +19,12 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do test "it marks all messages in a chat as read", %{conn: conn, user: user} do other_user = insert(:user) - {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) + {:ok, create} = CommonAPI.post_chat_message(other_user, user, "sup") + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + object = Object.normalize(create, false) + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) - assert chat.unread == 1 + assert cm_ref.seen == false result = conn @@ -30,9 +33,9 @@ test "it marks all messages in a chat as read", %{conn: conn, user: user} do assert result["unread"] == 0 - {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) - assert chat.unread == 0 + assert cm_ref.seen == true end end From 1e9efcf7c3de2aa4d57d4292dfa5843761bff111 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 14:27:54 +0200 Subject: [PATCH 177/401] Migrations: Fix migration module name --- ...200603105113_add_unique_index_to_chat_message_references.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs b/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs index 623ac6c85..fdf85132e 100644 --- a/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs +++ b/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs @@ -1,4 +1,4 @@ -defmodule Pleroma.Repo.Migrations.BackfillChatMessageReferences do +defmodule Pleroma.Repo.Migrations.AddUniqueIndexToChatMessageReferences do use Ecto.Migration def change do From 7b79871e9721dca9b134598c182df890b909047c Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 14:32:19 +0200 Subject: [PATCH 178/401] Migrations: Add chat_id, seen index to ChatMessageReferences This ensures fast count of unseen messages --- ...732_add_seen_index_to_chat_message_references.exs | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 priv/repo/migrations/20200603122732_add_seen_index_to_chat_message_references.exs diff --git a/priv/repo/migrations/20200603122732_add_seen_index_to_chat_message_references.exs b/priv/repo/migrations/20200603122732_add_seen_index_to_chat_message_references.exs new file mode 100644 index 000000000..a5065d612 --- /dev/null +++ b/priv/repo/migrations/20200603122732_add_seen_index_to_chat_message_references.exs @@ -0,0 +1,12 @@ +defmodule Pleroma.Repo.Migrations.AddSeenIndexToChatMessageReferences do + use Ecto.Migration + + def change do + create( + index(:chat_message_references, [:chat_id], + where: "seen = false", + name: "unseen_messages_count_index" + ) + ) + end +end From 903955b189561d3a95d5955feda723999078b894 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 14:40:44 +0200 Subject: [PATCH 179/401] FollowingRelationship: Remove meaningless change --- lib/pleroma/following_relationship.ex | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index 0343a20d4..3a3082e72 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -62,13 +62,10 @@ def update(%User{} = follower, %User{} = following, state) do follow(follower, following, state) following_relationship -> - {:ok, relationship} = - following_relationship - |> cast(%{state: state}, [:state]) - |> validate_required([:state]) - |> Repo.update() - - {:ok, relationship} + following_relationship + |> cast(%{state: state}, [:state]) + |> validate_required([:state]) + |> Repo.update() end end From fb4ae9c720054372c1f0e41e3227fb8ad24e6c2d Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 16:45:04 +0200 Subject: [PATCH 180/401] Streamer, SideEffects: Stream out ChatMessageReferences Saves us a few calles to fetch things from the DB that we already have. --- lib/pleroma/web/activity_pub/side_effects.ex | 8 +++- lib/pleroma/web/streamer/streamer.ex | 21 +++------ lib/pleroma/web/views/streamer_view.ex | 46 +++++++++----------- test/web/activity_pub/side_effects_test.exs | 5 +-- test/web/streamer/streamer_test.exs | 28 +++++++++--- 5 files changed, 58 insertions(+), 50 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 884d399d0..0c5709356 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -140,11 +140,15 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do |> Enum.each(fn [user, other_user] -> if user.local do {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) - ChatMessageReference.create(chat, object, user.ap_id == actor.ap_id) + {:ok, cm_ref} = ChatMessageReference.create(chat, object, user.ap_id == actor.ap_id) + + Streamer.stream( + ["user", "user:pleroma_chat"], + {user, %{cm_ref | chat: chat, object: object}} + ) end end) - Streamer.stream(["user", "user:pleroma_chat"], object) {:ok, object, meta} end end diff --git a/lib/pleroma/web/streamer/streamer.ex b/lib/pleroma/web/streamer/streamer.ex index 2201cbfef..5e37e2cf2 100644 --- a/lib/pleroma/web/streamer/streamer.ex +++ b/lib/pleroma/web/streamer/streamer.ex @@ -6,11 +6,11 @@ defmodule Pleroma.Web.Streamer do require Logger alias Pleroma.Activity + alias Pleroma.ChatMessageReference alias Pleroma.Config alias Pleroma.Conversation.Participation alias Pleroma.Notification alias Pleroma.Object - alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Visibility @@ -201,22 +201,15 @@ defp do_stream(topic, %Notification{} = item) end) end - defp do_stream(topic, %{data: %{"type" => "ChatMessage"}} = object) + defp do_stream(topic, {user, %ChatMessageReference{} = cm_ref}) when topic in ["user", "user:pleroma_chat"] do - recipients = [object.data["actor"] | object.data["to"]] + topic = "#{topic}:#{user.id}" - topics = - %{ap_id: recipients, local: true} - |> Pleroma.User.Query.build() - |> Repo.all() - |> Enum.map(fn %{id: id} = user -> {user, "#{topic}:#{id}"} end) + text = StreamerView.render("chat_update.json", %{chat_message_reference: cm_ref}) - Enum.each(topics, fn {user, topic} -> - Registry.dispatch(@registry, topic, fn list -> - Enum.each(list, fn {pid, _auth} -> - text = StreamerView.render("chat_update.json", object, user, recipients) - send(pid, {:text, text}) - end) + Registry.dispatch(@registry, topic, fn list -> + Enum.each(list, fn {pid, _auth} -> + send(pid, {:text, text}) end) end) end diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index 616e0c4f2..a6efd0109 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -6,36 +6,11 @@ defmodule Pleroma.Web.StreamerView do use Pleroma.Web, :view alias Pleroma.Activity - alias Pleroma.Chat - alias Pleroma.ChatMessageReference alias Pleroma.Conversation.Participation alias Pleroma.Notification alias Pleroma.User alias Pleroma.Web.MastodonAPI.NotificationView - def render("chat_update.json", object, user, recipients) do - chat = Chat.get(user.id, hd(recipients -- [user.ap_id])) - - # Explicitly giving the cmr for the object here, so we don't accidentally - # send a later 'last_message' that was inserted between inserting this and - # streaming it out - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) - - representation = - Pleroma.Web.PleromaAPI.ChatView.render( - "show.json", - %{last_message: cm_ref, chat: chat} - ) - - %{ - event: "pleroma:chat_update", - payload: - representation - |> Jason.encode!() - } - |> Jason.encode!() - end - def render("update.json", %Activity{} = activity, %User{} = user) do %{ event: "update", @@ -76,6 +51,27 @@ def render("update.json", %Activity{} = activity) do |> Jason.encode!() end + def render("chat_update.json", %{chat_message_reference: cm_ref}) do + # Explicitly giving the cmr for the object here, so we don't accidentally + # send a later 'last_message' that was inserted between inserting this and + # streaming it out + Logger.debug("Trying to stream out #{inspect(cm_ref)}") + + representation = + Pleroma.Web.PleromaAPI.ChatView.render( + "show.json", + %{last_message: cm_ref, chat: cm_ref.chat} + ) + + %{ + event: "pleroma:chat_update", + payload: + representation + |> Jason.encode!() + } + |> Jason.encode!() + end + def render("conversation.json", %Participation{} = participation) do %{ event: "conversation", diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index f2fa062b4..92c266d84 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -325,9 +325,8 @@ test "it streams the created ChatMessage" do {:ok, _create_activity, _meta} = SideEffects.handle(create_activity, local: false, object_data: chat_message_data) - object = Object.normalize(create_activity, false) - - assert called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], object)) + assert called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], {author, :_})) + assert called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], {recipient, :_})) end end diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index bcb05a02d..893ae5449 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -7,6 +7,8 @@ defmodule Pleroma.Web.StreamerTest do import Pleroma.Factory + alias Pleroma.Chat + alias Pleroma.ChatMessageReference alias Pleroma.Conversation.Participation alias Pleroma.List alias Pleroma.Object @@ -150,22 +152,36 @@ test "it sends notify to in the 'user:notification' stream", %{user: user, notif test "it sends chat messages to the 'user:pleroma_chat' stream", %{user: user} do other_user = insert(:user) - {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey") + {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey cirno") object = Object.normalize(create_activity, false) + chat = Chat.get(user.id, other_user.ap_id) + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = %{cm_ref | chat: chat, object: object} + Streamer.get_topic_and_add_socket("user:pleroma_chat", user) - Streamer.stream("user:pleroma_chat", object) - text = StreamerView.render("chat_update.json", object, user, [user.ap_id, other_user.ap_id]) + Streamer.stream("user:pleroma_chat", {user, cm_ref}) + + text = StreamerView.render("chat_update.json", %{chat_message_reference: cm_ref}) + + assert text =~ "hey cirno" assert_receive {:text, ^text} end test "it sends chat messages to the 'user' stream", %{user: user} do other_user = insert(:user) - {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey") + {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey cirno") object = Object.normalize(create_activity, false) + chat = Chat.get(user.id, other_user.ap_id) + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = %{cm_ref | chat: chat, object: object} + Streamer.get_topic_and_add_socket("user", user) - Streamer.stream("user", object) - text = StreamerView.render("chat_update.json", object, user, [user.ap_id, other_user.ap_id]) + Streamer.stream("user", {user, cm_ref}) + + text = StreamerView.render("chat_update.json", %{chat_message_reference: cm_ref}) + + assert text =~ "hey cirno" assert_receive {:text, ^text} end From 9d572f2f66d600d77cf74e40547dea0f959fe357 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 21 May 2020 19:43:56 +0400 Subject: [PATCH 181/401] Move report actions to AdminAPI.ReportController --- .../controllers/admin_api_controller.ex | 97 ----- .../controllers/report_controller.ex | 129 ++++++ lib/pleroma/web/router.ex | 10 +- .../controllers/admin_api_controller_test.exs | 341 ---------------- .../controllers/report_controller_test.exs | 368 ++++++++++++++++++ 5 files changed, 502 insertions(+), 443 deletions(-) create mode 100644 lib/pleroma/web/admin_api/controllers/report_controller.ex create mode 100644 test/web/admin_api/controllers/report_controller_test.exs diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index cc93fb509..467d05375 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -7,28 +7,22 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do import Pleroma.Web.ControllerHelper, only: [json_response: 3] - alias Pleroma.Activity alias Pleroma.Config alias Pleroma.ConfigDB alias Pleroma.MFA alias Pleroma.ModerationLog alias Pleroma.Plugs.OAuthScopesPlug - alias Pleroma.ReportNote alias Pleroma.Stats alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Relay - alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI.AccountView 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.Router @@ -71,18 +65,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do when action in [:user_follow, :user_unfollow, :relay_follow, :relay_unfollow] ) - plug( - OAuthScopesPlug, - %{scopes: ["read:reports"], admin: true} - when action in [:list_reports, :report_show] - ) - - plug( - OAuthScopesPlug, - %{scopes: ["write:reports"], admin: true} - when action in [:reports_update, :report_notes_create, :report_notes_delete] - ) - plug( OAuthScopesPlug, %{scopes: ["read:statuses"], admin: true} @@ -645,85 +627,6 @@ def update_user_credentials( end end - def list_reports(conn, params) do - {page, page_size} = page_params(params) - - reports = Utils.get_reports(params, page, page_size) - - conn - |> put_view(ReportView) - |> render("index.json", %{reports: reports}) - end - - def report_show(conn, %{"id" => id}) do - with %Activity{} = report <- Activity.get_by_id(id) do - conn - |> put_view(ReportView) - |> render("show.json", Report.extract_report_info(report)) - else - _ -> {:error, :not_found} - end - end - - def reports_update(%{assigns: %{user: admin}} = conn, %{"reports" => reports}) do - result = - reports - |> Enum.map(fn report -> - with {:ok, activity} <- CommonAPI.update_report_state(report["id"], report["state"]) do - ModerationLog.insert_log(%{ - action: "report_update", - actor: admin, - subject: activity - }) - - activity - else - {:error, message} -> %{id: report["id"], error: message} - end - end) - - case Enum.any?(result, &Map.has_key?(&1, :error)) do - true -> json_response(conn, :bad_request, result) - false -> json_response(conn, :no_content, "") - end - end - - def report_notes_create(%{assigns: %{user: user}} = conn, %{ - "id" => report_id, - "content" => content - }) do - with {:ok, _} <- ReportNote.create(user.id, report_id, content) do - ModerationLog.insert_log(%{ - action: "report_note", - actor: user, - subject: Activity.get_by_id(report_id), - text: content - }) - - json_response(conn, :no_content, "") - else - _ -> json_response(conn, :bad_request, "") - end - end - - def report_notes_delete(%{assigns: %{user: user}} = conn, %{ - "id" => note_id, - "report_id" => report_id - }) do - with {:ok, note} <- ReportNote.destroy(note_id) do - ModerationLog.insert_log(%{ - action: "report_note_delete", - actor: user, - subject: Activity.get_by_id(report_id), - text: note.content - }) - - json_response(conn, :no_content, "") - else - _ -> json_response(conn, :bad_request, "") - end - end - def list_log(conn, params) do {page, page_size} = page_params(params) diff --git a/lib/pleroma/web/admin_api/controllers/report_controller.ex b/lib/pleroma/web/admin_api/controllers/report_controller.ex new file mode 100644 index 000000000..23f0174d4 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/report_controller.ex @@ -0,0 +1,129 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.ReportController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [json_response: 3] + + alias Pleroma.Activity + alias Pleroma.ModerationLog + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.ReportNote + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.AdminAPI + alias Pleroma.Web.AdminAPI.Report + alias Pleroma.Web.CommonAPI + + require Logger + + @users_page_size 50 + + plug(OAuthScopesPlug, %{scopes: ["read:reports"], admin: true} when action in [:index, :show]) + + plug( + OAuthScopesPlug, + %{scopes: ["write:reports"], admin: true} + when action in [:update, :notes_create, :notes_delete] + ) + + action_fallback(AdminAPI.FallbackController) + + def index(conn, params) do + {page, page_size} = page_params(params) + + reports = Utils.get_reports(params, page, page_size) + + render(conn, "index.json", reports: reports) + end + + def show(conn, %{"id" => id}) do + with %Activity{} = report <- Activity.get_by_id(id) do + render(conn, "show.json", Report.extract_report_info(report)) + else + _ -> {:error, :not_found} + end + end + + def update(%{assigns: %{user: admin}} = conn, %{"reports" => reports}) do + result = + reports + |> Enum.map(fn report -> + with {:ok, activity} <- CommonAPI.update_report_state(report["id"], report["state"]) do + ModerationLog.insert_log(%{ + action: "report_update", + actor: admin, + subject: activity + }) + + activity + else + {:error, message} -> %{id: report["id"], error: message} + end + end) + + case Enum.any?(result, &Map.has_key?(&1, :error)) do + true -> json_response(conn, :bad_request, result) + false -> json_response(conn, :no_content, "") + end + end + + def notes_create(%{assigns: %{user: user}} = conn, %{ + "id" => report_id, + "content" => content + }) do + with {:ok, _} <- ReportNote.create(user.id, report_id, content) do + ModerationLog.insert_log(%{ + action: "report_note", + actor: user, + subject: Activity.get_by_id(report_id), + text: content + }) + + json_response(conn, :no_content, "") + else + _ -> json_response(conn, :bad_request, "") + end + end + + def notes_delete(%{assigns: %{user: user}} = conn, %{ + "id" => note_id, + "report_id" => report_id + }) do + with {:ok, note} <- ReportNote.destroy(note_id) do + ModerationLog.insert_log(%{ + action: "report_note_delete", + actor: user, + subject: Activity.get_by_id(report_id), + text: note.content + }) + + json_response(conn, :no_content, "") + else + _ -> json_response(conn, :bad_request, "") + end + end + + defp page_params(params) do + {get_page(params["page"]), get_page_size(params["page_size"])} + end + + defp get_page(page_string) when is_nil(page_string), do: 1 + + defp get_page(page_string) do + case Integer.parse(page_string) do + {page, _} -> page + :error -> 1 + end + end + + defp get_page_size(page_size_string) when is_nil(page_size_string), do: @users_page_size + + defp get_page_size(page_size_string) do + case Integer.parse(page_size_string) do + {page_size, _} -> page_size + :error -> @users_page_size + end + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 369c11138..80ea28364 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -183,11 +183,11 @@ defmodule Pleroma.Web.Router do patch("/users/confirm_email", AdminAPIController, :confirm_email) patch("/users/resend_confirmation_email", AdminAPIController, :resend_confirmation_email) - get("/reports", AdminAPIController, :list_reports) - get("/reports/:id", AdminAPIController, :report_show) - patch("/reports", AdminAPIController, :reports_update) - post("/reports/:id/notes", AdminAPIController, :report_notes_create) - delete("/reports/:report_id/notes/:id", AdminAPIController, :report_notes_delete) + get("/reports", ReportController, :index) + get("/reports/:id", ReportController, :show) + patch("/reports", ReportController, :update) + post("/reports/:id/notes", ReportController, :notes_create) + delete("/reports/:report_id/notes/:id", ReportController, :notes_delete) get("/statuses/:id", StatusController, :show) put("/statuses/:id", StatusController, :update) diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index d72851c9e..a1bff5688 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -17,7 +17,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do alias Pleroma.MFA alias Pleroma.ModerationLog alias Pleroma.Repo - alias Pleroma.ReportNote alias Pleroma.Tests.ObanHelpers alias Pleroma.User alias Pleroma.Web @@ -1198,286 +1197,6 @@ test "returns 404 if user not found", %{conn: conn} do end end - describe "GET /api/pleroma/admin/reports/:id" do - test "returns report by its id", %{conn: conn} do - [reporter, target_user] = insert_pair(:user) - activity = insert(:note_activity, user: target_user) - - {:ok, %{id: report_id}} = - CommonAPI.report(reporter, %{ - account_id: target_user.id, - comment: "I feel offended", - status_ids: [activity.id] - }) - - response = - conn - |> get("/api/pleroma/admin/reports/#{report_id}") - |> json_response(:ok) - - assert response["id"] == report_id - end - - test "returns 404 when report id is invalid", %{conn: conn} do - conn = get(conn, "/api/pleroma/admin/reports/test") - - assert json_response(conn, :not_found) == %{"error" => "Not found"} - end - end - - describe "PATCH /api/pleroma/admin/reports" do - setup do - [reporter, target_user] = insert_pair(:user) - activity = insert(:note_activity, user: target_user) - - {:ok, %{id: report_id}} = - CommonAPI.report(reporter, %{ - account_id: target_user.id, - comment: "I feel offended", - status_ids: [activity.id] - }) - - {:ok, %{id: second_report_id}} = - CommonAPI.report(reporter, %{ - account_id: target_user.id, - comment: "I feel very offended", - status_ids: [activity.id] - }) - - %{ - id: report_id, - second_report_id: second_report_id - } - end - - test "requires admin:write:reports scope", %{conn: conn, id: id, admin: admin} do - read_token = insert(:oauth_token, user: admin, scopes: ["admin:read"]) - write_token = insert(:oauth_token, user: admin, scopes: ["admin:write:reports"]) - - response = - conn - |> assign(:token, read_token) - |> patch("/api/pleroma/admin/reports", %{ - "reports" => [%{"state" => "resolved", "id" => id}] - }) - |> json_response(403) - - assert response == %{ - "error" => "Insufficient permissions: admin:write:reports." - } - - conn - |> assign(:token, write_token) - |> patch("/api/pleroma/admin/reports", %{ - "reports" => [%{"state" => "resolved", "id" => id}] - }) - |> json_response(:no_content) - end - - test "mark report as resolved", %{conn: conn, id: id, admin: admin} do - conn - |> patch("/api/pleroma/admin/reports", %{ - "reports" => [ - %{"state" => "resolved", "id" => id} - ] - }) - |> json_response(:no_content) - - activity = Activity.get_by_id(id) - assert activity.data["state"] == "resolved" - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} updated report ##{id} with 'resolved' state" - end - - test "closes report", %{conn: conn, id: id, admin: admin} do - conn - |> patch("/api/pleroma/admin/reports", %{ - "reports" => [ - %{"state" => "closed", "id" => id} - ] - }) - |> json_response(:no_content) - - activity = Activity.get_by_id(id) - assert activity.data["state"] == "closed" - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} updated report ##{id} with 'closed' state" - end - - test "returns 400 when state is unknown", %{conn: conn, id: id} do - conn = - conn - |> patch("/api/pleroma/admin/reports", %{ - "reports" => [ - %{"state" => "test", "id" => id} - ] - }) - - assert hd(json_response(conn, :bad_request))["error"] == "Unsupported state" - end - - test "returns 404 when report is not exist", %{conn: conn} do - conn = - conn - |> patch("/api/pleroma/admin/reports", %{ - "reports" => [ - %{"state" => "closed", "id" => "test"} - ] - }) - - assert hd(json_response(conn, :bad_request))["error"] == "not_found" - end - - test "updates state of multiple reports", %{ - conn: conn, - id: id, - admin: admin, - second_report_id: second_report_id - } do - conn - |> patch("/api/pleroma/admin/reports", %{ - "reports" => [ - %{"state" => "resolved", "id" => id}, - %{"state" => "closed", "id" => second_report_id} - ] - }) - |> json_response(:no_content) - - activity = Activity.get_by_id(id) - second_activity = Activity.get_by_id(second_report_id) - assert activity.data["state"] == "resolved" - assert second_activity.data["state"] == "closed" - - [first_log_entry, second_log_entry] = Repo.all(ModerationLog) - - assert ModerationLog.get_log_entry_message(first_log_entry) == - "@#{admin.nickname} updated report ##{id} with 'resolved' state" - - assert ModerationLog.get_log_entry_message(second_log_entry) == - "@#{admin.nickname} updated report ##{second_report_id} with 'closed' state" - end - end - - describe "GET /api/pleroma/admin/reports" do - test "returns empty response when no reports created", %{conn: conn} do - response = - conn - |> get("/api/pleroma/admin/reports") - |> json_response(:ok) - - assert Enum.empty?(response["reports"]) - assert response["total"] == 0 - end - - test "returns reports", %{conn: conn} do - [reporter, target_user] = insert_pair(:user) - activity = insert(:note_activity, user: target_user) - - {:ok, %{id: report_id}} = - CommonAPI.report(reporter, %{ - account_id: target_user.id, - comment: "I feel offended", - status_ids: [activity.id] - }) - - response = - conn - |> get("/api/pleroma/admin/reports") - |> json_response(:ok) - - [report] = response["reports"] - - assert length(response["reports"]) == 1 - assert report["id"] == report_id - - assert response["total"] == 1 - end - - test "returns reports with specified state", %{conn: conn} do - [reporter, target_user] = insert_pair(:user) - activity = insert(:note_activity, user: target_user) - - {:ok, %{id: first_report_id}} = - CommonAPI.report(reporter, %{ - account_id: target_user.id, - comment: "I feel offended", - status_ids: [activity.id] - }) - - {:ok, %{id: second_report_id}} = - CommonAPI.report(reporter, %{ - account_id: target_user.id, - comment: "I don't like this user" - }) - - CommonAPI.update_report_state(second_report_id, "closed") - - response = - conn - |> get("/api/pleroma/admin/reports", %{ - "state" => "open" - }) - |> json_response(:ok) - - [open_report] = response["reports"] - - assert length(response["reports"]) == 1 - assert open_report["id"] == first_report_id - - assert response["total"] == 1 - - response = - conn - |> get("/api/pleroma/admin/reports", %{ - "state" => "closed" - }) - |> json_response(:ok) - - [closed_report] = response["reports"] - - assert length(response["reports"]) == 1 - assert closed_report["id"] == second_report_id - - assert response["total"] == 1 - - response = - conn - |> get("/api/pleroma/admin/reports", %{ - "state" => "resolved" - }) - |> json_response(:ok) - - assert Enum.empty?(response["reports"]) - assert response["total"] == 0 - end - - test "returns 403 when requested by a non-admin" do - user = insert(:user) - token = insert(:oauth_token, user: user) - - conn = - build_conn() - |> assign(:user, user) - |> assign(:token, token) - |> get("/api/pleroma/admin/reports") - - assert json_response(conn, :forbidden) == - %{"error" => "User is not an admin or OAuth admin scope is not granted."} - end - - test "returns 403 when requested by anonymous" do - conn = get(build_conn(), "/api/pleroma/admin/reports") - - assert json_response(conn, :forbidden) == %{"error" => "Invalid credentials."} - end - end - describe "GET /api/pleroma/admin/config" do setup do: clear_config(:configurable_from_database, true) @@ -3195,66 +2914,6 @@ test "it resend emails for two users", %{conn: conn, admin: admin} do end end - describe "POST /reports/:id/notes" do - setup %{conn: conn, admin: admin} do - [reporter, target_user] = insert_pair(:user) - activity = insert(:note_activity, user: target_user) - - {:ok, %{id: report_id}} = - CommonAPI.report(reporter, %{ - account_id: target_user.id, - comment: "I feel offended", - status_ids: [activity.id] - }) - - post(conn, "/api/pleroma/admin/reports/#{report_id}/notes", %{ - content: "this is disgusting!" - }) - - post(conn, "/api/pleroma/admin/reports/#{report_id}/notes", %{ - content: "this is disgusting2!" - }) - - %{ - admin_id: admin.id, - report_id: report_id - } - end - - test "it creates report note", %{admin_id: admin_id, report_id: report_id} do - [note, _] = Repo.all(ReportNote) - - assert %{ - activity_id: ^report_id, - content: "this is disgusting!", - user_id: ^admin_id - } = note - end - - test "it returns reports with notes", %{conn: conn, admin: admin} do - conn = get(conn, "/api/pleroma/admin/reports") - - response = json_response(conn, 200) - notes = hd(response["reports"])["notes"] - [note, _] = notes - - assert note["user"]["nickname"] == admin.nickname - assert note["content"] == "this is disgusting!" - assert note["created_at"] - assert response["total"] == 1 - end - - test "it deletes the note", %{conn: conn, report_id: report_id} do - assert ReportNote |> Repo.all() |> length() == 2 - - [note, _] = Repo.all(ReportNote) - - delete(conn, "/api/pleroma/admin/reports/#{report_id}/notes/#{note.id}") - - assert ReportNote |> Repo.all() |> length() == 1 - end - end - describe "GET /api/pleroma/admin/config/descriptions" do test "structure", %{conn: conn} do admin = insert(:user, is_admin: true) diff --git a/test/web/admin_api/controllers/report_controller_test.exs b/test/web/admin_api/controllers/report_controller_test.exs new file mode 100644 index 000000000..0eddb369c --- /dev/null +++ b/test/web/admin_api/controllers/report_controller_test.exs @@ -0,0 +1,368 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.ReportControllerTest do + use Pleroma.Web.ConnCase + + import Pleroma.Factory + + alias Pleroma.Activity + alias Pleroma.Config + alias Pleroma.ModerationLog + alias Pleroma.Repo + alias Pleroma.ReportNote + alias Pleroma.Web.CommonAPI + + setup do + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + + {:ok, %{admin: admin, token: token, conn: conn}} + end + + describe "GET /api/pleroma/admin/reports/:id" do + test "returns report by its id", %{conn: conn} do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, %{id: report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] + }) + + response = + conn + |> get("/api/pleroma/admin/reports/#{report_id}") + |> json_response(:ok) + + assert response["id"] == report_id + end + + test "returns 404 when report id is invalid", %{conn: conn} do + conn = get(conn, "/api/pleroma/admin/reports/test") + + assert json_response(conn, :not_found) == %{"error" => "Not found"} + end + end + + describe "PATCH /api/pleroma/admin/reports" do + setup do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, %{id: report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] + }) + + {:ok, %{id: second_report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I feel very offended", + status_ids: [activity.id] + }) + + %{ + id: report_id, + second_report_id: second_report_id + } + end + + test "requires admin:write:reports scope", %{conn: conn, id: id, admin: admin} do + read_token = insert(:oauth_token, user: admin, scopes: ["admin:read"]) + write_token = insert(:oauth_token, user: admin, scopes: ["admin:write:reports"]) + + response = + conn + |> assign(:token, read_token) + |> patch("/api/pleroma/admin/reports", %{ + "reports" => [%{"state" => "resolved", "id" => id}] + }) + |> json_response(403) + + assert response == %{ + "error" => "Insufficient permissions: admin:write:reports." + } + + conn + |> assign(:token, write_token) + |> patch("/api/pleroma/admin/reports", %{ + "reports" => [%{"state" => "resolved", "id" => id}] + }) + |> json_response(:no_content) + end + + test "mark report as resolved", %{conn: conn, id: id, admin: admin} do + conn + |> patch("/api/pleroma/admin/reports", %{ + "reports" => [ + %{"state" => "resolved", "id" => id} + ] + }) + |> json_response(:no_content) + + activity = Activity.get_by_id(id) + assert activity.data["state"] == "resolved" + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} updated report ##{id} with 'resolved' state" + end + + test "closes report", %{conn: conn, id: id, admin: admin} do + conn + |> patch("/api/pleroma/admin/reports", %{ + "reports" => [ + %{"state" => "closed", "id" => id} + ] + }) + |> json_response(:no_content) + + activity = Activity.get_by_id(id) + assert activity.data["state"] == "closed" + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} updated report ##{id} with 'closed' state" + end + + test "returns 400 when state is unknown", %{conn: conn, id: id} do + conn = + conn + |> patch("/api/pleroma/admin/reports", %{ + "reports" => [ + %{"state" => "test", "id" => id} + ] + }) + + assert hd(json_response(conn, :bad_request))["error"] == "Unsupported state" + end + + test "returns 404 when report is not exist", %{conn: conn} do + conn = + conn + |> patch("/api/pleroma/admin/reports", %{ + "reports" => [ + %{"state" => "closed", "id" => "test"} + ] + }) + + assert hd(json_response(conn, :bad_request))["error"] == "not_found" + end + + test "updates state of multiple reports", %{ + conn: conn, + id: id, + admin: admin, + second_report_id: second_report_id + } do + conn + |> patch("/api/pleroma/admin/reports", %{ + "reports" => [ + %{"state" => "resolved", "id" => id}, + %{"state" => "closed", "id" => second_report_id} + ] + }) + |> json_response(:no_content) + + activity = Activity.get_by_id(id) + second_activity = Activity.get_by_id(second_report_id) + assert activity.data["state"] == "resolved" + assert second_activity.data["state"] == "closed" + + [first_log_entry, second_log_entry] = Repo.all(ModerationLog) + + assert ModerationLog.get_log_entry_message(first_log_entry) == + "@#{admin.nickname} updated report ##{id} with 'resolved' state" + + assert ModerationLog.get_log_entry_message(second_log_entry) == + "@#{admin.nickname} updated report ##{second_report_id} with 'closed' state" + end + end + + describe "GET /api/pleroma/admin/reports" do + test "returns empty response when no reports created", %{conn: conn} do + response = + conn + |> get("/api/pleroma/admin/reports") + |> json_response(:ok) + + assert Enum.empty?(response["reports"]) + assert response["total"] == 0 + end + + test "returns reports", %{conn: conn} do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, %{id: report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] + }) + + response = + conn + |> get("/api/pleroma/admin/reports") + |> json_response(:ok) + + [report] = response["reports"] + + assert length(response["reports"]) == 1 + assert report["id"] == report_id + + assert response["total"] == 1 + end + + test "returns reports with specified state", %{conn: conn} do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, %{id: first_report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] + }) + + {:ok, %{id: second_report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I don't like this user" + }) + + CommonAPI.update_report_state(second_report_id, "closed") + + response = + conn + |> get("/api/pleroma/admin/reports", %{ + "state" => "open" + }) + |> json_response(:ok) + + [open_report] = response["reports"] + + assert length(response["reports"]) == 1 + assert open_report["id"] == first_report_id + + assert response["total"] == 1 + + response = + conn + |> get("/api/pleroma/admin/reports", %{ + "state" => "closed" + }) + |> json_response(:ok) + + [closed_report] = response["reports"] + + assert length(response["reports"]) == 1 + assert closed_report["id"] == second_report_id + + assert response["total"] == 1 + + response = + conn + |> get("/api/pleroma/admin/reports", %{ + "state" => "resolved" + }) + |> json_response(:ok) + + assert Enum.empty?(response["reports"]) + assert response["total"] == 0 + end + + test "returns 403 when requested by a non-admin" do + user = insert(:user) + token = insert(:oauth_token, user: user) + + conn = + build_conn() + |> assign(:user, user) + |> assign(:token, token) + |> get("/api/pleroma/admin/reports") + + assert json_response(conn, :forbidden) == + %{"error" => "User is not an admin or OAuth admin scope is not granted."} + end + + test "returns 403 when requested by anonymous" do + conn = get(build_conn(), "/api/pleroma/admin/reports") + + assert json_response(conn, :forbidden) == %{"error" => "Invalid credentials."} + end + end + + describe "POST /api/pleroma/admin/reports/:id/notes" do + setup %{conn: conn, admin: admin} do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, %{id: report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] + }) + + post(conn, "/api/pleroma/admin/reports/#{report_id}/notes", %{ + content: "this is disgusting!" + }) + + post(conn, "/api/pleroma/admin/reports/#{report_id}/notes", %{ + content: "this is disgusting2!" + }) + + %{ + admin_id: admin.id, + report_id: report_id + } + end + + test "it creates report note", %{admin_id: admin_id, report_id: report_id} do + [note, _] = Repo.all(ReportNote) + + assert %{ + activity_id: ^report_id, + content: "this is disgusting!", + user_id: ^admin_id + } = note + end + + test "it returns reports with notes", %{conn: conn, admin: admin} do + conn = get(conn, "/api/pleroma/admin/reports") + + response = json_response(conn, 200) + notes = hd(response["reports"])["notes"] + [note, _] = notes + + assert note["user"]["nickname"] == admin.nickname + assert note["content"] == "this is disgusting!" + assert note["created_at"] + assert response["total"] == 1 + end + + test "it deletes the note", %{conn: conn, report_id: report_id} do + assert ReportNote |> Repo.all() |> length() == 2 + + [note, _] = Repo.all(ReportNote) + + delete(conn, "/api/pleroma/admin/reports/#{report_id}/notes/#{note.id}") + + assert ReportNote |> Repo.all() |> length() == 1 + end + end +end From c16315d055d07206dddb228583956d5b718ecdd4 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 3 Jun 2020 19:10:11 +0400 Subject: [PATCH 182/401] Add OpenAPI spec for AdminAPI.ReportController --- docs/API/admin_api.md | 4 +- lib/pleroma/web/activity_pub/utils.ex | 1 + .../controllers/report_controller.ex | 74 ++---- .../operations/admin/report_operation.ex | 237 ++++++++++++++++++ .../operations/admin/status_operation.ex | 2 +- .../controllers/report_controller_test.exs | 80 +++--- 6 files changed, 310 insertions(+), 88 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/admin/report_operation.ex diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index 639c3224d..92816baf9 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -547,7 +547,7 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret ```json { - "totalReports" : 1, + "total" : 1, "reports": [ { "account": { @@ -768,7 +768,7 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret - 400 Bad Request `"Invalid parameters"` when `status` is missing - On success: `204`, empty response -## `POST /api/pleroma/admin/reports/:report_id/notes/:id` +## `DELETE /api/pleroma/admin/reports/:report_id/notes/:id` ### Delete report note diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index f2375bcc4..a76a699ee 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -740,6 +740,7 @@ defp build_flag_object(_), do: [] def get_reports(params, page, page_size) do params = params + |> Map.new(fn {key, value} -> {to_string(key), value} end) |> Map.put("type", "Flag") |> Map.put("skip_preload", true) |> Map.put("preload_report_notes", true) diff --git a/lib/pleroma/web/admin_api/controllers/report_controller.ex b/lib/pleroma/web/admin_api/controllers/report_controller.ex index 23f0174d4..4c011e174 100644 --- a/lib/pleroma/web/admin_api/controllers/report_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/report_controller.ex @@ -18,8 +18,7 @@ defmodule Pleroma.Web.AdminAPI.ReportController do require Logger - @users_page_size 50 - + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(OAuthScopesPlug, %{scopes: ["read:reports"], admin: true} when action in [:index, :show]) plug( @@ -30,15 +29,15 @@ defmodule Pleroma.Web.AdminAPI.ReportController do action_fallback(AdminAPI.FallbackController) - def index(conn, params) do - {page, page_size} = page_params(params) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.ReportOperation - reports = Utils.get_reports(params, page, page_size) + def index(conn, params) do + reports = Utils.get_reports(params, params.page, params.page_size) render(conn, "index.json", reports: reports) end - def show(conn, %{"id" => id}) do + def show(conn, %{id: id}) do with %Activity{} = report <- Activity.get_by_id(id) do render(conn, "show.json", Report.extract_report_info(report)) else @@ -46,32 +45,33 @@ def show(conn, %{"id" => id}) do end end - def update(%{assigns: %{user: admin}} = conn, %{"reports" => reports}) do + def update(%{assigns: %{user: admin}, body_params: %{reports: reports}} = conn, _) do result = - reports - |> Enum.map(fn report -> - with {:ok, activity} <- CommonAPI.update_report_state(report["id"], report["state"]) do - ModerationLog.insert_log(%{ - action: "report_update", - actor: admin, - subject: activity - }) + Enum.map(reports, fn report -> + case CommonAPI.update_report_state(report.id, report.state) do + {:ok, activity} -> + ModerationLog.insert_log(%{ + action: "report_update", + actor: admin, + subject: activity + }) - activity - else - {:error, message} -> %{id: report["id"], error: message} + activity + + {:error, message} -> + %{id: report.id, error: message} end end) - case Enum.any?(result, &Map.has_key?(&1, :error)) do - true -> json_response(conn, :bad_request, result) - false -> json_response(conn, :no_content, "") + if Enum.any?(result, &Map.has_key?(&1, :error)) do + json_response(conn, :bad_request, result) + else + json_response(conn, :no_content, "") end end - def notes_create(%{assigns: %{user: user}} = conn, %{ - "id" => report_id, - "content" => content + def notes_create(%{assigns: %{user: user}, body_params: %{content: content}} = conn, %{ + id: report_id }) do with {:ok, _} <- ReportNote.create(user.id, report_id, content) do ModerationLog.insert_log(%{ @@ -88,8 +88,8 @@ def notes_create(%{assigns: %{user: user}} = conn, %{ end def notes_delete(%{assigns: %{user: user}} = conn, %{ - "id" => note_id, - "report_id" => report_id + id: note_id, + report_id: report_id }) do with {:ok, note} <- ReportNote.destroy(note_id) do ModerationLog.insert_log(%{ @@ -104,26 +104,4 @@ def notes_delete(%{assigns: %{user: user}} = conn, %{ _ -> json_response(conn, :bad_request, "") end end - - defp page_params(params) do - {get_page(params["page"]), get_page_size(params["page_size"])} - end - - defp get_page(page_string) when is_nil(page_string), do: 1 - - defp get_page(page_string) do - case Integer.parse(page_string) do - {page, _} -> page - :error -> 1 - end - end - - defp get_page_size(page_size_string) when is_nil(page_size_string), do: @users_page_size - - defp get_page_size(page_size_string) do - case Integer.parse(page_size_string) do - {page_size, _} -> page_size - :error -> @users_page_size - end - end end diff --git a/lib/pleroma/web/api_spec/operations/admin/report_operation.ex b/lib/pleroma/web/api_spec/operations/admin/report_operation.ex new file mode 100644 index 000000000..15e78bfaf --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/report_operation.ex @@ -0,0 +1,237 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.ReportOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Status + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Admin", "Reports"], + summary: "Get a list of reports", + operationId: "AdminAPI.ReportController.index", + security: [%{"oAuth" => ["read:reports"]}], + parameters: [ + Operation.parameter( + :state, + :query, + report_state(), + "Filter by report state" + ), + Operation.parameter( + :limit, + :query, + %Schema{type: :integer}, + "The number of records to retrieve" + ), + Operation.parameter( + :page, + :query, + %Schema{type: :integer, default: 1}, + "Page number" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 50}, + "Number number of log entries per page" + ) + ], + responses: %{ + 200 => + Operation.response("Response", "application/json", %Schema{ + type: :object, + properties: %{ + total: %Schema{type: :integer}, + reports: %Schema{ + type: :array, + items: report() + } + } + }), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Admin", "Reports"], + summary: "Get an individual report", + operationId: "AdminAPI.ReportController.show", + parameters: [id_param()], + security: [%{"oAuth" => ["read:reports"]}], + responses: %{ + 200 => Operation.response("Report", "application/json", report()), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Admin", "Reports"], + summary: "Change the state of one or multiple reports", + operationId: "AdminAPI.ReportController.update", + security: [%{"oAuth" => ["write:reports"]}], + requestBody: request_body("Parameters", update_request(), required: true), + responses: %{ + 204 => no_content_response(), + 400 => Operation.response("Bad Request", "application/json", update_400_response()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def notes_create_operation do + %Operation{ + tags: ["Admin", "Reports"], + summary: "Create report note", + operationId: "AdminAPI.ReportController.notes_create", + parameters: [id_param()], + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + content: %Schema{type: :string, description: "The message"} + } + }), + security: [%{"oAuth" => ["write:reports"]}], + responses: %{ + 204 => no_content_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def notes_delete_operation do + %Operation{ + tags: ["Admin", "Reports"], + summary: "Delete report note", + operationId: "AdminAPI.ReportController.notes_delete", + parameters: [ + Operation.parameter(:report_id, :path, :string, "Report ID"), + Operation.parameter(:id, :path, :string, "Note ID") + ], + security: [%{"oAuth" => ["write:reports"]}], + responses: %{ + 204 => no_content_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + defp report_state do + %Schema{type: :string, enum: ["open", "closed", "resolved"]} + end + + defp id_param do + Operation.parameter(:id, :path, FlakeID, "Report ID", + example: "9umDrYheeY451cQnEe", + required: true + ) + end + + defp report do + %Schema{ + type: :object, + properties: %{ + id: FlakeID, + state: report_state(), + account: account_admin(), + actor: account_admin(), + content: %Schema{type: :string}, + created_at: %Schema{type: :string, format: :"date-time"}, + statuses: %Schema{type: :array, items: Status}, + notes: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :integer}, + user_id: FlakeID, + content: %Schema{type: :string}, + inserted_at: %Schema{type: :string, format: :"date-time"} + } + } + } + } + } + end + + defp account_admin do + %Schema{ + title: "Account", + description: "Account view for admins", + type: :object, + properties: + Map.merge(Account.schema().properties, %{ + nickname: %Schema{type: :string}, + deactivated: %Schema{type: :boolean}, + local: %Schema{type: :boolean}, + roles: %Schema{ + type: :object, + properties: %{ + admin: %Schema{type: :boolean}, + moderator: %Schema{type: :boolean} + } + }, + confirmation_pending: %Schema{type: :boolean} + }) + } + end + + defp update_request do + %Schema{ + type: :object, + required: [:reports], + properties: %{ + reports: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{allOf: [FlakeID], description: "Required, report ID"}, + state: %Schema{ + type: :string, + description: + "Required, the new state. Valid values are `open`, `closed` and `resolved`" + } + } + }, + example: %{ + "reports" => [ + %{"id" => "123", "state" => "closed"}, + %{"id" => "1337", "state" => "resolved"} + ] + } + } + } + } + end + + defp update_400_response do + %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{allOf: [FlakeID], description: "Report ID"}, + error: %Schema{type: :string, description: "Error message"} + } + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/status_operation.ex b/lib/pleroma/web/api_spec/operations/admin/status_operation.ex index 2947e6b34..745399b4b 100644 --- a/lib/pleroma/web/api_spec/operations/admin/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/status_operation.ex @@ -123,7 +123,7 @@ defp status do } end - defp admin_account do + def admin_account do %Schema{ type: :object, properties: %{ diff --git a/test/web/admin_api/controllers/report_controller_test.exs b/test/web/admin_api/controllers/report_controller_test.exs index 0eddb369c..940bce340 100644 --- a/test/web/admin_api/controllers/report_controller_test.exs +++ b/test/web/admin_api/controllers/report_controller_test.exs @@ -41,7 +41,7 @@ test "returns report by its id", %{conn: conn} do response = conn |> get("/api/pleroma/admin/reports/#{report_id}") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert response["id"] == report_id end @@ -49,7 +49,7 @@ test "returns report by its id", %{conn: conn} do test "returns 404 when report id is invalid", %{conn: conn} do conn = get(conn, "/api/pleroma/admin/reports/test") - assert json_response(conn, :not_found) == %{"error" => "Not found"} + assert json_response_and_validate_schema(conn, :not_found) == %{"error" => "Not found"} end end @@ -85,10 +85,11 @@ test "requires admin:write:reports scope", %{conn: conn, id: id, admin: admin} d response = conn |> assign(:token, read_token) + |> put_req_header("content-type", "application/json") |> patch("/api/pleroma/admin/reports", %{ "reports" => [%{"state" => "resolved", "id" => id}] }) - |> json_response(403) + |> json_response_and_validate_schema(403) assert response == %{ "error" => "Insufficient permissions: admin:write:reports." @@ -96,20 +97,22 @@ test "requires admin:write:reports scope", %{conn: conn, id: id, admin: admin} d conn |> assign(:token, write_token) + |> put_req_header("content-type", "application/json") |> patch("/api/pleroma/admin/reports", %{ "reports" => [%{"state" => "resolved", "id" => id}] }) - |> json_response(:no_content) + |> json_response_and_validate_schema(:no_content) end test "mark report as resolved", %{conn: conn, id: id, admin: admin} do conn + |> put_req_header("content-type", "application/json") |> patch("/api/pleroma/admin/reports", %{ "reports" => [ %{"state" => "resolved", "id" => id} ] }) - |> json_response(:no_content) + |> json_response_and_validate_schema(:no_content) activity = Activity.get_by_id(id) assert activity.data["state"] == "resolved" @@ -122,12 +125,13 @@ test "mark report as resolved", %{conn: conn, id: id, admin: admin} do test "closes report", %{conn: conn, id: id, admin: admin} do conn + |> put_req_header("content-type", "application/json") |> patch("/api/pleroma/admin/reports", %{ "reports" => [ %{"state" => "closed", "id" => id} ] }) - |> json_response(:no_content) + |> json_response_and_validate_schema(:no_content) activity = Activity.get_by_id(id) assert activity.data["state"] == "closed" @@ -141,25 +145,28 @@ test "closes report", %{conn: conn, id: id, admin: admin} do test "returns 400 when state is unknown", %{conn: conn, id: id} do conn = conn + |> put_req_header("content-type", "application/json") |> patch("/api/pleroma/admin/reports", %{ "reports" => [ %{"state" => "test", "id" => id} ] }) - assert hd(json_response(conn, :bad_request))["error"] == "Unsupported state" + assert "Unsupported state" = + hd(json_response_and_validate_schema(conn, :bad_request))["error"] end test "returns 404 when report is not exist", %{conn: conn} do conn = conn + |> put_req_header("content-type", "application/json") |> patch("/api/pleroma/admin/reports", %{ "reports" => [ %{"state" => "closed", "id" => "test"} ] }) - assert hd(json_response(conn, :bad_request))["error"] == "not_found" + assert hd(json_response_and_validate_schema(conn, :bad_request))["error"] == "not_found" end test "updates state of multiple reports", %{ @@ -169,13 +176,14 @@ test "updates state of multiple reports", %{ second_report_id: second_report_id } do conn + |> put_req_header("content-type", "application/json") |> patch("/api/pleroma/admin/reports", %{ "reports" => [ %{"state" => "resolved", "id" => id}, %{"state" => "closed", "id" => second_report_id} ] }) - |> json_response(:no_content) + |> json_response_and_validate_schema(:no_content) activity = Activity.get_by_id(id) second_activity = Activity.get_by_id(second_report_id) @@ -197,7 +205,7 @@ test "returns empty response when no reports created", %{conn: conn} do response = conn |> get("/api/pleroma/admin/reports") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert Enum.empty?(response["reports"]) assert response["total"] == 0 @@ -217,7 +225,7 @@ test "returns reports", %{conn: conn} do response = conn |> get("/api/pleroma/admin/reports") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) [report] = response["reports"] @@ -248,12 +256,10 @@ test "returns reports with specified state", %{conn: conn} do response = conn - |> get("/api/pleroma/admin/reports", %{ - "state" => "open" - }) - |> json_response(:ok) + |> get("/api/pleroma/admin/reports?state=open") + |> json_response_and_validate_schema(:ok) - [open_report] = response["reports"] + assert [open_report] = response["reports"] assert length(response["reports"]) == 1 assert open_report["id"] == first_report_id @@ -262,27 +268,22 @@ test "returns reports with specified state", %{conn: conn} do response = conn - |> get("/api/pleroma/admin/reports", %{ - "state" => "closed" - }) - |> json_response(:ok) + |> get("/api/pleroma/admin/reports?state=closed") + |> json_response_and_validate_schema(:ok) - [closed_report] = response["reports"] + assert [closed_report] = response["reports"] assert length(response["reports"]) == 1 assert closed_report["id"] == second_report_id assert response["total"] == 1 - response = - conn - |> get("/api/pleroma/admin/reports", %{ - "state" => "resolved" - }) - |> json_response(:ok) - - assert Enum.empty?(response["reports"]) - assert response["total"] == 0 + assert %{"total" => 0, "reports" => []} == + conn + |> get("/api/pleroma/admin/reports?state=resolved", %{ + "" => "" + }) + |> json_response_and_validate_schema(:ok) end test "returns 403 when requested by a non-admin" do @@ -302,7 +303,9 @@ test "returns 403 when requested by a non-admin" do test "returns 403 when requested by anonymous" do conn = get(build_conn(), "/api/pleroma/admin/reports") - assert json_response(conn, :forbidden) == %{"error" => "Invalid credentials."} + assert json_response(conn, :forbidden) == %{ + "error" => "Invalid credentials." + } end end @@ -318,11 +321,15 @@ test "returns 403 when requested by anonymous" do status_ids: [activity.id] }) - post(conn, "/api/pleroma/admin/reports/#{report_id}/notes", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/reports/#{report_id}/notes", %{ content: "this is disgusting!" }) - post(conn, "/api/pleroma/admin/reports/#{report_id}/notes", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/reports/#{report_id}/notes", %{ content: "this is disgusting2!" }) @@ -333,7 +340,7 @@ test "returns 403 when requested by anonymous" do end test "it creates report note", %{admin_id: admin_id, report_id: report_id} do - [note, _] = Repo.all(ReportNote) + assert [note, _] = Repo.all(ReportNote) assert %{ activity_id: ^report_id, @@ -345,7 +352,7 @@ test "it creates report note", %{admin_id: admin_id, report_id: report_id} do test "it returns reports with notes", %{conn: conn, admin: admin} do conn = get(conn, "/api/pleroma/admin/reports") - response = json_response(conn, 200) + response = json_response_and_validate_schema(conn, 200) notes = hd(response["reports"])["notes"] [note, _] = notes @@ -357,8 +364,7 @@ test "it returns reports with notes", %{conn: conn, admin: admin} do test "it deletes the note", %{conn: conn, report_id: report_id} do assert ReportNote |> Repo.all() |> length() == 2 - - [note, _] = Repo.all(ReportNote) + assert [note, _] = Repo.all(ReportNote) delete(conn, "/api/pleroma/admin/reports/#{report_id}/notes/#{note.id}") From c020fd435216012f08812efdb9ee0c05352cec10 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 18:58:58 +0200 Subject: [PATCH 183/401] ChatMessageReferenceView: Return read status as `unread`. --- .../web/pleroma_api/views/chat_message_reference_view.ex | 2 +- .../pleroma_api/views/chat_message_reference_view_test.exs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex index ff170e162..f9405aec5 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex @@ -30,7 +30,7 @@ def render( attachment: chat_message["attachment"] && StatusView.render("attachment.json", attachment: chat_message["attachment"]), - seen: seen + unread: !seen } end diff --git a/test/web/pleroma_api/views/chat_message_reference_view_test.exs b/test/web/pleroma_api/views/chat_message_reference_view_test.exs index 00024d52c..b53bd3490 100644 --- a/test/web/pleroma_api/views/chat_message_reference_view_test.exs +++ b/test/web/pleroma_api/views/chat_message_reference_view_test.exs @@ -40,7 +40,7 @@ test "it displays a chat message" do assert chat_message[:account_id] == user.id assert chat_message[:chat_id] assert chat_message[:created_at] - assert chat_message[:seen] == true + assert chat_message[:unread] == false assert match?([%{shortcode: "firefox"}], chat_message[:emojis]) {:ok, activity} = CommonAPI.post_chat_message(recipient, user, "gkgkgk", media_id: upload.id) @@ -57,6 +57,6 @@ test "it displays a chat message" do assert chat_message_two[:account_id] == recipient.id assert chat_message_two[:chat_id] == chat_message[:chat_id] assert chat_message_two[:attachment] - assert chat_message_two[:seen] == false + assert chat_message_two[:unread] == true end end From b3407344d3acafa4a1271289d985632c058e7a6e Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 19:21:23 +0200 Subject: [PATCH 184/401] ChatController: Add function to mark single message as read. --- lib/pleroma/chat_message_reference.ex | 6 ++++ .../web/api_spec/operations/chat_operation.ex | 31 +++++++++++++++++-- .../controllers/chat_controller.ex | 23 +++++++++++++- lib/pleroma/web/router.ex | 1 + .../controllers/chat_controller_test.exs | 28 +++++++++++++++++ 5 files changed, 86 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/chat_message_reference.ex b/lib/pleroma/chat_message_reference.ex index ad174b294..9b00443f5 100644 --- a/lib/pleroma/chat_message_reference.ex +++ b/lib/pleroma/chat_message_reference.ex @@ -92,6 +92,12 @@ def unread_count_for_chat(chat) do |> Repo.aggregate(:count) end + def mark_as_read(cm_ref) do + cm_ref + |> changeset(%{seen: true}) + |> Repo.update() + end + def set_all_seen_for_chat(chat) do chat |> for_chat_query() diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index a1c5db5dc..6ad325113 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -39,6 +39,31 @@ def mark_as_read_operation do } end + def mark_message_as_read_operation do + %Operation{ + tags: ["chat"], + summary: "Mark one message in the chat as read", + operationId: "ChatController.mark_message_as_read", + parameters: [ + Operation.parameter(:id, :path, :string, "The ID of the Chat"), + Operation.parameter(:message_id, :path, :string, "The ID of the message") + ], + responses: %{ + 200 => + Operation.response( + "The read ChatMessage", + "application/json", + ChatMessage + ) + }, + security: [ + %{ + "oAuth" => ["write"] + } + ] + } + end + def show_operation do %Operation{ tags: ["chat"], @@ -274,7 +299,8 @@ def chat_messages_response do "content" => "Check this out :firefox:", "id" => "13", "chat_id" => "1", - "actor_id" => "someflakeid" + "actor_id" => "someflakeid", + "unread" => false }, %{ "actor_id" => "someflakeid", @@ -282,7 +308,8 @@ def chat_messages_response do "id" => "12", "chat_id" => "1", "emojis" => [], - "created_at" => "2020-04-21T15:06:45.000Z" + "created_at" => "2020-04-21T15:06:45.000Z", + "unread" => false } ] } diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 29922da99..01d47045d 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -24,7 +24,13 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do plug( OAuthScopesPlug, %{scopes: ["write:statuses"]} - when action in [:post_chat_message, :create, :mark_as_read, :delete_message] + when action in [ + :post_chat_message, + :create, + :mark_as_read, + :mark_message_as_read, + :delete_message + ] ) plug( @@ -88,6 +94,21 @@ def post_chat_message( end end + def mark_message_as_read(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ + id: chat_id, + message_id: message_id + }) do + with %ChatMessageReference{} = cm_ref <- + ChatMessageReference.get_by_id(message_id), + ^chat_id <- cm_ref.chat_id |> to_string(), + %Chat{user_id: ^user_id} <- Chat.get_by_id(chat_id), + {:ok, cm_ref} <- ChatMessageReference.mark_as_read(cm_ref) do + conn + |> put_view(ChatMessageReferenceView) + |> render("show.json", for: user, chat_message_reference: cm_ref) + end + end + def mark_as_read(%{assigns: %{user: %{id: user_id}}} = conn, %{id: id}) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), {_n, _} <- ChatMessageReference.set_all_seen_for_chat(chat) do diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index fef277ac6..fd2dc82ca 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -313,6 +313,7 @@ defmodule Pleroma.Web.Router do post("/chats/:id/messages", ChatController, :post_chat_message) delete("/chats/:id/messages/:message_id", ChatController, :delete_message) post("/chats/:id/read", ChatController, :mark_as_read) + post("/chats/:id/messages/:message_id/read", ChatController, :mark_message_as_read) get("/conversations/:id/statuses", ConversationController, :statuses) get("/conversations/:id", ConversationController, :show) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index e62b71799..e7892142a 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -13,6 +13,33 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do import Pleroma.Factory + describe "POST /api/v1/pleroma/chats/:id/messages/:message_id/read" do + setup do: oauth_access(["write:statuses"]) + + test "it marks one message as read", %{conn: conn, user: user} do + other_user = insert(:user) + + {:ok, create} = CommonAPI.post_chat_message(other_user, user, "sup") + {:ok, _create} = CommonAPI.post_chat_message(other_user, user, "sup part 2") + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + object = Object.normalize(create, false) + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + + assert cm_ref.seen == false + + result = + conn + |> post("/api/v1/pleroma/chats/#{chat.id}/messages/#{cm_ref.id}/read") + |> json_response_and_validate_schema(200) + + assert result["unread"] == false + + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + + assert cm_ref.seen == true + end + end + describe "POST /api/v1/pleroma/chats/:id/read" do setup do: oauth_access(["write:statuses"]) @@ -20,6 +47,7 @@ test "it marks all messages in a chat as read", %{conn: conn, user: user} do other_user = insert(:user) {:ok, create} = CommonAPI.post_chat_message(other_user, user, "sup") + {:ok, _create} = CommonAPI.post_chat_message(other_user, user, "sup part 2") {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) object = Object.normalize(create, false) cm_ref = ChatMessageReference.for_chat_and_object(chat, object) From 286bd8eb83e3fd9a2546e27c5e5d98f5316934a0 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 19:24:37 +0200 Subject: [PATCH 185/401] Docs: Add `mark_message_as_read` to docs --- docs/API/chats.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index d1d39f495..c0ef75664 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -94,6 +94,15 @@ Returned data: } ``` +### Marking a single chat message as read + +To set the `unread` property of a message to `false` + +`POST /api/v1/pleroma/chats/:id/messages/:message_id/read` + +Returned data: + +The modified chat message ### Getting a list of Chats @@ -149,7 +158,8 @@ Returned data: "visible_in_picker": false } ], - "id": "13" + "id": "13", + "unread": true }, { "account_id": "someflakeid", @@ -157,7 +167,8 @@ Returned data: "content": "Whats' up?", "created_at": "2020-04-21T15:06:45.000Z", "emojis": [], - "id": "12" + "id": "12", + "unread": false } ] ``` @@ -190,7 +201,8 @@ Returned data: "visible_in_picker": false } ], - "id": "13" + "id": "13", + "unread": false } ``` @@ -215,7 +227,8 @@ There's a new `pleroma:chat_mention` notification, which has this form. It is no "chat_id": "1", "id": "10", "content": "Hello", - "account_id": "someflakeid" + "account_id": "someflakeid", + "unread": false }, "created_at": "somedate" } From e213e3157737f87513999ef2aa00dffa735a8ada Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 19:25:57 +0200 Subject: [PATCH 186/401] Changelog: Add chats to changelog. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 839bf90ab..1cf2210f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **Breaking:** removed `with_move` parameter from notifications timeline. ### Added +- Chats: Added support for federated chats. For details, see the docs. - ActivityPub: Added support for existing AP ids for instances migrated from Mastodon. - Instance: Add `background_image` to configuration and `/api/v1/instance` - Instance: Extend `/api/v1/instance` with Pleroma-specific information. From aa26dc6a130614b049696784ecb29e341956bbc2 Mon Sep 17 00:00:00 2001 From: stwf Date: Wed, 3 Jun 2020 13:40:48 -0400 Subject: [PATCH 187/401] add status_net/config --- config/config.exs | 3 ++- lib/pleroma/web/preload.ex | 10 +++++--- lib/pleroma/web/preload/status_net.ex | 24 +++++++++++++++++++ .../controllers/util_controller.ex | 13 ++-------- .../web/twitter_api/views/util_view.ex | 14 +++++++++++ test/web/preload/status_net_test.exs | 14 +++++++++++ 6 files changed, 63 insertions(+), 15 deletions(-) create mode 100644 lib/pleroma/web/preload/status_net.ex create mode 100644 test/web/preload/status_net_test.exs diff --git a/config/config.exs b/config/config.exs index 1539b15c6..394c24d85 100644 --- a/config/config.exs +++ b/config/config.exs @@ -419,7 +419,8 @@ providers: [ Pleroma.Web.Preload.Providers.Instance, Pleroma.Web.Preload.Providers.User, - Pleroma.Web.Preload.Providers.Timelines + Pleroma.Web.Preload.Providers.Timelines, + Pleroma.Web.Preload.Providers.StatusNet ] config :pleroma, :http_security, diff --git a/lib/pleroma/web/preload.ex b/lib/pleroma/web/preload.ex index f13932b89..90e454468 100644 --- a/lib/pleroma/web/preload.ex +++ b/lib/pleroma/web/preload.ex @@ -9,7 +9,13 @@ defmodule Pleroma.Web.Preload do def build_tags(_conn, params) do preload_data = Enum.reduce(Pleroma.Config.get([__MODULE__, :providers], []), %{}, fn parser, acc -> - Map.merge(acc, parser.generate_terms(params)) + terms = + params + |> parser.generate_terms() + |> Enum.map(fn {k, v} -> {k, Base.encode64(Jason.encode!(v))} end) + |> Enum.into(%{}) + + Map.merge(acc, terms) end) rendered_html = @@ -22,8 +28,6 @@ def build_tags(_conn, params) do end def build_script_tag(content) do - content = Base.encode64(content) - HTML.Tag.content_tag(:script, HTML.raw(content), id: "initial-results", type: "application/json" diff --git a/lib/pleroma/web/preload/status_net.ex b/lib/pleroma/web/preload/status_net.ex new file mode 100644 index 000000000..7e592d60d --- /dev/null +++ b/lib/pleroma/web/preload/status_net.ex @@ -0,0 +1,24 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.StatusNet do + alias Pleroma.Web.TwitterAPI.UtilView + alias Pleroma.Web.Preload.Providers.Provider + + @behaviour Provider + @config_url :"/api/statusnet/config.json" + + @impl Provider + def generate_terms(_params) do + %{} + |> build_config_tag() + end + + defp build_config_tag(acc) do + instance = Pleroma.Config.get(:instance) + info_data = UtilView.status_net_config(instance) + + Map.put(acc, @config_url, info_data) + 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 fd2aee175..aaca182ec 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -15,6 +15,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do alias Pleroma.User alias Pleroma.Web alias Pleroma.Web.CommonAPI + alias Pleroma.Web.TwitterAPI.UtilView alias Pleroma.Web.WebFinger plug(Pleroma.Web.FederatingPlug when action == :remote_subscribe) @@ -90,17 +91,7 @@ def notifications_read(%{assigns: %{user: user}} = conn, %{"id" => notification_ def config(%{assigns: %{format: "xml"}} = conn, _params) do instance = Pleroma.Config.get(:instance) - - response = """ - - - #{Keyword.get(instance, :name)} - #{Web.base_url()} - #{Keyword.get(instance, :limit)} - #{!Keyword.get(instance, :registrations_open)} - - - """ + response = UtilView.status_net_config(instance) conn |> put_resp_content_type("application/xml") diff --git a/lib/pleroma/web/twitter_api/views/util_view.ex b/lib/pleroma/web/twitter_api/views/util_view.ex index 52054e020..d3bdb4f62 100644 --- a/lib/pleroma/web/twitter_api/views/util_view.ex +++ b/lib/pleroma/web/twitter_api/views/util_view.ex @@ -5,4 +5,18 @@ defmodule Pleroma.Web.TwitterAPI.UtilView do use Pleroma.Web, :view import Phoenix.HTML.Form + alias Pleroma.Web + + def status_net_config(instance) do + """ + + + #{Keyword.get(instance, :name)} + #{Web.base_url()} + #{Keyword.get(instance, :limit)} + #{!Keyword.get(instance, :registrations_open)} + + + """ + end end diff --git a/test/web/preload/status_net_test.exs b/test/web/preload/status_net_test.exs new file mode 100644 index 000000000..ab6823a7e --- /dev/null +++ b/test/web/preload/status_net_test.exs @@ -0,0 +1,14 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.StatusNetTest do + use Pleroma.DataCase + alias Pleroma.Web.Preload.Providers.StatusNet + + setup do: {:ok, StatusNet.generate_terms(nil)} + + test "it renders the info", %{"/api/statusnet/config.json": info} do + assert info =~ "Pleroma" + end +end From e46aecda55b20c0d48463fb2a5c0040d4fc34e97 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 20:51:59 +0200 Subject: [PATCH 188/401] Notification: Fix notifications backfill for compacted activities --- lib/pleroma/notification.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 0f33d282d..455d214bf 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -412,7 +412,7 @@ defp type_from_activity_object(%{data: %{"type" => "Create", "object" => %{}}}), defp type_from_activity_object(%{data: %{"type" => "Create"}} = activity) do object = Object.get_by_ap_id(activity.data["object"]) - case object.data["type"] do + case object && object.data["type"] do "ChatMessage" -> "pleroma:chat_mention" _ -> "mention" end From 29ae5bb77166d9d7f8108a965b0c3d147b747e80 Mon Sep 17 00:00:00 2001 From: stwf Date: Tue, 12 May 2020 11:08:00 -0400 Subject: [PATCH 189/401] preload data into index.html --- config/config.exs | 25 ++-- .../web/fallback_redirect_controller.ex | 82 +++++++---- lib/pleroma/web/nodeinfo/nodeinfo.ex | 130 ++++++++++++++++++ .../web/nodeinfo/nodeinfo_controller.ex | 112 ++------------- lib/pleroma/web/preload.ex | 30 ++++ lib/pleroma/web/preload/instance.ex | 49 +++++++ lib/pleroma/web/preload/provider.ex | 7 + lib/pleroma/web/preload/timelines.ex | 42 ++++++ lib/pleroma/web/preload/user.ex | 25 ++++ lib/pleroma/web/router.ex | 2 +- test/plugs/instance_static_test.exs | 2 +- test/web/fallback_test.exs | 38 +++-- test/web/preload/instance_test.exs | 37 +++++ test/web/preload/timeline_test.exs | 74 ++++++++++ test/web/preload/user_test.exs | 33 +++++ 15 files changed, 532 insertions(+), 156 deletions(-) create mode 100644 lib/pleroma/web/nodeinfo/nodeinfo.ex create mode 100644 lib/pleroma/web/preload.ex create mode 100644 lib/pleroma/web/preload/instance.ex create mode 100644 lib/pleroma/web/preload/provider.ex create mode 100644 lib/pleroma/web/preload/timelines.ex create mode 100644 lib/pleroma/web/preload/user.ex create mode 100644 test/web/preload/instance_test.exs create mode 100644 test/web/preload/timeline_test.exs create mode 100644 test/web/preload/user_test.exs diff --git a/config/config.exs b/config/config.exs index 9508ae077..ee81eb899 100644 --- a/config/config.exs +++ b/config/config.exs @@ -241,18 +241,7 @@ account_field_value_length: 2048, external_user_synchronization: true, extended_nickname_format: true, - cleanup_attachments: false, - multi_factor_authentication: [ - totp: [ - # digits 6 or 8 - digits: 6, - period: 30 - ], - backup_codes: [ - number: 5, - length: 16 - ] - ] + cleanup_attachments: false config :pleroma, :feed, post_title: %{ @@ -361,8 +350,7 @@ reject: [], accept: [], avatar_removal: [], - banner_removal: [], - reject_deletes: [] + banner_removal: [] config :pleroma, :mrf_keyword, reject: [], @@ -428,6 +416,13 @@ ], unfurl_nsfw: false +config :pleroma, Pleroma.Web.Preload, + providers: [ + Pleroma.Web.Preload.Providers.Instance, + Pleroma.Web.Preload.Providers.User, + Pleroma.Web.Preload.Providers.Timelines + ] + config :pleroma, :http_security, enabled: true, sts: false, @@ -682,8 +677,6 @@ profiles: %{local: false, remote: false}, activities: %{local: false, remote: false} -config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false - # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/lib/pleroma/web/fallback_redirect_controller.ex b/lib/pleroma/web/fallback_redirect_controller.ex index 0d9d578fc..932fb8d7e 100644 --- a/lib/pleroma/web/fallback_redirect_controller.ex +++ b/lib/pleroma/web/fallback_redirect_controller.ex @@ -4,11 +4,10 @@ defmodule Fallback.RedirectController do use Pleroma.Web, :controller - require Logger - alias Pleroma.User alias Pleroma.Web.Metadata + alias Pleroma.Web.Preload def api_not_implemented(conn, _params) do conn @@ -16,16 +15,7 @@ def api_not_implemented(conn, _params) do |> json(%{error: "Not implemented"}) end - def redirector(conn, _params, code \\ 200) - - # redirect to admin section - # /pleroma/admin -> /pleroma/admin/ - # - def redirector(conn, %{"path" => ["pleroma", "admin"]} = _, _code) do - redirect(conn, to: "/pleroma/admin/") - end - - def redirector(conn, _params, code) do + def redirector(conn, _params, code \\ 200) do conn |> put_resp_content_type("text/html") |> send_file(code, index_file_path()) @@ -43,28 +33,34 @@ def redirector_with_meta(conn, %{"maybe_nickname_or_id" => maybe_nickname_or_id} def redirector_with_meta(conn, params) do {:ok, index_content} = File.read(index_file_path()) - tags = - try do - Metadata.build_tags(params) - rescue - e -> - Logger.error( - "Metadata rendering for #{conn.request_path} failed.\n" <> - Exception.format(:error, e, __STACKTRACE__) - ) + tags = build_tags(conn, params) + preloads = preload_data(conn, params) - "" - end - - response = String.replace(index_content, "", tags) + response = + index_content + |> String.replace("", tags) + |> String.replace("", preloads) conn |> put_resp_content_type("text/html") |> send_resp(200, response) end - def index_file_path do - Pleroma.Plugs.InstanceStatic.file_path("index.html") + def redirector_with_preload(conn, %{"path" => ["pleroma", "admin"]}) do + redirect(conn, to: "/pleroma/admin/") + end + + def redirector_with_preload(conn, params) do + {:ok, index_content} = File.read(index_file_path()) + preloads = preload_data(conn, params) + + response = + index_content + |> String.replace("", preloads) + + conn + |> put_resp_content_type("text/html") + |> send_resp(200, response) end def registration_page(conn, params) do @@ -76,4 +72,36 @@ def empty(conn, _params) do |> put_status(204) |> text("") end + + defp index_file_path do + Pleroma.Plugs.InstanceStatic.file_path("index.html") + end + + defp build_tags(conn, params) do + try do + Metadata.build_tags(params) + rescue + e -> + Logger.error( + "Metadata rendering for #{conn.request_path} failed.\n" <> + Exception.format(:error, e, __STACKTRACE__) + ) + + "" + end + end + + defp preload_data(conn, params) do + try do + Preload.build_tags(conn, params) + rescue + e -> + Logger.error( + "Preloading for #{conn.request_path} failed.\n" <> + Exception.format(:error, e, __STACKTRACE__) + ) + + "" + end + end end diff --git a/lib/pleroma/web/nodeinfo/nodeinfo.ex b/lib/pleroma/web/nodeinfo/nodeinfo.ex new file mode 100644 index 000000000..d26b7c938 --- /dev/null +++ b/lib/pleroma/web/nodeinfo/nodeinfo.ex @@ -0,0 +1,130 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Nodeinfo.Nodeinfo do + alias Pleroma.Config + alias Pleroma.Stats + alias Pleroma.User + alias Pleroma.Web.ActivityPub.MRF + alias Pleroma.Web.Federator.Publisher + + # returns a nodeinfo 2.0 map, since 2.1 just adds a repository field + # under software. + def get_nodeinfo("2.0") do + stats = Stats.get_stats() + + quarantined = Config.get([:instance, :quarantined_instances], []) + + staff_accounts = + User.all_superusers() + |> Enum.map(fn u -> u.ap_id end) + + federation_response = + if Config.get([:instance, :mrf_transparency]) do + {:ok, data} = MRF.describe() + + data + |> Map.merge(%{quarantined_instances: quarantined}) + else + %{} + end + |> Map.put(:enabled, Config.get([:instance, :federating])) + + features = + [ + "pleroma_api", + "mastodon_api", + "mastodon_api_streaming", + "polls", + "pleroma_explicit_addressing", + "shareable_emoji_packs", + "multifetch", + "pleroma:api/v1/notifications:include_types_filter", + if Config.get([:media_proxy, :enabled]) do + "media_proxy" + end, + if Config.get([:gopher, :enabled]) do + "gopher" + end, + if Config.get([:chat, :enabled]) do + "chat" + end, + if Config.get([:instance, :allow_relay]) do + "relay" + end, + if Config.get([:instance, :safe_dm_mentions]) do + "safe_dm_mentions" + end, + "pleroma_emoji_reactions" + ] + |> Enum.filter(& &1) + + %{ + version: "2.0", + software: %{ + name: Pleroma.Application.name() |> String.downcase(), + version: Pleroma.Application.version() + }, + protocols: Publisher.gather_nodeinfo_protocol_names(), + services: %{ + inbound: [], + outbound: [] + }, + openRegistrations: Config.get([:instance, :registrations_open]), + usage: %{ + users: %{ + total: Map.get(stats, :user_count, 0) + }, + localPosts: Map.get(stats, :status_count, 0) + }, + metadata: %{ + nodeName: Config.get([:instance, :name]), + nodeDescription: Config.get([:instance, :description]), + private: !Config.get([:instance, :public], true), + suggestions: %{ + enabled: false + }, + staffAccounts: staff_accounts, + federation: federation_response, + pollLimits: Config.get([:instance, :poll_limits]), + postFormats: Config.get([:instance, :allowed_post_formats]), + uploadLimits: %{ + general: Config.get([:instance, :upload_limit]), + avatar: Config.get([:instance, :avatar_upload_limit]), + banner: Config.get([:instance, :banner_upload_limit]), + background: Config.get([:instance, :background_upload_limit]) + }, + fieldsLimits: %{ + maxFields: Config.get([:instance, :max_account_fields]), + maxRemoteFields: Config.get([:instance, :max_remote_account_fields]), + nameLength: Config.get([:instance, :account_field_name_length]), + valueLength: Config.get([:instance, :account_field_value_length]) + }, + accountActivationRequired: Config.get([:instance, :account_activation_required], false), + invitesEnabled: Config.get([:instance, :invites_enabled], false), + mailerEnabled: Config.get([Pleroma.Emails.Mailer, :enabled], false), + features: features, + restrictedNicknames: Config.get([Pleroma.User, :restricted_nicknames]), + skipThreadContainment: Config.get([:instance, :skip_thread_containment], false) + } + } + end + + def get_nodeinfo("2.1") do + raw_response = get_nodeinfo("2.0") + + updated_software = + raw_response + |> Map.get(:software) + |> Map.put(:repository, Pleroma.Application.repository()) + + raw_response + |> Map.put(:software, updated_software) + |> Map.put(:version, "2.1") + end + + def get_nodeinfo(_version) do + {:error, :missing} + end +end diff --git a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex index 721b599d4..8c7a9e565 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex @@ -5,12 +5,8 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do use Pleroma.Web, :controller - alias Pleroma.Config - alias Pleroma.Stats - alias Pleroma.User alias Pleroma.Web - alias Pleroma.Web.Federator.Publisher - alias Pleroma.Web.MastodonAPI.InstanceView + alias Pleroma.Web.Nodeinfo.Nodeinfo def schemas(conn, _params) do response = %{ @@ -29,102 +25,20 @@ def schemas(conn, _params) do json(conn, response) end - # returns a nodeinfo 2.0 map, since 2.1 just adds a repository field - # under software. - def raw_nodeinfo do - stats = Stats.get_stats() - - staff_accounts = - User.all_superusers() - |> Enum.map(fn u -> u.ap_id end) - - features = InstanceView.features() - federation = InstanceView.federation() - - %{ - version: "2.0", - software: %{ - name: Pleroma.Application.name() |> String.downcase(), - version: Pleroma.Application.version() - }, - protocols: Publisher.gather_nodeinfo_protocol_names(), - services: %{ - inbound: [], - outbound: [] - }, - openRegistrations: Config.get([:instance, :registrations_open]), - usage: %{ - users: %{ - total: Map.get(stats, :user_count, 0) - }, - localPosts: Map.get(stats, :status_count, 0) - }, - metadata: %{ - nodeName: Config.get([:instance, :name]), - nodeDescription: Config.get([:instance, :description]), - private: !Config.get([:instance, :public], true), - suggestions: %{ - enabled: false - }, - staffAccounts: staff_accounts, - federation: federation, - pollLimits: Config.get([:instance, :poll_limits]), - postFormats: Config.get([:instance, :allowed_post_formats]), - uploadLimits: %{ - general: Config.get([:instance, :upload_limit]), - avatar: Config.get([:instance, :avatar_upload_limit]), - banner: Config.get([:instance, :banner_upload_limit]), - background: Config.get([:instance, :background_upload_limit]) - }, - fieldsLimits: %{ - maxFields: Config.get([:instance, :max_account_fields]), - maxRemoteFields: Config.get([:instance, :max_remote_account_fields]), - nameLength: Config.get([:instance, :account_field_name_length]), - valueLength: Config.get([:instance, :account_field_value_length]) - }, - accountActivationRequired: Config.get([:instance, :account_activation_required], false), - invitesEnabled: Config.get([:instance, :invites_enabled], false), - mailerEnabled: Config.get([Pleroma.Emails.Mailer, :enabled], false), - features: features, - restrictedNicknames: Config.get([Pleroma.User, :restricted_nicknames]), - skipThreadContainment: Config.get([:instance, :skip_thread_containment], false) - } - } - end - # Schema definition: https://github.com/jhass/nodeinfo/blob/master/schemas/2.0/schema.json # and https://github.com/jhass/nodeinfo/blob/master/schemas/2.1/schema.json - def nodeinfo(conn, %{"version" => "2.0"}) do - conn - |> put_resp_header( - "content-type", - "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" - ) - |> json(raw_nodeinfo()) - end + def nodeinfo(conn, %{"version" => version}) do + case Nodeinfo.get_nodeinfo(version) do + {:error, :missing} -> + render_error(conn, :not_found, "Nodeinfo schema version not handled") - def nodeinfo(conn, %{"version" => "2.1"}) do - raw_response = raw_nodeinfo() - - updated_software = - raw_response - |> Map.get(:software) - |> Map.put(:repository, Pleroma.Application.repository()) - - response = - raw_response - |> Map.put(:software, updated_software) - |> Map.put(:version, "2.1") - - conn - |> put_resp_header( - "content-type", - "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.1#; charset=utf-8" - ) - |> json(response) - end - - def nodeinfo(conn, _) do - render_error(conn, :not_found, "Nodeinfo schema version not handled") + node_info -> + conn + |> put_resp_header( + "content-type", + "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" + ) + |> json(node_info) + end end end diff --git a/lib/pleroma/web/preload.ex b/lib/pleroma/web/preload.ex new file mode 100644 index 000000000..c2211c597 --- /dev/null +++ b/lib/pleroma/web/preload.ex @@ -0,0 +1,30 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload do + alias Phoenix.HTML + require Logger + + def build_tags(_conn, params) do + preload_data = + Enum.reduce(Pleroma.Config.get([__MODULE__, :providers], []), %{}, fn parser, acc -> + Map.merge(acc, parser.generate_terms(params)) + end) + + rendered_html = + preload_data + |> Jason.encode!() + |> build_script_tag() + |> HTML.safe_to_string() + + rendered_html + end + + def build_script_tag(content) do + HTML.Tag.content_tag(:script, HTML.raw(content), + id: "initial-results", + type: "application/json" + ) + end +end diff --git a/lib/pleroma/web/preload/instance.ex b/lib/pleroma/web/preload/instance.ex new file mode 100644 index 000000000..0b6fd3313 --- /dev/null +++ b/lib/pleroma/web/preload/instance.ex @@ -0,0 +1,49 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.Instance do + alias Pleroma.Web.MastodonAPI.InstanceView + alias Pleroma.Web.Nodeinfo.Nodeinfo + alias Pleroma.Web.Preload.Providers.Provider + + @behaviour Provider + @instance_url :"/api/v1/instance" + @panel_url :"/instance/panel.html" + @nodeinfo_url :"/nodeinfo/2.0" + + @impl Provider + def generate_terms(_params) do + %{} + |> build_info_tag() + |> build_panel_tag() + |> build_nodeinfo_tag() + end + + defp build_info_tag(acc) do + info_data = InstanceView.render("show.json", %{}) + + Map.put(acc, @instance_url, info_data) + end + + defp build_panel_tag(acc) do + instance_path = Path.join(:code.priv_dir(:pleroma), "static/instance/panel.html") + + if File.exists?(instance_path) do + panel_data = File.read!(instance_path) + Map.put(acc, @panel_url, panel_data) + else + acc + end + end + + defp build_nodeinfo_tag(acc) do + case Nodeinfo.get_nodeinfo("2.0") do + {:error, _} -> + acc + + nodeinfo_data -> + Map.put(acc, @nodeinfo_url, nodeinfo_data) + end + end +end diff --git a/lib/pleroma/web/preload/provider.ex b/lib/pleroma/web/preload/provider.ex new file mode 100644 index 000000000..7ef595a34 --- /dev/null +++ b/lib/pleroma/web/preload/provider.ex @@ -0,0 +1,7 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.Provider do + @callback generate_terms(map()) :: map() +end diff --git a/lib/pleroma/web/preload/timelines.ex b/lib/pleroma/web/preload/timelines.ex new file mode 100644 index 000000000..dbd7db407 --- /dev/null +++ b/lib/pleroma/web/preload/timelines.ex @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.Timelines do + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.Preload.Providers.Provider + + @behaviour Provider + @public_url :"/api/v1/timelines/public" + + @impl Provider + def generate_terms(_params) do + build_public_tag(%{}) + end + + def build_public_tag(acc) do + if Pleroma.Config.get([:restrict_unauthenticated, :timelines, :federated], true) do + acc + else + Map.put(acc, @public_url, public_timeline(nil)) + end + end + + defp public_timeline(user) do + activities = + create_timeline_params(user) + |> Map.put("local_only", false) + |> ActivityPub.fetch_public_activities() + + StatusView.render("index.json", activities: activities, for: user, as: :activity) + end + + defp create_timeline_params(user) do + %{} + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("user", user) + end +end diff --git a/lib/pleroma/web/preload/user.ex b/lib/pleroma/web/preload/user.ex new file mode 100644 index 000000000..3a244845b --- /dev/null +++ b/lib/pleroma/web/preload/user.ex @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.User do + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.Preload.Providers.Provider + + @behaviour Provider + @account_url :"/api/v1/accounts" + + @impl Provider + def generate_terms(%{user: user}) do + build_accounts_tag(%{}, user) + end + + def generate_terms(_params), do: %{} + + def build_accounts_tag(acc, nil), do: acc + + def build_accounts_tag(acc, user) do + account_data = AccountView.render("show.json", %{user: user, for: user}) + Map.put(acc, @account_url, account_data) + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 80ea28364..3b55afede 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -718,7 +718,7 @@ defmodule Pleroma.Web.Router do get("/registration/:token", RedirectController, :registration_page) get("/:maybe_nickname_or_id", RedirectController, :redirector_with_meta) get("/api*path", RedirectController, :api_not_implemented) - get("/*path", RedirectController, :redirector) + get("/*path", RedirectController, :redirector_with_preload) options("/*path", RedirectController, :empty) end diff --git a/test/plugs/instance_static_test.exs b/test/plugs/instance_static_test.exs index b8f070d6a..be2613ad0 100644 --- a/test/plugs/instance_static_test.exs +++ b/test/plugs/instance_static_test.exs @@ -16,7 +16,7 @@ defmodule Pleroma.Web.RuntimeStaticPlugTest do test "overrides index" do bundled_index = get(build_conn(), "/") - assert html_response(bundled_index, 200) == File.read!("priv/static/index.html") + refute html_response(bundled_index, 200) == "hello world" File.write!(@dir <> "/index.html", "hello world") diff --git a/test/web/fallback_test.exs b/test/web/fallback_test.exs index 3919ef93a..3b7a51d5e 100644 --- a/test/web/fallback_test.exs +++ b/test/web/fallback_test.exs @@ -6,22 +6,36 @@ defmodule Pleroma.Web.FallbackTest do use Pleroma.Web.ConnCase import Pleroma.Factory - test "GET /registration/:token", %{conn: conn} do - assert conn - |> get("/registration/foo") - |> html_response(200) =~ "" + describe "neither preloaded data nor metadata attached to" do + test "GET /registration/:token", %{conn: conn} do + response = get(conn, "/registration/foo") + + assert html_response(response, 200) =~ "" + assert html_response(response, 200) =~ "" + end end - test "GET /:maybe_nickname_or_id", %{conn: conn} do - user = insert(:user) + describe "preloaded data and metadata attached to" do + test "GET /:maybe_nickname_or_id", %{conn: conn} do + user = insert(:user) + user_missing = get(conn, "/foo") + user_present = get(conn, "/#{user.nickname}") - assert conn - |> get("/foo") - |> html_response(200) =~ "" + assert html_response(user_missing, 200) =~ "" + refute html_response(user_present, 200) =~ "" - refute conn - |> get("/" <> user.nickname) - |> html_response(200) =~ "" + assert html_response(user_missing, 200) =~ "" + refute html_response(user_present, 200) =~ "" + end + end + + describe "preloaded data only attached to" do + test "GET /*path", %{conn: conn} do + public_page = get(conn, "/main/public") + + assert html_response(public_page, 200) =~ "" + refute html_response(public_page, 200) =~ "" + end end test "GET /api*path", %{conn: conn} do diff --git a/test/web/preload/instance_test.exs b/test/web/preload/instance_test.exs new file mode 100644 index 000000000..52f9bab3b --- /dev/null +++ b/test/web/preload/instance_test.exs @@ -0,0 +1,37 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.InstanceTest do + use Pleroma.DataCase + alias Pleroma.Web.Preload.Providers.Instance + + setup do: {:ok, Instance.generate_terms(nil)} + + test "it renders the info", %{"/api/v1/instance": info} do + assert %{ + description: description, + email: "admin@example.com", + registrations: true + } = info + + assert String.equivalent?(description, "A Pleroma instance, an alternative fediverse server") + end + + test "it renders the panel", %{"/instance/panel.html": panel} do + assert String.contains?( + panel, + "

Welcome to Pleroma!

" + ) + end + + test "it renders the node_info", %{"/nodeinfo/2.0": nodeinfo} do + %{ + metadata: metadata, + version: "2.0" + } = nodeinfo + + assert metadata.private == false + assert metadata.suggestions == %{enabled: false} + end +end diff --git a/test/web/preload/timeline_test.exs b/test/web/preload/timeline_test.exs new file mode 100644 index 000000000..00b10d0ab --- /dev/null +++ b/test/web/preload/timeline_test.exs @@ -0,0 +1,74 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.TimelineTest do + use Pleroma.DataCase + import Pleroma.Factory + + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Preload.Providers.Timelines + + @public_url :"/api/v1/timelines/public" + + describe "unauthenticated timeliness when restricted" do + setup do + svd_config = Pleroma.Config.get([:restrict_unauthenticated, :timelines]) + Pleroma.Config.put([:restrict_unauthenticated, :timelines], %{local: true, federated: true}) + + on_exit(fn -> + Pleroma.Config.put([:restrict_unauthenticated, :timelines], svd_config) + end) + + :ok + end + + test "return nothing" do + tl_data = Timelines.generate_terms(%{}) + + refute Map.has_key?(tl_data, "/api/v1/timelines/public") + end + end + + describe "unauthenticated timeliness when unrestricted" do + setup do + svd_config = Pleroma.Config.get([:restrict_unauthenticated, :timelines]) + + Pleroma.Config.put([:restrict_unauthenticated, :timelines], %{ + local: false, + federated: false + }) + + on_exit(fn -> + Pleroma.Config.put([:restrict_unauthenticated, :timelines], svd_config) + end) + + {:ok, user: insert(:user)} + end + + test "returns the timeline when not restricted" do + assert Timelines.generate_terms(%{}) + |> Map.has_key?(@public_url) + end + + test "returns public items", %{user: user} do + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 1!"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 2!"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 3!"}) + + assert Timelines.generate_terms(%{}) + |> Map.fetch!(@public_url) + |> Enum.count() == 3 + end + + test "does not return non-public items", %{user: user} do + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 1!", "visibility" => "unlisted"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 2!", "visibility" => "direct"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 3!"}) + + assert Timelines.generate_terms(%{}) + |> Map.fetch!(@public_url) + |> Enum.count() == 1 + end + end +end diff --git a/test/web/preload/user_test.exs b/test/web/preload/user_test.exs new file mode 100644 index 000000000..99232cdfa --- /dev/null +++ b/test/web/preload/user_test.exs @@ -0,0 +1,33 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.UserTest do + use Pleroma.DataCase + import Pleroma.Factory + alias Pleroma.Web.Preload.Providers.User + + describe "returns empty when user doesn't exist" do + test "nil user specified" do + refute User.generate_terms(%{user: nil}) + |> Map.has_key?("/api/v1/accounts") + end + + test "missing user specified" do + refute User.generate_terms(%{user: :not_a_user}) + |> Map.has_key?("/api/v1/accounts") + end + end + + describe "specified user exists" do + setup do + user = insert(:user) + + {:ok, User.generate_terms(%{user: user})} + end + + test "account is rendered", %{"/api/v1/accounts": accounts} do + assert %{acct: user, username: user} = accounts + end + end +end From dbcc1b105ee1a2552595d189d8ac9d8484ffb601 Mon Sep 17 00:00:00 2001 From: stwf Date: Tue, 2 Jun 2020 10:18:06 -0400 Subject: [PATCH 190/401] encode data properly --- .../web/fallback_redirect_controller.ex | 7 +-- lib/pleroma/web/preload.ex | 2 + lib/pleroma/web/preload/timelines.ex | 31 ++++++------- test/web/fallback_test.exs | 46 +++++++++++-------- test/web/preload/timeline_test.exs | 12 ++--- 5 files changed, 54 insertions(+), 44 deletions(-) diff --git a/lib/pleroma/web/fallback_redirect_controller.ex b/lib/pleroma/web/fallback_redirect_controller.ex index 932fb8d7e..431ad5485 100644 --- a/lib/pleroma/web/fallback_redirect_controller.ex +++ b/lib/pleroma/web/fallback_redirect_controller.ex @@ -4,7 +4,9 @@ defmodule Fallback.RedirectController do use Pleroma.Web, :controller + require Logger + alias Pleroma.User alias Pleroma.Web.Metadata alias Pleroma.Web.Preload @@ -38,8 +40,7 @@ def redirector_with_meta(conn, params) do response = index_content - |> String.replace("", tags) - |> String.replace("", preloads) + |> String.replace("", tags <> preloads) conn |> put_resp_content_type("text/html") @@ -56,7 +57,7 @@ def redirector_with_preload(conn, params) do response = index_content - |> String.replace("", preloads) + |> String.replace("", preloads) conn |> put_resp_content_type("text/html") diff --git a/lib/pleroma/web/preload.ex b/lib/pleroma/web/preload.ex index c2211c597..f13932b89 100644 --- a/lib/pleroma/web/preload.ex +++ b/lib/pleroma/web/preload.ex @@ -22,6 +22,8 @@ def build_tags(_conn, params) do end def build_script_tag(content) do + content = Base.encode64(content) + HTML.Tag.content_tag(:script, HTML.raw(content), id: "initial-results", type: "application/json" diff --git a/lib/pleroma/web/preload/timelines.ex b/lib/pleroma/web/preload/timelines.ex index dbd7db407..2bb57567b 100644 --- a/lib/pleroma/web/preload/timelines.ex +++ b/lib/pleroma/web/preload/timelines.ex @@ -11,32 +11,29 @@ defmodule Pleroma.Web.Preload.Providers.Timelines do @public_url :"/api/v1/timelines/public" @impl Provider - def generate_terms(_params) do - build_public_tag(%{}) + def generate_terms(params) do + build_public_tag(%{}, params) end - def build_public_tag(acc) do + def build_public_tag(acc, params) do if Pleroma.Config.get([:restrict_unauthenticated, :timelines, :federated], true) do acc else - Map.put(acc, @public_url, public_timeline(nil)) + Map.put(acc, @public_url, public_timeline(params)) end end - defp public_timeline(user) do + defp public_timeline(%{"path" => ["main", "all"]}), do: get_public_timeline(false) + + defp public_timeline(_params), do: get_public_timeline(true) + + defp get_public_timeline(local_only) do activities = - create_timeline_params(user) - |> Map.put("local_only", false) - |> ActivityPub.fetch_public_activities() + ActivityPub.fetch_public_activities(%{ + "type" => ["Create"], + "local_only" => local_only + }) - StatusView.render("index.json", activities: activities, for: user, as: :activity) - end - - defp create_timeline_params(user) do - %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) + StatusView.render("index.json", activities: activities, for: nil, as: :activity) end end diff --git a/test/web/fallback_test.exs b/test/web/fallback_test.exs index 3b7a51d5e..a65865860 100644 --- a/test/web/fallback_test.exs +++ b/test/web/fallback_test.exs @@ -11,7 +11,12 @@ test "GET /registration/:token", %{conn: conn} do response = get(conn, "/registration/foo") assert html_response(response, 200) =~ "" - assert html_response(response, 200) =~ "" + end + + test "GET /*path", %{conn: conn} do + assert conn + |> get("/foo") + |> html_response(200) =~ "" end end @@ -21,20 +26,35 @@ test "GET /:maybe_nickname_or_id", %{conn: conn} do user_missing = get(conn, "/foo") user_present = get(conn, "/#{user.nickname}") - assert html_response(user_missing, 200) =~ "" + assert(html_response(user_missing, 200) =~ "") refute html_response(user_present, 200) =~ "" + assert html_response(user_present, 200) =~ "initial-results" + end - assert html_response(user_missing, 200) =~ "" - refute html_response(user_present, 200) =~ "" + test "GET /*path", %{conn: conn} do + assert conn + |> get("/foo") + |> html_response(200) =~ "" + + refute conn + |> get("/foo/bar") + |> html_response(200) =~ "" end end - describe "preloaded data only attached to" do - test "GET /*path", %{conn: conn} do + describe "preloaded data is attached to" do + test "GET /main/public", %{conn: conn} do public_page = get(conn, "/main/public") - assert html_response(public_page, 200) =~ "" - refute html_response(public_page, 200) =~ "" + refute html_response(public_page, 200) =~ "" + assert html_response(public_page, 200) =~ "initial-results" + end + + test "GET /main/all", %{conn: conn} do + public_page = get(conn, "/main/all") + + refute html_response(public_page, 200) =~ "" + assert html_response(public_page, 200) =~ "initial-results" end end @@ -48,16 +68,6 @@ test "GET /pleroma/admin -> /pleroma/admin/", %{conn: conn} do assert redirected_to(get(conn, "/pleroma/admin")) =~ "/pleroma/admin/" end - test "GET /*path", %{conn: conn} do - assert conn - |> get("/foo") - |> html_response(200) =~ "" - - assert conn - |> get("/foo/bar") - |> html_response(200) =~ "" - end - test "OPTIONS /*path", %{conn: conn} do assert conn |> options("/foo") diff --git a/test/web/preload/timeline_test.exs b/test/web/preload/timeline_test.exs index 00b10d0ab..da6a3aded 100644 --- a/test/web/preload/timeline_test.exs +++ b/test/web/preload/timeline_test.exs @@ -52,9 +52,9 @@ test "returns the timeline when not restricted" do end test "returns public items", %{user: user} do - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 1!"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 2!"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 3!"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 1!"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 2!"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 3!"}) assert Timelines.generate_terms(%{}) |> Map.fetch!(@public_url) @@ -62,9 +62,9 @@ test "returns public items", %{user: user} do end test "does not return non-public items", %{user: user} do - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 1!", "visibility" => "unlisted"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 2!", "visibility" => "direct"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 3!"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 1!", visibility: "unlisted"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 2!", visibility: "direct"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 3!"}) assert Timelines.generate_terms(%{}) |> Map.fetch!(@public_url) From 3b8180d7d1f52a9eae1913a59b9c970f6600e674 Mon Sep 17 00:00:00 2001 From: stwf Date: Wed, 3 Jun 2020 13:40:48 -0400 Subject: [PATCH 191/401] add status_net/config --- config/config.exs | 3 ++- lib/pleroma/web/preload.ex | 10 +++++--- lib/pleroma/web/preload/status_net.ex | 24 +++++++++++++++++++ .../controllers/util_controller.ex | 13 ++-------- .../web/twitter_api/views/util_view.ex | 14 +++++++++++ test/web/preload/status_net_test.exs | 14 +++++++++++ 6 files changed, 63 insertions(+), 15 deletions(-) create mode 100644 lib/pleroma/web/preload/status_net.ex create mode 100644 test/web/preload/status_net_test.exs diff --git a/config/config.exs b/config/config.exs index ee81eb899..0dca26152 100644 --- a/config/config.exs +++ b/config/config.exs @@ -420,7 +420,8 @@ providers: [ Pleroma.Web.Preload.Providers.Instance, Pleroma.Web.Preload.Providers.User, - Pleroma.Web.Preload.Providers.Timelines + Pleroma.Web.Preload.Providers.Timelines, + Pleroma.Web.Preload.Providers.StatusNet ] config :pleroma, :http_security, diff --git a/lib/pleroma/web/preload.ex b/lib/pleroma/web/preload.ex index f13932b89..90e454468 100644 --- a/lib/pleroma/web/preload.ex +++ b/lib/pleroma/web/preload.ex @@ -9,7 +9,13 @@ defmodule Pleroma.Web.Preload do def build_tags(_conn, params) do preload_data = Enum.reduce(Pleroma.Config.get([__MODULE__, :providers], []), %{}, fn parser, acc -> - Map.merge(acc, parser.generate_terms(params)) + terms = + params + |> parser.generate_terms() + |> Enum.map(fn {k, v} -> {k, Base.encode64(Jason.encode!(v))} end) + |> Enum.into(%{}) + + Map.merge(acc, terms) end) rendered_html = @@ -22,8 +28,6 @@ def build_tags(_conn, params) do end def build_script_tag(content) do - content = Base.encode64(content) - HTML.Tag.content_tag(:script, HTML.raw(content), id: "initial-results", type: "application/json" diff --git a/lib/pleroma/web/preload/status_net.ex b/lib/pleroma/web/preload/status_net.ex new file mode 100644 index 000000000..7e592d60d --- /dev/null +++ b/lib/pleroma/web/preload/status_net.ex @@ -0,0 +1,24 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.StatusNet do + alias Pleroma.Web.TwitterAPI.UtilView + alias Pleroma.Web.Preload.Providers.Provider + + @behaviour Provider + @config_url :"/api/statusnet/config.json" + + @impl Provider + def generate_terms(_params) do + %{} + |> build_config_tag() + end + + defp build_config_tag(acc) do + instance = Pleroma.Config.get(:instance) + info_data = UtilView.status_net_config(instance) + + Map.put(acc, @config_url, info_data) + 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 fd2aee175..aaca182ec 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -15,6 +15,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do alias Pleroma.User alias Pleroma.Web alias Pleroma.Web.CommonAPI + alias Pleroma.Web.TwitterAPI.UtilView alias Pleroma.Web.WebFinger plug(Pleroma.Web.FederatingPlug when action == :remote_subscribe) @@ -90,17 +91,7 @@ def notifications_read(%{assigns: %{user: user}} = conn, %{"id" => notification_ def config(%{assigns: %{format: "xml"}} = conn, _params) do instance = Pleroma.Config.get(:instance) - - response = """ - - - #{Keyword.get(instance, :name)} - #{Web.base_url()} - #{Keyword.get(instance, :limit)} - #{!Keyword.get(instance, :registrations_open)} - - - """ + response = UtilView.status_net_config(instance) conn |> put_resp_content_type("application/xml") diff --git a/lib/pleroma/web/twitter_api/views/util_view.ex b/lib/pleroma/web/twitter_api/views/util_view.ex index 52054e020..d3bdb4f62 100644 --- a/lib/pleroma/web/twitter_api/views/util_view.ex +++ b/lib/pleroma/web/twitter_api/views/util_view.ex @@ -5,4 +5,18 @@ defmodule Pleroma.Web.TwitterAPI.UtilView do use Pleroma.Web, :view import Phoenix.HTML.Form + alias Pleroma.Web + + def status_net_config(instance) do + """ + + + #{Keyword.get(instance, :name)} + #{Web.base_url()} + #{Keyword.get(instance, :limit)} + #{!Keyword.get(instance, :registrations_open)} + + + """ + end end diff --git a/test/web/preload/status_net_test.exs b/test/web/preload/status_net_test.exs new file mode 100644 index 000000000..ab6823a7e --- /dev/null +++ b/test/web/preload/status_net_test.exs @@ -0,0 +1,14 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.StatusNetTest do + use Pleroma.DataCase + alias Pleroma.Web.Preload.Providers.StatusNet + + setup do: {:ok, StatusNet.generate_terms(nil)} + + test "it renders the info", %{"/api/statusnet/config.json": info} do + assert info =~ "Pleroma" + end +end From 5677b21e824aa7f3b5124068ef65041e04002579 Mon Sep 17 00:00:00 2001 From: stwf Date: Wed, 3 Jun 2020 16:32:38 -0400 Subject: [PATCH 192/401] clean up --- config/config.exs | 18 ++++++++++++++++-- lib/pleroma/web/preload/status_net.ex | 2 +- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/config/config.exs b/config/config.exs index 0dca26152..6e75b79ec 100644 --- a/config/config.exs +++ b/config/config.exs @@ -241,7 +241,18 @@ account_field_value_length: 2048, external_user_synchronization: true, extended_nickname_format: true, - cleanup_attachments: false + cleanup_attachments: false, + multi_factor_authentication: [ + totp: [ + # digits 6 or 8 + digits: 6, + period: 30 + ], + backup_codes: [ + number: 5, + length: 16 + ] + ] config :pleroma, :feed, post_title: %{ @@ -350,7 +361,8 @@ reject: [], accept: [], avatar_removal: [], - banner_removal: [] + banner_removal: [], + reject_deletes: [] config :pleroma, :mrf_keyword, reject: [], @@ -678,6 +690,8 @@ profiles: %{local: false, remote: false}, activities: %{local: false, remote: false} +config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/lib/pleroma/web/preload/status_net.ex b/lib/pleroma/web/preload/status_net.ex index 7e592d60d..367442d5c 100644 --- a/lib/pleroma/web/preload/status_net.ex +++ b/lib/pleroma/web/preload/status_net.ex @@ -3,8 +3,8 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Preload.Providers.StatusNet do - alias Pleroma.Web.TwitterAPI.UtilView alias Pleroma.Web.Preload.Providers.Provider + alias Pleroma.Web.TwitterAPI.UtilView @behaviour Provider @config_url :"/api/statusnet/config.json" From 75e886b5065dd5ef6b7c99cbbe162f405e1b105e Mon Sep 17 00:00:00 2001 From: stwf Date: Wed, 3 Jun 2020 17:32:03 -0400 Subject: [PATCH 193/401] fix config --- config/config.exs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/config/config.exs b/config/config.exs index 0dca26152..6e75b79ec 100644 --- a/config/config.exs +++ b/config/config.exs @@ -241,7 +241,18 @@ account_field_value_length: 2048, external_user_synchronization: true, extended_nickname_format: true, - cleanup_attachments: false + cleanup_attachments: false, + multi_factor_authentication: [ + totp: [ + # digits 6 or 8 + digits: 6, + period: 30 + ], + backup_codes: [ + number: 5, + length: 16 + ] + ] config :pleroma, :feed, post_title: %{ @@ -350,7 +361,8 @@ reject: [], accept: [], avatar_removal: [], - banner_removal: [] + banner_removal: [], + reject_deletes: [] config :pleroma, :mrf_keyword, reject: [], @@ -678,6 +690,8 @@ profiles: %{local: false, remote: false}, activities: %{local: false, remote: false} +config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" From a8132690bd80b83fc0057566d78a49eceefe0349 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 4 Jun 2020 13:46:13 +0400 Subject: [PATCH 194/401] Fix credo --- test/web/admin_api/controllers/admin_api_controller_test.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index f4c37ae6e..2aaec510d 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -1744,7 +1744,6 @@ test "it resend emails for two users", %{conn: conn, admin: admin} do end end - describe "/api/pleroma/admin/stats" do test "status visibility count", %{conn: conn} do admin = insert(:user, is_admin: true) From 5d7dda883e76041025384e453da74110c550aa3b Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 4 Jun 2020 14:46:41 +0200 Subject: [PATCH 195/401] SideEffectsTest: More tests. --- test/web/activity_pub/side_effects_test.exs | 22 ++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 92c266d84..82d72119e 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -321,7 +321,27 @@ test "it streams the created ChatMessage" do {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) - with_mock Pleroma.Web.Streamer, [], stream: fn _, _ -> nil end do + with_mock Pleroma.Web.Streamer, [], + stream: fn _, payload -> + case payload do + {^author, cm_ref} -> + assert cm_ref.seen == true + + {^recipient, cm_ref} -> + assert cm_ref.seen == false + + view = + Pleroma.Web.PleromaAPI.ChatView.render("show.json", + last_message: cm_ref, + chat: cm_ref.chat + ) + + assert view.unread == 1 + + _ -> + nil + end + end do {:ok, _create_activity, _meta} = SideEffects.handle(create_activity, local: false, object_data: chat_message_data) From b952f3f37907c735e3426ba43d01027f6f49c5b5 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 4 Jun 2020 14:49:10 +0200 Subject: [PATCH 196/401] WebPush: Push out chat message notications. --- lib/pleroma/web/push/impl.ex | 3 ++- lib/pleroma/web/push/subscription.ex | 2 +- test/web/push/impl_test.exs | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index 125f33755..006a242af 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -32,7 +32,7 @@ def perform( mastodon_type = notification.type gcm_api_key = Application.get_env(:web_push_encryption, :gcm_api_key) avatar_url = User.avatar_url(actor) - object = Object.normalize(activity) + object = Object.normalize(activity, false) user = User.get_cached_by_id(user_id) direct_conversation_id = Activity.direct_conversation_id(activity, user) @@ -171,6 +171,7 @@ def format_title(%{type: type}, mastodon_type) do "follow_request" -> "New Follow Request" "reblog" -> "New Repeat" "favourite" -> "New Favorite" + "pleroma:chat_mention" -> "New Chat Message" type -> "New #{String.capitalize(type || "event")}" end end diff --git a/lib/pleroma/web/push/subscription.ex b/lib/pleroma/web/push/subscription.ex index 3e401a490..5b5aa0d59 100644 --- a/lib/pleroma/web/push/subscription.ex +++ b/lib/pleroma/web/push/subscription.ex @@ -25,7 +25,7 @@ defmodule Pleroma.Web.Push.Subscription do timestamps() end - @supported_alert_types ~w[follow favourite mention reblog]a + @supported_alert_types ~w[follow favourite mention reblog pleroma:chat_mention]a defp alerts(%{data: %{alerts: alerts}}) do alerts = Map.take(alerts, @supported_alert_types) diff --git a/test/web/push/impl_test.exs b/test/web/push/impl_test.exs index 26c65bc82..8fb7faaa5 100644 --- a/test/web/push/impl_test.exs +++ b/test/web/push/impl_test.exs @@ -5,6 +5,7 @@ defmodule Pleroma.Web.Push.ImplTest do use Pleroma.DataCase + alias Pleroma.Notification alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.CommonAPI @@ -196,6 +197,22 @@ test "renders title for create activity with direct visibility" do end describe "build_content/3" do + test "builds content for chat messages" do + user = insert(:user) + recipient = insert(:user) + + {:ok, chat} = CommonAPI.post_chat_message(user, recipient, "hey") + object = Object.normalize(chat, false) + [notification] = Notification.for_user(recipient) + + res = Impl.build_content(notification, user, object) + + assert res == %{ + body: "@#{user.nickname}: hey", + title: "New Chat Message" + } + end + test "hides details for notifications when privacy option enabled" do user = insert(:user, nickname: "Bob") user2 = insert(:user, nickname: "Rob", notification_settings: %{privacy_option: true}) From 6e103a18af6cfd7f454a911e2f0e1ae35cd45aa4 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 4 Jun 2020 14:49:36 +0200 Subject: [PATCH 197/401] Docs: Document WebPush changes. --- docs/API/chats.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/API/chats.md b/docs/API/chats.md index c0ef75664..abeee698f 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -237,3 +237,7 @@ There's a new `pleroma:chat_mention` notification, which has this form. It is no ### Streaming There is an additional `user:pleroma_chat` stream. Incoming chat messages will make the current chat be sent to this `user` stream. The `event` of an incoming chat message is `pleroma:chat_update`. The payload is the updated chat with the incoming chat message in the `last_message` field. + +### Web Push + +If you want to receive push messages for this type, you'll need to add the `pleroma:chat_mention` type to your alerts in the push subscription. From a42d135cce3e6326cd8a16f08f4ab83633386c2e Mon Sep 17 00:00:00 2001 From: stwf Date: Thu, 4 Jun 2020 10:51:24 -0400 Subject: [PATCH 198/401] test fix --- test/web/preload/instance_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/preload/instance_test.exs b/test/web/preload/instance_test.exs index 52f9bab3b..42a0d87bc 100644 --- a/test/web/preload/instance_test.exs +++ b/test/web/preload/instance_test.exs @@ -15,7 +15,7 @@ test "it renders the info", %{"/api/v1/instance": info} do registrations: true } = info - assert String.equivalent?(description, "A Pleroma instance, an alternative fediverse server") + assert String.equivalent?(description, "Pleroma: An efficient and flexible fediverse server") end test "it renders the panel", %{"/instance/panel.html": panel} do From 00748e9650e911d828dfe6f769ac20a6b31c8b69 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 4 Jun 2020 17:14:42 +0200 Subject: [PATCH 199/401] ChatMessageReferences: Change seen -> unread --- lib/pleroma/chat_message_reference.ex | 18 +++++------ lib/pleroma/web/activity_pub/side_effects.ex | 2 +- .../views/chat_message_reference_view.ex | 4 +-- ...n_to_unread_in_chat_message_references.exs | 30 +++++++++++++++++++ test/web/activity_pub/side_effects_test.exs | 8 ++--- .../controllers/chat_controller_test.exs | 8 ++--- 6 files changed, 50 insertions(+), 20 deletions(-) create mode 100644 priv/repo/migrations/20200604150318_migrate_seen_to_unread_in_chat_message_references.exs diff --git a/lib/pleroma/chat_message_reference.ex b/lib/pleroma/chat_message_reference.ex index 9b00443f5..fc2aaae7a 100644 --- a/lib/pleroma/chat_message_reference.ex +++ b/lib/pleroma/chat_message_reference.ex @@ -23,15 +23,15 @@ defmodule Pleroma.ChatMessageReference do belongs_to(:object, Object) belongs_to(:chat, Chat) - field(:seen, :boolean, default: false) + field(:unread, :boolean, default: true) timestamps() end def changeset(struct, params) do struct - |> cast(params, [:object_id, :chat_id, :seen]) - |> validate_required([:object_id, :chat_id, :seen]) + |> cast(params, [:object_id, :chat_id, :unread]) + |> validate_required([:object_id, :chat_id, :unread]) end def get_by_id(id) do @@ -73,11 +73,11 @@ def last_message_for_chat(chat) do |> Repo.one() end - def create(chat, object, seen) do + def create(chat, object, unread) do params = %{ chat_id: chat.id, object_id: object.id, - seen: seen + unread: unread } %__MODULE__{} @@ -88,13 +88,13 @@ def create(chat, object, seen) do def unread_count_for_chat(chat) do chat |> for_chat_query() - |> where([cmr], cmr.seen == false) + |> where([cmr], cmr.unread == true) |> Repo.aggregate(:count) end def mark_as_read(cm_ref) do cm_ref - |> changeset(%{seen: true}) + |> changeset(%{unread: false}) |> Repo.update() end @@ -103,7 +103,7 @@ def set_all_seen_for_chat(chat) do |> for_chat_query() |> exclude(:order_by) |> exclude(:preload) - |> where([cmr], cmr.seen == false) - |> Repo.update_all(set: [seen: true]) + |> where([cmr], cmr.unread == true) + |> Repo.update_all(set: [unread: false]) end end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 0c5709356..e9f109d80 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -140,7 +140,7 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do |> Enum.each(fn [user, other_user] -> if user.local do {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) - {:ok, cm_ref} = ChatMessageReference.create(chat, object, user.ap_id == actor.ap_id) + {:ok, cm_ref} = ChatMessageReference.create(chat, object, user.ap_id != actor.ap_id) Streamer.stream( ["user", "user:pleroma_chat"], diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex index f9405aec5..592bb17f0 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex @@ -16,7 +16,7 @@ def render( id: id, object: %{data: chat_message}, chat_id: chat_id, - seen: seen + unread: unread } } ) do @@ -30,7 +30,7 @@ def render( attachment: chat_message["attachment"] && StatusView.render("attachment.json", attachment: chat_message["attachment"]), - unread: !seen + unread: unread } end diff --git a/priv/repo/migrations/20200604150318_migrate_seen_to_unread_in_chat_message_references.exs b/priv/repo/migrations/20200604150318_migrate_seen_to_unread_in_chat_message_references.exs new file mode 100644 index 000000000..fd6bc7bc7 --- /dev/null +++ b/priv/repo/migrations/20200604150318_migrate_seen_to_unread_in_chat_message_references.exs @@ -0,0 +1,30 @@ +defmodule Pleroma.Repo.Migrations.MigrateSeenToUnreadInChatMessageReferences do + use Ecto.Migration + + def change do + drop( + index(:chat_message_references, [:chat_id], + where: "seen = false", + name: "unseen_messages_count_index" + ) + ) + + alter table(:chat_message_references) do + add(:unread, :boolean, default: true) + end + + execute("update chat_message_references set unread = not seen") + + alter table(:chat_message_references) do + modify(:unread, :boolean, default: true, null: false) + remove(:seen, :boolean, default: false, null: false) + end + + create( + index(:chat_message_references, [:chat_id], + where: "unread = true", + name: "unread_messages_count_index" + ) + ) + end +end diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 82d72119e..40df664eb 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -325,10 +325,10 @@ test "it streams the created ChatMessage" do stream: fn _, payload -> case payload do {^author, cm_ref} -> - assert cm_ref.seen == true + assert cm_ref.unread == false {^recipient, cm_ref} -> - assert cm_ref.seen == false + assert cm_ref.unread == true view = Pleroma.Web.PleromaAPI.ChatView.render("show.json", @@ -369,14 +369,14 @@ test "it creates a Chat and ChatMessageReferences for the local users and bumps [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() assert cm_ref.object.data["content"] == "hey" - assert cm_ref.seen == true + assert cm_ref.unread == false chat = Chat.get(recipient.id, author.ap_id) [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() assert cm_ref.object.data["content"] == "hey" - assert cm_ref.seen == false + assert cm_ref.unread == true end test "it creates a Chat for the local users and bumps the unread count" do diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index e7892142a..7af6dec1c 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -25,7 +25,7 @@ test "it marks one message as read", %{conn: conn, user: user} do object = Object.normalize(create, false) cm_ref = ChatMessageReference.for_chat_and_object(chat, object) - assert cm_ref.seen == false + assert cm_ref.unread == true result = conn @@ -36,7 +36,7 @@ test "it marks one message as read", %{conn: conn, user: user} do cm_ref = ChatMessageReference.for_chat_and_object(chat, object) - assert cm_ref.seen == true + assert cm_ref.unread == false end end @@ -52,7 +52,7 @@ test "it marks all messages in a chat as read", %{conn: conn, user: user} do object = Object.normalize(create, false) cm_ref = ChatMessageReference.for_chat_and_object(chat, object) - assert cm_ref.seen == false + assert cm_ref.unread == true result = conn @@ -63,7 +63,7 @@ test "it marks all messages in a chat as read", %{conn: conn, user: user} do cm_ref = ChatMessageReference.for_chat_and_object(chat, object) - assert cm_ref.seen == true + assert cm_ref.unread == false end end From 41503b167335a5f54eb122ecfd945018c9b50f90 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 4 Jun 2020 15:16:10 +0000 Subject: [PATCH 200/401] Apply suggestion to test/web/activity_pub/transmogrifier/chat_message_test.exs --- test/web/activity_pub/transmogrifier/chat_message_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs index 820090de3..d6736dc3e 100644 --- a/test/web/activity_pub/transmogrifier/chat_message_test.exs +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -13,7 +13,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageTest do alias Pleroma.Web.ActivityPub.Transmogrifier describe "handle_incoming" do - test "handles this" do + test "handles chonks with attachment" do data = %{ "@context" => "https://www.w3.org/ns/activitystreams", "actor" => "https://honk.tedunangst.com/u/tedu", From 9a53f619e03cd515460a7b1570d339e4554e1740 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 4 Jun 2020 15:16:15 +0000 Subject: [PATCH 201/401] Apply suggestion to test/chat_message_reference_test.exs --- test/chat_message_reference_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/chat_message_reference_test.exs b/test/chat_message_reference_test.exs index 963a0e225..66bf493b4 100644 --- a/test/chat_message_reference_test.exs +++ b/test/chat_message_reference_test.exs @@ -2,7 +2,7 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.ChatMessageReferencTest do +defmodule Pleroma.ChatMessageReferenceTest do use Pleroma.DataCase, async: true alias Pleroma.Chat From 56dfa0e0fb0ca34054930e64e092f4a3a1b87fd1 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 4 Jun 2020 19:22:49 +0200 Subject: [PATCH 202/401] Transmogrifier: Update notification after accepting. --- lib/pleroma/web/activity_pub/transmogrifier.ex | 1 + .../activity_pub/transmogrifier/follow_handling_test.exs | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 886403fcd..b2461de2b 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -538,6 +538,7 @@ def handle_incoming( {_, false} <- {:user_blocked, User.blocks?(followed, follower) && deny_follow_blocked}, {_, false} <- {:user_locked, User.locked?(followed)}, {_, {:ok, follower}} <- {:follow, User.follow(follower, followed)}, + _ <- Notification.update_notification_type(followed, activity), {_, {:ok, _}} <- {:follow_state_update, Utils.update_follow_state_for_all(activity, "accept")}, {:ok, _relationship} <- diff --git a/test/web/activity_pub/transmogrifier/follow_handling_test.exs b/test/web/activity_pub/transmogrifier/follow_handling_test.exs index 967389fae..6b003b51c 100644 --- a/test/web/activity_pub/transmogrifier/follow_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/follow_handling_test.exs @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.FollowHandlingTest do use Pleroma.DataCase alias Pleroma.Activity + alias Pleroma.Notification alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.ActivityPub.Transmogrifier @@ -57,9 +58,12 @@ test "it works for incoming follow requests" do activity = Repo.get(Activity, activity.id) assert activity.data["state"] == "accept" assert User.following?(User.get_cached_by_ap_id(data["actor"]), user) + + [notification] = Notification.for_user(user) + assert notification.type == "follow" end - test "with locked accounts, it does not create a follow or an accept" do + test "with locked accounts, it does create a Follow, but not an Accept" do user = insert(:user, locked: true) data = @@ -81,6 +85,9 @@ test "with locked accounts, it does not create a follow or an accept" do |> Repo.all() assert Enum.empty?(accepts) + + [notification] = Notification.for_user(user) + assert notification.type == "follow_request" end test "it works for follow requests when you are already followed, creating a new accept activity" do From 317e2b8d6126d86eafb493fe6c3b7a29af65ee21 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 4 Jun 2020 21:33:16 +0400 Subject: [PATCH 203/401] Use atoms as keys in `ActivityPub.fetch_*` functions options --- benchmarks/load_testing/fetcher.ex | 194 ++++---- .../mix/tasks/pleroma/benchmarks/tags.ex | 16 +- lib/pleroma/bbs/handler.ex | 8 +- lib/pleroma/conversation/participation.ex | 4 +- lib/pleroma/pagination.ex | 17 +- lib/pleroma/web/activity_pub/activity_pub.ex | 448 ++++++++---------- .../activity_pub/activity_pub_controller.ex | 16 +- lib/pleroma/web/activity_pub/utils.ex | 15 +- .../controllers/admin_api_controller.ex | 14 +- .../controllers/status_controller.ex | 10 +- .../web/admin_api/views/report_view.ex | 2 +- lib/pleroma/web/controller_helper.ex | 3 +- lib/pleroma/web/feed/tag_controller.ex | 4 +- lib/pleroma/web/feed/user_controller.ex | 6 +- .../controllers/account_controller.ex | 4 +- .../controllers/conversation_controller.ex | 17 - .../controllers/status_controller.ex | 11 +- .../controllers/timeline_controller.ex | 65 ++- .../mastodon_api/views/conversation_view.ex | 4 +- .../controllers/account_controller.ex | 7 +- .../controllers/conversation_controller.ex | 9 +- .../controllers/scrobble_controller.ex | 5 +- .../web/static_fe/static_fe_controller.ex | 8 +- test/pagination_test.exs | 12 +- test/tasks/relay_test.exs | 10 +- test/user_test.exs | 4 +- test/web/activity_pub/activity_pub_test.exs | 261 +++++----- 27 files changed, 538 insertions(+), 636 deletions(-) diff --git a/benchmarks/load_testing/fetcher.ex b/benchmarks/load_testing/fetcher.ex index 22a06e472..15fd06c3d 100644 --- a/benchmarks/load_testing/fetcher.ex +++ b/benchmarks/load_testing/fetcher.ex @@ -52,12 +52,12 @@ defp render_views(user) do defp opts_for_home_timeline(user) do %{ - "blocking_user" => user, - "count" => "20", - "muting_user" => user, - "type" => ["Create", "Announce"], - "user" => user, - "with_muted" => "true" + blocking_user: user, + count: "20", + muting_user: user, + type: ["Create", "Announce"], + user: user, + with_muted: true } end @@ -70,17 +70,17 @@ defp fetch_home_timeline(user) do ActivityPub.fetch_activities(recipients, opts) |> Enum.reverse() |> List.last() second_page_last = - ActivityPub.fetch_activities(recipients, Map.put(opts, "max_id", first_page_last.id)) + ActivityPub.fetch_activities(recipients, Map.put(opts, :max_id, first_page_last.id)) |> Enum.reverse() |> List.last() third_page_last = - ActivityPub.fetch_activities(recipients, Map.put(opts, "max_id", second_page_last.id)) + ActivityPub.fetch_activities(recipients, Map.put(opts, :max_id, second_page_last.id)) |> Enum.reverse() |> List.last() forth_page_last = - ActivityPub.fetch_activities(recipients, Map.put(opts, "max_id", third_page_last.id)) + ActivityPub.fetch_activities(recipients, Map.put(opts, :max_id, third_page_last.id)) |> Enum.reverse() |> List.last() @@ -90,19 +90,19 @@ defp fetch_home_timeline(user) do }, inputs: %{ "1 page" => opts, - "2 page" => Map.put(opts, "max_id", first_page_last.id), - "3 page" => Map.put(opts, "max_id", second_page_last.id), - "4 page" => Map.put(opts, "max_id", third_page_last.id), - "5 page" => Map.put(opts, "max_id", forth_page_last.id), - "1 page only media" => Map.put(opts, "only_media", "true"), + "2 page" => Map.put(opts, :max_id, first_page_last.id), + "3 page" => Map.put(opts, :max_id, second_page_last.id), + "4 page" => Map.put(opts, :max_id, third_page_last.id), + "5 page" => Map.put(opts, :max_id, forth_page_last.id), + "1 page only media" => Map.put(opts, :only_media, true), "2 page only media" => - Map.put(opts, "max_id", first_page_last.id) |> Map.put("only_media", "true"), + Map.put(opts, :max_id, first_page_last.id) |> Map.put(:only_media, true), "3 page only media" => - Map.put(opts, "max_id", second_page_last.id) |> Map.put("only_media", "true"), + Map.put(opts, :max_id, second_page_last.id) |> Map.put(:only_media, true), "4 page only media" => - Map.put(opts, "max_id", third_page_last.id) |> Map.put("only_media", "true"), + Map.put(opts, :max_id, third_page_last.id) |> Map.put(:only_media, true), "5 page only media" => - Map.put(opts, "max_id", forth_page_last.id) |> Map.put("only_media", "true") + Map.put(opts, :max_id, forth_page_last.id) |> Map.put(:only_media, true) }, formatters: formatters() ) @@ -110,12 +110,12 @@ defp fetch_home_timeline(user) do defp opts_for_direct_timeline(user) do %{ - :visibility => "direct", - "blocking_user" => user, - "count" => "20", - "type" => "Create", - "user" => user, - "with_muted" => "true" + visibility: "direct", + blocking_user: user, + count: "20", + type: "Create", + user: user, + with_muted: true } end @@ -130,7 +130,7 @@ defp fetch_direct_timeline(user) do |> Pagination.fetch_paginated(opts) |> List.last() - opts2 = Map.put(opts, "max_id", first_page_last.id) + opts2 = Map.put(opts, :max_id, first_page_last.id) second_page_last = recipients @@ -138,7 +138,7 @@ defp fetch_direct_timeline(user) do |> Pagination.fetch_paginated(opts2) |> List.last() - opts3 = Map.put(opts, "max_id", second_page_last.id) + opts3 = Map.put(opts, :max_id, second_page_last.id) third_page_last = recipients @@ -146,7 +146,7 @@ defp fetch_direct_timeline(user) do |> Pagination.fetch_paginated(opts3) |> List.last() - opts4 = Map.put(opts, "max_id", third_page_last.id) + opts4 = Map.put(opts, :max_id, third_page_last.id) forth_page_last = recipients @@ -165,7 +165,7 @@ defp fetch_direct_timeline(user) do "2 page" => opts2, "3 page" => opts3, "4 page" => opts4, - "5 page" => Map.put(opts4, "max_id", forth_page_last.id) + "5 page" => Map.put(opts4, :max_id, forth_page_last.id) }, formatters: formatters() ) @@ -173,34 +173,34 @@ defp fetch_direct_timeline(user) do defp opts_for_public_timeline(user) do %{ - "type" => ["Create", "Announce"], - "local_only" => false, - "blocking_user" => user, - "muting_user" => user + type: ["Create", "Announce"], + local_only: false, + blocking_user: user, + muting_user: user } end defp opts_for_public_timeline(user, :local) do %{ - "type" => ["Create", "Announce"], - "local_only" => true, - "blocking_user" => user, - "muting_user" => user + type: ["Create", "Announce"], + local_only: true, + blocking_user: user, + muting_user: user } end defp opts_for_public_timeline(user, :tag) do %{ - "blocking_user" => user, - "count" => "20", - "local_only" => nil, - "muting_user" => user, - "tag" => ["tag"], - "tag_all" => [], - "tag_reject" => [], - "type" => "Create", - "user" => user, - "with_muted" => "true" + blocking_user: user, + count: "20", + local_only: nil, + muting_user: user, + tag: ["tag"], + tag_all: [], + tag_reject: [], + type: "Create", + user: user, + with_muted: true } end @@ -223,7 +223,7 @@ defp fetch_public_timeline(user, :tag) do end defp fetch_public_timeline(user, :only_media) do - opts = opts_for_public_timeline(user) |> Map.put("only_media", "true") + opts = opts_for_public_timeline(user) |> Map.put(:only_media, true) fetch_public_timeline(opts, "public timeline only media") end @@ -245,15 +245,13 @@ defp fetch_public_timeline(user, :with_blocks) do user = User.get_by_id(user.id) - opts = Map.put(opts, "blocking_user", user) + opts = Map.put(opts, :blocking_user, user) - Benchee.run( - %{ - "public timeline with user block" => fn -> - ActivityPub.fetch_public_activities(opts) - end - }, - ) + Benchee.run(%{ + "public timeline with user block" => fn -> + ActivityPub.fetch_public_activities(opts) + end + }) domains = Enum.reduce(remote_non_friends, [], fn non_friend, domains -> @@ -269,30 +267,28 @@ defp fetch_public_timeline(user, :with_blocks) do end) user = User.get_by_id(user.id) - opts = Map.put(opts, "blocking_user", user) + opts = Map.put(opts, :blocking_user, user) - Benchee.run( - %{ - "public timeline with domain block" => fn opts -> - ActivityPub.fetch_public_activities(opts) - end - } - ) + Benchee.run(%{ + "public timeline with domain block" => fn -> + ActivityPub.fetch_public_activities(opts) + end + }) end defp fetch_public_timeline(opts, title) when is_binary(title) do first_page_last = ActivityPub.fetch_public_activities(opts) |> List.last() second_page_last = - ActivityPub.fetch_public_activities(Map.put(opts, "max_id", first_page_last.id)) + ActivityPub.fetch_public_activities(Map.put(opts, :max_id, first_page_last.id)) |> List.last() third_page_last = - ActivityPub.fetch_public_activities(Map.put(opts, "max_id", second_page_last.id)) + ActivityPub.fetch_public_activities(Map.put(opts, :max_id, second_page_last.id)) |> List.last() forth_page_last = - ActivityPub.fetch_public_activities(Map.put(opts, "max_id", third_page_last.id)) + ActivityPub.fetch_public_activities(Map.put(opts, :max_id, third_page_last.id)) |> List.last() Benchee.run( @@ -303,17 +299,17 @@ defp fetch_public_timeline(opts, title) when is_binary(title) do }, inputs: %{ "1 page" => opts, - "2 page" => Map.put(opts, "max_id", first_page_last.id), - "3 page" => Map.put(opts, "max_id", second_page_last.id), - "4 page" => Map.put(opts, "max_id", third_page_last.id), - "5 page" => Map.put(opts, "max_id", forth_page_last.id) + "2 page" => Map.put(opts, :max_id, first_page_last.id), + "3 page" => Map.put(opts, :max_id, second_page_last.id), + "4 page" => Map.put(opts, :max_id, third_page_last.id), + "5 page" => Map.put(opts, :max_id, forth_page_last.id) }, formatters: formatters() ) end defp opts_for_notifications do - %{"count" => "20", "with_muted" => "true"} + %{count: "20", with_muted: true} end defp fetch_notifications(user) do @@ -322,15 +318,15 @@ defp fetch_notifications(user) do first_page_last = MastodonAPI.get_notifications(user, opts) |> List.last() second_page_last = - MastodonAPI.get_notifications(user, Map.put(opts, "max_id", first_page_last.id)) + MastodonAPI.get_notifications(user, Map.put(opts, :max_id, first_page_last.id)) |> List.last() third_page_last = - MastodonAPI.get_notifications(user, Map.put(opts, "max_id", second_page_last.id)) + MastodonAPI.get_notifications(user, Map.put(opts, :max_id, second_page_last.id)) |> List.last() forth_page_last = - MastodonAPI.get_notifications(user, Map.put(opts, "max_id", third_page_last.id)) + MastodonAPI.get_notifications(user, Map.put(opts, :max_id, third_page_last.id)) |> List.last() Benchee.run( @@ -341,10 +337,10 @@ defp fetch_notifications(user) do }, inputs: %{ "1 page" => opts, - "2 page" => Map.put(opts, "max_id", first_page_last.id), - "3 page" => Map.put(opts, "max_id", second_page_last.id), - "4 page" => Map.put(opts, "max_id", third_page_last.id), - "5 page" => Map.put(opts, "max_id", forth_page_last.id) + "2 page" => Map.put(opts, :max_id, first_page_last.id), + "3 page" => Map.put(opts, :max_id, second_page_last.id), + "4 page" => Map.put(opts, :max_id, third_page_last.id), + "5 page" => Map.put(opts, :max_id, forth_page_last.id) }, formatters: formatters() ) @@ -354,13 +350,13 @@ defp fetch_favourites(user) do first_page_last = ActivityPub.fetch_favourites(user) |> List.last() second_page_last = - ActivityPub.fetch_favourites(user, %{"max_id" => first_page_last.id}) |> List.last() + ActivityPub.fetch_favourites(user, %{:max_id => first_page_last.id}) |> List.last() third_page_last = - ActivityPub.fetch_favourites(user, %{"max_id" => second_page_last.id}) |> List.last() + ActivityPub.fetch_favourites(user, %{:max_id => second_page_last.id}) |> List.last() forth_page_last = - ActivityPub.fetch_favourites(user, %{"max_id" => third_page_last.id}) |> List.last() + ActivityPub.fetch_favourites(user, %{:max_id => third_page_last.id}) |> List.last() Benchee.run( %{ @@ -370,10 +366,10 @@ defp fetch_favourites(user) do }, inputs: %{ "1 page" => %{}, - "2 page" => %{"max_id" => first_page_last.id}, - "3 page" => %{"max_id" => second_page_last.id}, - "4 page" => %{"max_id" => third_page_last.id}, - "5 page" => %{"max_id" => forth_page_last.id} + "2 page" => %{:max_id => first_page_last.id}, + "3 page" => %{:max_id => second_page_last.id}, + "4 page" => %{:max_id => third_page_last.id}, + "5 page" => %{:max_id => forth_page_last.id} }, formatters: formatters() ) @@ -381,8 +377,8 @@ defp fetch_favourites(user) do defp opts_for_long_thread(user) do %{ - "blocking_user" => user, - "user" => user + blocking_user: user, + user: user } end @@ -392,9 +388,9 @@ defp fetch_long_thread(user) do opts = opts_for_long_thread(user) - private_input = {private.data["context"], Map.put(opts, "exclude_id", private.id)} + private_input = {private.data["context"], Map.put(opts, :exclude_id, private.id)} - public_input = {public.data["context"], Map.put(opts, "exclude_id", public.id)} + public_input = {public.data["context"], Map.put(opts, :exclude_id, public.id)} Benchee.run( %{ @@ -514,13 +510,13 @@ defp render_long_thread(user) do public_context = ActivityPub.fetch_activities_for_context( public.data["context"], - Map.put(fetch_opts, "exclude_id", public.id) + Map.put(fetch_opts, :exclude_id, public.id) ) private_context = ActivityPub.fetch_activities_for_context( private.data["context"], - Map.put(fetch_opts, "exclude_id", private.id) + Map.put(fetch_opts, :exclude_id, private.id) ) Benchee.run( @@ -551,14 +547,14 @@ defp fetch_timelines_with_reply_filtering(user) do end, "Public timeline with reply filtering - following" => fn -> public_params - |> Map.put("reply_visibility", "following") - |> Map.put("reply_filtering_user", user) + |> Map.put(:reply_visibility, "following") + |> Map.put(:reply_filtering_user, user) |> ActivityPub.fetch_public_activities() end, "Public timeline with reply filtering - self" => fn -> public_params - |> Map.put("reply_visibility", "self") - |> Map.put("reply_filtering_user", user) + |> Map.put(:reply_visibility, "self") + |> Map.put(:reply_filtering_user, user) |> ActivityPub.fetch_public_activities() end }, @@ -577,16 +573,16 @@ defp fetch_timelines_with_reply_filtering(user) do "Home timeline with reply filtering - following" => fn -> private_params = private_params - |> Map.put("reply_filtering_user", user) - |> Map.put("reply_visibility", "following") + |> Map.put(:reply_filtering_user, user) + |> Map.put(:reply_visibility, "following") ActivityPub.fetch_activities(recipients, private_params) end, "Home timeline with reply filtering - self" => fn -> private_params = private_params - |> Map.put("reply_filtering_user", user) - |> Map.put("reply_visibility", "self") + |> Map.put(:reply_filtering_user, user) + |> Map.put(:reply_visibility, "self") ActivityPub.fetch_activities(recipients, private_params) end diff --git a/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex b/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex index 1162b2e06..c051335a5 100644 --- a/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex +++ b/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex @@ -100,14 +100,14 @@ defp hashtag_fetching(params, user, local_only) do _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) + |> 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) |> Pleroma.Web.ActivityPub.ActivityPub.fetch_public_activities() end end diff --git a/lib/pleroma/bbs/handler.ex b/lib/pleroma/bbs/handler.ex index 12d64c2fe..cd523cf7d 100644 --- a/lib/pleroma/bbs/handler.ex +++ b/lib/pleroma/bbs/handler.ex @@ -92,10 +92,10 @@ def handle_command(state, "home") do params = %{} - |> Map.put("type", ["Create"]) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) + |> Map.put(:type, ["Create"]) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) activities = [user.ap_id | Pleroma.User.following(user)] diff --git a/lib/pleroma/conversation/participation.ex b/lib/pleroma/conversation/participation.ex index 51bb1bda9..ce7bd2396 100644 --- a/lib/pleroma/conversation/participation.ex +++ b/lib/pleroma/conversation/participation.ex @@ -163,8 +163,8 @@ def for_user_with_last_activity_id(user, params \\ %{}) do |> Enum.map(fn participation -> activity_id = ActivityPub.fetch_latest_activity_id_for_context(participation.conversation.ap_id, %{ - "user" => user, - "blocking_user" => user + user: user, + blocking_user: user }) %{ diff --git a/lib/pleroma/pagination.ex b/lib/pleroma/pagination.ex index d43a96cd2..0ccc7b1f2 100644 --- a/lib/pleroma/pagination.ex +++ b/lib/pleroma/pagination.ex @@ -16,19 +16,16 @@ defmodule Pleroma.Pagination do @default_limit 20 @max_limit 40 - @page_keys ["max_id", "min_id", "limit", "since_id", "order"] - - def page_keys, do: @page_keys @spec fetch_paginated(Ecto.Query.t(), map(), type(), atom() | nil) :: [Ecto.Schema.t()] def fetch_paginated(query, params, type \\ :keyset, table_binding \\ nil) - def fetch_paginated(query, %{"total" => true} = params, :keyset, table_binding) do + def fetch_paginated(query, %{total: true} = params, :keyset, table_binding) do total = Repo.aggregate(query, :count, :id) %{ total: total, - items: fetch_paginated(query, Map.drop(params, ["total"]), :keyset, table_binding) + items: fetch_paginated(query, Map.drop(params, [:total]), :keyset, table_binding) } end @@ -41,7 +38,7 @@ def fetch_paginated(query, params, :keyset, table_binding) do |> enforce_order(options) end - def fetch_paginated(query, %{"total" => true} = params, :offset, table_binding) do + def fetch_paginated(query, %{total: true} = params, :offset, table_binding) do total = query |> Ecto.Query.exclude(:left_join) @@ -49,7 +46,7 @@ def fetch_paginated(query, %{"total" => true} = params, :offset, table_binding) %{ total: total, - items: fetch_paginated(query, Map.drop(params, ["total"]), :offset, table_binding) + items: fetch_paginated(query, Map.drop(params, [:total]), :offset, table_binding) } end @@ -90,12 +87,6 @@ defp cast_params(params) do skip_order: :boolean } - params = - Enum.reduce(params, %{}, fn - {key, _value}, acc when is_atom(key) -> Map.drop(acc, [key]) - {key, value}, acc -> Map.put(acc, key, value) - end) - changeset = cast({%{}, param_types}, params, Map.keys(param_types)) changeset.changes end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 958f3e5af..ef21f180b 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -67,16 +67,12 @@ defp get_recipients(data) do {recipients, to, cc} end - defp check_actor_is_active(actor) do - if not is_nil(actor) do - with user <- User.get_cached_by_ap_id(actor), - false <- user.deactivated do - true - else - _e -> false - end - else - true + defp check_actor_is_active(nil), do: true + + defp check_actor_is_active(actor) when is_binary(actor) do + case User.get_cached_by_ap_id(actor) do + %User{deactivated: deactivated} -> not deactivated + _ -> false end end @@ -87,7 +83,7 @@ defp check_remote_limit(%{"object" => %{"content" => content}}) when not is_nil( defp check_remote_limit(_), do: true - def increase_note_count_if_public(actor, object) do + defp increase_note_count_if_public(actor, object) do if is_public?(object), do: User.increase_note_count(actor), else: {:ok, actor} end @@ -95,36 +91,26 @@ def decrease_note_count_if_public(actor, object) do if is_public?(object), do: User.decrease_note_count(actor), else: {:ok, actor} end - def increase_replies_count_if_reply(%{ - "object" => %{"inReplyTo" => reply_ap_id} = object, - "type" => "Create" - }) do + defp increase_replies_count_if_reply(%{ + "object" => %{"inReplyTo" => reply_ap_id} = object, + "type" => "Create" + }) do if is_public?(object) do Object.increase_replies_count(reply_ap_id) end end - def increase_replies_count_if_reply(_create_data), do: :noop + defp increase_replies_count_if_reply(_create_data), do: :noop - def decrease_replies_count_if_reply(%Object{ - data: %{"inReplyTo" => reply_ap_id} = object - }) do - if is_public?(object) do - Object.decrease_replies_count(reply_ap_id) - end - end - - def decrease_replies_count_if_reply(_object), do: :noop - - def increase_poll_votes_if_vote(%{ - "object" => %{"inReplyTo" => reply_ap_id, "name" => name}, - "type" => "Create", - "actor" => actor - }) do + defp increase_poll_votes_if_vote(%{ + "object" => %{"inReplyTo" => reply_ap_id, "name" => name}, + "type" => "Create", + "actor" => actor + }) do Object.increase_vote_count(reply_ap_id, name, actor) end - def increase_poll_votes_if_vote(_create_data), do: :noop + defp increase_poll_votes_if_vote(_create_data), do: :noop @spec persist(map(), keyword()) :: {:ok, Activity.t() | Object.t()} def persist(object, meta) do @@ -203,8 +189,8 @@ def notify_and_stream(activity) do defp create_or_bump_conversation(activity, actor) do with {:ok, conversation} <- Conversation.create_or_bump_for(activity), - %User{} = user <- User.get_cached_by_ap_id(actor), - Participation.mark_as_read(user, conversation) do + %User{} = user <- User.get_cached_by_ap_id(actor) do + Participation.mark_as_read(user, conversation) {:ok, conversation} end end @@ -226,13 +212,15 @@ def stream_out_participations(participations) do end def stream_out_participations(%Object{data: %{"context" => context}}, user) do - with %Conversation{} = conversation <- Conversation.get_for_ap_id(context), - conversation = Repo.preload(conversation, :participations), - last_activity_id = - fetch_latest_activity_id_for_context(conversation.ap_id, %{ - "user" => user, - "blocking_user" => user - }) do + with %Conversation{} = conversation <- Conversation.get_for_ap_id(context) do + conversation = Repo.preload(conversation, :participations) + + last_activity_id = + fetch_latest_activity_id_for_context(conversation.ap_id, %{ + user: user, + blocking_user: user + }) + if last_activity_id do stream_out_participations(conversation.participations) end @@ -266,12 +254,13 @@ defp do_create(%{to: to, actor: actor, context: context, object: object} = param published = params[:published] quick_insert? = Config.get([:env]) == :benchmark - with create_data <- - make_create_data( - %{to: to, actor: actor, published: published, context: context, object: object}, - additional - ), - {:ok, activity} <- insert(create_data, local, fake), + create_data = + make_create_data( + %{to: to, actor: actor, published: published, context: context, object: object}, + additional + ) + + with {:ok, activity} <- insert(create_data, local, fake), {:fake, false, activity} <- {:fake, fake, activity}, _ <- increase_replies_count_if_reply(create_data), _ <- increase_poll_votes_if_vote(create_data), @@ -299,12 +288,13 @@ def listen(%{to: to, actor: actor, context: context, object: object} = params) d 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), + listen_data = + make_listen_data( + %{to: to, actor: actor, published: published, context: context, object: object}, + additional + ) + + with {:ok, activity} <- insert(listen_data, local), _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} @@ -322,14 +312,15 @@ def reject(params) do end @spec accept_or_reject(String.t(), map()) :: {:ok, Activity.t()} | {:error, any()} - def accept_or_reject(type, %{to: to, actor: actor, object: object} = params) do + defp accept_or_reject(type, %{to: to, actor: actor, object: object} = params) do local = Map.get(params, :local, true) activity_id = Map.get(params, :activity_id, nil) - with data <- - %{"to" => to, "type" => type, "actor" => actor.ap_id, "object" => object} - |> Utils.maybe_put("id", activity_id), - {:ok, activity} <- insert(data, local), + data = + %{"to" => to, "type" => type, "actor" => actor.ap_id, "object" => object} + |> Utils.maybe_put("id", activity_id) + + with {:ok, activity} <- insert(data, local), _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} @@ -341,15 +332,17 @@ def update(%{to: to, cc: cc, actor: actor, object: object} = params) do local = !(params[:local] == false) activity_id = params[:activity_id] - with data <- %{ - "to" => to, - "cc" => cc, - "type" => "Update", - "actor" => actor, - "object" => object - }, - data <- Utils.maybe_put(data, "id", activity_id), - {:ok, activity} <- insert(data, local), + data = + %{ + "to" => to, + "cc" => cc, + "type" => "Update", + "actor" => actor, + "object" => object + } + |> Utils.maybe_put("id", activity_id) + + with {:ok, activity} <- insert(data, local), _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} @@ -366,8 +359,9 @@ def follow(follower, followed, activity_id \\ nil, local \\ true) do end defp do_follow(follower, followed, activity_id, local) do - with data <- make_follow_data(follower, followed, activity_id), - {:ok, activity} <- insert(data, local), + data = make_follow_data(follower, followed, activity_id) + + with {:ok, activity} <- insert(data, local), _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} @@ -411,13 +405,13 @@ def block(blocker, blocked, activity_id \\ nil, local \\ true) do defp do_block(blocker, blocked, activity_id, local) do unfollow_blocked = Config.get([:activitypub, :unfollow_blocked]) - if unfollow_blocked do - follow_activity = fetch_latest_follow(blocker, blocked) - if follow_activity, do: unfollow(blocker, blocked, nil, local) + if unfollow_blocked and fetch_latest_follow(blocker, blocked) do + unfollow(blocker, blocked, nil, local) end - with block_data <- make_block_data(blocker, blocked, activity_id), - {:ok, activity} <- insert(block_data, local), + block_data = make_block_data(blocker, blocked, activity_id) + + with {:ok, activity} <- insert(block_data, local), _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} @@ -496,8 +490,8 @@ def fetch_activities_for_context_query(context, opts) do public = [Constants.as_public()] recipients = - if opts["user"], - do: [opts["user"].ap_id | User.following(opts["user"])] ++ public, + if opts[:user], + do: [opts[:user].ap_id | User.following(opts[:user])] ++ public, else: public from(activity in Activity) @@ -505,7 +499,7 @@ def fetch_activities_for_context_query(context, opts) do |> maybe_preload_bookmarks(opts) |> maybe_set_thread_muted_field(opts) |> restrict_blocked(opts) - |> restrict_recipients(recipients, opts["user"]) + |> restrict_recipients(recipients, opts[:user]) |> where( [activity], fragment( @@ -532,7 +526,7 @@ def fetch_activities_for_context(context, opts \\ %{}) do 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)) + |> fetch_activities_for_context_query(Map.merge(%{skip_preload: true}, opts)) |> limit(1) |> select([a], a.id) |> Repo.one() @@ -540,24 +534,18 @@ def fetch_latest_activity_id_for_context(context, opts \\ %{}) do @spec fetch_public_or_unlisted_activities(map(), Pagination.type()) :: [Activity.t()] def fetch_public_or_unlisted_activities(opts \\ %{}, pagination \\ :keyset) do - opts = Map.drop(opts, ["user"]) + opts = Map.delete(opts, :user) - query = fetch_activities_query([Constants.as_public()], opts) - - query = - if opts["restrict_unlisted"] do - restrict_unlisted(query) - else - query - end - - Pagination.fetch_paginated(query, opts, pagination) + [Constants.as_public()] + |> fetch_activities_query(opts) + |> restrict_unlisted(opts) + |> Pagination.fetch_paginated(opts, pagination) end @spec fetch_public_activities(map(), Pagination.type()) :: [Activity.t()] def fetch_public_activities(opts \\ %{}, pagination \\ :keyset) do opts - |> Map.put("restrict_unlisted", true) + |> Map.put(:restrict_unlisted, true) |> fetch_public_or_unlisted_activities(pagination) end @@ -566,20 +554,17 @@ def fetch_public_activities(opts \\ %{}, pagination \\ :keyset) do defp restrict_visibility(query, %{visibility: visibility}) when is_list(visibility) do if Enum.all?(visibility, &(&1 in @valid_visibilities)) do - query = - from( - a in query, - where: - fragment( - "activity_visibility(?, ?, ?) = ANY (?)", - a.actor, - a.recipients, - a.data, - ^visibility - ) - ) - - query + from( + a in query, + where: + fragment( + "activity_visibility(?, ?, ?) = ANY (?)", + a.actor, + a.recipients, + a.data, + ^visibility + ) + ) else Logger.error("Could not restrict visibility to #{visibility}") end @@ -601,7 +586,7 @@ defp restrict_visibility(_query, %{visibility: visibility}) defp restrict_visibility(query, _visibility), do: query - defp exclude_visibility(query, %{"exclude_visibilities" => visibility}) + defp exclude_visibility(query, %{exclude_visibilities: visibility}) when is_list(visibility) do if Enum.all?(visibility, &(&1 in @valid_visibilities)) do from( @@ -621,7 +606,7 @@ defp exclude_visibility(query, %{"exclude_visibilities" => visibility}) end end - defp exclude_visibility(query, %{"exclude_visibilities" => visibility}) + defp exclude_visibility(query, %{exclude_visibilities: visibility}) when visibility in @valid_visibilities do from( a in query, @@ -636,7 +621,7 @@ defp exclude_visibility(query, %{"exclude_visibilities" => visibility}) ) end - defp exclude_visibility(query, %{"exclude_visibilities" => visibility}) + defp exclude_visibility(query, %{exclude_visibilities: visibility}) when visibility not in [nil | @valid_visibilities] do Logger.error("Could not exclude visibility to #{visibility}") query @@ -647,14 +632,10 @@ defp exclude_visibility(query, _visibility), do: query defp restrict_thread_visibility(query, _, %{skip_thread_containment: true} = _), do: query - defp restrict_thread_visibility( - query, - %{"user" => %User{skip_thread_containment: true}}, - _ - ), - do: query + defp restrict_thread_visibility(query, %{user: %User{skip_thread_containment: true}}, _), + do: query - defp restrict_thread_visibility(query, %{"user" => %User{ap_id: ap_id}}, _) do + defp restrict_thread_visibility(query, %{user: %User{ap_id: ap_id}}, _) do from( a in query, where: fragment("thread_visibility(?, (?)->>'id') = true", ^ap_id, a.data) @@ -666,87 +647,79 @@ 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(:user, reading_user) + |> Map.put(:actor_id, user.ap_id) - recipients = - user_activities_recipients(%{ - "godmode" => params["godmode"], - "reading_user" => reading_user - }) - - fetch_activities(recipients, params) + %{ + godmode: params[:godmode], + reading_user: reading_user + } + |> user_activities_recipients() + |> fetch_activities(params) |> Enum.reverse() end def fetch_user_activities(user, reading_user, params \\ %{}) do params = params - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("user", reading_user) - |> Map.put("actor_id", user.ap_id) - |> Map.put("pinned_activity_ids", user.pinned_activities) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:user, reading_user) + |> Map.put(:actor_id, user.ap_id) + |> Map.put(:pinned_activity_ids, user.pinned_activities) params = if User.blocks?(reading_user, user) do params else params - |> Map.put("blocking_user", reading_user) - |> Map.put("muting_user", reading_user) + |> Map.put(:blocking_user, reading_user) + |> Map.put(:muting_user, reading_user) end - recipients = - user_activities_recipients(%{ - "godmode" => params["godmode"], - "reading_user" => reading_user - }) - - fetch_activities(recipients, params) + %{ + godmode: params[:godmode], + reading_user: reading_user + } + |> user_activities_recipients() + |> fetch_activities(params) |> Enum.reverse() end def fetch_statuses(reading_user, params) do - params = - params - |> Map.put("type", ["Create", "Announce"]) + params = Map.put(params, :type, ["Create", "Announce"]) - recipients = - user_activities_recipients(%{ - "godmode" => params["godmode"], - "reading_user" => reading_user - }) - - fetch_activities(recipients, params, :offset) + %{ + godmode: params[:godmode], + reading_user: reading_user + } + |> user_activities_recipients() + |> fetch_activities(params, :offset) |> Enum.reverse() end - defp user_activities_recipients(%{"godmode" => true}) do - [] - end + defp user_activities_recipients(%{godmode: true}), do: [] - defp user_activities_recipients(%{"reading_user" => reading_user}) do + defp user_activities_recipients(%{reading_user: reading_user}) do if reading_user do - [Constants.as_public()] ++ [reading_user.ap_id | User.following(reading_user)] + [Constants.as_public(), reading_user.ap_id | User.following(reading_user)] else [Constants.as_public()] end end - defp restrict_since(query, %{"since_id" => ""}), do: query + defp restrict_since(query, %{since_id: ""}), do: query - defp restrict_since(query, %{"since_id" => since_id}) do + defp restrict_since(query, %{since_id: since_id}) do from(activity in query, where: activity.id > ^since_id) end defp restrict_since(query, _), do: query - defp restrict_tag_reject(_query, %{"tag_reject" => _tag_reject, "skip_preload" => true}) do + defp restrict_tag_reject(_query, %{tag_reject: _tag_reject, skip_preload: true}) do raise "Can't use the child object without preloading!" end - defp restrict_tag_reject(query, %{"tag_reject" => tag_reject}) - when is_list(tag_reject) and tag_reject != [] do + defp restrict_tag_reject(query, %{tag_reject: [_ | _] = tag_reject}) do from( [_activity, object] in query, where: fragment("not (?)->'tag' \\?| (?)", object.data, ^tag_reject) @@ -755,12 +728,11 @@ defp restrict_tag_reject(query, %{"tag_reject" => tag_reject}) defp restrict_tag_reject(query, _), do: query - defp restrict_tag_all(_query, %{"tag_all" => _tag_all, "skip_preload" => true}) do + defp restrict_tag_all(_query, %{tag_all: _tag_all, skip_preload: true}) do raise "Can't use the child object without preloading!" end - defp restrict_tag_all(query, %{"tag_all" => tag_all}) - when is_list(tag_all) and tag_all != [] do + defp restrict_tag_all(query, %{tag_all: [_ | _] = tag_all}) do from( [_activity, object] in query, where: fragment("(?)->'tag' \\?& (?)", object.data, ^tag_all) @@ -769,18 +741,18 @@ defp restrict_tag_all(query, %{"tag_all" => tag_all}) defp restrict_tag_all(query, _), do: query - defp restrict_tag(_query, %{"tag" => _tag, "skip_preload" => true}) do + defp restrict_tag(_query, %{tag: _tag, skip_preload: true}) do raise "Can't use the child object without preloading!" end - defp restrict_tag(query, %{"tag" => tag}) when is_list(tag) do + defp restrict_tag(query, %{tag: tag}) when is_list(tag) do from( [_activity, object] in query, where: fragment("(?)->'tag' \\?| (?)", object.data, ^tag) ) end - defp restrict_tag(query, %{"tag" => tag}) when is_binary(tag) do + defp restrict_tag(query, %{tag: tag}) when is_binary(tag) do from( [_activity, object] in query, where: fragment("(?)->'tag' \\? (?)", object.data, ^tag) @@ -803,35 +775,35 @@ defp restrict_recipients(query, recipients, user) do ) end - defp restrict_local(query, %{"local_only" => true}) do + defp restrict_local(query, %{local_only: true}) do from(activity in query, where: activity.local == true) end defp restrict_local(query, _), do: query - defp restrict_actor(query, %{"actor_id" => actor_id}) do + defp restrict_actor(query, %{actor_id: actor_id}) do from(activity in query, where: activity.actor == ^actor_id) end defp restrict_actor(query, _), do: query - defp restrict_type(query, %{"type" => type}) when is_binary(type) do + defp restrict_type(query, %{type: type}) when is_binary(type) do from(activity in query, where: fragment("?->>'type' = ?", activity.data, ^type)) end - defp restrict_type(query, %{"type" => type}) do + defp restrict_type(query, %{type: type}) do from(activity in query, where: fragment("?->>'type' = ANY(?)", activity.data, ^type)) end defp restrict_type(query, _), do: query - defp restrict_state(query, %{"state" => state}) do + defp restrict_state(query, %{state: state}) do from(activity in query, where: fragment("?->>'state' = ?", activity.data, ^state)) end defp restrict_state(query, _), do: query - defp restrict_favorited_by(query, %{"favorited_by" => ap_id}) do + defp restrict_favorited_by(query, %{favorited_by: ap_id}) do from( [_activity, object] in query, where: fragment("(?)->'likes' \\? (?)", object.data, ^ap_id) @@ -840,11 +812,11 @@ defp restrict_favorited_by(query, %{"favorited_by" => ap_id}) do defp restrict_favorited_by(query, _), do: query - defp restrict_media(_query, %{"only_media" => _val, "skip_preload" => true}) do + defp restrict_media(_query, %{only_media: _val, skip_preload: true}) do raise "Can't use the child object without preloading!" end - defp restrict_media(query, %{"only_media" => val}) when val in [true, "true", "1"] do + defp restrict_media(query, %{only_media: true}) do from( [_activity, object] in query, where: fragment("not (?)->'attachment' = (?)", object.data, ^[]) @@ -853,7 +825,7 @@ defp restrict_media(query, %{"only_media" => val}) when val in [true, "true", "1 defp restrict_media(query, _), do: query - defp restrict_replies(query, %{"exclude_replies" => val}) when val in [true, "true", "1"] do + defp restrict_replies(query, %{exclude_replies: true}) do from( [_activity, object] in query, where: fragment("?->>'inReplyTo' is null", object.data) @@ -861,8 +833,8 @@ defp restrict_replies(query, %{"exclude_replies" => val}) when val in [true, "tr end defp restrict_replies(query, %{ - "reply_filtering_user" => user, - "reply_visibility" => "self" + reply_filtering_user: user, + reply_visibility: "self" }) do from( [activity, object] in query, @@ -877,8 +849,8 @@ defp restrict_replies(query, %{ end defp restrict_replies(query, %{ - "reply_filtering_user" => user, - "reply_visibility" => "following" + reply_filtering_user: user, + reply_visibility: "following" }) do from( [activity, object] in query, @@ -897,16 +869,16 @@ defp restrict_replies(query, %{ defp restrict_replies(query, _), do: query - defp restrict_reblogs(query, %{"exclude_reblogs" => val}) when val in [true, "true", "1"] do + defp restrict_reblogs(query, %{exclude_reblogs: true}) do from(activity in query, where: fragment("?->>'type' != 'Announce'", activity.data)) end defp restrict_reblogs(query, _), do: query - defp restrict_muted(query, %{"with_muted" => val}) when val in [true, "true", "1"], do: query + defp restrict_muted(query, %{with_muted: true}), do: query - defp restrict_muted(query, %{"muting_user" => %User{} = user} = opts) do - mutes = opts["muted_users_ap_ids"] || User.muted_users_ap_ids(user) + defp restrict_muted(query, %{muting_user: %User{} = user} = opts) do + mutes = opts[:muted_users_ap_ids] || User.muted_users_ap_ids(user) query = from([activity] in query, @@ -914,7 +886,7 @@ defp restrict_muted(query, %{"muting_user" => %User{} = user} = opts) do where: fragment("not (?->'to' \\?| ?)", activity.data, ^mutes) ) - unless opts["skip_preload"] do + unless opts[:skip_preload] do from([thread_mute: tm] in query, where: is_nil(tm.user_id)) else query @@ -923,8 +895,8 @@ defp restrict_muted(query, %{"muting_user" => %User{} = user} = opts) do defp restrict_muted(query, _), do: query - defp restrict_blocked(query, %{"blocking_user" => %User{} = user} = opts) do - blocked_ap_ids = opts["blocked_users_ap_ids"] || User.blocked_users_ap_ids(user) + defp restrict_blocked(query, %{blocking_user: %User{} = user} = opts) do + blocked_ap_ids = opts[:blocked_users_ap_ids] || User.blocked_users_ap_ids(user) domain_blocks = user.domain_blocks || [] following_ap_ids = User.get_friends_ap_ids(user) @@ -970,7 +942,7 @@ defp restrict_blocked(query, %{"blocking_user" => %User{} = user} = opts) do defp restrict_blocked(query, _), do: query - defp restrict_unlisted(query) do + defp restrict_unlisted(query, %{restrict_unlisted: true}) do from( activity in query, where: @@ -982,19 +954,16 @@ defp restrict_unlisted(query) do ) end - # TODO: when all endpoints migrated to OpenAPI compare `pinned` with `true` (boolean) only, - # the same for `restrict_media/2`, `restrict_replies/2`, 'restrict_reblogs/2' - # and `restrict_muted/2` + defp restrict_unlisted(query, _), do: query - defp restrict_pinned(query, %{"pinned" => pinned, "pinned_activity_ids" => ids}) - when pinned in [true, "true", "1"] do + defp restrict_pinned(query, %{pinned: true, pinned_activity_ids: ids}) do from(activity in query, where: activity.id in ^ids) end defp restrict_pinned(query, _), do: query - defp restrict_muted_reblogs(query, %{"muting_user" => %User{} = user} = opts) do - muted_reblogs = opts["reblog_muted_users_ap_ids"] || User.reblog_muted_users_ap_ids(user) + defp restrict_muted_reblogs(query, %{muting_user: %User{} = user} = opts) do + muted_reblogs = opts[:reblog_muted_users_ap_ids] || User.reblog_muted_users_ap_ids(user) from( activity in query, @@ -1010,7 +979,7 @@ defp restrict_muted_reblogs(query, %{"muting_user" => %User{} = user} = opts) do defp restrict_muted_reblogs(query, _), do: query - defp restrict_instance(query, %{"instance" => instance}) do + defp restrict_instance(query, %{instance: instance}) do users = from( u in User, @@ -1024,7 +993,7 @@ defp restrict_instance(query, %{"instance" => instance}) do defp restrict_instance(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 @@ -1036,7 +1005,7 @@ defp exclude_poll_votes(query, _) do end end - defp exclude_invisible_actors(query, %{"invisible_actors" => true}), do: query + defp exclude_invisible_actors(query, %{invisible_actors: true}), do: query defp exclude_invisible_actors(query, _opts) do invisible_ap_ids = @@ -1047,38 +1016,38 @@ defp exclude_invisible_actors(query, _opts) do from([activity] in query, where: activity.actor not in ^invisible_ap_ids) end - defp exclude_id(query, %{"exclude_id" => id}) when is_binary(id) do + defp exclude_id(query, %{exclude_id: id}) when is_binary(id) do from(activity in query, where: activity.id != ^id) end defp exclude_id(query, _), do: query - defp maybe_preload_objects(query, %{"skip_preload" => true}), do: query + defp maybe_preload_objects(query, %{skip_preload: true}), do: query defp maybe_preload_objects(query, _) do query |> Activity.with_preloaded_object() end - defp maybe_preload_bookmarks(query, %{"skip_preload" => true}), do: query + defp maybe_preload_bookmarks(query, %{skip_preload: true}), do: query defp maybe_preload_bookmarks(query, opts) do query - |> Activity.with_preloaded_bookmark(opts["user"]) + |> Activity.with_preloaded_bookmark(opts[:user]) end - defp maybe_preload_report_notes(query, %{"preload_report_notes" => true}) do + defp maybe_preload_report_notes(query, %{preload_report_notes: true}) do query |> Activity.with_preloaded_report_notes() end defp maybe_preload_report_notes(query, _), do: query - defp maybe_set_thread_muted_field(query, %{"skip_preload" => true}), do: query + defp maybe_set_thread_muted_field(query, %{skip_preload: true}), do: query defp maybe_set_thread_muted_field(query, opts) do query - |> Activity.with_set_thread_muted_field(opts["muting_user"] || opts["user"]) + |> Activity.with_set_thread_muted_field(opts[:muting_user] || opts[:user]) end defp maybe_order(query, %{order: :desc}) do @@ -1094,24 +1063,23 @@ defp maybe_order(query, %{order: :asc}) do defp maybe_order(query, _), do: query defp fetch_activities_query_ap_ids_ops(opts) do - source_user = opts["muting_user"] + source_user = opts[:muting_user] ap_id_relationships = if source_user, do: [:mute, :reblog_mute], else: [] ap_id_relationships = - ap_id_relationships ++ - if opts["blocking_user"] && opts["blocking_user"] == source_user do - [:block] - else - [] - end + if opts[:blocking_user] && opts[:blocking_user] == source_user do + [:block | ap_id_relationships] + else + ap_id_relationships + end preloaded_ap_ids = User.outgoing_relationships_ap_ids(source_user, ap_id_relationships) - restrict_blocked_opts = Map.merge(%{"blocked_users_ap_ids" => preloaded_ap_ids[:block]}, opts) - restrict_muted_opts = Map.merge(%{"muted_users_ap_ids" => preloaded_ap_ids[:mute]}, opts) + restrict_blocked_opts = Map.merge(%{blocked_users_ap_ids: preloaded_ap_ids[:block]}, opts) + restrict_muted_opts = Map.merge(%{muted_users_ap_ids: preloaded_ap_ids[:mute]}, opts) restrict_muted_reblogs_opts = - Map.merge(%{"reblog_muted_users_ap_ids" => preloaded_ap_ids[:reblog_mute]}, opts) + Map.merge(%{reblog_muted_users_ap_ids: preloaded_ap_ids[:reblog_mute]}, opts) {restrict_blocked_opts, restrict_muted_opts, restrict_muted_reblogs_opts} end @@ -1130,7 +1098,7 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> maybe_preload_report_notes(opts) |> maybe_set_thread_muted_field(opts) |> maybe_order(opts) - |> restrict_recipients(recipients, opts["user"]) + |> restrict_recipients(recipients, opts[:user]) |> restrict_replies(opts) |> restrict_tag(opts) |> restrict_tag_reject(opts) @@ -1157,12 +1125,12 @@ def fetch_activities_query(recipients, opts \\ %{}) do end def fetch_activities(recipients, opts \\ %{}, pagination \\ :keyset) do - list_memberships = Pleroma.List.memberships(opts["user"]) + list_memberships = Pleroma.List.memberships(opts[:user]) fetch_activities_query(recipients ++ list_memberships, opts) |> Pagination.fetch_paginated(opts, pagination) |> Enum.reverse() - |> maybe_update_cc(list_memberships, opts["user"]) + |> maybe_update_cc(list_memberships, opts[:user]) end @doc """ @@ -1178,16 +1146,15 @@ def fetch_favourites(user, params \\ %{}, pagination \\ :keyset) do |> select([_like, object, activity], %{activity | object: object}) |> order_by([like, _, _], desc_nulls_last: like.id) |> Pagination.fetch_paginated( - Map.merge(params, %{"skip_order" => true}), + Map.merge(params, %{skip_order: true}), pagination, :object_activity ) end - defp maybe_update_cc(activities, list_memberships, %User{ap_id: user_ap_id}) - when is_list(list_memberships) and length(list_memberships) > 0 do + defp maybe_update_cc(activities, [_ | _] = list_memberships, %User{ap_id: user_ap_id}) do Enum.map(activities, fn - %{data: %{"bcc" => bcc}} = activity when is_list(bcc) and length(bcc) > 0 -> + %{data: %{"bcc" => [_ | _] = bcc}} = activity -> if Enum.any?(bcc, &(&1 in list_memberships)) do update_in(activity.data["cc"], &[user_ap_id | &1]) else @@ -1201,7 +1168,7 @@ defp maybe_update_cc(activities, list_memberships, %User{ap_id: user_ap_id}) defp maybe_update_cc(activities, _, _), do: activities - def fetch_activities_bounded_query(query, recipients, recipients_with_public) do + defp fetch_activities_bounded_query(query, recipients, recipients_with_public) do from(activity in query, where: fragment("? && ?", activity.recipients, ^recipients) or @@ -1276,8 +1243,8 @@ defp object_to_user_data(data) do %{"type" => "Emoji"} -> true _ -> false end) - |> Enum.reduce(%{}, fn %{"icon" => %{"url" => url}, "name" => name}, acc -> - Map.put(acc, String.trim(name, ":"), url) + |> Map.new(fn %{"icon" => %{"url" => url}, "name" => name} -> + {String.trim(name, ":"), url} end) locked = data["manuallyApprovesFollowers"] || false @@ -1323,18 +1290,15 @@ defp object_to_user_data(data) do } # nickname can be nil because of virtual actors - user_data = - if data["preferredUsername"] do - Map.put( - user_data, - :nickname, - "#{data["preferredUsername"]}@#{URI.parse(data["id"]).host}" - ) - else - Map.put(user_data, :nickname, nil) - end - - {:ok, user_data} + if data["preferredUsername"] do + Map.put( + user_data, + :nickname, + "#{data["preferredUsername"]}@#{URI.parse(data["id"]).host}" + ) + else + Map.put(user_data, :nickname, nil) + end end def fetch_follow_information_for_user(user) do @@ -1409,9 +1373,8 @@ defp collection_private(%{"first" => first}) do defp collection_private(_data), do: {:ok, true} def user_data_from_user_object(data) do - with {:ok, data} <- MRF.filter(data), - {:ok, data} <- object_to_user_data(data) do - {:ok, data} + with {:ok, data} <- MRF.filter(data) do + {:ok, object_to_user_data(data)} else e -> {:error, e} end @@ -1419,15 +1382,14 @@ def user_data_from_user_object(data) do def fetch_and_prepare_user_from_ap_id(ap_id) do with {:ok, data} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id), - {:ok, data} <- user_data_from_user_object(data), - data <- maybe_update_follow_information(data) do - {:ok, data} + {:ok, data} <- user_data_from_user_object(data) do + {:ok, maybe_update_follow_information(data)} else - {:error, "Object has been deleted"} = e -> + {:error, "Object has been deleted" = e} -> Logger.debug("Could not decode user at fetch #{ap_id}, #{inspect(e)}") {:error, e} - e -> + {:error, e} -> Logger.error("Could not decode user at fetch #{ap_id}, #{inspect(e)}") {:error, e} end @@ -1450,8 +1412,6 @@ def make_user_from_ap_id(ap_id) do |> Repo.insert() |> User.set_cache() end - else - e -> {:error, e} end end end @@ -1465,7 +1425,7 @@ def make_user_from_nickname(nickname) do end # filter out broken threads - def contain_broken_threads(%Activity{} = activity, %User{} = user) do + defp contain_broken_threads(%Activity{} = activity, %User{} = user) do entire_thread_visible_for_user?(activity, user) end @@ -1476,7 +1436,7 @@ def contain_activity(%Activity{} = activity, %User{} = user) do def fetch_direct_messages_query do Activity - |> restrict_type(%{"type" => "Create"}) + |> restrict_type(%{type: "Create"}) |> restrict_visibility(%{visibility: "direct"}) |> order_by([activity], asc: activity.id) end diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 28727d619..55947925e 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -233,16 +233,16 @@ def outbox( activities = if params["max_id"] do ActivityPub.fetch_user_activities(user, for_user, %{ - "max_id" => params["max_id"], + 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 + include_poll_votes: true, + limit: 10 }) else ActivityPub.fetch_user_activities(user, for_user, %{ - "limit" => 10, - "include_poll_votes" => true + limit: 10, + include_poll_votes: true }) end @@ -356,11 +356,11 @@ def read_inbox( activities = if params["max_id"] do ActivityPub.fetch_activities([user.ap_id | User.following(user)], %{ - "max_id" => params["max_id"], - "limit" => 10 + max_id: params["max_id"], + limit: 10 }) else - ActivityPub.fetch_activities([user.ap_id | User.following(user)], %{"limit" => 10}) + ActivityPub.fetch_activities([user.ap_id | User.following(user)], %{limit: 10}) end conn diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index a76a699ee..1c40afdb2 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -244,7 +244,7 @@ defp lazy_put_object_defaults(activity, _), do: activity 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 + when type in @supported_object_types do with {:ok, object} <- Object.create(object_data) do map = Map.put(map, "object", object.data["id"]) @@ -740,13 +740,12 @@ defp build_flag_object(_), do: [] def get_reports(params, page, page_size) do params = params - |> Map.new(fn {key, value} -> {to_string(key), value} end) - |> Map.put("type", "Flag") - |> Map.put("skip_preload", true) - |> Map.put("preload_report_notes", true) - |> Map.put("total", true) - |> Map.put("limit", page_size) - |> Map.put("offset", (page - 1) * page_size) + |> Map.put(:type, "Flag") + |> Map.put(:skip_preload, true) + |> Map.put(:preload_report_notes, true) + |> Map.put(:total, true) + |> Map.put(:limit, page_size) + |> Map.put(:offset, (page - 1) * page_size) ActivityPub.fetch_activities([], params, :offset) end diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index bf24581cc..edd3abc63 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -228,10 +228,10 @@ def list_instance_statuses(conn, %{"instance" => instance} = params) do activities = ActivityPub.fetch_statuses(nil, %{ - "instance" => instance, - "limit" => page_size, - "offset" => (page - 1) * page_size, - "exclude_reblogs" => !with_reblogs && "true" + instance: instance, + limit: page_size, + offset: (page - 1) * page_size, + exclude_reblogs: not with_reblogs }) conn @@ -248,9 +248,9 @@ def list_user_statuses(conn, %{"nickname" => nickname} = params) do activities = ActivityPub.fetch_user_activities(user, nil, %{ - "limit" => page_size, - "godmode" => godmode, - "exclude_reblogs" => !with_reblogs && "true" + limit: page_size, + godmode: godmode, + exclude_reblogs: not with_reblogs }) conn diff --git a/lib/pleroma/web/admin_api/controllers/status_controller.ex b/lib/pleroma/web/admin_api/controllers/status_controller.ex index 574196be8..bc48cc527 100644 --- a/lib/pleroma/web/admin_api/controllers/status_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/status_controller.ex @@ -29,11 +29,11 @@ defmodule Pleroma.Web.AdminAPI.StatusController do def index(%{assigns: %{user: _admin}} = conn, params) do activities = ActivityPub.fetch_statuses(nil, %{ - "godmode" => params.godmode, - "local_only" => params.local_only, - "limit" => params.page_size, - "offset" => (params.page - 1) * params.page_size, - "exclude_reblogs" => not params.with_reblogs + godmode: params.godmode, + local_only: params.local_only, + limit: params.page_size, + offset: (params.page - 1) * params.page_size, + exclude_reblogs: not params.with_reblogs }) render(conn, "index.json", activities: activities, as: :activity) diff --git a/lib/pleroma/web/admin_api/views/report_view.ex b/lib/pleroma/web/admin_api/views/report_view.ex index f432b8c2c..773f798fe 100644 --- a/lib/pleroma/web/admin_api/views/report_view.ex +++ b/lib/pleroma/web/admin_api/views/report_view.ex @@ -18,7 +18,7 @@ def render("index.json", %{reports: reports}) do %{ reports: reports[:items] - |> Enum.map(&Report.extract_report_info(&1)) + |> Enum.map(&Report.extract_report_info/1) |> Enum.map(&render(__MODULE__, "show.json", &1)) |> Enum.reverse(), total: reports[:total] diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 5a1316a5f..9f0ca5b69 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -81,8 +81,7 @@ def add_link_headers(conn, activities, extra_params) do end def assign_account_by_id(conn, _) do - # TODO: use `conn.params[:id]` only after moving to OpenAPI - case Pleroma.User.get_cached_by_id(conn.params[:id] || conn.params["id"]) do + case Pleroma.User.get_cached_by_id(conn.params.id) do %Pleroma.User{} = account -> assign(conn, :account, account) nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt() end diff --git a/lib/pleroma/web/feed/tag_controller.ex b/lib/pleroma/web/feed/tag_controller.ex index 8133f8480..3404d2856 100644 --- a/lib/pleroma/web/feed/tag_controller.ex +++ b/lib/pleroma/web/feed/tag_controller.ex @@ -15,8 +15,8 @@ def feed(conn, %{"tag" => raw_tag} = params) do {format, tag} = parse_tag(raw_tag) activities = - %{"type" => ["Create"], "tag" => tag} - |> put_if_exist("max_id", params["max_id"]) + %{type: ["Create"], tag: tag} + |> put_if_exist(:max_id, params["max_id"]) |> ActivityPub.fetch_public_activities() conn diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex index 5a6fc9de0..7bf9bd3e3 100644 --- a/lib/pleroma/web/feed/user_controller.ex +++ b/lib/pleroma/web/feed/user_controller.ex @@ -52,10 +52,10 @@ def feed(conn, %{"nickname" => nickname} = params) do with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)} do activities = %{ - "type" => ["Create"], - "actor_id" => user.ap_id + type: ["Create"], + actor_id: user.ap_id } - |> put_if_exist("max_id", params["max_id"]) + |> put_if_exist(:max_id, params["max_id"]) |> ActivityPub.fetch_public_or_unlisted_activities() conn diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 97295a52f..edecbf418 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -254,9 +254,7 @@ def statuses(%{assigns: %{user: reading_user}} = conn, params) do params = params |> Map.delete(:tagged) - |> Enum.filter(&(not is_nil(&1))) - |> Map.new(fn {key, value} -> {to_string(key), value} end) - |> Map.put("tag", params[:tagged]) + |> Map.put(:tag, params[:tagged]) activities = ActivityPub.fetch_user_activities(user, reading_user, params) diff --git a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex index 69f0e3846..f35ec3596 100644 --- a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex @@ -21,7 +21,6 @@ defmodule Pleroma.Web.MastodonAPI.ConversationController do @doc "GET /api/v1/conversations" def index(%{assigns: %{user: user}} = conn, params) do - params = stringify_pagination_params(params) participations = Participation.for_user_with_last_activity_id(user, params) conn @@ -37,20 +36,4 @@ def mark_as_read(%{assigns: %{user: user}} = conn, %{id: participation_id}) do render(conn, "participation.json", participation: participation, for: user) end end - - defp stringify_pagination_params(params) do - atom_keys = - Pleroma.Pagination.page_keys() - |> Enum.map(&String.to_atom(&1)) - - str_keys = - params - |> Map.take(atom_keys) - |> Enum.map(fn {key, value} -> {to_string(key), value} end) - |> Enum.into(%{}) - - params - |> Map.delete(atom_keys) - |> Map.merge(str_keys) - end end diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index f20157a5f..468b44b67 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -359,9 +359,9 @@ 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 + blocking_user: user, + user: user, + exclude_id: activity.id }) render(conn, "context.json", activity: activity, activities: activities, user: user) @@ -370,11 +370,6 @@ def context(%{assigns: %{user: user}} = conn, %{id: id}) do @doc "GET /api/v1/favourites" def favourites(%{assigns: %{user: %User{} = user}} = conn, params) do - params = - params - |> Map.new(fn {key, value} -> {to_string(key), value} end) - |> Map.take(Pleroma.Pagination.page_keys()) - activities = ActivityPub.fetch_favourites(user, params) conn diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index f67f75430..ed74a771a 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -44,12 +44,11 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do def home(%{assigns: %{user: user}} = conn, params) do params = params - |> Map.new(fn {key, value} -> {to_string(key), value} end) - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("reply_filtering_user", user) - |> Map.put("user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:reply_filtering_user, user) + |> Map.put(:user, user) recipients = [user.ap_id | User.following(user)] @@ -71,10 +70,9 @@ def home(%{assigns: %{user: user}} = conn, params) do def direct(%{assigns: %{user: user}} = conn, params) do params = params - |> Map.new(fn {key, value} -> {to_string(key), value} end) - |> Map.put("type", "Create") - |> Map.put("blocking_user", user) - |> Map.put("user", user) + |> Map.put(:type, "Create") + |> Map.put(:blocking_user, user) + |> Map.put(:user, user) |> Map.put(:visibility, "direct") activities = @@ -93,9 +91,7 @@ def direct(%{assigns: %{user: user}} = conn, params) do # GET /api/v1/timelines/public def public(%{assigns: %{user: user}} = conn, params) do - params = Map.new(params, fn {key, value} -> {to_string(key), value} end) - - local_only = params["local"] + local_only = params[:local] cfg_key = if local_only do @@ -111,11 +107,11 @@ def public(%{assigns: %{user: user}} = conn, params) do else activities = params - |> Map.put("type", ["Create"]) - |> Map.put("local_only", local_only) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("reply_filtering_user", user) + |> Map.put(:type, ["Create"]) + |> Map.put(:local_only, local_only) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:reply_filtering_user, user) |> ActivityPub.fetch_public_activities() conn @@ -130,39 +126,38 @@ def public(%{assigns: %{user: user}} = conn, params) do defp hashtag_fetching(params, user, local_only) do tags = - [params["tag"], params["any"]] + [params[:tag], params[:any]] |> List.flatten() |> Enum.uniq() - |> Enum.filter(& &1) - |> Enum.map(&String.downcase(&1)) + |> Enum.reject(&is_nil/1) + |> Enum.map(&String.downcase/1) tag_all = params - |> Map.get("all", []) - |> Enum.map(&String.downcase(&1)) + |> Map.get(:all, []) + |> Enum.map(&String.downcase/1) tag_reject = params - |> Map.get("none", []) - |> Enum.map(&String.downcase(&1)) + |> 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) + |> 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() end # GET /api/v1/timelines/tag/:tag def hashtag(%{assigns: %{user: user}} = conn, params) do - params = Map.new(params, fn {key, value} -> {to_string(key), value} end) - local_only = params["local"] + local_only = params[:local] activities = hashtag_fetching(params, user, local_only) conn diff --git a/lib/pleroma/web/mastodon_api/views/conversation_view.ex b/lib/pleroma/web/mastodon_api/views/conversation_view.ex index 2b6f84c72..fbe618377 100644 --- a/lib/pleroma/web/mastodon_api/views/conversation_view.ex +++ b/lib/pleroma/web/mastodon_api/views/conversation_view.ex @@ -24,8 +24,8 @@ def render("participation.json", %{participation: participation, for: user}) do last_activity_id = with nil <- participation.last_activity_id do ActivityPub.fetch_latest_activity_id_for_context(participation.conversation.ap_id, %{ - "user" => user, - "blocking_user" => user + user: user, + blocking_user: user }) end diff --git a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex index 0a3f45620..f3554d919 100644 --- a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex @@ -126,10 +126,9 @@ def favourites(%{assigns: %{account: %{hide_favorites: true}}} = conn, _params) def favourites(%{assigns: %{user: for_user, account: user}} = conn, params) do params = params - |> Map.new(fn {key, value} -> {to_string(key), value} end) - |> Map.put("type", "Create") - |> Map.put("favorited_by", user.ap_id) - |> Map.put("blocking_user", for_user) + |> Map.put(:type, "Create") + |> Map.put(:favorited_by, user.ap_id) + |> Map.put(:blocking_user, for_user) recipients = if for_user do diff --git a/lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex b/lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex index 21d5eb8d5..3d007f324 100644 --- a/lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex @@ -42,15 +42,14 @@ def statuses( Participation.get(participation_id, preload: [:conversation]) do params = params - |> Map.new(fn {key, value} -> {to_string(key), value} end) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) activities = participation.conversation.ap_id |> ActivityPub.fetch_activities_for_context_query(params) - |> Pleroma.Pagination.fetch_paginated(Map.put(params, "total", false)) + |> Pleroma.Pagination.fetch_paginated(Map.put(params, :total, false)) |> Enum.reverse() conn diff --git a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex index 8665ca56c..e9a4fba92 100644 --- a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex @@ -36,10 +36,7 @@ def create(%{assigns: %{user: user}, body_params: params} = conn, _) do def index(%{assigns: %{user: reading_user}} = conn, %{id: id} = params) do with %User{} = user <- User.get_cached_by_nickname_or_id(id, for: reading_user) do - params = - params - |> Map.new(fn {key, value} -> {to_string(key), value} end) - |> Map.put("type", ["Listen"]) + params = Map.put(params, :type, ["Listen"]) activities = ActivityPub.fetch_user_abstract_activities(user, reading_user, params) diff --git a/lib/pleroma/web/static_fe/static_fe_controller.ex b/lib/pleroma/web/static_fe/static_fe_controller.ex index c3efb6651..a7a891b13 100644 --- a/lib/pleroma/web/static_fe/static_fe_controller.ex +++ b/lib/pleroma/web/static_fe/static_fe_controller.ex @@ -111,8 +111,14 @@ def show(%{assigns: %{username_or_id: username_or_id}} = conn, params) do %User{} = user -> meta = Metadata.build_tags(%{user: user}) + params = + params + |> Map.take(@page_keys) + |> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end) + timeline = - ActivityPub.fetch_user_activities(user, nil, Map.take(params, @page_keys)) + user + |> ActivityPub.fetch_user_activities(nil, params) |> Enum.map(&represent/1) prev_page_id = diff --git a/test/pagination_test.exs b/test/pagination_test.exs index d5b1b782d..9165427ae 100644 --- a/test/pagination_test.exs +++ b/test/pagination_test.exs @@ -21,7 +21,7 @@ test "paginates by min_id", %{notes: notes} do id = Enum.at(notes, 2).id |> Integer.to_string() %{total: total, items: paginated} = - Pagination.fetch_paginated(Object, %{"min_id" => id, "total" => true}) + Pagination.fetch_paginated(Object, %{min_id: id, total: true}) assert length(paginated) == 2 assert total == 5 @@ -31,7 +31,7 @@ test "paginates by since_id", %{notes: notes} do id = Enum.at(notes, 2).id |> Integer.to_string() %{total: total, items: paginated} = - Pagination.fetch_paginated(Object, %{"since_id" => id, "total" => true}) + Pagination.fetch_paginated(Object, %{since_id: id, total: true}) assert length(paginated) == 2 assert total == 5 @@ -41,7 +41,7 @@ test "paginates by max_id", %{notes: notes} do id = Enum.at(notes, 1).id |> Integer.to_string() %{total: total, items: paginated} = - Pagination.fetch_paginated(Object, %{"max_id" => id, "total" => true}) + Pagination.fetch_paginated(Object, %{max_id: id, total: true}) assert length(paginated) == 1 assert total == 5 @@ -50,7 +50,7 @@ test "paginates by max_id", %{notes: notes} do test "paginates by min_id & limit", %{notes: notes} do id = Enum.at(notes, 2).id |> Integer.to_string() - paginated = Pagination.fetch_paginated(Object, %{"min_id" => id, "limit" => 1}) + paginated = Pagination.fetch_paginated(Object, %{min_id: id, limit: 1}) assert length(paginated) == 1 end @@ -64,13 +64,13 @@ test "paginates by min_id & limit", %{notes: notes} do end test "paginates by limit" do - paginated = Pagination.fetch_paginated(Object, %{"limit" => 2}, :offset) + paginated = Pagination.fetch_paginated(Object, %{limit: 2}, :offset) assert length(paginated) == 2 end test "paginates by limit & offset" do - paginated = Pagination.fetch_paginated(Object, %{"limit" => 2, "offset" => 4}, :offset) + paginated = Pagination.fetch_paginated(Object, %{limit: 2, offset: 4}, :offset) assert length(paginated) == 1 end diff --git a/test/tasks/relay_test.exs b/test/tasks/relay_test.exs index 678288854..a8ba0658d 100644 --- a/test/tasks/relay_test.exs +++ b/test/tasks/relay_test.exs @@ -62,11 +62,11 @@ test "relay is unfollowed" do [undo_activity] = ActivityPub.fetch_activities([], %{ - "type" => "Undo", - "actor_id" => follower_id, - "limit" => 1, - "skip_preload" => true, - "invisible_actors" => true + type: "Undo", + actor_id: follower_id, + limit: 1, + skip_preload: true, + invisible_actors: true }) assert undo_activity.data["type"] == "Undo" diff --git a/test/user_test.exs b/test/user_test.exs index 6b344158d..48c7605f5 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1122,7 +1122,7 @@ test "hide a user's statuses from timelines and notifications" do assert [%{activity | thread_muted?: CommonAPI.thread_muted?(user2, activity)}] == ActivityPub.fetch_activities([user2.ap_id | User.following(user2)], %{ - "user" => user2 + user: user2 }) {:ok, _user} = User.deactivate(user) @@ -1132,7 +1132,7 @@ test "hide a user's statuses from timelines and notifications" do assert [] == ActivityPub.fetch_activities([user2.ap_id | User.following(user2)], %{ - "user" => user2 + user: user2 }) end end diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 3dcb62873..2f65dfc8e 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -82,30 +82,28 @@ test "it restricts by the appropriate visibility" do {:ok, private_activity} = CommonAPI.post(user, %{status: ".", visibility: "private"}) - activities = - ActivityPub.fetch_activities([], %{:visibility => "direct", "actor_id" => user.ap_id}) + activities = ActivityPub.fetch_activities([], %{visibility: "direct", actor_id: user.ap_id}) assert activities == [direct_activity] activities = - ActivityPub.fetch_activities([], %{:visibility => "unlisted", "actor_id" => user.ap_id}) + ActivityPub.fetch_activities([], %{visibility: "unlisted", actor_id: user.ap_id}) assert activities == [unlisted_activity] activities = - ActivityPub.fetch_activities([], %{:visibility => "private", "actor_id" => user.ap_id}) + ActivityPub.fetch_activities([], %{visibility: "private", actor_id: user.ap_id}) assert activities == [private_activity] - activities = - ActivityPub.fetch_activities([], %{:visibility => "public", "actor_id" => user.ap_id}) + activities = ActivityPub.fetch_activities([], %{visibility: "public", actor_id: user.ap_id}) assert activities == [public_activity] activities = ActivityPub.fetch_activities([], %{ - :visibility => ~w[private public], - "actor_id" => user.ap_id + visibility: ~w[private public], + actor_id: user.ap_id }) assert activities == [public_activity, private_activity] @@ -126,8 +124,8 @@ test "it excludes by the appropriate visibility" do activities = ActivityPub.fetch_activities([], %{ - "exclude_visibilities" => "direct", - "actor_id" => user.ap_id + exclude_visibilities: "direct", + actor_id: user.ap_id }) assert public_activity in activities @@ -137,8 +135,8 @@ test "it excludes by the appropriate visibility" do activities = ActivityPub.fetch_activities([], %{ - "exclude_visibilities" => "unlisted", - "actor_id" => user.ap_id + exclude_visibilities: "unlisted", + actor_id: user.ap_id }) assert public_activity in activities @@ -148,8 +146,8 @@ test "it excludes by the appropriate visibility" do activities = ActivityPub.fetch_activities([], %{ - "exclude_visibilities" => "private", - "actor_id" => user.ap_id + exclude_visibilities: "private", + actor_id: user.ap_id }) assert public_activity in activities @@ -159,8 +157,8 @@ test "it excludes by the appropriate visibility" do activities = ActivityPub.fetch_activities([], %{ - "exclude_visibilities" => "public", - "actor_id" => user.ap_id + exclude_visibilities: "public", + actor_id: user.ap_id }) refute public_activity in activities @@ -193,23 +191,22 @@ test "it fetches the appropriate tag-restricted posts" do {:ok, status_two} = CommonAPI.post(user, %{status: ". #essais"}) {:ok, status_three} = CommonAPI.post(user, %{status: ". #test #reject"}) - fetch_one = ActivityPub.fetch_activities([], %{"type" => "Create", "tag" => "test"}) + fetch_one = ActivityPub.fetch_activities([], %{type: "Create", tag: "test"}) - fetch_two = - ActivityPub.fetch_activities([], %{"type" => "Create", "tag" => ["test", "essais"]}) + fetch_two = ActivityPub.fetch_activities([], %{type: "Create", tag: ["test", "essais"]}) fetch_three = ActivityPub.fetch_activities([], %{ - "type" => "Create", - "tag" => ["test", "essais"], - "tag_reject" => ["reject"] + type: "Create", + tag: ["test", "essais"], + tag_reject: ["reject"] }) fetch_four = ActivityPub.fetch_activities([], %{ - "type" => "Create", - "tag" => ["test"], - "tag_all" => ["test", "reject"] + type: "Create", + tag: ["test"], + tag_all: ["test", "reject"] }) assert fetch_one == [status_one, status_three] @@ -375,7 +372,7 @@ test "can be fetched into a timeline" do _listen_activity_2 = insert(:listen) _listen_activity_3 = insert(:listen) - timeline = ActivityPub.fetch_activities([], %{"type" => ["Listen"]}) + timeline = ActivityPub.fetch_activities([], %{type: ["Listen"]}) assert length(timeline) == 3 end @@ -507,7 +504,7 @@ test "retrieves activities that have a given context" do {:ok, _user_relationship} = User.block(user, %{ap_id: activity_five.data["actor"]}) - activities = ActivityPub.fetch_activities_for_context("2hu", %{"blocking_user" => user}) + activities = ActivityPub.fetch_activities_for_context("2hu", %{blocking_user: user}) assert activities == [activity_two, activity] end end @@ -520,8 +517,7 @@ test "doesn't return blocked activities" do booster = insert(:user) {:ok, _user_relationship} = User.block(user, %{ap_id: activity_one.data["actor"]}) - activities = - ActivityPub.fetch_activities([], %{"blocking_user" => user, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{blocking_user: user, skip_preload: true}) assert Enum.member?(activities, activity_two) assert Enum.member?(activities, activity_three) @@ -529,8 +525,7 @@ test "doesn't return blocked activities" do {:ok, _user_block} = User.unblock(user, %{ap_id: activity_one.data["actor"]}) - activities = - ActivityPub.fetch_activities([], %{"blocking_user" => user, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{blocking_user: user, skip_preload: true}) assert Enum.member?(activities, activity_two) assert Enum.member?(activities, activity_three) @@ -541,16 +536,14 @@ test "doesn't return blocked activities" do %Activity{} = boost_activity = Activity.get_create_by_object_ap_id(id) activity_three = Activity.get_by_id(activity_three.id) - activities = - ActivityPub.fetch_activities([], %{"blocking_user" => user, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{blocking_user: user, skip_preload: true}) assert Enum.member?(activities, activity_two) refute Enum.member?(activities, activity_three) refute Enum.member?(activities, boost_activity) assert Enum.member?(activities, activity_one) - activities = - ActivityPub.fetch_activities([], %{"blocking_user" => nil, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{blocking_user: nil, skip_preload: true}) assert Enum.member?(activities, activity_two) assert Enum.member?(activities, activity_three) @@ -573,7 +566,7 @@ test "doesn't return transitive interactions concerning blocked users" do {:ok, activity_four} = CommonAPI.post(blockee, %{status: "hey! @#{blocker.nickname}"}) - activities = ActivityPub.fetch_activities([], %{"blocking_user" => blocker}) + activities = ActivityPub.fetch_activities([], %{blocking_user: blocker}) assert Enum.member?(activities, activity_one) refute Enum.member?(activities, activity_two) @@ -595,7 +588,7 @@ test "doesn't return announce activities concerning blocked users" do {:ok, activity_three} = CommonAPI.repeat(activity_two.id, friend) activities = - ActivityPub.fetch_activities([], %{"blocking_user" => blocker}) + ActivityPub.fetch_activities([], %{blocking_user: blocker}) |> Enum.map(fn act -> act.id end) assert Enum.member?(activities, activity_one.id) @@ -611,8 +604,7 @@ test "doesn't return activities from blocked domains" do user = insert(:user) {:ok, user} = User.block_domain(user, domain) - activities = - ActivityPub.fetch_activities([], %{"blocking_user" => user, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{blocking_user: user, skip_preload: true}) refute activity in activities @@ -620,8 +612,7 @@ test "doesn't return activities from blocked domains" do ActivityPub.follow(user, followed_user) {:ok, repeat_activity} = CommonAPI.repeat(activity.id, followed_user) - activities = - ActivityPub.fetch_activities([], %{"blocking_user" => user, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{blocking_user: user, skip_preload: true}) refute repeat_activity in activities end @@ -641,8 +632,7 @@ test "does return activities from followed users on blocked domains" do note = insert(:note, %{data: %{"actor" => domain_user.ap_id}}) activity = insert(:note_activity, %{note: note}) - activities = - ActivityPub.fetch_activities([], %{"blocking_user" => blocker, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{blocking_user: blocker, skip_preload: true}) assert activity in activities @@ -653,8 +643,7 @@ test "does return activities from followed users on blocked domains" do bad_activity = insert(:note_activity, %{note: bad_note}) {:ok, repeat_activity} = CommonAPI.repeat(bad_activity.id, domain_user) - activities = - ActivityPub.fetch_activities([], %{"blocking_user" => blocker, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{blocking_user: blocker, skip_preload: true}) refute repeat_activity in activities end @@ -669,8 +658,7 @@ test "doesn't return muted activities" do activity_one_actor = User.get_by_ap_id(activity_one.data["actor"]) {:ok, _user_relationships} = User.mute(user, activity_one_actor) - activities = - ActivityPub.fetch_activities([], %{"muting_user" => user, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{muting_user: user, skip_preload: true}) assert Enum.member?(activities, activity_two) assert Enum.member?(activities, activity_three) @@ -679,9 +667,9 @@ test "doesn't return muted activities" do # Calling with 'with_muted' will deliver muted activities, too. activities = ActivityPub.fetch_activities([], %{ - "muting_user" => user, - "with_muted" => true, - "skip_preload" => true + muting_user: user, + with_muted: true, + skip_preload: true }) assert Enum.member?(activities, activity_two) @@ -690,8 +678,7 @@ test "doesn't return muted activities" do {:ok, _user_mute} = User.unmute(user, activity_one_actor) - activities = - ActivityPub.fetch_activities([], %{"muting_user" => user, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{muting_user: user, skip_preload: true}) assert Enum.member?(activities, activity_two) assert Enum.member?(activities, activity_three) @@ -703,15 +690,14 @@ test "doesn't return muted activities" do %Activity{} = boost_activity = Activity.get_create_by_object_ap_id(id) activity_three = Activity.get_by_id(activity_three.id) - activities = - ActivityPub.fetch_activities([], %{"muting_user" => user, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{muting_user: user, skip_preload: true}) assert Enum.member?(activities, activity_two) refute Enum.member?(activities, activity_three) refute Enum.member?(activities, boost_activity) assert Enum.member?(activities, activity_one) - activities = ActivityPub.fetch_activities([], %{"muting_user" => nil, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{muting_user: nil, skip_preload: true}) assert Enum.member?(activities, activity_two) assert Enum.member?(activities, activity_three) @@ -727,7 +713,7 @@ test "doesn't return thread muted activities" do {:ok, _activity_two} = CommonAPI.add_mute(user, activity_two) - assert [_activity_one] = ActivityPub.fetch_activities([], %{"muting_user" => user}) + assert [_activity_one] = ActivityPub.fetch_activities([], %{muting_user: user}) end test "returns thread muted activities when with_muted is set" do @@ -739,7 +725,7 @@ test "returns thread muted activities when with_muted is set" do {:ok, _activity_two} = CommonAPI.add_mute(user, activity_two) assert [_activity_two, _activity_one] = - ActivityPub.fetch_activities([], %{"muting_user" => user, "with_muted" => true}) + ActivityPub.fetch_activities([], %{muting_user: user, with_muted: true}) end test "does include announces on request" do @@ -761,7 +747,7 @@ test "excludes reblogs on request" do {:ok, expected_activity} = ActivityBuilder.insert(%{"type" => "Create"}, %{:user => user}) {:ok, _} = ActivityBuilder.insert(%{"type" => "Announce"}, %{:user => user}) - [activity] = ActivityPub.fetch_user_activities(user, nil, %{"exclude_reblogs" => "true"}) + [activity] = ActivityPub.fetch_user_activities(user, nil, %{exclude_reblogs: true}) assert activity == expected_activity end @@ -804,7 +790,7 @@ test "retrieves ids starting from a since_id" do expected_activities = ActivityBuilder.insert_list(10) since_id = List.last(activities).id - activities = ActivityPub.fetch_public_activities(%{"since_id" => since_id}) + activities = ActivityPub.fetch_public_activities(%{since_id: since_id}) assert collect_ids(activities) == collect_ids(expected_activities) assert length(activities) == 10 @@ -819,7 +805,7 @@ test "retrieves ids up to max_id" do |> ActivityBuilder.insert_list() |> List.first() - activities = ActivityPub.fetch_public_activities(%{"max_id" => max_id}) + activities = ActivityPub.fetch_public_activities(%{max_id: max_id}) assert length(activities) == 20 assert collect_ids(activities) == collect_ids(expected_activities) @@ -831,8 +817,7 @@ test "paginates via offset/limit" do later_activities = ActivityBuilder.insert_list(10) - activities = - ActivityPub.fetch_public_activities(%{"page" => "2", "page_size" => "20"}, :offset) + activities = ActivityPub.fetch_public_activities(%{page: "2", page_size: "20"}, :offset) assert length(activities) == 20 @@ -848,7 +833,7 @@ test "doesn't return reblogs for users for whom reblogs have been muted" do {:ok, activity} = CommonAPI.repeat(activity.id, booster) - activities = ActivityPub.fetch_activities([], %{"muting_user" => user}) + activities = ActivityPub.fetch_activities([], %{muting_user: user}) refute Enum.any?(activities, fn %{id: id} -> id == activity.id end) end @@ -862,7 +847,7 @@ test "returns reblogs for users for whom reblogs have not been muted" do {:ok, activity} = CommonAPI.repeat(activity.id, booster) - activities = ActivityPub.fetch_activities([], %{"muting_user" => user}) + activities = ActivityPub.fetch_activities([], %{muting_user: user}) assert Enum.any?(activities, fn %{id: id} -> id == activity.id end) end @@ -1066,7 +1051,7 @@ test "it filters broken threads" do assert length(activities) == 3 activities = - ActivityPub.fetch_activities([user1.ap_id | User.following(user1)], %{"user" => user1}) + ActivityPub.fetch_activities([user1.ap_id | User.following(user1)], %{user: user1}) |> Enum.map(fn a -> a.id end) assert [public_activity.id, private_activity_1.id] == activities @@ -1115,7 +1100,7 @@ test "returned pinned statuses" do CommonAPI.pin(activity_three.id, user) user = refresh_record(user) - activities = ActivityPub.fetch_user_activities(user, nil, %{"pinned" => "true"}) + activities = ActivityPub.fetch_user_activities(user, nil, %{pinned: true}) assert 3 = length(activities) end @@ -1226,7 +1211,7 @@ test "fetch_activities/2 returns activities addressed to a list " do activity = Repo.preload(activity, :bookmark) activity = %Activity{activity | thread_muted?: !!activity.thread_muted?} - assert ActivityPub.fetch_activities([], %{"user" => user}) == [activity] + assert ActivityPub.fetch_activities([], %{user: user}) == [activity] end def data_uri do @@ -1400,7 +1385,7 @@ test "returns a favourite activities sorted by adds to favorite" do assert Enum.map(result, & &1.id) == [a1.id, a5.id, a3.id, a4.id] - result = ActivityPub.fetch_favourites(user, %{"limit" => 2}) + result = ActivityPub.fetch_favourites(user, %{limit: 2}) assert Enum.map(result, & &1.id) == [a1.id, a5.id] end end @@ -1470,7 +1455,7 @@ test "doesn't retrieve replies activities with exclude_replies" do {:ok, _reply} = CommonAPI.post(user, %{status: "yeah", in_reply_to_status_id: activity.id}) - [result] = ActivityPub.fetch_public_activities(%{"exclude_replies" => "true"}) + [result] = ActivityPub.fetch_public_activities(%{exclude_replies: true}) assert result.id == activity.id @@ -1483,11 +1468,11 @@ test "doesn't retrieve replies activities with exclude_replies" do test "public timeline", %{users: %{u1: user}} do activities_ids = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("local_only", false) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("reply_filtering_user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:local_only, false) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:reply_filtering_user, user) |> ActivityPub.fetch_public_activities() |> Enum.map(& &1.id) @@ -1504,12 +1489,12 @@ test "public timeline with reply_visibility `following`", %{ } do activities_ids = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("local_only", false) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("reply_visibility", "following") - |> Map.put("reply_filtering_user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:local_only, false) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:reply_visibility, "following") + |> Map.put(:reply_filtering_user, user) |> ActivityPub.fetch_public_activities() |> Enum.map(& &1.id) @@ -1531,12 +1516,12 @@ test "public timeline with reply_visibility `self`", %{ } do activities_ids = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("local_only", false) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("reply_visibility", "self") - |> Map.put("reply_filtering_user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:local_only, false) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:reply_visibility, "self") + |> Map.put(:reply_filtering_user, user) |> ActivityPub.fetch_public_activities() |> Enum.map(& &1.id) @@ -1555,11 +1540,11 @@ test "home timeline", %{ } do params = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) - |> Map.put("reply_filtering_user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) + |> Map.put(:reply_filtering_user, user) activities_ids = ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) @@ -1593,12 +1578,12 @@ test "home timeline with reply_visibility `following`", %{ } do params = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) - |> Map.put("reply_visibility", "following") - |> Map.put("reply_filtering_user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) + |> Map.put(:reply_visibility, "following") + |> Map.put(:reply_filtering_user, user) activities_ids = ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) @@ -1632,12 +1617,12 @@ test "home timeline with reply_visibility `self`", %{ } do params = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) - |> Map.put("reply_visibility", "self") - |> Map.put("reply_filtering_user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) + |> Map.put(:reply_visibility, "self") + |> Map.put(:reply_filtering_user, user) activities_ids = ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) @@ -1666,11 +1651,11 @@ test "home timeline with reply_visibility `self`", %{ test "public timeline", %{users: %{u1: user}} do activities_ids = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("local_only", false) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:local_only, false) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) |> ActivityPub.fetch_public_activities() |> Enum.map(& &1.id) @@ -1680,13 +1665,13 @@ test "public timeline", %{users: %{u1: user}} do test "public timeline with default reply_visibility `following`", %{users: %{u1: user}} do activities_ids = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("local_only", false) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("reply_visibility", "following") - |> Map.put("reply_filtering_user", user) - |> Map.put("user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:local_only, false) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:reply_visibility, "following") + |> Map.put(:reply_filtering_user, user) + |> Map.put(:user, user) |> ActivityPub.fetch_public_activities() |> Enum.map(& &1.id) @@ -1696,13 +1681,13 @@ test "public timeline with default reply_visibility `following`", %{users: %{u1: test "public timeline with default reply_visibility `self`", %{users: %{u1: user}} do activities_ids = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("local_only", false) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("reply_visibility", "self") - |> Map.put("reply_filtering_user", user) - |> Map.put("user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:local_only, false) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:reply_visibility, "self") + |> Map.put(:reply_filtering_user, user) + |> Map.put(:user, user) |> ActivityPub.fetch_public_activities() |> Enum.map(& &1.id) @@ -1712,10 +1697,10 @@ test "public timeline with default reply_visibility `self`", %{users: %{u1: user test "home timeline", %{users: %{u1: user}} do params = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) activities_ids = ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) @@ -1727,12 +1712,12 @@ test "home timeline", %{users: %{u1: user}} do test "home timeline with default reply_visibility `following`", %{users: %{u1: user}} do params = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) - |> Map.put("reply_visibility", "following") - |> Map.put("reply_filtering_user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) + |> Map.put(:reply_visibility, "following") + |> Map.put(:reply_filtering_user, user) activities_ids = ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) @@ -1751,12 +1736,12 @@ test "home timeline with default reply_visibility `self`", %{ } do params = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) - |> Map.put("reply_visibility", "self") - |> Map.put("reply_filtering_user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) + |> Map.put(:reply_visibility, "self") + |> Map.put(:reply_filtering_user, user) activities_ids = ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) From d44da91bbf50ae91e8246ebe3669cfaf1fabda1b Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 4 Jun 2020 20:28:33 +0200 Subject: [PATCH 204/401] SubscriptionOperation: Let chat mentions through. --- .../web/api_spec/operations/subscription_operation.ex | 5 +++++ .../controllers/subscription_controller_test.exs | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/subscription_operation.ex b/lib/pleroma/web/api_spec/operations/subscription_operation.ex index c575a87e6..775dd795d 100644 --- a/lib/pleroma/web/api_spec/operations/subscription_operation.ex +++ b/lib/pleroma/web/api_spec/operations/subscription_operation.ex @@ -141,6 +141,11 @@ defp create_request do allOf: [BooleanLike], nullable: true, description: "Receive poll notifications?" + }, + "pleroma:chat_mention": %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive chat notifications?" } } } diff --git a/test/web/mastodon_api/controllers/subscription_controller_test.exs b/test/web/mastodon_api/controllers/subscription_controller_test.exs index 4aa260663..d36bb1ae8 100644 --- a/test/web/mastodon_api/controllers/subscription_controller_test.exs +++ b/test/web/mastodon_api/controllers/subscription_controller_test.exs @@ -58,7 +58,9 @@ test "successful creation", %{conn: conn} do result = conn |> post("/api/v1/push/subscription", %{ - "data" => %{"alerts" => %{"mention" => true, "test" => true}}, + "data" => %{ + "alerts" => %{"mention" => true, "test" => true, "pleroma:chat_mention" => true} + }, "subscription" => @sub }) |> json_response_and_validate_schema(200) @@ -66,7 +68,7 @@ test "successful creation", %{conn: conn} do [subscription] = Pleroma.Repo.all(Subscription) assert %{ - "alerts" => %{"mention" => true}, + "alerts" => %{"mention" => true, "pleroma:chat_mention" => true}, "endpoint" => subscription.endpoint, "id" => to_string(subscription.id), "server_key" => @server_key From aa2ac76510d95f2412e23f3739e8e1ae4402643f Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 4 Jun 2020 20:40:46 +0200 Subject: [PATCH 205/401] Notification: Don't break on figuring out the type of old EmojiReactions --- lib/pleroma/notification.ex | 4 ++++ test/notification_test.exs | 17 ++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 455d214bf..e5b880b10 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -398,6 +398,10 @@ defp type_from_activity(%{data: %{"type" => type}} = activity, opts \\ []) do "EmojiReact" -> "pleroma:emoji_reaction" + # Compatibility with old reactions + "EmojiReaction" -> + "pleroma:emoji_reaction" + "Create" -> activity |> type_from_activity_object() diff --git a/test/notification_test.exs b/test/notification_test.exs index 6bc2b6904..f2115a29e 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -8,8 +8,10 @@ defmodule Pleroma.NotificationTest do import Pleroma.Factory import Mock + alias Pleroma.Activity alias Pleroma.FollowingRelationship alias Pleroma.Notification + alias Pleroma.Repo alias Pleroma.Tests.ObanHelpers alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub @@ -29,8 +31,18 @@ test "it fills in missing notification types" do {:ok, chat} = CommonAPI.post_chat_message(user, other_user, "yo") {:ok, react} = CommonAPI.react_with_emoji(post.id, other_user, "☕") {:ok, like} = CommonAPI.favorite(other_user, post.id) + {:ok, react_2} = CommonAPI.react_with_emoji(post.id, other_user, "☕") - assert {4, nil} = Repo.update_all(Notification, set: [type: nil]) + data = + react_2.data + |> Map.put("type", "EmojiReaction") + + {:ok, react_2} = + react_2 + |> Activity.change(%{data: data}) + |> Repo.update() + + assert {5, nil} = Repo.update_all(Notification, set: [type: nil]) Notification.fill_in_notification_types() @@ -43,6 +55,9 @@ test "it fills in missing notification types" do assert %{type: "pleroma:emoji_reaction"} = Repo.get_by(Notification, user_id: user.id, activity_id: react.id) + assert %{type: "pleroma:emoji_reaction"} = + Repo.get_by(Notification, user_id: user.id, activity_id: react_2.id) + assert %{type: "pleroma:chat_mention"} = Repo.get_by(Notification, user_id: other_user.id, activity_id: chat.id) end From cc8a7dc205a4516452c48659e6bf081f3f730496 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 5 Jun 2020 12:01:33 +0200 Subject: [PATCH 206/401] SideEffects / ChatView: Add an unread cache. This is to prevent wrong values in the stream. --- lib/pleroma/web/activity_pub/side_effects.ex | 5 ++++ .../web/pleroma_api/views/chat_view.ex | 2 +- lib/pleroma/web/streamer/streamer.ex | 28 +++++-------------- lib/pleroma/web/views/streamer_view.ex | 2 ++ test/web/pleroma_api/views/chat_view_test.exs | 15 ++++++++++ 5 files changed, 30 insertions(+), 22 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index e9f109d80..992c04ac1 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -142,6 +142,11 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) {:ok, cm_ref} = ChatMessageReference.create(chat, object, user.ap_id != actor.ap_id) + # We add a cache of the unread value here so that it doesn't change when being streamed out + chat = + chat + |> Map.put(:unread, ChatMessageReference.unread_count_for_chat(chat)) + Streamer.stream( ["user", "user:pleroma_chat"], {user, %{cm_ref | chat: chat, object: object}} diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index c903a71fd..91d50dd1e 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -20,7 +20,7 @@ def render("show.json", %{chat: %Chat{} = chat} = opts) do %{ id: chat.id |> to_string(), account: AccountView.render("show.json", Map.put(opts, :user, recipient)), - unread: ChatMessageReference.unread_count_for_chat(chat), + unread: Map.get(chat, :unread) || ChatMessageReference.unread_count_for_chat(chat), last_message: last_message && ChatMessageReferenceView.render("show.json", chat_message_reference: last_message), diff --git a/lib/pleroma/web/streamer/streamer.ex b/lib/pleroma/web/streamer/streamer.ex index 5e37e2cf2..b22297955 100644 --- a/lib/pleroma/web/streamer/streamer.ex +++ b/lib/pleroma/web/streamer/streamer.ex @@ -90,34 +90,20 @@ def remove_socket(topic) do if should_env_send?(), do: Registry.unregister(@registry, topic) end - def stream(topics, item) when is_list(topics) do + def stream(topics, items) do if should_env_send?() do - Enum.each(topics, fn t -> - spawn(fn -> do_stream(t, item) end) + List.wrap(topics) + |> Enum.each(fn topic -> + List.wrap(items) + |> Enum.each(fn item -> + spawn(fn -> do_stream(topic, item) end) + end) end) end :ok end - def stream(topic, items) when is_list(items) do - if should_env_send?() do - Enum.each(items, fn i -> - spawn(fn -> do_stream(topic, i) end) - end) - - :ok - end - end - - def stream(topic, item) do - if should_env_send?() do - spawn(fn -> do_stream(topic, item) end) - end - - :ok - end - def filtered_by_user?(%User{} = user, %Activity{} = item) do %{block: blocked_ap_ids, mute: muted_ap_ids, reblog_mute: reblog_muted_ap_ids} = User.outgoing_relationships_ap_ids(user, [:block, :mute, :reblog_mute]) diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index a6efd0109..b000e7ce0 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -55,6 +55,8 @@ def render("chat_update.json", %{chat_message_reference: cm_ref}) do # Explicitly giving the cmr for the object here, so we don't accidentally # send a later 'last_message' that was inserted between inserting this and # streaming it out + # + # It also contains the chat with a cache of the correct unread count Logger.debug("Trying to stream out #{inspect(cm_ref)}") representation = diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index f3bd12616..f77584dd1 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -16,6 +16,21 @@ defmodule Pleroma.Web.PleromaAPI.ChatViewTest do import Pleroma.Factory + test "giving a chat with an 'unread' field, it uses that" do + user = insert(:user) + recipient = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, recipient.ap_id) + + chat = + chat + |> Map.put(:unread, 5) + + represented_chat = ChatView.render("show.json", chat: chat) + + assert represented_chat[:unread] == 5 + end + test "it represents a chat" do user = insert(:user) recipient = insert(:user) From 0efa8aa0b9567f42b1af63e2b93a9c51e9a0fb11 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 5 Jun 2020 12:26:07 +0200 Subject: [PATCH 207/401] Transmogrifier: For follows, create notifications last. As the notification type changes depending on the follow state, the notification should not be created and streamed out before the state settles. For this reason, the notification creation has been delayed until it's clear if the user has been followed or not. This is a bit hacky but it will be properly rewritten using the pipeline soon. --- lib/pleroma/web/activity_pub/activity_pub.ex | 12 +++++++----- lib/pleroma/web/activity_pub/transmogrifier.ex | 5 +++-- .../transmogrifier/follow_handling_test.exs | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 568db2348..4f7043c92 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -363,19 +363,21 @@ def update(%{to: to, cc: cc, actor: actor, object: object} = params) do end end - @spec follow(User.t(), User.t(), String.t() | nil, boolean()) :: + @spec follow(User.t(), User.t(), String.t() | nil, boolean(), keyword()) :: {:ok, Activity.t()} | {:error, any()} - def follow(follower, followed, activity_id \\ nil, local \\ true) do + def follow(follower, followed, activity_id \\ nil, local \\ true, opts \\ []) do with {:ok, result} <- - Repo.transaction(fn -> do_follow(follower, followed, activity_id, local) end) do + Repo.transaction(fn -> do_follow(follower, followed, activity_id, local, opts) end) do result end end - defp do_follow(follower, followed, activity_id, local) do + defp do_follow(follower, followed, activity_id, local, opts) do + skip_notify_and_stream = Keyword.get(opts, :skip_notify_and_stream, false) + with data <- make_follow_data(follower, followed, activity_id), {:ok, activity} <- insert(data, local), - _ <- notify_and_stream(activity), + _ <- skip_notify_and_stream || notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} else diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index b2461de2b..50f3216f3 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -533,12 +533,12 @@ def handle_incoming( User.get_cached_by_ap_id(Containment.get_actor(%{"actor" => followed})), {:ok, %User{} = follower} <- User.get_or_fetch_by_ap_id(Containment.get_actor(%{"actor" => follower})), - {:ok, activity} <- ActivityPub.follow(follower, followed, id, false) do + {:ok, activity} <- + ActivityPub.follow(follower, followed, id, false, skip_notify_and_stream: true) do with deny_follow_blocked <- Pleroma.Config.get([:user, :deny_follow_blocked]), {_, false} <- {:user_blocked, User.blocks?(followed, follower) && deny_follow_blocked}, {_, false} <- {:user_locked, User.locked?(followed)}, {_, {:ok, follower}} <- {:follow, User.follow(follower, followed)}, - _ <- Notification.update_notification_type(followed, activity), {_, {:ok, _}} <- {:follow_state_update, Utils.update_follow_state_for_all(activity, "accept")}, {:ok, _relationship} <- @@ -577,6 +577,7 @@ def handle_incoming( :noop end + ActivityPub.notify_and_stream(activity) {:ok, activity} else _e -> diff --git a/test/web/activity_pub/transmogrifier/follow_handling_test.exs b/test/web/activity_pub/transmogrifier/follow_handling_test.exs index 6b003b51c..06c39eed6 100644 --- a/test/web/activity_pub/transmogrifier/follow_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/follow_handling_test.exs @@ -13,6 +13,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.FollowHandlingTest do import Pleroma.Factory import Ecto.Query + import Mock setup_all do Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) @@ -151,6 +152,23 @@ test "it rejects incoming follow requests from blocked users when deny_follow_bl assert activity.data["state"] == "reject" end + test "it rejects incoming follow requests if the following errors for some reason" do + user = insert(:user) + + data = + File.read!("test/fixtures/mastodon-follow-activity.json") + |> Poison.decode!() + |> Map.put("object", user.ap_id) + + with_mock Pleroma.User, [:passthrough], follow: fn _, _ -> {:error, :testing} end do + {:ok, %Activity{data: %{"id" => id}}} = Transmogrifier.handle_incoming(data) + + %Activity{} = activity = Activity.get_by_ap_id(id) + + assert activity.data["state"] == "reject" + end + end + test "it works for incoming follow requests from hubzilla" do user = insert(:user) From f3ea6ee2c82b2d63991d3e658566298c722ac0af Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 5 Jun 2020 12:45:25 +0200 Subject: [PATCH 208/401] Credo fixes. --- lib/pleroma/web/activity_pub/side_effects.ex | 3 ++- lib/pleroma/web/views/streamer_view.ex | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 992c04ac1..e7d050e81 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -142,7 +142,8 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) {:ok, cm_ref} = ChatMessageReference.create(chat, object, user.ap_id != actor.ap_id) - # We add a cache of the unread value here so that it doesn't change when being streamed out + # We add a cache of the unread value here so that it + # doesn't change when being streamed out chat = chat |> Map.put(:unread, ChatMessageReference.unread_count_for_chat(chat)) diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index b000e7ce0..476a33245 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -55,7 +55,7 @@ def render("chat_update.json", %{chat_message_reference: cm_ref}) do # Explicitly giving the cmr for the object here, so we don't accidentally # send a later 'last_message' that was inserted between inserting this and # streaming it out - # + # # It also contains the chat with a cache of the correct unread count Logger.debug("Trying to stream out #{inspect(cm_ref)}") From 65689ba9bd44e291fc626cce2bd5136b93a5da90 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 5 Jun 2020 13:10:48 +0200 Subject: [PATCH 209/401] If Credo fixes is so good, why is there no Credo fixes 2? --- lib/pleroma/web/activity_pub/side_effects.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index e7d050e81..b3aacff40 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -142,7 +142,7 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) {:ok, cm_ref} = ChatMessageReference.create(chat, object, user.ap_id != actor.ap_id) - # We add a cache of the unread value here so that it + # We add a cache of the unread value here so that it # doesn't change when being streamed out chat = chat From 115d08a7542b92c5e1d889da41c0ee6837a1235e Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 5 Jun 2020 16:47:02 +0200 Subject: [PATCH 210/401] Pipeline: Add a side effects step after the transaction finishes This is to run things like streaming notifications out, which will sometimes need data that is created by the transaction, but is streamed out asynchronously. --- lib/pleroma/notification.ex | 26 ++++-- lib/pleroma/web/activity_pub/pipeline.ex | 4 + lib/pleroma/web/activity_pub/side_effects.ex | 30 ++++++- test/web/activity_pub/pipeline_test.exs | 9 +- test/web/activity_pub/side_effects_test.exs | 86 +++++++++++++++++--- test/web/common_api/common_api_test.exs | 43 ++++++++-- 6 files changed, 169 insertions(+), 29 deletions(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index e5b880b10..49e27c05a 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -334,30 +334,34 @@ def dismiss(%{id: user_id} = _user, id) do end end - def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity) do + def create_notifications(activity, options \\ []) + + def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity, options) do object = Object.normalize(activity, false) if object && object.data["type"] == "Answer" do {:ok, []} else - do_create_notifications(activity) + do_create_notifications(activity, options) end end - def create_notifications(%Activity{data: %{"type" => type}} = activity) + def create_notifications(%Activity{data: %{"type" => type}} = activity, options) when type in ["Follow", "Like", "Announce", "Move", "EmojiReact"] do - do_create_notifications(activity) + do_create_notifications(activity, options) end - def create_notifications(_), do: {:ok, []} + def create_notifications(_, _), do: {:ok, []} + + defp do_create_notifications(%Activity{} = activity, options) do + do_send = Keyword.get(options, :do_send, true) - defp do_create_notifications(%Activity{} = activity) do {enabled_receivers, disabled_receivers} = get_notified_from_activity(activity) potential_receivers = enabled_receivers ++ disabled_receivers notifications = Enum.map(potential_receivers, fn user -> - do_send = user in enabled_receivers + do_send = do_send && user in enabled_receivers create_notification(activity, user, do_send) end) @@ -623,4 +627,12 @@ def skip?(:recently_followed, %Activity{data: %{"type" => "Follow"}} = activity, end def skip?(_, _, _), do: false + + def for_user_and_activity(user, activity) do + from(n in __MODULE__, + where: n.user_id == ^user.id, + where: n.activity_id == ^activity.id + ) + |> Repo.one() + end end diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 0c54c4b23..6875c47f6 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -17,6 +17,10 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do {:ok, Activity.t() | Object.t(), keyword()} | {:error, any()} def common_pipeline(object, meta) do case Repo.transaction(fn -> do_common_pipeline(object, meta) end) do + {:ok, {:ok, activity, meta}} -> + SideEffects.handle_after_transaction(meta) + {:ok, activity, meta} + {:ok, value} -> value diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index b3aacff40..10136789a 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -16,6 +16,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.Streamer + alias Pleroma.Web.Push def handle(object, meta \\ []) @@ -37,7 +38,12 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do # - Set up notifications def handle(%{data: %{"type" => "Create"}} = activity, meta) do with {:ok, _object, _meta} <- handle_object_creation(meta[:object_data], meta) do - Notification.create_notifications(activity) + {:ok, notifications} = Notification.create_notifications(activity, do_send: false) + + meta = + meta + |> add_notifications(notifications) + {:ok, activity, meta} else e -> Repo.rollback(e) @@ -200,4 +206,26 @@ def handle_undoing( end def handle_undoing(object), do: {:error, ["don't know how to handle", object]} + + defp send_notifications(meta) do + Keyword.get(meta, :created_notifications, []) + |> Enum.each(fn notification -> + Streamer.stream(["user", "user:notification"], notification) + Push.send(notification) + end) + + meta + end + + defp add_notifications(meta, notifications) do + existing = Keyword.get(meta, :created_notifications, []) + + meta + |> Keyword.put(:created_notifications, notifications ++ existing) + end + + def handle_after_transaction(meta) do + meta + |> send_notifications() + end end diff --git a/test/web/activity_pub/pipeline_test.exs b/test/web/activity_pub/pipeline_test.exs index 26557720b..8deb64501 100644 --- a/test/web/activity_pub/pipeline_test.exs +++ b/test/web/activity_pub/pipeline_test.exs @@ -33,7 +33,10 @@ test "it goes through validation, filtering, persisting, side effects and federa { Pleroma.Web.ActivityPub.SideEffects, [], - [handle: fn o, m -> {:ok, o, m} end] + [ + handle: fn o, m -> {:ok, o, m} end, + handle_after_transaction: fn m -> m end + ] }, { Pleroma.Web.Federator, @@ -71,7 +74,7 @@ test "it goes through validation, filtering, persisting, side effects without fe { Pleroma.Web.ActivityPub.SideEffects, [], - [handle: fn o, m -> {:ok, o, m} end] + [handle: fn o, m -> {:ok, o, m} end, handle_after_transaction: fn m -> m end] }, { Pleroma.Web.Federator, @@ -110,7 +113,7 @@ test "it goes through validation, filtering, persisting, side effects without fe { Pleroma.Web.ActivityPub.SideEffects, [], - [handle: fn o, m -> {:ok, o, m} end] + [handle: fn o, m -> {:ok, o, m} end, handle_after_transaction: fn m -> m end] }, { Pleroma.Web.Federator, diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 40df664eb..43ffe1337 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -22,6 +22,47 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do import Pleroma.Factory import Mock + describe "handle_after_transaction" do + test "it streams out notifications" do + author = insert(:user, local: true) + recipient = insert(:user, local: true) + + {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") + + {:ok, create_activity_data, _meta} = + Builder.create(author, chat_message_data["id"], [recipient.ap_id]) + + {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) + + {:ok, _create_activity, meta} = + SideEffects.handle(create_activity, local: false, object_data: chat_message_data) + + assert [notification] = meta[:created_notifications] + + with_mocks([ + { + Pleroma.Web.Streamer, + [], + [ + stream: fn _, _ -> nil end + ] + }, + { + Pleroma.Web.Push, + [], + [ + send: fn _ -> nil end + ] + } + ]) do + SideEffects.handle_after_transaction(meta) + + assert called(Pleroma.Web.Streamer.stream(["user", "user:notification"], notification)) + assert called(Pleroma.Web.Push.send(notification)) + end + end + end + describe "delete objects" do setup do user = insert(:user) @@ -361,22 +402,47 @@ test "it creates a Chat and ChatMessageReferences for the local users and bumps {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) - {:ok, _create_activity, _meta} = - SideEffects.handle(create_activity, local: false, object_data: chat_message_data) + with_mocks([ + { + Pleroma.Web.Streamer, + [], + [ + stream: fn _, _ -> nil end + ] + }, + { + Pleroma.Web.Push, + [], + [ + send: fn _ -> nil end + ] + } + ]) do + {:ok, _create_activity, meta} = + SideEffects.handle(create_activity, local: false, object_data: chat_message_data) - chat = Chat.get(author.id, recipient.ap_id) + # The notification gets created + assert [notification] = meta[:created_notifications] + assert notification.activity_id == create_activity.id - [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() + # But it is not sent out + refute called(Pleroma.Web.Streamer.stream(["user", "user:notification"], notification)) + refute called(Pleroma.Web.Push.send(notification)) - assert cm_ref.object.data["content"] == "hey" - assert cm_ref.unread == false + chat = Chat.get(author.id, recipient.ap_id) - chat = Chat.get(recipient.id, author.ap_id) + [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() - [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() + assert cm_ref.object.data["content"] == "hey" + assert cm_ref.unread == false - assert cm_ref.object.data["content"] == "hey" - assert cm_ref.unread == true + chat = Chat.get(recipient.id, author.ap_id) + + [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() + + assert cm_ref.object.data["content"] == "hey" + assert cm_ref.unread == true + end end test "it creates a Chat for the local users and bumps the unread count" do diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 611a9ae66..63b59820e 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -7,6 +7,7 @@ defmodule Pleroma.Web.CommonAPITest do alias Pleroma.Activity alias Pleroma.Chat alias Pleroma.Conversation.Participation + alias Pleroma.Notification alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub @@ -39,15 +40,41 @@ test "it posts a chat message without content but with an attachment" do {:ok, upload} = ActivityPub.upload(file, actor: author.ap_id) - {:ok, activity} = - CommonAPI.post_chat_message( - author, - recipient, - nil, - media_id: upload.id - ) + with_mocks([ + { + Pleroma.Web.Streamer, + [], + [ + stream: fn _, _ -> + nil + end + ] + }, + { + Pleroma.Web.Push, + [], + [ + send: fn _ -> nil end + ] + } + ]) do + {:ok, activity} = + CommonAPI.post_chat_message( + author, + recipient, + nil, + media_id: upload.id + ) - assert activity + notification = + Notification.for_user_and_activity(recipient, activity) + |> Repo.preload(:activity) + + assert called(Pleroma.Web.Push.send(notification)) + assert called(Pleroma.Web.Streamer.stream(["user", "user:notification"], notification)) + + assert activity + end end test "it adds html newlines" do From 54bae06b4fa960eadb9918414f50b9ececc1faa4 Mon Sep 17 00:00:00 2001 From: Haelwenn Date: Fri, 5 Jun 2020 14:48:02 +0000 Subject: [PATCH 211/401] Create Pleroma.Maps.put_if_present(map, key, value, value_fun // &{:ok, &1}) Unifies all the similar functions to one and simplify some blocks with it. --- lib/pleroma/helpers/uri_helper.ex | 8 ----- lib/pleroma/maps.ex | 15 ++++++++ lib/pleroma/web/activity_pub/activity_pub.ex | 20 +++-------- .../web/activity_pub/transmogrifier.ex | 17 ++++----- lib/pleroma/web/activity_pub/utils.ex | 18 +++++----- .../controllers/config_controller.ex | 5 ++- .../controllers/oauth_app_controller.ex | 14 ++------ lib/pleroma/web/controller_helper.ex | 5 --- lib/pleroma/web/feed/tag_controller.ex | 4 +-- lib/pleroma/web/feed/user_controller.ex | 4 +-- .../controllers/account_controller.ex | 36 +++++++------------ .../web/mastodon_api/views/app_view.ex | 6 +--- .../views/scheduled_activity_view.ex | 8 ++--- lib/pleroma/web/oauth/oauth_controller.ex | 5 +-- 14 files changed, 59 insertions(+), 106 deletions(-) create mode 100644 lib/pleroma/maps.ex diff --git a/lib/pleroma/helpers/uri_helper.ex b/lib/pleroma/helpers/uri_helper.ex index 69d8c8fe0..6d205a636 100644 --- a/lib/pleroma/helpers/uri_helper.ex +++ b/lib/pleroma/helpers/uri_helper.ex @@ -17,14 +17,6 @@ def append_uri_params(uri, appended_params) do |> URI.to_string() end - def append_param_if_present(%{} = params, param_name, param_value) do - if param_value do - Map.put(params, param_name, param_value) - else - params - end - end - def maybe_add_base("/" <> uri, base), do: Path.join([base, uri]) def maybe_add_base(uri, _base), do: uri end diff --git a/lib/pleroma/maps.ex b/lib/pleroma/maps.ex new file mode 100644 index 000000000..ab2e32e2f --- /dev/null +++ b/lib/pleroma/maps.ex @@ -0,0 +1,15 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Maps do + def put_if_present(map, key, value, value_function \\ &{:ok, &1}) when is_map(map) do + with false <- is_nil(key), + false <- is_nil(value), + {:ok, new_value} <- value_function.(value) do + Map.put(map, key, new_value) + else + _ -> map + end + end +end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 958f3e5af..75468f415 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do alias Pleroma.Constants alias Pleroma.Conversation alias Pleroma.Conversation.Participation + alias Pleroma.Maps alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Object.Containment @@ -19,7 +20,6 @@ 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 @@ -161,12 +161,7 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when }) # Splice in the child object if we have one. - activity = - if not is_nil(object) do - Map.put(activity, :object, object) - else - activity - end + activity = Maps.put_if_present(activity, :object, object) BackgroundWorker.enqueue("fetch_data_for_activity", %{"activity_id" => activity.id}) @@ -328,7 +323,7 @@ def accept_or_reject(type, %{to: to, actor: actor, object: object} = params) do with data <- %{"to" => to, "type" => type, "actor" => actor.ap_id, "object" => object} - |> Utils.maybe_put("id", activity_id), + |> Maps.put_if_present("id", activity_id), {:ok, activity} <- insert(data, local), _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do @@ -348,7 +343,7 @@ def update(%{to: to, cc: cc, actor: actor, object: object} = params) do "actor" => actor, "object" => object }, - data <- Utils.maybe_put(data, "id", activity_id), + data <- Maps.put_if_present(data, "id", activity_id), {:ok, activity} <- insert(data, local), _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do @@ -1225,12 +1220,7 @@ def fetch_activities_bounded( @spec upload(Upload.source(), keyword()) :: {:ok, Object.t()} | {:error, any()} def upload(file, opts \\ []) do with {:ok, data} <- Upload.store(file, opts) do - obj_data = - if opts[:actor] do - Map.put(data, "actor", opts[:actor]) - else - data - end + obj_data = Maps.put_if_present(data, "actor", opts[:actor]) Repo.insert(%Object{data: obj_data}) end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 8443c284c..fda1c71df 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do alias Pleroma.Activity alias Pleroma.EarmarkRenderer alias Pleroma.FollowingRelationship + alias Pleroma.Maps alias Pleroma.Object alias Pleroma.Object.Containment alias Pleroma.Repo @@ -208,12 +209,6 @@ def fix_context(object) do |> Map.put("conversation", context) end - defp add_if_present(map, _key, nil), do: map - - defp add_if_present(map, key, value) do - Map.put(map, key, value) - end - def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do attachments = Enum.map(attachment, fn data -> @@ -241,13 +236,13 @@ def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachm attachment_url = %{"href" => href} - |> add_if_present("mediaType", media_type) - |> add_if_present("type", Map.get(url || %{}, "type")) + |> Maps.put_if_present("mediaType", media_type) + |> Maps.put_if_present("type", Map.get(url || %{}, "type")) %{"url" => [attachment_url]} - |> add_if_present("mediaType", media_type) - |> add_if_present("type", data["type"]) - |> add_if_present("name", data["name"]) + |> Maps.put_if_present("mediaType", media_type) + |> Maps.put_if_present("type", data["type"]) + |> Maps.put_if_present("name", data["name"]) end) Map.put(object, "attachment", attachments) diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index a76a699ee..5fce0ba63 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do alias Ecto.UUID alias Pleroma.Activity alias Pleroma.Config + alias Pleroma.Maps alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo @@ -307,7 +308,7 @@ def make_like_data( "cc" => cc, "context" => object.data["context"] } - |> maybe_put("id", activity_id) + |> Maps.put_if_present("id", activity_id) end def make_emoji_reaction_data(user, object, emoji, activity_id) do @@ -477,7 +478,7 @@ def make_follow_data( "object" => followed_id, "state" => "pending" } - |> maybe_put("id", activity_id) + |> Maps.put_if_present("id", activity_id) end def fetch_latest_follow(%User{ap_id: follower_id}, %User{ap_id: followed_id}) do @@ -546,7 +547,7 @@ def make_announce_data( "cc" => [], "context" => object.data["context"] } - |> maybe_put("id", activity_id) + |> Maps.put_if_present("id", activity_id) end def make_announce_data( @@ -563,7 +564,7 @@ def make_announce_data( "cc" => [Pleroma.Constants.as_public()], "context" => object.data["context"] } - |> maybe_put("id", activity_id) + |> Maps.put_if_present("id", activity_id) end def make_undo_data( @@ -582,7 +583,7 @@ def make_undo_data( "cc" => [Pleroma.Constants.as_public()], "context" => context } - |> maybe_put("id", activity_id) + |> Maps.put_if_present("id", activity_id) end @spec add_announce_to_object(Activity.t(), Object.t()) :: @@ -627,7 +628,7 @@ def make_unfollow_data(follower, followed, follow_activity, activity_id) do "to" => [followed.ap_id], "object" => follow_activity.data } - |> maybe_put("id", activity_id) + |> Maps.put_if_present("id", activity_id) end #### Block-related helpers @@ -650,7 +651,7 @@ def make_block_data(blocker, blocked, activity_id) do "to" => [blocked.ap_id], "object" => blocked.ap_id } - |> maybe_put("id", activity_id) + |> Maps.put_if_present("id", activity_id) end #### Create-related helpers @@ -871,7 +872,4 @@ def get_existing_votes(actor, %{data: %{"id" => id}}) do |> where([a, object: o], fragment("(?)->>'type' = 'Answer'", o.data)) |> Repo.all() end - - 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/admin_api/controllers/config_controller.ex b/lib/pleroma/web/admin_api/controllers/config_controller.ex index e221d9418..d6e2019bc 100644 --- a/lib/pleroma/web/admin_api/controllers/config_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/config_controller.ex @@ -61,13 +61,12 @@ def show(conn, _params) do value end - setting = %{ + %{ group: ConfigDB.convert(group), key: ConfigDB.convert(key), value: ConfigDB.convert(merged_value) } - - if db, do: Map.put(setting, :db, db), else: setting + |> Pleroma.Maps.put_if_present(:db, db) end) end) |> List.flatten() diff --git a/lib/pleroma/web/admin_api/controllers/oauth_app_controller.ex b/lib/pleroma/web/admin_api/controllers/oauth_app_controller.ex index 04e629fc1..dca23ea73 100644 --- a/lib/pleroma/web/admin_api/controllers/oauth_app_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/oauth_app_controller.ex @@ -42,12 +42,7 @@ def index(conn, params) do end def create(%{body_params: params} = conn, _) do - params = - if params[:name] do - Map.put(params, :client_name, params[:name]) - else - params - end + params = Pleroma.Maps.put_if_present(params, :client_name, params[:name]) case App.create(params) do {:ok, app} -> @@ -59,12 +54,7 @@ def create(%{body_params: params} = conn, _) do end def update(%{body_params: params} = conn, %{id: id}) do - params = - if params[:name] do - Map.put(params, :client_name, params.name) - else - params - end + params = Pleroma.Maps.put_if_present(params, :client_name, params[:name]) with {:ok, app} <- App.update(id, params) do render(conn, "show.json", app: app, admin: true) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 5a1316a5f..bf832fe94 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -99,11 +99,6 @@ def try_render(conn, _, _) do render_error(conn, :not_implemented, "Can't display this activity") end - @spec put_if_exist(map(), atom() | String.t(), any) :: map() - def put_if_exist(map, _key, nil), do: map - - def put_if_exist(map, key, value), do: Map.put(map, key, value) - @doc """ Returns true if request specifies to include embedded relationships in account objects. May only be used in selected account-related endpoints; has no effect for status- or diff --git a/lib/pleroma/web/feed/tag_controller.ex b/lib/pleroma/web/feed/tag_controller.ex index 8133f8480..4e86cfeb5 100644 --- a/lib/pleroma/web/feed/tag_controller.ex +++ b/lib/pleroma/web/feed/tag_controller.ex @@ -9,14 +9,12 @@ defmodule Pleroma.Web.Feed.TagController do alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.Feed.FeedView - import Pleroma.Web.ControllerHelper, only: [put_if_exist: 3] - def feed(conn, %{"tag" => raw_tag} = params) do {format, tag} = parse_tag(raw_tag) activities = %{"type" => ["Create"], "tag" => tag} - |> put_if_exist("max_id", params["max_id"]) + |> Pleroma.Maps.put_if_present("max_id", params["max_id"]) |> ActivityPub.fetch_public_activities() conn diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex index 5a6fc9de0..7c2e0d522 100644 --- a/lib/pleroma/web/feed/user_controller.ex +++ b/lib/pleroma/web/feed/user_controller.ex @@ -11,8 +11,6 @@ defmodule Pleroma.Web.Feed.UserController do alias Pleroma.Web.ActivityPub.ActivityPubController alias Pleroma.Web.Feed.FeedView - import Pleroma.Web.ControllerHelper, only: [put_if_exist: 3] - plug(Pleroma.Plugs.SetFormatPlug when action in [:feed_redirect]) action_fallback(:errors) @@ -55,7 +53,7 @@ def feed(conn, %{"nickname" => nickname} = params) do "type" => ["Create"], "actor_id" => user.ap_id } - |> put_if_exist("max_id", params["max_id"]) + |> Pleroma.Maps.put_if_present("max_id", params["max_id"]) |> ActivityPub.fetch_public_or_unlisted_activities() conn diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 97295a52f..5734bb854 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -14,6 +14,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do json_response: 3 ] + alias Pleroma.Maps alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.RateLimiter @@ -160,23 +161,22 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p :discoverable ] |> Enum.reduce(%{}, fn key, acc -> - add_if_present(acc, params, key, key, &{:ok, truthy_param?(&1)}) + Maps.put_if_present(acc, key, params[key], &{:ok, truthy_param?(&1)}) end) - |> add_if_present(params, :display_name, :name) - |> add_if_present(params, :note, :bio) - |> add_if_present(params, :avatar, :avatar) - |> add_if_present(params, :header, :banner) - |> add_if_present(params, :pleroma_background_image, :background) - |> add_if_present( - params, - :fields_attributes, + |> Maps.put_if_present(:name, params[:display_name]) + |> Maps.put_if_present(:bio, params[:note]) + |> Maps.put_if_present(:avatar, params[:avatar]) + |> Maps.put_if_present(:banner, params[:header]) + |> Maps.put_if_present(:background, params[:pleroma_background_image]) + |> Maps.put_if_present( :raw_fields, + params[:fields_attributes], &{:ok, normalize_fields_attributes(&1)} ) - |> add_if_present(params, :pleroma_settings_store, :pleroma_settings_store) - |> add_if_present(params, :default_scope, :default_scope) - |> add_if_present(params["source"], "privacy", :default_scope) - |> add_if_present(params, :actor_type, :actor_type) + |> Maps.put_if_present(:pleroma_settings_store, params[:pleroma_settings_store]) + |> Maps.put_if_present(:default_scope, params[:default_scope]) + |> Maps.put_if_present(:default_scope, params["source"]["privacy"]) + |> Maps.put_if_present(:actor_type, params[:actor_type]) changeset = User.update_changeset(user, user_params) @@ -206,16 +206,6 @@ defp build_update_activity_params(user) do } end - defp add_if_present(map, params, params_field, map_field, value_function \\ &{:ok, &1}) do - with true <- is_map(params), - true <- Map.has_key?(params, params_field), - {:ok, new_value} <- value_function.(Map.get(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) diff --git a/lib/pleroma/web/mastodon_api/views/app_view.ex b/lib/pleroma/web/mastodon_api/views/app_view.ex index 36071cd25..e44272c6f 100644 --- a/lib/pleroma/web/mastodon_api/views/app_view.ex +++ b/lib/pleroma/web/mastodon_api/views/app_view.ex @@ -45,10 +45,6 @@ def render("short.json", %{app: %App{website: webiste, client_name: name}}) do defp with_vapid_key(data) do vapid_key = Application.get_env(:web_push_encryption, :vapid_details, [])[:public_key] - if vapid_key do - Map.put(data, "vapid_key", vapid_key) - else - data - end + Pleroma.Maps.put_if_present(data, "vapid_key", vapid_key) end end 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 458f6bc78..5b896bf3b 100644 --- a/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex +++ b/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex @@ -30,7 +30,7 @@ defp with_media_attachments(data, %{params: %{"media_attachments" => media_attac defp with_media_attachments(data, _), do: data defp status_params(params) do - data = %{ + %{ text: params["status"], sensitive: params["sensitive"], spoiler_text: params["spoiler_text"], @@ -39,10 +39,6 @@ defp status_params(params) do poll: params["poll"], in_reply_to_id: params["in_reply_to_id"] } - - case params["media_ids"] do - nil -> data - media_ids -> Map.put(data, :media_ids, media_ids) - end + |> Pleroma.Maps.put_if_present(:media_ids, params["media_ids"]) end end diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index 7c804233c..c557778ca 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do use Pleroma.Web, :controller alias Pleroma.Helpers.UriHelper + alias Pleroma.Maps alias Pleroma.MFA alias Pleroma.Plugs.RateLimiter alias Pleroma.Registration @@ -108,7 +109,7 @@ defp handle_existing_authorization( if redirect_uri in String.split(app.redirect_uris) do redirect_uri = redirect_uri(conn, redirect_uri) url_params = %{access_token: token.token} - url_params = UriHelper.append_param_if_present(url_params, :state, params["state"]) + url_params = Maps.put_if_present(url_params, :state, params["state"]) url = UriHelper.append_uri_params(redirect_uri, url_params) redirect(conn, external: url) else @@ -147,7 +148,7 @@ def after_create_authorization(%Plug.Conn{} = conn, %Authorization{} = auth, %{ if redirect_uri in String.split(app.redirect_uris) do redirect_uri = redirect_uri(conn, redirect_uri) url_params = %{code: auth.token} - url_params = UriHelper.append_param_if_present(url_params, :state, auth_attrs["state"]) + url_params = Maps.put_if_present(url_params, :state, auth_attrs["state"]) url = UriHelper.append_uri_params(redirect_uri, url_params) redirect(conn, external: url) else From f24d2f714f44175cae9fcd878de1629ee32be73c Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 5 Jun 2020 17:18:48 +0200 Subject: [PATCH 212/401] Credo fixes --- lib/pleroma/web/activity_pub/side_effects.ex | 2 +- lib/pleroma/web/activity_pub/transmogrifier.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 10136789a..5258212ec 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -15,8 +15,8 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils - alias Pleroma.Web.Streamer alias Pleroma.Web.Push + alias Pleroma.Web.Streamer def handle(object, meta \\ []) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index f97ab510e..d2347cdc9 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -9,8 +9,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do alias Pleroma.Activity alias Pleroma.EarmarkRenderer alias Pleroma.FollowingRelationship - alias Pleroma.Notification alias Pleroma.Maps + alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Object.Containment alias Pleroma.Repo From 167812a3f2c1470012cb161f3c5ba4c021fbad97 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 5 Jun 2020 23:18:29 +0400 Subject: [PATCH 213/401] Fix pagination --- lib/pleroma/pagination.ex | 3 +++ lib/pleroma/web/activity_pub/activity_pub_controller.ex | 2 ++ 2 files changed, 5 insertions(+) diff --git a/lib/pleroma/pagination.ex b/lib/pleroma/pagination.ex index 0ccc7b1f2..1b99e44f9 100644 --- a/lib/pleroma/pagination.ex +++ b/lib/pleroma/pagination.ex @@ -16,6 +16,9 @@ defmodule Pleroma.Pagination do @default_limit 20 @max_limit 40 + @page_keys ["max_id", "min_id", "limit", "since_id", "order"] + + def page_keys, do: @page_keys @spec fetch_paginated(Ecto.Query.t(), map(), type(), atom() | nil) :: [Ecto.Schema.t()] def fetch_paginated(query, params, type \\ :keyset, table_binding \\ nil) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 5b8441384..f0b5c6e93 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -238,6 +238,7 @@ def outbox( params |> Map.drop(["nickname", "page"]) |> Map.put("include_poll_votes", true) + |> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end) activities = ActivityPub.fetch_user_activities(user, for_user, params) @@ -354,6 +355,7 @@ def read_inbox( |> Map.drop(["nickname", "page"]) |> Map.put("blocking_user", user) |> Map.put("user", user) + |> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end) activities = [user.ap_id | User.following(user)] From 4e8c0eecd5179428a47795380a9da1ab0419e024 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 6 Jun 2020 09:46:07 +0200 Subject: [PATCH 214/401] WebPush: Don't break on contentless chat messages. --- lib/pleroma/web/push/impl.ex | 7 +++++++ test/web/push/impl_test.exs | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index 006a242af..cdb827e76 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -124,6 +124,13 @@ def build_content(notification, actor, object, mastodon_type) do def format_body(activity, actor, object, mastodon_type \\ nil) + def format_body(_activity, actor, %{data: %{"type" => "ChatMessage", "content" => content}}, _) do + case content do + nil -> "@#{actor.nickname}: (Attachment)" + content -> "@#{actor.nickname}: #{Utils.scrub_html_and_truncate(content, 80)}" + end + end + def format_body( %{activity: %{data: %{"type" => "Create"}}}, actor, diff --git a/test/web/push/impl_test.exs b/test/web/push/impl_test.exs index 8fb7faaa5..b48952b29 100644 --- a/test/web/push/impl_test.exs +++ b/test/web/push/impl_test.exs @@ -8,6 +8,7 @@ defmodule Pleroma.Web.Push.ImplTest do alias Pleroma.Notification alias Pleroma.Object alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI alias Pleroma.Web.Push.Impl alias Pleroma.Web.Push.Subscription @@ -213,6 +214,30 @@ test "builds content for chat messages" do } end + test "builds content for chat messages with no content" do + user = insert(:user) + recipient = insert(:user) + + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id) + + {:ok, chat} = CommonAPI.post_chat_message(user, recipient, nil, media_id: upload.id) + object = Object.normalize(chat, false) + [notification] = Notification.for_user(recipient) + + res = Impl.build_content(notification, user, object) + + assert res == %{ + body: "@#{user.nickname}: (Attachment)", + title: "New Chat Message" + } + end + test "hides details for notifications when privacy option enabled" do user = insert(:user, nickname: "Bob") user2 = insert(:user, nickname: "Rob", notification_settings: %{privacy_option: true}) From c5e3f2454c736e09de5c433a2bf578e8eb0e70c3 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 6 Jun 2020 10:35:38 +0200 Subject: [PATCH 215/401] Docs: Unify parameters in examples. --- docs/API/chats.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index abeee698f..761047336 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -41,7 +41,7 @@ This is the overview of using the API. The API is also documented via OpenAPI, s To create or get an existing Chat for a certain recipient (identified by Account ID) you can call: -`POST /api/v1/pleroma/chats/by-account-id/{account_id}` +`POST /api/v1/pleroma/chats/by-account-id/:account_id` The account id is the normal FlakeId of the user ``` @@ -136,7 +136,7 @@ The usual pagination options are implemented. For a given Chat id, you can get the associated messages with -`GET /api/v1/pleroma/chats/{id}/messages` +`GET /api/v1/pleroma/chats/:id/messages` This will return all messages, sorted by most recent to least recent. The usual pagination options are implemented. @@ -177,7 +177,7 @@ Returned data: Posting a chat message for given Chat id works like this: -`POST /api/v1/pleroma/chats/{id}/messages` +`POST /api/v1/pleroma/chats/:id/messages` Parameters: - content: The text content of the message. Optional if media is attached. @@ -210,7 +210,7 @@ Returned data: Deleting a chat message for given Chat id works like this: -`DELETE /api/v1/pleroma/chats/{chat_id}/messages/{message_id}` +`DELETE /api/v1/pleroma/chats/:chat_id/messages/:message_id` Returned data is the deleted message. From 239d03499ebe9196c099b6c8ded05f1f6634c09d Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 6 Jun 2020 10:38:45 +0200 Subject: [PATCH 216/401] Chat: creation_cng -> changeset Make our usage of this more uniform. --- lib/pleroma/chat.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 5aefddc5e..4fe31de94 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -23,7 +23,7 @@ defmodule Pleroma.Chat do timestamps() end - def creation_cng(struct, params) do + def changeset(struct, params) do struct |> cast(params, [:user_id, :recipient]) |> validate_change(:recipient, fn @@ -49,7 +49,7 @@ def get(user_id, recipient) do def get_or_create(user_id, recipient) do %__MODULE__{} - |> creation_cng(%{user_id: user_id, recipient: recipient}) + |> changeset(%{user_id: user_id, recipient: recipient}) |> Repo.insert( # Need to set something, otherwise we get nothing back at all on_conflict: [set: [recipient: recipient]], @@ -60,7 +60,7 @@ def get_or_create(user_id, recipient) do def bump_or_create(user_id, recipient) do %__MODULE__{} - |> creation_cng(%{user_id: user_id, recipient: recipient}) + |> changeset(%{user_id: user_id, recipient: recipient}) |> Repo.insert( on_conflict: [set: [updated_at: NaiveDateTime.utc_now()]], conflict_target: [:user_id, :recipient] From 137adef6e061a1d7d7fc704feac27ebf5319a768 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 6 Jun 2020 10:42:24 +0200 Subject: [PATCH 217/401] ChatMessageReference: Use FlakeId.Ecto.Type No need for compat because this is brand new. --- lib/pleroma/chat_message_reference.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/chat_message_reference.ex b/lib/pleroma/chat_message_reference.ex index fc2aaae7a..6e836cad9 100644 --- a/lib/pleroma/chat_message_reference.ex +++ b/lib/pleroma/chat_message_reference.ex @@ -17,7 +17,7 @@ defmodule Pleroma.ChatMessageReference do import Ecto.Changeset import Ecto.Query - @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} + @primary_key {:id, FlakeId.Ecto.Type, autogenerate: true} schema "chat_message_references" do belongs_to(:object, Object) From ca0e6e702be3714bb40ff0fb48e9c08aaf322fff Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 6 Jun 2020 11:51:10 +0200 Subject: [PATCH 218/401] ChatMessageReference -> Chat.MessageReference --- .../message_reference.ex} | 2 +- lib/pleroma/web/activity_pub/side_effects.ex | 8 ++--- .../mastodon_api/views/notification_view.ex | 8 ++--- .../controllers/chat_controller.ex | 30 +++++++++---------- .../message_reference_view.ex} | 2 +- .../web/pleroma_api/views/chat_view.ex | 10 +++---- lib/pleroma/web/streamer/streamer.ex | 4 +-- .../message_reference_test.exs} | 6 ++-- test/web/activity_pub/side_effects_test.exs | 8 ++--- .../views/notification_view_test.exs | 9 +++--- .../controllers/chat_controller_test.exs | 18 +++++------ .../message_reference_view_test.exs} | 15 +++++----- test/web/pleroma_api/views/chat_view_test.exs | 8 ++--- test/web/streamer/streamer_test.exs | 6 ++-- 14 files changed, 66 insertions(+), 68 deletions(-) rename lib/pleroma/{chat_message_reference.ex => chat/message_reference.ex} (98%) rename lib/pleroma/web/pleroma_api/views/{chat_message_reference_view.ex => chat/message_reference_view.ex} (95%) rename test/{chat_message_reference_test.exs => chat/message_reference_test.exs} (82%) rename test/web/pleroma_api/views/{chat_message_reference_view_test.exs => chat/message_reference_view_test.exs} (77%) diff --git a/lib/pleroma/chat_message_reference.ex b/lib/pleroma/chat/message_reference.ex similarity index 98% rename from lib/pleroma/chat_message_reference.ex rename to lib/pleroma/chat/message_reference.ex index 6e836cad9..4b201db2e 100644 --- a/lib/pleroma/chat_message_reference.ex +++ b/lib/pleroma/chat/message_reference.ex @@ -2,7 +2,7 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.ChatMessageReference do +defmodule Pleroma.Chat.MessageReference do @moduledoc """ A reference that builds a relation between an AP chat message that a user can see and whether it has been seen by them, or should be displayed to them. Used to build the chat view that is presented to the user. diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 5258212ec..1e9d6c2fc 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -7,7 +7,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do """ alias Pleroma.Activity alias Pleroma.Chat - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo @@ -111,7 +111,7 @@ def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, Object.decrease_replies_count(in_reply_to) end - ChatMessageReference.delete_for_object(deleted_object) + MessageReference.delete_for_object(deleted_object) ActivityPub.stream_out(object) ActivityPub.stream_out_participations(deleted_object, user) @@ -146,13 +146,13 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do |> Enum.each(fn [user, other_user] -> if user.local do {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) - {:ok, cm_ref} = ChatMessageReference.create(chat, object, user.ap_id != actor.ap_id) + {:ok, cm_ref} = MessageReference.create(chat, object, user.ap_id != actor.ap_id) # We add a cache of the unread value here so that it # doesn't change when being streamed out chat = chat - |> Map.put(:unread, ChatMessageReference.unread_count_for_chat(chat)) + |> Map.put(:unread, MessageReference.unread_count_for_chat(chat)) Streamer.stream( ["user", "user:pleroma_chat"], diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 2ae82eb2d..b11578623 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do use Pleroma.Web, :view alias Pleroma.Activity - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.Notification alias Pleroma.Object alias Pleroma.User @@ -15,7 +15,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView - alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView + alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView @parent_types ~w{Like Announce EmojiReact} @@ -139,9 +139,9 @@ defp put_chat_message(response, activity, reading_user, opts) do object = Object.normalize(activity) author = User.get_cached_by_ap_id(object.data["actor"]) chat = Pleroma.Chat.get(reading_user.id, author.ap_id) - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) render_opts = Map.merge(opts, %{for: reading_user, chat_message_reference: cm_ref}) - chat_message_render = ChatMessageReferenceView.render("show.json", render_opts) + chat_message_render = MessageReferenceView.render("show.json", render_opts) Map.put(response, :chat_message, chat_message_render) end diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 01d47045d..d6b3415d1 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -6,14 +6,14 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do alias Pleroma.Activity alias Pleroma.Chat - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.Object alias Pleroma.Pagination alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.CommonAPI - alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView + alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView alias Pleroma.Web.PleromaAPI.ChatView import Ecto.Query @@ -46,13 +46,13 @@ def delete_message(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ message_id: message_id, id: chat_id }) do - with %ChatMessageReference{} = cm_ref <- - ChatMessageReference.get_by_id(message_id), + with %MessageReference{} = cm_ref <- + MessageReference.get_by_id(message_id), ^chat_id <- cm_ref.chat_id |> to_string(), %Chat{user_id: ^user_id} <- Chat.get_by_id(chat_id), {:ok, _} <- remove_or_delete(cm_ref, user) do conn - |> put_view(ChatMessageReferenceView) + |> put_view(MessageReferenceView) |> render("show.json", chat_message_reference: cm_ref) else _e -> @@ -71,7 +71,7 @@ defp remove_or_delete( defp remove_or_delete(cm_ref, _) do cm_ref - |> ChatMessageReference.delete() + |> MessageReference.delete() end def post_chat_message( @@ -87,9 +87,9 @@ def post_chat_message( media_id: params[:media_id] ), message <- Object.normalize(activity, false), - cm_ref <- ChatMessageReference.for_chat_and_object(chat, message) do + cm_ref <- MessageReference.for_chat_and_object(chat, message) do conn - |> put_view(ChatMessageReferenceView) + |> put_view(MessageReferenceView) |> render("show.json", for: user, chat_message_reference: cm_ref) end end @@ -98,20 +98,20 @@ def mark_message_as_read(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ id: chat_id, message_id: message_id }) do - with %ChatMessageReference{} = cm_ref <- - ChatMessageReference.get_by_id(message_id), + with %MessageReference{} = cm_ref <- + MessageReference.get_by_id(message_id), ^chat_id <- cm_ref.chat_id |> to_string(), %Chat{user_id: ^user_id} <- Chat.get_by_id(chat_id), - {:ok, cm_ref} <- ChatMessageReference.mark_as_read(cm_ref) do + {:ok, cm_ref} <- MessageReference.mark_as_read(cm_ref) do conn - |> put_view(ChatMessageReferenceView) + |> put_view(MessageReferenceView) |> render("show.json", for: user, chat_message_reference: cm_ref) end end def mark_as_read(%{assigns: %{user: %{id: user_id}}} = conn, %{id: id}) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), - {_n, _} <- ChatMessageReference.set_all_seen_for_chat(chat) do + {_n, _} <- MessageReference.set_all_seen_for_chat(chat) do conn |> put_view(ChatView) |> render("show.json", chat: chat) @@ -122,11 +122,11 @@ def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{id: id} = para with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id) do cm_refs = chat - |> ChatMessageReference.for_chat_query() + |> MessageReference.for_chat_query() |> Pagination.fetch_paginated(params |> stringify_keys()) conn - |> put_view(ChatMessageReferenceView) + |> put_view(MessageReferenceView) |> render("index.json", for: user, chat_message_references: cm_refs) else _ -> diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex b/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex similarity index 95% rename from lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex rename to lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex index 592bb17f0..f2112a86e 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex @@ -2,7 +2,7 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.PleromaAPI.ChatMessageReferenceView do +defmodule Pleroma.Web.PleromaAPI.Chat.MessageReferenceView do use Pleroma.Web, :view alias Pleroma.User diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index 91d50dd1e..d4c10977f 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -6,24 +6,24 @@ defmodule Pleroma.Web.PleromaAPI.ChatView do use Pleroma.Web, :view alias Pleroma.Chat - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.User alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView - alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView + alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView def render("show.json", %{chat: %Chat{} = chat} = opts) do recipient = User.get_cached_by_ap_id(chat.recipient) - last_message = opts[:last_message] || ChatMessageReference.last_message_for_chat(chat) + last_message = opts[:last_message] || MessageReference.last_message_for_chat(chat) %{ id: chat.id |> to_string(), account: AccountView.render("show.json", Map.put(opts, :user, recipient)), - unread: Map.get(chat, :unread) || ChatMessageReference.unread_count_for_chat(chat), + unread: Map.get(chat, :unread) || MessageReference.unread_count_for_chat(chat), last_message: last_message && - ChatMessageReferenceView.render("show.json", chat_message_reference: last_message), + MessageReferenceView.render("show.json", chat_message_reference: last_message), updated_at: Utils.to_masto_date(chat.updated_at) } end diff --git a/lib/pleroma/web/streamer/streamer.ex b/lib/pleroma/web/streamer/streamer.ex index b22297955..d1d2c9b9c 100644 --- a/lib/pleroma/web/streamer/streamer.ex +++ b/lib/pleroma/web/streamer/streamer.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.Streamer do require Logger alias Pleroma.Activity - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.Config alias Pleroma.Conversation.Participation alias Pleroma.Notification @@ -187,7 +187,7 @@ defp do_stream(topic, %Notification{} = item) end) end - defp do_stream(topic, {user, %ChatMessageReference{} = cm_ref}) + defp do_stream(topic, {user, %MessageReference{} = cm_ref}) when topic in ["user", "user:pleroma_chat"] do topic = "#{topic}:#{user.id}" diff --git a/test/chat_message_reference_test.exs b/test/chat/message_reference_test.exs similarity index 82% rename from test/chat_message_reference_test.exs rename to test/chat/message_reference_test.exs index 66bf493b4..aaa7c1ad4 100644 --- a/test/chat_message_reference_test.exs +++ b/test/chat/message_reference_test.exs @@ -2,11 +2,11 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.ChatMessageReferenceTest do +defmodule Pleroma.Chat.MessageReferenceTest do use Pleroma.DataCase, async: true alias Pleroma.Chat - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.Web.CommonAPI import Pleroma.Factory @@ -21,7 +21,7 @@ test "it returns the last message in a chat" do {:ok, chat} = Chat.get_or_create(user.id, recipient.ap_id) - message = ChatMessageReference.last_message_for_chat(chat) + message = MessageReference.last_message_for_chat(chat) assert message.object.data["content"] == "ho" end diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 43ffe1337..b1afa6a2e 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -8,7 +8,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do alias Pleroma.Activity alias Pleroma.Chat - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo @@ -391,7 +391,7 @@ test "it streams the created ChatMessage" do end end - test "it creates a Chat and ChatMessageReferences for the local users and bumps the unread count, except for the author" do + test "it creates a Chat and MessageReferences for the local users and bumps the unread count, except for the author" do author = insert(:user, local: true) recipient = insert(:user, local: true) @@ -431,14 +431,14 @@ test "it creates a Chat and ChatMessageReferences for the local users and bumps chat = Chat.get(author.id, recipient.ap_id) - [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() + [cm_ref] = MessageReference.for_chat_query(chat) |> Repo.all() assert cm_ref.object.data["content"] == "hey" assert cm_ref.unread == false chat = Chat.get(recipient.id, author.ap_id) - [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() + [cm_ref] = MessageReference.for_chat_query(chat) |> Repo.all() assert cm_ref.object.data["content"] == "hey" assert cm_ref.unread == true diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index c5691341a..b2fa5b302 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -7,7 +7,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do alias Pleroma.Activity alias Pleroma.Chat - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo @@ -17,7 +17,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView - alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView + alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView import Pleroma.Factory defp test_notifications_rendering(notifications, user, expected_result) do @@ -45,15 +45,14 @@ test "ChatMessage notification" do object = Object.normalize(activity) chat = Chat.get(recipient.id, user.ap_id) - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) expected = %{ id: to_string(notification.id), pleroma: %{is_seen: false}, type: "pleroma:chat_mention", account: AccountView.render("show.json", %{user: user, for: recipient}), - chat_message: - ChatMessageReferenceView.render("show.json", %{chat_message_reference: cm_ref}), + chat_message: MessageReferenceView.render("show.json", %{chat_message_reference: cm_ref}), created_at: Utils.to_masto_date(notification.inserted_at) } diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 7af6dec1c..e73e4a32e 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -5,7 +5,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do use Pleroma.Web.ConnCase, async: true alias Pleroma.Chat - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub @@ -23,7 +23,7 @@ test "it marks one message as read", %{conn: conn, user: user} do {:ok, _create} = CommonAPI.post_chat_message(other_user, user, "sup part 2") {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) object = Object.normalize(create, false) - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) assert cm_ref.unread == true @@ -34,7 +34,7 @@ test "it marks one message as read", %{conn: conn, user: user} do assert result["unread"] == false - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) assert cm_ref.unread == false end @@ -50,7 +50,7 @@ test "it marks all messages in a chat as read", %{conn: conn, user: user} do {:ok, _create} = CommonAPI.post_chat_message(other_user, user, "sup part 2") {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) object = Object.normalize(create, false) - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) assert cm_ref.unread == true @@ -61,7 +61,7 @@ test "it marks all messages in a chat as read", %{conn: conn, user: user} do assert result["unread"] == 0 - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) assert cm_ref.unread == false end @@ -139,7 +139,7 @@ test "it deletes a message from the chat", %{conn: conn, user: user} do chat = Chat.get(user.id, recipient.ap_id) - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) # Deleting your own message removes the message and the reference result = @@ -149,12 +149,12 @@ test "it deletes a message from the chat", %{conn: conn, user: user} do |> json_response_and_validate_schema(200) assert result["id"] == cm_ref.id - refute ChatMessageReference.get_by_id(cm_ref.id) + refute MessageReference.get_by_id(cm_ref.id) assert %{data: %{"type" => "Tombstone"}} = Object.get_by_id(object.id) # Deleting other people's messages just removes the reference object = Object.normalize(other_message, false) - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) result = conn @@ -163,7 +163,7 @@ test "it deletes a message from the chat", %{conn: conn, user: user} do |> json_response_and_validate_schema(200) assert result["id"] == cm_ref.id - refute ChatMessageReference.get_by_id(cm_ref.id) + refute MessageReference.get_by_id(cm_ref.id) assert Object.get_by_id(object.id) end end diff --git a/test/web/pleroma_api/views/chat_message_reference_view_test.exs b/test/web/pleroma_api/views/chat/message_reference_view_test.exs similarity index 77% rename from test/web/pleroma_api/views/chat_message_reference_view_test.exs rename to test/web/pleroma_api/views/chat/message_reference_view_test.exs index b53bd3490..e5b165255 100644 --- a/test/web/pleroma_api/views/chat_message_reference_view_test.exs +++ b/test/web/pleroma_api/views/chat/message_reference_view_test.exs @@ -2,15 +2,15 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.PleromaAPI.ChatMessageReferenceViewTest do +defmodule Pleroma.Web.PleromaAPI.Chat.MessageReferenceViewTest do use Pleroma.DataCase alias Pleroma.Chat - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.Object alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI - alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView + alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView import Pleroma.Factory @@ -31,9 +31,9 @@ test "it displays a chat message" do object = Object.normalize(activity) - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) - chat_message = ChatMessageReferenceView.render("show.json", chat_message_reference: cm_ref) + chat_message = MessageReferenceView.render("show.json", chat_message_reference: cm_ref) assert chat_message[:id] == cm_ref.id assert chat_message[:content] == "kippis :firefox:" @@ -47,10 +47,9 @@ test "it displays a chat message" do object = Object.normalize(activity) - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) - chat_message_two = - ChatMessageReferenceView.render("show.json", chat_message_reference: cm_ref) + chat_message_two = MessageReferenceView.render("show.json", chat_message_reference: cm_ref) assert chat_message_two[:id] == cm_ref.id assert chat_message_two[:content] == "gkgkgk" diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index f77584dd1..f7af5d4e0 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -6,12 +6,12 @@ defmodule Pleroma.Web.PleromaAPI.ChatViewTest do use Pleroma.DataCase alias Pleroma.Chat - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.Object alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView - alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView + alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView alias Pleroma.Web.PleromaAPI.ChatView import Pleroma.Factory @@ -55,9 +55,9 @@ test "it represents a chat" do represented_chat = ChatView.render("show.json", chat: chat) - cm_ref = ChatMessageReference.for_chat_and_object(chat, chat_message) + cm_ref = MessageReference.for_chat_and_object(chat, chat_message) assert represented_chat[:last_message] == - ChatMessageReferenceView.render("show.json", chat_message_reference: cm_ref) + MessageReferenceView.render("show.json", chat_message_reference: cm_ref) end end diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index 893ae5449..245f6e63f 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -8,7 +8,7 @@ defmodule Pleroma.Web.StreamerTest do import Pleroma.Factory alias Pleroma.Chat - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.Conversation.Participation alias Pleroma.List alias Pleroma.Object @@ -155,7 +155,7 @@ test "it sends chat messages to the 'user:pleroma_chat' stream", %{user: user} d {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey cirno") object = Object.normalize(create_activity, false) chat = Chat.get(user.id, other_user.ap_id) - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) cm_ref = %{cm_ref | chat: chat, object: object} Streamer.get_topic_and_add_socket("user:pleroma_chat", user) @@ -173,7 +173,7 @@ test "it sends chat messages to the 'user' stream", %{user: user} do {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey cirno") object = Object.normalize(create_activity, false) chat = Chat.get(user.id, other_user.ap_id) - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) cm_ref = %{cm_ref | chat: chat, object: object} Streamer.get_topic_and_add_socket("user", user) From 9fa3f0b156f92ba575b58b191685fa068a83f4d2 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 6 Jun 2020 13:08:45 +0200 Subject: [PATCH 219/401] Notification: Change type of `type` to an enum. --- lib/pleroma/notification.ex | 3 ++ ..._change_type_to_enum_for_notifications.exs | 36 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 priv/repo/migrations/20200606105430_change_type_to_enum_for_notifications.exs diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 49e27c05a..5c8994e35 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -30,6 +30,9 @@ defmodule Pleroma.Notification do schema "notifications" do field(:seen, :boolean, default: false) + # This is an enum type in the database. If you add a new notification type, + # remembert to add a migration to add it to the `notifications_type` enum + # as well. field(:type, :string) belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType) diff --git a/priv/repo/migrations/20200606105430_change_type_to_enum_for_notifications.exs b/priv/repo/migrations/20200606105430_change_type_to_enum_for_notifications.exs new file mode 100644 index 000000000..9ea34436b --- /dev/null +++ b/priv/repo/migrations/20200606105430_change_type_to_enum_for_notifications.exs @@ -0,0 +1,36 @@ +defmodule Pleroma.Repo.Migrations.ChangeTypeToEnumForNotifications do + use Ecto.Migration + + def up do + """ + create type notification_type as enum ( + 'follow', + 'follow_request', + 'mention', + 'move', + 'pleroma:emoji_reaction', + 'pleroma:chat_mention', + 'reblog', + 'favourite' + ) + """ + |> execute() + + """ + alter table notifications + alter column type type notification_type using (type::notification_type) + """ + |> execute() + end + + def down do + alter table(:notifications) do + modify(:type, :string) + end + + """ + drop type notification_type + """ + |> execute() + end +end From 9189b489eef29be723389e1b3642a843bc0d01bc Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 6 Jun 2020 15:33:02 +0200 Subject: [PATCH 220/401] Migrations: Move Notification migration code to helper --- lib/pleroma/migration_helper.ex | 85 +++++++++++++++++++ lib/pleroma/notification.ex | 37 +------- ...0602125218_backfill_notification_types.exs | 2 +- test/migration_helper_test.exs | 56 ++++++++++++ test/notification_test.exs | 42 --------- 5 files changed, 144 insertions(+), 78 deletions(-) create mode 100644 lib/pleroma/migration_helper.ex create mode 100644 test/migration_helper_test.exs diff --git a/lib/pleroma/migration_helper.ex b/lib/pleroma/migration_helper.ex new file mode 100644 index 000000000..e6346aff1 --- /dev/null +++ b/lib/pleroma/migration_helper.ex @@ -0,0 +1,85 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MigrationHelper do + alias Pleroma.User + alias Pleroma.Object + alias Pleroma.Notification + alias Pleroma.Repo + + import Ecto.Query + + def fill_in_notification_types do + query = + from(n in Pleroma.Notification, + where: is_nil(n.type), + preload: :activity + ) + + query + |> Repo.all() + |> Enum.each(fn notification -> + type = + notification.activity + |> type_from_activity() + + notification + |> Notification.changeset(%{type: type}) + |> Repo.update() + end) + end + + # This is copied over from Notifications to keep this stable. + defp type_from_activity(%{data: %{"type" => type}} = activity) do + case type do + "Follow" -> + accepted_function = fn activity -> + with %User{} = follower <- User.get_by_ap_id(activity.data["actor"]), + %User{} = followed <- User.get_by_ap_id(activity.data["object"]) do + Pleroma.FollowingRelationship.following?(follower, followed) + end + end + + if accepted_function.(activity) do + "follow" + else + "follow_request" + end + + "Announce" -> + "reblog" + + "Like" -> + "favourite" + + "Move" -> + "move" + + "EmojiReact" -> + "pleroma:emoji_reaction" + + # Compatibility with old reactions + "EmojiReaction" -> + "pleroma:emoji_reaction" + + "Create" -> + activity + |> type_from_activity_object() + + t -> + raise "No notification type for activity type #{t}" + end + end + + defp type_from_activity_object(%{data: %{"type" => "Create", "object" => %{}}}), do: "mention" + + defp type_from_activity_object(%{data: %{"type" => "Create"}} = activity) do + object = Object.get_by_ap_id(activity.data["object"]) + + case object && object.data["type"] do + "ChatMessage" -> "pleroma:chat_mention" + _ -> "mention" + end + end +end diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 5c8994e35..682a26912 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -40,26 +40,6 @@ defmodule Pleroma.Notification do timestamps() end - def fill_in_notification_types do - query = - from(n in __MODULE__, - where: is_nil(n.type), - preload: :activity - ) - - query - |> Repo.all() - |> Enum.each(fn notification -> - type = - notification.activity - |> type_from_activity(no_cachex: true) - - notification - |> changeset(%{type: type}) - |> Repo.update() - end) - end - def update_notification_type(user, activity) do with %__MODULE__{} = notification <- Repo.get_by(__MODULE__, user_id: user.id, activity_id: activity.id) do @@ -371,23 +351,10 @@ defp do_create_notifications(%Activity{} = activity, options) do {:ok, notifications} end - defp type_from_activity(%{data: %{"type" => type}} = activity, opts \\ []) do + defp type_from_activity(%{data: %{"type" => type}} = activity) do case type do "Follow" -> - accepted_function = - if Keyword.get(opts, :no_cachex, false) do - # A special function to make this usable in a migration. - fn activity -> - with %User{} = follower <- User.get_by_ap_id(activity.data["actor"]), - %User{} = followed <- User.get_by_ap_id(activity.data["object"]) do - Pleroma.FollowingRelationship.following?(follower, followed) - end - end - else - &Activity.follow_accepted?/1 - end - - if accepted_function.(activity) do + if Activity.follow_accepted?(activity) do "follow" else "follow_request" diff --git a/priv/repo/migrations/20200602125218_backfill_notification_types.exs b/priv/repo/migrations/20200602125218_backfill_notification_types.exs index 493c0280c..58943fad0 100644 --- a/priv/repo/migrations/20200602125218_backfill_notification_types.exs +++ b/priv/repo/migrations/20200602125218_backfill_notification_types.exs @@ -2,7 +2,7 @@ defmodule Pleroma.Repo.Migrations.BackfillNotificationTypes do use Ecto.Migration def up do - Pleroma.Notification.fill_in_notification_types() + Pleroma.MigrationHelper.fill_in_notification_types() end def down do diff --git a/test/migration_helper_test.exs b/test/migration_helper_test.exs new file mode 100644 index 000000000..1c8173987 --- /dev/null +++ b/test/migration_helper_test.exs @@ -0,0 +1,56 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MigrationHelperTest do + use Pleroma.DataCase + + alias Pleroma.Activity + alias Pleroma.MigrationHelper + alias Pleroma.Notification + alias Pleroma.Repo + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + describe "fill_in_notification_types" do + test "it fills in missing notification types" do + user = insert(:user) + other_user = insert(:user) + + {:ok, post} = CommonAPI.post(user, %{status: "yeah, @#{other_user.nickname}"}) + {:ok, chat} = CommonAPI.post_chat_message(user, other_user, "yo") + {:ok, react} = CommonAPI.react_with_emoji(post.id, other_user, "☕") + {:ok, like} = CommonAPI.favorite(other_user, post.id) + {:ok, react_2} = CommonAPI.react_with_emoji(post.id, other_user, "☕") + + data = + react_2.data + |> Map.put("type", "EmojiReaction") + + {:ok, react_2} = + react_2 + |> Activity.change(%{data: data}) + |> Repo.update() + + assert {5, nil} = Repo.update_all(Notification, set: [type: nil]) + + MigrationHelper.fill_in_notification_types() + + assert %{type: "mention"} = + Repo.get_by(Notification, user_id: other_user.id, activity_id: post.id) + + assert %{type: "favourite"} = + Repo.get_by(Notification, user_id: user.id, activity_id: like.id) + + assert %{type: "pleroma:emoji_reaction"} = + Repo.get_by(Notification, user_id: user.id, activity_id: react.id) + + assert %{type: "pleroma:emoji_reaction"} = + Repo.get_by(Notification, user_id: user.id, activity_id: react_2.id) + + assert %{type: "pleroma:chat_mention"} = + Repo.get_by(Notification, user_id: other_user.id, activity_id: chat.id) + end + end +end diff --git a/test/notification_test.exs b/test/notification_test.exs index f2115a29e..b9bbdceca 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -8,7 +8,6 @@ defmodule Pleroma.NotificationTest do import Pleroma.Factory import Mock - alias Pleroma.Activity alias Pleroma.FollowingRelationship alias Pleroma.Notification alias Pleroma.Repo @@ -22,47 +21,6 @@ defmodule Pleroma.NotificationTest do alias Pleroma.Web.Push alias Pleroma.Web.Streamer - describe "fill_in_notification_types" do - test "it fills in missing notification types" do - user = insert(:user) - other_user = insert(:user) - - {:ok, post} = CommonAPI.post(user, %{status: "yeah, @#{other_user.nickname}"}) - {:ok, chat} = CommonAPI.post_chat_message(user, other_user, "yo") - {:ok, react} = CommonAPI.react_with_emoji(post.id, other_user, "☕") - {:ok, like} = CommonAPI.favorite(other_user, post.id) - {:ok, react_2} = CommonAPI.react_with_emoji(post.id, other_user, "☕") - - data = - react_2.data - |> Map.put("type", "EmojiReaction") - - {:ok, react_2} = - react_2 - |> Activity.change(%{data: data}) - |> Repo.update() - - assert {5, nil} = Repo.update_all(Notification, set: [type: nil]) - - Notification.fill_in_notification_types() - - assert %{type: "mention"} = - Repo.get_by(Notification, user_id: other_user.id, activity_id: post.id) - - assert %{type: "favourite"} = - Repo.get_by(Notification, user_id: user.id, activity_id: like.id) - - assert %{type: "pleroma:emoji_reaction"} = - Repo.get_by(Notification, user_id: user.id, activity_id: react.id) - - assert %{type: "pleroma:emoji_reaction"} = - Repo.get_by(Notification, user_id: user.id, activity_id: react_2.id) - - assert %{type: "pleroma:chat_mention"} = - Repo.get_by(Notification, user_id: other_user.id, activity_id: chat.id) - end - end - describe "create_notifications" do test "creates a notification for an emoji reaction" do user = insert(:user) From f77d4a302d8ee5999979136290caac556cac8873 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 6 Jun 2020 15:51:08 +0200 Subject: [PATCH 221/401] Credo fixes. --- lib/pleroma/migration_helper.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/migration_helper.ex b/lib/pleroma/migration_helper.ex index e6346aff1..a20d27a01 100644 --- a/lib/pleroma/migration_helper.ex +++ b/lib/pleroma/migration_helper.ex @@ -3,10 +3,10 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.MigrationHelper do - alias Pleroma.User - alias Pleroma.Object alias Pleroma.Notification + alias Pleroma.Object alias Pleroma.Repo + alias Pleroma.User import Ecto.Query From e1b07402ab077899dd5b9c0023fbe1c48af259e9 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 23 Mar 2020 22:52:25 +0100 Subject: [PATCH 222/401] User: Add raw_bio, storing unformatted bio Related: https://git.pleroma.social/pleroma/pleroma/issues/1643 --- lib/pleroma/user.ex | 13 +++++++++- .../controllers/account_controller.ex | 1 + .../web/mastodon_api/views/account_view.ex | 13 +--------- .../20200322174133_user_raw_bio.exs | 9 +++++++ .../20200328193433_populate_user_raw_bio.exs | 25 +++++++++++++++++++ test/support/factory.ex | 3 ++- .../update_credentials_test.exs | 13 +++++++--- .../mastodon_api/views/account_view_test.exs | 3 ++- 8 files changed, 61 insertions(+), 19 deletions(-) create mode 100644 priv/repo/migrations/20200322174133_user_raw_bio.exs create mode 100644 priv/repo/migrations/20200328193433_populate_user_raw_bio.exs diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 72ee2d58e..23ca8c9f3 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -79,6 +79,7 @@ defmodule Pleroma.User do schema "users" do field(:bio, :string) + field(:raw_bio, :string) field(:email, :string) field(:name, :string) field(:nickname, :string) @@ -432,6 +433,7 @@ def update_changeset(struct, params \\ %{}) do params, [ :bio, + :raw_bio, :name, :emoji, :avatar, @@ -607,7 +609,16 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do struct |> confirmation_changeset(need_confirmation: need_confirmation?) - |> cast(params, [:bio, :email, :name, :nickname, :password, :password_confirmation, :emoji]) + |> cast(params, [ + :bio, + :raw_bio, + :email, + :name, + :nickname, + :password, + :password_confirmation, + :emoji + ]) |> validate_required([:name, :nickname, :password, :password_confirmation]) |> validate_confirmation(:password) |> unique_constraint(:email) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 5734bb854..ebfa533dd 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -165,6 +165,7 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p end) |> Maps.put_if_present(:name, params[:display_name]) |> Maps.put_if_present(:bio, params[:note]) + |> Maps.put_if_present(:raw_bio, params[:note]) |> Maps.put_if_present(:avatar, params[:avatar]) |> Maps.put_if_present(:banner, params[:header]) |> Maps.put_if_present(:background, params[:pleroma_background_image]) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 04c419d2f..5326b02c6 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -224,7 +224,7 @@ defp do_render("show.json", %{user: user} = opts) do fields: user.fields, bot: bot, source: %{ - note: prepare_user_bio(user), + note: user.raw_bio || "", sensitive: false, fields: user.raw_fields, pleroma: %{ @@ -259,17 +259,6 @@ defp do_render("show.json", %{user: user} = opts) do |> maybe_put_unread_notification_count(user, opts[:for]) end - defp prepare_user_bio(%User{bio: ""}), do: "" - - defp prepare_user_bio(%User{bio: bio}) when is_binary(bio) do - bio - |> String.replace(~r(
), "\n") - |> Pleroma.HTML.strip_tags() - |> HtmlEntities.decode() - end - - defp prepare_user_bio(_), do: "" - defp username_from_nickname(string) when is_binary(string) do hd(String.split(string, "@")) end diff --git a/priv/repo/migrations/20200322174133_user_raw_bio.exs b/priv/repo/migrations/20200322174133_user_raw_bio.exs new file mode 100644 index 000000000..ddf9be4f5 --- /dev/null +++ b/priv/repo/migrations/20200322174133_user_raw_bio.exs @@ -0,0 +1,9 @@ +defmodule Pleroma.Repo.Migrations.UserRawBio do + use Ecto.Migration + + def change do + alter table(:users) do + add_if_not_exists(:raw_bio, :text) + end + end +end diff --git a/priv/repo/migrations/20200328193433_populate_user_raw_bio.exs b/priv/repo/migrations/20200328193433_populate_user_raw_bio.exs new file mode 100644 index 000000000..cb35db3f5 --- /dev/null +++ b/priv/repo/migrations/20200328193433_populate_user_raw_bio.exs @@ -0,0 +1,25 @@ +defmodule Pleroma.Repo.Migrations.PopulateUserRawBio do + use Ecto.Migration + import Ecto.Query + alias Pleroma.User + alias Pleroma.Repo + + def change do + {:ok, _} = Application.ensure_all_started(:fast_sanitize) + + User.Query.build(%{local: true}) + |> select([u], struct(u, [:id, :ap_id, :bio])) + |> Repo.stream() + |> Enum.each(fn %{bio: bio} = user -> + if bio do + raw_bio = + bio + |> String.replace(~r(
), "\n") + |> Pleroma.HTML.strip_tags() + + Ecto.Changeset.cast(user, %{raw_bio: raw_bio}, [:raw_bio]) + |> Repo.update() + end + end) + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index 6e3676aca..1a9b96180 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -42,7 +42,8 @@ def user_factory do user | ap_id: User.ap_id(user), follower_address: User.ap_followers(user), - following_address: User.ap_following(user) + following_address: User.ap_following(user), + raw_bio: user.bio } end diff --git a/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs index 7c420985d..76e6d603a 100644 --- a/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs +++ b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs @@ -83,10 +83,9 @@ test "sets user settings in a generic way", %{conn: conn} do test "updates the user's bio", %{conn: conn} do user2 = insert(:user) - conn = - patch(conn, "/api/v1/accounts/update_credentials", %{ - "note" => "I drink #cofe with @#{user2.nickname}\n\nsuya.." - }) + raw_bio = "I drink #cofe with @#{user2.nickname}\n\nsuya.." + + conn = patch(conn, "/api/v1/accounts/update_credentials", %{"note" => raw_bio}) assert user_data = json_response_and_validate_schema(conn, 200) @@ -94,6 +93,12 @@ test "updates the user's bio", %{conn: conn} do ~s(I drink #cofe with @#{user2.nickname}

suya..) + + assert user_data["source"]["note"] == raw_bio + + user = Repo.get(User, user_data["id"]) + + assert user.raw_bio == raw_bio end test "updates the user's locking status", %{conn: conn} do diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index f91333e5c..7ac70dc58 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -33,7 +33,8 @@ test "Represent a user account" do bio: "valid html. a
b
c
d
f '&<>\"", inserted_at: ~N[2017-08-15 15:47:06.597036], - emoji: %{"karjalanpiirakka" => "/file.png"} + emoji: %{"karjalanpiirakka" => "/file.png"}, + raw_bio: "valid html. a\nb\nc\nd\nf '&<>\"" }) expected = %{ From f4cf4ae16ee84655bf6630cf7e98e9eef2f410cc Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 6 Jun 2020 16:48:02 +0200 Subject: [PATCH 223/401] ChatController: Use new oauth scope *:chats. --- .../web/api_spec/operations/chat_operation.ex | 14 +++++++------- .../pleroma_api/controllers/chat_controller.ex | 4 ++-- .../controllers/chat_controller_test.exs | 16 ++++++++-------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 6ad325113..74c3ad0bd 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -33,7 +33,7 @@ def mark_as_read_operation do }, security: [ %{ - "oAuth" => ["write"] + "oAuth" => ["write:chats"] } ] } @@ -58,7 +58,7 @@ def mark_message_as_read_operation do }, security: [ %{ - "oAuth" => ["write"] + "oAuth" => ["write:chats"] } ] } @@ -120,7 +120,7 @@ def create_operation do }, security: [ %{ - "oAuth" => ["write"] + "oAuth" => ["write:chats"] } ] } @@ -137,7 +137,7 @@ def index_operation do }, security: [ %{ - "oAuth" => ["read"] + "oAuth" => ["read:chats"] } ] } @@ -161,7 +161,7 @@ def messages_operation do }, security: [ %{ - "oAuth" => ["read"] + "oAuth" => ["read:chats"] } ] } @@ -187,7 +187,7 @@ def post_chat_message_operation do }, security: [ %{ - "oAuth" => ["write"] + "oAuth" => ["write:chats"] } ] } @@ -212,7 +212,7 @@ def delete_message_operation do }, security: [ %{ - "oAuth" => ["write"] + "oAuth" => ["write:chats"] } ] } diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index d6b3415d1..983550b13 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -23,7 +23,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do plug( OAuthScopesPlug, - %{scopes: ["write:statuses"]} + %{scopes: ["write:chats"]} when action in [ :post_chat_message, :create, @@ -35,7 +35,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do plug( OAuthScopesPlug, - %{scopes: ["read:statuses"]} when action in [:messages, :index, :show] + %{scopes: ["read:chats"]} when action in [:messages, :index, :show] ) plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index e73e4a32e..2128fd9dd 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -14,7 +14,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do import Pleroma.Factory describe "POST /api/v1/pleroma/chats/:id/messages/:message_id/read" do - setup do: oauth_access(["write:statuses"]) + setup do: oauth_access(["write:chats"]) test "it marks one message as read", %{conn: conn, user: user} do other_user = insert(:user) @@ -41,7 +41,7 @@ test "it marks one message as read", %{conn: conn, user: user} do end describe "POST /api/v1/pleroma/chats/:id/read" do - setup do: oauth_access(["write:statuses"]) + setup do: oauth_access(["write:chats"]) test "it marks all messages in a chat as read", %{conn: conn, user: user} do other_user = insert(:user) @@ -68,7 +68,7 @@ test "it marks all messages in a chat as read", %{conn: conn, user: user} do end describe "POST /api/v1/pleroma/chats/:id/messages" do - setup do: oauth_access(["write:statuses"]) + setup do: oauth_access(["write:chats"]) test "it posts a message to the chat", %{conn: conn, user: user} do other_user = insert(:user) @@ -125,7 +125,7 @@ test "it works with an attachment", %{conn: conn, user: user} do end describe "DELETE /api/v1/pleroma/chats/:id/messages/:message_id" do - setup do: oauth_access(["write:statuses"]) + setup do: oauth_access(["write:chats"]) test "it deletes a message from the chat", %{conn: conn, user: user} do recipient = insert(:user) @@ -169,7 +169,7 @@ test "it deletes a message from the chat", %{conn: conn, user: user} do end describe "GET /api/v1/pleroma/chats/:id/messages" do - setup do: oauth_access(["read:statuses"]) + setup do: oauth_access(["read:chats"]) test "it paginates", %{conn: conn, user: user} do recipient = insert(:user) @@ -229,7 +229,7 @@ test "it returns the messages for a given chat", %{conn: conn, user: user} do end describe "POST /api/v1/pleroma/chats/by-account-id/:id" do - setup do: oauth_access(["write:statuses"]) + setup do: oauth_access(["write:chats"]) test "it creates or returns a chat", %{conn: conn} do other_user = insert(:user) @@ -244,7 +244,7 @@ test "it creates or returns a chat", %{conn: conn} do end describe "GET /api/v1/pleroma/chats/:id" do - setup do: oauth_access(["read:statuses"]) + setup do: oauth_access(["read:chats"]) test "it returns a chat", %{conn: conn, user: user} do other_user = insert(:user) @@ -261,7 +261,7 @@ test "it returns a chat", %{conn: conn, user: user} do end describe "GET /api/v1/pleroma/chats" do - setup do: oauth_access(["read:statuses"]) + setup do: oauth_access(["read:chats"]) test "it does not return chats with users you blocked", %{conn: conn, user: user} do recipient = insert(:user) From 40fc4e974e5f60c3d61702b17029566774898e84 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 6 Jun 2020 16:59:08 +0200 Subject: [PATCH 224/401] Notfication: Add validation of notification types --- lib/pleroma/notification.ex | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 682a26912..3ac8737e2 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -61,9 +61,21 @@ def unread_notifications_count(%User{id: user_id}) do |> Repo.aggregate(:count, :id) end + @notification_types ~w{ + favourite + follow + follow_request + mention + move + pleroma:chat_mention + pleroma:emoji_reaction + reblog + } + def changeset(%Notification{} = notification, attrs) do notification |> cast(attrs, [:seen, :type]) + |> validate_inclusion(:type, @notification_types) end @spec last_read_query(User.t()) :: Ecto.Queryable.t() From 0365053c8dbbcae4a4883f68b7eaec263c14f656 Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 7 Jun 2020 09:19:00 +0200 Subject: [PATCH 225/401] AttachmentValidator: Check if the mime type is valid. --- .../activity_pub/object_validators/attachment_validator.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex index c4b502cb9..f53bb02be 100644 --- a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex @@ -45,11 +45,11 @@ def fix_media_type(data) do data |> Map.put_new("mediaType", data["mimeType"]) - if data["mediaType"] == "" do + if MIME.valid?(data["mediaType"]) do data - |> Map.put("mediaType", "application/octet-stream") else data + |> Map.put("mediaType", "application/octet-stream") end end From 1a11f0e453527070a8ab5511318045470abc95e2 Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 7 Jun 2020 14:25:30 +0200 Subject: [PATCH 226/401] Chats: Change id to flake id. --- lib/pleroma/chat.ex | 3 +++ lib/pleroma/chat/message_reference.ex | 2 +- ...20200607112923_change_chat_id_to_flake.exs | 23 +++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 priv/repo/migrations/20200607112923_change_chat_id_to_flake.exs diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 4fe31de94..24a86371e 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -16,6 +16,8 @@ defmodule Pleroma.Chat do It is a helper only, to make it easy to display a list of chats with other people, ordered by last bump. The actual messages are retrieved by querying the recipients of the ChatMessages. """ + @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} + schema "chats" do belongs_to(:user, User, type: FlakeId.Ecto.CompatType) field(:recipient, :string) @@ -63,6 +65,7 @@ def bump_or_create(user_id, recipient) do |> changeset(%{user_id: user_id, recipient: recipient}) |> Repo.insert( on_conflict: [set: [updated_at: NaiveDateTime.utc_now()]], + returning: true, conflict_target: [:user_id, :recipient] ) end diff --git a/lib/pleroma/chat/message_reference.ex b/lib/pleroma/chat/message_reference.ex index 4b201db2e..7ee7508ca 100644 --- a/lib/pleroma/chat/message_reference.ex +++ b/lib/pleroma/chat/message_reference.ex @@ -21,7 +21,7 @@ defmodule Pleroma.Chat.MessageReference do schema "chat_message_references" do belongs_to(:object, Object) - belongs_to(:chat, Chat) + belongs_to(:chat, Chat, type: FlakeId.Ecto.CompatType) field(:unread, :boolean, default: true) diff --git a/priv/repo/migrations/20200607112923_change_chat_id_to_flake.exs b/priv/repo/migrations/20200607112923_change_chat_id_to_flake.exs new file mode 100644 index 000000000..f14e269ca --- /dev/null +++ b/priv/repo/migrations/20200607112923_change_chat_id_to_flake.exs @@ -0,0 +1,23 @@ +defmodule Pleroma.Repo.Migrations.ChangeChatIdToFlake do + use Ecto.Migration + + def up do + execute(""" + alter table chats + drop constraint chats_pkey cascade, + alter column id drop default, + alter column id set data type uuid using cast( lpad( to_hex(id), 32, '0') as uuid), + add primary key (id) + """) + + execute(""" + alter table chat_message_references + alter column chat_id set data type uuid using cast( lpad( to_hex(chat_id), 32, '0') as uuid), + add constraint chat_message_references_chat_id_fkey foreign key (chat_id) references chats(id) on delete cascade + """) + end + + def down do + :ok + end +end From 2cdaac433035d8df3890eae098b55380b9e1c9fc Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 7 Jun 2020 14:52:56 +0200 Subject: [PATCH 227/401] SideEffects: Move streaming of chats to after the transaction. --- lib/pleroma/web/activity_pub/side_effects.ex | 59 ++++++++++++------- .../web/pleroma_api/views/chat_view.ex | 3 +- test/web/activity_pub/side_effects_test.exs | 41 ++++--------- test/web/common_api/common_api_test.exs | 1 + test/web/pleroma_api/views/chat_view_test.exs | 15 ----- 5 files changed, 52 insertions(+), 67 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 1e9d6c2fc..1a1cc675c 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -37,7 +37,7 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do # - Rollback if we couldn't create it # - Set up notifications def handle(%{data: %{"type" => "Create"}} = activity, meta) do - with {:ok, _object, _meta} <- handle_object_creation(meta[:object_data], meta) do + with {:ok, _object, meta} <- handle_object_creation(meta[:object_data], meta) do {:ok, notifications} = Notification.create_notifications(activity, do_send: false) meta = @@ -142,24 +142,24 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do actor = User.get_cached_by_ap_id(object.data["actor"]) recipient = User.get_cached_by_ap_id(hd(object.data["to"])) - [[actor, recipient], [recipient, actor]] - |> Enum.each(fn [user, other_user] -> - if user.local do - {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) - {:ok, cm_ref} = MessageReference.create(chat, object, user.ap_id != actor.ap_id) + streamables = + [[actor, recipient], [recipient, actor]] + |> Enum.map(fn [user, other_user] -> + if user.local do + {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) + {:ok, cm_ref} = MessageReference.create(chat, object, user.ap_id != actor.ap_id) - # We add a cache of the unread value here so that it - # doesn't change when being streamed out - chat = - chat - |> Map.put(:unread, MessageReference.unread_count_for_chat(chat)) + { + ["user", "user:pleroma_chat"], + {user, %{cm_ref | chat: chat, object: object}} + } + end + end) + |> Enum.filter(& &1) - Streamer.stream( - ["user", "user:pleroma_chat"], - {user, %{cm_ref | chat: chat, object: object}} - ) - end - end) + meta = + meta + |> add_streamables(streamables) {:ok, object, meta} end @@ -208,7 +208,7 @@ def handle_undoing( def handle_undoing(object), do: {:error, ["don't know how to handle", object]} defp send_notifications(meta) do - Keyword.get(meta, :created_notifications, []) + Keyword.get(meta, :notifications, []) |> Enum.each(fn notification -> Streamer.stream(["user", "user:notification"], notification) Push.send(notification) @@ -217,15 +217,32 @@ defp send_notifications(meta) do meta end - defp add_notifications(meta, notifications) do - existing = Keyword.get(meta, :created_notifications, []) + defp send_streamables(meta) do + Keyword.get(meta, :streamables, []) + |> Enum.each(fn {topics, items} -> + Streamer.stream(topics, items) + end) meta - |> Keyword.put(:created_notifications, notifications ++ existing) + end + + defp add_streamables(meta, streamables) do + existing = Keyword.get(meta, :streamables, []) + + meta + |> Keyword.put(:streamables, streamables ++ existing) + end + + defp add_notifications(meta, notifications) do + existing = Keyword.get(meta, :notifications, []) + + meta + |> Keyword.put(:notifications, notifications ++ existing) end def handle_after_transaction(meta) do meta |> send_notifications() + |> send_streamables() end end diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index d4c10977f..1c996da11 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -14,13 +14,12 @@ defmodule Pleroma.Web.PleromaAPI.ChatView do def render("show.json", %{chat: %Chat{} = chat} = opts) do recipient = User.get_cached_by_ap_id(chat.recipient) - last_message = opts[:last_message] || MessageReference.last_message_for_chat(chat) %{ id: chat.id |> to_string(), account: AccountView.render("show.json", Map.put(opts, :user, recipient)), - unread: Map.get(chat, :unread) || MessageReference.unread_count_for_chat(chat), + unread: MessageReference.unread_count_for_chat(chat), last_message: last_message && MessageReferenceView.render("show.json", chat_message_reference: last_message), diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index b1afa6a2e..6bbbaae87 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -23,7 +23,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do import Mock describe "handle_after_transaction" do - test "it streams out notifications" do + test "it streams out notifications and streams" do author = insert(:user, local: true) recipient = insert(:user, local: true) @@ -37,7 +37,7 @@ test "it streams out notifications" do {:ok, _create_activity, meta} = SideEffects.handle(create_activity, local: false, object_data: chat_message_data) - assert [notification] = meta[:created_notifications] + assert [notification] = meta[:notifications] with_mocks([ { @@ -58,6 +58,7 @@ test "it streams out notifications" do SideEffects.handle_after_transaction(meta) assert called(Pleroma.Web.Streamer.stream(["user", "user:notification"], notification)) + assert called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], :_)) assert called(Pleroma.Web.Push.send(notification)) end end @@ -362,33 +363,10 @@ test "it streams the created ChatMessage" do {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) - with_mock Pleroma.Web.Streamer, [], - stream: fn _, payload -> - case payload do - {^author, cm_ref} -> - assert cm_ref.unread == false + {:ok, _create_activity, meta} = + SideEffects.handle(create_activity, local: false, object_data: chat_message_data) - {^recipient, cm_ref} -> - assert cm_ref.unread == true - - view = - Pleroma.Web.PleromaAPI.ChatView.render("show.json", - last_message: cm_ref, - chat: cm_ref.chat - ) - - assert view.unread == 1 - - _ -> - nil - end - end do - {:ok, _create_activity, _meta} = - SideEffects.handle(create_activity, local: false, object_data: chat_message_data) - - assert called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], {author, :_})) - assert called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], {recipient, :_})) - end + assert [_, _] = meta[:streamables] end test "it creates a Chat and MessageReferences for the local users and bumps the unread count, except for the author" do @@ -422,13 +400,18 @@ test "it creates a Chat and MessageReferences for the local users and bumps the SideEffects.handle(create_activity, local: false, object_data: chat_message_data) # The notification gets created - assert [notification] = meta[:created_notifications] + assert [notification] = meta[:notifications] assert notification.activity_id == create_activity.id # But it is not sent out refute called(Pleroma.Web.Streamer.stream(["user", "user:notification"], notification)) refute called(Pleroma.Web.Push.send(notification)) + # Same for the user chat stream + assert [{topics, _}, _] = meta[:streamables] + assert topics == ["user", "user:pleroma_chat"] + refute called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], :_)) + chat = Chat.get(author.id, recipient.ap_id) [cm_ref] = MessageReference.for_chat_query(chat) |> Repo.all() diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 63b59820e..6bd26050e 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -72,6 +72,7 @@ test "it posts a chat message without content but with an attachment" do assert called(Pleroma.Web.Push.send(notification)) assert called(Pleroma.Web.Streamer.stream(["user", "user:notification"], notification)) + assert called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], :_)) assert activity end diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index f7af5d4e0..14eecb1bd 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -16,21 +16,6 @@ defmodule Pleroma.Web.PleromaAPI.ChatViewTest do import Pleroma.Factory - test "giving a chat with an 'unread' field, it uses that" do - user = insert(:user) - recipient = insert(:user) - - {:ok, chat} = Chat.get_or_create(user.id, recipient.ap_id) - - chat = - chat - |> Map.put(:unread, 5) - - represented_chat = ChatView.render("show.json", chat: chat) - - assert represented_chat[:unread] == 5 - end - test "it represents a chat" do user = insert(:user) recipient = insert(:user) From 801e668a97adff4a33451dd7bb48799562ed8796 Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 7 Jun 2020 15:38:33 +0200 Subject: [PATCH 228/401] ChatController: Add `last_read_id` option to mark_as_read. --- lib/pleroma/chat/message_reference.ex | 20 +++++++++++----- .../web/api_spec/operations/chat_operation.ex | 18 ++++++++++++++ .../controllers/chat_controller.ex | 3 ++- .../controllers/chat_controller_test.exs | 24 +++++++++++++++++++ 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/chat/message_reference.ex b/lib/pleroma/chat/message_reference.ex index 7ee7508ca..131ae0186 100644 --- a/lib/pleroma/chat/message_reference.ex +++ b/lib/pleroma/chat/message_reference.ex @@ -98,12 +98,20 @@ def mark_as_read(cm_ref) do |> Repo.update() end - def set_all_seen_for_chat(chat) do - chat - |> for_chat_query() - |> exclude(:order_by) - |> exclude(:preload) - |> where([cmr], cmr.unread == true) + def set_all_seen_for_chat(chat, last_read_id \\ nil) do + query = + chat + |> for_chat_query() + |> exclude(:order_by) + |> exclude(:preload) + |> where([cmr], cmr.unread == true) + + if last_read_id do + query + |> where([cmr], cmr.id <= ^last_read_id) + else + query + end |> Repo.update_all(set: [unread: false]) end end diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 74c3ad0bd..45fbad311 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -23,6 +23,7 @@ def mark_as_read_operation do summary: "Mark all messages in the chat as read", operationId: "ChatController.mark_as_read", parameters: [Operation.parameter(:id, :path, :string, "The ID of the Chat")], + requestBody: request_body("Parameters", mark_as_read()), responses: %{ 200 => Operation.response( @@ -333,4 +334,21 @@ def chat_message_create do } } end + + def mark_as_read do + %Schema{ + title: "MarkAsReadRequest", + description: "POST body for marking a number of chat messages as read", + type: :object, + properties: %{ + last_read_id: %Schema{ + type: :string, + description: "The content of your message. Optional." + } + }, + example: %{ + "last_read_id" => "abcdef12456" + } + } + end end diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 983550b13..002b75082 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -111,7 +111,8 @@ def mark_message_as_read(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ def mark_as_read(%{assigns: %{user: %{id: user_id}}} = conn, %{id: id}) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), - {_n, _} <- MessageReference.set_all_seen_for_chat(chat) do + {_n, _} <- + MessageReference.set_all_seen_for_chat(chat, conn.body_params[:last_read_id]) do conn |> put_view(ChatView) |> render("show.json", chat: chat) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 2128fd9dd..63cd89c73 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -65,6 +65,30 @@ test "it marks all messages in a chat as read", %{conn: conn, user: user} do assert cm_ref.unread == false end + + test "it given a `last_read_id` ", %{conn: conn, user: user} do + other_user = insert(:user) + + {:ok, create} = CommonAPI.post_chat_message(other_user, user, "sup") + {:ok, _create} = CommonAPI.post_chat_message(other_user, user, "sup part 2") + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + object = Object.normalize(create, false) + cm_ref = MessageReference.for_chat_and_object(chat, object) + + assert cm_ref.unread == true + + result = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/pleroma/chats/#{chat.id}/read", %{"last_read_id" => cm_ref.id}) + |> json_response_and_validate_schema(200) + + assert result["unread"] == 1 + + cm_ref = MessageReference.for_chat_and_object(chat, object) + + assert cm_ref.unread == false + end end describe "POST /api/v1/pleroma/chats/:id/messages" do From 680fa5fa36d8b30a9a9749edacf1a2c69fded29a Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 7 Jun 2020 15:41:46 +0200 Subject: [PATCH 229/401] Docs: Update docs on mark as read. --- docs/API/chats.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/API/chats.md b/docs/API/chats.md index 761047336..81ff57941 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -79,6 +79,11 @@ To set the `unread` count of a chat to 0, call `POST /api/v1/pleroma/chats/:id/read` + +Parameters: +- last_read_id: Given this id, all chat messages until this one will be marked as read. This should always be used. + + Returned data: ```json From 8d9e58688712ea416109aaee2883cc9ace644e02 Mon Sep 17 00:00:00 2001 From: Sergey Suprunenko Date: Sun, 7 Jun 2020 17:31:37 +0200 Subject: [PATCH 230/401] Delete pending follow requests on user deletion --- lib/pleroma/following_relationship.ex | 6 ++++++ lib/pleroma/user.ex | 8 ++++++++ test/user_test.exs | 5 +++++ 3 files changed, 19 insertions(+) diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index 3a3082e72..093b1f405 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -141,6 +141,12 @@ def following_query(%User{} = user) do |> where([r], r.state == ^:follow_accept) end + def outgoing_pending_follow_requests_query(%User{} = follower) do + __MODULE__ + |> where([r], r.follower_id == ^follower.id) + |> where([r], r.state == ^:follow_pending) + end + def following(%User{} = user) do following = following_query(user) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 72ee2d58e..c5c74d132 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1489,6 +1489,8 @@ def perform(:delete, %User{} = user) do delete_user_activities(user) + delete_outgoing_pending_follow_requests(user) + delete_or_deactivate(user) end @@ -1611,6 +1613,12 @@ defp delete_activity(%{data: %{"type" => type}} = activity, user) defp delete_activity(_activity, _user), do: "Doing nothing" + defp delete_outgoing_pending_follow_requests(user) do + user + |> FollowingRelationship.outgoing_pending_follow_requests_query() + |> Repo.delete_all() + end + def html_filter_policy(%User{no_rich_text: true}) do Pleroma.HTML.Scrubber.TwitterText end diff --git a/test/user_test.exs b/test/user_test.exs index 6b344158d..d68b4a58c 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1159,6 +1159,9 @@ test "it deactivates a user, all follow relationships and all activities", %{use follower = insert(:user) {:ok, follower} = User.follow(follower, user) + locked_user = insert(:user, name: "locked", locked: true) + {:ok, _} = User.follow(user, locked_user, :follow_pending) + object = insert(:note, user: user) activity = insert(:note_activity, user: user, note: object) @@ -1177,6 +1180,8 @@ test "it deactivates a user, all follow relationships and all activities", %{use refute User.following?(follower, user) assert %{deactivated: true} = User.get_by_id(user.id) + assert [] == User.get_follow_requests(locked_user) + user_activities = user.ap_id |> Activity.Queries.by_actor() From fe2a5d061463313f447b0557de05572fa3771728 Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 7 Jun 2020 20:22:08 +0200 Subject: [PATCH 231/401] ChatController: Make last_read_id mandatory. --- .../web/api_spec/operations/chat_operation.ex | 3 +- .../controllers/chat_controller.ex | 7 +++-- .../controllers/chat_controller_test.exs | 28 +++---------------- 3 files changed, 11 insertions(+), 27 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 45fbad311..cf299bfc2 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -340,10 +340,11 @@ def mark_as_read do title: "MarkAsReadRequest", description: "POST body for marking a number of chat messages as read", type: :object, + required: [:last_read_id], properties: %{ last_read_id: %Schema{ type: :string, - description: "The content of your message. Optional." + description: "The content of your message." } }, example: %{ diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 002b75082..b9949236c 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -109,10 +109,13 @@ def mark_message_as_read(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ end end - def mark_as_read(%{assigns: %{user: %{id: user_id}}} = conn, %{id: id}) do + def mark_as_read( + %{body_params: %{last_read_id: last_read_id}, assigns: %{user: %{id: user_id}}} = conn, + %{id: id} + ) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), {_n, _} <- - MessageReference.set_all_seen_for_chat(chat, conn.body_params[:last_read_id]) do + MessageReference.set_all_seen_for_chat(chat, last_read_id) do conn |> put_view(ChatView) |> render("show.json", chat: chat) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 63cd89c73..c2960956d 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -43,30 +43,10 @@ test "it marks one message as read", %{conn: conn, user: user} do describe "POST /api/v1/pleroma/chats/:id/read" do setup do: oauth_access(["write:chats"]) - test "it marks all messages in a chat as read", %{conn: conn, user: user} do - other_user = insert(:user) - - {:ok, create} = CommonAPI.post_chat_message(other_user, user, "sup") - {:ok, _create} = CommonAPI.post_chat_message(other_user, user, "sup part 2") - {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) - object = Object.normalize(create, false) - cm_ref = MessageReference.for_chat_and_object(chat, object) - - assert cm_ref.unread == true - - result = - conn - |> post("/api/v1/pleroma/chats/#{chat.id}/read") - |> json_response_and_validate_schema(200) - - assert result["unread"] == 0 - - cm_ref = MessageReference.for_chat_and_object(chat, object) - - assert cm_ref.unread == false - end - - test "it given a `last_read_id` ", %{conn: conn, user: user} do + test "given a `last_read_id`, it marks everything until then as read", %{ + conn: conn, + user: user + } do other_user = insert(:user) {:ok, create} = CommonAPI.post_chat_message(other_user, user, "sup") From 1a2acce7c5927cd113ebcffd0acc7a5c547bbf0e Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 7 Jun 2020 20:23:17 +0200 Subject: [PATCH 232/401] Docs: Document new mandatory parameter. --- docs/API/chats.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index 81ff57941..9eb581943 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -75,13 +75,13 @@ Returned data: ### Marking a chat as read -To set the `unread` count of a chat to 0, call +To mark a number of messages in a chat up to a certain message as read, you can use `POST /api/v1/pleroma/chats/:id/read` Parameters: -- last_read_id: Given this id, all chat messages until this one will be marked as read. This should always be used. +- last_read_id: Given this id, all chat messages until this one will be marked as read. Required. Returned data: From 89b85f65297ef4b8ce92eacb27c90e8f7c874f54 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 8 Jun 2020 11:09:53 +0200 Subject: [PATCH 233/401] ChatController: Remove nonsensical pagination. --- docs/API/chats.md | 2 +- .../web/pleroma_api/controllers/chat_controller.ex | 4 ++-- .../pleroma_api/controllers/chat_controller_test.exs | 11 ++--------- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index 9eb581943..aa6119670 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -135,7 +135,7 @@ Returned data: ``` The recipient of messages that are sent to this chat is given by their AP ID. -The usual pagination options are implemented. +No pagination is implemented for now. ### Getting the messages for a Chat diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index b9949236c..e4760f53e 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -140,7 +140,7 @@ def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{id: id} = para end end - def index(%{assigns: %{user: %{id: user_id} = user}} = conn, params) do + def index(%{assigns: %{user: %{id: user_id} = user}} = conn, _params) do blocked_ap_ids = User.blocked_users_ap_ids(user) chats = @@ -149,7 +149,7 @@ def index(%{assigns: %{user: %{id: user_id} = user}} = conn, params) do where: c.recipient not in ^blocked_ap_ids, order_by: [desc: c.updated_at] ) - |> Pagination.fetch_paginated(params |> stringify_keys) + |> Repo.all() conn |> put_view(ChatView) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index c2960956d..82e16741d 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -289,7 +289,7 @@ test "it does not return chats with users you blocked", %{conn: conn, user: user assert length(result) == 0 end - test "it paginates", %{conn: conn, user: user} do + test "it returns all chats", %{conn: conn, user: user} do Enum.each(1..30, fn _ -> recipient = insert(:user) {:ok, _} = Chat.get_or_create(user.id, recipient.ap_id) @@ -300,14 +300,7 @@ test "it paginates", %{conn: conn, user: user} do |> get("/api/v1/pleroma/chats") |> json_response_and_validate_schema(200) - assert length(result) == 20 - - result = - conn - |> get("/api/v1/pleroma/chats?max_id=#{List.last(result)["id"]}") - |> json_response_and_validate_schema(200) - - assert length(result) == 10 + assert length(result) == 30 end test "it return a list of chats the current user is participating in, in descending order of updates", From d44843e6774ed1c60d510a5307e0113e39569416 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 8 Jun 2020 17:56:34 +0400 Subject: [PATCH 234/401] Restrict ActivityExpirationPolicy to Notes only --- .../mrf/activity_expiration_policy.ex | 6 ++++- .../mrf/activity_expiration_policy_test.exs | 26 +++++++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex index a9bdf3b69..8e47f1e02 100644 --- a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy do @impl true def filter(activity) do activity = - if activity["type"] == "Create" && local?(activity) do + if note?(activity) and local?(activity) do maybe_add_expiration(activity) else activity @@ -25,6 +25,10 @@ defp local?(%{"id" => id}) do String.starts_with?(id, Pleroma.Web.Endpoint.url()) end + defp note?(activity) do + match?(%{"type" => "Create", "object" => %{"type" => "Note"}}, activity) + end + defp maybe_add_expiration(activity) do days = Pleroma.Config.get([:mrf_activity_expiration, :days], 365) expires_at = NaiveDateTime.utc_now() |> Timex.shift(days: days) diff --git a/test/web/activity_pub/mrf/activity_expiration_policy_test.exs b/test/web/activity_pub/mrf/activity_expiration_policy_test.exs index 0d3bcc457..8babf49e7 100644 --- a/test/web/activity_pub/mrf/activity_expiration_policy_test.exs +++ b/test/web/activity_pub/mrf/activity_expiration_policy_test.exs @@ -10,7 +10,11 @@ defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicyTest do test "adds `expires_at` property" do assert {:ok, %{"type" => "Create", "expires_at" => expires_at}} = - ActivityExpirationPolicy.filter(%{"id" => @id, "type" => "Create"}) + ActivityExpirationPolicy.filter(%{ + "id" => @id, + "type" => "Create", + "object" => %{"type" => "Note"} + }) assert Timex.diff(expires_at, NaiveDateTime.utc_now(), :days) == 364 end @@ -22,7 +26,8 @@ test "keeps existing `expires_at` if it less than the config setting" do ActivityExpirationPolicy.filter(%{ "id" => @id, "type" => "Create", - "expires_at" => expires_at + "expires_at" => expires_at, + "object" => %{"type" => "Note"} }) end @@ -33,7 +38,8 @@ test "overwrites existing `expires_at` if it greater than the config setting" do ActivityExpirationPolicy.filter(%{ "id" => @id, "type" => "Create", - "expires_at" => too_distant_future + "expires_at" => too_distant_future, + "object" => %{"type" => "Note"} }) assert Timex.diff(expires_at, NaiveDateTime.utc_now(), :days) == 364 @@ -43,13 +49,14 @@ test "ignores remote activities" do assert {:ok, activity} = ActivityExpirationPolicy.filter(%{ "id" => "https://example.com/123", - "type" => "Create" + "type" => "Create", + "object" => %{"type" => "Note"} }) refute Map.has_key?(activity, "expires_at") end - test "ignores non-Create activities" do + test "ignores non-Create/Note activities" do assert {:ok, activity} = ActivityExpirationPolicy.filter(%{ "id" => "https://example.com/123", @@ -57,5 +64,14 @@ test "ignores non-Create activities" do }) refute Map.has_key?(activity, "expires_at") + + assert {:ok, activity} = + ActivityExpirationPolicy.filter(%{ + "id" => "https://example.com/123", + "type" => "Create", + "object" => %{"type" => "Cofe"} + }) + + refute Map.has_key?(activity, "expires_at") end end From fe1cb56fdc52092a8af2895fae4c020679156674 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 8 Jun 2020 21:04:16 +0200 Subject: [PATCH 235/401] transmogrifier: MIME.valid?/1 for mediaType No issues with the rest of the network yet but this makes sure it will work once https://git.pleroma.social/pleroma/pleroma/-/merge_requests/2429 is merged. --- lib/pleroma/web/activity_pub/transmogrifier.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index fda1c71df..543972ae9 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -221,9 +221,9 @@ def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachm media_type = cond do - is_map(url) && is_binary(url["mediaType"]) -> url["mediaType"] - is_binary(data["mediaType"]) -> data["mediaType"] - is_binary(data["mimeType"]) -> data["mimeType"] + is_map(url) && MIME.valid?(url["mediaType"]) -> url["mediaType"] + MIME.valid?(data["mediaType"]) -> data["mediaType"] + MIME.valid?(data["mimeType"]) -> data["mimeType"] true -> nil end From fc04a138d46c43860a2838d60fc8668112fdc1ec Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 8 Jun 2020 20:01:37 +0000 Subject: [PATCH 236/401] Apply suggestion to lib/pleroma/notification.ex --- lib/pleroma/notification.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 3ac8737e2..3386a1933 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -31,7 +31,7 @@ defmodule Pleroma.Notification do schema "notifications" do field(:seen, :boolean, default: false) # This is an enum type in the database. If you add a new notification type, - # remembert to add a migration to add it to the `notifications_type` enum + # remember to add a migration to add it to the `notifications_type` enum # as well. field(:type, :string) belongs_to(:user, User, type: FlakeId.Ecto.CompatType) From e1bc37d11852684a5007a9550208944d899800ca Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 9 Jun 2020 09:20:55 +0200 Subject: [PATCH 237/401] MigrationHelper: Move notification backfilling to own module. --- .../notification_backfill.ex} | 2 +- .../20200602125218_backfill_notification_types.exs | 2 +- .../notification_backfill_test.exs} | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) rename lib/pleroma/{migration_helper.ex => migration_helper/notification_backfill.ex} (97%) rename test/{migration_helper_test.exs => migration_helper/notification_backfill_test.exs} (91%) diff --git a/lib/pleroma/migration_helper.ex b/lib/pleroma/migration_helper/notification_backfill.ex similarity index 97% rename from lib/pleroma/migration_helper.ex rename to lib/pleroma/migration_helper/notification_backfill.ex index a20d27a01..09647d12a 100644 --- a/lib/pleroma/migration_helper.ex +++ b/lib/pleroma/migration_helper/notification_backfill.ex @@ -2,7 +2,7 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.MigrationHelper do +defmodule Pleroma.MigrationHelper.NotificationBackfill do alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo diff --git a/priv/repo/migrations/20200602125218_backfill_notification_types.exs b/priv/repo/migrations/20200602125218_backfill_notification_types.exs index 58943fad0..996d721ee 100644 --- a/priv/repo/migrations/20200602125218_backfill_notification_types.exs +++ b/priv/repo/migrations/20200602125218_backfill_notification_types.exs @@ -2,7 +2,7 @@ defmodule Pleroma.Repo.Migrations.BackfillNotificationTypes do use Ecto.Migration def up do - Pleroma.MigrationHelper.fill_in_notification_types() + Pleroma.MigrationHelper.NotificationBackfill.fill_in_notification_types() end def down do diff --git a/test/migration_helper_test.exs b/test/migration_helper/notification_backfill_test.exs similarity index 91% rename from test/migration_helper_test.exs rename to test/migration_helper/notification_backfill_test.exs index 1c8173987..2a62a2b00 100644 --- a/test/migration_helper_test.exs +++ b/test/migration_helper/notification_backfill_test.exs @@ -2,11 +2,11 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.MigrationHelperTest do +defmodule Pleroma.MigrationHelper.NotificationBackfillTest do use Pleroma.DataCase alias Pleroma.Activity - alias Pleroma.MigrationHelper + alias Pleroma.MigrationHelper.NotificationBackfill alias Pleroma.Notification alias Pleroma.Repo alias Pleroma.Web.CommonAPI @@ -35,7 +35,7 @@ test "it fills in missing notification types" do assert {5, nil} = Repo.update_all(Notification, set: [type: nil]) - MigrationHelper.fill_in_notification_types() + NotificationBackfill.fill_in_notification_types() assert %{type: "mention"} = Repo.get_by(Notification, user_id: other_user.id, activity_id: post.id) From 063e6b9841ec72c7e89339c54581d199fa31e675 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 9 Jun 2020 10:53:40 +0200 Subject: [PATCH 238/401] StatusController: Correctly paginate favorites. Favorites were paginating wrongly, because the pagination headers where using the id of the id of the `Create` activity, while the ordering was by the id of the `Like` activity. This isn't easy to notice in most cases, as they usually have a similar order because people tend to favorite posts as they come in. This commit adds a way to give different pagination ids to the pagination helper, so we can paginate correctly in cases like this. --- lib/pleroma/activity.ex | 4 ++ lib/pleroma/web/activity_pub/activity_pub.ex | 5 +- .../api_spec/operations/status_operation.ex | 3 +- lib/pleroma/web/controller_helper.ex | 56 +++++++++++-------- .../controllers/status_controller_test.exs | 43 ++++++++++++-- 5 files changed, 79 insertions(+), 32 deletions(-) diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 6213d0eb7..f800447fd 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -41,6 +41,10 @@ defmodule Pleroma.Activity do field(:recipients, {:array, :string}, default: []) field(:thread_muted?, :boolean, virtual: true) + # A field that can be used if you need to join some kind of other + # id to order / paginate this field by + field(:pagination_id, :string, virtual: true) + # This is a fake relation, # do not use outside of with_preloaded_user_actor/with_joined_user_actor has_one(:user_actor, User, on_delete: :nothing, foreign_key: :id) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index eb73c95fe..cc883ccce 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1138,12 +1138,11 @@ def fetch_favourites(user, params \\ %{}, pagination \\ :keyset) do |> Activity.Queries.by_type("Like") |> Activity.with_joined_object() |> Object.with_joined_activity() - |> select([_like, object, activity], %{activity | object: object}) + |> select([like, object, activity], %{activity | object: object, pagination_id: like.id}) |> order_by([like, _, _], desc_nulls_last: like.id) |> Pagination.fetch_paginated( Map.merge(params, %{skip_order: true}), - pagination, - :object_activity + pagination ) end diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index ca9db01e5..0b7fad793 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -333,7 +333,8 @@ def favourites_operation do %Operation{ tags: ["Statuses"], summary: "Favourited statuses", - description: "Statuses the user has favourited", + description: + "Statuses the user has favourited. Please note that you have to use the link headers to paginate this. You can not build the query parameters yourself.", operationId: "StatusController.favourites", parameters: pagination_params(), security: [%{"oAuth" => ["read:favourites"]}], diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 5d67d75b5..5e33e0810 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -57,35 +57,45 @@ def add_link_headers(conn, activities, extra_params) do end end + defp build_pagination_fields(conn, min_id, max_id, extra_params) do + params = + conn.params + |> Map.drop(Map.keys(conn.path_params)) + |> Map.merge(extra_params) + |> Map.drop(Pagination.page_keys() -- ["limit", "order"]) + + fields = %{ + "next" => current_url(conn, Map.put(params, :max_id, max_id)), + "prev" => current_url(conn, Map.put(params, :min_id, min_id)) + } + + # Generating an `id` without already present pagination keys would + # need a query-restriction with an `q.id >= ^id` or `q.id <= ^id` + # instead of the `q.id > ^min_id` and `q.id < ^max_id`. + # This is because we only have ids present inside of the page, while + # `min_id`, `since_id` and `max_id` requires to know one outside of it. + if Map.take(conn.params, Pagination.page_keys() -- ["limit", "order"]) != [] do + Map.put(fields, "id", current_url(conn, conn.params)) + else + fields + end + end + def get_pagination_fields(conn, activities, extra_params \\ %{}) do case List.last(activities) do - %{id: max_id} -> - params = - conn.params - |> Map.drop(Map.keys(conn.path_params)) - |> Map.merge(extra_params) - |> Map.drop(Pagination.page_keys() -- ["limit", "order"]) - - min_id = + %{pagination_id: max_id} when not is_nil(max_id) -> + %{pagination_id: min_id} = activities |> List.first() - |> Map.get(:id) - fields = %{ - "next" => current_url(conn, Map.put(params, :max_id, max_id)), - "prev" => current_url(conn, Map.put(params, :min_id, min_id)) - } + build_pagination_fields(conn, min_id, max_id, extra_params) - # Generating an `id` without already present pagination keys would - # need a query-restriction with an `q.id >= ^id` or `q.id <= ^id` - # instead of the `q.id > ^min_id` and `q.id < ^max_id`. - # This is because we only have ids present inside of the page, while - # `min_id`, `since_id` and `max_id` requires to know one outside of it. - if Map.take(conn.params, Pagination.page_keys() -- ["limit", "order"]) != [] do - Map.put(fields, "id", current_url(conn, conn.params)) - else - fields - end + %{id: max_id} -> + %{id: min_id} = + activities + |> List.first() + + build_pagination_fields(conn, min_id, max_id, extra_params) _ -> %{} diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index 700c82e4f..648e6f2ce 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -1541,14 +1541,49 @@ test "context" do } = response end + test "favorites paginate correctly" do + %{user: user, conn: conn} = oauth_access(["read:favourites"]) + other_user = insert(:user) + {:ok, first_post} = CommonAPI.post(other_user, %{status: "bla"}) + {:ok, second_post} = CommonAPI.post(other_user, %{status: "bla"}) + {:ok, third_post} = CommonAPI.post(other_user, %{status: "bla"}) + + {:ok, _first_favorite} = CommonAPI.favorite(user, third_post.id) + {:ok, _second_favorite} = CommonAPI.favorite(user, first_post.id) + {:ok, third_favorite} = CommonAPI.favorite(user, second_post.id) + + result = + conn + |> get("/api/v1/favourites?limit=1") + + assert [%{"id" => post_id}] = json_response_and_validate_schema(result, 200) + assert post_id == second_post.id + + # Using the header for pagination works correctly + [next, _] = get_resp_header(result, "link") |> hd() |> String.split(", ") + [_, max_id] = Regex.run(~r/max_id=(.*)>;/, next) + + assert max_id == third_favorite.id + + result = + conn + |> get("/api/v1/favourites?max_id=#{max_id}") + + assert [%{"id" => first_post_id}, %{"id" => third_post_id}] = + json_response_and_validate_schema(result, 200) + + assert first_post_id == first_post.id + assert third_post_id == third_post.id + end + test "returns the favorites of a user" do %{user: user, conn: conn} = oauth_access(["read:favourites"]) other_user = insert(:user) {:ok, _} = CommonAPI.post(other_user, %{status: "bla"}) - {:ok, activity} = CommonAPI.post(other_user, %{status: "traps are happy"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "trees are happy"}) - {:ok, _} = CommonAPI.favorite(user, activity.id) + {:ok, last_like} = CommonAPI.favorite(user, activity.id) first_conn = get(conn, "/api/v1/favourites") @@ -1566,9 +1601,7 @@ test "returns the favorites of a user" do {:ok, _} = CommonAPI.favorite(user, second_activity.id) - last_like = status["id"] - - second_conn = get(conn, "/api/v1/favourites?since_id=#{last_like}") + second_conn = get(conn, "/api/v1/favourites?since_id=#{last_like.id}") assert [second_status] = json_response_and_validate_schema(second_conn, 200) assert second_status["id"] == to_string(second_activity.id) From 3dd1de61a78f9571a1d886411d70cd52584e084a Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 8 Jun 2020 22:08:57 +0400 Subject: [PATCH 239/401] Add `url` field to AdminAPI.AccountView --- .../web/admin_api/views/account_view.ex | 3 +- .../controllers/admin_api_controller_test.exs | 66 ++++++++++++------- 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index 120159527..e1e929632 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -76,7 +76,8 @@ def render("show.json", %{user: user}) do "local" => user.local, "roles" => User.roles(user), "tags" => user.tags || [], - "confirmation_pending" => user.confirmation_pending + "confirmation_pending" => user.confirmation_pending, + "url" => user.uri || user.ap_id } end diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index bea810c4a..e3d3ccb8d 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -337,7 +337,8 @@ test "Show", %{conn: conn} do "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } assert expected == json_response(conn, 200) @@ -614,7 +615,8 @@ test "renders users array for the first page", %{conn: conn, admin: admin} do "tags" => [], "avatar" => User.avatar_url(admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(admin.name || admin.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => admin.ap_id }, %{ "deactivated" => user.deactivated, @@ -625,7 +627,8 @@ test "renders users array for the first page", %{conn: conn, admin: admin} do "tags" => ["foo", "bar"], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } ] |> Enum.sort_by(& &1["nickname"]) @@ -697,7 +700,8 @@ test "regular search", %{conn: conn} do "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } ] } @@ -722,7 +726,8 @@ test "search by domain", %{conn: conn} do "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } ] } @@ -747,7 +752,8 @@ test "search by full nickname", %{conn: conn} do "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } ] } @@ -772,7 +778,8 @@ test "search by display name", %{conn: conn} do "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } ] } @@ -797,7 +804,8 @@ test "search by email", %{conn: conn} do "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } ] } @@ -822,7 +830,8 @@ test "regular search with page size", %{conn: conn} do "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } ] } @@ -842,7 +851,8 @@ test "regular search with page size", %{conn: conn} do "tags" => [], "avatar" => User.avatar_url(user2) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user2.name || user2.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user2.ap_id } ] } @@ -874,7 +884,8 @@ test "only local users" do "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } ] } @@ -899,7 +910,8 @@ test "only local users with no query", %{conn: conn, admin: old_admin} do "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id }, %{ "deactivated" => admin.deactivated, @@ -910,7 +922,8 @@ test "only local users with no query", %{conn: conn, admin: old_admin} do "tags" => [], "avatar" => User.avatar_url(admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(admin.name || admin.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => admin.ap_id }, %{ "deactivated" => false, @@ -921,7 +934,8 @@ test "only local users with no query", %{conn: conn, admin: old_admin} do "tags" => [], "avatar" => User.avatar_url(old_admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(old_admin.name || old_admin.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => old_admin.ap_id } ] |> Enum.sort_by(& &1["nickname"]) @@ -951,7 +965,8 @@ test "load only admins", %{conn: conn, admin: admin} do "tags" => [], "avatar" => User.avatar_url(admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(admin.name || admin.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => admin.ap_id }, %{ "deactivated" => false, @@ -962,7 +977,8 @@ test "load only admins", %{conn: conn, admin: admin} do "tags" => [], "avatar" => User.avatar_url(second_admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(second_admin.name || second_admin.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => second_admin.ap_id } ] |> Enum.sort_by(& &1["nickname"]) @@ -994,7 +1010,8 @@ test "load only moderators", %{conn: conn} do "tags" => [], "avatar" => User.avatar_url(moderator) |> MediaProxy.url(), "display_name" => HTML.strip_tags(moderator.name || moderator.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => moderator.ap_id } ] } @@ -1019,7 +1036,8 @@ test "load users with tags list", %{conn: conn} do "tags" => ["first"], "avatar" => User.avatar_url(user1) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user1.name || user1.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user1.ap_id }, %{ "deactivated" => false, @@ -1030,7 +1048,8 @@ test "load users with tags list", %{conn: conn} do "tags" => ["second"], "avatar" => User.avatar_url(user2) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user2.name || user2.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user2.ap_id } ] |> Enum.sort_by(& &1["nickname"]) @@ -1069,7 +1088,8 @@ test "it works with multiple filters" do "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } ] } @@ -1093,7 +1113,8 @@ test "it omits relay user", %{admin: admin, conn: conn} do "tags" => [], "avatar" => User.avatar_url(admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(admin.name || admin.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => admin.ap_id } ] } @@ -1155,7 +1176,8 @@ test "PATCH /api/pleroma/admin/users/:nickname/toggle_activation", %{admin: admi "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } log_entry = Repo.one(ModerationLog) From c4f267b3bef90dcac21b7db2a91f86d3ba5dc7c2 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 10 Jun 2020 08:02:26 +0000 Subject: [PATCH 240/401] Apply suggestion to lib/pleroma/web/controller_helper.ex --- lib/pleroma/web/controller_helper.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 5e33e0810..6cb19d539 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -57,6 +57,7 @@ def add_link_headers(conn, activities, extra_params) do end end + @id_keys Pagination.page_keys() -- ["limit", "order"] defp build_pagination_fields(conn, min_id, max_id, extra_params) do params = conn.params From be7c322865b2b7aa1c8c25147cc598b6362ab187 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 10 Jun 2020 08:02:35 +0000 Subject: [PATCH 241/401] Apply suggestion to lib/pleroma/web/controller_helper.ex --- lib/pleroma/web/controller_helper.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 6cb19d539..b7971e940 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -63,7 +63,7 @@ defp build_pagination_fields(conn, min_id, max_id, extra_params) do conn.params |> Map.drop(Map.keys(conn.path_params)) |> Map.merge(extra_params) - |> Map.drop(Pagination.page_keys() -- ["limit", "order"]) + |> Map.drop(@id_keys) fields = %{ "next" => current_url(conn, Map.put(params, :max_id, max_id)), From b4c50be9df701dc9faf0a25f776f631d2175c99f Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 10 Jun 2020 08:12:29 +0000 Subject: [PATCH 242/401] Apply suggestion to lib/pleroma/web/controller_helper.ex --- lib/pleroma/web/controller_helper.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index b7971e940..ab6e6c61a 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -75,7 +75,7 @@ defp build_pagination_fields(conn, min_id, max_id, extra_params) do # instead of the `q.id > ^min_id` and `q.id < ^max_id`. # This is because we only have ids present inside of the page, while # `min_id`, `since_id` and `max_id` requires to know one outside of it. - if Map.take(conn.params, Pagination.page_keys() -- ["limit", "order"]) != [] do + if Map.take(conn.params, @id_keys) != %{} do Map.put(fields, "id", current_url(conn, conn.params)) else fields From 86fec45f40dfa45cc89eddc6dcc7799e89d6f461 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 10 Jun 2020 11:09:45 +0200 Subject: [PATCH 243/401] ControllerHelper: Fix wrong comparison. --- lib/pleroma/web/controller_helper.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index ab6e6c61a..88f2cc6f1 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -75,7 +75,7 @@ defp build_pagination_fields(conn, min_id, max_id, extra_params) do # instead of the `q.id > ^min_id` and `q.id < ^max_id`. # This is because we only have ids present inside of the page, while # `min_id`, `since_id` and `max_id` requires to know one outside of it. - if Map.take(conn.params, @id_keys) != %{} do + if Map.take(conn.params, @id_keys) != [] do Map.put(fields, "id", current_url(conn, conn.params)) else fields From 9e411372d0b7ae286941063956305c0a2eae46a6 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 10 Jun 2020 12:10:09 +0200 Subject: [PATCH 244/401] ActivityPub: Don't show announces of your own objects in timeline. --- lib/pleroma/web/activity_pub/activity_pub.ex | 40 ++++++++++--------- .../controllers/timeline_controller.ex | 1 + test/web/activity_pub/activity_pub_test.exs | 24 +++++++++++ 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index eb73c95fe..4182275bc 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -31,25 +31,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do require Logger require Pleroma.Constants - # For Announce activities, we filter the recipients based on following status for any actors - # that match actual users. See issue #164 for more information about why this is necessary. - defp get_recipients(%{"type" => "Announce"} = data) do - to = Map.get(data, "to", []) - cc = Map.get(data, "cc", []) - bcc = Map.get(data, "bcc", []) - actor = User.get_cached_by_ap_id(data["actor"]) - - recipients = - Enum.filter(Enum.concat([to, cc, bcc]), fn recipient -> - case User.get_cached_by_ap_id(recipient) do - nil -> true - user -> User.following?(user, actor) - end - end) - - {recipients, to, cc} - end - defp get_recipients(%{"type" => "Create"} = data) do to = Map.get(data, "to", []) cc = Map.get(data, "cc", []) @@ -702,6 +683,26 @@ defp user_activities_recipients(%{reading_user: reading_user}) do end end + defp restrict_announce_object_actor(_query, %{announce_filtering_user: _, skip_preload: true}) do + raise "Can't use the child object without preloading!" + end + + defp restrict_announce_object_actor(query, %{announce_filtering_user: %{ap_id: actor}}) do + from( + [activity, object] in query, + where: + fragment( + "?->>'type' != ? or ?->>'actor' != ?", + activity.data, + "Announce", + object.data, + ^actor + ) + ) + end + + defp restrict_announce_object_actor(query, _), do: query + defp restrict_since(query, %{since_id: ""}), do: query defp restrict_since(query, %{since_id: since_id}) do @@ -1113,6 +1114,7 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> restrict_pinned(opts) |> restrict_muted_reblogs(restrict_muted_reblogs_opts) |> restrict_instance(opts) + |> restrict_announce_object_actor(opts) |> Activity.restrict_deactivated_users() |> exclude_poll_votes(opts) |> exclude_invisible_actors(opts) diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 9270ca267..4bdd46d7e 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -48,6 +48,7 @@ def home(%{assigns: %{user: user}} = conn, params) do |> Map.put(:blocking_user, user) |> Map.put(:muting_user, user) |> Map.put(:reply_filtering_user, user) + |> Map.put(:announce_filtering_user, user) |> Map.put(:user, user) activities = diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 2f65dfc8e..e17cc4ab1 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -1643,6 +1643,30 @@ test "home timeline with reply_visibility `self`", %{ assert Enum.all?(visible_ids, &(&1 in activities_ids)) end + + test "filtering out announces where the user is the actor of the announced message" do + user = insert(:user) + other_user = insert(:user) + third_user = insert(:user) + User.follow(user, other_user) + + {:ok, post} = CommonAPI.post(user, %{status: "yo"}) + {:ok, other_post} = CommonAPI.post(third_user, %{status: "yo"}) + {:ok, _announce} = CommonAPI.repeat(post.id, other_user) + {:ok, _announce} = CommonAPI.repeat(post.id, third_user) + {:ok, announce} = CommonAPI.repeat(other_post.id, other_user) + + params = %{ + type: ["Announce"], + announce_filtering_user: user + } + + [result] = + [user.ap_id | User.following(user)] + |> ActivityPub.fetch_activities(params) + + assert result.id == announce.id + end end describe "replies filtering with private messages" do From 600e2ea07396489325e06dee3e8432288e0e13c2 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 10 Jun 2020 12:15:56 +0200 Subject: [PATCH 245/401] ActivityPubTest: Make test easier to understand. --- test/web/activity_pub/activity_pub_test.exs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index e17cc4ab1..6cd3b8d1b 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -1656,6 +1656,16 @@ test "filtering out announces where the user is the actor of the announced messa {:ok, _announce} = CommonAPI.repeat(post.id, third_user) {:ok, announce} = CommonAPI.repeat(other_post.id, other_user) + params = %{ + type: ["Announce"] + } + + results = + [user.ap_id | User.following(user)] + |> ActivityPub.fetch_activities(params) + + assert length(results) == 3 + params = %{ type: ["Announce"], announce_filtering_user: user From 570123ae21382c7e78b99442e3c025b0e66b8f6d Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Sun, 7 Jun 2020 18:21:11 +0200 Subject: [PATCH 246/401] Add test --- test/web/activity_pub/activity_pub_test.exs | 35 ++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 6cd3b8d1b..72d3f3dfa 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -574,7 +574,7 @@ test "doesn't return transitive interactions concerning blocked users" do refute Enum.member?(activities, activity_four) end - test "doesn't return announce activities concerning blocked users" do + test "doesn't return announce activities with blocked users in 'to'" do blocker = insert(:user) blockee = insert(:user) friend = insert(:user) @@ -596,6 +596,39 @@ test "doesn't return announce activities concerning blocked users" do refute Enum.member?(activities, activity_three.id) end + test "doesn't return announce activities with blocked users in 'cc'" do + blocker = insert(:user) + blockee = insert(:user) + friend = insert(:user) + + {:ok, _user_relationship} = User.block(blocker, blockee) + + {:ok, activity_one} = CommonAPI.post(friend, %{status: "hey!"}) + + {:ok, activity_two} = CommonAPI.post(blockee, %{status: "hey! @#{friend.nickname}"}) + + assert object = Pleroma.Object.normalize(activity_two) + + data = %{ + "actor" => friend.ap_id, + "object" => object.data["id"], + "context" => object.data["context"], + "type" => "Announce", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [blockee.ap_id] + } + + assert {:ok, activity_three} = ActivityPub.insert(data) + + activities = + ActivityPub.fetch_activities([], %{"blocking_user" => blocker}) + |> Enum.map(fn act -> act.id end) + + assert Enum.member?(activities, activity_one.id) + refute Enum.member?(activities, activity_two.id) + refute Enum.member?(activities, activity_three.id) + end + test "doesn't return activities from blocked domains" do domain = "dogwhistle.zone" domain_user = insert(:user, %{ap_id: "https://#{domain}/@pundit"}) From 5d87405b51efe9f99fea669090a5914db22ca9ed Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 10 Jun 2020 16:55:30 +0200 Subject: [PATCH 247/401] ActivityPubTest: Update test for atomized parameters. --- test/web/activity_pub/activity_pub_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 72d3f3dfa..b239b812f 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -621,7 +621,7 @@ test "doesn't return announce activities with blocked users in 'cc'" do assert {:ok, activity_three} = ActivityPub.insert(data) activities = - ActivityPub.fetch_activities([], %{"blocking_user" => blocker}) + ActivityPub.fetch_activities([], %{blocking_user: blocker}) |> Enum.map(fn act -> act.id end) assert Enum.member?(activities, activity_one.id) From 99afc7f4e423997079aaee1287b9ffb28a851d8b Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 10 Jun 2020 20:09:16 +0300 Subject: [PATCH 248/401] HTTP security plug: add media proxy base url host to csp --- lib/pleroma/plugs/http_security_plug.ex | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index 6a339b32c..620408d0f 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -113,6 +113,10 @@ defp get_proxy_and_attachment_sources do add_source(acc, host) end) + media_proxy_base_url = + if Config.get([Pleroma.Upload, :base_url]), + do: URI.parse(Config.get([:media_proxy, :base_url])).host + upload_base_url = if Config.get([Pleroma.Upload, :base_url]), do: URI.parse(Config.get([Pleroma.Upload, :base_url])).host @@ -122,6 +126,7 @@ defp get_proxy_and_attachment_sources do do: URI.parse(Config.get([Pleroma.Uploaders.S3, :public_endpoint])).host [] + |> add_source(media_proxy_base_url) |> add_source(upload_base_url) |> add_source(s3_endpoint) |> add_source(media_proxy_whitelist) From 7c47f791a803aa5cee2f2f6931b8445d2c0551e5 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 10 Jun 2020 13:02:08 -0500 Subject: [PATCH 249/401] Add command to reload emoji packs from cli for OTP users Not useful for source releases as we don't have a way to automate connecting to the running instance. --- docs/administration/CLI_tasks/emoji.md | 8 ++++++++ lib/mix/tasks/pleroma/emoji.ex | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/docs/administration/CLI_tasks/emoji.md b/docs/administration/CLI_tasks/emoji.md index 3d524a52b..ddcb7e62c 100644 --- a/docs/administration/CLI_tasks/emoji.md +++ b/docs/administration/CLI_tasks/emoji.md @@ -44,3 +44,11 @@ Currently, only .zip archives are recognized as remote pack files and packs are The manifest entry will either be written to a newly created `pack_name.json` file (pack name is asked in questions) 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). + +## Reload emoji packs + +```sh tab="OTP" +./bin/pleroma_ctl emoji reload +``` + +This command only works with OTP releases. diff --git a/lib/mix/tasks/pleroma/emoji.ex b/lib/mix/tasks/pleroma/emoji.ex index 29a5fa99c..f4eaeac98 100644 --- a/lib/mix/tasks/pleroma/emoji.ex +++ b/lib/mix/tasks/pleroma/emoji.ex @@ -237,6 +237,12 @@ def run(["gen-pack" | args]) do end end + def run(["reload"]) do + start_pleroma() + Pleroma.Emoji.reload() + IO.puts("Emoji packs have been reloaded.") + end + defp fetch_and_decode(from) do with {:ok, json} <- fetch(from) do Jason.decode!(json) From 5e44e9d69871f2e5805a8dddcfce43ae713eb52d Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 10 Jun 2020 18:56:46 +0000 Subject: [PATCH 250/401] Apply suggestion to lib/pleroma/web/controller_helper.ex --- lib/pleroma/web/controller_helper.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 88f2cc6f1..a5eb3e9e0 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -76,7 +76,7 @@ defp build_pagination_fields(conn, min_id, max_id, extra_params) do # This is because we only have ids present inside of the page, while # `min_id`, `since_id` and `max_id` requires to know one outside of it. if Map.take(conn.params, @id_keys) != [] do - Map.put(fields, "id", current_url(conn, conn.params)) + Map.put(fields, "id", current_url(conn)) else fields end From b28cec4271c52d55f6e6cf8a1bcdb41efec3ef03 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 11 Jun 2020 16:05:14 +0300 Subject: [PATCH 251/401] [#1794] Fixes URI query handling for hashtags extraction in search. --- .../mastodon_api/controllers/search_controller.ex | 14 ++++++++++++++ .../controllers/search_controller_test.exs | 9 +++++++++ 2 files changed, 23 insertions(+) diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index 8840fc19c..46bcf4228 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -124,6 +124,7 @@ defp resource_search(:v1, "hashtags", query, _options) do defp prepare_tags(query, add_joined_tag \\ true) do tags = query + |> preprocess_uri_query() |> String.split(~r/[^#\w]+/u, trim: true) |> Enum.uniq_by(&String.downcase/1) @@ -147,6 +148,19 @@ defp prepare_tags(query, add_joined_tag \\ true) do end end + # If `query` is a URI, returns last component of its path, otherwise returns `query` + defp preprocess_uri_query(query) do + if query =~ ~r/https?:\/\// do + query + |> URI.parse() + |> Map.get(:path) + |> String.split("/") + |> Enum.at(-1) + else + query + end + end + defp joined_tag(tags) do tags |> Enum.map(fn tag -> String.capitalize(tag) end) diff --git a/test/web/mastodon_api/controllers/search_controller_test.exs b/test/web/mastodon_api/controllers/search_controller_test.exs index 84d46895e..0e025adca 100644 --- a/test/web/mastodon_api/controllers/search_controller_test.exs +++ b/test/web/mastodon_api/controllers/search_controller_test.exs @@ -111,6 +111,15 @@ test "constructs hashtags from search query", %{conn: conn} do %{"name" => "prone", "url" => "#{Web.base_url()}/tag/prone"}, %{"name" => "AccidentProne", "url" => "#{Web.base_url()}/tag/AccidentProne"} ] + + results = + conn + |> get("/api/v2/search?#{URI.encode_query(%{q: "https://shpposter.club/users/shpuld"})}") + |> json_response_and_validate_schema(200) + + assert results["hashtags"] == [ + %{"name" => "shpuld", "url" => "#{Web.base_url()}/tag/shpuld"} + ] end test "excludes a blocked users from search results", %{conn: conn} do From 1f35acce54ea6924a54b4fc387be3346a6f5551e Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 11 Jun 2020 17:57:31 +0400 Subject: [PATCH 252/401] Merge OGP parser with TwitterCard --- CHANGELOG.md | 1 + config/config.exs | 1 - config/description.exs | 2 - lib/pleroma/web/rich_media/parser.ex | 4 +- .../rich_media/parsers/meta_tags_parser.ex | 23 ++-- .../web/rich_media/parsers/oembed_parser.ex | 4 +- lib/pleroma/web/rich_media/parsers/ogp.ex | 3 +- .../web/rich_media/parsers/twitter_card.ex | 15 +- .../rich_media/parsers/twitter_card_test.exs | 130 ++++++++++-------- 9 files changed, 90 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf2210f5..575eb67b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] ### Changed +- OGP rich media parser merged with TwitterCard
API Changes - **Breaking:** Emoji API: changed methods and renamed routes. diff --git a/config/config.exs b/config/config.exs index 9508ae077..cafa40820 100644 --- a/config/config.exs +++ b/config/config.exs @@ -385,7 +385,6 @@ ignore_tld: ["local", "localdomain", "lan"], parsers: [ Pleroma.Web.RichMedia.Parsers.TwitterCard, - Pleroma.Web.RichMedia.Parsers.OGP, Pleroma.Web.RichMedia.Parsers.OEmbed ], ttl_setters: [Pleroma.Web.RichMedia.Parser.TTL.AwsSignedUrl] diff --git a/config/description.exs b/config/description.exs index 807c945e0..b993959d7 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2091,9 +2091,7 @@ description: "List of Rich Media parsers. Module names are shortened (removed leading `Pleroma.Web.RichMedia.Parsers.` part), but on adding custom module you need to use full name.", suggestions: [ - Pleroma.Web.RichMedia.Parsers.MetaTagsParser, Pleroma.Web.RichMedia.Parsers.OEmbed, - Pleroma.Web.RichMedia.Parsers.OGP, Pleroma.Web.RichMedia.Parsers.TwitterCard ] }, diff --git a/lib/pleroma/web/rich_media/parser.ex b/lib/pleroma/web/rich_media/parser.ex index 40980def8..78e9048f3 100644 --- a/lib/pleroma/web/rich_media/parser.ex +++ b/lib/pleroma/web/rich_media/parser.ex @@ -105,8 +105,8 @@ defp parse_html(html), do: Floki.parse_document!(html) defp maybe_parse(html) do Enum.reduce_while(parsers(), %{}, fn parser, acc -> case parser.parse(html, acc) do - {:ok, data} -> {:halt, data} - {:error, _msg} -> {:cont, acc} + data when data != %{} -> {:halt, data} + _ -> {:cont, acc} end end) end diff --git a/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex b/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex index ae0f36702..c09b96eae 100644 --- a/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex +++ b/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex @@ -3,22 +3,15 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.RichMedia.Parsers.MetaTagsParser do - def parse(html, data, prefix, error_message, key_name, value_name \\ "content") do - meta_data = - html - |> get_elements(key_name, prefix) - |> Enum.reduce(data, fn el, acc -> - attributes = normalize_attributes(el, prefix, key_name, value_name) + def parse(data, html, prefix, key_name, value_name \\ "content") do + html + |> get_elements(key_name, prefix) + |> Enum.reduce(data, fn el, acc -> + attributes = normalize_attributes(el, prefix, key_name, value_name) - Map.merge(acc, attributes) - end) - |> maybe_put_title(html) - - if Enum.empty?(meta_data) do - {:error, error_message} - else - {:ok, meta_data} - end + Map.merge(acc, attributes) + end) + |> maybe_put_title(html) end defp get_elements(html, key_name, prefix) do diff --git a/lib/pleroma/web/rich_media/parsers/oembed_parser.ex b/lib/pleroma/web/rich_media/parsers/oembed_parser.ex index 8f32bf91b..5d87a90e9 100644 --- a/lib/pleroma/web/rich_media/parsers/oembed_parser.ex +++ b/lib/pleroma/web/rich_media/parsers/oembed_parser.ex @@ -7,9 +7,9 @@ def parse(html, _data) do with elements = [_ | _] <- get_discovery_data(html), {:ok, oembed_url} <- get_oembed_url(elements), {:ok, oembed_data} <- get_oembed_data(oembed_url) do - {:ok, oembed_data} + oembed_data else - _e -> {:error, "No OEmbed data found"} + _e -> %{} end end diff --git a/lib/pleroma/web/rich_media/parsers/ogp.ex b/lib/pleroma/web/rich_media/parsers/ogp.ex index 3e9012588..5eebe42f7 100644 --- a/lib/pleroma/web/rich_media/parsers/ogp.ex +++ b/lib/pleroma/web/rich_media/parsers/ogp.ex @@ -5,10 +5,9 @@ defmodule Pleroma.Web.RichMedia.Parsers.OGP do def parse(html, data) do Pleroma.Web.RichMedia.Parsers.MetaTagsParser.parse( - html, data, + html, "og", - "No OGP metadata found", "property" ) end diff --git a/lib/pleroma/web/rich_media/parsers/twitter_card.ex b/lib/pleroma/web/rich_media/parsers/twitter_card.ex index 09d4b526e..4a04865d2 100644 --- a/lib/pleroma/web/rich_media/parsers/twitter_card.ex +++ b/lib/pleroma/web/rich_media/parsers/twitter_card.ex @@ -5,18 +5,11 @@ defmodule Pleroma.Web.RichMedia.Parsers.TwitterCard do alias Pleroma.Web.RichMedia.Parsers.MetaTagsParser - @spec parse(String.t(), map()) :: {:ok, map()} | {:error, String.t()} + @spec parse(list(), map()) :: map() def parse(html, data) do data - |> parse_name_attrs(html) - |> parse_property_attrs(html) - end - - defp parse_name_attrs(data, html) do - MetaTagsParser.parse(html, data, "twitter", %{}, "name") - end - - defp parse_property_attrs({_, data}, html) do - MetaTagsParser.parse(html, data, "twitter", "No twitter card metadata found", "property") + |> MetaTagsParser.parse(html, "og", "property") + |> MetaTagsParser.parse(html, "twitter", "name") + |> MetaTagsParser.parse(html, "twitter", "property") end end diff --git a/test/web/rich_media/parsers/twitter_card_test.exs b/test/web/rich_media/parsers/twitter_card_test.exs index 87c767c15..3ccf26651 100644 --- a/test/web/rich_media/parsers/twitter_card_test.exs +++ b/test/web/rich_media/parsers/twitter_card_test.exs @@ -7,8 +7,7 @@ defmodule Pleroma.Web.RichMedia.Parsers.TwitterCardTest do alias Pleroma.Web.RichMedia.Parsers.TwitterCard test "returns error when html not contains twitter card" do - assert TwitterCard.parse([{"html", [], [{"head", [], []}, {"body", [], []}]}], %{}) == - {:error, "No twitter card metadata found"} + assert TwitterCard.parse([{"html", [], [{"head", [], []}, {"body", [], []}]}], %{}) == %{} end test "parses twitter card with only name attributes" do @@ -17,15 +16,21 @@ test "parses twitter card with only name attributes" do |> Floki.parse_document!() assert TwitterCard.parse(html, %{}) == - {:ok, - %{ - "app:id:googleplay": "com.nytimes.android", - "app:name:googleplay": "NYTimes", - "app:url:googleplay": "nytimes://reader/id/100000006583622", - site: nil, - title: - "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database. - The New York Times" - }} + %{ + "app:id:googleplay": "com.nytimes.android", + "app:name:googleplay": "NYTimes", + "app:url:googleplay": "nytimes://reader/id/100000006583622", + site: nil, + description: + "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", + image: + "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-facebookJumbo.jpg", + type: "article", + url: + "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html", + title: + "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database." + } end test "parses twitter card with only property attributes" do @@ -34,19 +39,19 @@ test "parses twitter card with only property attributes" do |> Floki.parse_document!() assert TwitterCard.parse(html, %{}) == - {:ok, - %{ - card: "summary_large_image", - description: - "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", - image: - "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-videoSixteenByNineJumbo1600.jpg", - "image:alt": "", - title: - "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.", - url: - "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html" - }} + %{ + card: "summary_large_image", + description: + "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", + image: + "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-videoSixteenByNineJumbo1600.jpg", + "image:alt": "", + title: + "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.", + url: + "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html", + type: "article" + } end test "parses twitter card with name & property attributes" do @@ -55,23 +60,23 @@ test "parses twitter card with name & property attributes" do |> Floki.parse_document!() assert TwitterCard.parse(html, %{}) == - {:ok, - %{ - "app:id:googleplay": "com.nytimes.android", - "app:name:googleplay": "NYTimes", - "app:url:googleplay": "nytimes://reader/id/100000006583622", - card: "summary_large_image", - description: - "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", - image: - "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-videoSixteenByNineJumbo1600.jpg", - "image:alt": "", - site: nil, - title: - "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.", - url: - "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html" - }} + %{ + "app:id:googleplay": "com.nytimes.android", + "app:name:googleplay": "NYTimes", + "app:url:googleplay": "nytimes://reader/id/100000006583622", + card: "summary_large_image", + description: + "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", + image: + "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-videoSixteenByNineJumbo1600.jpg", + "image:alt": "", + site: nil, + title: + "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.", + url: + "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html", + type: "article" + } end test "respect only first title tag on the page" do @@ -84,14 +89,17 @@ test "respect only first title tag on the page" do File.read!("test/fixtures/margaret-corbin-grave-west-point.html") |> Floki.parse_document!() assert TwitterCard.parse(html, %{}) == - {:ok, - %{ - site: "@atlasobscura", - title: - "The Missing Grave of Margaret Corbin, Revolutionary War Veteran - Atlas Obscura", - card: "summary_large_image", - image: image_path - }} + %{ + site: "@atlasobscura", + title: "The Missing Grave of Margaret Corbin, Revolutionary War Veteran", + card: "summary_large_image", + image: image_path, + description: + "She's the only woman veteran honored with a monument at West Point. But where was she buried?", + site_name: "Atlas Obscura", + type: "article", + url: "http://www.atlasobscura.com/articles/margaret-corbin-grave-west-point" + } end test "takes first founded title in html head if there is html markup error" do @@ -100,14 +108,20 @@ test "takes first founded title in html head if there is html markup error" do |> Floki.parse_document!() assert TwitterCard.parse(html, %{}) == - {:ok, - %{ - site: nil, - title: - "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database. - The New York Times", - "app:id:googleplay": "com.nytimes.android", - "app:name:googleplay": "NYTimes", - "app:url:googleplay": "nytimes://reader/id/100000006583622" - }} + %{ + site: nil, + title: + "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.", + "app:id:googleplay": "com.nytimes.android", + "app:name:googleplay": "NYTimes", + "app:url:googleplay": "nytimes://reader/id/100000006583622", + description: + "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", + image: + "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-facebookJumbo.jpg", + type: "article", + url: + "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html" + } end end From 7f7a1a467677471e0e1ec688e4eca9ba759d976a Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 11 Jun 2020 11:05:22 -0500 Subject: [PATCH 253/401] Check for media proxy base_url, not Upload base_url --- lib/pleroma/plugs/http_security_plug.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index 620408d0f..1420a9611 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -114,7 +114,7 @@ defp get_proxy_and_attachment_sources do end) media_proxy_base_url = - if Config.get([Pleroma.Upload, :base_url]), + if Config.get([:media_proxy, :base_url]), do: URI.parse(Config.get([:media_proxy, :base_url])).host upload_base_url = From 2419776e192316cefbdbe607306c9b92ec558319 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 11 Jun 2020 23:11:46 +0400 Subject: [PATCH 254/401] Deprecate Pleroma.Web.RichMedia.Parsers.OGP --- lib/pleroma/web/rich_media/parsers/ogp.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/web/rich_media/parsers/ogp.ex b/lib/pleroma/web/rich_media/parsers/ogp.ex index 5eebe42f7..363815f81 100644 --- a/lib/pleroma/web/rich_media/parsers/ogp.ex +++ b/lib/pleroma/web/rich_media/parsers/ogp.ex @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.RichMedia.Parsers.OGP do + @deprecated "OGP parser is deprecated. Use TwitterCard instead." def parse(html, data) do Pleroma.Web.RichMedia.Parsers.MetaTagsParser.parse( data, From 40970f6bb94760d19cc1d3201405df5bb32f5083 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 11 Jun 2020 22:54:39 +0200 Subject: [PATCH 255/401] New mix task: pleroma.user reset_mfa --- docs/administration/CLI_tasks/user.md | 10 +++++++++ lib/mix/tasks/pleroma/user.ex | 12 +++++++++++ test/tasks/user_test.exs | 30 +++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/docs/administration/CLI_tasks/user.md b/docs/administration/CLI_tasks/user.md index afeb8d52f..1e6f4a8b4 100644 --- a/docs/administration/CLI_tasks/user.md +++ b/docs/administration/CLI_tasks/user.md @@ -135,6 +135,16 @@ mix pleroma.user reset_password ``` +## Disable Multi Factor Authentication (MFA/2FA) for a user +```sh tab="OTP" + ./bin/pleroma_ctl user reset_mfa +``` + +```sh tab="From Source" +mix pleroma.user reset_mfa +``` + + ## Set the value of the given user's settings ```sh tab="OTP" ./bin/pleroma_ctl user set [option ...] diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex index 3635c02bc..bca7e87bf 100644 --- a/lib/mix/tasks/pleroma/user.ex +++ b/lib/mix/tasks/pleroma/user.ex @@ -144,6 +144,18 @@ def run(["reset_password", nickname]) do end end + def run(["reset_mfa", nickname]) do + start_pleroma() + + with %User{local: true} = user <- User.get_cached_by_nickname(nickname), + {:ok, _token} <- Pleroma.MFA.disable(user) do + shell_info("Multi-Factor Authentication disabled for #{user.nickname}") + else + _ -> + shell_error("No local user #{nickname}") + end + end + def run(["deactivate", nickname]) do start_pleroma() diff --git a/test/tasks/user_test.exs b/test/tasks/user_test.exs index b55aa1cdb..9220d23fc 100644 --- a/test/tasks/user_test.exs +++ b/test/tasks/user_test.exs @@ -4,6 +4,7 @@ defmodule Mix.Tasks.Pleroma.UserTest do alias Pleroma.Activity + alias Pleroma.MFA alias Pleroma.Object alias Pleroma.Repo alias Pleroma.Tests.ObanHelpers @@ -278,6 +279,35 @@ test "no user to reset password" do end end + describe "running reset_mfa" do + test "disables MFA" do + user = + insert(:user, + multi_factor_authentication_settings: %MFA.Settings{ + enabled: true, + totp: %MFA.Settings.TOTP{secret: "xx", confirmed: true} + } + ) + + Mix.Tasks.Pleroma.User.run(["reset_mfa", user.nickname]) + + assert_received {:mix_shell, :info, [message]} + assert message == "Multi-Factor Authentication disabled for #{user.nickname}" + + assert %{enabled: false, totp: false} == + user.nickname + |> User.get_cached_by_nickname() + |> MFA.mfa_settings() + end + + test "no user to reset MFA" do + Mix.Tasks.Pleroma.User.run(["reset_password", "nonexistent"]) + + assert_received {:mix_shell, :error, [message]} + assert message =~ "No local user" + end + end + describe "running invite" do test "invite token is generated" do assert capture_io(fn -> From 122328b93a708e396b5c0cd1930a4b759e7b7db6 Mon Sep 17 00:00:00 2001 From: normandy Date: Fri, 12 Jun 2020 01:41:09 +0000 Subject: [PATCH 256/401] Update pleroma.nginx to support TLSv1.3 Based on SSL config from https://ssl-config.mozilla.org/ --- installation/pleroma.nginx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/installation/pleroma.nginx b/installation/pleroma.nginx index 688be3e71..d301ca615 100644 --- a/installation/pleroma.nginx +++ b/installation/pleroma.nginx @@ -37,18 +37,17 @@ server { listen 443 ssl http2; listen [::]:443 ssl http2; - ssl_session_timeout 5m; + ssl_session_timeout 1d; + ssl_session_cache shared:MozSSL:10m; # about 40000 sessions + ssl_session_tickets off; ssl_trusted_certificate /etc/letsencrypt/live/example.tld/chain.pem; ssl_certificate /etc/letsencrypt/live/example.tld/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.tld/privkey.pem; - # Add TLSv1.0 to support older devices - ssl_protocols TLSv1.2; - # Uncomment line below if you want to support older devices (Before Android 4.4.2, IE 8, etc.) - # ssl_ciphers "HIGH:!aNULL:!MD5 or HIGH:!aNULL:!MD5:!3DES"; + ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4"; - ssl_prefer_server_ciphers on; + ssl_prefer_server_ciphers off; # In case of an old server with an OpenSSL version of 1.0.2 or below, # leave only prime256v1 or comment out the following line. ssl_ecdh_curve X25519:prime256v1:secp384r1:secp521r1; From 21880970660906d8072dc501e6a8b25fb4a4b0c7 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 12 Jun 2020 14:25:41 +0300 Subject: [PATCH 257/401] [#1794] Fixes URI query handling for hashtags extraction in search. --- .../controllers/search_controller.ex | 1 + .../controllers/search_controller_test.exs | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index 46bcf4228..3be0ca095 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -152,6 +152,7 @@ defp prepare_tags(query, add_joined_tag \\ true) do defp preprocess_uri_query(query) do if query =~ ~r/https?:\/\// do query + |> String.trim_trailing("/") |> URI.parse() |> Map.get(:path) |> String.split("/") diff --git a/test/web/mastodon_api/controllers/search_controller_test.exs b/test/web/mastodon_api/controllers/search_controller_test.exs index 0e025adca..c605957b1 100644 --- a/test/web/mastodon_api/controllers/search_controller_test.exs +++ b/test/web/mastodon_api/controllers/search_controller_test.exs @@ -120,6 +120,35 @@ test "constructs hashtags from search query", %{conn: conn} do assert results["hashtags"] == [ %{"name" => "shpuld", "url" => "#{Web.base_url()}/tag/shpuld"} ] + + results = + conn + |> get( + "/api/v2/search?#{ + URI.encode_query(%{ + q: + "https://www.washingtonpost.com/sports/2020/06/10/" <> + "nascar-ban-display-confederate-flag-all-events-properties/" + }) + }" + ) + |> json_response_and_validate_schema(200) + + assert results["hashtags"] == [ + %{"name" => "nascar", "url" => "#{Web.base_url()}/tag/nascar"}, + %{"name" => "ban", "url" => "#{Web.base_url()}/tag/ban"}, + %{"name" => "display", "url" => "#{Web.base_url()}/tag/display"}, + %{"name" => "confederate", "url" => "#{Web.base_url()}/tag/confederate"}, + %{"name" => "flag", "url" => "#{Web.base_url()}/tag/flag"}, + %{"name" => "all", "url" => "#{Web.base_url()}/tag/all"}, + %{"name" => "events", "url" => "#{Web.base_url()}/tag/events"}, + %{"name" => "properties", "url" => "#{Web.base_url()}/tag/properties"}, + %{ + "name" => "NascarBanDisplayConfederateFlagAllEventsProperties", + "url" => + "#{Web.base_url()}/tag/NascarBanDisplayConfederateFlagAllEventsProperties" + } + ] end test "excludes a blocked users from search results", %{conn: conn} do From f9dcf15ecb684b4b802d731a216448c76913d462 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Fri, 12 Jun 2020 14:49:54 +0300 Subject: [PATCH 258/401] added admin api for MediaProxy cache invalidation --- .../media_proxy_cache_controller.ex | 38 ++++++ .../admin_api/views/media_proxy_cache_view.ex | 11 ++ .../admin/media_proxy_cache_operation.ex | 109 ++++++++++++++++++ lib/pleroma/web/router.ex | 4 + .../media_proxy_cache_controller_test.exs | 66 +++++++++++ 5 files changed, 228 insertions(+) create mode 100644 lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex create mode 100644 lib/pleroma/web/admin_api/views/media_proxy_cache_view.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/media_proxy_cache_operation.ex create mode 100644 test/web/admin_api/controllers/media_proxy_cache_controller_test.exs diff --git a/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex new file mode 100644 index 000000000..7b28f7c72 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex @@ -0,0 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.MediaProxyCacheController do + use Pleroma.Web, :controller + + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Web.ApiSpec.Admin, as: Spec + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + OAuthScopesPlug, + %{scopes: ["read:media_proxy_caches"], admin: true} when action in [:index] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:media_proxy_caches"], admin: true} when action in [:purge, :delete] + ) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Spec.MediaProxyCacheOperation + + def index(%{assigns: %{user: _}} = conn, _) do + render(conn, "index.json", urls: []) + end + + def delete(%{assigns: %{user: _}, body_params: %{urls: urls}} = conn, _) do + render(conn, "index.json", urls: urls) + end + + def purge(%{assigns: %{user: _}, body_params: %{urls: urls, ban: _ban}} = conn, _) do + render(conn, "index.json", urls: urls) + end +end diff --git a/lib/pleroma/web/admin_api/views/media_proxy_cache_view.ex b/lib/pleroma/web/admin_api/views/media_proxy_cache_view.ex new file mode 100644 index 000000000..c97400beb --- /dev/null +++ b/lib/pleroma/web/admin_api/views/media_proxy_cache_view.ex @@ -0,0 +1,11 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.MediaProxyCacheView do + use Pleroma.Web, :view + + def render("index.json", %{urls: urls}) do + %{urls: urls} + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/media_proxy_cache_operation.ex b/lib/pleroma/web/api_spec/operations/admin/media_proxy_cache_operation.ex new file mode 100644 index 000000000..0358cfbad --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/media_proxy_cache_operation.ex @@ -0,0 +1,109 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.MediaProxyCacheOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Admin", "MediaProxyCache"], + summary: "Fetch a paginated list of all banned MediaProxy URLs in Cachex", + operationId: "AdminAPI.MediaProxyCacheController.index", + security: [%{"oAuth" => ["read:media_proxy_caches"]}], + parameters: [ + Operation.parameter( + :page, + :query, + %Schema{type: :integer, default: 1}, + "Page" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 50}, + "Number of statuses to return" + ) + ], + responses: %{ + 200 => success_response() + } + } + end + + def delete_operation do + %Operation{ + tags: ["Admin", "MediaProxyCache"], + summary: "Remove a banned MediaProxy URL from Cachex", + operationId: "AdminAPI.MediaProxyCacheController.delete", + security: [%{"oAuth" => ["write:media_proxy_caches"]}], + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + required: [:urls], + properties: %{ + urls: %Schema{type: :array, items: %Schema{type: :string, format: :uri}} + } + }, + required: true + ), + responses: %{ + 200 => success_response(), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def purge_operation do + %Operation{ + tags: ["Admin", "MediaProxyCache"], + summary: "Purge and optionally ban a MediaProxy URL", + operationId: "AdminAPI.MediaProxyCacheController.purge", + security: [%{"oAuth" => ["write:media_proxy_caches"]}], + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + required: [:urls], + properties: %{ + urls: %Schema{type: :array, items: %Schema{type: :string, format: :uri}}, + ban: %Schema{type: :boolean, default: true} + } + }, + required: true + ), + responses: %{ + 200 => success_response(), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp success_response do + Operation.response("Array of banned MediaProxy URLs in Cachex", "application/json", %Schema{ + type: :object, + properties: %{ + urls: %Schema{ + type: :array, + items: %Schema{ + type: :string, + format: :uri, + description: "MediaProxy URLs" + } + } + } + }) + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 57570b672..eda74a171 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -209,6 +209,10 @@ defmodule Pleroma.Web.Router do post("/oauth_app", OAuthAppController, :create) patch("/oauth_app/:id", OAuthAppController, :update) delete("/oauth_app/:id", OAuthAppController, :delete) + + get("/media_proxy_caches", MediaProxyCacheController, :index) + post("/media_proxy_caches/delete", MediaProxyCacheController, :delete) + post("/media_proxy_caches/purge", MediaProxyCacheController, :purge) end scope "/api/pleroma/emoji", Pleroma.Web.PleromaAPI do diff --git a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs new file mode 100644 index 000000000..1b1d6bc36 --- /dev/null +++ b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs @@ -0,0 +1,66 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.MediaProxyCacheControllerTest do + use Pleroma.Web.ConnCase + + import Pleroma.Factory + + setup do + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + + {:ok, %{admin: admin, token: token, conn: conn}} + end + + describe "GET /api/pleroma/admin/media_proxy_caches" do + test "shows banned MediaProxy URLs", %{conn: conn} do + response = + conn + |> get("/api/pleroma/admin/media_proxy_caches") + |> json_response_and_validate_schema(200) + + assert response["urls"] == [] + end + end + + describe "DELETE /api/pleroma/admin/media_proxy_caches/delete" do + test "deleted MediaProxy URLs from banned", %{conn: conn} do + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/media_proxy_caches/delete", %{ + urls: ["http://example.com/media/a688346.jpg", "http://example.com/media/fb1f4d.jpg"] + }) + |> json_response_and_validate_schema(200) + + assert response["urls"] == [ + "http://example.com/media/a688346.jpg", + "http://example.com/media/fb1f4d.jpg" + ] + end + end + + describe "PURGE /api/pleroma/admin/media_proxy_caches/purge" do + test "perform invalidates cache of MediaProxy", %{conn: conn} do + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/media_proxy_caches/purge", %{ + urls: ["http://example.com/media/a688346.jpg", "http://example.com/media/fb1f4d.jpg"] + }) + |> json_response_and_validate_schema(200) + + assert response["urls"] == [ + "http://example.com/media/a688346.jpg", + "http://example.com/media/fb1f4d.jpg" + ] + end + end +end From c2048f75cd09696e30b443423cae4ba6ef3e593b Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 12 Jun 2020 08:42:23 -0500 Subject: [PATCH 259/401] Add changelog entry for emoji pack reload command --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf2210f5..b19cae8b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Mastodon API: Add support for filtering replies in public and home timelines - Admin API: endpoints for create/update/delete OAuth Apps. - Admin API: endpoint for status view. +- OTP: Add command to reload emoji packs
### Fixed From e505e59d9c43db286ccf7fe70da2fa974ae3d700 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 12 Jun 2020 08:51:11 -0500 Subject: [PATCH 260/401] Document new mix task feature to reset mfa --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf2210f5..c23beec9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Configuration: `filename_display_max_length` option to set filename truncate limit, if filename display enabled (0 = no limit). - New HTTP adapter [gun](https://github.com/ninenines/gun). Gun adapter requires minimum OTP version of 22.2 otherwise Pleroma won’t start. For hackney OTP update is not required. - Mix task to create trusted OAuth App. +- Mix task to reset MFA for user accounts - Notifications: Added `follow_request` notification type. - Added `:reject_deletes` group to SimplePolicy - MRF (`EmojiStealPolicy`): New MRF Policy which allows to automatically download emojis from remote instances From 4655407451c8dd05b6024f607e598359047efce2 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 12 Jun 2020 14:03:33 +0000 Subject: [PATCH 261/401] Apply suggestion to config/description.exs --- config/description.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index 086a28ace..add1601e2 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1476,7 +1476,7 @@ key: :mrf_activity_expiration, label: "MRF Activity Expiration Policy", type: :group, - description: "Adds expiration to all local Create activities", + description: "Adds expiration to all local Create Note activities", children: [ %{ key: :days, From 09d31d24de568aac06fe203beeb8bb2a9de8f602 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 12 Jun 2020 18:37:32 +0400 Subject: [PATCH 262/401] Return an empty map from Pleroma.Web.RichMedia.Parsers.OGP.parse/2 --- lib/pleroma/web/rich_media/parsers/ogp.ex | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/web/rich_media/parsers/ogp.ex b/lib/pleroma/web/rich_media/parsers/ogp.ex index 363815f81..b3b3b059c 100644 --- a/lib/pleroma/web/rich_media/parsers/ogp.ex +++ b/lib/pleroma/web/rich_media/parsers/ogp.ex @@ -4,12 +4,7 @@ defmodule Pleroma.Web.RichMedia.Parsers.OGP do @deprecated "OGP parser is deprecated. Use TwitterCard instead." - def parse(html, data) do - Pleroma.Web.RichMedia.Parsers.MetaTagsParser.parse( - data, - html, - "og", - "property" - ) + def parse(_html, _data) do + %{} end end From 520367d6fd8a268e0bc8c145a46aca46a62e8b66 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 9 Jun 2020 21:49:24 +0400 Subject: [PATCH 263/401] Fix atom leak in Rich Media Parser --- .../web/mastodon_api/views/status_view.ex | 14 ++-- lib/pleroma/web/rich_media/helpers.ex | 6 +- lib/pleroma/web/rich_media/parser.ex | 12 +-- .../rich_media/parsers/meta_tags_parser.ex | 8 +- .../web/rich_media/parsers/oembed_parser.ex | 18 ++--- test/web/rich_media/parser_test.exs | 75 ++++++++++--------- .../rich_media/parsers/twitter_card_test.exs | 60 +++++++-------- 7 files changed, 91 insertions(+), 102 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 8e3715093..2c49bedb3 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -377,8 +377,8 @@ def render("card.json", %{rich_media: rich_media, page_url: page_url}) do page_url_data = URI.parse(page_url) page_url_data = - if rich_media[:url] != nil do - URI.merge(page_url_data, URI.parse(rich_media[:url])) + if is_binary(rich_media["url"]) do + URI.merge(page_url_data, URI.parse(rich_media["url"])) else page_url_data end @@ -386,11 +386,9 @@ def render("card.json", %{rich_media: rich_media, page_url: page_url}) do page_url = page_url_data |> to_string image_url = - if rich_media[:image] != nil do - URI.merge(page_url_data, URI.parse(rich_media[:image])) + if is_binary(rich_media["image"]) do + URI.merge(page_url_data, URI.parse(rich_media["image"])) |> to_string - else - nil end %{ @@ -399,8 +397,8 @@ def render("card.json", %{rich_media: rich_media, page_url: page_url}) do provider_url: page_url_data.scheme <> "://" <> page_url_data.host, url: page_url, image: image_url |> MediaProxy.url(), - title: rich_media[:title] || "", - description: rich_media[:description] || "", + title: rich_media["title"] || "", + description: rich_media["description"] || "", pleroma: %{ opengraph: rich_media } diff --git a/lib/pleroma/web/rich_media/helpers.ex b/lib/pleroma/web/rich_media/helpers.ex index 9d3d7f978..1729141e9 100644 --- a/lib/pleroma/web/rich_media/helpers.ex +++ b/lib/pleroma/web/rich_media/helpers.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.RichMedia.Helpers do alias Pleroma.Object alias Pleroma.Web.RichMedia.Parser - @spec validate_page_url(any()) :: :ok | :error + @spec validate_page_url(URI.t() | binary()) :: :ok | :error defp validate_page_url(page_url) when is_binary(page_url) do validate_tld = Application.get_env(:auto_linker, :opts)[:validate_tld] @@ -18,8 +18,8 @@ defp validate_page_url(page_url) when is_binary(page_url) do |> parse_uri(page_url) end - defp validate_page_url(%URI{host: host, scheme: scheme, authority: authority}) - when scheme == "https" and not is_nil(authority) do + defp validate_page_url(%URI{host: host, scheme: "https", authority: authority}) + when is_binary(authority) do cond do host in Config.get([:rich_media, :ignore_hosts], []) -> :error diff --git a/lib/pleroma/web/rich_media/parser.ex b/lib/pleroma/web/rich_media/parser.ex index 40980def8..d9b5068b1 100644 --- a/lib/pleroma/web/rich_media/parser.ex +++ b/lib/pleroma/web/rich_media/parser.ex @@ -91,7 +91,7 @@ defp parse_url(url) do html |> parse_html() |> maybe_parse() - |> Map.put(:url, url) + |> Map.put("url", url) |> clean_parsed_data() |> check_parsed_data() rescue @@ -111,8 +111,8 @@ defp maybe_parse(html) 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 title != "" do {:ok, data} end @@ -123,11 +123,7 @@ defp check_parsed_data(data) do defp clean_parsed_data(data) do data |> Enum.reject(fn {key, val} -> - with {:ok, _} <- Jason.encode(%{key => val}) do - false - else - _ -> true - end + not match?({:ok, _}, Jason.encode(%{key => val})) end) |> Map.new() end diff --git a/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex b/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex index ae0f36702..2762b5902 100644 --- a/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex +++ b/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex @@ -29,19 +29,19 @@ defp normalize_attributes(html_node, prefix, key_name, value_name) do {_tag, attributes, _children} = html_node data = - Enum.into(attributes, %{}, fn {name, value} -> + Map.new(attributes, fn {name, value} -> {name, String.trim_leading(value, "#{prefix}:")} end) - %{String.to_atom(data[key_name]) => data[value_name]} + %{data[key_name] => data[value_name]} end - defp maybe_put_title(%{title: _} = meta, _), do: meta + defp maybe_put_title(%{"title" => _} = meta, _), do: meta defp maybe_put_title(meta, html) when meta != %{} do case get_page_title(html) do "" -> meta - title -> Map.put_new(meta, :title, title) + title -> Map.put_new(meta, "title", title) end end diff --git a/lib/pleroma/web/rich_media/parsers/oembed_parser.ex b/lib/pleroma/web/rich_media/parsers/oembed_parser.ex index 8f32bf91b..db8ccf15d 100644 --- a/lib/pleroma/web/rich_media/parsers/oembed_parser.ex +++ b/lib/pleroma/web/rich_media/parsers/oembed_parser.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.RichMedia.Parsers.OEmbed do def parse(html, _data) do with elements = [_ | _] <- get_discovery_data(html), - {:ok, oembed_url} <- get_oembed_url(elements), + oembed_url when is_binary(oembed_url) <- get_oembed_url(elements), {:ok, oembed_data} <- get_oembed_data(oembed_url) do {:ok, oembed_data} else @@ -17,19 +17,13 @@ defp get_discovery_data(html) do html |> Floki.find("link[type='application/json+oembed']") end - defp get_oembed_url(nodes) do - {"link", attributes, _children} = nodes |> hd() - - {:ok, Enum.into(attributes, %{})["href"]} + defp get_oembed_url([{"link", attributes, _children} | _]) do + Enum.find_value(attributes, fn {k, v} -> if k == "href", do: v end) end defp get_oembed_data(url) do - {:ok, %Tesla.Env{body: json}} = Pleroma.HTTP.get(url, [], adapter: [pool: :media]) - - {:ok, data} = Jason.decode(json) - - data = data |> Map.new(fn {k, v} -> {String.to_atom(k), v} end) - - {:ok, data} + with {:ok, %Tesla.Env{body: json}} <- Pleroma.HTTP.get(url, [], adapter: [pool: :media]) do + Jason.decode(json) + end end end diff --git a/test/web/rich_media/parser_test.exs b/test/web/rich_media/parser_test.exs index e54a13bc8..420a612c6 100644 --- a/test/web/rich_media/parser_test.exs +++ b/test/web/rich_media/parser_test.exs @@ -60,19 +60,19 @@ test "returns error when no metadata present" do test "doesn't just add a title" do assert Pleroma.Web.RichMedia.Parser.parse("http://example.com/non-ogp") == {:error, - "Found metadata was invalid or incomplete: %{url: \"http://example.com/non-ogp\"}"} + "Found metadata was invalid or incomplete: %{\"url\" => \"http://example.com/non-ogp\"}"} end test "parses ogp" do assert Pleroma.Web.RichMedia.Parser.parse("http://example.com/ogp") == {:ok, %{ - image: "http://ia.media-imdb.com/images/rock.jpg", - title: "The Rock", - description: + "image" => "http://ia.media-imdb.com/images/rock.jpg", + "title" => "The Rock", + "description" => "Directed by Michael Bay. With Sean Connery, Nicolas Cage, Ed Harris, John Spencer.", - type: "video.movie", - url: "http://example.com/ogp" + "type" => "video.movie", + "url" => "http://example.com/ogp" }} end @@ -80,12 +80,12 @@ test "falls back to when ogp:title is missing" do assert Pleroma.Web.RichMedia.Parser.parse("http://example.com/ogp-missing-title") == {:ok, %{ - image: "http://ia.media-imdb.com/images/rock.jpg", - title: "The Rock (1996)", - description: + "image" => "http://ia.media-imdb.com/images/rock.jpg", + "title" => "The Rock (1996)", + "description" => "Directed by Michael Bay. With Sean Connery, Nicolas Cage, Ed Harris, John Spencer.", - type: "video.movie", - url: "http://example.com/ogp-missing-title" + "type" => "video.movie", + "url" => "http://example.com/ogp-missing-title" }} end @@ -93,12 +93,12 @@ test "parses twitter card" do assert Pleroma.Web.RichMedia.Parser.parse("http://example.com/twitter-card") == {:ok, %{ - card: "summary", - site: "@flickr", - image: "https://farm6.staticflickr.com/5510/14338202952_93595258ff_z.jpg", - title: "Small Island Developing States Photo Submission", - description: "View the album on Flickr.", - url: "http://example.com/twitter-card" + "card" => "summary", + "site" => "@flickr", + "image" => "https://farm6.staticflickr.com/5510/14338202952_93595258ff_z.jpg", + "title" => "Small Island Developing States Photo Submission", + "description" => "View the album on Flickr.", + "url" => "http://example.com/twitter-card" }} end @@ -106,27 +106,28 @@ test "parses OEmbed" do assert Pleroma.Web.RichMedia.Parser.parse("http://example.com/oembed") == {:ok, %{ - author_name: "‮‭‬bees‬", - author_url: "https://www.flickr.com/photos/bees/", - cache_age: 3600, - flickr_type: "photo", - height: "768", - html: + "author_name" => "‮‭‬bees‬", + "author_url" => "https://www.flickr.com/photos/bees/", + "cache_age" => 3600, + "flickr_type" => "photo", + "height" => "768", + "html" => "<a data-flickr-embed=\"true\" href=\"https://www.flickr.com/photos/bees/2362225867/\" title=\"Bacon Lollys by ‮‭‬bees‬, on Flickr\"><img src=\"https://farm4.staticflickr.com/3040/2362225867_4a87ab8baf_b.jpg\" width=\"1024\" height=\"768\" alt=\"Bacon Lollys\"></a><script async src=\"https://embedr.flickr.com/assets/client-code.js\" charset=\"utf-8\"></script>", - license: "All Rights Reserved", - license_id: 0, - provider_name: "Flickr", - provider_url: "https://www.flickr.com/", - thumbnail_height: 150, - thumbnail_url: "https://farm4.staticflickr.com/3040/2362225867_4a87ab8baf_q.jpg", - thumbnail_width: 150, - title: "Bacon Lollys", - type: "photo", - url: "http://example.com/oembed", - version: "1.0", - web_page: "https://www.flickr.com/photos/bees/2362225867/", - web_page_short_url: "https://flic.kr/p/4AK2sc", - width: "1024" + "license" => "All Rights Reserved", + "license_id" => 0, + "provider_name" => "Flickr", + "provider_url" => "https://www.flickr.com/", + "thumbnail_height" => 150, + "thumbnail_url" => + "https://farm4.staticflickr.com/3040/2362225867_4a87ab8baf_q.jpg", + "thumbnail_width" => 150, + "title" => "Bacon Lollys", + "type" => "photo", + "url" => "http://example.com/oembed", + "version" => "1.0", + "web_page" => "https://www.flickr.com/photos/bees/2362225867/", + "web_page_short_url" => "https://flic.kr/p/4AK2sc", + "width" => "1024" }} end diff --git a/test/web/rich_media/parsers/twitter_card_test.exs b/test/web/rich_media/parsers/twitter_card_test.exs index 87c767c15..847623535 100644 --- a/test/web/rich_media/parsers/twitter_card_test.exs +++ b/test/web/rich_media/parsers/twitter_card_test.exs @@ -19,11 +19,11 @@ test "parses twitter card with only name attributes" do assert TwitterCard.parse(html, %{}) == {:ok, %{ - "app:id:googleplay": "com.nytimes.android", - "app:name:googleplay": "NYTimes", - "app:url:googleplay": "nytimes://reader/id/100000006583622", - site: nil, - title: + "app:id:googleplay" => "com.nytimes.android", + "app:name:googleplay" => "NYTimes", + "app:url:googleplay" => "nytimes://reader/id/100000006583622", + "site" => nil, + "title" => "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database. - The New York Times" }} end @@ -36,15 +36,15 @@ test "parses twitter card with only property attributes" do assert TwitterCard.parse(html, %{}) == {:ok, %{ - card: "summary_large_image", - description: + "card" => "summary_large_image", + "description" => "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", - image: + "image" => "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-videoSixteenByNineJumbo1600.jpg", - "image:alt": "", - title: + "image:alt" => "", + "title" => "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.", - url: + "url" => "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html" }} end @@ -57,19 +57,19 @@ test "parses twitter card with name & property attributes" do assert TwitterCard.parse(html, %{}) == {:ok, %{ - "app:id:googleplay": "com.nytimes.android", - "app:name:googleplay": "NYTimes", - "app:url:googleplay": "nytimes://reader/id/100000006583622", - card: "summary_large_image", - description: + "app:id:googleplay" => "com.nytimes.android", + "app:name:googleplay" => "NYTimes", + "app:url:googleplay" => "nytimes://reader/id/100000006583622", + "card" => "summary_large_image", + "description" => "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", - image: + "image" => "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-videoSixteenByNineJumbo1600.jpg", - "image:alt": "", - site: nil, - title: + "image:alt" => "", + "site" => nil, + "title" => "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.", - url: + "url" => "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html" }} end @@ -86,11 +86,11 @@ test "respect only first title tag on the page" do assert TwitterCard.parse(html, %{}) == {:ok, %{ - site: "@atlasobscura", - title: + "site" => "@atlasobscura", + "title" => "The Missing Grave of Margaret Corbin, Revolutionary War Veteran - Atlas Obscura", - card: "summary_large_image", - image: image_path + "card" => "summary_large_image", + "image" => image_path }} end @@ -102,12 +102,12 @@ test "takes first founded title in html head if there is html markup error" do assert TwitterCard.parse(html, %{}) == {:ok, %{ - site: nil, - title: + "site" => nil, + "title" => "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database. - The New York Times", - "app:id:googleplay": "com.nytimes.android", - "app:name:googleplay": "NYTimes", - "app:url:googleplay": "nytimes://reader/id/100000006583622" + "app:id:googleplay" => "com.nytimes.android", + "app:name:googleplay" => "NYTimes", + "app:url:googleplay" => "nytimes://reader/id/100000006583622" }} end end From cb7be6eef252216d7ba5d5f72c8005d66b04986c Mon Sep 17 00:00:00 2001 From: href <href@random.sh> Date: Wed, 10 Jun 2020 17:34:23 +0200 Subject: [PATCH 264/401] Remove use of atoms in MRF.UserAllowListPolicy --- config/description.exs | 6 ++--- docs/configuration/cheatsheet.md | 5 ++-- lib/pleroma/config/deprecation_warnings.ex | 25 ++++++++++++++++++- .../mrf/user_allow_list_policy.ex | 2 +- .../mrf/user_allowlist_policy_test.exs | 6 ++--- 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/config/description.exs b/config/description.exs index add1601e2..2f1eaf5f2 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1623,14 +1623,12 @@ # %{ # group: :pleroma, # key: :mrf_user_allowlist, - # type: :group, + # type: :map, # description: # "The keys in this section are the domain names that the policy should apply to." <> # " Each key should be assigned a list of users that should be allowed through by their ActivityPub ID", - # children: [ - # ["example.org": ["https://example.org/users/admin"]], # suggestions: [ - # ["example.org": ["https://example.org/users/admin"]] + # %{"example.org" => ["https://example.org/users/admin"]} # ] # ] # }, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 456762151..fad67fc4d 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -138,8 +138,9 @@ their ActivityPub ID. An example: ```elixir -config :pleroma, :mrf_user_allowlist, - "example.org": ["https://example.org/users/admin"] +config :pleroma, :mrf_user_allowlist, %{ + "example.org" => ["https://example.org/users/admin"] +} ``` #### :mrf_object_age diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index c39a8984b..b68ded01f 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -4,9 +4,10 @@ defmodule Pleroma.Config.DeprecationWarnings do require Logger + alias Pleroma.Config def check_hellthread_threshold do - if Pleroma.Config.get([:mrf_hellthread, :threshold]) do + if Config.get([:mrf_hellthread, :threshold]) do Logger.warn(""" !!!DEPRECATION WARNING!!! You are using the old configuration mechanism for the hellthread filter. Please check config.md. @@ -14,7 +15,29 @@ def check_hellthread_threshold do end end + def mrf_user_allowlist do + config = Config.get(:mrf_user_allowlist) + + if config && Enum.any?(config, fn {k, _} -> is_atom(k) end) do + rewritten = + Enum.reduce(Config.get(:mrf_user_allowlist), Map.new(), fn {k, v}, acc -> + Map.put(acc, to_string(k), v) + end) + + Config.put(:mrf_user_allowlist, rewritten) + + Logger.error(""" + !!!DEPRECATION WARNING!!! + As of Pleroma 2.0.7, the `mrf_user_allowlist` setting changed of format. + Pleroma 2.1 will remove support for the old format. Please change your configuration to match this: + + config :pleroma, :mrf_user_allowlist, #{inspect(rewritten, pretty: true)} + """) + end + end + def warn do check_hellthread_threshold() + mrf_user_allowlist() end end diff --git a/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex index a927a4ed8..651aed70f 100644 --- a/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex @@ -24,7 +24,7 @@ def filter(%{"actor" => actor} = object) do allow_list = Config.get( - [:mrf_user_allowlist, String.to_atom(actor_info.host)], + [:mrf_user_allowlist, actor_info.host], [] ) diff --git a/test/web/activity_pub/mrf/user_allowlist_policy_test.exs b/test/web/activity_pub/mrf/user_allowlist_policy_test.exs index 724bae058..ba1b69658 100644 --- a/test/web/activity_pub/mrf/user_allowlist_policy_test.exs +++ b/test/web/activity_pub/mrf/user_allowlist_policy_test.exs @@ -7,7 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.UserAllowListPolicyTest do alias Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy - setup do: clear_config([:mrf_user_allowlist, :localhost]) + setup do: clear_config(:mrf_user_allowlist) test "pass filter if allow list is empty" do actor = insert(:user) @@ -17,14 +17,14 @@ test "pass filter if allow list is empty" do test "pass filter if allow list isn't empty and user in allow list" do actor = insert(:user) - Pleroma.Config.put([:mrf_user_allowlist, :localhost], [actor.ap_id, "test-ap-id"]) + Pleroma.Config.put([:mrf_user_allowlist], %{"localhost" => [actor.ap_id, "test-ap-id"]}) message = %{"actor" => actor.ap_id} assert UserAllowListPolicy.filter(message) == {:ok, message} end test "rejected if allow list isn't empty and user not in allow list" do actor = insert(:user) - Pleroma.Config.put([:mrf_user_allowlist, :localhost], ["test-ap-id"]) + Pleroma.Config.put([:mrf_user_allowlist], %{"localhost" => ["test-ap-id"]}) message = %{"actor" => actor.ap_id} assert UserAllowListPolicy.filter(message) == {:reject, nil} end From 4b865bba107b0db1de886cefd14227454cbece1e Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Sat, 13 Jun 2020 10:37:15 +0000 Subject: [PATCH 265/401] Apply suggestion to lib/pleroma/web/controller_helper.ex --- lib/pleroma/web/controller_helper.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index a5eb3e9e0..d5e9c33f5 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -75,7 +75,7 @@ defp build_pagination_fields(conn, min_id, max_id, extra_params) do # instead of the `q.id > ^min_id` and `q.id < ^max_id`. # This is because we only have ids present inside of the page, while # `min_id`, `since_id` and `max_id` requires to know one outside of it. - if Map.take(conn.params, @id_keys) != [] do + if Map.take(conn.params, @id_keys) != %{} do Map.put(fields, "id", current_url(conn)) else fields From 1d625c29a09cf7c0fb415d5606a91315902efaad Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Sat, 13 Jun 2020 13:12:43 +0200 Subject: [PATCH 266/401] ControllerHelper: Always return id field. --- lib/pleroma/web/controller_helper.ex | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index d5e9c33f5..69946fb81 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -65,21 +65,11 @@ defp build_pagination_fields(conn, min_id, max_id, extra_params) do |> Map.merge(extra_params) |> Map.drop(@id_keys) - fields = %{ + %{ "next" => current_url(conn, Map.put(params, :max_id, max_id)), - "prev" => current_url(conn, Map.put(params, :min_id, min_id)) + "prev" => current_url(conn, Map.put(params, :min_id, min_id)), + "id" => current_url(conn) } - - # Generating an `id` without already present pagination keys would - # need a query-restriction with an `q.id >= ^id` or `q.id <= ^id` - # instead of the `q.id > ^min_id` and `q.id < ^max_id`. - # This is because we only have ids present inside of the page, while - # `min_id`, `since_id` and `max_id` requires to know one outside of it. - if Map.take(conn.params, @id_keys) != %{} do - Map.put(fields, "id", current_url(conn)) - else - fields - end end def get_pagination_fields(conn, activities, extra_params \\ %{}) do From b15cfc3d365dcfa5f99159fe06e29de6f8aceb4f Mon Sep 17 00:00:00 2001 From: eugenijm <eugenijm@protonmail.com> Date: Mon, 18 May 2020 18:46:04 +0300 Subject: [PATCH 267/401] Mastodon API: ensure the notification endpoint doesn't return less than the requested amount of records unless it's the last page --- CHANGELOG.md | 1 + lib/pleroma/notification.ex | 19 +++++- lib/pleroma/user.ex | 8 +++ .../mastodon_api/views/notification_view.ex | 68 +++++++++---------- ...ete_notifications_from_invisible_users.exs | 18 +++++ test/notification_test.exs | 8 +++ .../notification_controller_test.exs | 27 ++++++++ .../views/notification_view_test.exs | 4 +- 8 files changed, 112 insertions(+), 41 deletions(-) create mode 100644 priv/repo/migrations/20200527163635_delete_notifications_from_invisible_users.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9361fa260..b3f2dd10f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Filtering of push notifications on activities from blocked domains - Resolving Peertube accounts with Webfinger - `blob:` urls not being allowed by connect-src CSP +- Mastodon API: fix `GET /api/v1/notifications` not returning the full result set ## [Unreleased (patch)] diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 3386a1933..9ee9606be 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -166,8 +166,16 @@ defp exclude_visibility(query, %{exclude_visibilities: visibility}) query |> join(:left, [n, a], mutated_activity in Pleroma.Activity, on: - fragment("?->>'context'", a.data) == - fragment("?->>'context'", mutated_activity.data) and + fragment( + "COALESCE((?->'object')->>'id', ?->>'object')", + a.data, + a.data + ) == + fragment( + "COALESCE((?->'object')->>'id', ?->>'object')", + mutated_activity.data, + mutated_activity.data + ) and fragment("(?->>'type' = 'Like' or ?->>'type' = 'Announce')", a.data, a.data) and fragment("?->>'type'", mutated_activity.data) == "Create", as: :mutated_activity @@ -541,6 +549,7 @@ def exclude_thread_muter_ap_ids(ap_ids, %Activity{} = activity) do def skip?(%Activity{} = activity, %User{} = user) do [ :self, + :invisible, :followers, :follows, :non_followers, @@ -557,6 +566,12 @@ def skip?(:self, %Activity{} = activity, %User{} = user) do activity.data["actor"] == user.ap_id end + def skip?(:invisible, %Activity{} = activity, _) do + actor = activity.data["actor"] + user = User.get_cached_by_ap_id(actor) + User.invisible?(user) + end + def skip?( :followers, %Activity{} = activity, diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index c5c74d132..52ac9052b 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1488,6 +1488,7 @@ def perform(:delete, %User{} = user) do end) delete_user_activities(user) + delete_notifications_from_user_activities(user) delete_outgoing_pending_follow_requests(user) @@ -1576,6 +1577,13 @@ def follow_import(%User{} = follower, followed_identifiers) }) end + def delete_notifications_from_user_activities(%User{ap_id: ap_id}) do + Notification + |> join(:inner, [n], activity in assoc(n, :activity)) + |> where([n, a], fragment("? = ?", a.actor, ^ap_id)) + |> Repo.delete_all() + end + def delete_user_activities(%User{ap_id: ap_id} = user) do ap_id |> Activity.Queries.by_actor() diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index b11578623..3865be280 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -46,6 +46,7 @@ def render("index.json", %{notifications: notifications, for: reading_user} = op activities |> Enum.filter(&(&1.data["type"] == "Move")) |> Enum.map(&User.get_cached_by_ap_id(&1.data["target"])) + |> Enum.filter(& &1) actors = activities @@ -84,50 +85,45 @@ def render( # Note: :relationships contain user mutes (needed for :muted flag in :status) status_render_opts = %{relationships: opts[:relationships]} - with %{id: _} = account <- - AccountView.render( - "show.json", - %{user: actor, for: reading_user} - ) do - response = %{ - id: to_string(notification.id), - type: notification.type, - created_at: CommonAPI.Utils.to_masto_date(notification.inserted_at), - account: account, - pleroma: %{ - is_seen: notification.seen - } + account = + AccountView.render( + "show.json", + %{user: actor, for: reading_user} + ) + + response = %{ + id: to_string(notification.id), + type: notification.type, + created_at: CommonAPI.Utils.to_masto_date(notification.inserted_at), + account: account, + pleroma: %{ + is_seen: notification.seen } + } - case notification.type do - "mention" -> - put_status(response, activity, reading_user, status_render_opts) + case notification.type do + "mention" -> + put_status(response, activity, reading_user, status_render_opts) - "favourite" -> - put_status(response, parent_activity_fn.(), reading_user, status_render_opts) + "favourite" -> + put_status(response, parent_activity_fn.(), reading_user, status_render_opts) - "reblog" -> - put_status(response, parent_activity_fn.(), reading_user, status_render_opts) + "reblog" -> + put_status(response, parent_activity_fn.(), reading_user, status_render_opts) - "move" -> - put_target(response, activity, reading_user, %{}) + "move" -> + put_target(response, activity, reading_user, %{}) - "pleroma:emoji_reaction" -> - response - |> put_status(parent_activity_fn.(), reading_user, status_render_opts) - |> put_emoji(activity) + "pleroma:emoji_reaction" -> + response + |> put_status(parent_activity_fn.(), reading_user, status_render_opts) + |> put_emoji(activity) - "pleroma:chat_mention" -> - put_chat_message(response, activity, reading_user, status_render_opts) + "pleroma:chat_mention" -> + put_chat_message(response, activity, reading_user, status_render_opts) - type when type in ["follow", "follow_request"] -> - response - - _ -> - nil - end - else - _ -> nil + type when type in ["follow", "follow_request"] -> + response end end diff --git a/priv/repo/migrations/20200527163635_delete_notifications_from_invisible_users.exs b/priv/repo/migrations/20200527163635_delete_notifications_from_invisible_users.exs new file mode 100644 index 000000000..9e95a8111 --- /dev/null +++ b/priv/repo/migrations/20200527163635_delete_notifications_from_invisible_users.exs @@ -0,0 +1,18 @@ +defmodule Pleroma.Repo.Migrations.DeleteNotificationsFromInvisibleUsers do + use Ecto.Migration + + import Ecto.Query + alias Pleroma.Repo + + def up do + Pleroma.Notification + |> join(:inner, [n], activity in assoc(n, :activity)) + |> where( + [n, a], + fragment("? in (SELECT ap_id FROM users WHERE invisible = true)", a.actor) + ) + |> Repo.delete_all() + end + + def down, do: :ok +end diff --git a/test/notification_test.exs b/test/notification_test.exs index b9bbdceca..526f43fab 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -306,6 +306,14 @@ test "it doesn't create subscription notifications if the recipient cannot see t assert {:ok, []} == Notification.create_notifications(status) end + + test "it disables notifications from people who are invisible" do + author = insert(:user, invisible: true) + user = insert(:user) + + {:ok, status} = CommonAPI.post(author, %{status: "hey @#{user.nickname}"}) + refute Notification.create_notification(status, user) + end end describe "follow / follow_request notifications" do diff --git a/test/web/mastodon_api/controllers/notification_controller_test.exs b/test/web/mastodon_api/controllers/notification_controller_test.exs index 698c99711..70ef0e8b5 100644 --- a/test/web/mastodon_api/controllers/notification_controller_test.exs +++ b/test/web/mastodon_api/controllers/notification_controller_test.exs @@ -313,6 +313,33 @@ test "filters notifications for Announce activities" do assert public_activity.id in activity_ids refute unlisted_activity.id in activity_ids end + + test "doesn't return less than the requested amount of records when the user's reply is liked" do + user = insert(:user) + %{user: other_user, conn: conn} = oauth_access(["read:notifications"]) + + {:ok, mention} = + CommonAPI.post(user, %{status: "@#{other_user.nickname}", visibility: "public"}) + + {:ok, activity} = CommonAPI.post(user, %{status: ".", visibility: "public"}) + + {:ok, reply} = + CommonAPI.post(other_user, %{ + status: ".", + visibility: "public", + in_reply_to_status_id: activity.id + }) + + {:ok, _favorite} = CommonAPI.favorite(user, reply.id) + + activity_ids = + conn + |> get("/api/v1/notifications?exclude_visibilities[]=direct&limit=2") + |> json_response_and_validate_schema(200) + |> Enum.map(& &1["status"]["id"]) + + assert [reply.id, mention.id] == activity_ids + end end test "filters notifications using exclude_types" do diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index b2fa5b302..9c399b2df 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -139,9 +139,7 @@ test "Follow notification" do test_notifications_rendering([notification], followed, [expected]) User.perform(:delete, follower) - notification = Notification |> Repo.one() |> Repo.preload(:activity) - - test_notifications_rendering([notification], followed, []) + refute Repo.one(Notification) end @tag capture_log: true From 2e8a236cef28c0b754aecb04a5c60c3b7655c5a6 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Sun, 14 Jun 2020 21:02:57 +0300 Subject: [PATCH 268/401] fix invalidates media url's --- config/config.exs | 7 + config/description.exs | 64 +++++++++ docs/configuration/cheatsheet.md | 6 +- installation/nginx-cache-purge.sh.example | 4 +- lib/pleroma/application.ex | 3 +- lib/pleroma/plugs/uploaded_media.ex | 16 ++- lib/pleroma/web/media_proxy/invalidation.ex | 29 ++-- .../web/media_proxy/invalidations/http.ex | 8 +- .../web/media_proxy/invalidations/script.ex | 36 ++--- lib/pleroma/web/media_proxy/media_proxy.ex | 35 ++++- .../web/media_proxy/media_proxy_controller.ex | 3 +- .../workers/attachments_cleanup_worker.ex | 133 ++++++++++-------- test/web/media_proxy/invalidation_test.exs | 65 +++++++++ .../media_proxy/invalidations/http_test.exs | 13 +- .../media_proxy/invalidations/script_test.exs | 21 ++- .../media_proxy_controller_test.exs | 17 +++ 16 files changed, 346 insertions(+), 114 deletions(-) create mode 100644 test/web/media_proxy/invalidation_test.exs diff --git a/config/config.exs b/config/config.exs index 9508ae077..e299fb8dd 100644 --- a/config/config.exs +++ b/config/config.exs @@ -406,6 +406,13 @@ ], whitelist: [] +config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Http, + method: :purge, + headers: [], + options: [] + +config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Script, script_path: nil + config :pleroma, :chat, enabled: true config :phoenix, :format_encoders, json: Jason diff --git a/config/description.exs b/config/description.exs index 807c945e0..857293794 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1637,6 +1637,31 @@ "The base URL to access a user-uploaded file. Useful when you want to proxy the media files via another host/CDN fronts.", suggestions: ["https://example.com"] }, + %{ + key: :invalidation, + type: :keyword, + descpiption: "", + suggestions: [ + enabled: true, + provider: Pleroma.Web.MediaProxy.Invalidation.Script + ], + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Enables invalidate media cache" + }, + %{ + key: :provider, + type: :module, + description: "Module which will be used to cache purge.", + suggestions: [ + Pleroma.Web.MediaProxy.Invalidation.Script, + Pleroma.Web.MediaProxy.Invalidation.Http + ] + } + ] + }, %{ key: :proxy_opts, type: :keyword, @@ -1709,6 +1734,45 @@ } ] }, + %{ + group: :pleroma, + key: Pleroma.Web.MediaProxy.Invalidation.Http, + type: :group, + description: "HTTP invalidate settings", + children: [ + %{ + key: :method, + type: :atom, + description: "HTTP method of request. Default: :purge" + }, + %{ + key: :headers, + type: {:list, :tuple}, + description: "HTTP headers of request.", + suggestions: [{"x-refresh", 1}] + }, + %{ + key: :options, + type: :keyword, + description: "Request options.", + suggestions: [params: %{ts: "xxx"}] + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Web.MediaProxy.Invalidation.Script, + type: :group, + description: "Script invalidate settings", + children: [ + %{ + key: :script_path, + type: :string, + description: "Path to shell script. Which will run purge cache.", + suggestions: ["./installation/nginx-cache-purge.sh.example"] + } + ] + }, %{ group: :pleroma, key: :gopher, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 505acb293..20bd0ed85 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -262,7 +262,7 @@ This section describe PWA manifest instance-specific values. Currently this opti #### Pleroma.Web.MediaProxy.Invalidation.Script -This strategy allow perform external bash script to purge cache. +This strategy allow perform external shell script to purge cache. Urls of attachments pass to script as arguments. * `script_path`: path to external script. @@ -278,8 +278,8 @@ config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Script, This strategy allow perform custom http request to purge cache. * `method`: http method. default is `purge` -* `headers`: http headers. default is empty -* `options`: request options. default is empty +* `headers`: http headers. +* `options`: request options. Example: ```elixir diff --git a/installation/nginx-cache-purge.sh.example b/installation/nginx-cache-purge.sh.example index b2915321c..5f6cbb128 100755 --- a/installation/nginx-cache-purge.sh.example +++ b/installation/nginx-cache-purge.sh.example @@ -13,7 +13,7 @@ CACHE_DIRECTORY="/tmp/pleroma-media-cache" ## $3 - (optional) the number of parallel processes to run for grep. get_cache_files() { local max_parallel=${3-16} - find $2 -maxdepth 2 -type d | xargs -P $max_parallel -n 1 grep -E Rl "^KEY:.*$1" | sort -u + find $2 -maxdepth 2 -type d | xargs -P $max_parallel -n 1 grep -E -Rl "^KEY:.*$1" | sort -u } ## Removes an item from the given cache zone. @@ -37,4 +37,4 @@ purge() { } -purge $1 +purge $@ diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 9d3d92b38..adebebc7a 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -148,7 +148,8 @@ defp cachex_children do 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) + build_cachex("failed_proxy_url", limit: 2500), + build_cachex("deleted_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000) ] end diff --git a/lib/pleroma/plugs/uploaded_media.ex b/lib/pleroma/plugs/uploaded_media.ex index 94147e0c4..2f3fde002 100644 --- a/lib/pleroma/plugs/uploaded_media.ex +++ b/lib/pleroma/plugs/uploaded_media.ex @@ -10,6 +10,8 @@ defmodule Pleroma.Plugs.UploadedMedia do import Pleroma.Web.Gettext require Logger + alias Pleroma.Web.MediaProxy + @behaviour Plug # no slashes @path "media" @@ -35,8 +37,7 @@ def call(%{request_path: <<"/", @path, "/", file::binary>>} = conn, opts) do %{query_params: %{"name" => name}} = conn -> name = String.replace(name, "\"", "\\\"") - conn - |> put_resp_header("content-disposition", "filename=\"#{name}\"") + put_resp_header(conn, "content-disposition", "filename=\"#{name}\"") conn -> conn @@ -47,7 +48,8 @@ def call(%{request_path: <<"/", @path, "/", file::binary>>} = conn, opts) do with uploader <- Keyword.fetch!(config, :uploader), proxy_remote = Keyword.get(config, :proxy_remote, false), - {:ok, get_method} <- uploader.get_file(file) do + {:ok, get_method} <- uploader.get_file(file), + false <- media_is_deleted(conn, get_method) do get_media(conn, get_method, proxy_remote, opts) else _ -> @@ -59,6 +61,14 @@ def call(%{request_path: <<"/", @path, "/", file::binary>>} = conn, opts) do def call(conn, _opts), do: conn + defp media_is_deleted(%{request_path: path} = _conn, {:static_dir, _}) do + MediaProxy.in_deleted_urls(Pleroma.Web.base_url() <> path) + end + + defp media_is_deleted(_, {:url, url}), do: MediaProxy.in_deleted_urls(url) + + defp media_is_deleted(_, _), do: false + defp get_media(conn, {:static_dir, directory}, _, opts) do static_opts = Map.get(opts, :static_plug_opts) diff --git a/lib/pleroma/web/media_proxy/invalidation.ex b/lib/pleroma/web/media_proxy/invalidation.ex index c037ff13e..83ff8589c 100644 --- a/lib/pleroma/web/media_proxy/invalidation.ex +++ b/lib/pleroma/web/media_proxy/invalidation.ex @@ -5,22 +5,33 @@ defmodule Pleroma.Web.MediaProxy.Invalidation do @moduledoc false - @callback purge(list(String.t()), map()) :: {:ok, String.t()} | {:error, String.t()} + @callback purge(list(String.t()), Keyword.t()) :: {:ok, list(String.t())} | {:error, String.t()} alias Pleroma.Config + alias Pleroma.Web.MediaProxy - @spec purge(list(String.t())) :: {:ok, String.t()} | {:error, String.t()} + @spec enabled?() :: boolean() + def enabled?, do: Config.get([:media_proxy, :invalidation, :enabled]) + + @spec purge(list(String.t()) | String.t()) :: {:ok, list(String.t())} | {:error, String.t()} def purge(urls) do - [:media_proxy, :invalidation, :enabled] - |> Config.get() - |> do_purge(urls) + prepared_urls = prepare_urls(urls) + + if enabled?() do + do_purge(prepared_urls) + else + {:ok, prepared_urls} + end end - defp do_purge(true, urls) do + defp do_purge(urls) do provider = Config.get([:media_proxy, :invalidation, :provider]) - options = Config.get(provider) - provider.purge(urls, options) + provider.purge(urls, Config.get(provider)) end - defp do_purge(_, _), do: :ok + def prepare_urls(urls) do + urls + |> List.wrap() + |> Enum.map(&MediaProxy.url(&1)) + end end diff --git a/lib/pleroma/web/media_proxy/invalidations/http.ex b/lib/pleroma/web/media_proxy/invalidations/http.ex index 07248df6e..3694b56e8 100644 --- a/lib/pleroma/web/media_proxy/invalidations/http.ex +++ b/lib/pleroma/web/media_proxy/invalidations/http.ex @@ -10,9 +10,9 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.Http do @impl Pleroma.Web.MediaProxy.Invalidation def purge(urls, opts) do - method = Map.get(opts, :method, :purge) - headers = Map.get(opts, :headers, []) - options = Map.get(opts, :options, []) + method = Keyword.get(opts, :method, :purge) + headers = Keyword.get(opts, :headers, []) + options = Keyword.get(opts, :options, []) Logger.debug("Running cache purge: #{inspect(urls)}") @@ -22,7 +22,7 @@ def purge(urls, opts) do end end) - {:ok, "success"} + {:ok, urls} end defp do_purge(method, url, headers, options) do diff --git a/lib/pleroma/web/media_proxy/invalidations/script.ex b/lib/pleroma/web/media_proxy/invalidations/script.ex index 6be782132..d41d647bb 100644 --- a/lib/pleroma/web/media_proxy/invalidations/script.ex +++ b/lib/pleroma/web/media_proxy/invalidations/script.ex @@ -10,32 +10,34 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.Script do require Logger @impl Pleroma.Web.MediaProxy.Invalidation - def purge(urls, %{script_path: script_path} = _options) do + def purge(urls, opts) do args = urls |> List.wrap() |> Enum.uniq() |> Enum.join(" ") - path = Path.expand(script_path) - - Logger.debug("Running cache purge: #{inspect(urls)}, #{path}") - - case do_purge(path, [args]) do - {result, exit_status} when exit_status > 0 -> - Logger.error("Error while cache purge: #{inspect(result)}") - {:error, inspect(result)} - - _ -> - {:ok, "success"} - end + opts + |> Keyword.get(:script_path, nil) + |> do_purge([args]) + |> handle_result(urls) end - def purge(_, _), do: {:error, "not found script path"} - - defp do_purge(path, args) do + defp do_purge(script_path, args) when is_binary(script_path) do + path = Path.expand(script_path) + Logger.debug("Running cache purge: #{inspect(args)}, #{inspect(path)}") System.cmd(path, args) rescue - error -> {inspect(error), 1} + error -> error + end + + defp do_purge(_, _), do: {:error, "not found script path"} + + defp handle_result({_result, 0}, urls), do: {:ok, urls} + defp handle_result({:error, error}, urls), do: handle_result(error, urls) + + defp handle_result(error, _) do + Logger.error("Error while cache purge: #{inspect(error)}") + {:error, inspect(error)} end end diff --git a/lib/pleroma/web/media_proxy/media_proxy.ex b/lib/pleroma/web/media_proxy/media_proxy.ex index b2b524524..59ca217ab 100644 --- a/lib/pleroma/web/media_proxy/media_proxy.ex +++ b/lib/pleroma/web/media_proxy/media_proxy.ex @@ -6,20 +6,53 @@ defmodule Pleroma.Web.MediaProxy do alias Pleroma.Config alias Pleroma.Upload alias Pleroma.Web + alias Pleroma.Web.MediaProxy.Invalidation @base64_opts [padding: false] + @spec in_deleted_urls(String.t()) :: boolean() + def in_deleted_urls(url), do: elem(Cachex.exists?(:deleted_urls_cache, url(url)), 1) + + def remove_from_deleted_urls(urls) when is_list(urls) do + Cachex.execute!(:deleted_urls_cache, fn cache -> + Enum.each(Invalidation.prepare_urls(urls), &Cachex.del(cache, &1)) + end) + end + + def remove_from_deleted_urls(url) when is_binary(url) do + Cachex.del(:deleted_urls_cache, url(url)) + end + + def put_in_deleted_urls(urls) when is_list(urls) do + Cachex.execute!(:deleted_urls_cache, fn cache -> + Enum.each(Invalidation.prepare_urls(urls), &Cachex.put(cache, &1, true)) + end) + end + + def put_in_deleted_urls(url) when is_binary(url) do + Cachex.put(:deleted_urls_cache, url(url), true) + end + def url(url) when is_nil(url) or url == "", do: nil def url("/" <> _ = url), do: url def url(url) do - if disabled?() or local?(url) or whitelisted?(url) do + if disabled?() or not is_url_proxiable?(url) do url else encode_url(url) end end + @spec is_url_proxiable?(String.t()) :: boolean() + def is_url_proxiable?(url) do + if local?(url) or whitelisted?(url) do + false + else + true + end + end + defp disabled?, do: !Config.get([:media_proxy, :enabled], false) defp local?(url), do: String.starts_with?(url, Pleroma.Web.base_url()) diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 4657a4383..ff0158d83 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -14,10 +14,11 @@ def remote(conn, %{"sig" => sig64, "url" => url64} = params) do with config <- Pleroma.Config.get([:media_proxy], []), true <- Keyword.get(config, :enabled, false), {:ok, url} <- MediaProxy.decode_url(sig64, url64), + {_, false} <- {:in_deleted_urls, MediaProxy.in_deleted_urls(url)}, :ok <- filename_matches(params, conn.request_path, url) do ReverseProxy.call(conn, url, Keyword.get(config, :proxy_opts, @default_proxy_opts)) else - false -> + error when error in [false, {:in_deleted_urls, true}] -> send_resp(conn, 404, Plug.Conn.Status.reason_phrase(404)) {:error, :invalid_signature} -> diff --git a/lib/pleroma/workers/attachments_cleanup_worker.ex b/lib/pleroma/workers/attachments_cleanup_worker.ex index 49352db2a..4ad19c0fc 100644 --- a/lib/pleroma/workers/attachments_cleanup_worker.ex +++ b/lib/pleroma/workers/attachments_cleanup_worker.ex @@ -23,8 +23,25 @@ def perform( Enum.map(attachment["url"], & &1["href"]) end) - names = Enum.map(attachments, & &1["name"]) + # find all objects for copies of the attachments, name and actor doesn't matter here + hrefs + |> fetch_objects + |> prepare_objects(actor, Enum.map(attachments, & &1["name"])) + |> Enum.reduce({[], []}, fn {href, %{id: id, count: count}}, {ids, hrefs} -> + with 1 <- count do + {ids ++ [id], hrefs ++ [href]} + else + _ -> {ids ++ [id], hrefs} + end + end) + |> do_clean + {:ok, :success} + end + + def perform(%{"op" => "cleanup_attachments", "object" => _object}, _job), do: {:ok, :skip} + + defp do_clean({object_ids, attachment_urls}) do uploader = Pleroma.Config.get([Pleroma.Upload, :uploader]) prefix = @@ -39,68 +56,60 @@ def perform( "/" ) - # find all objects for copies of the attachments, name and actor doesn't matter here - object_ids_and_hrefs = - from(o in Object, - where: - fragment( - "to_jsonb(array(select jsonb_array_elements((?)#>'{url}') ->> 'href' where jsonb_typeof((?)#>'{url}') = 'array'))::jsonb \\?| (?)", - o.data, - o.data, - ^hrefs - ) - ) - # The query above can be time consumptive on large instances until we - # refactor how uploads are stored - |> Repo.all(timeout: :infinity) - # we should delete 1 object for any given attachment, but don't delete - # files if there are more than 1 object for it - |> Enum.reduce(%{}, fn %{ - id: id, - data: %{ - "url" => [%{"href" => href}], - "actor" => obj_actor, - "name" => name - } - }, - acc -> - Map.update(acc, href, %{id: id, count: 1}, fn val -> - case obj_actor == actor and name in names do - true -> - # set id of the actor's object that will be deleted - %{val | id: id, count: val.count + 1} + Enum.each(attachment_urls, fn href -> + href + |> String.trim_leading("#{base_url}/#{prefix}") + |> uploader.delete_file() + end) - false -> - # another actor's object, just increase count to not delete file - %{val | count: val.count + 1} - end - end) - end) - |> Enum.map(fn {href, %{id: id, count: count}} -> - # only delete files that have single instance - with 1 <- count do - href - |> String.trim_leading("#{base_url}/#{prefix}") - |> uploader.delete_file() - - {id, href} - else - _ -> {id, nil} - end - end) - - object_ids = Enum.map(object_ids_and_hrefs, fn {id, _} -> id end) - - from(o in Object, where: o.id in ^object_ids) - |> Repo.delete_all() - - object_ids_and_hrefs - |> Enum.filter(fn {_, href} -> not is_nil(href) end) - |> Enum.map(&elem(&1, 1)) - |> Pleroma.Web.MediaProxy.Invalidation.purge() - - {:ok, :success} + delete_objects(object_ids) end - def perform(%{"op" => "cleanup_attachments", "object" => _object}, _job), do: {:ok, :skip} + defp delete_objects([_ | _] = object_ids) do + Repo.delete_all(from(o in Object, where: o.id in ^object_ids)) + end + + defp delete_objects(_), do: :ok + + # we should delete 1 object for any given attachment, but don't delete + # files if there are more than 1 object for it + def prepare_objects(objects, actor, names) do + objects + |> Enum.reduce(%{}, fn %{ + id: id, + data: %{ + "url" => [%{"href" => href}], + "actor" => obj_actor, + "name" => name + } + }, + acc -> + Map.update(acc, href, %{id: id, count: 1}, fn val -> + case obj_actor == actor and name in names do + true -> + # set id of the actor's object that will be deleted + %{val | id: id, count: val.count + 1} + + false -> + # another actor's object, just increase count to not delete file + %{val | count: val.count + 1} + end + end) + end) + end + + def fetch_objects(hrefs) do + from(o in Object, + where: + fragment( + "to_jsonb(array(select jsonb_array_elements((?)#>'{url}') ->> 'href' where jsonb_typeof((?)#>'{url}') = 'array'))::jsonb \\?| (?)", + o.data, + o.data, + ^hrefs + ) + ) + # The query above can be time consumptive on large instances until we + # refactor how uploads are stored + |> Repo.all(timeout: :infinity) + end end diff --git a/test/web/media_proxy/invalidation_test.exs b/test/web/media_proxy/invalidation_test.exs new file mode 100644 index 000000000..3a9fa8c88 --- /dev/null +++ b/test/web/media_proxy/invalidation_test.exs @@ -0,0 +1,65 @@ +defmodule Pleroma.Web.MediaProxy.InvalidationTest do + use ExUnit.Case + use Pleroma.Tests.Helpers + + alias Pleroma.Config + alias Pleroma.Web.MediaProxy.Invalidation + + import ExUnit.CaptureLog + import Mock + import Tesla.Mock + + setup do: clear_config([:media_proxy]) + + setup do + on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) + :ok + end + + describe "Invalidation.Http" do + test "perform request to clear cache" do + Config.put([:media_proxy, :enabled], false) + Config.put([:media_proxy, :invalidation, :enabled], true) + Config.put([:media_proxy, :invalidation, :provider], Invalidation.Http) + + Config.put([Invalidation.Http], method: :purge, headers: [{"x-refresh", 1}]) + image_url = "http://example.com/media/example.jpg" + Pleroma.Web.MediaProxy.put_in_deleted_urls(image_url) + + mock(fn + %{ + method: :purge, + url: "http://example.com/media/example.jpg", + headers: [{"x-refresh", 1}] + } -> + %Tesla.Env{status: 200} + end) + + assert capture_log(fn -> + assert Pleroma.Web.MediaProxy.in_deleted_urls(image_url) + assert Invalidation.purge([image_url]) == {:ok, [image_url]} + assert Pleroma.Web.MediaProxy.in_deleted_urls(image_url) + end) =~ "Running cache purge: [\"#{image_url}\"]" + end + end + + describe "Invalidation.Script" do + test "run script to clear cache" do + Config.put([:media_proxy, :enabled], false) + Config.put([:media_proxy, :invalidation, :enabled], true) + Config.put([:media_proxy, :invalidation, :provider], Invalidation.Script) + Config.put([Invalidation.Script], script_path: "purge-nginx") + + image_url = "http://example.com/media/example.jpg" + Pleroma.Web.MediaProxy.put_in_deleted_urls(image_url) + + with_mocks [{System, [], [cmd: fn _, _ -> {"ok", 0} end]}] do + assert capture_log(fn -> + assert Pleroma.Web.MediaProxy.in_deleted_urls(image_url) + assert Invalidation.purge([image_url]) == {:ok, [image_url]} + assert Pleroma.Web.MediaProxy.in_deleted_urls(image_url) + end) =~ "Running cache purge: [\"#{image_url}\"]" + end + end + end +end diff --git a/test/web/media_proxy/invalidations/http_test.exs b/test/web/media_proxy/invalidations/http_test.exs index 8a3b4141c..09e7ca0fb 100644 --- a/test/web/media_proxy/invalidations/http_test.exs +++ b/test/web/media_proxy/invalidations/http_test.exs @@ -5,6 +5,11 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.HttpTest do import ExUnit.CaptureLog import Tesla.Mock + setup do + on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) + :ok + end + test "logs hasn't error message when request is valid" do mock(fn %{method: :purge, url: "http://example.com/media/example.jpg"} -> @@ -14,8 +19,8 @@ test "logs hasn't error message when request is valid" do refute capture_log(fn -> assert Invalidation.Http.purge( ["http://example.com/media/example.jpg"], - %{} - ) == {:ok, "success"} + [] + ) == {:ok, ["http://example.com/media/example.jpg"]} end) =~ "Error while cache purge" end @@ -28,8 +33,8 @@ test "it write error message in logs when request invalid" do assert capture_log(fn -> assert Invalidation.Http.purge( ["http://example.com/media/example1.jpg"], - %{} - ) == {:ok, "success"} + [] + ) == {:ok, ["http://example.com/media/example1.jpg"]} end) =~ "Error while cache purge: url - http://example.com/media/example1.jpg" end end diff --git a/test/web/media_proxy/invalidations/script_test.exs b/test/web/media_proxy/invalidations/script_test.exs index 1358963ab..c69cec07a 100644 --- a/test/web/media_proxy/invalidations/script_test.exs +++ b/test/web/media_proxy/invalidations/script_test.exs @@ -4,17 +4,24 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.ScriptTest do import ExUnit.CaptureLog + setup do + on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) + :ok + end + test "it logger error when script not found" do assert capture_log(fn -> assert Invalidation.Script.purge( ["http://example.com/media/example.jpg"], - %{script_path: "./example"} - ) == {:error, "\"%ErlangError{original: :enoent}\""} - end) =~ "Error while cache purge: \"%ErlangError{original: :enoent}\"" + script_path: "./example" + ) == {:error, "%ErlangError{original: :enoent}"} + end) =~ "Error while cache purge: %ErlangError{original: :enoent}" - assert Invalidation.Script.purge( - ["http://example.com/media/example.jpg"], - %{} - ) == {:error, "not found script path"} + capture_log(fn -> + assert Invalidation.Script.purge( + ["http://example.com/media/example.jpg"], + [] + ) == {:error, "\"not found script path\""} + end) end end diff --git a/test/web/media_proxy/media_proxy_controller_test.exs b/test/web/media_proxy/media_proxy_controller_test.exs index da79d38a5..2b6b25221 100644 --- a/test/web/media_proxy/media_proxy_controller_test.exs +++ b/test/web/media_proxy/media_proxy_controller_test.exs @@ -10,6 +10,11 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do setup do: clear_config(:media_proxy) setup do: clear_config([Pleroma.Web.Endpoint, :secret_key_base]) + setup do + on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) + :ok + end + test "it returns 404 when MediaProxy disabled", %{conn: conn} do Config.put([:media_proxy, :enabled], false) @@ -66,4 +71,16 @@ test "it performs ReverseProxy.call when signature valid", %{conn: conn} do assert %Plug.Conn{status: :success} = get(conn, url) end end + + test "it returns 404 when url contains in deleted_urls cache", %{conn: conn} do + Config.put([:media_proxy, :enabled], true) + Config.put([Pleroma.Web.Endpoint, :secret_key_base], "00000000000") + url = Pleroma.Web.MediaProxy.encode_url("https://google.fn/test.png") + Pleroma.Web.MediaProxy.put_in_deleted_urls("https://google.fn/test.png") + + with_mock Pleroma.ReverseProxy, + call: fn _conn, _url, _opts -> %Plug.Conn{status: :success} end do + assert %Plug.Conn{status: 404, resp_body: "Not Found"} = get(conn, url) + end + end end From b7df7436c813bfcb4f27ac64c85ebc1507153601 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Mon, 15 Jun 2020 12:27:13 +0200 Subject: [PATCH 269/401] Conversations: Return last dm for conversation, not last message. --- lib/pleroma/conversation/participation.ex | 11 +++++++---- lib/pleroma/web/activity_pub/activity_pub.ex | 7 ++++--- .../web/mastodon_api/views/conversation_view.ex | 11 +++++++---- .../web/mastodon_api/views/conversation_view_test.exs | 11 ++++++++++- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/conversation/participation.ex b/lib/pleroma/conversation/participation.ex index ce7bd2396..8bc3e85d6 100644 --- a/lib/pleroma/conversation/participation.ex +++ b/lib/pleroma/conversation/participation.ex @@ -162,10 +162,13 @@ def for_user_with_last_activity_id(user, params \\ %{}) do for_user(user, params) |> Enum.map(fn participation -> activity_id = - ActivityPub.fetch_latest_activity_id_for_context(participation.conversation.ap_id, %{ - user: user, - blocking_user: user - }) + ActivityPub.fetch_latest_direct_activity_id_for_context( + participation.conversation.ap_id, + %{ + user: user, + blocking_user: user + } + ) %{ participation diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index c9dc6135c..3e4f3ad30 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -210,7 +210,7 @@ def stream_out_participations(%Object{data: %{"context" => context}}, user) do conversation = Repo.preload(conversation, :participations) last_activity_id = - fetch_latest_activity_id_for_context(conversation.ap_id, %{ + fetch_latest_direct_activity_id_for_context(conversation.ap_id, %{ user: user, blocking_user: user }) @@ -517,11 +517,12 @@ def fetch_activities_for_context(context, opts \\ %{}) do |> Repo.all() end - @spec fetch_latest_activity_id_for_context(String.t(), keyword() | map()) :: + @spec fetch_latest_direct_activity_id_for_context(String.t(), keyword() | map()) :: FlakeId.Ecto.CompatType.t() | nil - def fetch_latest_activity_id_for_context(context, opts \\ %{}) do + def fetch_latest_direct_activity_id_for_context(context, opts \\ %{}) do context |> fetch_activities_for_context_query(Map.merge(%{skip_preload: true}, opts)) + |> restrict_visibility(%{visibility: "direct"}) |> limit(1) |> select([a], a.id) |> Repo.one() diff --git a/lib/pleroma/web/mastodon_api/views/conversation_view.ex b/lib/pleroma/web/mastodon_api/views/conversation_view.ex index fbe618377..06f0c1728 100644 --- a/lib/pleroma/web/mastodon_api/views/conversation_view.ex +++ b/lib/pleroma/web/mastodon_api/views/conversation_view.ex @@ -23,10 +23,13 @@ def render("participation.json", %{participation: participation, for: user}) do last_activity_id = with nil <- participation.last_activity_id do - ActivityPub.fetch_latest_activity_id_for_context(participation.conversation.ap_id, %{ - user: user, - blocking_user: user - }) + ActivityPub.fetch_latest_direct_activity_id_for_context( + participation.conversation.ap_id, + %{ + user: user, + blocking_user: user + } + ) end activity = Activity.get_by_id_with_object(last_activity_id) diff --git a/test/web/mastodon_api/views/conversation_view_test.exs b/test/web/mastodon_api/views/conversation_view_test.exs index 6f84366f8..2e8203c9b 100644 --- a/test/web/mastodon_api/views/conversation_view_test.exs +++ b/test/web/mastodon_api/views/conversation_view_test.exs @@ -15,8 +15,17 @@ test "represents a Mastodon Conversation entity" do user = insert(:user) other_user = insert(:user) + {:ok, parent} = CommonAPI.post(user, %{status: "parent"}) + {:ok, activity} = - CommonAPI.post(user, %{status: "hey @#{other_user.nickname}", visibility: "direct"}) + CommonAPI.post(user, %{ + status: "hey @#{other_user.nickname}", + visibility: "direct", + in_reply_to_id: parent.id + }) + + {:ok, _reply_activity} = + CommonAPI.post(user, %{status: "hu", visibility: "public", in_reply_to_id: parent.id}) [participation] = Participation.for_user_with_last_activity_id(user) From 1092b3650068169ece0ac95cd88ec0e4da30036b Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Mon, 15 Jun 2020 12:30:11 +0200 Subject: [PATCH 270/401] Changelog: Add info about conversation view changes. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3f2dd10f..c546f1f04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] ### Changed +- In Conversations, return only direct messages as `last_status` - MFR policy to set global expiration for all local Create activities <details> <summary>API Changes</summary> From 62b8c31b7a84dadb2a46861fe0f2dd1dbf9d40f0 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Mon, 15 Jun 2020 14:55:00 +0300 Subject: [PATCH 271/401] added tests --- .../media_proxy_cache_controller.ex | 31 ++++- .../web/media_proxy/invalidations/script.ex | 2 +- .../media_proxy_cache_controller_test.exs | 116 +++++++++++++++--- 3 files changed, 127 insertions(+), 22 deletions(-) diff --git a/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex index 7b28f7c72..e3fa0ac28 100644 --- a/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheController do alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Web.ApiSpec.Admin, as: Spec + alias Pleroma.Web.MediaProxy plug(Pleroma.Web.ApiSpec.CastAndValidate) @@ -24,15 +25,39 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheController do defdelegate open_api_operation(action), to: Spec.MediaProxyCacheOperation - def index(%{assigns: %{user: _}} = conn, _) do - render(conn, "index.json", urls: []) + def index(%{assigns: %{user: _}} = conn, params) do + cursor = + :deleted_urls_cache + |> :ets.table([{:traverse, {:select, Cachex.Query.create(true, :key)}}]) + |> :qlc.cursor() + + urls = + case params.page do + 1 -> + :qlc.next_answers(cursor, params.page_size) + + _ -> + :qlc.next_answers(cursor, (params.page - 1) * params.page_size) + :qlc.next_answers(cursor, params.page_size) + end + + :qlc.delete_cursor(cursor) + + render(conn, "index.json", urls: urls) end def delete(%{assigns: %{user: _}, body_params: %{urls: urls}} = conn, _) do + MediaProxy.remove_from_deleted_urls(urls) render(conn, "index.json", urls: urls) end - def purge(%{assigns: %{user: _}, body_params: %{urls: urls, ban: _ban}} = conn, _) do + def purge(%{assigns: %{user: _}, body_params: %{urls: urls, ban: ban}} = conn, _) do + MediaProxy.Invalidation.purge(urls) + + if ban do + MediaProxy.put_in_deleted_urls(urls) + end + render(conn, "index.json", urls: urls) end end diff --git a/lib/pleroma/web/media_proxy/invalidations/script.ex b/lib/pleroma/web/media_proxy/invalidations/script.ex index d41d647bb..0217b119d 100644 --- a/lib/pleroma/web/media_proxy/invalidations/script.ex +++ b/lib/pleroma/web/media_proxy/invalidations/script.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.Script do require Logger @impl Pleroma.Web.MediaProxy.Invalidation - def purge(urls, opts) do + def purge(urls, opts \\ %{}) do args = urls |> List.wrap() diff --git a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs index 1b1d6bc36..76a96f46f 100644 --- a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs +++ b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs @@ -6,6 +6,16 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheControllerTest do use Pleroma.Web.ConnCase import Pleroma.Factory + import Mock + + alias Pleroma.Web.MediaProxy + + setup do: clear_config([:media_proxy]) + + setup do + on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) + :ok + end setup do admin = insert(:user, is_admin: true) @@ -16,51 +26,121 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheControllerTest do |> assign(:user, admin) |> assign(:token, token) + Config.put([:media_proxy, :enabled], true) + Config.put([:media_proxy, :invalidation, :enabled], true) + Config.put([:media_proxy, :invalidation, :provider], MediaProxy.Invalidation.Script) + {:ok, %{admin: admin, token: token, conn: conn}} end describe "GET /api/pleroma/admin/media_proxy_caches" do test "shows banned MediaProxy URLs", %{conn: conn} do + MediaProxy.put_in_deleted_urls([ + "http://localhost:4001/media/a688346.jpg", + "http://localhost:4001/media/fb1f4d.jpg" + ]) + + MediaProxy.put_in_deleted_urls("http://localhost:4001/media/gb1f44.jpg") + MediaProxy.put_in_deleted_urls("http://localhost:4001/media/tb13f47.jpg") + MediaProxy.put_in_deleted_urls("http://localhost:4001/media/wb1f46.jpg") + response = conn - |> get("/api/pleroma/admin/media_proxy_caches") + |> get("/api/pleroma/admin/media_proxy_caches?page_size=2") |> json_response_and_validate_schema(200) - assert response["urls"] == [] + assert response["urls"] == [ + "http://localhost:4001/media/fb1f4d.jpg", + "http://localhost:4001/media/a688346.jpg" + ] + + response = + conn + |> get("/api/pleroma/admin/media_proxy_caches?page_size=2&page=2") + |> json_response_and_validate_schema(200) + + assert response["urls"] == [ + "http://localhost:4001/media/gb1f44.jpg", + "http://localhost:4001/media/tb13f47.jpg" + ] + + response = + conn + |> get("/api/pleroma/admin/media_proxy_caches?page_size=2&page=3") + |> json_response_and_validate_schema(200) + + assert response["urls"] == ["http://localhost:4001/media/wb1f46.jpg"] end end describe "DELETE /api/pleroma/admin/media_proxy_caches/delete" do test "deleted MediaProxy URLs from banned", %{conn: conn} do + MediaProxy.put_in_deleted_urls([ + "http://localhost:4001/media/a688346.jpg", + "http://localhost:4001/media/fb1f4d.jpg" + ]) + response = conn |> put_req_header("content-type", "application/json") |> post("/api/pleroma/admin/media_proxy_caches/delete", %{ - urls: ["http://example.com/media/a688346.jpg", "http://example.com/media/fb1f4d.jpg"] + urls: ["http://localhost:4001/media/a688346.jpg"] }) |> json_response_and_validate_schema(200) - assert response["urls"] == [ - "http://example.com/media/a688346.jpg", - "http://example.com/media/fb1f4d.jpg" - ] + assert response["urls"] == ["http://localhost:4001/media/a688346.jpg"] + refute MediaProxy.in_deleted_urls("http://localhost:4001/media/a688346.jpg") + assert MediaProxy.in_deleted_urls("http://localhost:4001/media/fb1f4d.jpg") end end describe "PURGE /api/pleroma/admin/media_proxy_caches/purge" do test "perform invalidates cache of MediaProxy", %{conn: conn} do - response = - conn - |> put_req_header("content-type", "application/json") - |> post("/api/pleroma/admin/media_proxy_caches/purge", %{ - urls: ["http://example.com/media/a688346.jpg", "http://example.com/media/fb1f4d.jpg"] - }) - |> json_response_and_validate_schema(200) + urls = [ + "http://example.com/media/a688346.jpg", + "http://example.com/media/fb1f4d.jpg" + ] - assert response["urls"] == [ - "http://example.com/media/a688346.jpg", - "http://example.com/media/fb1f4d.jpg" - ] + with_mocks [ + {MediaProxy.Invalidation.Script, [], + [ + purge: fn _, _ -> {"ok", 0} end + ]} + ] do + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/media_proxy_caches/purge", %{urls: urls, ban: false}) + |> json_response_and_validate_schema(200) + + assert response["urls"] == urls + + refute MediaProxy.in_deleted_urls("http://example.com/media/a688346.jpg") + refute MediaProxy.in_deleted_urls("http://example.com/media/fb1f4d.jpg") + end + end + + test "perform invalidates cache of MediaProxy and adds url to banned", %{conn: conn} do + urls = [ + "http://example.com/media/a688346.jpg", + "http://example.com/media/fb1f4d.jpg" + ] + + with_mocks [{MediaProxy.Invalidation.Script, [], [purge: fn _, _ -> {"ok", 0} end]}] do + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/media_proxy_caches/purge", %{ + urls: urls, + ban: true + }) + |> json_response_and_validate_schema(200) + + assert response["urls"] == urls + + assert MediaProxy.in_deleted_urls("http://example.com/media/a688346.jpg") + assert MediaProxy.in_deleted_urls("http://example.com/media/fb1f4d.jpg") + end end end end From bd63089a633099233d4fc19faece2796253a7ee0 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn <egor@kislitsyn.com> Date: Mon, 15 Jun 2020 16:20:05 +0400 Subject: [PATCH 272/401] Fix tests --- .../rich_media/parsers/twitter_card_test.exs | 88 +++++++++---------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/test/web/rich_media/parsers/twitter_card_test.exs b/test/web/rich_media/parsers/twitter_card_test.exs index 3ccf26651..219f005a2 100644 --- a/test/web/rich_media/parsers/twitter_card_test.exs +++ b/test/web/rich_media/parsers/twitter_card_test.exs @@ -17,18 +17,18 @@ test "parses twitter card with only name attributes" do assert TwitterCard.parse(html, %{}) == %{ - "app:id:googleplay": "com.nytimes.android", - "app:name:googleplay": "NYTimes", - "app:url:googleplay": "nytimes://reader/id/100000006583622", - site: nil, - description: + "app:id:googleplay" => "com.nytimes.android", + "app:name:googleplay" => "NYTimes", + "app:url:googleplay" => "nytimes://reader/id/100000006583622", + "site" => nil, + "description" => "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", - image: + "image" => "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-facebookJumbo.jpg", - type: "article", - url: + "type" => "article", + "url" => "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html", - title: + "title" => "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database." } end @@ -40,17 +40,17 @@ test "parses twitter card with only property attributes" do assert TwitterCard.parse(html, %{}) == %{ - card: "summary_large_image", - description: + "card" => "summary_large_image", + "description" => "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", - image: + "image" => "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-videoSixteenByNineJumbo1600.jpg", - "image:alt": "", - title: + "image:alt" => "", + "title" => "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.", - url: + "url" => "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html", - type: "article" + "type" => "article" } end @@ -61,21 +61,21 @@ test "parses twitter card with name & property attributes" do assert TwitterCard.parse(html, %{}) == %{ - "app:id:googleplay": "com.nytimes.android", - "app:name:googleplay": "NYTimes", - "app:url:googleplay": "nytimes://reader/id/100000006583622", - card: "summary_large_image", - description: + "app:id:googleplay" => "com.nytimes.android", + "app:name:googleplay" => "NYTimes", + "app:url:googleplay" => "nytimes://reader/id/100000006583622", + "card" => "summary_large_image", + "description" => "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", - image: + "image" => "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-videoSixteenByNineJumbo1600.jpg", - "image:alt": "", - site: nil, - title: + "image:alt" => "", + "site" => nil, + "title" => "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.", - url: + "url" => "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html", - type: "article" + "type" => "article" } end @@ -90,15 +90,15 @@ test "respect only first title tag on the page" do assert TwitterCard.parse(html, %{}) == %{ - site: "@atlasobscura", - title: "The Missing Grave of Margaret Corbin, Revolutionary War Veteran", - card: "summary_large_image", - image: image_path, - description: + "site" => "@atlasobscura", + "title" => "The Missing Grave of Margaret Corbin, Revolutionary War Veteran", + "card" => "summary_large_image", + "image" => image_path, + "description" => "She's the only woman veteran honored with a monument at West Point. But where was she buried?", - site_name: "Atlas Obscura", - type: "article", - url: "http://www.atlasobscura.com/articles/margaret-corbin-grave-west-point" + "site_name" => "Atlas Obscura", + "type" => "article", + "url" => "http://www.atlasobscura.com/articles/margaret-corbin-grave-west-point" } end @@ -109,18 +109,18 @@ test "takes first founded title in html head if there is html markup error" do assert TwitterCard.parse(html, %{}) == %{ - site: nil, - title: + "site" => nil, + "title" => "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.", - "app:id:googleplay": "com.nytimes.android", - "app:name:googleplay": "NYTimes", - "app:url:googleplay": "nytimes://reader/id/100000006583622", - description: + "app:id:googleplay" => "com.nytimes.android", + "app:name:googleplay" => "NYTimes", + "app:url:googleplay" => "nytimes://reader/id/100000006583622", + "description" => "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", - image: + "image" => "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-facebookJumbo.jpg", - type: "article", - url: + "type" => "article", + "url" => "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html" } end From efdfc85c2d8e5118c1aa18e4f04026ec90cd11d2 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Mon, 15 Jun 2020 15:24:00 +0300 Subject: [PATCH 273/401] update docs --- docs/API/admin_api.md | 64 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index 92816baf9..6659b605d 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -1224,4 +1224,66 @@ Loads json generated from `config/descriptions.exs`. - Response: - On success: `204`, empty response - On failure: - - 400 Bad Request `"Invalid parameters"` when `status` is missing \ No newline at end of file + - 400 Bad Request `"Invalid parameters"` when `status` is missing + +## `GET /api/pleroma/admin/media_proxy_caches` + +### Get a list of all banned MediaProxy URLs in Cachex + +- Authentication: required +- Params: +- *optional* `page`: **integer** page number +- *optional* `page_size`: **integer** number of log entries per page (default is `50`) + +- Response: + +``` json +{ + "urls": [ + "http://example.com/media/a688346.jpg", + "http://example.com/media/fb1f4d.jpg" + ] +} + +``` + +## `POST /api/pleroma/admin/media_proxy_caches/delete` + +### Remove a banned MediaProxy URL from Cachex + +- Authentication: required +- Params: + - `urls` + +- Response: + +``` json +{ + "urls": [ + "http://example.com/media/a688346.jpg", + "http://example.com/media/fb1f4d.jpg" + ] +} + +``` + +## `POST /api/pleroma/admin/media_proxy_caches/purge` + +### Purge a MediaProxy URL + +- Authentication: required +- Params: + - `urls` + - `ban` + +- Response: + +``` json +{ + "urls": [ + "http://example.com/media/a688346.jpg", + "http://example.com/media/fb1f4d.jpg" + ] +} + +``` From e1ee8bc1da17a356c88b535db7a9228fccc5251f Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Mon, 15 Jun 2020 14:29:34 +0200 Subject: [PATCH 274/401] User: update_follower_count refactor. --- lib/pleroma/user.ex | 30 +++++++++--------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 52ac9052b..39a9e13e8 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -747,7 +747,6 @@ def follow(%User{} = follower, %User{} = followed, state \\ :follow_accept) do follower |> update_following_count() - |> set_cache() end end @@ -776,7 +775,6 @@ defp do_unfollow(%User{} = follower, %User{} = followed) do {:ok, follower} = follower |> update_following_count() - |> set_cache() {:ok, follower, followed} @@ -1128,35 +1126,25 @@ defp follow_information_changeset(user, params) do ]) end + @spec update_follower_count(User.t()) :: {:ok, User.t()} def update_follower_count(%User{} = user) do if user.local or !Pleroma.Config.get([:instance, :external_user_synchronization]) do - follower_count_query = - User.Query.build(%{followers: user, deactivated: false}) - |> select([u], %{count: count(u.id)}) + follower_count = FollowingRelationship.follower_count(user) - User - |> where(id: ^user.id) - |> join(:inner, [u], s in subquery(follower_count_query)) - |> update([u, s], - set: [follower_count: s.count] - ) - |> select([u], u) - |> Repo.update_all([]) - |> case do - {1, [user]} -> set_cache(user) - _ -> {:error, user} - end + user + |> follow_information_changeset(%{follower_count: follower_count}) + |> update_and_set_cache else {:ok, maybe_fetch_follow_information(user)} end end - @spec update_following_count(User.t()) :: User.t() + @spec update_following_count(User.t()) :: {:ok, User.t()} def update_following_count(%User{local: false} = user) do if Pleroma.Config.get([:instance, :external_user_synchronization]) do - maybe_fetch_follow_information(user) + {:ok, maybe_fetch_follow_information(user)} else - user + {:ok, user} end end @@ -1165,7 +1153,7 @@ def update_following_count(%User{local: true} = user) do user |> follow_information_changeset(%{following_count: following_count}) - |> Repo.update!() + |> update_and_set_cache() end def set_unread_conversation_count(%User{local: true} = user) do From faba1a6e337715af557e2e222e62de6fd35c9e8a Mon Sep 17 00:00:00 2001 From: stwf <steven.fuchs@dockyard.com> Date: Mon, 15 Jun 2020 12:25:03 -0400 Subject: [PATCH 275/401] fix tests --- lib/pleroma/web/preload/timelines.ex | 4 ++-- test/web/node_info_test.exs | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/preload/timelines.ex b/lib/pleroma/web/preload/timelines.ex index 2bb57567b..e531b8960 100644 --- a/lib/pleroma/web/preload/timelines.ex +++ b/lib/pleroma/web/preload/timelines.ex @@ -30,8 +30,8 @@ defp public_timeline(_params), do: get_public_timeline(true) defp get_public_timeline(local_only) do activities = ActivityPub.fetch_public_activities(%{ - "type" => ["Create"], - "local_only" => local_only + type: ["Create"], + local_only: local_only }) StatusView.render("index.json", activities: activities, for: nil, as: :activity) diff --git a/test/web/node_info_test.exs b/test/web/node_info_test.exs index 00925caad..9bcc07b37 100644 --- a/test/web/node_info_test.exs +++ b/test/web/node_info_test.exs @@ -145,8 +145,7 @@ test "it shows default features flags", %{conn: conn} do "shareable_emoji_packs", "multifetch", "pleroma_emoji_reactions", - "pleroma:api/v1/notifications:include_types_filter", - "pleroma_chat_messages" + "pleroma:api/v1/notifications:include_types_filter" ] assert MapSet.subset?( From b02311079961c5193af1c144516a3caeee72b582 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Mon, 15 Jun 2020 20:47:02 +0300 Subject: [PATCH 276/401] fixed a visibility of functions --- .../workers/attachments_cleanup_worker.ex | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/lib/pleroma/workers/attachments_cleanup_worker.ex b/lib/pleroma/workers/attachments_cleanup_worker.ex index 4ad19c0fc..8deeabda0 100644 --- a/lib/pleroma/workers/attachments_cleanup_worker.ex +++ b/lib/pleroma/workers/attachments_cleanup_worker.ex @@ -18,22 +18,11 @@ def perform( }, _job ) do - hrefs = - Enum.flat_map(attachments, fn attachment -> - Enum.map(attachment["url"], & &1["href"]) - end) - - # find all objects for copies of the attachments, name and actor doesn't matter here - hrefs + attachments + |> Enum.flat_map(fn item -> Enum.map(item["url"], & &1["href"]) end) |> fetch_objects |> prepare_objects(actor, Enum.map(attachments, & &1["name"])) - |> Enum.reduce({[], []}, fn {href, %{id: id, count: count}}, {ids, hrefs} -> - with 1 <- count do - {ids ++ [id], hrefs ++ [href]} - else - _ -> {ids ++ [id], hrefs} - end - end) + |> filter_objects |> do_clean {:ok, :success} @@ -73,7 +62,17 @@ defp delete_objects(_), do: :ok # we should delete 1 object for any given attachment, but don't delete # files if there are more than 1 object for it - def prepare_objects(objects, actor, names) do + defp filter_objects(objects) do + Enum.reduce(objects, {[], []}, fn {href, %{id: id, count: count}}, {ids, hrefs} -> + with 1 <- count do + {ids ++ [id], hrefs ++ [href]} + else + _ -> {ids ++ [id], hrefs} + end + end) + end + + defp prepare_objects(objects, actor, names) do objects |> Enum.reduce(%{}, fn %{ id: id, @@ -98,7 +97,7 @@ def prepare_objects(objects, actor, names) do end) end - def fetch_objects(hrefs) do + defp fetch_objects(hrefs) do from(o in Object, where: fragment( From 1eb6cedaadee4e1ab3e0885b4e03a8dd17ba08ea Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Tue, 16 Jun 2020 13:08:27 +0200 Subject: [PATCH 277/401] ActivityPub: When restricting to media posts, only show 'Creates'. --- lib/pleroma/web/activity_pub/activity_pub.ex | 3 ++- .../controllers/account_controller_test.exs | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index c9dc6135c..efb8b81db 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -833,7 +833,8 @@ defp restrict_media(_query, %{only_media: _val, skip_preload: true}) do defp restrict_media(query, %{only_media: true}) do from( - [_activity, object] in query, + [activity, object] in query, + where: fragment("(?)->>'type' = ?", activity.data, "Create"), where: fragment("not (?)->'attachment' = (?)", object.data, ^[]) ) end diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 1ce97378d..2343a9d2d 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -350,9 +350,10 @@ test "unimplemented pinned statuses feature", %{conn: conn} do assert json_response_and_validate_schema(conn, 200) == [] end - test "gets an users media", %{conn: conn} do + test "gets an users media, excludes reblogs", %{conn: conn} do note = insert(:note_activity) user = User.get_cached_by_ap_id(note.data["actor"]) + other_user = insert(:user) file = %Plug.Upload{ content_type: "image/jpg", @@ -364,6 +365,13 @@ test "gets an users media", %{conn: conn} do {:ok, %{id: image_post_id}} = CommonAPI.post(user, %{status: "cofe", media_ids: [media_id]}) + {:ok, %{id: media_id}} = ActivityPub.upload(file, actor: other_user.ap_id) + + {:ok, %{id: other_image_post_id}} = + CommonAPI.post(other_user, %{status: "cofe2", media_ids: [media_id]}) + + {:ok, _announce} = CommonAPI.repeat(other_image_post_id, user) + conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?only_media=true") assert [%{"id" => ^image_post_id}] = json_response_and_validate_schema(conn, 200) From 4733f6a3371504ebb3eeb447d7c20d56c10b43bf Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Tue, 16 Jun 2020 13:09:28 +0200 Subject: [PATCH 278/401] Changelog: Add info about `only_media` changes. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2629bf84..eee442817 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] ### Changed +- Using the `only_media` filter on timelines will now exclude reblog media - MFR policy to set global expiration for all local Create activities - OGP rich media parser merged with TwitterCard <details> From 015f9258a9bd1430ab079f449b118b664c3b9664 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Tue, 16 Jun 2020 14:48:46 +0200 Subject: [PATCH 279/401] Transmogrifier: Extract user update handling tests. --- .../user_update_handling_test.exs | 154 +++++++++++++++++ test/web/activity_pub/transmogrifier_test.exs | 156 ------------------ 2 files changed, 154 insertions(+), 156 deletions(-) create mode 100644 test/web/activity_pub/transmogrifier/user_update_handling_test.exs diff --git a/test/web/activity_pub/transmogrifier/user_update_handling_test.exs b/test/web/activity_pub/transmogrifier/user_update_handling_test.exs new file mode 100644 index 000000000..8e5d3b883 --- /dev/null +++ b/test/web/activity_pub/transmogrifier/user_update_handling_test.exs @@ -0,0 +1,154 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier.UserUpdateHandlingTest do + use Pleroma.DataCase + + alias Pleroma.Activity + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Transmogrifier + + import Pleroma.Factory + + test "it works for incoming update activities" do + user = insert(:user, local: false) + + update_data = File.read!("test/fixtures/mastodon-update.json") |> Poison.decode!() + + object = + update_data["object"] + |> Map.put("actor", user.ap_id) + |> Map.put("id", user.ap_id) + + update_data = + update_data + |> Map.put("actor", user.ap_id) + |> Map.put("object", object) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(update_data) + + assert data["id"] == update_data["id"] + + user = User.get_cached_by_ap_id(data["actor"]) + assert user.name == "gargle" + + assert user.avatar["url"] == [ + %{ + "href" => + "https://cd.niu.moe/accounts/avatars/000/033/323/original/fd7f8ae0b3ffedc9.jpeg" + } + ] + + assert user.banner["url"] == [ + %{ + "href" => + "https://cd.niu.moe/accounts/headers/000/033/323/original/850b3448fa5fd477.png" + } + ] + + assert user.bio == "<p>Some bio</p>" + end + + test "it works with alsoKnownAs" do + %{ap_id: actor} = insert(:user, local: false) + + assert User.get_cached_by_ap_id(actor).also_known_as == [] + + {:ok, _activity} = + "test/fixtures/mastodon-update.json" + |> File.read!() + |> Poison.decode!() + |> Map.put("actor", actor) + |> Map.update!("object", fn object -> + object + |> Map.put("actor", actor) + |> Map.put("id", actor) + |> Map.put("alsoKnownAs", [ + "http://mastodon.example.org/users/foo", + "http://example.org/users/bar" + ]) + end) + |> Transmogrifier.handle_incoming() + + assert User.get_cached_by_ap_id(actor).also_known_as == [ + "http://mastodon.example.org/users/foo", + "http://example.org/users/bar" + ] + end + + test "it works with custom profile fields" do + user = insert(:user, local: false) + + assert user.fields == [] + + update_data = File.read!("test/fixtures/mastodon-update.json") |> Poison.decode!() + + object = + update_data["object"] + |> Map.put("actor", user.ap_id) + |> Map.put("id", user.ap_id) + + update_data = + update_data + |> Map.put("actor", user.ap_id) + |> Map.put("object", object) + + {:ok, _update_activity} = Transmogrifier.handle_incoming(update_data) + + user = User.get_cached_by_ap_id(user.ap_id) + + assert user.fields == [ + %{"name" => "foo", "value" => "updated"}, + %{"name" => "foo1", "value" => "updated"} + ] + + Pleroma.Config.put([:instance, :max_remote_account_fields], 2) + + update_data = + put_in(update_data, ["object", "attachment"], [ + %{"name" => "foo", "type" => "PropertyValue", "value" => "bar"}, + %{"name" => "foo11", "type" => "PropertyValue", "value" => "bar11"}, + %{"name" => "foo22", "type" => "PropertyValue", "value" => "bar22"} + ]) + + {:ok, _} = Transmogrifier.handle_incoming(update_data) + + user = User.get_cached_by_ap_id(user.ap_id) + + assert user.fields == [ + %{"name" => "foo", "value" => "updated"}, + %{"name" => "foo1", "value" => "updated"} + ] + + update_data = put_in(update_data, ["object", "attachment"], []) + + {:ok, _} = Transmogrifier.handle_incoming(update_data) + + user = User.get_cached_by_ap_id(user.ap_id) + + assert user.fields == [] + end + + test "it works for incoming update activities which lock the account" do + user = insert(:user, local: false) + + update_data = File.read!("test/fixtures/mastodon-update.json") |> Poison.decode!() + + object = + update_data["object"] + |> Map.put("actor", user.ap_id) + |> Map.put("id", user.ap_id) + |> Map.put("manuallyApprovesFollowers", true) + + update_data = + update_data + |> Map.put("actor", user.ap_id) + |> Map.put("object", object) + + {:ok, %Activity{local: false}} = Transmogrifier.handle_incoming(update_data) + + user = User.get_cached_by_ap_id(user.ap_id) + assert user.locked == true + end +end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 94d8552e8..b542bb7b8 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -401,162 +401,6 @@ test "it strips internal reactions" do refute Map.has_key?(object_data, "reaction_count") end - test "it works for incoming update activities" do - data = File.read!("test/fixtures/mastodon-post-activity.json") |> Poison.decode!() - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - update_data = File.read!("test/fixtures/mastodon-update.json") |> Poison.decode!() - - object = - update_data["object"] - |> Map.put("actor", data["actor"]) - |> Map.put("id", data["actor"]) - - update_data = - update_data - |> Map.put("actor", data["actor"]) - |> Map.put("object", object) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(update_data) - - assert data["id"] == update_data["id"] - - user = User.get_cached_by_ap_id(data["actor"]) - assert user.name == "gargle" - - assert user.avatar["url"] == [ - %{ - "href" => - "https://cd.niu.moe/accounts/avatars/000/033/323/original/fd7f8ae0b3ffedc9.jpeg" - } - ] - - assert user.banner["url"] == [ - %{ - "href" => - "https://cd.niu.moe/accounts/headers/000/033/323/original/850b3448fa5fd477.png" - } - ] - - assert user.bio == "<p>Some bio</p>" - end - - test "it works with alsoKnownAs" do - {:ok, %Activity{data: %{"actor" => actor}}} = - "test/fixtures/mastodon-post-activity.json" - |> File.read!() - |> Poison.decode!() - |> Transmogrifier.handle_incoming() - - assert User.get_cached_by_ap_id(actor).also_known_as == ["http://example.org/users/foo"] - - {:ok, _activity} = - "test/fixtures/mastodon-update.json" - |> File.read!() - |> Poison.decode!() - |> Map.put("actor", actor) - |> Map.update!("object", fn object -> - object - |> Map.put("actor", actor) - |> Map.put("id", actor) - |> Map.put("alsoKnownAs", [ - "http://mastodon.example.org/users/foo", - "http://example.org/users/bar" - ]) - end) - |> Transmogrifier.handle_incoming() - - assert User.get_cached_by_ap_id(actor).also_known_as == [ - "http://mastodon.example.org/users/foo", - "http://example.org/users/bar" - ] - end - - test "it works with custom profile fields" do - {:ok, activity} = - "test/fixtures/mastodon-post-activity.json" - |> File.read!() - |> Poison.decode!() - |> Transmogrifier.handle_incoming() - - user = User.get_cached_by_ap_id(activity.actor) - - assert user.fields == [ - %{"name" => "foo", "value" => "bar"}, - %{"name" => "foo1", "value" => "bar1"} - ] - - update_data = File.read!("test/fixtures/mastodon-update.json") |> Poison.decode!() - - object = - update_data["object"] - |> Map.put("actor", user.ap_id) - |> Map.put("id", user.ap_id) - - update_data = - update_data - |> Map.put("actor", user.ap_id) - |> Map.put("object", object) - - {:ok, _update_activity} = Transmogrifier.handle_incoming(update_data) - - user = User.get_cached_by_ap_id(user.ap_id) - - assert user.fields == [ - %{"name" => "foo", "value" => "updated"}, - %{"name" => "foo1", "value" => "updated"} - ] - - Pleroma.Config.put([:instance, :max_remote_account_fields], 2) - - update_data = - put_in(update_data, ["object", "attachment"], [ - %{"name" => "foo", "type" => "PropertyValue", "value" => "bar"}, - %{"name" => "foo11", "type" => "PropertyValue", "value" => "bar11"}, - %{"name" => "foo22", "type" => "PropertyValue", "value" => "bar22"} - ]) - - {:ok, _} = Transmogrifier.handle_incoming(update_data) - - user = User.get_cached_by_ap_id(user.ap_id) - - assert user.fields == [ - %{"name" => "foo", "value" => "updated"}, - %{"name" => "foo1", "value" => "updated"} - ] - - update_data = put_in(update_data, ["object", "attachment"], []) - - {:ok, _} = Transmogrifier.handle_incoming(update_data) - - user = User.get_cached_by_ap_id(user.ap_id) - - assert user.fields == [] - end - - test "it works for incoming update activities which lock the account" do - data = File.read!("test/fixtures/mastodon-post-activity.json") |> Poison.decode!() - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - update_data = File.read!("test/fixtures/mastodon-update.json") |> Poison.decode!() - - object = - update_data["object"] - |> Map.put("actor", data["actor"]) - |> Map.put("id", data["actor"]) - |> Map.put("manuallyApprovesFollowers", true) - - update_data = - update_data - |> Map.put("actor", data["actor"]) - |> Map.put("object", object) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(update_data) - - user = User.get_cached_by_ap_id(data["actor"]) - assert user.locked == true - end - test "it works for incomming unfollows with an existing follow" do user = insert(:user) From 9a4fde97661595630ea840917ef83b4786f2e2d3 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Sun, 31 May 2020 10:46:02 +0300 Subject: [PATCH 280/401] Mogrify args as custom tuples --- lib/mix/tasks/pleroma/config.ex | 10 +- lib/pleroma/config/config_db.ex | 274 ++++---- lib/pleroma/config/transfer_task.ex | 12 +- lib/pleroma/config/type/atom.ex | 22 + lib/pleroma/config/type/binary_value.ex | 23 + .../controllers/config_controller.ex | 34 +- .../web/admin_api/views/config_view.ex | 21 +- test/config/config_db_test.exs | 587 +++++++----------- test/config/transfer_task_test.exs | 94 +-- test/support/factory.ex | 17 +- test/tasks/config_test.exs | 41 +- test/upload/filter/mogrify_test.exs | 8 +- .../controllers/config_controller_test.exs | 256 +++++--- 13 files changed, 620 insertions(+), 779 deletions(-) create mode 100644 lib/pleroma/config/type/atom.ex create mode 100644 lib/pleroma/config/type/binary_value.ex diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 5c9ef6904..f1b3a8766 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -72,8 +72,7 @@ defp create(group, settings) do group |> Pleroma.Config.Loader.filter_group(settings) |> Enum.each(fn {key, value} -> - key = inspect(key) - {:ok, _} = ConfigDB.update_or_create(%{group: inspect(group), key: key, value: value}) + {:ok, _} = ConfigDB.update_or_create(%{group: group, key: key, value: value}) shell_info("Settings for key #{key} migrated.") end) @@ -131,12 +130,9 @@ defp write_and_delete(config, file, delete?) do end defp write(config, file) do - value = - config.value - |> ConfigDB.from_binary() - |> inspect(limit: :infinity) + value = inspect(config.value, limit: :infinity) - IO.write(file, "config #{config.group}, #{config.key}, #{value}\r\n\r\n") + IO.write(file, "config #{inspect(config.group)}, #{inspect(config.key)}, #{value}\r\n\r\n") config end diff --git a/lib/pleroma/config/config_db.ex b/lib/pleroma/config/config_db.ex index 2b43d4c36..39b37c42e 100644 --- a/lib/pleroma/config/config_db.ex +++ b/lib/pleroma/config/config_db.ex @@ -6,7 +6,7 @@ defmodule Pleroma.ConfigDB do use Ecto.Schema import Ecto.Changeset - import Ecto.Query + import Ecto.Query, only: [select: 3] import Pleroma.Web.Gettext alias __MODULE__ @@ -14,16 +14,6 @@ defmodule Pleroma.ConfigDB do @type t :: %__MODULE__{} - @full_key_update [ - {:pleroma, :ecto_repos}, - {:quack, :meta}, - {:mime, :types}, - {:cors_plug, [:max_age, :methods, :expose, :headers]}, - {:auto_linker, :opts}, - {:swarm, :node_blacklist}, - {:logger, :backends} - ] - @full_subkey_update [ {:pleroma, :assets, :mascots}, {:pleroma, :emoji, :groups}, @@ -32,14 +22,10 @@ defmodule Pleroma.ConfigDB do {:pleroma, :mrf_keyword, :replace} ] - @regex ~r/^~r(?'delimiter'[\/|"'([{<]{1})(?'pattern'.+)[\/|"')\]}>]{1}(?'modifier'[uismxfU]*)/u - - @delimiters ["/", "|", "\"", "'", {"(", ")"}, {"[", "]"}, {"{", "}"}, {"<", ">"}] - schema "config" do - field(:key, :string) - field(:group, :string) - field(:value, :binary) + field(:key, Pleroma.Config.Type.Atom) + field(:group, Pleroma.Config.Type.Atom) + field(:value, Pleroma.Config.Type.BinaryValue) field(:db, {:array, :string}, virtual: true, default: []) timestamps() @@ -51,10 +37,6 @@ def get_all_as_keyword do |> select([c], {c.group, c.key, c.value}) |> Repo.all() |> Enum.reduce([], fn {group, key, value}, acc -> - group = ConfigDB.from_string(group) - key = ConfigDB.from_string(key) - value = from_binary(value) - Keyword.update(acc, group, [{key, value}], &Keyword.merge(&1, [{key, value}])) end) end @@ -64,50 +46,41 @@ def get_by_params(params), do: Repo.get_by(ConfigDB, params) @spec changeset(ConfigDB.t(), map()) :: Changeset.t() def changeset(config, params \\ %{}) do - params = Map.put(params, :value, transform(params[:value])) - config |> cast(params, [:key, :group, :value]) |> validate_required([:key, :group, :value]) |> unique_constraint(:key, name: :config_group_key_index) end - @spec create(map()) :: {:ok, ConfigDB.t()} | {:error, Changeset.t()} - def create(params) do + defp create(params) do %ConfigDB{} |> changeset(params) |> Repo.insert() end - @spec update(ConfigDB.t(), map()) :: {:ok, ConfigDB.t()} | {:error, Changeset.t()} - def update(%ConfigDB{} = config, %{value: value}) do + defp update(%ConfigDB{} = config, %{value: value}) do config |> changeset(%{value: value}) |> Repo.update() end - @spec get_db_keys(ConfigDB.t()) :: [String.t()] - def get_db_keys(%ConfigDB{} = config) do - config.value - |> ConfigDB.from_binary() - |> get_db_keys(config.key) - end - @spec get_db_keys(keyword(), any()) :: [String.t()] def get_db_keys(value, key) do - if Keyword.keyword?(value) do - value |> Keyword.keys() |> Enum.map(&convert(&1)) - else - [convert(key)] - end + keys = + if Keyword.keyword?(value) do + Keyword.keys(value) + else + [key] + end + + Enum.map(keys, &to_json_types(&1)) end @spec merge_group(atom(), atom(), keyword(), keyword()) :: keyword() def merge_group(group, key, old_value, new_value) do - new_keys = to_map_set(new_value) + new_keys = to_mapset(new_value) - intersect_keys = - old_value |> to_map_set() |> MapSet.intersection(new_keys) |> MapSet.to_list() + intersect_keys = old_value |> to_mapset() |> MapSet.intersection(new_keys) |> MapSet.to_list() merged_value = ConfigDB.merge(old_value, new_value) @@ -120,12 +93,10 @@ def merge_group(group, key, old_value, new_value) do [] end) |> List.flatten() - |> Enum.reduce(merged_value, fn subkey, acc -> - Keyword.put(acc, subkey, new_value[subkey]) - end) + |> Enum.reduce(merged_value, &Keyword.put(&2, &1, new_value[&1])) end - defp to_map_set(keyword) do + defp to_mapset(keyword) do keyword |> Keyword.keys() |> MapSet.new() @@ -159,43 +130,39 @@ defp deep_merge(_key, value1, value2) do @spec update_or_create(map()) :: {:ok, ConfigDB.t()} | {:error, Changeset.t()} def update_or_create(params) do + params = Map.put(params, :value, to_elixir_types(params[:value])) search_opts = Map.take(params, [:group, :key]) with %ConfigDB{} = config <- ConfigDB.get_by_params(search_opts), - {:partial_update, true, config} <- - {:partial_update, can_be_partially_updated?(config), config}, - old_value <- from_binary(config.value), - transformed_value <- do_transform(params[:value]), - {:can_be_merged, true, config} <- {:can_be_merged, is_list(transformed_value), config}, - new_value <- - merge_group( - ConfigDB.from_string(config.group), - ConfigDB.from_string(config.key), - old_value, - transformed_value - ) do - ConfigDB.update(config, %{value: new_value}) + {_, true, config} <- {:partial_update, can_be_partially_updated?(config), config}, + {_, true, config} <- {:can_be_merged, is_list(params[:value]), config} do + new_value = merge_group(config.group, config.key, config.value, params[:value]) + update(config, %{value: new_value}) else {reason, false, config} when reason in [:partial_update, :can_be_merged] -> - ConfigDB.update(config, params) + update(config, params) nil -> - ConfigDB.create(params) + create(params) end end defp can_be_partially_updated?(%ConfigDB{} = config), do: not only_full_update?(config) - defp only_full_update?(%ConfigDB{} = config) do - config_group = ConfigDB.from_string(config.group) - config_key = ConfigDB.from_string(config.key) + defp only_full_update?(%ConfigDB{group: group, key: key}) do + full_key_update = [ + {:pleroma, :ecto_repos}, + {:quack, :meta}, + {:mime, :types}, + {:cors_plug, [:max_age, :methods, :expose, :headers]}, + {:auto_linker, :opts}, + {:swarm, :node_blacklist}, + {:logger, :backends} + ] - Enum.any?(@full_key_update, fn - {group, key} when is_list(key) -> - config_group == group and config_key in key - - {group, key} -> - config_group == group and config_key == key + Enum.any?(full_key_update, fn + {s_group, s_key} -> + group == s_group and ((is_list(s_key) and key in s_key) or key == s_key) end) end @@ -205,11 +172,10 @@ def delete(params) do with %ConfigDB{} = config <- ConfigDB.get_by_params(search_opts), {config, sub_keys} when is_list(sub_keys) <- {config, params[:subkeys]}, - old_value <- from_binary(config.value), - keys <- Enum.map(sub_keys, &do_transform_string(&1)), - {:partial_remove, config, new_value} when new_value != [] <- - {:partial_remove, config, Keyword.drop(old_value, keys)} do - ConfigDB.update(config, %{value: new_value}) + keys <- Enum.map(sub_keys, &string_to_elixir_types(&1)), + {_, config, new_value} when new_value != [] <- + {:partial_remove, config, Keyword.drop(config.value, keys)} do + update(config, %{value: new_value}) else {:partial_remove, config, []} -> Repo.delete(config) @@ -225,37 +191,32 @@ def delete(params) do end end - @spec from_binary(binary()) :: term() - def from_binary(binary), do: :erlang.binary_to_term(binary) - - @spec from_binary_with_convert(binary()) :: any() - def from_binary_with_convert(binary) do - binary - |> from_binary() - |> do_convert() + @spec to_json_types(term()) :: map() | list() | boolean() | String.t() + def to_json_types(entity) when is_list(entity) do + Enum.map(entity, &to_json_types/1) end - @spec from_string(String.t()) :: atom() | no_return() - def from_string(string), do: do_transform_string(string) + def to_json_types(%Regex{} = entity), do: inspect(entity) - @spec convert(any()) :: any() - def convert(entity), do: do_convert(entity) - - defp do_convert(entity) when is_list(entity) do - for v <- entity, into: [], do: do_convert(v) + def to_json_types(entity) when is_map(entity) do + Map.new(entity, fn {k, v} -> {to_json_types(k), to_json_types(v)} end) end - defp do_convert(%Regex{} = entity), do: inspect(entity) + def to_json_types({:args, args}) when is_list(args) do + arguments = + Enum.map(args, fn + arg when is_tuple(arg) -> inspect(arg) + arg -> to_json_types(arg) + end) - defp do_convert(entity) when is_map(entity) do - for {k, v} <- entity, into: %{}, do: {do_convert(k), do_convert(v)} + %{"tuple" => [":args", arguments]} end - defp do_convert({:proxy_url, {type, :localhost, port}}) do - %{"tuple" => [":proxy_url", %{"tuple" => [do_convert(type), "localhost", port]}]} + def to_json_types({:proxy_url, {type, :localhost, port}}) do + %{"tuple" => [":proxy_url", %{"tuple" => [to_json_types(type), "localhost", port]}]} end - defp do_convert({:proxy_url, {type, host, port}}) when is_tuple(host) do + def to_json_types({:proxy_url, {type, host, port}}) when is_tuple(host) do ip = host |> :inet_parse.ntoa() @@ -264,66 +225,64 @@ defp do_convert({:proxy_url, {type, host, port}}) when is_tuple(host) do %{ "tuple" => [ ":proxy_url", - %{"tuple" => [do_convert(type), ip, port]} + %{"tuple" => [to_json_types(type), ip, port]} ] } end - defp do_convert({:proxy_url, {type, host, port}}) do + def to_json_types({:proxy_url, {type, host, port}}) do %{ "tuple" => [ ":proxy_url", - %{"tuple" => [do_convert(type), to_string(host), port]} + %{"tuple" => [to_json_types(type), to_string(host), port]} ] } end - defp do_convert({:partial_chain, entity}), do: %{"tuple" => [":partial_chain", inspect(entity)]} + def to_json_types({:partial_chain, entity}), + do: %{"tuple" => [":partial_chain", inspect(entity)]} - defp do_convert(entity) when is_tuple(entity) do + def to_json_types(entity) when is_tuple(entity) do value = entity |> Tuple.to_list() - |> do_convert() + |> to_json_types() %{"tuple" => value} end - defp do_convert(entity) when is_boolean(entity) or is_number(entity) or is_nil(entity) do + def to_json_types(entity) when is_binary(entity), do: entity + + def to_json_types(entity) when is_boolean(entity) or is_number(entity) or is_nil(entity) do entity end - defp do_convert(entity) - when is_atom(entity) and entity in [:"tlsv1.1", :"tlsv1.2", :"tlsv1.3"] do + def to_json_types(entity) when entity in [:"tlsv1.1", :"tlsv1.2", :"tlsv1.3"] do ":#{entity}" end - defp do_convert(entity) when is_atom(entity), do: inspect(entity) + def to_json_types(entity) when is_atom(entity), do: inspect(entity) - defp do_convert(entity) when is_binary(entity), do: entity + @spec to_elixir_types(boolean() | String.t() | map() | list()) :: term() + def to_elixir_types(%{"tuple" => [":args", args]}) when is_list(args) do + arguments = + Enum.map(args, fn arg -> + if String.contains?(arg, ["{", "}"]) do + {elem, []} = Code.eval_string(arg) + elem + else + to_elixir_types(arg) + end + end) - @spec transform(any()) :: binary() | no_return() - def transform(entity) when is_binary(entity) or is_map(entity) or is_list(entity) do - entity - |> do_transform() - |> to_binary() + {:args, arguments} end - def transform(entity), do: to_binary(entity) - - @spec transform_with_out_binary(any()) :: any() - def transform_with_out_binary(entity), do: do_transform(entity) - - @spec to_binary(any()) :: binary() - def to_binary(entity), do: :erlang.term_to_binary(entity) - - defp do_transform(%Regex{} = entity), do: entity - - defp do_transform(%{"tuple" => [":proxy_url", %{"tuple" => [type, host, port]}]}) do - {:proxy_url, {do_transform_string(type), parse_host(host), port}} + def to_elixir_types(%{"tuple" => [":proxy_url", %{"tuple" => [type, host, port]}]}) do + {:proxy_url, {string_to_elixir_types(type), parse_host(host), port}} end - defp do_transform(%{"tuple" => [":partial_chain", entity]}) do + def to_elixir_types(%{"tuple" => [":partial_chain", entity]}) do {partial_chain, []} = entity |> String.replace(~r/[^\w|^{:,[|^,|^[|^\]^}|^\/|^\.|^"]^\s/, "") @@ -332,25 +291,51 @@ defp do_transform(%{"tuple" => [":partial_chain", entity]}) do {:partial_chain, partial_chain} end - defp do_transform(%{"tuple" => entity}) do - Enum.reduce(entity, {}, fn val, acc -> Tuple.append(acc, do_transform(val)) end) + def to_elixir_types(%{"tuple" => entity}) do + Enum.reduce(entity, {}, &Tuple.append(&2, to_elixir_types(&1))) end - defp do_transform(entity) when is_map(entity) do - for {k, v} <- entity, into: %{}, do: {do_transform(k), do_transform(v)} + def to_elixir_types(entity) when is_map(entity) do + Map.new(entity, fn {k, v} -> {to_elixir_types(k), to_elixir_types(v)} end) end - defp do_transform(entity) when is_list(entity) do - for v <- entity, into: [], do: do_transform(v) + def to_elixir_types(entity) when is_list(entity) do + Enum.map(entity, &to_elixir_types/1) end - defp do_transform(entity) when is_binary(entity) do + def to_elixir_types(entity) when is_binary(entity) do entity |> String.trim() - |> do_transform_string() + |> string_to_elixir_types() end - defp do_transform(entity), do: entity + def to_elixir_types(entity), do: entity + + @spec string_to_elixir_types(String.t()) :: + atom() | Regex.t() | module() | String.t() | no_return() + def string_to_elixir_types("~r" <> _pattern = regex) do + pattern = + ~r/^~r(?'delimiter'[\/|"'([{<]{1})(?'pattern'.+)[\/|"')\]}>]{1}(?'modifier'[uismxfU]*)/u + + delimiters = ["/", "|", "\"", "'", {"(", ")"}, {"[", "]"}, {"{", "}"}, {"<", ">"}] + + with %{"modifier" => modifier, "pattern" => pattern, "delimiter" => regex_delimiter} <- + Regex.named_captures(pattern, regex), + {:ok, {leading, closing}} <- find_valid_delimiter(delimiters, pattern, regex_delimiter), + {result, _} <- Code.eval_string("~r#{leading}#{pattern}#{closing}#{modifier}") do + result + end + end + + def string_to_elixir_types(":" <> atom), do: String.to_atom(atom) + + def string_to_elixir_types(value) do + if is_module_name?(value) do + String.to_existing_atom("Elixir." <> value) + else + value + end + end defp parse_host("localhost"), do: :localhost @@ -387,25 +372,6 @@ defp find_valid_delimiter([delimiter | others], pattern, regex_delimiter) do end end - defp do_transform_string("~r" <> _pattern = regex) do - with %{"modifier" => modifier, "pattern" => pattern, "delimiter" => regex_delimiter} <- - Regex.named_captures(@regex, regex), - {:ok, {leading, closing}} <- find_valid_delimiter(@delimiters, pattern, regex_delimiter), - {result, _} <- Code.eval_string("~r#{leading}#{pattern}#{closing}#{modifier}") do - result - end - end - - defp do_transform_string(":" <> atom), do: String.to_atom(atom) - - defp do_transform_string(value) do - if is_module_name?(value) do - String.to_existing_atom("Elixir." <> value) - else - value - end - end - @spec is_module_name?(String.t()) :: boolean() def is_module_name?(string) do Regex.match?(~r/^(Pleroma|Phoenix|Tesla|Quack|Ueberauth|Swoosh)\./, string) or diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex index c02b70e96..eb86b8ff4 100644 --- a/lib/pleroma/config/transfer_task.ex +++ b/lib/pleroma/config/transfer_task.ex @@ -28,10 +28,6 @@ defmodule Pleroma.Config.TransferTask do {:pleroma, Pleroma.Captcha, [:seconds_valid]}, {:pleroma, Pleroma.Upload, [:proxy_remote]}, {:pleroma, :instance, [:upload_limit]}, - {:pleroma, :email_notifications, [:digest]}, - {:pleroma, :oauth2, [:clean_expired_tokens]}, - {:pleroma, Pleroma.ActivityExpiration, [:enabled]}, - {:pleroma, Pleroma.ScheduledActivity, [:enabled]}, {:pleroma, :gopher, [:enabled]} ] @@ -48,7 +44,7 @@ def load_and_update_env(deleted_settings \\ [], restart_pleroma? \\ true) do {logger, other} = (Repo.all(ConfigDB) ++ deleted_settings) - |> Enum.map(&transform_and_merge/1) + |> Enum.map(&merge_with_default/1) |> Enum.split_with(fn {group, _, _, _} -> group in [:logger, :quack] end) logger @@ -92,11 +88,7 @@ defp maybe_set_pleroma_last(apps) do end end - defp transform_and_merge(%{group: group, key: key, value: value} = setting) do - group = ConfigDB.from_string(group) - key = ConfigDB.from_string(key) - value = ConfigDB.from_binary(value) - + defp merge_with_default(%{group: group, key: key, value: value} = setting) do default = Config.Holder.default_config(group, key) merged = diff --git a/lib/pleroma/config/type/atom.ex b/lib/pleroma/config/type/atom.ex new file mode 100644 index 000000000..387869284 --- /dev/null +++ b/lib/pleroma/config/type/atom.ex @@ -0,0 +1,22 @@ +defmodule Pleroma.Config.Type.Atom do + use Ecto.Type + + def type, do: :atom + + def cast(key) when is_atom(key) do + {:ok, key} + end + + def cast(key) when is_binary(key) do + {:ok, Pleroma.ConfigDB.string_to_elixir_types(key)} + end + + def cast(_), do: :error + + def load(key) do + {:ok, Pleroma.ConfigDB.string_to_elixir_types(key)} + end + + def dump(key) when is_atom(key), do: {:ok, inspect(key)} + def dump(_), do: :error +end diff --git a/lib/pleroma/config/type/binary_value.ex b/lib/pleroma/config/type/binary_value.ex new file mode 100644 index 000000000..17c5524a3 --- /dev/null +++ b/lib/pleroma/config/type/binary_value.ex @@ -0,0 +1,23 @@ +defmodule Pleroma.Config.Type.BinaryValue do + use Ecto.Type + + def type, do: :term + + def cast(value) when is_binary(value) do + if String.valid?(value) do + {:ok, value} + else + {:ok, :erlang.binary_to_term(value)} + end + end + + def cast(value), do: {:ok, value} + + def load(value) when is_binary(value) do + {:ok, :erlang.binary_to_term(value)} + end + + def dump(value) do + {:ok, :erlang.term_to_binary(value)} + end +end diff --git a/lib/pleroma/web/admin_api/controllers/config_controller.ex b/lib/pleroma/web/admin_api/controllers/config_controller.ex index d6e2019bc..7f60470cb 100644 --- a/lib/pleroma/web/admin_api/controllers/config_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/config_controller.ex @@ -33,7 +33,11 @@ def descriptions(conn, _params) do def show(conn, %{only_db: true}) do with :ok <- configurable_from_database() do configs = Pleroma.Repo.all(ConfigDB) - render(conn, "index.json", %{configs: configs}) + + render(conn, "index.json", %{ + configs: configs, + need_reboot: Restarter.Pleroma.need_reboot?() + }) end end @@ -61,17 +65,20 @@ def show(conn, _params) do value end - %{ - group: ConfigDB.convert(group), - key: ConfigDB.convert(key), - value: ConfigDB.convert(merged_value) + %ConfigDB{ + group: group, + key: key, + value: merged_value } |> Pleroma.Maps.put_if_present(:db, db) end) end) |> List.flatten() - json(conn, %{configs: merged, need_reboot: Restarter.Pleroma.need_reboot?()}) + render(conn, "index.json", %{ + configs: merged, + need_reboot: Restarter.Pleroma.need_reboot?() + }) end end @@ -91,24 +98,17 @@ def update(%{body_params: %{configs: configs}} = conn, _) do {deleted, updated} = results - |> Enum.map(fn {:ok, config} -> - Map.put(config, :db, ConfigDB.get_db_keys(config)) - end) - |> Enum.split_with(fn config -> - Ecto.get_meta(config, :state) == :deleted + |> Enum.map(fn {:ok, %{key: key, value: value} = config} -> + Map.put(config, :db, ConfigDB.get_db_keys(value, key)) end) + |> Enum.split_with(&(Ecto.get_meta(&1, :state) == :deleted)) Config.TransferTask.load_and_update_env(deleted, false) if not Restarter.Pleroma.need_reboot?() do changed_reboot_settings? = (updated ++ deleted) - |> Enum.any?(fn config -> - group = ConfigDB.from_string(config.group) - key = ConfigDB.from_string(config.key) - value = ConfigDB.from_binary(config.value) - Config.TransferTask.pleroma_need_restart?(group, key, value) - end) + |> Enum.any?(&Config.TransferTask.pleroma_need_restart?(&1.group, &1.key, &1.value)) if changed_reboot_settings?, do: Restarter.Pleroma.need_reboot() end diff --git a/lib/pleroma/web/admin_api/views/config_view.ex b/lib/pleroma/web/admin_api/views/config_view.ex index 587ef760e..d2d8b5907 100644 --- a/lib/pleroma/web/admin_api/views/config_view.ex +++ b/lib/pleroma/web/admin_api/views/config_view.ex @@ -5,23 +5,20 @@ defmodule Pleroma.Web.AdminAPI.ConfigView do use Pleroma.Web, :view - def render("index.json", %{configs: configs} = params) do - map = %{ - configs: render_many(configs, __MODULE__, "show.json", as: :config) - } + alias Pleroma.ConfigDB - if params[:need_reboot] do - Map.put(map, :need_reboot, true) - else - map - end + def render("index.json", %{configs: configs} = params) do + %{ + configs: render_many(configs, __MODULE__, "show.json", as: :config), + need_reboot: params[:need_reboot] + } end def render("show.json", %{config: config}) do map = %{ - key: config.key, - group: config.group, - value: Pleroma.ConfigDB.from_binary_with_convert(config.value) + key: ConfigDB.to_json_types(config.key), + group: ConfigDB.to_json_types(config.group), + value: ConfigDB.to_json_types(config.value) } if config.db != [] do diff --git a/test/config/config_db_test.exs b/test/config/config_db_test.exs index 336de7359..a04575c6f 100644 --- a/test/config/config_db_test.exs +++ b/test/config/config_db_test.exs @@ -7,40 +7,28 @@ defmodule Pleroma.ConfigDBTest do import Pleroma.Factory alias Pleroma.ConfigDB - test "get_by_key/1" do + test "get_by_params/1" do config = insert(:config) insert(:config) assert config == ConfigDB.get_by_params(%{group: config.group, key: config.key}) end - test "create/1" do - {:ok, config} = ConfigDB.create(%{group: ":pleroma", key: ":some_key", value: "some_value"}) - assert config == ConfigDB.get_by_params(%{group: ":pleroma", key: ":some_key"}) - end - - test "update/1" do - config = insert(:config) - {:ok, updated} = ConfigDB.update(config, %{value: "some_value"}) - loaded = ConfigDB.get_by_params(%{group: config.group, key: config.key}) - assert loaded == updated - end - test "get_all_as_keyword/0" do saved = insert(:config) - insert(:config, group: ":quack", key: ":level", value: ConfigDB.to_binary(:info)) - insert(:config, group: ":quack", key: ":meta", value: ConfigDB.to_binary([:none])) + insert(:config, group: ":quack", key: ":level", value: :info) + insert(:config, group: ":quack", key: ":meta", value: [:none]) insert(:config, group: ":quack", key: ":webhook_url", - value: ConfigDB.to_binary("https://hooks.slack.com/services/KEY/some_val") + value: "https://hooks.slack.com/services/KEY/some_val" ) config = ConfigDB.get_all_as_keyword() assert config[:pleroma] == [ - {ConfigDB.from_string(saved.key), ConfigDB.from_binary(saved.value)} + {saved.key, saved.value} ] assert config[:quack][:level] == :info @@ -51,11 +39,11 @@ test "get_all_as_keyword/0" do describe "update_or_create/1" do test "common" do config = insert(:config) - key2 = "another_key" + key2 = :another_key params = [ - %{group: "pleroma", key: key2, value: "another_value"}, - %{group: config.group, key: config.key, value: "new_value"} + %{group: :pleroma, key: key2, value: "another_value"}, + %{group: :pleroma, key: config.key, value: "new_value"} ] assert Repo.all(ConfigDB) |> length() == 1 @@ -65,16 +53,16 @@ test "common" do assert Repo.all(ConfigDB) |> length() == 2 config1 = ConfigDB.get_by_params(%{group: config.group, key: config.key}) - config2 = ConfigDB.get_by_params(%{group: "pleroma", key: key2}) + config2 = ConfigDB.get_by_params(%{group: :pleroma, key: key2}) - assert config1.value == ConfigDB.transform("new_value") - assert config2.value == ConfigDB.transform("another_value") + assert config1.value == "new_value" + assert config2.value == "another_value" end test "partial update" do - config = insert(:config, value: ConfigDB.to_binary(key1: "val1", key2: :val2)) + config = insert(:config, value: [key1: "val1", key2: :val2]) - {:ok, _config} = + {:ok, config} = ConfigDB.update_or_create(%{ group: config.group, key: config.key, @@ -83,15 +71,14 @@ test "partial update" do updated = ConfigDB.get_by_params(%{group: config.group, key: config.key}) - value = ConfigDB.from_binary(updated.value) - assert length(value) == 3 - assert value[:key1] == :val1 - assert value[:key2] == :val2 - assert value[:key3] == :val3 + assert config.value == updated.value + assert updated.value[:key1] == :val1 + assert updated.value[:key2] == :val2 + assert updated.value[:key3] == :val3 end test "deep merge" do - config = insert(:config, value: ConfigDB.to_binary(key1: "val1", key2: [k1: :v1, k2: "v2"])) + config = insert(:config, value: [key1: "val1", key2: [k1: :v1, k2: "v2"]]) {:ok, config} = ConfigDB.update_or_create(%{ @@ -103,18 +90,15 @@ test "deep merge" do updated = ConfigDB.get_by_params(%{group: config.group, key: config.key}) assert config.value == updated.value - - value = ConfigDB.from_binary(updated.value) - assert value[:key1] == :val1 - assert value[:key2] == [k1: :v1, k2: :v2, k3: :v3] - assert value[:key3] == :val3 + assert updated.value[:key1] == :val1 + assert updated.value[:key2] == [k1: :v1, k2: :v2, k3: :v3] + assert updated.value[:key3] == :val3 end test "only full update for some keys" do - config1 = insert(:config, key: ":ecto_repos", value: ConfigDB.to_binary(repo: Pleroma.Repo)) + config1 = insert(:config, key: :ecto_repos, value: [repo: Pleroma.Repo]) - config2 = - insert(:config, group: ":cors_plug", key: ":max_age", value: ConfigDB.to_binary(18)) + config2 = insert(:config, group: :cors_plug, key: :max_age, value: 18) {:ok, _config} = ConfigDB.update_or_create(%{ @@ -133,8 +117,8 @@ test "only full update for some keys" do updated1 = ConfigDB.get_by_params(%{group: config1.group, key: config1.key}) updated2 = ConfigDB.get_by_params(%{group: config2.group, key: config2.key}) - assert ConfigDB.from_binary(updated1.value) == [another_repo: [Pleroma.Repo]] - assert ConfigDB.from_binary(updated2.value) == 777 + assert updated1.value == [another_repo: [Pleroma.Repo]] + assert updated2.value == 777 end test "full update if value is not keyword" do @@ -142,7 +126,7 @@ test "full update if value is not keyword" do insert(:config, group: ":tesla", key: ":adapter", - value: ConfigDB.to_binary(Tesla.Adapter.Hackney) + value: Tesla.Adapter.Hackney ) {:ok, _config} = @@ -154,20 +138,20 @@ test "full update if value is not keyword" do updated = ConfigDB.get_by_params(%{group: config.group, key: config.key}) - assert ConfigDB.from_binary(updated.value) == Tesla.Adapter.Httpc + assert updated.value == Tesla.Adapter.Httpc end test "only full update for some subkeys" do config1 = insert(:config, key: ":emoji", - value: ConfigDB.to_binary(groups: [a: 1, b: 2], key: [a: 1]) + value: [groups: [a: 1, b: 2], key: [a: 1]] ) config2 = insert(:config, key: ":assets", - value: ConfigDB.to_binary(mascots: [a: 1, b: 2], key: [a: 1]) + value: [mascots: [a: 1, b: 2], key: [a: 1]] ) {:ok, _config} = @@ -187,8 +171,8 @@ test "only full update for some subkeys" do updated1 = ConfigDB.get_by_params(%{group: config1.group, key: config1.key}) updated2 = ConfigDB.get_by_params(%{group: config2.group, key: config2.key}) - assert ConfigDB.from_binary(updated1.value) == [groups: [c: 3, d: 4], key: [a: 1, b: 2]] - assert ConfigDB.from_binary(updated2.value) == [mascots: [c: 3, d: 4], key: [a: 1, b: 2]] + assert updated1.value == [groups: [c: 3, d: 4], key: [a: 1, b: 2]] + assert updated2.value == [mascots: [c: 3, d: 4], key: [a: 1, b: 2]] end end @@ -206,14 +190,14 @@ test "full delete" do end test "partial subkeys delete" do - config = insert(:config, value: ConfigDB.to_binary(groups: [a: 1, b: 2], key: [a: 1])) + config = insert(:config, value: [groups: [a: 1, b: 2], key: [a: 1]]) {:ok, deleted} = ConfigDB.delete(%{group: config.group, key: config.key, subkeys: [":groups"]}) assert Ecto.get_meta(deleted, :state) == :loaded - assert deleted.value == ConfigDB.to_binary(key: [a: 1]) + assert deleted.value == [key: [a: 1]] updated = ConfigDB.get_by_params(%{group: config.group, key: config.key}) @@ -221,7 +205,7 @@ test "partial subkeys delete" do end test "full delete if remaining value after subkeys deletion is empty list" do - config = insert(:config, value: ConfigDB.to_binary(groups: [a: 1, b: 2])) + config = insert(:config, value: [groups: [a: 1, b: 2]]) {:ok, deleted} = ConfigDB.delete(%{group: config.group, key: config.key, subkeys: [":groups"]}) @@ -232,234 +216,159 @@ test "full delete if remaining value after subkeys deletion is empty list" do end end - describe "transform/1" do + describe "to_elixir_types/1" do test "string" do - binary = ConfigDB.transform("value as string") - assert binary == :erlang.term_to_binary("value as string") - assert ConfigDB.from_binary(binary) == "value as string" + assert ConfigDB.to_elixir_types("value as string") == "value as string" end test "boolean" do - binary = ConfigDB.transform(false) - assert binary == :erlang.term_to_binary(false) - assert ConfigDB.from_binary(binary) == false + assert ConfigDB.to_elixir_types(false) == false end test "nil" do - binary = ConfigDB.transform(nil) - assert binary == :erlang.term_to_binary(nil) - assert ConfigDB.from_binary(binary) == nil + assert ConfigDB.to_elixir_types(nil) == nil end test "integer" do - binary = ConfigDB.transform(150) - assert binary == :erlang.term_to_binary(150) - assert ConfigDB.from_binary(binary) == 150 + assert ConfigDB.to_elixir_types(150) == 150 end test "atom" do - binary = ConfigDB.transform(":atom") - assert binary == :erlang.term_to_binary(:atom) - assert ConfigDB.from_binary(binary) == :atom + assert ConfigDB.to_elixir_types(":atom") == :atom end test "ssl options" do - binary = ConfigDB.transform([":tlsv1", ":tlsv1.1", ":tlsv1.2"]) - assert binary == :erlang.term_to_binary([:tlsv1, :"tlsv1.1", :"tlsv1.2"]) - assert ConfigDB.from_binary(binary) == [:tlsv1, :"tlsv1.1", :"tlsv1.2"] + assert ConfigDB.to_elixir_types([":tlsv1", ":tlsv1.1", ":tlsv1.2"]) == [ + :tlsv1, + :"tlsv1.1", + :"tlsv1.2" + ] end test "pleroma module" do - binary = ConfigDB.transform("Pleroma.Bookmark") - assert binary == :erlang.term_to_binary(Pleroma.Bookmark) - assert ConfigDB.from_binary(binary) == Pleroma.Bookmark + assert ConfigDB.to_elixir_types("Pleroma.Bookmark") == Pleroma.Bookmark end test "pleroma string" do - binary = ConfigDB.transform("Pleroma") - assert binary == :erlang.term_to_binary("Pleroma") - assert ConfigDB.from_binary(binary) == "Pleroma" + assert ConfigDB.to_elixir_types("Pleroma") == "Pleroma" end test "phoenix module" do - binary = ConfigDB.transform("Phoenix.Socket.V1.JSONSerializer") - assert binary == :erlang.term_to_binary(Phoenix.Socket.V1.JSONSerializer) - assert ConfigDB.from_binary(binary) == Phoenix.Socket.V1.JSONSerializer + assert ConfigDB.to_elixir_types("Phoenix.Socket.V1.JSONSerializer") == + Phoenix.Socket.V1.JSONSerializer end test "tesla module" do - binary = ConfigDB.transform("Tesla.Adapter.Hackney") - assert binary == :erlang.term_to_binary(Tesla.Adapter.Hackney) - assert ConfigDB.from_binary(binary) == Tesla.Adapter.Hackney + assert ConfigDB.to_elixir_types("Tesla.Adapter.Hackney") == Tesla.Adapter.Hackney end test "ExSyslogger module" do - binary = ConfigDB.transform("ExSyslogger") - assert binary == :erlang.term_to_binary(ExSyslogger) - assert ConfigDB.from_binary(binary) == ExSyslogger + assert ConfigDB.to_elixir_types("ExSyslogger") == ExSyslogger end test "Quack.Logger module" do - binary = ConfigDB.transform("Quack.Logger") - assert binary == :erlang.term_to_binary(Quack.Logger) - assert ConfigDB.from_binary(binary) == Quack.Logger + assert ConfigDB.to_elixir_types("Quack.Logger") == Quack.Logger end test "Swoosh.Adapters modules" do - binary = ConfigDB.transform("Swoosh.Adapters.SMTP") - assert binary == :erlang.term_to_binary(Swoosh.Adapters.SMTP) - assert ConfigDB.from_binary(binary) == Swoosh.Adapters.SMTP - binary = ConfigDB.transform("Swoosh.Adapters.AmazonSES") - assert binary == :erlang.term_to_binary(Swoosh.Adapters.AmazonSES) - assert ConfigDB.from_binary(binary) == Swoosh.Adapters.AmazonSES + assert ConfigDB.to_elixir_types("Swoosh.Adapters.SMTP") == Swoosh.Adapters.SMTP + assert ConfigDB.to_elixir_types("Swoosh.Adapters.AmazonSES") == Swoosh.Adapters.AmazonSES end test "sigil" do - binary = ConfigDB.transform("~r[comp[lL][aA][iI][nN]er]") - assert binary == :erlang.term_to_binary(~r/comp[lL][aA][iI][nN]er/) - assert ConfigDB.from_binary(binary) == ~r/comp[lL][aA][iI][nN]er/ + assert ConfigDB.to_elixir_types("~r[comp[lL][aA][iI][nN]er]") == ~r/comp[lL][aA][iI][nN]er/ end test "link sigil" do - binary = ConfigDB.transform("~r/https:\/\/example.com/") - assert binary == :erlang.term_to_binary(~r/https:\/\/example.com/) - assert ConfigDB.from_binary(binary) == ~r/https:\/\/example.com/ + assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/") == ~r/https:\/\/example.com/ end test "link sigil with um modifiers" do - binary = ConfigDB.transform("~r/https:\/\/example.com/um") - assert binary == :erlang.term_to_binary(~r/https:\/\/example.com/um) - assert ConfigDB.from_binary(binary) == ~r/https:\/\/example.com/um + assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/um") == + ~r/https:\/\/example.com/um end test "link sigil with i modifier" do - binary = ConfigDB.transform("~r/https:\/\/example.com/i") - assert binary == :erlang.term_to_binary(~r/https:\/\/example.com/i) - assert ConfigDB.from_binary(binary) == ~r/https:\/\/example.com/i + assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/i") == ~r/https:\/\/example.com/i end test "link sigil with s modifier" do - binary = ConfigDB.transform("~r/https:\/\/example.com/s") - assert binary == :erlang.term_to_binary(~r/https:\/\/example.com/s) - assert ConfigDB.from_binary(binary) == ~r/https:\/\/example.com/s + assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/s") == ~r/https:\/\/example.com/s end test "raise if valid delimiter not found" do assert_raise ArgumentError, "valid delimiter for Regex expression not found", fn -> - ConfigDB.transform("~r/https://[]{}<>\"'()|example.com/s") + ConfigDB.to_elixir_types("~r/https://[]{}<>\"'()|example.com/s") end end test "2 child tuple" do - binary = ConfigDB.transform(%{"tuple" => ["v1", ":v2"]}) - assert binary == :erlang.term_to_binary({"v1", :v2}) - assert ConfigDB.from_binary(binary) == {"v1", :v2} + assert ConfigDB.to_elixir_types(%{"tuple" => ["v1", ":v2"]}) == {"v1", :v2} end test "proxy tuple with localhost" do - binary = - ConfigDB.transform(%{ - "tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}] - }) - - assert binary == :erlang.term_to_binary({:proxy_url, {:socks5, :localhost, 1234}}) - assert ConfigDB.from_binary(binary) == {:proxy_url, {:socks5, :localhost, 1234}} + assert ConfigDB.to_elixir_types(%{ + "tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}] + }) == {:proxy_url, {:socks5, :localhost, 1234}} end test "proxy tuple with domain" do - binary = - ConfigDB.transform(%{ - "tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}] - }) - - assert binary == :erlang.term_to_binary({:proxy_url, {:socks5, 'domain.com', 1234}}) - assert ConfigDB.from_binary(binary) == {:proxy_url, {:socks5, 'domain.com', 1234}} + assert ConfigDB.to_elixir_types(%{ + "tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}] + }) == {:proxy_url, {:socks5, 'domain.com', 1234}} end test "proxy tuple with ip" do - binary = - ConfigDB.transform(%{ - "tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}] - }) - - assert binary == :erlang.term_to_binary({:proxy_url, {:socks5, {127, 0, 0, 1}, 1234}}) - assert ConfigDB.from_binary(binary) == {:proxy_url, {:socks5, {127, 0, 0, 1}, 1234}} + assert ConfigDB.to_elixir_types(%{ + "tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}] + }) == {:proxy_url, {:socks5, {127, 0, 0, 1}, 1234}} end test "tuple with n childs" do - binary = - ConfigDB.transform(%{ - "tuple" => [ - "v1", - ":v2", - "Pleroma.Bookmark", - 150, - false, - "Phoenix.Socket.V1.JSONSerializer" - ] - }) - - assert binary == - :erlang.term_to_binary( - {"v1", :v2, Pleroma.Bookmark, 150, false, Phoenix.Socket.V1.JSONSerializer} - ) - - assert ConfigDB.from_binary(binary) == - {"v1", :v2, Pleroma.Bookmark, 150, false, Phoenix.Socket.V1.JSONSerializer} + assert ConfigDB.to_elixir_types(%{ + "tuple" => [ + "v1", + ":v2", + "Pleroma.Bookmark", + 150, + false, + "Phoenix.Socket.V1.JSONSerializer" + ] + }) == {"v1", :v2, Pleroma.Bookmark, 150, false, Phoenix.Socket.V1.JSONSerializer} end test "map with string key" do - binary = ConfigDB.transform(%{"key" => "value"}) - assert binary == :erlang.term_to_binary(%{"key" => "value"}) - assert ConfigDB.from_binary(binary) == %{"key" => "value"} + assert ConfigDB.to_elixir_types(%{"key" => "value"}) == %{"key" => "value"} end test "map with atom key" do - binary = ConfigDB.transform(%{":key" => "value"}) - assert binary == :erlang.term_to_binary(%{key: "value"}) - assert ConfigDB.from_binary(binary) == %{key: "value"} + assert ConfigDB.to_elixir_types(%{":key" => "value"}) == %{key: "value"} end test "list of strings" do - binary = ConfigDB.transform(["v1", "v2", "v3"]) - assert binary == :erlang.term_to_binary(["v1", "v2", "v3"]) - assert ConfigDB.from_binary(binary) == ["v1", "v2", "v3"] + assert ConfigDB.to_elixir_types(["v1", "v2", "v3"]) == ["v1", "v2", "v3"] end test "list of modules" do - binary = ConfigDB.transform(["Pleroma.Repo", "Pleroma.Activity"]) - assert binary == :erlang.term_to_binary([Pleroma.Repo, Pleroma.Activity]) - assert ConfigDB.from_binary(binary) == [Pleroma.Repo, Pleroma.Activity] + assert ConfigDB.to_elixir_types(["Pleroma.Repo", "Pleroma.Activity"]) == [ + Pleroma.Repo, + Pleroma.Activity + ] end test "list of atoms" do - binary = ConfigDB.transform([":v1", ":v2", ":v3"]) - assert binary == :erlang.term_to_binary([:v1, :v2, :v3]) - assert ConfigDB.from_binary(binary) == [:v1, :v2, :v3] + assert ConfigDB.to_elixir_types([":v1", ":v2", ":v3"]) == [:v1, :v2, :v3] end test "list of mixed values" do - binary = - ConfigDB.transform([ - "v1", - ":v2", - "Pleroma.Repo", - "Phoenix.Socket.V1.JSONSerializer", - 15, - false - ]) - - assert binary == - :erlang.term_to_binary([ - "v1", - :v2, - Pleroma.Repo, - Phoenix.Socket.V1.JSONSerializer, - 15, - false - ]) - - assert ConfigDB.from_binary(binary) == [ + assert ConfigDB.to_elixir_types([ + "v1", + ":v2", + "Pleroma.Repo", + "Phoenix.Socket.V1.JSONSerializer", + 15, + false + ]) == [ "v1", :v2, Pleroma.Repo, @@ -470,40 +379,23 @@ test "list of mixed values" do end test "simple keyword" do - binary = ConfigDB.transform([%{"tuple" => [":key", "value"]}]) - assert binary == :erlang.term_to_binary([{:key, "value"}]) - assert ConfigDB.from_binary(binary) == [{:key, "value"}] - assert ConfigDB.from_binary(binary) == [key: "value"] + assert ConfigDB.to_elixir_types([%{"tuple" => [":key", "value"]}]) == [key: "value"] end test "keyword with partial_chain key" do - binary = - ConfigDB.transform([%{"tuple" => [":partial_chain", "&:hackney_connect.partial_chain/1"]}]) - - assert binary == :erlang.term_to_binary(partial_chain: &:hackney_connect.partial_chain/1) - assert ConfigDB.from_binary(binary) == [partial_chain: &:hackney_connect.partial_chain/1] + assert ConfigDB.to_elixir_types([ + %{"tuple" => [":partial_chain", "&:hackney_connect.partial_chain/1"]} + ]) == [partial_chain: &:hackney_connect.partial_chain/1] end test "keyword" do - binary = - ConfigDB.transform([ - %{"tuple" => [":types", "Pleroma.PostgresTypes"]}, - %{"tuple" => [":telemetry_event", ["Pleroma.Repo.Instrumenter"]]}, - %{"tuple" => [":migration_lock", nil]}, - %{"tuple" => [":key1", 150]}, - %{"tuple" => [":key2", "string"]} - ]) - - assert binary == - :erlang.term_to_binary( - types: Pleroma.PostgresTypes, - telemetry_event: [Pleroma.Repo.Instrumenter], - migration_lock: nil, - key1: 150, - key2: "string" - ) - - assert ConfigDB.from_binary(binary) == [ + assert ConfigDB.to_elixir_types([ + %{"tuple" => [":types", "Pleroma.PostgresTypes"]}, + %{"tuple" => [":telemetry_event", ["Pleroma.Repo.Instrumenter"]]}, + %{"tuple" => [":migration_lock", nil]}, + %{"tuple" => [":key1", 150]}, + %{"tuple" => [":key2", "string"]} + ]) == [ types: Pleroma.PostgresTypes, telemetry_event: [Pleroma.Repo.Instrumenter], migration_lock: nil, @@ -513,85 +405,55 @@ test "keyword" do end test "complex keyword with nested mixed childs" do - binary = - ConfigDB.transform([ - %{"tuple" => [":uploader", "Pleroma.Uploaders.Local"]}, - %{"tuple" => [":filters", ["Pleroma.Upload.Filter.Dedupe"]]}, - %{"tuple" => [":link_name", true]}, - %{"tuple" => [":proxy_remote", false]}, - %{"tuple" => [":common_map", %{":key" => "value"}]}, - %{ - "tuple" => [ - ":proxy_opts", - [ - %{"tuple" => [":redirect_on_failure", false]}, - %{"tuple" => [":max_body_length", 1_048_576]}, - %{ - "tuple" => [ - ":http", - [%{"tuple" => [":follow_redirect", true]}, %{"tuple" => [":pool", ":upload"]}] - ] - } - ] - ] - } - ]) - - assert binary == - :erlang.term_to_binary( - uploader: Pleroma.Uploaders.Local, - filters: [Pleroma.Upload.Filter.Dedupe], - link_name: true, - proxy_remote: false, - common_map: %{key: "value"}, - proxy_opts: [ - redirect_on_failure: false, - max_body_length: 1_048_576, - http: [ - follow_redirect: true, - pool: :upload + assert ConfigDB.to_elixir_types([ + %{"tuple" => [":uploader", "Pleroma.Uploaders.Local"]}, + %{"tuple" => [":filters", ["Pleroma.Upload.Filter.Dedupe"]]}, + %{"tuple" => [":link_name", true]}, + %{"tuple" => [":proxy_remote", false]}, + %{"tuple" => [":common_map", %{":key" => "value"}]}, + %{ + "tuple" => [ + ":proxy_opts", + [ + %{"tuple" => [":redirect_on_failure", false]}, + %{"tuple" => [":max_body_length", 1_048_576]}, + %{ + "tuple" => [ + ":http", + [ + %{"tuple" => [":follow_redirect", true]}, + %{"tuple" => [":pool", ":upload"]} + ] + ] + } ] ] - ) - - assert ConfigDB.from_binary(binary) == - [ - uploader: Pleroma.Uploaders.Local, - filters: [Pleroma.Upload.Filter.Dedupe], - link_name: true, - proxy_remote: false, - common_map: %{key: "value"}, - proxy_opts: [ - redirect_on_failure: false, - max_body_length: 1_048_576, - http: [ - follow_redirect: true, - pool: :upload - ] + } + ]) == [ + uploader: Pleroma.Uploaders.Local, + filters: [Pleroma.Upload.Filter.Dedupe], + link_name: true, + proxy_remote: false, + common_map: %{key: "value"}, + proxy_opts: [ + redirect_on_failure: false, + max_body_length: 1_048_576, + http: [ + follow_redirect: true, + pool: :upload ] ] + ] end test "common keyword" do - binary = - ConfigDB.transform([ - %{"tuple" => [":level", ":warn"]}, - %{"tuple" => [":meta", [":all"]]}, - %{"tuple" => [":path", ""]}, - %{"tuple" => [":val", nil]}, - %{"tuple" => [":webhook_url", "https://hooks.slack.com/services/YOUR-KEY-HERE"]} - ]) - - assert binary == - :erlang.term_to_binary( - level: :warn, - meta: [:all], - path: "", - val: nil, - webhook_url: "https://hooks.slack.com/services/YOUR-KEY-HERE" - ) - - assert ConfigDB.from_binary(binary) == [ + assert ConfigDB.to_elixir_types([ + %{"tuple" => [":level", ":warn"]}, + %{"tuple" => [":meta", [":all"]]}, + %{"tuple" => [":path", ""]}, + %{"tuple" => [":val", nil]}, + %{"tuple" => [":webhook_url", "https://hooks.slack.com/services/YOUR-KEY-HERE"]} + ]) == [ level: :warn, meta: [:all], path: "", @@ -601,98 +463,73 @@ test "common keyword" do end test "complex keyword with sigil" do - binary = - ConfigDB.transform([ - %{"tuple" => [":federated_timeline_removal", []]}, - %{"tuple" => [":reject", ["~r/comp[lL][aA][iI][nN]er/"]]}, - %{"tuple" => [":replace", []]} - ]) - - assert binary == - :erlang.term_to_binary( - federated_timeline_removal: [], - reject: [~r/comp[lL][aA][iI][nN]er/], - replace: [] - ) - - assert ConfigDB.from_binary(binary) == - [federated_timeline_removal: [], reject: [~r/comp[lL][aA][iI][nN]er/], replace: []] + assert ConfigDB.to_elixir_types([ + %{"tuple" => [":federated_timeline_removal", []]}, + %{"tuple" => [":reject", ["~r/comp[lL][aA][iI][nN]er/"]]}, + %{"tuple" => [":replace", []]} + ]) == [ + federated_timeline_removal: [], + reject: [~r/comp[lL][aA][iI][nN]er/], + replace: [] + ] end test "complex keyword with tuples with more than 2 values" do - binary = - ConfigDB.transform([ - %{ - "tuple" => [ - ":http", - [ - %{ - "tuple" => [ - ":key1", - [ - %{ - "tuple" => [ - ":_", - [ - %{ - "tuple" => [ - "/api/v1/streaming", - "Pleroma.Web.MastodonAPI.WebsocketHandler", - [] - ] - }, - %{ - "tuple" => [ - "/websocket", - "Phoenix.Endpoint.CowboyWebSocket", - %{ - "tuple" => [ - "Phoenix.Transports.WebSocket", - %{ - "tuple" => [ - "Pleroma.Web.Endpoint", - "Pleroma.Web.UserSocket", - [] - ] - } - ] - } - ] - }, - %{ - "tuple" => [ - ":_", - "Phoenix.Endpoint.Cowboy2Handler", - %{"tuple" => ["Pleroma.Web.Endpoint", []]} - ] - } - ] - ] - } - ] - ] - } - ] - ] - } - ]) - - assert binary == - :erlang.term_to_binary( - http: [ - key1: [ - _: [ - {"/api/v1/streaming", Pleroma.Web.MastodonAPI.WebsocketHandler, []}, - {"/websocket", Phoenix.Endpoint.CowboyWebSocket, - {Phoenix.Transports.WebSocket, - {Pleroma.Web.Endpoint, Pleroma.Web.UserSocket, []}}}, - {:_, Phoenix.Endpoint.Cowboy2Handler, {Pleroma.Web.Endpoint, []}} - ] + assert ConfigDB.to_elixir_types([ + %{ + "tuple" => [ + ":http", + [ + %{ + "tuple" => [ + ":key1", + [ + %{ + "tuple" => [ + ":_", + [ + %{ + "tuple" => [ + "/api/v1/streaming", + "Pleroma.Web.MastodonAPI.WebsocketHandler", + [] + ] + }, + %{ + "tuple" => [ + "/websocket", + "Phoenix.Endpoint.CowboyWebSocket", + %{ + "tuple" => [ + "Phoenix.Transports.WebSocket", + %{ + "tuple" => [ + "Pleroma.Web.Endpoint", + "Pleroma.Web.UserSocket", + [] + ] + } + ] + } + ] + }, + %{ + "tuple" => [ + ":_", + "Phoenix.Endpoint.Cowboy2Handler", + %{"tuple" => ["Pleroma.Web.Endpoint", []]} + ] + } + ] + ] + } + ] + ] + } ] ] - ) - - assert ConfigDB.from_binary(binary) == [ + } + ]) == [ http: [ key1: [ {:_, diff --git a/test/config/transfer_task_test.exs b/test/config/transfer_task_test.exs index 473899d1d..f53829e09 100644 --- a/test/config/transfer_task_test.exs +++ b/test/config/transfer_task_test.exs @@ -6,9 +6,9 @@ defmodule Pleroma.Config.TransferTaskTest do use Pleroma.DataCase import ExUnit.CaptureLog + import Pleroma.Factory alias Pleroma.Config.TransferTask - alias Pleroma.ConfigDB setup do: clear_config(:configurable_from_database, true) @@ -19,31 +19,11 @@ test "transfer config values from db to env" do refute Application.get_env(:postgrex, :test_key) initial = Application.get_env(:logger, :level) - ConfigDB.create(%{ - group: ":pleroma", - key: ":test_key", - value: [live: 2, com: 3] - }) - - ConfigDB.create(%{ - group: ":idna", - key: ":test_key", - value: [live: 15, com: 35] - }) - - ConfigDB.create(%{ - group: ":quack", - key: ":test_key", - value: [:test_value1, :test_value2] - }) - - ConfigDB.create(%{ - group: ":postgrex", - key: ":test_key", - value: :value - }) - - ConfigDB.create(%{group: ":logger", key: ":level", value: :debug}) + insert(:config, key: :test_key, value: [live: 2, com: 3]) + insert(:config, group: :idna, key: :test_key, value: [live: 15, com: 35]) + insert(:config, group: :quack, key: :test_key, value: [:test_value1, :test_value2]) + insert(:config, group: :postgrex, key: :test_key, value: :value) + insert(:config, group: :logger, key: :level, value: :debug) TransferTask.start_link([]) @@ -66,17 +46,8 @@ test "transfer config values for 1 group and some keys" do level = Application.get_env(:quack, :level) meta = Application.get_env(:quack, :meta) - ConfigDB.create(%{ - group: ":quack", - key: ":level", - value: :info - }) - - ConfigDB.create(%{ - group: ":quack", - key: ":meta", - value: [:none] - }) + insert(:config, group: :quack, key: :level, value: :info) + insert(:config, group: :quack, key: :meta, value: [:none]) TransferTask.start_link([]) @@ -95,17 +66,8 @@ test "transfer config values with full subkey update" do clear_config(:emoji) clear_config(:assets) - ConfigDB.create(%{ - group: ":pleroma", - key: ":emoji", - value: [groups: [a: 1, b: 2]] - }) - - ConfigDB.create(%{ - group: ":pleroma", - key: ":assets", - value: [mascots: [a: 1, b: 2]] - }) + insert(:config, key: :emoji, value: [groups: [a: 1, b: 2]]) + insert(:config, key: :assets, value: [mascots: [a: 1, b: 2]]) TransferTask.start_link([]) @@ -122,12 +84,7 @@ test "transfer config values with full subkey update" do test "don't restart if no reboot time settings were changed" do clear_config(:emoji) - - ConfigDB.create(%{ - group: ":pleroma", - key: ":emoji", - value: [groups: [a: 1, b: 2]] - }) + insert(:config, key: :emoji, value: [groups: [a: 1, b: 2]]) refute String.contains?( capture_log(fn -> TransferTask.start_link([]) end), @@ -137,25 +94,13 @@ test "don't restart if no reboot time settings were changed" do test "on reboot time key" do clear_config(:chat) - - ConfigDB.create(%{ - group: ":pleroma", - key: ":chat", - value: [enabled: false] - }) - + insert(:config, key: :chat, value: [enabled: false]) assert capture_log(fn -> TransferTask.start_link([]) end) =~ "pleroma restarted" end test "on reboot time subkey" do clear_config(Pleroma.Captcha) - - ConfigDB.create(%{ - group: ":pleroma", - key: "Pleroma.Captcha", - value: [seconds_valid: 60] - }) - + insert(:config, key: Pleroma.Captcha, value: [seconds_valid: 60]) assert capture_log(fn -> TransferTask.start_link([]) end) =~ "pleroma restarted" end @@ -163,17 +108,8 @@ test "don't restart pleroma on reboot time key and subkey if there is false flag clear_config(:chat) clear_config(Pleroma.Captcha) - ConfigDB.create(%{ - group: ":pleroma", - key: ":chat", - value: [enabled: false] - }) - - ConfigDB.create(%{ - group: ":pleroma", - key: "Pleroma.Captcha", - value: [seconds_valid: 60] - }) + insert(:config, key: :chat, value: [enabled: false]) + insert(:config, key: Pleroma.Captcha, value: [seconds_valid: 60]) refute String.contains?( capture_log(fn -> TransferTask.load_and_update_env([], false) end), diff --git a/test/support/factory.ex b/test/support/factory.ex index 6e3676aca..e517d5bc6 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -396,24 +396,17 @@ def registration_factory do } end - def config_factory do + def config_factory(attrs \\ %{}) do %Pleroma.ConfigDB{ - key: - sequence(:key, fn key -> - # Atom dynamic registration hack in tests - "some_key_#{key}" - |> String.to_atom() - |> inspect() - end), - group: ":pleroma", + key: sequence(:key, &String.to_atom("some_key_#{&1}")), + group: :pleroma, value: sequence( :value, - fn key -> - :erlang.term_to_binary(%{another_key: "#{key}somevalue", another: "#{key}somevalue"}) - end + &%{another_key: "#{&1}somevalue", another: "#{&1}somevalue"} ) } + |> merge_attributes(attrs) end def marker_factory do diff --git a/test/tasks/config_test.exs b/test/tasks/config_test.exs index 04bc947a9..e1bddfebf 100644 --- a/test/tasks/config_test.exs +++ b/test/tasks/config_test.exs @@ -5,6 +5,8 @@ defmodule Mix.Tasks.Pleroma.ConfigTest do use Pleroma.DataCase + import Pleroma.Factory + alias Pleroma.ConfigDB alias Pleroma.Repo @@ -49,24 +51,19 @@ test "filtered settings are migrated to db" do refute ConfigDB.get_by_params(%{group: ":pleroma", key: "Pleroma.Repo"}) refute ConfigDB.get_by_params(%{group: ":postgrex", key: ":json_library"}) - assert ConfigDB.from_binary(config1.value) == [key: "value", key2: [Repo]] - assert ConfigDB.from_binary(config2.value) == [key: "value2", key2: ["Activity"]] - assert ConfigDB.from_binary(config3.value) == :info + assert config1.value == [key: "value", key2: [Repo]] + assert config2.value == [key: "value2", key2: ["Activity"]] + assert config3.value == :info end test "config table is truncated before migration" do - ConfigDB.create(%{ - group: ":pleroma", - key: ":first_setting", - value: [key: "value", key2: ["Activity"]] - }) - + insert(:config, key: :first_setting, value: [key: "value", key2: ["Activity"]]) assert Repo.aggregate(ConfigDB, :count, :id) == 1 Mix.Tasks.Pleroma.Config.migrate_to_db("test/fixtures/config/temp.secret.exs") config = ConfigDB.get_by_params(%{group: ":pleroma", key: ":first_setting"}) - assert ConfigDB.from_binary(config.value) == [key: "value", key2: [Repo]] + assert config.value == [key: "value", key2: [Repo]] end end @@ -82,19 +79,9 @@ test "config table is truncated before migration" do end test "settings are migrated to file and deleted from db", %{temp_file: temp_file} do - ConfigDB.create(%{ - group: ":pleroma", - key: ":setting_first", - value: [key: "value", key2: ["Activity"]] - }) - - ConfigDB.create(%{ - group: ":pleroma", - key: ":setting_second", - value: [key: "value2", key2: [Repo]] - }) - - ConfigDB.create(%{group: ":quack", key: ":level", value: :info}) + insert(:config, key: :setting_first, value: [key: "value", key2: ["Activity"]]) + insert(:config, key: :setting_second, value: [key: "value2", key2: [Repo]]) + insert(:config, group: :quack, key: :level, value: :info) Mix.Tasks.Pleroma.Config.run(["migrate_from_db", "--env", "temp", "-d"]) @@ -107,9 +94,8 @@ test "settings are migrated to file and deleted from db", %{temp_file: temp_file end test "load a settings with large values and pass to file", %{temp_file: temp_file} do - ConfigDB.create(%{ - group: ":pleroma", - key: ":instance", + insert(:config, + key: :instance, value: [ name: "Pleroma", email: "example@example.com", @@ -163,7 +149,6 @@ test "load a settings with large values and pass to file", %{temp_file: temp_fil extended_nickname_format: true, multi_factor_authentication: [ totp: [ - # digits 6 or 8 digits: 6, period: 30 ], @@ -173,7 +158,7 @@ test "load a settings with large values and pass to file", %{temp_file: temp_fil ] ] ] - }) + ) Mix.Tasks.Pleroma.Config.run(["migrate_from_db", "--env", "temp", "-d"]) diff --git a/test/upload/filter/mogrify_test.exs b/test/upload/filter/mogrify_test.exs index b6a463e8c..62ca30487 100644 --- a/test/upload/filter/mogrify_test.exs +++ b/test/upload/filter/mogrify_test.exs @@ -6,21 +6,17 @@ defmodule Pleroma.Upload.Filter.MogrifyTest do use Pleroma.DataCase import Mock - alias Pleroma.Config - alias Pleroma.Upload alias Pleroma.Upload.Filter - setup do: clear_config([Filter.Mogrify, :args]) - test "apply mogrify filter" do - Config.put([Filter.Mogrify, :args], [{"tint", "40"}]) + clear_config(Filter.Mogrify, args: [{"tint", "40"}]) File.cp!( "test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg" ) - upload = %Upload{ + upload = %Pleroma.Upload{ name: "an… image.jpg", content_type: "image/jpg", path: Path.absname("test/fixtures/image_tmp.jpg"), diff --git a/test/web/admin_api/controllers/config_controller_test.exs b/test/web/admin_api/controllers/config_controller_test.exs index 780de8d18..064ef9bc7 100644 --- a/test/web/admin_api/controllers/config_controller_test.exs +++ b/test/web/admin_api/controllers/config_controller_test.exs @@ -57,12 +57,12 @@ test "with settings only in db", %{conn: conn} do ] } = json_response_and_validate_schema(conn, 200) - assert key1 == config1.key - assert key2 == config2.key + assert key1 == inspect(config1.key) + assert key2 == inspect(config2.key) end test "db is added to settings that are in db", %{conn: conn} do - _config = insert(:config, key: ":instance", value: ConfigDB.to_binary(name: "Some name")) + _config = insert(:config, key: ":instance", value: [name: "Some name"]) %{"configs" => configs} = conn @@ -83,7 +83,7 @@ test "merged default setting with db settings", %{conn: conn} do config3 = insert(:config, - value: ConfigDB.to_binary(k1: :v1, k2: :v2) + value: [k1: :v1, k2: :v2] ) %{"configs" => configs} = @@ -93,42 +93,45 @@ test "merged default setting with db settings", %{conn: conn} do assert length(configs) > 3 + saved_configs = [config1, config2, config3] + keys = Enum.map(saved_configs, &inspect(&1.key)) + received_configs = Enum.filter(configs, fn %{"group" => group, "key" => key} -> - group == ":pleroma" and key in [config1.key, config2.key, config3.key] + group == ":pleroma" and key in keys end) assert length(received_configs) == 3 db_keys = config3.value - |> ConfigDB.from_binary() |> Keyword.keys() - |> ConfigDB.convert() + |> ConfigDB.to_json_types() + + keys = Enum.map(saved_configs -- [config3], &inspect(&1.key)) + + values = Enum.map(saved_configs, &ConfigDB.to_json_types(&1.value)) + + mapset_keys = MapSet.new(keys ++ db_keys) Enum.each(received_configs, fn %{"value" => value, "db" => db} -> - assert db in [[config1.key], [config2.key], db_keys] + db = MapSet.new(db) + assert MapSet.subset?(db, mapset_keys) - assert value in [ - ConfigDB.from_binary_with_convert(config1.value), - ConfigDB.from_binary_with_convert(config2.value), - ConfigDB.from_binary_with_convert(config3.value) - ] + assert value in values end) end test "subkeys with full update right merge", %{conn: conn} do - config1 = - insert(:config, - key: ":emoji", - value: ConfigDB.to_binary(groups: [a: 1, b: 2], key: [a: 1]) - ) + insert(:config, + key: ":emoji", + value: [groups: [a: 1, b: 2], key: [a: 1]] + ) - config2 = - insert(:config, - key: ":assets", - value: ConfigDB.to_binary(mascots: [a: 1, b: 2], key: [a: 1]) - ) + insert(:config, + key: ":assets", + value: [mascots: [a: 1, b: 2], key: [a: 1]] + ) %{"configs" => configs} = conn @@ -137,14 +140,14 @@ test "subkeys with full update right merge", %{conn: conn} do vals = Enum.filter(configs, fn %{"group" => group, "key" => key} -> - group == ":pleroma" and key in [config1.key, config2.key] + group == ":pleroma" and key in [":emoji", ":assets"] end) emoji = Enum.find(vals, fn %{"key" => key} -> key == ":emoji" end) assets = Enum.find(vals, fn %{"key" => key} -> key == ":assets" end) - emoji_val = ConfigDB.transform_with_out_binary(emoji["value"]) - assets_val = ConfigDB.transform_with_out_binary(assets["value"]) + emoji_val = ConfigDB.to_elixir_types(emoji["value"]) + assets_val = ConfigDB.to_elixir_types(assets["value"]) assert emoji_val[:groups] == [a: 1, b: 2] assert assets_val[:mascots] == [a: 1, b: 2] @@ -277,7 +280,8 @@ test "create new config setting in db", %{conn: conn} do "value" => %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]}, "db" => [":key5"] } - ] + ], + "need_reboot" => false } assert Application.get_env(:pleroma, :key1) == "value1" @@ -357,7 +361,8 @@ test "save configs setting without explicit key", %{conn: conn} do "value" => "https://hooks.slack.com/services/KEY", "db" => [":webhook_url"] } - ] + ], + "need_reboot" => false } assert Application.get_env(:quack, :level) == :info @@ -366,14 +371,14 @@ test "save configs setting without explicit key", %{conn: conn} do end test "saving config with partial update", %{conn: conn} do - config = insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: 2)) + insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: 2)) conn = conn |> put_req_header("content-type", "application/json") |> post("/api/pleroma/admin/config", %{ configs: [ - %{group: config.group, key: config.key, value: [%{"tuple" => [":key3", 3]}]} + %{group: ":pleroma", key: ":key1", value: [%{"tuple" => [":key3", 3]}]} ] }) @@ -389,7 +394,8 @@ test "saving config with partial update", %{conn: conn} do ], "db" => [":key1", ":key2", ":key3"] } - ] + ], + "need_reboot" => false } end @@ -500,8 +506,7 @@ test "update setting which need reboot, don't change reboot flag until reboot", end test "saving config with nested merge", %{conn: conn} do - config = - insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: [k1: 1, k2: 2])) + insert(:config, key: :key1, value: [key1: 1, key2: [k1: 1, k2: 2]]) conn = conn @@ -509,8 +514,8 @@ test "saving config with nested merge", %{conn: conn} do |> post("/api/pleroma/admin/config", %{ configs: [ %{ - group: config.group, - key: config.key, + group: ":pleroma", + key: ":key1", value: [ %{"tuple" => [":key3", 3]}, %{ @@ -548,7 +553,8 @@ test "saving config with nested merge", %{conn: conn} do ], "db" => [":key1", ":key3", ":key2"] } - ] + ], + "need_reboot" => false } end @@ -588,7 +594,8 @@ test "saving special atoms", %{conn: conn} do ], "db" => [":ssl_options"] } - ] + ], + "need_reboot" => false } assert Application.get_env(:pleroma, :key1) == [ @@ -600,12 +607,11 @@ test "saving full setting if value is in full_key_update list", %{conn: conn} do backends = Application.get_env(:logger, :backends) on_exit(fn -> Application.put_env(:logger, :backends, backends) end) - config = - insert(:config, - group: ":logger", - key: ":backends", - value: :erlang.term_to_binary([]) - ) + insert(:config, + group: :logger, + key: :backends, + value: [] + ) Pleroma.Config.TransferTask.load_and_update_env([], false) @@ -617,8 +623,8 @@ test "saving full setting if value is in full_key_update list", %{conn: conn} do |> post("/api/pleroma/admin/config", %{ configs: [ %{ - group: config.group, - key: config.key, + group: ":logger", + key: ":backends", value: [":console"] } ] @@ -634,7 +640,8 @@ test "saving full setting if value is in full_key_update list", %{conn: conn} do ], "db" => [":backends"] } - ] + ], + "need_reboot" => false } assert Application.get_env(:logger, :backends) == [ @@ -643,19 +650,18 @@ test "saving full setting if value is in full_key_update list", %{conn: conn} do end test "saving full setting if value is not keyword", %{conn: conn} do - config = - insert(:config, - group: ":tesla", - key: ":adapter", - value: :erlang.term_to_binary(Tesla.Adapter.Hackey) - ) + insert(:config, + group: :tesla, + key: :adapter, + value: Tesla.Adapter.Hackey + ) conn = conn |> put_req_header("content-type", "application/json") |> post("/api/pleroma/admin/config", %{ configs: [ - %{group: config.group, key: config.key, value: "Tesla.Adapter.Httpc"} + %{group: ":tesla", key: ":adapter", value: "Tesla.Adapter.Httpc"} ] }) @@ -667,7 +673,8 @@ test "saving full setting if value is not keyword", %{conn: conn} do "value" => "Tesla.Adapter.Httpc", "db" => [":adapter"] } - ] + ], + "need_reboot" => false } end @@ -677,13 +684,13 @@ test "update config setting & delete with fallback to default value", %{ token: token } do ueberauth = Application.get_env(:ueberauth, Ueberauth) - config1 = insert(:config, key: ":keyaa1") - config2 = insert(:config, key: ":keyaa2") + insert(:config, key: :keyaa1) + insert(:config, key: :keyaa2) config3 = insert(:config, - group: ":ueberauth", - key: "Ueberauth" + group: :ueberauth, + key: Ueberauth ) conn = @@ -691,8 +698,8 @@ test "update config setting & delete with fallback to default value", %{ |> put_req_header("content-type", "application/json") |> post("/api/pleroma/admin/config", %{ configs: [ - %{group: config1.group, key: config1.key, value: "another_value"}, - %{group: config2.group, key: config2.key, value: "another_value"} + %{group: ":pleroma", key: ":keyaa1", value: "another_value"}, + %{group: ":pleroma", key: ":keyaa2", value: "another_value"} ] }) @@ -700,22 +707,23 @@ test "update config setting & delete with fallback to default value", %{ "configs" => [ %{ "group" => ":pleroma", - "key" => config1.key, + "key" => ":keyaa1", "value" => "another_value", "db" => [":keyaa1"] }, %{ "group" => ":pleroma", - "key" => config2.key, + "key" => ":keyaa2", "value" => "another_value", "db" => [":keyaa2"] } - ] + ], + "need_reboot" => false } assert Application.get_env(:pleroma, :keyaa1) == "another_value" assert Application.get_env(:pleroma, :keyaa2) == "another_value" - assert Application.get_env(:ueberauth, Ueberauth) == ConfigDB.from_binary(config3.value) + assert Application.get_env(:ueberauth, Ueberauth) == config3.value conn = build_conn() @@ -724,7 +732,7 @@ test "update config setting & delete with fallback to default value", %{ |> put_req_header("content-type", "application/json") |> post("/api/pleroma/admin/config", %{ configs: [ - %{group: config2.group, key: config2.key, delete: true}, + %{group: ":pleroma", key: ":keyaa2", delete: true}, %{ group: ":ueberauth", key: "Ueberauth", @@ -734,7 +742,8 @@ test "update config setting & delete with fallback to default value", %{ }) assert json_response_and_validate_schema(conn, 200) == %{ - "configs" => [] + "configs" => [], + "need_reboot" => false } assert Application.get_env(:ueberauth, Ueberauth) == ueberauth @@ -801,7 +810,8 @@ test "common config example", %{conn: conn} do ":name" ] } - ] + ], + "need_reboot" => false } end @@ -935,7 +945,8 @@ test "tuples with more than two values", %{conn: conn} do ], "db" => [":http"] } - ] + ], + "need_reboot" => false } end @@ -1000,7 +1011,8 @@ test "settings with nesting map", %{conn: conn} do ], "db" => [":key2", ":key3"] } - ] + ], + "need_reboot" => false } end @@ -1027,7 +1039,8 @@ test "value as map", %{conn: conn} do "value" => %{"key" => "some_val"}, "db" => [":key1"] } - ] + ], + "need_reboot" => false } end @@ -1077,16 +1090,16 @@ test "queues key as atom", %{conn: conn} do ":background" ] } - ] + ], + "need_reboot" => false } end test "delete part of settings by atom subkeys", %{conn: conn} do - config = - insert(:config, - key: ":keyaa1", - value: :erlang.term_to_binary(subkey1: "val1", subkey2: "val2", subkey3: "val3") - ) + insert(:config, + key: :keyaa1, + value: [subkey1: "val1", subkey2: "val2", subkey3: "val3"] + ) conn = conn @@ -1094,8 +1107,8 @@ test "delete part of settings by atom subkeys", %{conn: conn} do |> post("/api/pleroma/admin/config", %{ configs: [ %{ - group: config.group, - key: config.key, + group: ":pleroma", + key: ":keyaa1", subkeys: [":subkey1", ":subkey3"], delete: true } @@ -1110,7 +1123,8 @@ test "delete part of settings by atom subkeys", %{conn: conn} do "value" => [%{"tuple" => [":subkey2", "val2"]}], "db" => [":subkey2"] } - ] + ], + "need_reboot" => false } end @@ -1236,6 +1250,90 @@ test "doesn't set keys not in the whitelist", %{conn: conn} do assert Application.get_env(:pleroma, Pleroma.Captcha.NotReal) == "value5" assert Application.get_env(:not_real, :anything) == "value6" end + + test "args for Pleroma.Upload.Filter.Mogrify with custom tuples", %{conn: conn} do + clear_config(Pleroma.Upload.Filter.Mogrify) + + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ + configs: [ + %{ + group: ":pleroma", + key: "Pleroma.Upload.Filter.Mogrify", + value: [ + %{"tuple" => [":args", ["auto-orient", "strip"]]} + ] + } + ] + }) + |> json_response_and_validate_schema(200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => "Pleroma.Upload.Filter.Mogrify", + "value" => [ + %{"tuple" => [":args", ["auto-orient", "strip"]]} + ], + "db" => [":args"] + } + ], + "need_reboot" => false + } + + assert Config.get(Pleroma.Upload.Filter.Mogrify) == [args: ["auto-orient", "strip"]] + + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ + configs: [ + %{ + group: ":pleroma", + key: "Pleroma.Upload.Filter.Mogrify", + value: [ + %{ + "tuple" => [ + ":args", + [ + "auto-orient", + "strip", + "{\"implode\", \"1\"}", + "{\"resize\", \"3840x1080>\"}" + ] + ] + } + ] + } + ] + }) + |> json_response(200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => "Pleroma.Upload.Filter.Mogrify", + "value" => [ + %{ + "tuple" => [ + ":args", + [ + "auto-orient", + "strip", + "{\"implode\", \"1\"}", + "{\"resize\", \"3840x1080>\"}" + ] + ] + } + ], + "db" => [":args"] + } + ], + "need_reboot" => false + } + + assert Config.get(Pleroma.Upload.Filter.Mogrify) == [ + args: ["auto-orient", "strip", {"implode", "1"}, {"resize", "3840x1080>"}] + ] + end end describe "GET /api/pleroma/admin/config/descriptions" do From 23decaab81b900bff0f6eacad7ea6a894239e4ce Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Sun, 31 May 2020 12:38:24 +0300 Subject: [PATCH 281/401] fix for updated hackney warning: :hackney_connect.partial_chain/1 is undefined or private --- test/config/config_db_test.exs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test/config/config_db_test.exs b/test/config/config_db_test.exs index a04575c6f..8d753e255 100644 --- a/test/config/config_db_test.exs +++ b/test/config/config_db_test.exs @@ -382,12 +382,6 @@ test "simple keyword" do assert ConfigDB.to_elixir_types([%{"tuple" => [":key", "value"]}]) == [key: "value"] end - test "keyword with partial_chain key" do - assert ConfigDB.to_elixir_types([ - %{"tuple" => [":partial_chain", "&:hackney_connect.partial_chain/1"]} - ]) == [partial_chain: &:hackney_connect.partial_chain/1] - end - test "keyword" do assert ConfigDB.to_elixir_types([ %{"tuple" => [":types", "Pleroma.PostgresTypes"]}, From e1603ac8fee2a660c3dc510dee5967e0fd1bbd98 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Sun, 31 May 2020 13:25:04 +0300 Subject: [PATCH 282/401] fix attemps to merge map --- lib/pleroma/config/config_db.ex | 3 ++- test/config/config_db_test.exs | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/config/config_db.ex b/lib/pleroma/config/config_db.ex index 39b37c42e..70be17ecf 100644 --- a/lib/pleroma/config/config_db.ex +++ b/lib/pleroma/config/config_db.ex @@ -135,7 +135,8 @@ def update_or_create(params) do with %ConfigDB{} = config <- ConfigDB.get_by_params(search_opts), {_, true, config} <- {:partial_update, can_be_partially_updated?(config), config}, - {_, true, config} <- {:can_be_merged, is_list(params[:value]), config} do + {_, true, config} <- + {:can_be_merged, is_list(params[:value]) and is_list(config.value), config} do new_value = merge_group(config.group, config.key, config.value, params[:value]) update(config, %{value: new_value}) else diff --git a/test/config/config_db_test.exs b/test/config/config_db_test.exs index 8d753e255..3895e2cda 100644 --- a/test/config/config_db_test.exs +++ b/test/config/config_db_test.exs @@ -43,7 +43,7 @@ test "common" do params = [ %{group: :pleroma, key: key2, value: "another_value"}, - %{group: :pleroma, key: config.key, value: "new_value"} + %{group: :pleroma, key: config.key, value: [a: 1, b: 2, c: "new_value"]} ] assert Repo.all(ConfigDB) |> length() == 1 @@ -55,7 +55,7 @@ test "common" do config1 = ConfigDB.get_by_params(%{group: config.group, key: config.key}) config2 = ConfigDB.get_by_params(%{group: :pleroma, key: key2}) - assert config1.value == "new_value" + assert config1.value == [a: 1, b: 2, c: "new_value"] assert config2.value == "another_value" end @@ -398,6 +398,10 @@ test "keyword" do ] end + test "trandformed keyword" do + assert ConfigDB.to_elixir_types(a: 1, b: 2, c: "string") == [a: 1, b: 2, c: "string"] + end + test "complex keyword with nested mixed childs" do assert ConfigDB.to_elixir_types([ %{"tuple" => [":uploader", "Pleroma.Uploaders.Local"]}, From 32c6576b600e2f24310f429f4b2391f95a5b5ba0 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Sun, 31 May 2020 14:42:15 +0300 Subject: [PATCH 283/401] naming --- lib/pleroma/config/config_db.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/config/config_db.ex b/lib/pleroma/config/config_db.ex index 70be17ecf..30bd51b05 100644 --- a/lib/pleroma/config/config_db.ex +++ b/lib/pleroma/config/config_db.ex @@ -331,7 +331,7 @@ def string_to_elixir_types("~r" <> _pattern = regex) do def string_to_elixir_types(":" <> atom), do: String.to_atom(atom) def string_to_elixir_types(value) do - if is_module_name?(value) do + if module_name?(value) do String.to_existing_atom("Elixir." <> value) else value @@ -373,8 +373,8 @@ defp find_valid_delimiter([delimiter | others], pattern, regex_delimiter) do end end - @spec is_module_name?(String.t()) :: boolean() - def is_module_name?(string) do + @spec module_name?(String.t()) :: boolean() + def module_name?(string) do Regex.match?(~r/^(Pleroma|Phoenix|Tesla|Quack|Ueberauth|Swoosh)\./, string) or string in ["Oban", "Ueberauth", "ExSyslogger"] end From aca6a7543ae97da2d1af8a6f9c547a0088d9e240 Mon Sep 17 00:00:00 2001 From: Steven Fuchs <steven.fuchs@dockyard.com> Date: Tue, 16 Jun 2020 13:18:29 +0000 Subject: [PATCH 284/401] Upgrade to Elixir 1.9 --- .gitlab-ci.yml | 2 +- elixir_buildpack.config | 4 ++-- mix.exs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index aad28a2d8..bc7b289a2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: elixir:1.8.1 +image: elixir:1.9.4 variables: &global_variables POSTGRES_DB: pleroma_test diff --git a/elixir_buildpack.config b/elixir_buildpack.config index c23b08fb8..946408c12 100644 --- a/elixir_buildpack.config +++ b/elixir_buildpack.config @@ -1,2 +1,2 @@ -elixir_version=1.8.2 -erlang_version=21.3.7 +elixir_version=1.9.4 +erlang_version=22.3.4.1 diff --git a/mix.exs b/mix.exs index 03b060bc0..6040c994e 100644 --- a/mix.exs +++ b/mix.exs @@ -5,7 +5,7 @@ def project do [ app: :pleroma, version: version("2.0.50"), - elixir: "~> 1.8", + elixir: "~> 1.9", elixirc_paths: elixirc_paths(Mix.env()), compilers: [:phoenix, :gettext] ++ Mix.compilers(), elixirc_options: [warnings_as_errors: warnings_as_errors(Mix.env())], From 3c2cee33adcd79a76b192a66f6f2d3772e2fda99 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Tue, 16 Jun 2020 17:50:33 +0300 Subject: [PATCH 285/401] moving custom ecto types in context folders --- lib/pleroma/config/config_db.ex | 6 +++--- .../activity_pub/object_validators}/date_time.ex | 6 +++++- .../activity_pub/object_validators}/object_id.ex | 6 +++++- .../activity_pub/object_validators}/recipients.ex | 8 ++++++-- .../activity_pub/object_validators}/safe_text.ex | 2 +- .../activity_pub/object_validators}/uri.ex | 6 +++++- .../{config/type => ecto_type/config}/atom.ex | 6 +++++- .../type => ecto_type/config}/binary_value.ex | 6 +++++- lib/pleroma/signature.ex | 4 ++-- lib/pleroma/user.ex | 4 ++-- lib/pleroma/web/activity_pub/object_validator.ex | 4 ++-- .../object_validators/announce_validator.ex | 14 +++++++------- .../object_validators/chat_message_validator.ex | 12 ++++++------ .../create_chat_message_validator.ex | 10 +++++----- .../object_validators/create_note_validator.ex | 6 +++--- .../object_validators/delete_validator.ex | 14 +++++++------- .../object_validators/emoji_react_validator.ex | 8 ++++---- .../object_validators/like_validator.ex | 14 +++++++------- .../object_validators/note_validator.ex | 12 ++++++------ .../object_validators/undo_validator.ex | 8 ++++---- .../object_validators/url_object_validator.ex | 8 ++++++-- lib/pleroma/web/activity_pub/transmogrifier.ex | 4 ++-- .../object_validators/types/date_time_test.exs | 2 +- .../object_validators/types/object_id_test.exs | 2 +- .../object_validators/types/recipients_test.exs | 2 +- .../object_validators/types/safe_text_test.exs | 2 +- 26 files changed, 102 insertions(+), 74 deletions(-) rename lib/pleroma/{web/activity_pub/object_validators/types => ecto_type/activity_pub/object_validators}/date_time.ex (77%) rename lib/pleroma/{web/activity_pub/object_validators/types => ecto_type/activity_pub/object_validators}/object_id.ex (69%) rename lib/pleroma/{web/activity_pub/object_validators/types => ecto_type/activity_pub/object_validators}/recipients.ex (64%) rename lib/pleroma/{web/activity_pub/object_validators/types => ecto_type/activity_pub/object_validators}/safe_text.ex (85%) rename lib/pleroma/{web/activity_pub/object_validators/types => ecto_type/activity_pub/object_validators}/uri.ex (63%) rename lib/pleroma/{config/type => ecto_type/config}/atom.ex (66%) rename lib/pleroma/{config/type => ecto_type/config}/binary_value.ex (65%) diff --git a/lib/pleroma/config/config_db.ex b/lib/pleroma/config/config_db.ex index 30bd51b05..2f4eb8581 100644 --- a/lib/pleroma/config/config_db.ex +++ b/lib/pleroma/config/config_db.ex @@ -23,9 +23,9 @@ defmodule Pleroma.ConfigDB do ] schema "config" do - field(:key, Pleroma.Config.Type.Atom) - field(:group, Pleroma.Config.Type.Atom) - field(:value, Pleroma.Config.Type.BinaryValue) + field(:key, Pleroma.EctoType.Config.Atom) + field(:group, Pleroma.EctoType.Config.Atom) + field(:value, Pleroma.EctoType.Config.BinaryValue) field(:db, {:array, :string}, virtual: true, default: []) timestamps() diff --git a/lib/pleroma/web/activity_pub/object_validators/types/date_time.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/date_time.ex similarity index 77% rename from lib/pleroma/web/activity_pub/object_validators/types/date_time.ex rename to lib/pleroma/ecto_type/activity_pub/object_validators/date_time.ex index 4f412fcde..d852c0abd 100644 --- a/lib/pleroma/web/activity_pub/object_validators/types/date_time.ex +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/date_time.ex @@ -1,4 +1,8 @@ -defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.DateTime do +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.DateTime do @moduledoc """ The AP standard defines the date fields in AP as xsd:DateTime. Elixir's DateTime can't parse this, but it can parse the related iso8601. This diff --git a/lib/pleroma/web/activity_pub/object_validators/types/object_id.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/object_id.ex similarity index 69% rename from lib/pleroma/web/activity_pub/object_validators/types/object_id.ex rename to lib/pleroma/ecto_type/activity_pub/object_validators/object_id.ex index f71f76370..8034235b0 100644 --- a/lib/pleroma/web/activity_pub/object_validators/types/object_id.ex +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/object_id.ex @@ -1,4 +1,8 @@ -defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID do +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.ObjectID do use Ecto.Type def type, do: :string diff --git a/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex similarity index 64% rename from lib/pleroma/web/activity_pub/object_validators/types/recipients.ex rename to lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex index 408e0f6ee..205527a96 100644 --- a/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex @@ -1,7 +1,11 @@ -defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients do +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.Recipients do use Ecto.Type - alias Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID + alias Pleroma.EctoType.ActivityPub.ObjectValidators.ObjectID def type, do: {:array, ObjectID} diff --git a/lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/safe_text.ex similarity index 85% rename from lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex rename to lib/pleroma/ecto_type/activity_pub/object_validators/safe_text.ex index 95c948123..7f0405c7b 100644 --- a/lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/safe_text.ex @@ -2,7 +2,7 @@ # Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.SafeText do +defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.SafeText do use Ecto.Type alias Pleroma.HTML diff --git a/lib/pleroma/web/activity_pub/object_validators/types/uri.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/uri.ex similarity index 63% rename from lib/pleroma/web/activity_pub/object_validators/types/uri.ex rename to lib/pleroma/ecto_type/activity_pub/object_validators/uri.ex index 24845bcc0..2054c26be 100644 --- a/lib/pleroma/web/activity_pub/object_validators/types/uri.ex +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/uri.ex @@ -1,4 +1,8 @@ -defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.Uri do +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.Uri do use Ecto.Type def type, do: :string diff --git a/lib/pleroma/config/type/atom.ex b/lib/pleroma/ecto_type/config/atom.ex similarity index 66% rename from lib/pleroma/config/type/atom.ex rename to lib/pleroma/ecto_type/config/atom.ex index 387869284..df565d432 100644 --- a/lib/pleroma/config/type/atom.ex +++ b/lib/pleroma/ecto_type/config/atom.ex @@ -1,4 +1,8 @@ -defmodule Pleroma.Config.Type.Atom do +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.Config.Atom do use Ecto.Type def type, do: :atom diff --git a/lib/pleroma/config/type/binary_value.ex b/lib/pleroma/ecto_type/config/binary_value.ex similarity index 65% rename from lib/pleroma/config/type/binary_value.ex rename to lib/pleroma/ecto_type/config/binary_value.ex index 17c5524a3..bbd2608c5 100644 --- a/lib/pleroma/config/type/binary_value.ex +++ b/lib/pleroma/ecto_type/config/binary_value.ex @@ -1,4 +1,8 @@ -defmodule Pleroma.Config.Type.BinaryValue do +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.Config.BinaryValue do use Ecto.Type def type, do: :term diff --git a/lib/pleroma/signature.ex b/lib/pleroma/signature.ex index d01728361..3aa6909d2 100644 --- a/lib/pleroma/signature.ex +++ b/lib/pleroma/signature.ex @@ -5,10 +5,10 @@ defmodule Pleroma.Signature do @behaviour HTTPSignatures.Adapter + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Keys alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.ActivityPub.ObjectValidators.Types def key_id_to_actor_id(key_id) do uri = @@ -24,7 +24,7 @@ def key_id_to_actor_id(key_id) do maybe_ap_id = URI.to_string(uri) - case Types.ObjectID.cast(maybe_ap_id) do + case ObjectValidators.ObjectID.cast(maybe_ap_id) do {:ok, ap_id} -> {:ok, ap_id} diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 52ac9052b..686ab0123 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -14,6 +14,7 @@ defmodule Pleroma.User do alias Pleroma.Config alias Pleroma.Conversation.Participation alias Pleroma.Delivery + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Emoji alias Pleroma.FollowingRelationship alias Pleroma.Formatter @@ -30,7 +31,6 @@ defmodule Pleroma.User do alias Pleroma.Web alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder - alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI @@ -115,7 +115,7 @@ defmodule Pleroma.User do field(:is_admin, :boolean, default: false) field(:show_role, :boolean, default: true) field(:settings, :map, default: nil) - field(:uri, Types.Uri, default: nil) + field(:uri, ObjectValidators.Uri, default: nil) field(:hide_followers_count, :boolean, default: false) field(:hide_follows_count, :boolean, default: false) field(:hide_followers, :boolean, default: false) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index c01c5f780..3d699e8a5 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do """ alias Pleroma.Object + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator @@ -17,7 +18,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator alias Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} @@ -120,7 +120,7 @@ def stringify_keys(object) when is_list(object) do def stringify_keys(object), do: object def fetch_actor(object) do - with {:ok, actor} <- Types.ObjectID.cast(object["actor"]) do + with {:ok, actor} <- ObjectValidators.ObjectID.cast(object["actor"]) do User.get_or_fetch_by_ap_id(actor) end end diff --git a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex index 40f861f47..6f757f49c 100644 --- a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex @@ -5,9 +5,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator do use Ecto.Schema + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Object alias Pleroma.User - alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility @@ -19,14 +19,14 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator do @primary_key false embedded_schema do - field(:id, Types.ObjectID, primary_key: true) + field(:id, ObjectValidators.ObjectID, primary_key: true) field(:type, :string) - field(:object, Types.ObjectID) - field(:actor, Types.ObjectID) + field(:object, ObjectValidators.ObjectID) + field(:actor, ObjectValidators.ObjectID) field(:context, :string, autogenerate: {Utils, :generate_context_id, []}) - field(:to, Types.Recipients, default: []) - field(:cc, Types.Recipients, default: []) - field(:published, Types.DateTime) + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) + field(:published, ObjectValidators.DateTime) end def cast_and_validate(data) do diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index 138736f23..c481d79e0 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -5,9 +5,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do use Ecto.Schema + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset import Pleroma.Web.ActivityPub.Transmogrifier, only: [fix_emoji: 1] @@ -16,12 +16,12 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do @derive Jason.Encoder embedded_schema do - field(:id, Types.ObjectID, primary_key: true) - field(:to, Types.Recipients, default: []) + field(:id, ObjectValidators.ObjectID, primary_key: true) + field(:to, ObjectValidators.Recipients, default: []) field(:type, :string) - field(:content, Types.SafeText) - field(:actor, Types.ObjectID) - field(:published, Types.DateTime) + field(:content, ObjectValidators.SafeText) + field(:actor, ObjectValidators.ObjectID) + field(:published, ObjectValidators.DateTime) field(:emoji, :map, default: %{}) embeds_one(:attachment, AttachmentValidator) diff --git a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex index fc582400b..7269f9ff0 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex @@ -7,9 +7,9 @@ # - doesn't embed, will only get the object id defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator do use Ecto.Schema + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Object - alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @@ -17,11 +17,11 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator do @primary_key false embedded_schema do - field(:id, Types.ObjectID, primary_key: true) - field(:actor, Types.ObjectID) + field(:id, ObjectValidators.ObjectID, primary_key: true) + field(:actor, ObjectValidators.ObjectID) field(:type, :string) - field(:to, Types.Recipients, default: []) - field(:object, Types.ObjectID) + field(:to, ObjectValidators.Recipients, default: []) + field(:object, ObjectValidators.ObjectID) end def cast_and_apply(data) do diff --git a/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex index 926804ce7..316bd0c07 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex @@ -5,16 +5,16 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateNoteValidator do use Ecto.Schema + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset @primary_key false embedded_schema do - field(:id, Types.ObjectID, primary_key: true) - field(:actor, Types.ObjectID) + field(:id, ObjectValidators.ObjectID, primary_key: true) + field(:actor, ObjectValidators.ObjectID) field(:type, :string) field(:to, {:array, :string}) field(:cc, {:array, :string}) diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex index e5d08eb5c..93a7b0e0b 100644 --- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex @@ -6,8 +6,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do use Ecto.Schema alias Pleroma.Activity + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.User - alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @@ -15,13 +15,13 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do @primary_key false embedded_schema do - field(:id, Types.ObjectID, primary_key: true) + field(:id, ObjectValidators.ObjectID, primary_key: true) field(:type, :string) - field(:actor, Types.ObjectID) - field(:to, Types.Recipients, default: []) - field(:cc, Types.Recipients, default: []) - field(:deleted_activity_id, Types.ObjectID) - field(:object, Types.ObjectID) + field(:actor, ObjectValidators.ObjectID) + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) + field(:deleted_activity_id, ObjectValidators.ObjectID) + field(:object, ObjectValidators.ObjectID) end def cast_data(data) do diff --git a/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex b/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex index e87519c59..a543af1f8 100644 --- a/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex @@ -5,8 +5,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator do use Ecto.Schema + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Object - alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @@ -14,10 +14,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator do @primary_key false embedded_schema do - field(:id, Types.ObjectID, primary_key: true) + field(:id, ObjectValidators.ObjectID, primary_key: true) field(:type, :string) - field(:object, Types.ObjectID) - field(:actor, Types.ObjectID) + field(:object, ObjectValidators.ObjectID) + field(:actor, ObjectValidators.ObjectID) field(:context, :string) field(:content, :string) field(:to, {:array, :string}, default: []) diff --git a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex index 034f25492..493e4c247 100644 --- a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex @@ -5,8 +5,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator do use Ecto.Schema + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Object - alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.Utils import Ecto.Changeset @@ -15,13 +15,13 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator do @primary_key false embedded_schema do - field(:id, Types.ObjectID, primary_key: true) + field(:id, ObjectValidators.ObjectID, primary_key: true) field(:type, :string) - field(:object, Types.ObjectID) - field(:actor, Types.ObjectID) + field(:object, ObjectValidators.ObjectID) + field(:actor, ObjectValidators.ObjectID) field(:context, :string) - field(:to, Types.Recipients, default: []) - field(:cc, Types.Recipients, default: []) + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) end def cast_and_validate(data) do @@ -67,7 +67,7 @@ def fix_recipients(cng) do with {[], []} <- {to, cc}, %Object{data: %{"actor" => actor}} <- Object.get_cached_by_ap_id(object), - {:ok, actor} <- Types.ObjectID.cast(actor) do + {:ok, actor} <- ObjectValidators.ObjectID.cast(actor) do cng |> put_change(:to, [actor]) else diff --git a/lib/pleroma/web/activity_pub/object_validators/note_validator.ex b/lib/pleroma/web/activity_pub/object_validators/note_validator.ex index 462a5620a..a10728ac6 100644 --- a/lib/pleroma/web/activity_pub/object_validators/note_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/note_validator.ex @@ -5,14 +5,14 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator do use Ecto.Schema - alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.EctoType.ActivityPub.ObjectValidators import Ecto.Changeset @primary_key false embedded_schema do - field(:id, Types.ObjectID, primary_key: true) + field(:id, ObjectValidators.ObjectID, primary_key: true) field(:to, {:array, :string}, default: []) field(:cc, {:array, :string}, default: []) field(:bto, {:array, :string}, default: []) @@ -22,10 +22,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator do field(:type, :string) field(:content, :string) field(:context, :string) - field(:actor, Types.ObjectID) - field(:attributedTo, Types.ObjectID) + field(:actor, ObjectValidators.ObjectID) + field(:attributedTo, ObjectValidators.ObjectID) field(:summary, :string) - field(:published, Types.DateTime) + field(:published, ObjectValidators.DateTime) # TODO: Write type field(:emoji, :map, default: %{}) field(:sensitive, :boolean, default: false) @@ -35,7 +35,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator do field(:like_count, :integer, default: 0) field(:announcement_count, :integer, default: 0) field(:inRepyTo, :string) - field(:uri, Types.Uri) + field(:uri, ObjectValidators.Uri) field(:likes, {:array, :string}, default: []) field(:announcements, {:array, :string}, default: []) diff --git a/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex b/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex index d0ba418e8..e8d2d39c1 100644 --- a/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator do use Ecto.Schema alias Pleroma.Activity - alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.EctoType.ActivityPub.ObjectValidators import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @@ -14,10 +14,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator do @primary_key false embedded_schema do - field(:id, Types.ObjectID, primary_key: true) + field(:id, ObjectValidators.ObjectID, primary_key: true) field(:type, :string) - field(:object, Types.ObjectID) - field(:actor, Types.ObjectID) + field(:object, ObjectValidators.ObjectID) + field(:actor, ObjectValidators.ObjectID) field(:to, {:array, :string}, default: []) field(:cc, {:array, :string}, default: []) end diff --git a/lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex b/lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex index 47e231150..f64fac46d 100644 --- a/lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex @@ -1,14 +1,18 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.ActivityPub.ObjectValidators.UrlObjectValidator do use Ecto.Schema - alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.EctoType.ActivityPub.ObjectValidators import Ecto.Changeset @primary_key false embedded_schema do field(:type, :string) - field(:href, Types.Uri) + field(:href, ObjectValidators.Uri) field(:mediaType, :string) end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 985921aa0..851f474b8 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do """ alias Pleroma.Activity alias Pleroma.EarmarkRenderer + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.FollowingRelationship alias Pleroma.Maps alias Pleroma.Notification @@ -18,7 +19,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility @@ -725,7 +725,7 @@ def handle_incoming( else {:error, {:validate_object, _}} = e -> # Check if we have a create activity for this - with {:ok, object_id} <- Types.ObjectID.cast(data["object"]), + with {:ok, object_id} <- ObjectValidators.ObjectID.cast(data["object"]), %Activity{data: %{"actor" => actor}} <- Activity.create_by_object_ap_id(object_id) |> Repo.one(), # We have one, insert a tombstone and retry diff --git a/test/web/activity_pub/object_validators/types/date_time_test.exs b/test/web/activity_pub/object_validators/types/date_time_test.exs index 3e17a9497..43be8e936 100644 --- a/test/web/activity_pub/object_validators/types/date_time_test.exs +++ b/test/web/activity_pub/object_validators/types/date_time_test.exs @@ -1,5 +1,5 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.DateTimeTest do - alias Pleroma.Web.ActivityPub.ObjectValidators.Types.DateTime + alias Pleroma.EctoType.ActivityPub.ObjectValidators.DateTime use Pleroma.DataCase test "it validates an xsd:Datetime" do diff --git a/test/web/activity_pub/object_validators/types/object_id_test.exs b/test/web/activity_pub/object_validators/types/object_id_test.exs index c8911948e..e0ab76379 100644 --- a/test/web/activity_pub/object_validators/types/object_id_test.exs +++ b/test/web/activity_pub/object_validators/types/object_id_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ObjectValidators.Types.ObjectIDTest do - alias Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID + alias Pleroma.EctoType.ActivityPub.ObjectValidators.ObjectID use Pleroma.DataCase @uris [ diff --git a/test/web/activity_pub/object_validators/types/recipients_test.exs b/test/web/activity_pub/object_validators/types/recipients_test.exs index f278f039b..053916bdd 100644 --- a/test/web/activity_pub/object_validators/types/recipients_test.exs +++ b/test/web/activity_pub/object_validators/types/recipients_test.exs @@ -1,5 +1,5 @@ defmodule Pleroma.Web.ObjectValidators.Types.RecipientsTest do - alias Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients + alias Pleroma.EctoType.ActivityPub.ObjectValidators.Recipients use Pleroma.DataCase test "it asserts that all elements of the list are object ids" do diff --git a/test/web/activity_pub/object_validators/types/safe_text_test.exs b/test/web/activity_pub/object_validators/types/safe_text_test.exs index d4a574554..9c08606f6 100644 --- a/test/web/activity_pub/object_validators/types/safe_text_test.exs +++ b/test/web/activity_pub/object_validators/types/safe_text_test.exs @@ -5,7 +5,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.SafeTextTest do use Pleroma.DataCase - alias Pleroma.Web.ActivityPub.ObjectValidators.Types.SafeText + alias Pleroma.EctoType.ActivityPub.ObjectValidators.SafeText test "it lets normal text go through" do text = "hey how are you" From ed189568f3c2c6fc6ae9ba4d676e95902b3019d1 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Sat, 21 Mar 2020 09:47:05 +0300 Subject: [PATCH 286/401] moving mrf settings from instance to separate group --- CHANGELOG.md | 5 +- config/config.exs | 8 ++- config/description.exs | 64 +++++++++++-------- docs/configuration/cheatsheet.md | 45 +++++++------ docs/configuration/mrf.md | 14 ++-- lib/pleroma/config/config_db.ex | 8 ++- lib/pleroma/config/deprecation_warnings.ex | 44 +++++++++++++ lib/pleroma/web/activity_pub/mrf.ex | 4 +- .../web/activity_pub/mrf/simple_policy.ex | 28 ++++---- .../web/mastodon_api/views/instance_view.ex | 2 +- ...rf_config_move_from_instance_namespace.exs | 39 +++++++++++ test/config/deprecation_warnings_test.exs | 57 +++++++++++++++++ test/tasks/config_test.exs | 5 +- test/web/activity_pub/mrf/mrf_test.exs | 4 +- test/web/federator_test.exs | 4 +- test/web/node_info_test.exs | 46 ++++--------- 16 files changed, 256 insertions(+), 121 deletions(-) create mode 100644 priv/repo/migrations/20200323122421_mrf_config_move_from_instance_namespace.exs create mode 100644 test/config/deprecation_warnings_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index d2629bf84..12e8d58e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] - ### Changed - MFR policy to set global expiration for all local Create activities - OGP rich media parser merged with TwitterCard +- Configuration: `rewrite_policy` renamed to `policies` and moved from `instance` to `mrf` group. Old config namespace is deprecated. +- Configuration: `mrf_transparency` renamed to `transparency` and moved from `instance` to `mrf` group. Old config namespace is deprecated. +- Configuration: `mrf_transparency_exclusions` renamed to `transparency_exclusions` and moved from `instance` to `mrf` group. Old config namespace is deprecated. + <details> <summary>API Changes</summary> - **Breaking:** Emoji API: changed methods and renamed routes. diff --git a/config/config.exs b/config/config.exs index 6a7bb9e06..3d6336a66 100644 --- a/config/config.exs +++ b/config/config.exs @@ -209,7 +209,6 @@ Pleroma.Web.ActivityPub.Publisher ], allow_relay: true, - rewrite_policy: Pleroma.Web.ActivityPub.MRF.NoOpPolicy, public: true, quarantined_instances: [], managed_config: true, @@ -220,8 +219,6 @@ "text/markdown", "text/bbcode" ], - mrf_transparency: true, - mrf_transparency_exclusions: [], autofollowed_nicknames: [], max_pinned_statuses: 1, attachment_links: false, @@ -685,6 +682,11 @@ config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false +config :pleroma, :mrf, + policies: Pleroma.Web.ActivityPub.MRF.NoOpPolicy, + transparency: true, + transparency_exclusions: [] + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/config/description.exs b/config/description.exs index b21d7840c..2ab95e5ab 100644 --- a/config/description.exs +++ b/config/description.exs @@ -689,17 +689,6 @@ type: :boolean, description: "Enable Pleroma's Relay, which makes it possible to follow a whole instance" }, - %{ - key: :rewrite_policy, - type: [:module, {:list, :module}], - description: - "A list of enabled MRF policies. Module names are shortened (removed leading `Pleroma.Web.ActivityPub.MRF.` part), but on adding custom module you need to use full name.", - suggestions: - Generator.list_modules_in_dir( - "lib/pleroma/web/activity_pub/mrf", - "Elixir.Pleroma.Web.ActivityPub.MRF." - ) - }, %{ key: :public, type: :boolean, @@ -742,23 +731,6 @@ "text/bbcode" ] }, - %{ - key: :mrf_transparency, - label: "MRF transparency", - type: :boolean, - description: - "Make the content of your Message Rewrite Facility settings public (via nodeinfo)" - }, - %{ - key: :mrf_transparency_exclusions, - label: "MRF transparency exclusions", - type: {:list, :string}, - description: - "Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value.", - suggestions: [ - "exclusion.com" - ] - }, %{ key: :extended_nickname_format, type: :boolean, @@ -3325,5 +3297,41 @@ suggestions: [false] } ] + }, + %{ + group: :pleroma, + key: :mrf, + type: :group, + description: "General MRF settings", + children: [ + %{ + key: :policies, + type: [:module, {:list, :module}], + description: + "A list of MRF policies enabled. Module names are shortened (removed leading `Pleroma.Web.ActivityPub.MRF.` part), but on adding custom module you need to use full name.", + suggestions: + Generator.list_modules_in_dir( + "lib/pleroma/web/activity_pub/mrf", + "Elixir.Pleroma.Web.ActivityPub.MRF." + ) + }, + %{ + key: :transparency, + label: "MRF transparency", + type: :boolean, + description: + "Make the content of your Message Rewrite Facility settings public (via nodeinfo)" + }, + %{ + key: :transparency_exclusions, + label: "MRF transparency exclusions", + type: {:list, :string}, + description: + "Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value.", + suggestions: [ + "exclusion.com" + ] + } + ] } ] diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index fad67fc4d..e9af604e2 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -36,26 +36,10 @@ To add configuration to your config file, you can copy it from the base config. * `federation_incoming_replies_max_depth`: Max. depth of reply-to activities fetching on incoming federation, to prevent out-of-memory situations while fetching very long threads. If set to `nil`, threads of any depth will be fetched. Lower this value if you experience out-of-memory crashes. * `federation_reachability_timeout_days`: Timeout (in days) of each external federation target being unreachable prior to pausing federating to it. * `allow_relay`: Enable Pleroma’s Relay, which makes it possible to follow a whole instance. -* `rewrite_policy`: Message Rewrite Policy, either one or a list. Here are the ones available by default: - * `Pleroma.Web.ActivityPub.MRF.NoOpPolicy`: Doesn’t modify activities (default). - * `Pleroma.Web.ActivityPub.MRF.DropPolicy`: Drops all activities. It generally doesn’t makes sense to use in production. - * `Pleroma.Web.ActivityPub.MRF.SimplePolicy`: Restrict the visibility of activities from certain instances (See [`:mrf_simple`](#mrf_simple)). - * `Pleroma.Web.ActivityPub.MRF.TagPolicy`: Applies policies to individual users based on tags, which can be set using pleroma-fe/admin-fe/any other app that supports Pleroma Admin API. For example it allows marking posts from individual users nsfw (sensitive). - * `Pleroma.Web.ActivityPub.MRF.SubchainPolicy`: Selectively runs other MRF policies when messages match (See [`:mrf_subchain`](#mrf_subchain)). - * `Pleroma.Web.ActivityPub.MRF.RejectNonPublic`: Drops posts with non-public visibility settings (See [`:mrf_rejectnonpublic`](#mrf_rejectnonpublic)). - * `Pleroma.Web.ActivityPub.MRF.EnsureRePrepended`: Rewrites posts to ensure that replies to posts with subjects do not have an identical subject and instead begin with re:. - * `Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy`: Rejects posts from likely spambots by rejecting posts from new users that contain links. - * `Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`: Crawls attachments using their MediaProxy URLs so that the MediaProxy cache is primed. - * `Pleroma.Web.ActivityPub.MRF.MentionPolicy`: Drops posts mentioning configurable users. (See [`:mrf_mention`](#mrf_mention)). - * `Pleroma.Web.ActivityPub.MRF.VocabularyPolicy`: Restricts activities to a configured set of vocabulary. (See [`:mrf_vocabulary`](#mrf_vocabulary)). - * `Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy`: Rejects or delists posts based on their age when received. (See [`:mrf_object_age`](#mrf_object_age)). - * `Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy`: Adds expiration to all local Create activities (see [`:mrf_activity_expiration`](#mrf_activity_expiration)). * `public`: Makes the client API in authenticated mode-only except for user-profiles. Useful for disabling the Local Timeline and The Whole Known Network. * `quarantined_instances`: List of ActivityPub instances where private(DMs, followers-only) activities will not be send. * `managed_config`: Whenether the config for pleroma-fe is configured in [:frontend_configurations](#frontend_configurations) or in ``static/config.json``. * `allowed_post_formats`: MIME-type list of formats allowed to be posted (transformed into HTML). -* `mrf_transparency`: Make the content of your Message Rewrite Facility settings public (via nodeinfo). -* `mrf_transparency_exclusions`: Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value. * `extended_nickname_format`: Set to `true` to use extended local nicknames format (allows underscores/dashes). This will break federation with older software for theses nicknames. * `max_pinned_statuses`: The maximum number of pinned statuses. `0` will disable the feature. @@ -78,11 +62,30 @@ To add configuration to your config file, you can copy it from the base config. * `external_user_synchronization`: Enabling following/followers counters synchronization for external users. * `cleanup_attachments`: Remove attachments along with statuses. Does not affect duplicate files and attachments without status. Enabling this will increase load to database when deleting statuses on larger instances. +## Message rewrite facility + +### :mrf +* `policies`: Message Rewrite Policy, either one or a list. Here are the ones available by default: + * `Pleroma.Web.ActivityPub.MRF.NoOpPolicy`: Doesn’t modify activities (default). + * `Pleroma.Web.ActivityPub.MRF.DropPolicy`: Drops all activities. It generally doesn’t makes sense to use in production. + * `Pleroma.Web.ActivityPub.MRF.SimplePolicy`: Restrict the visibility of activities from certains instances (See [`:mrf_simple`](#mrf_simple)). + * `Pleroma.Web.ActivityPub.MRF.TagPolicy`: Applies policies to individual users based on tags, which can be set using pleroma-fe/admin-fe/any other app that supports Pleroma Admin API. For example it allows marking posts from individual users nsfw (sensitive). + * `Pleroma.Web.ActivityPub.MRF.SubchainPolicy`: Selectively runs other MRF policies when messages match (See [`:mrf_subchain`](#mrf_subchain)). + * `Pleroma.Web.ActivityPub.MRF.RejectNonPublic`: Drops posts with non-public visibility settings (See [`:mrf_rejectnonpublic`](#mrf_rejectnonpublic)). + * `Pleroma.Web.ActivityPub.MRF.EnsureRePrepended`: Rewrites posts to ensure that replies to posts with subjects do not have an identical subject and instead begin with re:. + * `Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy`: Rejects posts from likely spambots by rejecting posts from new users that contain links. + * `Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`: Crawls attachments using their MediaProxy URLs so that the MediaProxy cache is primed. + * `Pleroma.Web.ActivityPub.MRF.MentionPolicy`: Drops posts mentioning configurable users. (See [`:mrf_mention`](#mrf_mention)). + * `Pleroma.Web.ActivityPub.MRF.VocabularyPolicy`: Restricts activities to a configured set of vocabulary. (See [`:mrf_vocabulary`](#mrf_vocabulary)). + * `Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy`: Rejects or delists posts based on their age when received. (See [`:mrf_object_age`](#mrf_object_age)). +* `transparency`: Make the content of your Message Rewrite Facility settings public (via nodeinfo). +* `transparency_exclusions`: Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value. + ## Federation ### MRF policies !!! note - Configuring MRF policies is not enough for them to take effect. You have to enable them by specifying their module in `rewrite_policy` under [:instance](#instance) section. + Configuring MRF policies is not enough for them to take effect. You have to enable them by specifying their module in `policies` under [:mrf](#mrf) section. #### :mrf_simple * `media_removal`: List of instances to remove media from. @@ -969,13 +972,13 @@ config :pleroma, :database_config_whitelist, [ Restrict access for unauthenticated users to timelines (public and federate), user profiles and statuses. -* `timelines` - public and federated timelines - * `local` - public timeline +* `timelines`: public and federated timelines + * `local`: public timeline * `federated` -* `profiles` - user profiles +* `profiles`: user profiles * `local` * `remote` -* `activities` - statuses +* `activities`: statuses * `local` * `remote` diff --git a/docs/configuration/mrf.md b/docs/configuration/mrf.md index d48d0cc99..31c66e098 100644 --- a/docs/configuration/mrf.md +++ b/docs/configuration/mrf.md @@ -34,9 +34,9 @@ config :pleroma, :instance, To use `SimplePolicy`, you must enable it. Do so by adding the following to your `:instance` config object, so that it looks like this: ```elixir -config :pleroma, :instance, +config :pleroma, :mrf, [...] - rewrite_policy: Pleroma.Web.ActivityPub.MRF.SimplePolicy + policies: Pleroma.Web.ActivityPub.MRF.SimplePolicy ``` Once `SimplePolicy` is enabled, you can configure various groups in the `:mrf_simple` config object. These groups are: @@ -58,8 +58,8 @@ Servers should be configured as lists. This example will enable `SimplePolicy`, block media from `illegalporn.biz`, mark media as NSFW from `porn.biz` and `porn.business`, reject messages from `spam.com`, remove messages from `spam.university` from the federated timeline and block reports (flags) from `whiny.whiner`: ```elixir -config :pleroma, :instance, - rewrite_policy: [Pleroma.Web.ActivityPub.MRF.SimplePolicy] +config :pleroma, :mrf, + policies: [Pleroma.Web.ActivityPub.MRF.SimplePolicy] config :pleroma, :mrf_simple, media_removal: ["illegalporn.biz"], @@ -75,7 +75,7 @@ The effects of MRF policies can be very drastic. It is important to use this fun ## Writing your own MRF Policy -As discussed above, the MRF system is a modular system that supports pluggable policies. This means that an admin may write a custom MRF policy in Elixir or any other language that runs on the Erlang VM, by specifying the module name in the `rewrite_policy` config setting. +As discussed above, the MRF system is a modular system that supports pluggable policies. This means that an admin may write a custom MRF policy in Elixir or any other language that runs on the Erlang VM, by specifying the module name in the `policies` config setting. For example, here is a sample policy module which rewrites all messages to "new message content": @@ -125,8 +125,8 @@ end If you save this file as `lib/pleroma/web/activity_pub/mrf/rewrite_policy.ex`, it will be included when you next rebuild Pleroma. You can enable it in the configuration like so: ```elixir -config :pleroma, :instance, - rewrite_policy: [ +config :pleroma, :mrf, + policies: [ Pleroma.Web.ActivityPub.MRF.SimplePolicy, Pleroma.Web.ActivityPub.MRF.RewritePolicy ] diff --git a/lib/pleroma/config/config_db.ex b/lib/pleroma/config/config_db.ex index 30bd51b05..c0f3fe888 100644 --- a/lib/pleroma/config/config_db.ex +++ b/lib/pleroma/config/config_db.ex @@ -54,13 +54,13 @@ def changeset(config, params \\ %{}) do defp create(params) do %ConfigDB{} - |> changeset(params) + |> changeset(params, transform?) |> Repo.insert() end defp update(%ConfigDB{} = config, %{value: value}) do config - |> changeset(%{value: value}) + |> changeset(%{value: value}, transform?) |> Repo.update() end @@ -167,7 +167,9 @@ defp only_full_update?(%ConfigDB{group: group, key: key}) do end) end - @spec delete(map()) :: {:ok, ConfigDB.t()} | {:error, Changeset.t()} + @spec delete(ConfigDB.t() | map()) :: {:ok, ConfigDB.t()} | {:error, Changeset.t()} + def delete(%ConfigDB{} = config), do: Repo.delete(config) + def delete(params) do search_opts = Map.delete(params, :subkeys) diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index b68ded01f..0a6c724fb 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -3,9 +3,23 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Config.DeprecationWarnings do + alias Pleroma.Config + require Logger alias Pleroma.Config + @type config_namespace() :: [atom()] + @type config_map() :: {config_namespace(), config_namespace(), String.t()} + + @mrf_config_map [ + {[:instance, :rewrite_policy], [:mrf, :policies], + "\n* `config :pleroma, :instance, rewrite_policy` is now `config :pleroma, :mrf, policies`"}, + {[:instance, :mrf_transparency], [:mrf, :transparency], + "\n* `config :pleroma, :instance, mrf_transparency` is now `config :pleroma, :mrf, transparency`"}, + {[:instance, :mrf_transparency_exclusions], [:mrf, :transparency_exclusions], + "\n* `config :pleroma, :instance, mrf_transparency_exclusions` is now `config :pleroma, :mrf, transparency_exclusions`"} + ] + def check_hellthread_threshold do if Config.get([:mrf_hellthread, :threshold]) do Logger.warn(""" @@ -39,5 +53,35 @@ def mrf_user_allowlist do def warn do check_hellthread_threshold() mrf_user_allowlist() + check_old_mrf_config() + end + + def check_old_mrf_config do + warning_preface = """ + !!!DEPRECATION WARNING!!! + Your config is using old namespaces for MRF configuration. They should work for now, but you are advised to change to new namespaces to prevent possible issues later: + """ + + move_namespace_and_warn(@mrf_config_map, warning_preface) + end + + @spec move_namespace_and_warn([config_map()], String.t()) :: :ok + def move_namespace_and_warn(config_map, warning_preface) do + warning = + Enum.reduce(config_map, "", fn + {old, new, err_msg}, acc -> + old_config = Config.get(old) + + if old_config do + Config.put(new, old_config) + acc <> err_msg + else + acc + end + end) + + if warning != "" do + Logger.warn(warning_preface <> warning) + end end end diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index 5a4a76085..206d6af52 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -16,7 +16,7 @@ def filter(policies, %{} = object) do def filter(%{} = object), do: get_policies() |> filter(object) def get_policies do - Pleroma.Config.get([:instance, :rewrite_policy], []) |> get_policies() + Pleroma.Config.get([:mrf, :policies], []) |> get_policies() end defp get_policies(policy) when is_atom(policy), do: [policy] @@ -51,7 +51,7 @@ def describe(policies) do get_policies() |> Enum.map(fn policy -> to_string(policy) |> String.split(".") |> List.last() end) - exclusions = Pleroma.Config.get([:instance, :mrf_transparency_exclusions]) + exclusions = Pleroma.Config.get([:mrf, :transparency_exclusions]) base = %{ diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index b7dcb1b86..9cea6bcf9 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -3,21 +3,23 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do - alias Pleroma.User - alias Pleroma.Web.ActivityPub.MRF @moduledoc "Filter activities depending on their origin instance" @behaviour Pleroma.Web.ActivityPub.MRF + alias Pleroma.Config + alias Pleroma.User + alias Pleroma.Web.ActivityPub.MRF + require Pleroma.Constants defp check_accept(%{host: actor_host} = _actor_info, object) do accepts = - Pleroma.Config.get([:mrf_simple, :accept]) + Config.get([:mrf_simple, :accept]) |> MRF.subdomains_regex() cond do accepts == [] -> {:ok, object} - actor_host == Pleroma.Config.get([Pleroma.Web.Endpoint, :url, :host]) -> {:ok, object} + actor_host == Config.get([Pleroma.Web.Endpoint, :url, :host]) -> {:ok, object} MRF.subdomain_match?(accepts, actor_host) -> {:ok, object} true -> {:reject, nil} end @@ -25,7 +27,7 @@ defp check_accept(%{host: actor_host} = _actor_info, object) do defp check_reject(%{host: actor_host} = _actor_info, object) do rejects = - Pleroma.Config.get([:mrf_simple, :reject]) + Config.get([:mrf_simple, :reject]) |> MRF.subdomains_regex() if MRF.subdomain_match?(rejects, actor_host) do @@ -41,7 +43,7 @@ defp check_media_removal( ) when length(child_attachment) > 0 do media_removal = - Pleroma.Config.get([:mrf_simple, :media_removal]) + Config.get([:mrf_simple, :media_removal]) |> MRF.subdomains_regex() object = @@ -65,7 +67,7 @@ defp check_media_nsfw( } = object ) do media_nsfw = - Pleroma.Config.get([:mrf_simple, :media_nsfw]) + Config.get([:mrf_simple, :media_nsfw]) |> MRF.subdomains_regex() object = @@ -85,7 +87,7 @@ defp check_media_nsfw(_actor_info, object), do: {:ok, object} defp check_ftl_removal(%{host: actor_host} = _actor_info, object) do timeline_removal = - Pleroma.Config.get([:mrf_simple, :federated_timeline_removal]) + Config.get([:mrf_simple, :federated_timeline_removal]) |> MRF.subdomains_regex() object = @@ -108,7 +110,7 @@ defp check_ftl_removal(%{host: actor_host} = _actor_info, object) do defp check_report_removal(%{host: actor_host} = _actor_info, %{"type" => "Flag"} = object) do report_removal = - Pleroma.Config.get([:mrf_simple, :report_removal]) + Config.get([:mrf_simple, :report_removal]) |> MRF.subdomains_regex() if MRF.subdomain_match?(report_removal, actor_host) do @@ -122,7 +124,7 @@ defp check_report_removal(_actor_info, object), do: {:ok, object} defp check_avatar_removal(%{host: actor_host} = _actor_info, %{"icon" => _icon} = object) do avatar_removal = - Pleroma.Config.get([:mrf_simple, :avatar_removal]) + Config.get([:mrf_simple, :avatar_removal]) |> MRF.subdomains_regex() if MRF.subdomain_match?(avatar_removal, actor_host) do @@ -136,7 +138,7 @@ defp check_avatar_removal(_actor_info, object), do: {:ok, object} defp check_banner_removal(%{host: actor_host} = _actor_info, %{"image" => _image} = object) do banner_removal = - Pleroma.Config.get([:mrf_simple, :banner_removal]) + Config.get([:mrf_simple, :banner_removal]) |> MRF.subdomains_regex() if MRF.subdomain_match?(banner_removal, actor_host) do @@ -197,10 +199,10 @@ def filter(object), do: {:ok, object} @impl true def describe do - exclusions = Pleroma.Config.get([:instance, :mrf_transparency_exclusions]) + exclusions = Config.get([:mrf, :transparency_exclusions]) mrf_simple = - Pleroma.Config.get(:mrf_simple) + Config.get(:mrf_simple) |> Enum.map(fn {k, v} -> {k, Enum.reject(v, fn v -> v in exclusions end)} end) |> Enum.into(%{}) diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index c498fe632..4f0ae4e8f 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -78,7 +78,7 @@ def features do def federation do quarantined = Config.get([:instance, :quarantined_instances], []) - if Config.get([:instance, :mrf_transparency]) do + if Config.get([:mrf, :transparency]) do {:ok, data} = MRF.describe() data diff --git a/priv/repo/migrations/20200323122421_mrf_config_move_from_instance_namespace.exs b/priv/repo/migrations/20200323122421_mrf_config_move_from_instance_namespace.exs new file mode 100644 index 000000000..6f6094613 --- /dev/null +++ b/priv/repo/migrations/20200323122421_mrf_config_move_from_instance_namespace.exs @@ -0,0 +1,39 @@ +defmodule Pleroma.Repo.Migrations.MrfConfigMoveFromInstanceNamespace do + use Ecto.Migration + + alias Pleroma.ConfigDB + + @old_keys [:rewrite_policy, :mrf_transparency, :mrf_transparency_exclusions] + def change do + config = ConfigDB.get_by_params(%{group: ":pleroma", key: ":instance"}) + + if config do + old_instance = ConfigDB.from_binary(config.value) + + mrf = + old_instance + |> Keyword.take(@old_keys) + |> Keyword.new(fn + {:rewrite_policy, policies} -> {:policies, policies} + {:mrf_transparency, transparency} -> {:transparency, transparency} + {:mrf_transparency_exclusions, exclusions} -> {:transparency_exclusions, exclusions} + end) + + if mrf != [] do + {:ok, _} = + ConfigDB.create( + %{group: ":pleroma", key: ":mrf", value: ConfigDB.to_binary(mrf)}, + false + ) + + new_instance = Keyword.drop(old_instance, @old_keys) + + if new_instance != [] do + {:ok, _} = ConfigDB.update(config, %{value: ConfigDB.to_binary(new_instance)}, false) + else + {:ok, _} = ConfigDB.delete(config) + end + end + end + end +end diff --git a/test/config/deprecation_warnings_test.exs b/test/config/deprecation_warnings_test.exs new file mode 100644 index 000000000..548ee87b0 --- /dev/null +++ b/test/config/deprecation_warnings_test.exs @@ -0,0 +1,57 @@ +defmodule Pleroma.Config.DeprecationWarningsTest do + use ExUnit.Case, async: true + use Pleroma.Tests.Helpers + + import ExUnit.CaptureLog + + test "check_old_mrf_config/0" do + clear_config([:instance, :rewrite_policy], Pleroma.Web.ActivityPub.MRF.NoOpPolicy) + clear_config([:instance, :mrf_transparency], true) + clear_config([:instance, :mrf_transparency_exclusions], []) + + assert capture_log(fn -> Pleroma.Config.DeprecationWarnings.check_old_mrf_config() end) =~ + """ + !!!DEPRECATION WARNING!!! + Your config is using old namespaces for MRF configuration. They should work for now, but you are advised to change to new namespaces to prevent possible issues later: + + * `config :pleroma, :instance, rewrite_policy` is now `config :pleroma, :mrf, policies` + * `config :pleroma, :instance, mrf_transparency` is now `config :pleroma, :mrf, transparency` + * `config :pleroma, :instance, mrf_transparency_exclusions` is now `config :pleroma, :mrf, transparency_exclusions` + """ + end + + test "move_namespace_and_warn/2" do + old_group1 = [:group, :key] + old_group2 = [:group, :key2] + old_group3 = [:group, :key3] + + new_group1 = [:another_group, :key4] + new_group2 = [:another_group, :key5] + new_group3 = [:another_group, :key6] + + clear_config(old_group1, 1) + clear_config(old_group2, 2) + clear_config(old_group3, 3) + + clear_config(new_group1) + clear_config(new_group2) + clear_config(new_group3) + + config_map = [ + {old_group1, new_group1, "\n error :key"}, + {old_group2, new_group2, "\n error :key2"}, + {old_group3, new_group3, "\n error :key3"} + ] + + assert capture_log(fn -> + Pleroma.Config.DeprecationWarnings.move_namespace_and_warn( + config_map, + "Warning preface" + ) + end) =~ "Warning preface\n error :key\n error :key2\n error :key3" + + assert Pleroma.Config.get(new_group1) == 1 + assert Pleroma.Config.get(new_group2) == 2 + assert Pleroma.Config.get(new_group3) == 3 + end +end diff --git a/test/tasks/config_test.exs b/test/tasks/config_test.exs index e1bddfebf..bae171074 100644 --- a/test/tasks/config_test.exs +++ b/test/tasks/config_test.exs @@ -120,14 +120,11 @@ test "load a settings with large values and pass to file", %{temp_file: temp_fil federation_reachability_timeout_days: 7, federation_publisher_modules: [Pleroma.Web.ActivityPub.Publisher], allow_relay: true, - rewrite_policy: Pleroma.Web.ActivityPub.MRF.NoOpPolicy, public: true, quarantined_instances: [], managed_config: true, static_dir: "instance/static/", allowed_post_formats: ["text/plain", "text/html", "text/markdown", "text/bbcode"], - mrf_transparency: true, - mrf_transparency_exclusions: [], autofollowed_nicknames: [], max_pinned_statuses: 1, attachment_links: false, @@ -174,7 +171,7 @@ test "load a settings with large values and pass to file", %{temp_file: temp_fil end assert file == - "#{header}\n\nconfig :pleroma, :instance,\n name: \"Pleroma\",\n email: \"example@example.com\",\n notify_email: \"noreply@example.com\",\n description: \"A Pleroma instance, an alternative fediverse server\",\n limit: 5000,\n chat_limit: 5000,\n remote_limit: 100_000,\n upload_limit: 16_000_000,\n avatar_upload_limit: 2_000_000,\n background_upload_limit: 4_000_000,\n banner_upload_limit: 4_000_000,\n poll_limits: %{\n max_expiration: 31_536_000,\n max_option_chars: 200,\n max_options: 20,\n min_expiration: 0\n },\n registrations_open: true,\n federating: true,\n federation_incoming_replies_max_depth: 100,\n federation_reachability_timeout_days: 7,\n federation_publisher_modules: [Pleroma.Web.ActivityPub.Publisher],\n allow_relay: true,\n rewrite_policy: Pleroma.Web.ActivityPub.MRF.NoOpPolicy,\n public: true,\n quarantined_instances: [],\n managed_config: true,\n static_dir: \"instance/static/\",\n allowed_post_formats: [\"text/plain\", \"text/html\", \"text/markdown\", \"text/bbcode\"],\n mrf_transparency: true,\n mrf_transparency_exclusions: [],\n autofollowed_nicknames: [],\n max_pinned_statuses: 1,\n attachment_links: false,\n welcome_user_nickname: nil,\n welcome_message: nil,\n max_report_comment_size: 1000,\n safe_dm_mentions: false,\n healthcheck: false,\n remote_post_retention_days: 90,\n skip_thread_containment: true,\n limit_to_local_content: :unauthenticated,\n user_bio_length: 5000,\n user_name_length: 100,\n max_account_fields: 10,\n max_remote_account_fields: 20,\n account_field_name_length: 512,\n account_field_value_length: 2048,\n external_user_synchronization: true,\n extended_nickname_format: true,\n multi_factor_authentication: [\n totp: [digits: 6, period: 30],\n backup_codes: [number: 2, length: 6]\n ]\n" + "#{header}\n\nconfig :pleroma, :instance,\n name: \"Pleroma\",\n email: \"example@example.com\",\n notify_email: \"noreply@example.com\",\n description: \"A Pleroma instance, an alternative fediverse server\",\n limit: 5000,\n chat_limit: 5000,\n remote_limit: 100_000,\n upload_limit: 16_000_000,\n avatar_upload_limit: 2_000_000,\n background_upload_limit: 4_000_000,\n banner_upload_limit: 4_000_000,\n poll_limits: %{\n max_expiration: 31_536_000,\n max_option_chars: 200,\n max_options: 20,\n min_expiration: 0\n },\n registrations_open: true,\n federating: true,\n federation_incoming_replies_max_depth: 100,\n federation_reachability_timeout_days: 7,\n federation_publisher_modules: [Pleroma.Web.ActivityPub.Publisher],\n allow_relay: true,\n public: true,\n quarantined_instances: [],\n managed_config: true,\n static_dir: \"instance/static/\",\n allowed_post_formats: [\"text/plain\", \"text/html\", \"text/markdown\", \"text/bbcode\"],\n autofollowed_nicknames: [],\n max_pinned_statuses: 1,\n attachment_links: false,\n welcome_user_nickname: nil,\n welcome_message: nil,\n max_report_comment_size: 1000,\n safe_dm_mentions: false,\n healthcheck: false,\n remote_post_retention_days: 90,\n skip_thread_containment: true,\n limit_to_local_content: :unauthenticated,\n user_bio_length: 5000,\n user_name_length: 100,\n max_account_fields: 10,\n max_remote_account_fields: 20,\n account_field_name_length: 512,\n account_field_value_length: 2048,\n external_user_synchronization: true,\n extended_nickname_format: true,\n multi_factor_authentication: [\n totp: [digits: 6, period: 30],\n backup_codes: [number: 2, length: 6]\n ]\n" end end end diff --git a/test/web/activity_pub/mrf/mrf_test.exs b/test/web/activity_pub/mrf/mrf_test.exs index c941066f2..a63b25423 100644 --- a/test/web/activity_pub/mrf/mrf_test.exs +++ b/test/web/activity_pub/mrf/mrf_test.exs @@ -60,8 +60,6 @@ test "matches are case-insensitive" do end describe "describe/0" do - setup do: clear_config([:instance, :rewrite_policy]) - test "it works as expected with noop policy" do expected = %{ mrf_policies: ["NoOpPolicy"], @@ -72,7 +70,7 @@ test "it works as expected with noop policy" do end test "it works as expected with mock policy" do - Pleroma.Config.put([:instance, :rewrite_policy], [MRFModuleMock]) + clear_config([:mrf, :policies], [MRFModuleMock]) expected = %{ mrf_policies: ["MRFModuleMock"], diff --git a/test/web/federator_test.exs b/test/web/federator_test.exs index de90aa6e0..592fdccd1 100644 --- a/test/web/federator_test.exs +++ b/test/web/federator_test.exs @@ -23,7 +23,7 @@ defmodule Pleroma.Web.FederatorTest do setup_all do: clear_config([:instance, :federating], true) setup do: clear_config([:instance, :allow_relay]) - setup do: clear_config([:instance, :rewrite_policy]) + setup do: clear_config([:mrf, :policies]) setup do: clear_config([:mrf_keyword]) describe "Publish an activity" do @@ -158,7 +158,7 @@ test "it does not crash if MRF rejects the post" do Pleroma.Config.put([:mrf_keyword, :reject], ["lain"]) Pleroma.Config.put( - [:instance, :rewrite_policy], + [:mrf, :policies], Pleroma.Web.ActivityPub.MRF.KeywordPolicy ) diff --git a/test/web/node_info_test.exs b/test/web/node_info_test.exs index 00925caad..06b33607f 100644 --- a/test/web/node_info_test.exs +++ b/test/web/node_info_test.exs @@ -67,10 +67,10 @@ test "returns software.repository field in nodeinfo 2.1", %{conn: conn} do end test "returns fieldsLimits field", %{conn: conn} do - Config.put([:instance, :max_account_fields], 10) - Config.put([:instance, :max_remote_account_fields], 15) - Config.put([:instance, :account_field_name_length], 255) - Config.put([:instance, :account_field_value_length], 2048) + clear_config([:instance, :max_account_fields], 10) + clear_config([:instance, :max_remote_account_fields], 15) + clear_config([:instance, :account_field_name_length], 255) + clear_config([:instance, :account_field_value_length], 2048) response = conn @@ -84,8 +84,7 @@ test "returns fieldsLimits field", %{conn: conn} do end test "it returns the safe_dm_mentions feature if enabled", %{conn: conn} do - option = Config.get([:instance, :safe_dm_mentions]) - Config.put([:instance, :safe_dm_mentions], true) + clear_config([:instance, :safe_dm_mentions], true) response = conn @@ -102,8 +101,6 @@ test "it returns the safe_dm_mentions feature if enabled", %{conn: conn} do |> json_response(:ok) refute "safe_dm_mentions" in response["metadata"]["features"] - - Config.put([:instance, :safe_dm_mentions], option) end describe "`metadata/federation/enabled`" do @@ -156,14 +153,11 @@ test "it shows default features flags", %{conn: conn} do end test "it shows MRF transparency data if enabled", %{conn: conn} do - config = Config.get([:instance, :rewrite_policy]) - Config.put([:instance, :rewrite_policy], [Pleroma.Web.ActivityPub.MRF.SimplePolicy]) - - option = Config.get([:instance, :mrf_transparency]) - Config.put([:instance, :mrf_transparency], true) + clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.SimplePolicy]) + clear_config([:mrf, :transparency], true) simple_config = %{"reject" => ["example.com"]} - Config.put(:mrf_simple, simple_config) + clear_config(:mrf_simple, simple_config) response = conn @@ -171,26 +165,17 @@ test "it shows MRF transparency data if enabled", %{conn: conn} do |> json_response(:ok) assert response["metadata"]["federation"]["mrf_simple"] == simple_config - - Config.put([:instance, :rewrite_policy], config) - Config.put([:instance, :mrf_transparency], option) - Config.put(:mrf_simple, %{}) end test "it performs exclusions from MRF transparency data if configured", %{conn: conn} do - config = Config.get([:instance, :rewrite_policy]) - Config.put([:instance, :rewrite_policy], [Pleroma.Web.ActivityPub.MRF.SimplePolicy]) - - option = Config.get([:instance, :mrf_transparency]) - Config.put([:instance, :mrf_transparency], true) - - exclusions = Config.get([:instance, :mrf_transparency_exclusions]) - Config.put([:instance, :mrf_transparency_exclusions], ["other.site"]) + clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.SimplePolicy]) + clear_config([:mrf, :transparency], true) + clear_config([:mrf, :transparency_exclusions], ["other.site"]) simple_config = %{"reject" => ["example.com", "other.site"]} - expected_config = %{"reject" => ["example.com"]} + clear_config(:mrf_simple, simple_config) - Config.put(:mrf_simple, simple_config) + expected_config = %{"reject" => ["example.com"]} response = conn @@ -199,10 +184,5 @@ test "it performs exclusions from MRF transparency data if configured", %{conn: assert response["metadata"]["federation"]["mrf_simple"] == expected_config assert response["metadata"]["federation"]["exclusions"] == true - - Config.put([:instance, :rewrite_policy], config) - Config.put([:instance, :mrf_transparency], option) - Config.put([:instance, :mrf_transparency_exclusions], exclusions) - Config.put(:mrf_simple, %{}) end end From b66e6eb521c2901da119179016c99751cb5e6f95 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Tue, 16 Jun 2020 19:03:45 +0300 Subject: [PATCH 287/401] fixes for tests --- docs/configuration/storing_remote_media.md | 4 ++-- lib/pleroma/config/config_db.ex | 4 ++-- test/web/activity_pub/activity_pub_test.exs | 4 ++-- test/workers/cron/purge_expired_activities_worker_test.exs | 6 +----- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/docs/configuration/storing_remote_media.md b/docs/configuration/storing_remote_media.md index 7e91fe7d9..c01985d25 100644 --- a/docs/configuration/storing_remote_media.md +++ b/docs/configuration/storing_remote_media.md @@ -33,6 +33,6 @@ as soon as the post is received by your instance. Add to your `prod.secret.exs`: ``` -config :pleroma, :instance, - rewrite_policy: [Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy] +config :pleroma, :mrf, + policies: [Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy] ``` diff --git a/lib/pleroma/config/config_db.ex b/lib/pleroma/config/config_db.ex index c0f3fe888..134116863 100644 --- a/lib/pleroma/config/config_db.ex +++ b/lib/pleroma/config/config_db.ex @@ -54,13 +54,13 @@ def changeset(config, params \\ %{}) do defp create(params) do %ConfigDB{} - |> changeset(params, transform?) + |> changeset(params) |> Repo.insert() end defp update(%ConfigDB{} = config, %{value: value}) do config - |> changeset(%{value: value}, transform?) + |> changeset(%{value: value}) |> Repo.update() end diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 7693f6400..1c684df1a 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -2055,11 +2055,11 @@ test "it just returns the input if the user has no following/follower addresses" end describe "global activity expiration" do - setup do: clear_config([:instance, :rewrite_policy]) + setup do: clear_config([:mrf, :policies]) test "creates an activity expiration for local Create activities" do Pleroma.Config.put( - [:instance, :rewrite_policy], + [:mrf, :policies], Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy ) diff --git a/test/workers/cron/purge_expired_activities_worker_test.exs b/test/workers/cron/purge_expired_activities_worker_test.exs index 6d2991a60..b1db59fdf 100644 --- a/test/workers/cron/purge_expired_activities_worker_test.exs +++ b/test/workers/cron/purge_expired_activities_worker_test.exs @@ -13,7 +13,6 @@ defmodule Pleroma.Workers.Cron.PurgeExpiredActivitiesWorkerTest do setup do clear_config([ActivityExpiration, :enabled]) - clear_config([:instance, :rewrite_policy]) end test "deletes an expiration activity" do @@ -42,10 +41,7 @@ test "deletes an expiration activity" do test "works with ActivityExpirationPolicy" do Pleroma.Config.put([ActivityExpiration, :enabled], true) - Pleroma.Config.put( - [:instance, :rewrite_policy], - Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy - ) + clear_config([:mrf, :policies], Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy) user = insert(:user) From 5c0e1039ce41a2717598992a590658d4d079451c Mon Sep 17 00:00:00 2001 From: rinpatch <rinpatch@sdf.org> Date: Tue, 16 Jun 2020 23:45:59 +0300 Subject: [PATCH 288/401] Chunk the notification type backfill migration Long-term we want that migration to be done entirely in SQL, but for now this is a hotfix to not cause OOMs on large databases. This is using a homegrown version of `Repo.stream`, it's worse in terms of performance than the upstream since it doesn't use the same prepared query for chunk queries, but unlike the upstream it supports preloads. --- .../migration_helper/notification_backfill.ex | 2 +- lib/pleroma/repo.ex | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/migration_helper/notification_backfill.ex b/lib/pleroma/migration_helper/notification_backfill.ex index 09647d12a..b3770307a 100644 --- a/lib/pleroma/migration_helper/notification_backfill.ex +++ b/lib/pleroma/migration_helper/notification_backfill.ex @@ -18,7 +18,7 @@ def fill_in_notification_types do ) query - |> Repo.all() + |> Repo.chunk_stream(100) |> Enum.each(fn notification -> type = notification.activity diff --git a/lib/pleroma/repo.ex b/lib/pleroma/repo.ex index f62138466..6d85d70bc 100644 --- a/lib/pleroma/repo.ex +++ b/lib/pleroma/repo.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Repo do adapter: Ecto.Adapters.Postgres, migration_timestamps: [type: :naive_datetime_usec] + import Ecto.Query require Logger defmodule Instrumenter do @@ -78,6 +79,33 @@ def check_migrations_applied!() do :ok end end + + def chunk_stream(query, chunk_size) do + # We don't actually need start and end funcitons of resource streaming, + # but it seems to be the only way to not fetch records one-by-one and + # have individual records be the elements of the stream, instead of + # lists of records + Stream.resource( + fn -> 0 end, + fn + last_id -> + query + |> order_by(asc: :id) + |> where([r], r.id > ^last_id) + |> limit(^chunk_size) + |> all() + |> case do + [] -> + {:halt, last_id} + + records -> + last_id = List.last(records).id + {records, last_id} + end + end, + fn _ -> :ok end + ) + end end defmodule Pleroma.Repo.UnappliedMigrationsError do From 55d8263c0040b32075b9bb90ab1d6693e627f6bf Mon Sep 17 00:00:00 2001 From: rinpatch <rinpatch@sdf.org> Date: Wed, 17 Jun 2020 02:27:28 +0300 Subject: [PATCH 289/401] Update OTP releases to official images of 1.10.3 This is necessary since we bumped required version of elixir to 1.9. The dlsym bug should be gone by now. --- .gitlab-ci.yml | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index bc7b289a2..b4bd59b43 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -170,8 +170,7 @@ stop_review_app: amd64: stage: release - # TODO: Replace with upstream image when 1.9.0 comes out - image: rinpatch/elixir:1.9.0-rc.0 + image: elixir:1.10.3 only: &release-only - stable@pleroma/pleroma - develop@pleroma/pleroma @@ -208,8 +207,7 @@ amd64-musl: stage: release artifacts: *release-artifacts only: *release-only - # TODO: Replace with upstream image when 1.9.0 comes out - image: rinpatch/elixir:1.9.0-rc.0-alpine + image: elixir:1.10.3-alpine cache: *release-cache variables: *release-variables before_script: &before-release-musl @@ -225,8 +223,7 @@ arm: only: *release-only tags: - arm32 - # TODO: Replace with upstream image when 1.9.0 comes out - image: rinpatch/elixir:1.9.0-rc.0-arm + image: elixir:1.10.3 cache: *release-cache variables: *release-variables before_script: *before-release @@ -238,8 +235,7 @@ arm-musl: only: *release-only tags: - arm32 - # TODO: Replace with upstream image when 1.9.0 comes out - image: rinpatch/elixir:1.9.0-rc.0-arm-alpine + image: elixir:1.10.3-alpine cache: *release-cache variables: *release-variables before_script: *before-release-musl @@ -251,8 +247,7 @@ arm64: only: *release-only tags: - arm - # TODO: Replace with upstream image when 1.9.0 comes out - image: rinpatch/elixir:1.9.0-rc.0-arm64 + image: elixir:1.10.3 cache: *release-cache variables: *release-variables before_script: *before-release @@ -265,7 +260,7 @@ arm64-musl: tags: - arm # TODO: Replace with upstream image when 1.9.0 comes out - image: rinpatch/elixir:1.9.0-rc.0-arm64-alpine + image: elixir:1.10.3-alpine cache: *release-cache variables: *release-variables before_script: *before-release-musl From 281ecd6b30a165843c5b6a1899894646dc25c0f9 Mon Sep 17 00:00:00 2001 From: rinpatch <rinpatch@sdf.org> Date: Wed, 17 Jun 2020 02:29:32 +0300 Subject: [PATCH 290/401] CHANGELOG.md: mention minimal elixir version update --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f291ad2a..3ee13904f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] ### Changed +- **Breaking:** Elixir >=1.9 is now required (was >= 1.8) - In Conversations, return only direct messages as `last_status` - MFR policy to set global expiration for all local Create activities - OGP rich media parser merged with TwitterCard From 02a5648febb8a508116c29e2271e1ade2ffafb2d Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Wed, 17 Jun 2020 09:15:35 +0300 Subject: [PATCH 291/401] fixed migration the settings to DB --- lib/mix/tasks/pleroma/config.ex | 1 + lib/pleroma/config/loader.ex | 1 - ...510135645_add_fts_index_to_objects_two.exs | 33 +++++++++++-------- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index f1b3a8766..65691f9c1 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -52,6 +52,7 @@ def migrate_to_db(file_path \\ nil) do defp do_migrate_to_db(config_file) do if File.exists?(config_file) do + shell_info("Running migrate settings from file: #{Path.expand(config_file)}") Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;") Ecto.Adapters.SQL.query!(Repo, "ALTER SEQUENCE config_id_seq RESTART;") diff --git a/lib/pleroma/config/loader.ex b/lib/pleroma/config/loader.ex index 0f3ecf1ed..76559e70c 100644 --- a/lib/pleroma/config/loader.ex +++ b/lib/pleroma/config/loader.ex @@ -8,7 +8,6 @@ defmodule Pleroma.Config.Loader do Pleroma.Web.Endpoint, :env, :configurable_from_database, - :database, :swarm ] diff --git a/priv/repo/optional_migrations/rum_indexing/20190510135645_add_fts_index_to_objects_two.exs b/priv/repo/optional_migrations/rum_indexing/20190510135645_add_fts_index_to_objects_two.exs index 6227769dc..79bde163d 100644 --- a/priv/repo/optional_migrations/rum_indexing/20190510135645_add_fts_index_to_objects_two.exs +++ b/priv/repo/optional_migrations/rum_indexing/20190510135645_add_fts_index_to_objects_two.exs @@ -2,24 +2,29 @@ defmodule Pleroma.Repo.Migrations.AddFtsIndexToObjectsTwo do use Ecto.Migration def up do - execute("create extension if not exists rum") - drop_if_exists index(:objects, ["(to_tsvector('english', data->>'content'))"], using: :gin, name: :objects_fts) - alter table(:objects) do - add(:fts_content, :tsvector) - end + if Pleroma.Config.get([:database, :rum_enabled]) do + execute("create extension if not exists rum") + drop_if_exists index(:objects, ["(to_tsvector('english', data->>'content'))"], using: :gin, name: :objects_fts) + alter table(:objects) do + add(:fts_content, :tsvector) + end - execute("CREATE FUNCTION objects_fts_update() RETURNS trigger AS $$ - begin + execute("CREATE FUNCTION objects_fts_update() RETURNS trigger AS $$ + begin new.fts_content := to_tsvector('english', new.data->>'content'); return new; + end + $$ LANGUAGE plpgsql") + execute("create index if not exists objects_fts on objects using RUM (fts_content rum_tsvector_addon_ops, inserted_at) with (attach = 'inserted_at', to = 'fts_content');") + + execute("CREATE TRIGGER tsvectorupdate BEFORE INSERT OR UPDATE ON objects + FOR EACH ROW EXECUTE PROCEDURE objects_fts_update()") + + execute("UPDATE objects SET updated_at = NOW()") + else + raise Ecto.MigrationError, + message: "Migration is not allowed. You can change this behavior by setting `database/rum_enabled` to true." end - $$ LANGUAGE plpgsql") - execute("create index if not exists objects_fts on objects using RUM (fts_content rum_tsvector_addon_ops, inserted_at) with (attach = 'inserted_at', to = 'fts_content');") - - execute("CREATE TRIGGER tsvectorupdate BEFORE INSERT OR UPDATE ON objects - FOR EACH ROW EXECUTE PROCEDURE objects_fts_update()") - - execute("UPDATE objects SET updated_at = NOW()") end def down do From a77b0388f48084bd3a420855a232bf2e504f0bce Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Wed, 17 Jun 2020 10:31:06 +0300 Subject: [PATCH 292/401] credo fix --- lib/pleroma/web/activity_pub/object_validator.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 3d699e8a5..6a83a2c33 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -9,8 +9,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do the system. """ - alias Pleroma.Object alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator From abda3f2d92eda0888e018cea0a2fffb21d9e0a60 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Wed, 17 Jun 2020 10:47:20 +0300 Subject: [PATCH 293/401] suggestion for changelog --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12e8d58e6..6095cf139 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed - MFR policy to set global expiration for all local Create activities - OGP rich media parser merged with TwitterCard -- Configuration: `rewrite_policy` renamed to `policies` and moved from `instance` to `mrf` group. Old config namespace is deprecated. -- Configuration: `mrf_transparency` renamed to `transparency` and moved from `instance` to `mrf` group. Old config namespace is deprecated. -- Configuration: `mrf_transparency_exclusions` renamed to `transparency_exclusions` and moved from `instance` to `mrf` group. Old config namespace is deprecated. +- Configuration: `:instance, rewrite_policy` moved to `:mrf, policies`, `:instance, :mrf_transparency` moved to `:mrf, :transparency`, `:instance, :mrf_transparency_exclusions` moved to `:mrf, :transparency_exclusions`. Old config namespace is deprecated. <details> <summary>API Changes</summary> From 9a82de219c264f467b485316570c5425e3fe2f00 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Wed, 17 Jun 2020 10:50:05 +0300 Subject: [PATCH 294/401] formatting --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6095cf139..fab87b569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] + ### Changed - MFR policy to set global expiration for all local Create activities - OGP rich media parser merged with TwitterCard From 90613348ed8078e1906f7ffd18eebfa1a3b7f25a Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 12:56:13 +0000 Subject: [PATCH 295/401] Apply suggestion to docs/API/admin_api.md --- docs/API/admin_api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index 6659b605d..8a3d60187 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -1253,7 +1253,7 @@ Loads json generated from `config/descriptions.exs`. - Authentication: required - Params: - - `urls` + - `urls` (array) - Response: From abfb1c756b62c24589d2881d77bf5974a80809d3 Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 12:56:17 +0000 Subject: [PATCH 296/401] Apply suggestion to docs/API/admin_api.md --- docs/API/admin_api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index 8a3d60187..c7f56cf5f 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -1273,8 +1273,8 @@ Loads json generated from `config/descriptions.exs`. - Authentication: required - Params: - - `urls` - - `ban` + - `urls` (array) + - `ban` (boolean) - Response: From 74fd761637f737822d01aed945b6e0c75ced7008 Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 12:56:30 +0000 Subject: [PATCH 297/401] Apply suggestion to lib/pleroma/web/media_proxy/invalidation.ex --- lib/pleroma/web/media_proxy/invalidation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/media_proxy/invalidation.ex b/lib/pleroma/web/media_proxy/invalidation.ex index 83ff8589c..6da7eb720 100644 --- a/lib/pleroma/web/media_proxy/invalidation.ex +++ b/lib/pleroma/web/media_proxy/invalidation.ex @@ -32,6 +32,6 @@ defp do_purge(urls) do def prepare_urls(urls) do urls |> List.wrap() - |> Enum.map(&MediaProxy.url(&1)) + |> Enum.map(&MediaProxy.url/1) end end From 1b45bc7b2ac53e56a4868da7e2b5b198d16306ab Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 12:58:08 +0000 Subject: [PATCH 298/401] Apply suggestion to test/web/admin_api/controllers/media_proxy_cache_controller_test.exs --- .../admin_api/controllers/media_proxy_cache_controller_test.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs index 76a96f46f..ddaf39f14 100644 --- a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs +++ b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs @@ -14,7 +14,6 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheControllerTest do setup do on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) - :ok end setup do From 793a53f1ec18a42f15f58494e40ed3c37d35f95c Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 12:58:16 +0000 Subject: [PATCH 299/401] Apply suggestion to test/web/admin_api/controllers/media_proxy_cache_controller_test.exs --- .../admin_api/controllers/media_proxy_cache_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs index ddaf39f14..81e20d001 100644 --- a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs +++ b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs @@ -72,7 +72,7 @@ test "shows banned MediaProxy URLs", %{conn: conn} do end end - describe "DELETE /api/pleroma/admin/media_proxy_caches/delete" do + describe "POST /api/pleroma/admin/media_proxy_caches/delete" do test "deleted MediaProxy URLs from banned", %{conn: conn} do MediaProxy.put_in_deleted_urls([ "http://localhost:4001/media/a688346.jpg", From 6d33a3a51bb8ff0afdf7f4f9880f8f5c5f2dfebc Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 12:58:28 +0000 Subject: [PATCH 300/401] Apply suggestion to test/web/admin_api/controllers/media_proxy_cache_controller_test.exs --- .../admin_api/controllers/media_proxy_cache_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs index 81e20d001..42a3c0dd8 100644 --- a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs +++ b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs @@ -93,7 +93,7 @@ test "deleted MediaProxy URLs from banned", %{conn: conn} do end end - describe "PURGE /api/pleroma/admin/media_proxy_caches/purge" do + describe "POST /api/pleroma/admin/media_proxy_caches/purge" do test "perform invalidates cache of MediaProxy", %{conn: conn} do urls = [ "http://example.com/media/a688346.jpg", From 11b22a42293ec2ac0e66897bf4b29b5363913c19 Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 12:58:33 +0000 Subject: [PATCH 301/401] Apply suggestion to test/web/media_proxy/invalidations/http_test.exs --- test/web/media_proxy/invalidations/http_test.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/web/media_proxy/invalidations/http_test.exs b/test/web/media_proxy/invalidations/http_test.exs index 09e7ca0fb..9d181dd8b 100644 --- a/test/web/media_proxy/invalidations/http_test.exs +++ b/test/web/media_proxy/invalidations/http_test.exs @@ -7,7 +7,6 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.HttpTest do setup do on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) - :ok end test "logs hasn't error message when request is valid" do From 2991aae4c4e4ae430539c1e6fac53fb8a0c991e9 Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 12:58:38 +0000 Subject: [PATCH 302/401] Apply suggestion to test/web/media_proxy/invalidations/script_test.exs --- test/web/media_proxy/invalidations/script_test.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/web/media_proxy/invalidations/script_test.exs b/test/web/media_proxy/invalidations/script_test.exs index c69cec07a..8e155b705 100644 --- a/test/web/media_proxy/invalidations/script_test.exs +++ b/test/web/media_proxy/invalidations/script_test.exs @@ -6,7 +6,6 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.ScriptTest do setup do on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) - :ok end test "it logger error when script not found" do From 078d687e6ed66f921d7f54114f2dc6bf4abbf237 Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 12:58:50 +0000 Subject: [PATCH 303/401] Apply suggestion to test/web/media_proxy/media_proxy_controller_test.exs --- test/web/media_proxy/media_proxy_controller_test.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/web/media_proxy/media_proxy_controller_test.exs b/test/web/media_proxy/media_proxy_controller_test.exs index 2b6b25221..72da98a6a 100644 --- a/test/web/media_proxy/media_proxy_controller_test.exs +++ b/test/web/media_proxy/media_proxy_controller_test.exs @@ -12,7 +12,6 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do setup do on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) - :ok end test "it returns 404 when MediaProxy disabled", %{conn: conn} do From 44ce97a9c9e90d5906386d1b51dea144cd258c32 Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 13:12:32 +0000 Subject: [PATCH 304/401] Apply suggestion to lib/pleroma/web/media_proxy/invalidations/script.ex --- lib/pleroma/web/media_proxy/invalidations/script.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/media_proxy/invalidations/script.ex b/lib/pleroma/web/media_proxy/invalidations/script.ex index 0217b119d..b0f44e8e2 100644 --- a/lib/pleroma/web/media_proxy/invalidations/script.ex +++ b/lib/pleroma/web/media_proxy/invalidations/script.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.Script do require Logger @impl Pleroma.Web.MediaProxy.Invalidation - def purge(urls, opts \\ %{}) do + def purge(urls, opts \\ []) do args = urls |> List.wrap() From 9a371bf5f6245b0f372bec7af15e2f4fc41d4ab7 Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 13:12:38 +0000 Subject: [PATCH 305/401] Apply suggestion to lib/pleroma/web/media_proxy/invalidations/script.ex --- lib/pleroma/web/media_proxy/invalidations/script.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/media_proxy/invalidations/script.ex b/lib/pleroma/web/media_proxy/invalidations/script.ex index b0f44e8e2..d32ffc50b 100644 --- a/lib/pleroma/web/media_proxy/invalidations/script.ex +++ b/lib/pleroma/web/media_proxy/invalidations/script.ex @@ -18,7 +18,7 @@ def purge(urls, opts \\ []) do |> Enum.join(" ") opts - |> Keyword.get(:script_path, nil) + |> Keyword.get(:script_path) |> do_purge([args]) |> handle_result(urls) end From 96493da7bdab4ff4a51cbebf18df4127ddc47990 Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 13:14:01 +0000 Subject: [PATCH 306/401] Apply suggestion to test/web/media_proxy/invalidation_test.exs --- test/web/media_proxy/invalidation_test.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/web/media_proxy/invalidation_test.exs b/test/web/media_proxy/invalidation_test.exs index 3a9fa8c88..bf9af251c 100644 --- a/test/web/media_proxy/invalidation_test.exs +++ b/test/web/media_proxy/invalidation_test.exs @@ -13,7 +13,6 @@ defmodule Pleroma.Web.MediaProxy.InvalidationTest do setup do on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) - :ok end describe "Invalidation.Http" do From d4b5a9730e8fe7adf5a2eca15bf40fafff85f30d Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn <egor@kislitsyn.com> Date: Wed, 17 Jun 2020 18:47:59 +0400 Subject: [PATCH 307/401] Remove `poll` from `notification_type` OpenAPI spec --- lib/pleroma/web/api_spec/operations/notification_operation.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/notification_operation.ex b/lib/pleroma/web/api_spec/operations/notification_operation.ex index c966b553a..41328b5f2 100644 --- a/lib/pleroma/web/api_spec/operations/notification_operation.ex +++ b/lib/pleroma/web/api_spec/operations/notification_operation.ex @@ -183,7 +183,6 @@ defp notification_type do "favourite", "reblog", "mention", - "poll", "pleroma:emoji_reaction", "pleroma:chat_mention", "move", From 71a5d9bffb33d9424ea28900ea006678617e0096 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Wed, 17 Jun 2020 12:54:02 -0500 Subject: [PATCH 308/401] Empty list as default --- lib/pleroma/web/media_proxy/invalidations/http.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/media_proxy/invalidations/http.ex b/lib/pleroma/web/media_proxy/invalidations/http.ex index 3694b56e8..bb81d8888 100644 --- a/lib/pleroma/web/media_proxy/invalidations/http.ex +++ b/lib/pleroma/web/media_proxy/invalidations/http.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.Http do require Logger @impl Pleroma.Web.MediaProxy.Invalidation - def purge(urls, opts) do + def purge(urls, opts \\ []) do method = Keyword.get(opts, :method, :purge) headers = Keyword.get(opts, :headers, []) options = Keyword.get(opts, :options, []) From c08c9db0c137d36896910194a6dc50a391a8fee2 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Wed, 17 Jun 2020 13:02:01 -0500 Subject: [PATCH 309/401] Remove misleading is_ prefix from boolean function --- lib/pleroma/web/media_proxy/media_proxy.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/media_proxy/media_proxy.ex b/lib/pleroma/web/media_proxy/media_proxy.ex index 59ca217ab..3dccd6b7f 100644 --- a/lib/pleroma/web/media_proxy/media_proxy.ex +++ b/lib/pleroma/web/media_proxy/media_proxy.ex @@ -37,15 +37,15 @@ def url(url) when is_nil(url) or url == "", do: nil def url("/" <> _ = url), do: url def url(url) do - if disabled?() or not is_url_proxiable?(url) do + if disabled?() or not url_proxiable?(url) do url else encode_url(url) end end - @spec is_url_proxiable?(String.t()) :: boolean() - def is_url_proxiable?(url) do + @spec url_proxiable?(String.t()) :: boolean() + def url_proxiable?(url) do if local?(url) or whitelisted?(url) do false else From 2731ea1334c2c91315465659a0874829cb9e1e11 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Wed, 17 Jun 2020 13:13:55 -0500 Subject: [PATCH 310/401] Change references from "deleted_urls" to "banned_urls" as nothing is handled via media deletions anymore; all actions are manual operations by an admin to ban the url --- lib/pleroma/application.ex | 2 +- lib/pleroma/plugs/uploaded_media.ex | 10 ++++---- .../media_proxy_cache_controller.ex | 6 ++--- lib/pleroma/web/media_proxy/media_proxy.ex | 20 ++++++++-------- .../web/media_proxy/media_proxy_controller.ex | 4 ++-- .../media_proxy_cache_controller_test.exs | 24 +++++++++---------- test/web/media_proxy/invalidation_test.exs | 14 +++++------ .../media_proxy_controller_test.exs | 6 ++--- 8 files changed, 43 insertions(+), 43 deletions(-) diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index adebebc7a..4a21bf138 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -149,7 +149,7 @@ defp cachex_children do build_cachex("web_resp", limit: 2500), build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10), build_cachex("failed_proxy_url", limit: 2500), - build_cachex("deleted_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000) + build_cachex("banned_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000) ] end diff --git a/lib/pleroma/plugs/uploaded_media.ex b/lib/pleroma/plugs/uploaded_media.ex index 2f3fde002..40984cfc0 100644 --- a/lib/pleroma/plugs/uploaded_media.ex +++ b/lib/pleroma/plugs/uploaded_media.ex @@ -49,7 +49,7 @@ def call(%{request_path: <<"/", @path, "/", file::binary>>} = conn, opts) do with uploader <- Keyword.fetch!(config, :uploader), proxy_remote = Keyword.get(config, :proxy_remote, false), {:ok, get_method} <- uploader.get_file(file), - false <- media_is_deleted(conn, get_method) do + false <- media_is_banned(conn, get_method) do get_media(conn, get_method, proxy_remote, opts) else _ -> @@ -61,13 +61,13 @@ def call(%{request_path: <<"/", @path, "/", file::binary>>} = conn, opts) do def call(conn, _opts), do: conn - defp media_is_deleted(%{request_path: path} = _conn, {:static_dir, _}) do - MediaProxy.in_deleted_urls(Pleroma.Web.base_url() <> path) + defp media_is_banned(%{request_path: path} = _conn, {:static_dir, _}) do + MediaProxy.in_banned_urls(Pleroma.Web.base_url() <> path) end - defp media_is_deleted(_, {:url, url}), do: MediaProxy.in_deleted_urls(url) + defp media_is_banned(_, {:url, url}), do: MediaProxy.in_banned_urls(url) - defp media_is_deleted(_, _), do: false + defp media_is_banned(_, _), do: false defp get_media(conn, {:static_dir, directory}, _, opts) do static_opts = diff --git a/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex index e3fa0ac28..e2759d59f 100644 --- a/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex @@ -27,7 +27,7 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheController do def index(%{assigns: %{user: _}} = conn, params) do cursor = - :deleted_urls_cache + :banned_urls_cache |> :ets.table([{:traverse, {:select, Cachex.Query.create(true, :key)}}]) |> :qlc.cursor() @@ -47,7 +47,7 @@ def index(%{assigns: %{user: _}} = conn, params) do end def delete(%{assigns: %{user: _}, body_params: %{urls: urls}} = conn, _) do - MediaProxy.remove_from_deleted_urls(urls) + MediaProxy.remove_from_banned_urls(urls) render(conn, "index.json", urls: urls) end @@ -55,7 +55,7 @@ def purge(%{assigns: %{user: _}, body_params: %{urls: urls, ban: ban}} = conn, _ MediaProxy.Invalidation.purge(urls) if ban do - MediaProxy.put_in_deleted_urls(urls) + MediaProxy.put_in_banned_urls(urls) end render(conn, "index.json", urls: urls) diff --git a/lib/pleroma/web/media_proxy/media_proxy.ex b/lib/pleroma/web/media_proxy/media_proxy.ex index 3dccd6b7f..077fabe47 100644 --- a/lib/pleroma/web/media_proxy/media_proxy.ex +++ b/lib/pleroma/web/media_proxy/media_proxy.ex @@ -10,27 +10,27 @@ defmodule Pleroma.Web.MediaProxy do @base64_opts [padding: false] - @spec in_deleted_urls(String.t()) :: boolean() - def in_deleted_urls(url), do: elem(Cachex.exists?(:deleted_urls_cache, url(url)), 1) + @spec in_banned_urls(String.t()) :: boolean() + def in_banned_urls(url), do: elem(Cachex.exists?(:banned_urls_cache, url(url)), 1) - def remove_from_deleted_urls(urls) when is_list(urls) do - Cachex.execute!(:deleted_urls_cache, fn cache -> + def remove_from_banned_urls(urls) when is_list(urls) do + Cachex.execute!(:banned_urls_cache, fn cache -> Enum.each(Invalidation.prepare_urls(urls), &Cachex.del(cache, &1)) end) end - def remove_from_deleted_urls(url) when is_binary(url) do - Cachex.del(:deleted_urls_cache, url(url)) + def remove_from_banned_urls(url) when is_binary(url) do + Cachex.del(:banned_urls_cache, url(url)) end - def put_in_deleted_urls(urls) when is_list(urls) do - Cachex.execute!(:deleted_urls_cache, fn cache -> + def put_in_banned_urls(urls) when is_list(urls) do + Cachex.execute!(:banned_urls_cache, fn cache -> Enum.each(Invalidation.prepare_urls(urls), &Cachex.put(cache, &1, true)) end) end - def put_in_deleted_urls(url) when is_binary(url) do - Cachex.put(:deleted_urls_cache, url(url), true) + def put_in_banned_urls(url) when is_binary(url) do + Cachex.put(:banned_urls_cache, url(url), true) end def url(url) when is_nil(url) or url == "", do: nil diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index ff0158d83..9a64b0ef3 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -14,11 +14,11 @@ def remote(conn, %{"sig" => sig64, "url" => url64} = params) do with config <- Pleroma.Config.get([:media_proxy], []), true <- Keyword.get(config, :enabled, false), {:ok, url} <- MediaProxy.decode_url(sig64, url64), - {_, false} <- {:in_deleted_urls, MediaProxy.in_deleted_urls(url)}, + {_, false} <- {:in_banned_urls, MediaProxy.in_banned_urls(url)}, :ok <- filename_matches(params, conn.request_path, url) do ReverseProxy.call(conn, url, Keyword.get(config, :proxy_opts, @default_proxy_opts)) else - error when error in [false, {:in_deleted_urls, true}] -> + error when error in [false, {:in_banned_urls, true}] -> send_resp(conn, 404, Plug.Conn.Status.reason_phrase(404)) {:error, :invalid_signature} -> diff --git a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs index 42a3c0dd8..5ab6cb78a 100644 --- a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs +++ b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs @@ -13,7 +13,7 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheControllerTest do setup do: clear_config([:media_proxy]) setup do - on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) + on_exit(fn -> Cachex.clear(:banned_urls_cache) end) end setup do @@ -34,14 +34,14 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheControllerTest do describe "GET /api/pleroma/admin/media_proxy_caches" do test "shows banned MediaProxy URLs", %{conn: conn} do - MediaProxy.put_in_deleted_urls([ + MediaProxy.put_in_banned_urls([ "http://localhost:4001/media/a688346.jpg", "http://localhost:4001/media/fb1f4d.jpg" ]) - MediaProxy.put_in_deleted_urls("http://localhost:4001/media/gb1f44.jpg") - MediaProxy.put_in_deleted_urls("http://localhost:4001/media/tb13f47.jpg") - MediaProxy.put_in_deleted_urls("http://localhost:4001/media/wb1f46.jpg") + MediaProxy.put_in_banned_urls("http://localhost:4001/media/gb1f44.jpg") + MediaProxy.put_in_banned_urls("http://localhost:4001/media/tb13f47.jpg") + MediaProxy.put_in_banned_urls("http://localhost:4001/media/wb1f46.jpg") response = conn @@ -74,7 +74,7 @@ test "shows banned MediaProxy URLs", %{conn: conn} do describe "POST /api/pleroma/admin/media_proxy_caches/delete" do test "deleted MediaProxy URLs from banned", %{conn: conn} do - MediaProxy.put_in_deleted_urls([ + MediaProxy.put_in_banned_urls([ "http://localhost:4001/media/a688346.jpg", "http://localhost:4001/media/fb1f4d.jpg" ]) @@ -88,8 +88,8 @@ test "deleted MediaProxy URLs from banned", %{conn: conn} do |> json_response_and_validate_schema(200) assert response["urls"] == ["http://localhost:4001/media/a688346.jpg"] - refute MediaProxy.in_deleted_urls("http://localhost:4001/media/a688346.jpg") - assert MediaProxy.in_deleted_urls("http://localhost:4001/media/fb1f4d.jpg") + refute MediaProxy.in_banned_urls("http://localhost:4001/media/a688346.jpg") + assert MediaProxy.in_banned_urls("http://localhost:4001/media/fb1f4d.jpg") end end @@ -114,8 +114,8 @@ test "perform invalidates cache of MediaProxy", %{conn: conn} do assert response["urls"] == urls - refute MediaProxy.in_deleted_urls("http://example.com/media/a688346.jpg") - refute MediaProxy.in_deleted_urls("http://example.com/media/fb1f4d.jpg") + refute MediaProxy.in_banned_urls("http://example.com/media/a688346.jpg") + refute MediaProxy.in_banned_urls("http://example.com/media/fb1f4d.jpg") end end @@ -137,8 +137,8 @@ test "perform invalidates cache of MediaProxy and adds url to banned", %{conn: c assert response["urls"] == urls - assert MediaProxy.in_deleted_urls("http://example.com/media/a688346.jpg") - assert MediaProxy.in_deleted_urls("http://example.com/media/fb1f4d.jpg") + assert MediaProxy.in_banned_urls("http://example.com/media/a688346.jpg") + assert MediaProxy.in_banned_urls("http://example.com/media/fb1f4d.jpg") end end end diff --git a/test/web/media_proxy/invalidation_test.exs b/test/web/media_proxy/invalidation_test.exs index bf9af251c..926ae74ca 100644 --- a/test/web/media_proxy/invalidation_test.exs +++ b/test/web/media_proxy/invalidation_test.exs @@ -12,7 +12,7 @@ defmodule Pleroma.Web.MediaProxy.InvalidationTest do setup do: clear_config([:media_proxy]) setup do - on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) + on_exit(fn -> Cachex.clear(:banned_urls_cache) end) end describe "Invalidation.Http" do @@ -23,7 +23,7 @@ test "perform request to clear cache" do Config.put([Invalidation.Http], method: :purge, headers: [{"x-refresh", 1}]) image_url = "http://example.com/media/example.jpg" - Pleroma.Web.MediaProxy.put_in_deleted_urls(image_url) + Pleroma.Web.MediaProxy.put_in_banned_urls(image_url) mock(fn %{ @@ -35,9 +35,9 @@ test "perform request to clear cache" do end) assert capture_log(fn -> - assert Pleroma.Web.MediaProxy.in_deleted_urls(image_url) + assert Pleroma.Web.MediaProxy.in_banned_urls(image_url) assert Invalidation.purge([image_url]) == {:ok, [image_url]} - assert Pleroma.Web.MediaProxy.in_deleted_urls(image_url) + assert Pleroma.Web.MediaProxy.in_banned_urls(image_url) end) =~ "Running cache purge: [\"#{image_url}\"]" end end @@ -50,13 +50,13 @@ test "run script to clear cache" do Config.put([Invalidation.Script], script_path: "purge-nginx") image_url = "http://example.com/media/example.jpg" - Pleroma.Web.MediaProxy.put_in_deleted_urls(image_url) + Pleroma.Web.MediaProxy.put_in_banned_urls(image_url) with_mocks [{System, [], [cmd: fn _, _ -> {"ok", 0} end]}] do assert capture_log(fn -> - assert Pleroma.Web.MediaProxy.in_deleted_urls(image_url) + assert Pleroma.Web.MediaProxy.in_banned_urls(image_url) assert Invalidation.purge([image_url]) == {:ok, [image_url]} - assert Pleroma.Web.MediaProxy.in_deleted_urls(image_url) + assert Pleroma.Web.MediaProxy.in_banned_urls(image_url) end) =~ "Running cache purge: [\"#{image_url}\"]" end end diff --git a/test/web/media_proxy/media_proxy_controller_test.exs b/test/web/media_proxy/media_proxy_controller_test.exs index 72da98a6a..d61cef83b 100644 --- a/test/web/media_proxy/media_proxy_controller_test.exs +++ b/test/web/media_proxy/media_proxy_controller_test.exs @@ -11,7 +11,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do setup do: clear_config([Pleroma.Web.Endpoint, :secret_key_base]) setup do - on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) + on_exit(fn -> Cachex.clear(:banned_urls_cache) end) end test "it returns 404 when MediaProxy disabled", %{conn: conn} do @@ -71,11 +71,11 @@ test "it performs ReverseProxy.call when signature valid", %{conn: conn} do end end - test "it returns 404 when url contains in deleted_urls cache", %{conn: conn} do + test "it returns 404 when url contains in banned_urls cache", %{conn: conn} do Config.put([:media_proxy, :enabled], true) Config.put([Pleroma.Web.Endpoint, :secret_key_base], "00000000000") url = Pleroma.Web.MediaProxy.encode_url("https://google.fn/test.png") - Pleroma.Web.MediaProxy.put_in_deleted_urls("https://google.fn/test.png") + Pleroma.Web.MediaProxy.put_in_banned_urls("https://google.fn/test.png") with_mock Pleroma.ReverseProxy, call: fn _conn, _url, _opts -> %Plug.Conn{status: :success} end do From 4044f24e2e4935757e038e7f06373ed1c9172560 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Thu, 18 Jun 2020 05:02:33 +0300 Subject: [PATCH 311/401] fix test --- lib/pleroma/web/media_proxy/invalidation.ex | 3 ++- test/web/media_proxy/invalidations/http_test.exs | 2 +- test/web/media_proxy/invalidations/script_test.exs | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/media_proxy/invalidation.ex b/lib/pleroma/web/media_proxy/invalidation.ex index 6da7eb720..5808861e6 100644 --- a/lib/pleroma/web/media_proxy/invalidation.ex +++ b/lib/pleroma/web/media_proxy/invalidation.ex @@ -26,7 +26,8 @@ def purge(urls) do defp do_purge(urls) do provider = Config.get([:media_proxy, :invalidation, :provider]) - provider.purge(urls, Config.get(provider)) + options = Config.get(provider) + provider.purge(urls, options) end def prepare_urls(urls) do diff --git a/test/web/media_proxy/invalidations/http_test.exs b/test/web/media_proxy/invalidations/http_test.exs index 9d181dd8b..a1bef5237 100644 --- a/test/web/media_proxy/invalidations/http_test.exs +++ b/test/web/media_proxy/invalidations/http_test.exs @@ -6,7 +6,7 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.HttpTest do import Tesla.Mock setup do - on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) + on_exit(fn -> Cachex.clear(:banned_urls_cache) end) end test "logs hasn't error message when request is valid" do diff --git a/test/web/media_proxy/invalidations/script_test.exs b/test/web/media_proxy/invalidations/script_test.exs index 8e155b705..51833ab18 100644 --- a/test/web/media_proxy/invalidations/script_test.exs +++ b/test/web/media_proxy/invalidations/script_test.exs @@ -5,7 +5,7 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.ScriptTest do import ExUnit.CaptureLog setup do - on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) + on_exit(fn -> Cachex.clear(:banned_urls_cache) end) end test "it logger error when script not found" do From c9b5e3fedabd0b6ef3bb9e6108385ffa3857af54 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Thu, 18 Jun 2020 05:29:31 +0300 Subject: [PATCH 312/401] revert 'database' option to rejected keys --- lib/pleroma/config/loader.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/config/loader.ex b/lib/pleroma/config/loader.ex index 76559e70c..0f3ecf1ed 100644 --- a/lib/pleroma/config/loader.ex +++ b/lib/pleroma/config/loader.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Config.Loader do Pleroma.Web.Endpoint, :env, :configurable_from_database, + :database, :swarm ] From e4c61f1741f32fec3201f7d9a8403bc1bc329710 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Thu, 18 Jun 2020 05:45:15 +0300 Subject: [PATCH 313/401] added test --- test/fixtures/config/temp.secret.exs | 2 ++ test/tasks/config_test.exs | 1 + 2 files changed, 3 insertions(+) diff --git a/test/fixtures/config/temp.secret.exs b/test/fixtures/config/temp.secret.exs index dc950ca30..fa8c7c7e8 100644 --- a/test/fixtures/config/temp.secret.exs +++ b/test/fixtures/config/temp.secret.exs @@ -9,3 +9,5 @@ config :pleroma, Pleroma.Repo, pool: Ecto.Adapters.SQL.Sandbox config :postgrex, :json_library, Poison + +config :pleroma, :database, rum_enabled: true diff --git a/test/tasks/config_test.exs b/test/tasks/config_test.exs index e1bddfebf..99038e544 100644 --- a/test/tasks/config_test.exs +++ b/test/tasks/config_test.exs @@ -50,6 +50,7 @@ test "filtered settings are migrated to db" do config3 = ConfigDB.get_by_params(%{group: ":quack", key: ":level"}) refute ConfigDB.get_by_params(%{group: ":pleroma", key: "Pleroma.Repo"}) refute ConfigDB.get_by_params(%{group: ":postgrex", key: ":json_library"}) + refute ConfigDB.get_by_params(%{group: ":pleroma", key: ":database"}) assert config1.value == [key: "value", key2: [Repo]] assert config2.value == [key: "value2", key2: ["Activity"]] From 3becdafd335f95d9320d287ecf9a55ea1b1765cd Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Thu, 18 Jun 2020 14:32:21 +0300 Subject: [PATCH 314/401] emoji packs pagination --- lib/pleroma/emoji/pack.ex | 19 ++++++++++++---- .../pleroma_emoji_pack_operation.ex | 14 ++++++++++++ .../controllers/emoji_pack_controller.ex | 4 ++-- .../emoji_pack_controller_test.exs | 22 +++++++++++++++++++ 4 files changed, 53 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index 14a5185be..5660c4c9d 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -16,7 +16,7 @@ defmodule Pleroma.Emoji.Pack do alias Pleroma.Emoji - @spec create(String.t()) :: :ok | {:error, File.posix()} | {:error, :empty_values} + @spec create(String.t()) :: {:ok, t()} | {:error, File.posix()} | {:error, :empty_values} def create(name) do with :ok <- validate_not_empty([name]), dir <- Path.join(emoji_path(), name), @@ -120,8 +120,8 @@ def list_remote(url) do end end - @spec list_local() :: {:ok, map()} - def list_local do + @spec list_local(keyword()) :: {:ok, map()} + def list_local(opts) do with {:ok, results} <- list_packs_dir() do packs = results @@ -132,6 +132,17 @@ def list_local do end end) |> Enum.reject(&is_nil/1) + + packs = + case opts[:page] do + 1 -> + Enum.take(packs, opts[:page_size]) + + _ -> + packs + |> Enum.take(opts[:page] * opts[:page_size]) + |> Enum.take(-opts[:page_size]) + end |> Map.new(fn pack -> {pack.name, validate_pack(pack)} end) {:ok, packs} @@ -146,7 +157,7 @@ def get_archive(name) do end end - @spec download(String.t(), String.t(), String.t()) :: :ok | {:error, atom()} + @spec download(String.t(), String.t(), String.t()) :: {:ok, t()} | {:error, atom()} def download(name, url, as) do uri = url |> String.trim() |> URI.parse() diff --git a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex index 567688ff5..0d842382b 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex @@ -33,6 +33,20 @@ def index_operation do tags: ["Emoji Packs"], summary: "Lists local custom emoji packs", operationId: "PleromaAPI.EmojiPackController.index", + parameters: [ + Operation.parameter( + :page, + :query, + %Schema{type: :integer, default: 1}, + "Page" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 50}, + "Number of statuses to return" + ) + ], responses: %{ 200 => emoji_packs_response() } diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex index d1efdeb5d..5654b3fbe 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex @@ -37,13 +37,13 @@ def remote(conn, %{url: url}) do end end - def index(conn, _params) do + def index(conn, params) do emoji_path = [:instance, :static_dir] |> Pleroma.Config.get!() |> Path.join("emoji") - with {:ok, packs} <- Pack.list_local() do + with {:ok, packs} <- Pack.list_local(page: params.page, page_size: params.page_size) do json(conn, packs) else {:error, :create_dir, e} -> diff --git a/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs b/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs index ee3d281a0..aafca6359 100644 --- a/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs +++ b/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs @@ -39,6 +39,28 @@ test "GET /api/pleroma/emoji/packs", %{conn: conn} do non_shared = resp["test_pack_nonshared"] assert non_shared["pack"]["share-files"] == false assert non_shared["pack"]["can-download"] == false + + resp = + conn + |> get("/api/pleroma/emoji/packs?page_size=1") + |> json_response_and_validate_schema(200) + + [pack1] = Map.keys(resp) + + resp = + conn + |> get("/api/pleroma/emoji/packs?page_size=1&page=2") + |> json_response_and_validate_schema(200) + + [pack2] = Map.keys(resp) + + resp = + conn + |> get("/api/pleroma/emoji/packs?page_size=1&page=3") + |> json_response_and_validate_schema(200) + + [pack3] = Map.keys(resp) + assert [pack1, pack2, pack3] |> Enum.uniq() |> length() == 3 end describe "GET /api/pleroma/emoji/packs/remote" do From 4975ed86bcca330373a68c9e6c6798a6b2167b14 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Thu, 18 Jun 2020 18:50:03 +0300 Subject: [PATCH 315/401] emoji pagination for pack show action --- docs/API/pleroma_api.md | 12 ++- lib/pleroma/emoji/pack.ex | 37 +++++---- .../pleroma_emoji_pack_operation.ex | 16 +++- .../controllers/emoji_pack_controller.ex | 4 +- .../emoji/test_pack/blank2.png | Bin 0 -> 95 bytes .../instance_static/emoji/test_pack/pack.json | 3 +- .../emoji/test_pack_nonshared/nonshared.zip | Bin 256 -> 548 bytes .../emoji/test_pack_nonshared/pack.json | 2 +- .../emoji_pack_controller_test.exs | 72 +++++++++++++----- 9 files changed, 104 insertions(+), 42 deletions(-) create mode 100644 test/instance_static/emoji/test_pack/blank2.png diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index 70d4755b7..d8d3ba85f 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -450,17 +450,25 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * Response: JSON, list with updated files for updated pack (hashmap -> shortcode => filename) with status 200, either error status with error message. ## `GET /api/pleroma/emoji/packs` + ### Lists local custom emoji packs + * Method `GET` * Authentication: not required -* Params: None +* Params: + * `page`: page number for packs (default 1) + * `page_size`: page size for packs (default 50) * Response: JSON, "ok" and 200 status and the JSON hashmap of pack name to pack contents ## `GET /api/pleroma/emoji/packs/:name` + ### Get pack.json for the pack + * Method `GET` * Authentication: not required -* Params: None +* Params: + * `page`: page number for files (default 1) + * `page_size`: page size for files (default 50) * Response: JSON, pack json with `files` and `pack` keys with 200 status or 404 if the pack does not exist ## `GET /api/pleroma/emoji/packs/:name/archive` diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index 5660c4c9d..c033572c1 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -26,10 +26,27 @@ def create(name) do end end - @spec show(String.t()) :: {:ok, t()} | {:error, atom()} - def show(name) do + defp paginate(entities, 1, page_size), do: Enum.take(entities, page_size) + + defp paginate(entities, page, page_size) do + entities + |> Enum.take(page * page_size) + |> Enum.take(-page_size) + end + + @spec show(keyword()) :: {:ok, t()} | {:error, atom()} + def show(opts) do + name = opts[:name] + with :ok <- validate_not_empty([name]), {:ok, pack} <- load_pack(name) do + shortcodes = + pack.files + |> Map.keys() + |> paginate(opts[:page], opts[:page_size]) + + pack = Map.put(pack, :files, Map.take(pack.files, shortcodes)) + {:ok, validate_pack(pack)} end end @@ -132,17 +149,7 @@ def list_local(opts) do end end) |> Enum.reject(&is_nil/1) - - packs = - case opts[:page] do - 1 -> - Enum.take(packs, opts[:page_size]) - - _ -> - packs - |> Enum.take(opts[:page] * opts[:page_size]) - |> Enum.take(-opts[:page_size]) - end + |> paginate(opts[:page], opts[:page_size]) |> Map.new(fn pack -> {pack.name, validate_pack(pack)} end) {:ok, packs} @@ -307,7 +314,9 @@ defp downloadable?(pack) do # Otherwise, they'd have to download it from external-src pack.pack["share-files"] && Enum.all?(pack.files, fn {_, file} -> - File.exists?(Path.join(pack.path, file)) + pack.path + |> Path.join(file) + |> File.exists?() end) end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex index 0d842382b..e8abe654d 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex @@ -58,7 +58,21 @@ def show_operation do tags: ["Emoji Packs"], summary: "Show emoji pack", operationId: "PleromaAPI.EmojiPackController.show", - parameters: [name_param()], + parameters: [ + name_param(), + Operation.parameter( + :page, + :query, + %Schema{type: :integer, default: 1}, + "Page" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 50}, + "Number of statuses to return" + ) + ], responses: %{ 200 => Operation.response("Emoji Pack", "application/json", emoji_pack()), 400 => Operation.response("Bad Request", "application/json", ApiError), diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex index 5654b3fbe..078fb88dd 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex @@ -60,10 +60,10 @@ def index(conn, params) do end end - def show(conn, %{name: name}) do + def show(conn, %{name: name, page: page, page_size: page_size}) do name = String.trim(name) - with {:ok, pack} <- Pack.show(name) do + with {:ok, pack} <- Pack.show(name: name, page: page, page_size: page_size) do json(conn, pack) else {:error, :not_found} -> diff --git a/test/instance_static/emoji/test_pack/blank2.png b/test/instance_static/emoji/test_pack/blank2.png new file mode 100644 index 0000000000000000000000000000000000000000..8f50fa02340e7e09e562f86e00b6e4bd6ad1d565 GIT binary patch literal 95 zcmeAS@N?(olHy`uVBq!ia0vp^4Is=2Bp6=1#-sr$rjj7PU<QV=$!9HqJPA)1$B+uf q<ORkOtcw#wdYS?axZDnEFfed5Ffe}4nR*bYhQZU-&t;ucLK6U?=oVoB literal 0 HcmV?d00001 diff --git a/test/instance_static/emoji/test_pack/pack.json b/test/instance_static/emoji/test_pack/pack.json index 481891b08..5b33fbb32 100644 --- a/test/instance_static/emoji/test_pack/pack.json +++ b/test/instance_static/emoji/test_pack/pack.json @@ -1,6 +1,7 @@ { "files": { - "blank": "blank.png" + "blank": "blank.png", + "blank2": "blank2.png" }, "pack": { "description": "Test description", diff --git a/test/instance_static/emoji/test_pack_nonshared/nonshared.zip b/test/instance_static/emoji/test_pack_nonshared/nonshared.zip index 148446c642ea24b494bc3e25ccd772faaf2f2a13..59bff37f0895f17fecf4285015c5dcd18fcc8b35 100644 GIT binary patch literal 548 zcmWIWW@Zs#-~hstas2)aP!JEKIT;ifl5!IBvh@n`(nCXd8Q6EphQ<a|ypHn$;?fFk z21b^zj0_Aw?F<aBc|H_Be>&+=QbNLmuU`r{nJ)1voZ(QBh}(T^38Ut+NecTD*xELo zOxJC&;q(_jK7s4l6V_uwYr2J9s%A0q?zqF3WnfTXVqj2rerA=xlFOH`o==?{>?F(( z;LXkvAKoY$0ki|;r~sVK<$^ia2*th8K(|H>q<a~eM3@l)jO-Us0K>qNMi7hW;12M{ gZ7e7tU|>n(dYG|91xtW8D;r2J6A*%Q#xsI=0P0(p8vp<R delta 152 zcmZ3&(!k^x;LXe;!oa}5!EiE;-#<RQQ8WU`iw9y(1{sE=oW#6ry@I^-&=5`r=3TO( zv8RBzw1S&~k>v$50|Stl=o%x-$Rx*%%Mgjlb&OIvtPtHOIvE%Oyjj_RHZd>)p+AtG I4dO5W0E3SliU0rr diff --git a/test/instance_static/emoji/test_pack_nonshared/pack.json b/test/instance_static/emoji/test_pack_nonshared/pack.json index 93d643a5f..09f6274d1 100644 --- a/test/instance_static/emoji/test_pack_nonshared/pack.json +++ b/test/instance_static/emoji/test_pack_nonshared/pack.json @@ -4,7 +4,7 @@ "homepage": "https://pleroma.social", "description": "Test description", "fallback-src": "https://nonshared-pack", - "fallback-src-sha256": "74409E2674DAA06C072729C6C8426C4CB3B7E0B85ED77792DB7A436E11D76DAF", + "fallback-src-sha256": "1967BB4E42BCC34BCC12D57BE7811D3B7BE52F965BCE45C87BD377B9499CE11D", "share-files": false }, "files": { diff --git a/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs b/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs index aafca6359..f6239cae5 100644 --- a/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs +++ b/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs @@ -31,7 +31,7 @@ test "GET /api/pleroma/emoji/packs", %{conn: conn} do resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) shared = resp["test_pack"] - assert shared["files"] == %{"blank" => "blank.png"} + assert shared["files"] == %{"blank" => "blank.png", "blank2" => "blank2.png"} assert Map.has_key?(shared["pack"], "download-sha256") assert shared["pack"]["can-download"] assert shared["pack"]["share-files"] @@ -354,7 +354,7 @@ test "for a pack with a fallback source", ctx do Map.put( new_data, "fallback-src-sha256", - "74409E2674DAA06C072729C6C8426C4CB3B7E0B85ED77792DB7A436E11D76DAF" + "1967BB4E42BCC34BCC12D57BE7811D3B7BE52F965BCE45C87BD377B9499CE11D" ) assert ctx[:admin_conn] @@ -420,7 +420,7 @@ test "don't rewrite old emoji", %{admin_conn: admin_conn} do assert admin_conn |> put_req_header("content-type", "multipart/form-data") |> post("/api/pleroma/emoji/packs/test_pack/files", %{ - shortcode: "blank2", + shortcode: "blank3", filename: "dir/blank.png", file: %Plug.Upload{ filename: "blank.png", @@ -429,7 +429,8 @@ test "don't rewrite old emoji", %{admin_conn: admin_conn} do }) |> json_response_and_validate_schema(200) == %{ "blank" => "blank.png", - "blank2" => "dir/blank.png" + "blank2" => "blank2.png", + "blank3" => "dir/blank.png" } assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png") @@ -453,7 +454,7 @@ test "rewrite old emoji with force option", %{admin_conn: admin_conn} do assert admin_conn |> put_req_header("content-type", "multipart/form-data") |> post("/api/pleroma/emoji/packs/test_pack/files", %{ - shortcode: "blank2", + shortcode: "blank3", filename: "dir/blank.png", file: %Plug.Upload{ filename: "blank.png", @@ -462,7 +463,8 @@ test "rewrite old emoji with force option", %{admin_conn: admin_conn} do }) |> json_response_and_validate_schema(200) == %{ "blank" => "blank.png", - "blank2" => "dir/blank.png" + "blank2" => "blank2.png", + "blank3" => "dir/blank.png" } assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png") @@ -470,14 +472,15 @@ test "rewrite old emoji with force option", %{admin_conn: admin_conn} do assert admin_conn |> put_req_header("content-type", "multipart/form-data") |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ - shortcode: "blank2", - new_shortcode: "blank3", + shortcode: "blank3", + new_shortcode: "blank4", new_filename: "dir_2/blank_3.png", force: true }) |> json_response_and_validate_schema(200) == %{ "blank" => "blank.png", - "blank3" => "dir_2/blank_3.png" + "blank2" => "blank2.png", + "blank4" => "dir_2/blank_3.png" } assert File.exists?("#{@emoji_path}/test_pack/dir_2/blank_3.png") @@ -503,7 +506,7 @@ test "add file with not loaded pack", %{admin_conn: admin_conn} do assert admin_conn |> put_req_header("content-type", "multipart/form-data") |> post("/api/pleroma/emoji/packs/not_loaded/files", %{ - shortcode: "blank2", + shortcode: "blank3", filename: "dir/blank.png", file: %Plug.Upload{ filename: "blank.png", @@ -557,7 +560,8 @@ test "new with shortcode as file with update", %{admin_conn: admin_conn} do }) |> json_response_and_validate_schema(200) == %{ "blank" => "blank.png", - "blank4" => "dir/blank.png" + "blank4" => "dir/blank.png", + "blank2" => "blank2.png" } assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png") @@ -571,7 +575,8 @@ test "new with shortcode as file with update", %{admin_conn: admin_conn} do }) |> json_response_and_validate_schema(200) == %{ "blank3" => "dir_2/blank_3.png", - "blank" => "blank.png" + "blank" => "blank.png", + "blank2" => "blank2.png" } refute File.exists?("#{@emoji_path}/test_pack/dir/") @@ -579,7 +584,10 @@ test "new with shortcode as file with update", %{admin_conn: admin_conn} do assert admin_conn |> delete("/api/pleroma/emoji/packs/test_pack/files?shortcode=blank3") - |> json_response_and_validate_schema(200) == %{"blank" => "blank.png"} + |> json_response_and_validate_schema(200) == %{ + "blank" => "blank.png", + "blank2" => "blank2.png" + } refute File.exists?("#{@emoji_path}/test_pack/dir_2/") @@ -603,7 +611,8 @@ test "new with shortcode from url", %{admin_conn: admin_conn} do }) |> json_response_and_validate_schema(200) == %{ "blank_url" => "blank_url.png", - "blank" => "blank.png" + "blank" => "blank.png", + "blank2" => "blank2.png" } assert File.exists?("#{@emoji_path}/test_pack/blank_url.png") @@ -624,15 +633,16 @@ test "new without shortcode", %{admin_conn: admin_conn} do }) |> json_response_and_validate_schema(200) == %{ "shortcode" => "shortcode.png", - "blank" => "blank.png" + "blank" => "blank.png", + "blank2" => "blank2.png" } end test "remove non existing shortcode in pack.json", %{admin_conn: admin_conn} do assert admin_conn - |> delete("/api/pleroma/emoji/packs/test_pack/files?shortcode=blank2") + |> delete("/api/pleroma/emoji/packs/test_pack/files?shortcode=blank3") |> json_response_and_validate_schema(:bad_request) == %{ - "error" => "Emoji \"blank2\" does not exist" + "error" => "Emoji \"blank3\" does not exist" } end @@ -640,12 +650,12 @@ test "update non existing emoji", %{admin_conn: admin_conn} do assert admin_conn |> put_req_header("content-type", "multipart/form-data") |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ - shortcode: "blank2", - new_shortcode: "blank3", + shortcode: "blank3", + new_shortcode: "blank4", new_filename: "dir_2/blank_3.png" }) |> json_response_and_validate_schema(:bad_request) == %{ - "error" => "Emoji \"blank2\" does not exist" + "error" => "Emoji \"blank3\" does not exist" } end @@ -768,7 +778,7 @@ test "filesystem import", %{admin_conn: admin_conn, conn: conn} do describe "GET /api/pleroma/emoji/packs/:name" do test "shows pack.json", %{conn: conn} do assert %{ - "files" => %{"blank" => "blank.png"}, + "files" => files, "pack" => %{ "can-download" => true, "description" => "Test description", @@ -781,6 +791,26 @@ test "shows pack.json", %{conn: conn} do conn |> get("/api/pleroma/emoji/packs/test_pack") |> json_response_and_validate_schema(200) + + assert files == %{"blank" => "blank.png", "blank2" => "blank2.png"} + + assert %{ + "files" => files + } = + conn + |> get("/api/pleroma/emoji/packs/test_pack?page_size=1") + |> json_response_and_validate_schema(200) + + assert files |> Map.keys() |> length() == 1 + + assert %{ + "files" => files + } = + conn + |> get("/api/pleroma/emoji/packs/test_pack?page_size=1&page=2") + |> json_response_and_validate_schema(200) + + assert files |> Map.keys() |> length() == 1 end test "non existing pack", %{conn: conn} do From 3e3f9253e6db17b691c7393ad7a5f89df84348ea Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Fri, 19 Jun 2020 10:17:24 +0300 Subject: [PATCH 316/401] adding overall count for packs and files --- docs/API/pleroma_api.md | 22 ++++++++++++-- lib/pleroma/emoji/pack.ex | 20 +++++++++---- .../controllers/emoji_pack_controller.ex | 4 +-- .../emoji_pack_controller_test.exs | 30 ++++++++++++------- 4 files changed, 56 insertions(+), 20 deletions(-) diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index d8d3ba85f..e5bc29eb2 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -458,7 +458,17 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * Params: * `page`: page number for packs (default 1) * `page_size`: page size for packs (default 50) -* Response: JSON, "ok" and 200 status and the JSON hashmap of pack name to pack contents +* Response: `packs` key with JSON hashmap of pack name to pack contents and `count` key for count of packs. + +```json +{ + "packs": { + "pack_name": {...}, // pack contents + ... + }, + "count": 0 // packs count +} +``` ## `GET /api/pleroma/emoji/packs/:name` @@ -469,7 +479,15 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * Params: * `page`: page number for files (default 1) * `page_size`: page size for files (default 50) -* Response: JSON, pack json with `files` and `pack` keys with 200 status or 404 if the pack does not exist +* Response: JSON, pack json with `files`, `files_count` and `pack` keys with 200 status or 404 if the pack does not exist. + +```json +{ + "files": {...}, + "files_count": 0, // emoji count in pack + "pack": {...} +} +``` ## `GET /api/pleroma/emoji/packs/:name/archive` ### Requests a local pack archive from the instance diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index c033572c1..2dca21c93 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -1,6 +1,7 @@ defmodule Pleroma.Emoji.Pack do - @derive {Jason.Encoder, only: [:files, :pack]} + @derive {Jason.Encoder, only: [:files, :pack, :files_count]} defstruct files: %{}, + files_count: 0, pack_file: nil, path: nil, pack: %{}, @@ -8,6 +9,7 @@ defmodule Pleroma.Emoji.Pack do @type t() :: %__MODULE__{ files: %{String.t() => Path.t()}, + files_count: non_neg_integer(), pack_file: Path.t(), path: Path.t(), pack: map(), @@ -137,10 +139,10 @@ def list_remote(url) do end end - @spec list_local(keyword()) :: {:ok, map()} + @spec list_local(keyword()) :: {:ok, map(), non_neg_integer()} def list_local(opts) do with {:ok, results} <- list_packs_dir() do - packs = + all_packs = results |> Enum.map(fn name -> case load_pack(name) do @@ -149,10 +151,13 @@ def list_local(opts) do end end) |> Enum.reject(&is_nil/1) + + packs = + all_packs |> paginate(opts[:page], opts[:page_size]) |> Map.new(fn pack -> {pack.name, validate_pack(pack)} end) - {:ok, packs} + {:ok, packs, length(all_packs)} end end @@ -215,7 +220,12 @@ def load_pack(name) do |> Map.put(:path, Path.dirname(pack_file)) |> Map.put(:name, name) - {:ok, pack} + files_count = + pack.files + |> Map.keys() + |> length() + + {:ok, Map.put(pack, :files_count, files_count)} else {:error, :not_found} end diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex index 078fb88dd..33ecd1f70 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex @@ -43,8 +43,8 @@ def index(conn, params) do |> Pleroma.Config.get!() |> Path.join("emoji") - with {:ok, packs} <- Pack.list_local(page: params.page, page_size: params.page_size) do - json(conn, packs) + with {:ok, packs, count} <- Pack.list_local(page: params.page, page_size: params.page_size) do + json(conn, %{packs: packs, count: count}) else {:error, :create_dir, e} -> conn diff --git a/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs b/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs index f6239cae5..91312c832 100644 --- a/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs +++ b/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs @@ -30,13 +30,14 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackControllerTest do test "GET /api/pleroma/emoji/packs", %{conn: conn} do resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) - shared = resp["test_pack"] + assert resp["count"] == 3 + shared = resp["packs"]["test_pack"] assert shared["files"] == %{"blank" => "blank.png", "blank2" => "blank2.png"} assert Map.has_key?(shared["pack"], "download-sha256") assert shared["pack"]["can-download"] assert shared["pack"]["share-files"] - non_shared = resp["test_pack_nonshared"] + non_shared = resp["packs"]["test_pack_nonshared"] assert non_shared["pack"]["share-files"] == false assert non_shared["pack"]["can-download"] == false @@ -45,21 +46,24 @@ test "GET /api/pleroma/emoji/packs", %{conn: conn} do |> get("/api/pleroma/emoji/packs?page_size=1") |> json_response_and_validate_schema(200) - [pack1] = Map.keys(resp) + assert resp["count"] == 3 + [pack1] = Map.keys(resp["packs"]) resp = conn |> get("/api/pleroma/emoji/packs?page_size=1&page=2") |> json_response_and_validate_schema(200) - [pack2] = Map.keys(resp) + assert resp["count"] == 3 + [pack2] = Map.keys(resp["packs"]) resp = conn |> get("/api/pleroma/emoji/packs?page_size=1&page=3") |> json_response_and_validate_schema(200) - [pack3] = Map.keys(resp) + assert resp["count"] == 3 + [pack3] = Map.keys(resp["packs"]) assert [pack1, pack2, pack3] |> Enum.uniq() |> length() == 3 end @@ -683,7 +687,8 @@ test "creating and deleting a pack", %{admin_conn: admin_conn} do assert Jason.decode!(File.read!("#{@emoji_path}/test_created/pack.json")) == %{ "pack" => %{}, - "files" => %{} + "files" => %{}, + "files_count" => 0 } assert admin_conn @@ -741,14 +746,14 @@ test "filesystem import", %{admin_conn: admin_conn, conn: conn} do resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) - refute Map.has_key?(resp, "test_pack_for_import") + refute Map.has_key?(resp["packs"], "test_pack_for_import") assert admin_conn |> get("/api/pleroma/emoji/packs/import") |> json_response_and_validate_schema(200) == ["test_pack_for_import"] resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) - assert resp["test_pack_for_import"]["files"] == %{"blank" => "blank.png"} + assert resp["packs"]["test_pack_for_import"]["files"] == %{"blank" => "blank.png"} File.rm!("#{@emoji_path}/test_pack_for_import/pack.json") refute File.exists?("#{@emoji_path}/test_pack_for_import/pack.json") @@ -768,7 +773,7 @@ test "filesystem import", %{admin_conn: admin_conn, conn: conn} do resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) - assert resp["test_pack_for_import"]["files"] == %{ + assert resp["packs"]["test_pack_for_import"]["files"] == %{ "blank" => "blank.png", "blank2" => "blank.png", "foo" => "blank.png" @@ -779,6 +784,7 @@ test "filesystem import", %{admin_conn: admin_conn, conn: conn} do test "shows pack.json", %{conn: conn} do assert %{ "files" => files, + "files_count" => 2, "pack" => %{ "can-download" => true, "description" => "Test description", @@ -795,7 +801,8 @@ test "shows pack.json", %{conn: conn} do assert files == %{"blank" => "blank.png", "blank2" => "blank2.png"} assert %{ - "files" => files + "files" => files, + "files_count" => 2 } = conn |> get("/api/pleroma/emoji/packs/test_pack?page_size=1") @@ -804,7 +811,8 @@ test "shows pack.json", %{conn: conn} do assert files |> Map.keys() |> length() == 1 assert %{ - "files" => files + "files" => files, + "files_count" => 2 } = conn |> get("/api/pleroma/emoji/packs/test_pack?page_size=1&page=2") From 0c739b423aad4cc6baa3a59308200ca5a5060716 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Fri, 19 Jun 2020 12:31:55 +0300 Subject: [PATCH 317/401] changelog entry --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ee13904f..ee657de13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **Breaking:** removed `with_move` parameter from notifications timeline. ### Added + - Chats: Added support for federated chats. For details, see the docs. - ActivityPub: Added support for existing AP ids for instances migrated from Mastodon. - Instance: Add `background_image` to configuration and `/api/v1/instance` @@ -34,6 +35,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Notifications: Added `follow_request` notification type. - Added `:reject_deletes` group to SimplePolicy - MRF (`EmojiStealPolicy`): New MRF Policy which allows to automatically download emojis from remote instances +- Support pagination in emoji packs API (for packs and for files in pack) + <details> <summary>API Changes</summary> - Mastodon API: Extended `/api/v1/instance`. From 02ca8a363f738ece7b605940690f6a538f6c2fa8 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Fri, 19 Jun 2020 14:46:38 +0300 Subject: [PATCH 318/401] default page size for files --- docs/API/pleroma_api.md | 2 +- .../web/api_spec/operations/pleroma_emoji_pack_operation.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index e5bc29eb2..b7eee5192 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -478,7 +478,7 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * Authentication: not required * Params: * `page`: page number for files (default 1) - * `page_size`: page size for files (default 50) + * `page_size`: page size for files (default 30) * Response: JSON, pack json with `files`, `files_count` and `pack` keys with 200 status or 404 if the pack does not exist. ```json diff --git a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex index e8abe654d..da7cc5154 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex @@ -69,7 +69,7 @@ def show_operation do Operation.parameter( :page_size, :query, - %Schema{type: :integer, default: 50}, + %Schema{type: :integer, default: 30}, "Number of statuses to return" ) ], From 5237a2df9f123f661de30a53193b7d9fec69ecae Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Fri, 19 Jun 2020 16:14:06 +0300 Subject: [PATCH 319/401] [#1873] Fixes missing :offset pagination param support. Added pagination support for hashtags search. --- lib/pleroma/pagination.ex | 6 ++++ lib/pleroma/web/api_spec/helpers.ex | 6 ++++ .../controllers/search_controller.ex | 31 ++++++++++++------- .../controllers/search_controller_test.exs | 16 ++++++++++ .../controllers/status_controller_test.exs | 2 +- 5 files changed, 48 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/pagination.ex b/lib/pleroma/pagination.ex index 1b99e44f9..9a3795769 100644 --- a/lib/pleroma/pagination.ex +++ b/lib/pleroma/pagination.ex @@ -64,6 +64,12 @@ def fetch_paginated(query, params, :offset, table_binding) do @spec paginate(Ecto.Query.t(), map(), type(), atom() | nil) :: [Ecto.Schema.t()] def paginate(query, options, method \\ :keyset, table_binding \\ nil) + def paginate(list, options, _method, _table_binding) when is_list(list) do + offset = options[:offset] || 0 + limit = options[:limit] || 0 + Enum.slice(list, offset, limit) + end + def paginate(query, options, :keyset, table_binding) do query |> restrict(:min_id, options, table_binding) diff --git a/lib/pleroma/web/api_spec/helpers.ex b/lib/pleroma/web/api_spec/helpers.ex index a9cfe0fed..a258e8421 100644 --- a/lib/pleroma/web/api_spec/helpers.ex +++ b/lib/pleroma/web/api_spec/helpers.ex @@ -39,6 +39,12 @@ def pagination_params do :string, "Return the newest items newer than this ID" ), + Operation.parameter( + :offset, + :query, + %Schema{type: :integer, default: 0}, + "Return items past this number of items" + ), Operation.parameter( :limit, :query, diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index 3be0ca095..e50980122 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -107,21 +107,21 @@ defp resource_search(_, "statuses", query, options) do ) end - defp resource_search(:v2, "hashtags", query, _options) do + defp resource_search(:v2, "hashtags", query, options) do tags_path = Web.base_url() <> "/tag/" query - |> prepare_tags() + |> prepare_tags(options) |> Enum.map(fn tag -> %{name: tag, url: tags_path <> tag} end) end - defp resource_search(:v1, "hashtags", query, _options) do - prepare_tags(query) + defp resource_search(:v1, "hashtags", query, options) do + prepare_tags(query, options) end - defp prepare_tags(query, add_joined_tag \\ true) do + defp prepare_tags(query, options) do tags = query |> preprocess_uri_query() @@ -139,13 +139,20 @@ defp prepare_tags(query, add_joined_tag \\ true) do tags = Enum.map(tags, fn tag -> String.trim_leading(tag, "#") end) - if Enum.empty?(explicit_tags) && add_joined_tag do - tags - |> Kernel.++([joined_tag(tags)]) - |> Enum.uniq_by(&String.downcase/1) - else - tags - end + tags = + if Enum.empty?(explicit_tags) && !options[:skip_joined_tag] do + add_joined_tag(tags) + else + tags + end + + Pleroma.Pagination.paginate(tags, options) + end + + defp add_joined_tag(tags) do + tags + |> Kernel.++([joined_tag(tags)]) + |> Enum.uniq_by(&String.downcase/1) end # If `query` is a URI, returns last component of its path, otherwise returns `query` diff --git a/test/web/mastodon_api/controllers/search_controller_test.exs b/test/web/mastodon_api/controllers/search_controller_test.exs index c605957b1..826f37fbc 100644 --- a/test/web/mastodon_api/controllers/search_controller_test.exs +++ b/test/web/mastodon_api/controllers/search_controller_test.exs @@ -151,6 +151,22 @@ test "constructs hashtags from search query", %{conn: conn} do ] end + test "supports pagination of hashtags search results", %{conn: conn} do + results = + conn + |> get( + "/api/v2/search?#{ + URI.encode_query(%{q: "#some #text #with #hashtags", limit: 2, offset: 1}) + }" + ) + |> json_response_and_validate_schema(200) + + assert results["hashtags"] == [ + %{"name" => "text", "url" => "#{Web.base_url()}/tag/text"}, + %{"name" => "with", "url" => "#{Web.base_url()}/tag/with"} + ] + end + test "excludes a blocked users from search results", %{conn: conn} do user = insert(:user) user_smith = insert(:user, %{nickname: "Agent", name: "I love 2hu"}) diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index 648e6f2ce..a98e939e8 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -1561,7 +1561,7 @@ test "favorites paginate correctly" do # Using the header for pagination works correctly [next, _] = get_resp_header(result, "link") |> hd() |> String.split(", ") - [_, max_id] = Regex.run(~r/max_id=(.*)>;/, next) + [_, max_id] = Regex.run(~r/max_id=([^&]+)/, next) assert max_id == third_favorite.id From abdb540d450b5e68ea452f78d865d63bca764a49 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Fri, 19 Jun 2020 15:30:30 +0200 Subject: [PATCH 320/401] ObjectValidators: Add basic UpdateValidator. --- lib/pleroma/web/activity_pub/builder.ex | 15 +++++++ .../web/activity_pub/object_validator.ex | 11 +++++ .../object_validators/update_validator.ex | 43 +++++++++++++++++++ .../activity_pub/object_validator_test.exs | 20 +++++++++ 4 files changed, 89 insertions(+) create mode 100644 lib/pleroma/web/activity_pub/object_validators/update_validator.ex diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 1aac62c69..135a5c431 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -123,6 +123,21 @@ def like(actor, object) do end end + # Retricted to user updates for now, always public + @spec update(User.t(), Object.t()) :: {:ok, map(), keyword()} + def update(actor, object) do + to = [Pleroma.Constants.as_public(), actor.follower_address] + + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "type" => "Update", + "actor" => actor.ap_id, + "object" => object, + "to" => to + }, []} + end + @spec announce(User.t(), Object.t(), keyword()) :: {:ok, map(), keyword()} def announce(actor, object, options \\ []) do public? = Keyword.get(options, :public, false) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 6a83a2c33..804a9d06e 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -19,10 +19,21 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) + def validate(%{"type" => "Update"} = object, meta) do + with {:ok, object} <- + object + |> UpdateValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object) + {:ok, object, meta} + end + end + def validate(%{"type" => "Undo"} = object, meta) do with {:ok, object} <- object diff --git a/lib/pleroma/web/activity_pub/object_validators/update_validator.ex b/lib/pleroma/web/activity_pub/object_validators/update_validator.ex new file mode 100644 index 000000000..94d72491b --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/update_validator.ex @@ -0,0 +1,43 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator do + use Ecto.Schema + + alias Pleroma.EctoType.ActivityPub.ObjectValidators + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + field(:id, ObjectValidators.ObjectID, primary_key: true) + field(:type, :string) + field(:actor, ObjectValidators.ObjectID) + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) + # In this case, we save the full object in this activity instead of just a + # reference, so we can always see what was actually changed by this. + field(:object, :map) + end + + def cast_data(data) do + %__MODULE__{} + |> cast(data, __schema__(:fields)) + end + + def validate_data(cng) do + cng + |> validate_required([:id, :type, :actor, :to, :cc, :object]) + |> validate_inclusion(:type, ["Update"]) + |> validate_actor_presence() + end + + def cast_and_validate(data) do + data + |> cast_data + |> validate_data + end +end diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 31224abe0..adb56092d 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -622,4 +622,24 @@ test "returns an error if the actor can't announce the object", %{ assert {:actor, {"can not announce this object publicly", []}} in cng.errors end end + + describe "updates" do + setup do + user = insert(:user) + + object = %{ + "id" => user.ap_id, + "name" => "A new name", + "summary" => "A new bio" + } + + {:ok, valid_update, []} = Builder.update(user, object) + + %{user: user, valid_update: valid_update} + end + + test "validates a basic object", %{valid_update: valid_update} do + assert {:ok, _update, []} = ObjectValidator.validate(valid_update, []) + end + end end From d54b0432eae74a830a0294cf48f23933a16382aa Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Fri, 19 Jun 2020 15:49:34 +0200 Subject: [PATCH 321/401] README: Add some troubleshooting info for compilation issues. --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 7fc1fd381..6ca3118fb 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,16 @@ Currently Pleroma is not packaged by any OS/Distros, but if you want to package ### Docker While we don’t provide docker files, other people have written very good ones. Take a look at <https://github.com/angristan/docker-pleroma> or <https://glitch.sh/sn0w/pleroma-docker>. +### Compilation Troubleshooting +If you ever encounter compilation issues during the updating of Pleroma, you can try these commands and see if they fix things: + +- `mix deps.clean --all` +- `mix local.rebar` +- `mix local.hex` +- `rm -r _build` + +If you are not developing Pleroma, it is better to use the OTP release, which comes with everything precompiled. + ## Documentation - Latest Released revision: <https://docs.pleroma.social> - Latest Git revision: <https://docs-develop.pleroma.social> From 75670a99e46a09f9bddc0959c680c2cb173e1f3b Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Fri, 19 Jun 2020 16:38:57 +0200 Subject: [PATCH 322/401] UpdateValidator: Only allow updates from the user themselves. --- .../object_validators/update_validator.ex | 16 ++++++++++++++++ test/web/activity_pub/object_validator_test.exs | 12 ++++++++++++ 2 files changed, 28 insertions(+) diff --git a/lib/pleroma/web/activity_pub/object_validators/update_validator.ex b/lib/pleroma/web/activity_pub/object_validators/update_validator.ex index 94d72491b..b4ba5ede0 100644 --- a/lib/pleroma/web/activity_pub/object_validators/update_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/update_validator.ex @@ -33,6 +33,7 @@ def validate_data(cng) do |> validate_required([:id, :type, :actor, :to, :cc, :object]) |> validate_inclusion(:type, ["Update"]) |> validate_actor_presence() + |> validate_updating_rights() end def cast_and_validate(data) do @@ -40,4 +41,19 @@ def cast_and_validate(data) do |> cast_data |> validate_data end + + # For now we only support updating users, and here the rule is easy: + # object id == actor id + def validate_updating_rights(cng) do + with actor = get_field(cng, :actor), + object = get_field(cng, :object), + {:ok, object_id} <- ObjectValidators.ObjectID.cast(object), + true <- actor == object_id do + cng + else + _e -> + cng + |> add_error(:object, "Can't be updated by this actor") + end + end end diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index adb56092d..770a8dcf8 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -641,5 +641,17 @@ test "returns an error if the actor can't announce the object", %{ test "validates a basic object", %{valid_update: valid_update} do assert {:ok, _update, []} = ObjectValidator.validate(valid_update, []) end + + test "returns an error if the object can't be updated by the actor", %{ + valid_update: valid_update + } do + other_user = insert(:user) + + update = + valid_update + |> Map.put("actor", other_user.ap_id) + + assert {:error, _cng} = ObjectValidator.validate(update, []) + end end end From b63646169dbed68814bdb867c4b8b3c88a3d2360 Mon Sep 17 00:00:00 2001 From: Sergey Suprunenko <suprunenko.s@gmail.com> Date: Fri, 19 Jun 2020 21:18:07 +0200 Subject: [PATCH 323/401] Add support for bot field in update_credentials --- CHANGELOG.md | 1 + lib/pleroma/user.ex | 1 + .../controllers/account_controller.ex | 3 + .../update_credentials_test.exs | 67 +++++++++++++++++++ 4 files changed, 72 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ee13904f..8a8798e8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Mastodon API: Support for `include_types` in `/api/v1/notifications`. - Mastodon API: Added `/api/v1/notifications/:id/dismiss` endpoint. - Mastodon API: Add support for filtering replies in public and home timelines +- Mastodon API: Support for `bot` field in `/api/v1/accounts/update_credentials` - Admin API: endpoints for create/update/delete OAuth Apps. - Admin API: endpoint for status view. - OTP: Add command to reload emoji packs diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index f0ccc7c79..ae4f96aac 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -465,6 +465,7 @@ def update_changeset(struct, params \\ %{}) do |> validate_format(:nickname, local_nickname_regex()) |> validate_length(:bio, max: bio_limit) |> validate_length(:name, min: 1, max: name_limit) + |> validate_inclusion(:actor_type, ["Person", "Service"]) |> put_fields() |> put_emoji() |> put_change_if_present(:bio, &{:ok, parse_bio(&1, struct)}) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index c38c2b895..adbbac624 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -177,6 +177,9 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p |> Maps.put_if_present(:pleroma_settings_store, params[:pleroma_settings_store]) |> Maps.put_if_present(:default_scope, params[:default_scope]) |> Maps.put_if_present(:default_scope, params["source"]["privacy"]) + |> Maps.put_if_present(:actor_type, params[:bot], fn bot -> + if bot, do: {:ok, "Service"}, else: {:ok, "Person"} + end) |> Maps.put_if_present(:actor_type, params[:actor_type]) changeset = User.update_changeset(user, user_params) diff --git a/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs index 76e6d603a..f67d294ba 100644 --- a/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs +++ b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs @@ -400,4 +400,71 @@ test "update fields when invalid request", %{conn: conn} do |> json_response_and_validate_schema(403) end end + + describe "Mark account as bot" do + setup do: oauth_access(["write:accounts"]) + setup :request_content_type + + test "changing actor_type to Service makes account a bot", %{conn: conn} do + account = + conn + |> patch("/api/v1/accounts/update_credentials", %{actor_type: "Service"}) + |> json_response_and_validate_schema(200) + + assert account["bot"] + assert account["source"]["pleroma"]["actor_type"] == "Service" + end + + test "changing actor_type to Person makes account a human", %{conn: conn} do + account = + conn + |> patch("/api/v1/accounts/update_credentials", %{actor_type: "Person"}) + |> json_response_and_validate_schema(200) + + refute account["bot"] + assert account["source"]["pleroma"]["actor_type"] == "Person" + end + + test "changing actor_type to Application causes error", %{conn: conn} do + response = + conn + |> patch("/api/v1/accounts/update_credentials", %{actor_type: "Application"}) + |> json_response_and_validate_schema(403) + + assert %{"error" => "Invalid request"} == response + end + + test "changing bot field to true changes actor_type to Service", %{conn: conn} do + account = + conn + |> patch("/api/v1/accounts/update_credentials", %{bot: "true"}) + |> json_response_and_validate_schema(200) + + assert account["bot"] + assert account["source"]["pleroma"]["actor_type"] == "Service" + end + + test "changing bot field to false changes actor_type to Person", %{conn: conn} do + account = + conn + |> patch("/api/v1/accounts/update_credentials", %{bot: "false"}) + |> json_response_and_validate_schema(200) + + refute account["bot"] + assert account["source"]["pleroma"]["actor_type"] == "Person" + end + + test "actor_type field has a higher priority than bot", %{conn: conn} do + account = + conn + |> patch("/api/v1/accounts/update_credentials", %{ + actor_type: "Person", + bot: "true" + }) + |> json_response_and_validate_schema(200) + + refute account["bot"] + assert account["source"]["pleroma"]["actor_type"] == "Person" + end + end end From ac0344dd24d520ab61e835b9caea97529f4c1dad Mon Sep 17 00:00:00 2001 From: Sergey Suprunenko <suprunenko.s@gmail.com> Date: Fri, 19 Jun 2020 21:19:00 +0200 Subject: [PATCH 324/401] Only accounts with Service actor_type are considered as bots --- lib/pleroma/web/mastodon_api/views/account_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 68beb69b8..6c40b8ccd 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -179,7 +179,7 @@ defp do_render("show.json", %{user: user} = opts) do 0 end - bot = user.actor_type in ["Application", "Service"] + bot = user.actor_type == "Service" emojis = Enum.map(user.emoji, fn {shortcode, raw_url} -> From 3d4cfc9c5f3969e08c32781385c86f310eba70a2 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Thu, 18 Jun 2020 19:32:03 +0200 Subject: [PATCH 325/401] Stop filling conversation field on incoming objects (legacy, unused) conversation field is still set for outgoing federation for compatibility. --- .../web/activity_pub/object_validators/note_validator.ex | 1 - lib/pleroma/web/activity_pub/transmogrifier.ex | 6 +++--- test/web/activity_pub/transmogrifier_test.exs | 3 --- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/note_validator.ex b/lib/pleroma/web/activity_pub/object_validators/note_validator.ex index a10728ac6..56b93dde8 100644 --- a/lib/pleroma/web/activity_pub/object_validators/note_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/note_validator.ex @@ -41,7 +41,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator do field(:announcements, {:array, :string}, default: []) # see if needed - field(:conversation, :string) field(:context_id, :string) end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 851f474b8..1c60ef8f5 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -172,8 +172,8 @@ def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object, options) 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"]) + |> Map.drop(["conversation"]) else e -> Logger.error("Couldn't fetch #{inspect(in_reply_to_id)}, error: #{inspect(e)}") @@ -207,7 +207,7 @@ def fix_context(object) do object |> Map.put("context", context) - |> Map.put("conversation", context) + |> Map.drop(["conversation"]) end def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do @@ -458,7 +458,7 @@ def handle_incoming( to: data["to"], object: object, actor: user, - context: object["conversation"], + context: object["context"], local: false, published: data["published"], additional: diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 94d8552e8..47d6e843a 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -1571,9 +1571,6 @@ test "returns modified object when allowed incoming reply", %{data: data} do assert modified_object["inReplyToAtomUri"] == "https://shitposter.club/notice/2827873" - assert modified_object["conversation"] == - "tag:shitposter.club,2017-05-05:objectType=thread:nonce=3c16e9c2681f6d26" - assert modified_object["context"] == "tag:shitposter.club,2017-05-05:objectType=thread:nonce=3c16e9c2681f6d26" end From 1a704e1f1e0acb73cbfb49acc4f614dd01799c46 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Sat, 20 Jun 2020 10:56:28 +0300 Subject: [PATCH 326/401] fix for packs pagination --- lib/pleroma/emoji/pack.ex | 6 +++--- .../emoji_pack_controller_test.exs | 20 ++++++++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index 2dca21c93..787ff8141 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -32,8 +32,8 @@ defp paginate(entities, 1, page_size), do: Enum.take(entities, page_size) defp paginate(entities, page, page_size) do entities - |> Enum.take(page * page_size) - |> Enum.take(-page_size) + |> Enum.chunk_every(page_size) + |> Enum.at(page - 1) end @spec show(keyword()) :: {:ok, t()} | {:error, atom()} @@ -470,7 +470,7 @@ defp list_packs_dir do # with the API so it should be sufficient with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(emoji_path)}, {:ls, {:ok, results}} <- {:ls, File.ls(emoji_path)} do - {:ok, results} + {:ok, Enum.sort(results)} else {:create_dir, {:error, e}} -> {:error, :create_dir, e} {:ls, {:error, e}} -> {:error, :ls, e} diff --git a/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs b/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs index 91312c832..df58a5eb6 100644 --- a/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs +++ b/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs @@ -31,6 +31,11 @@ test "GET /api/pleroma/emoji/packs", %{conn: conn} do resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) assert resp["count"] == 3 + + assert resp["packs"] + |> Map.keys() + |> length() == 3 + shared = resp["packs"]["test_pack"] assert shared["files"] == %{"blank" => "blank.png", "blank2" => "blank2.png"} assert Map.has_key?(shared["pack"], "download-sha256") @@ -47,7 +52,12 @@ test "GET /api/pleroma/emoji/packs", %{conn: conn} do |> json_response_and_validate_schema(200) assert resp["count"] == 3 - [pack1] = Map.keys(resp["packs"]) + + packs = Map.keys(resp["packs"]) + + assert length(packs) == 1 + + [pack1] = packs resp = conn @@ -55,7 +65,9 @@ test "GET /api/pleroma/emoji/packs", %{conn: conn} do |> json_response_and_validate_schema(200) assert resp["count"] == 3 - [pack2] = Map.keys(resp["packs"]) + packs = Map.keys(resp["packs"]) + assert length(packs) == 1 + [pack2] = packs resp = conn @@ -63,7 +75,9 @@ test "GET /api/pleroma/emoji/packs", %{conn: conn} do |> json_response_and_validate_schema(200) assert resp["count"] == 3 - [pack3] = Map.keys(resp["packs"]) + packs = Map.keys(resp["packs"]) + assert length(packs) == 1 + [pack3] = packs assert [pack1, pack2, pack3] |> Enum.uniq() |> length() == 3 end From 4cb7b1ebc6b255faae635f6138bf90264e84e1fb Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Sat, 20 Jun 2020 09:34:34 +0000 Subject: [PATCH 327/401] Apply suggestion to lib/mix/tasks/pleroma/config.ex --- lib/mix/tasks/pleroma/config.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 65691f9c1..d5129d410 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -52,7 +52,7 @@ def migrate_to_db(file_path \\ nil) do defp do_migrate_to_db(config_file) do if File.exists?(config_file) do - shell_info("Running migrate settings from file: #{Path.expand(config_file)}") + shell_info("Migrating settings from file: #{Path.expand(config_file)}") Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;") Ecto.Adapters.SQL.query!(Repo, "ALTER SEQUENCE config_id_seq RESTART;") From 15ba5392584a2d4e8129a99e825f5025e57e6ebd Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Sat, 20 Jun 2020 11:39:06 +0200 Subject: [PATCH 328/401] =?UTF-8?q?cheatsheet.md:=20no=5Fattachment=5Flink?= =?UTF-8?q?s=20=E2=86=92=20attachment=5Flinks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/configuration/cheatsheet.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 6ebdab546..7e5f1cd29 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -60,7 +60,7 @@ To add configuration to your config file, you can copy it from the base config. older software for theses nicknames. * `max_pinned_statuses`: The maximum number of pinned statuses. `0` will disable the feature. * `autofollowed_nicknames`: Set to nicknames of (local) users that every new user should automatically follow. -* `no_attachment_links`: Set to true to disable automatically adding attachment link text to statuses. +* `attachment_links`: Set to true to enable automatically adding attachment link text to statuses. * `welcome_message`: A message that will be send to a newly registered users as a direct message. * `welcome_user_nickname`: The nickname of the local user that sends the welcome message. * `max_report_comment_size`: The maximum size of the report comment (Default: `1000`). From 0e789bc55fed24fd913d6bf1a5c6be135320b0c9 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Sat, 20 Jun 2020 09:39:50 +0000 Subject: [PATCH 329/401] Apply suggestion to lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex --- .../web/api_spec/operations/pleroma_emoji_pack_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex index da7cc5154..caa849721 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex @@ -44,7 +44,7 @@ def index_operation do :page_size, :query, %Schema{type: :integer, default: 50}, - "Number of statuses to return" + "Number of emoji packs to return" ) ], responses: %{ From c5863438ba9079a01a832fe48e203907fe5b37cd Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Sat, 20 Jun 2020 13:53:57 +0300 Subject: [PATCH 330/401] proper error codes for error in adminFE --- docs/API/admin_api.md | 54 ++++++++++--------- .../controllers/admin_api_controller.ex | 29 +++++----- .../controllers/fallback_controller.ex | 6 +++ .../controllers/admin_api_controller_test.exs | 4 +- 4 files changed, 50 insertions(+), 43 deletions(-) diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index c7f56cf5f..b6fb43dcb 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -488,35 +488,39 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret ### Change the user's email, password, display and settings-related fields -- Params: - - `email` - - `password` - - `name` - - `bio` - - `avatar` - - `locked` - - `no_rich_text` - - `default_scope` - - `banner` - - `hide_follows` - - `hide_followers` - - `hide_followers_count` - - `hide_follows_count` - - `hide_favorites` - - `allow_following_move` - - `background` - - `show_role` - - `skip_thread_containment` - - `fields` - - `discoverable` - - `actor_type` +* Params: + * `email` + * `password` + * `name` + * `bio` + * `avatar` + * `locked` + * `no_rich_text` + * `default_scope` + * `banner` + * `hide_follows` + * `hide_followers` + * `hide_followers_count` + * `hide_follows_count` + * `hide_favorites` + * `allow_following_move` + * `background` + * `show_role` + * `skip_thread_containment` + * `fields` + * `discoverable` + * `actor_type` -- Response: +* Responses: + +Status: 200 ```json {"status": "success"} ``` +Status: 400 + ```json {"errors": {"actor_type": "is invalid"}, @@ -525,8 +529,10 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret } ``` +Status: 404 + ```json -{"error": "Unable to update user."} +{"error": "Not found"} ``` ## `GET /api/pleroma/admin/reports` diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index 5cbf0dd4f..db2413dfe 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -111,8 +111,7 @@ def user_delete(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) action: "delete" }) - conn - |> json(nicknames) + json(conn, nicknames) end def user_follow(%{assigns: %{user: admin}} = conn, %{ @@ -131,8 +130,7 @@ def user_follow(%{assigns: %{user: admin}} = conn, %{ }) end - conn - |> json("ok") + json(conn, "ok") end def user_unfollow(%{assigns: %{user: admin}} = conn, %{ @@ -151,8 +149,7 @@ def user_unfollow(%{assigns: %{user: admin}} = conn, %{ }) end - conn - |> json("ok") + json(conn, "ok") end def users_create(%{assigns: %{user: admin}} = conn, %{"users" => users}) do @@ -191,8 +188,7 @@ def users_create(%{assigns: %{user: admin}} = conn, %{"users" => users}) do action: "create" }) - conn - |> json(res) + json(conn, res) {:error, id, changeset, _} -> res = @@ -363,8 +359,8 @@ defp maybe_parse_filters(filters) do filters |> String.split(",") |> Enum.filter(&Enum.member?(@filters, &1)) - |> Enum.map(&String.to_atom(&1)) - |> Enum.into(%{}, &{&1, true}) + |> Enum.map(&String.to_atom/1) + |> Map.new(&{&1, true}) end def right_add_multiple(%{assigns: %{user: admin}} = conn, %{ @@ -568,10 +564,10 @@ def update_user_credentials( {:error, changeset} -> errors = Map.new(changeset.errors, fn {key, {error, _}} -> {key, error} end) - json(conn, %{errors: errors}) + {:errors, errors} _ -> - json(conn, %{error: "Unable to update user."}) + {:error, :not_found} end end @@ -616,7 +612,7 @@ defp configurable_from_database do def reload_emoji(conn, _params) do Pleroma.Emoji.reload() - conn |> json("ok") + json(conn, "ok") end def confirm_email(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do @@ -630,7 +626,7 @@ def confirm_email(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames} action: "confirm_email" }) - conn |> json("") + json(conn, "") end def resend_confirmation_email(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do @@ -644,14 +640,13 @@ def resend_confirmation_email(%{assigns: %{user: admin}} = conn, %{"nicknames" = action: "resend_confirmation_email" }) - conn |> json("") + json(conn, "") end def stats(conn, _) do count = Stats.get_status_visibility_count() - conn - |> json(%{"status_visibility" => count}) + json(conn, %{"status_visibility" => count}) end defp page_params(params) do diff --git a/lib/pleroma/web/admin_api/controllers/fallback_controller.ex b/lib/pleroma/web/admin_api/controllers/fallback_controller.ex index 82965936d..34d90db07 100644 --- a/lib/pleroma/web/admin_api/controllers/fallback_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/fallback_controller.ex @@ -17,6 +17,12 @@ def call(conn, {:error, reason}) do |> json(%{error: reason}) end + def call(conn, {:errors, errors}) do + conn + |> put_status(:bad_request) + |> json(%{errors: errors}) + end + def call(conn, {:param_cast, _}) do conn |> put_status(:bad_request) diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index e3d3ccb8d..3a3eb822d 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -1599,14 +1599,14 @@ test "changes actor type from permitted list", %{conn: conn, user: user} do assert patch(conn, "/api/pleroma/admin/users/#{user.nickname}/credentials", %{ "actor_type" => "Application" }) - |> json_response(200) == %{"errors" => %{"actor_type" => "is invalid"}} + |> json_response(400) == %{"errors" => %{"actor_type" => "is invalid"}} end test "update non existing user", %{conn: conn} do assert patch(conn, "/api/pleroma/admin/users/non-existing/credentials", %{ "password" => "new_password" }) - |> json_response(200) == %{"error" => "Unable to update user."} + |> json_response(404) == %{"error" => "Not found"} end end From b5f13af7ba66924f6aed448bd519f6becc269922 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Sat, 20 Jun 2020 10:59:08 +0000 Subject: [PATCH 331/401] Apply suggestion to lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex --- .../web/api_spec/operations/pleroma_emoji_pack_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex index caa849721..b2b4f8713 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex @@ -70,7 +70,7 @@ def show_operation do :page_size, :query, %Schema{type: :integer, default: 30}, - "Number of statuses to return" + "Number of emoji to return" ) ], responses: %{ From 35e9282ffdafd8a04d1c09ec5eff3f176bb389de Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Mon, 22 Jun 2020 10:35:11 +0200 Subject: [PATCH 332/401] HellthreadPolicy: Restrict to Notes and Articles. --- .../web/activity_pub/mrf/hellthread_policy.ex | 7 +++++-- .../mrf/hellthread_policy_test.exs | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex index 1764bc789..f6b2c4415 100644 --- a/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex @@ -13,8 +13,10 @@ defmodule Pleroma.Web.ActivityPub.MRF.HellthreadPolicy do defp delist_message(message, threshold) when threshold > 0 do follower_collection = User.get_cached_by_ap_id(message["actor"]).follower_address + to = message["to"] || [] + cc = message["cc"] || [] - follower_collection? = Enum.member?(message["to"] ++ message["cc"], follower_collection) + follower_collection? = Enum.member?(to ++ cc, follower_collection) message = case get_recipient_count(message) do @@ -71,7 +73,8 @@ defp get_recipient_count(message) do end @impl true - def filter(%{"type" => "Create"} = message) do + def filter(%{"type" => "Create", "object" => %{"type" => object_type}} = message) + when object_type in ~w{Note Article} do reject_threshold = Pleroma.Config.get( [:mrf_hellthread, :reject_threshold], diff --git a/test/web/activity_pub/mrf/hellthread_policy_test.exs b/test/web/activity_pub/mrf/hellthread_policy_test.exs index 95ef0b168..6e9daa7f9 100644 --- a/test/web/activity_pub/mrf/hellthread_policy_test.exs +++ b/test/web/activity_pub/mrf/hellthread_policy_test.exs @@ -8,6 +8,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.HellthreadPolicyTest do import Pleroma.Web.ActivityPub.MRF.HellthreadPolicy + alias Pleroma.Web.CommonAPI + setup do user = insert(:user) @@ -20,7 +22,10 @@ defmodule Pleroma.Web.ActivityPub.MRF.HellthreadPolicyTest do "https://instance.tld/users/user1", "https://instance.tld/users/user2", "https://instance.tld/users/user3" - ] + ], + "object" => %{ + "type" => "Note" + } } [user: user, message: message] @@ -28,6 +33,17 @@ defmodule Pleroma.Web.ActivityPub.MRF.HellthreadPolicyTest do setup do: clear_config(:mrf_hellthread) + test "doesn't die on chat messages" do + Pleroma.Config.put([:mrf_hellthread], %{delist_threshold: 2, reject_threshold: 0}) + + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post_chat_message(user, other_user, "moin") + + assert {:ok, _} = filter(activity.data) + end + describe "reject" do test "rejects the message if the recipient count is above reject_threshold", %{ message: message From 9f7ee5dfa283f8db9a5fcb006630674263425ac8 Mon Sep 17 00:00:00 2001 From: Ilja <domainepublic@spectraltheorem.be> Date: Mon, 22 Jun 2020 11:41:22 +0200 Subject: [PATCH 333/401] Add include for the "Further reading" section * I added an include and use this include for the installation guides that already had this section * I added the "Further reading" section as well as te "Questions" section to the English guides that didn't have it yet * I added a first point "How Federation Works/Why is my Federated Timeline empty?" to link to lains blogpost about this because we still get this question a lot in the #pleroma support channel * I reordered the list a bit --- docs/installation/alpine_linux_en.md | 5 +---- docs/installation/arch_linux_en.md | 5 +---- docs/installation/debian_based_en.md | 5 +---- docs/installation/debian_based_jp.md | 5 +---- docs/installation/further_reading.include | 5 +++++ docs/installation/gentoo_en.md | 5 +---- docs/installation/netbsd_en.md | 8 ++++++++ docs/installation/openbsd_en.md | 8 ++++++++ docs/installation/otp_en.md | 5 +---- 9 files changed, 27 insertions(+), 24 deletions(-) create mode 100644 docs/installation/further_reading.include diff --git a/docs/installation/alpine_linux_en.md b/docs/installation/alpine_linux_en.md index 2a9b8f6ff..c726d559f 100644 --- a/docs/installation/alpine_linux_en.md +++ b/docs/installation/alpine_linux_en.md @@ -225,10 +225,7 @@ sudo -Hu pleroma MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress #### Further reading -* [Backup your instance](../administration/backup.md) -* [Hardening your instance](../configuration/hardening.md) -* [How to activate mediaproxy](../configuration/howto_mediaproxy.md) -* [Updating your instance](../administration/updating.md) +{! backend/installation/further_reading.include !} ## Questions diff --git a/docs/installation/arch_linux_en.md b/docs/installation/arch_linux_en.md index 8370986ad..bf9cfb488 100644 --- a/docs/installation/arch_linux_en.md +++ b/docs/installation/arch_linux_en.md @@ -200,10 +200,7 @@ sudo -Hu pleroma MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress #### Further reading -* [Backup your instance](../administration/backup.md) -* [Hardening your instance](../configuration/hardening.md) -* [How to activate mediaproxy](../configuration/howto_mediaproxy.md) -* [Updating your instance](../administration/updating.md) +{! backend/installation/further_reading.include !} ## Questions diff --git a/docs/installation/debian_based_en.md b/docs/installation/debian_based_en.md index 2c20d521a..8ae5044b5 100644 --- a/docs/installation/debian_based_en.md +++ b/docs/installation/debian_based_en.md @@ -186,10 +186,7 @@ sudo -Hu pleroma MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress #### Further reading -* [Backup your instance](../administration/backup.md) -* [Hardening your instance](../configuration/hardening.md) -* [How to activate mediaproxy](../configuration/howto_mediaproxy.md) -* [Updating your instance](../administration/updating.md) +{! backend/installation/further_reading.include !} ## Questions diff --git a/docs/installation/debian_based_jp.md b/docs/installation/debian_based_jp.md index 1e5a9be91..42e91cda7 100644 --- a/docs/installation/debian_based_jp.md +++ b/docs/installation/debian_based_jp.md @@ -175,10 +175,7 @@ sudo -Hu pleroma MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress #### その他の設定とカスタマイズ -* [Backup your instance](../administration/backup.md) -* [Hardening your instance](../configuration/hardening.md) -* [How to activate mediaproxy](../configuration/howto_mediaproxy.md) -* [Updating your instance](../administration/updating.md) +{! backend/installation/further_reading.include !} ## 質問ある? diff --git a/docs/installation/further_reading.include b/docs/installation/further_reading.include new file mode 100644 index 000000000..46752c722 --- /dev/null +++ b/docs/installation/further_reading.include @@ -0,0 +1,5 @@ +* [How Federation Works/Why is my Federated Timeline empty?](https://blog.soykaf.com/post/how-federation-works/) +* [Backup your instance](../administration/backup.md) +* [Updating your instance](../administration/updating.md) +* [Hardening your instance](../configuration/hardening.md) +* [How to activate mediaproxy](../configuration/howto_mediaproxy.md) diff --git a/docs/installation/gentoo_en.md b/docs/installation/gentoo_en.md index 1e61373cc..32152aea7 100644 --- a/docs/installation/gentoo_en.md +++ b/docs/installation/gentoo_en.md @@ -283,10 +283,7 @@ If you opted to allow sudo for the `pleroma` user but would like to remove the a #### Further reading -* [Backup your instance](../administration/backup.md) -* [Hardening your instance](../configuration/hardening.md) -* [How to activate mediaproxy](../configuration/howto_mediaproxy.md) -* [Updating your instance](../administration/updating.md) +{! backend/installation/further_reading.include !} ## Questions diff --git a/docs/installation/netbsd_en.md b/docs/installation/netbsd_en.md index 6a922a27e..3626acc69 100644 --- a/docs/installation/netbsd_en.md +++ b/docs/installation/netbsd_en.md @@ -196,3 +196,11 @@ incorrect timestamps. You should have ntpd running. ## Instances running NetBSD * <https://catgirl.science> + +#### Further reading + +{! backend/installation/further_reading.include !} + +## Questions + +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:matrix.org](https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org) or IRC Channel **#pleroma** on **Freenode**. diff --git a/docs/installation/openbsd_en.md b/docs/installation/openbsd_en.md index e8c5d844c..5dbe24f75 100644 --- a/docs/installation/openbsd_en.md +++ b/docs/installation/openbsd_en.md @@ -242,3 +242,11 @@ If your instance is up and running, you can create your first user with administ ``` LC_ALL=en_US.UTF-8 MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress> --admin ``` + +#### Further reading + +{! backend/installation/further_reading.include !} + +## Questions + +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:matrix.org](https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org) or IRC Channel **#pleroma** on **Freenode**. diff --git a/docs/installation/otp_en.md b/docs/installation/otp_en.md index 86135cd20..e4f822d1c 100644 --- a/docs/installation/otp_en.md +++ b/docs/installation/otp_en.md @@ -270,10 +270,7 @@ This will create an account withe the username of 'joeuser' with the email addre ## Further reading -* [Backup your instance](../administration/backup.md) -* [Hardening your instance](../configuration/hardening.md) -* [How to activate mediaproxy](../configuration/howto_mediaproxy.md) -* [Updating your instance](../administration/updating.md) +{! backend/installation/further_reading.include !} ## Questions From 31a4d42ce0470d74417279a855192294650cff97 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Mon, 22 Jun 2020 13:15:37 +0200 Subject: [PATCH 334/401] SideEffects: Handle user updating. --- lib/pleroma/web/activity_pub/side_effects.ex | 12 ++++++++++++ test/web/activity_pub/side_effects_test.exs | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 1a1cc675c..09fd7d7c9 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -20,6 +20,18 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do def handle(object, meta \\ []) + # Tasks this handles: + # Update the user + def handle(%{data: %{"type" => "Update", "object" => updated_object}} = object, meta) do + {:ok, new_user_data} = ActivityPub.user_data_from_user_object(updated_object) + + User.get_by_ap_id(updated_object["id"]) + |> User.remote_user_changeset(new_user_data) + |> User.update_and_set_cache() + + {:ok, object, meta} + end + # Tasks this handles: # - Add like to object # - Set up notification diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 6bbbaae87..1d7c2736b 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -64,6 +64,22 @@ test "it streams out notifications and streams" do end end + describe "update users" do + setup do + user = insert(:user) + {:ok, update_data, []} = Builder.update(user, %{"id" => user.ap_id, "name" => "new name!"}) + {:ok, update, _meta} = ActivityPub.persist(update_data, local: true) + + %{user: user, update_data: update_data, update: update} + end + + test "it updates the user", %{user: user, update: update} do + {:ok, _, _} = SideEffects.handle(update) + user = User.get_by_id(user.id) + assert user.name == "new name!" + end + end + describe "delete objects" do setup do user = insert(:user) From 9438f83f83305f101b9fed65f68a5b9fd622bcbb Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Mon, 22 Jun 2020 13:16:05 +0200 Subject: [PATCH 335/401] Transmogrifier: Handle `Update` with the pipeline. --- .../web/activity_pub/transmogrifier.ex | 33 +++---------------- 1 file changed, 5 insertions(+), 28 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 851f474b8..8165218ee 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -684,35 +684,12 @@ def handle_incoming(%{"type" => type} = data, _options) end def handle_incoming( - %{"type" => "Update", "object" => %{"type" => object_type} = object, "actor" => actor_id} = - data, + %{"type" => "Update"} = data, _options - ) - when object_type in [ - "Person", - "Application", - "Service", - "Organization" - ] do - with %User{ap_id: ^actor_id} = actor <- User.get_cached_by_ap_id(object["id"]) do - {:ok, new_user_data} = ActivityPub.user_data_from_user_object(object) - - actor - |> User.remote_user_changeset(new_user_data) - |> User.update_and_set_cache() - - ActivityPub.update(%{ - local: false, - to: data["to"] || [], - cc: data["cc"] || [], - object: object, - actor: actor_id, - activity_id: data["id"] - }) - else - e -> - Logger.error(e) - :error + ) do + with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), + {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do + {:ok, activity} end end From 1e7ca2443011f65aa766c3ddd5cd1203e79db50b Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Mon, 22 Jun 2020 13:23:21 +0200 Subject: [PATCH 336/401] Update Handling Test: Fix for re-used update ids. --- .../transmogrifier/user_update_handling_test.exs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/web/activity_pub/transmogrifier/user_update_handling_test.exs b/test/web/activity_pub/transmogrifier/user_update_handling_test.exs index 8e5d3b883..64636656c 100644 --- a/test/web/activity_pub/transmogrifier/user_update_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/user_update_handling_test.exs @@ -106,11 +106,13 @@ test "it works with custom profile fields" do Pleroma.Config.put([:instance, :max_remote_account_fields], 2) update_data = - put_in(update_data, ["object", "attachment"], [ + update_data + |> put_in(["object", "attachment"], [ %{"name" => "foo", "type" => "PropertyValue", "value" => "bar"}, %{"name" => "foo11", "type" => "PropertyValue", "value" => "bar11"}, %{"name" => "foo22", "type" => "PropertyValue", "value" => "bar22"} ]) + |> Map.put("id", update_data["id"] <> ".") {:ok, _} = Transmogrifier.handle_incoming(update_data) @@ -121,7 +123,10 @@ test "it works with custom profile fields" do %{"name" => "foo1", "value" => "updated"} ] - update_data = put_in(update_data, ["object", "attachment"], []) + update_data = + update_data + |> put_in(["object", "attachment"], []) + |> Map.put("id", update_data["id"] <> ".") {:ok, _} = Transmogrifier.handle_incoming(update_data) From 4f5af68b3e96c5b5b62185f86af39fc2f8955e10 Mon Sep 17 00:00:00 2001 From: Ben Is <srsbzns@cock.li> Date: Fri, 19 Jun 2020 14:33:58 +0000 Subject: [PATCH 337/401] Added translation using Weblate (Italian) --- priv/gettext/it/LC_MESSAGES/errors.po | 578 ++++++++++++++++++++++++++ 1 file changed, 578 insertions(+) create mode 100644 priv/gettext/it/LC_MESSAGES/errors.po diff --git a/priv/gettext/it/LC_MESSAGES/errors.po b/priv/gettext/it/LC_MESSAGES/errors.po new file mode 100644 index 000000000..18ec03c83 --- /dev/null +++ b/priv/gettext/it/LC_MESSAGES/errors.po @@ -0,0 +1,578 @@ +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-06-19 14:33+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Translate Toolkit 2.5.1\n" + +## This file is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here as no +## effect: edit them in PO (`.po`) files instead. +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:421 +#, elixir-format +msgid "Account not found" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:249 +#, elixir-format +msgid "Already voted" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:360 +#, elixir-format +msgid "Bad request" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:425 +#, elixir-format +msgid "Can't delete object" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/status_controller.ex:196 +#, elixir-format +msgid "Can't delete this post" +msgstr "" + +#: lib/pleroma/web/controller_helper.ex:95 +#: lib/pleroma/web/controller_helper.ex:101 +#, elixir-format +msgid "Can't display this activity" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:227 +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:254 +#, elixir-format +msgid "Can't find user" +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/account_controller.ex:114 +#, elixir-format +msgid "Can't get favorites" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:437 +#, elixir-format +msgid "Can't like object" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:556 +#, elixir-format +msgid "Cannot post an empty status without attachments" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:504 +#, elixir-format +msgid "Comment must be up to %{max_size} characters" +msgstr "" + +#: lib/pleroma/config/config_db.ex:222 +#, elixir-format +msgid "Config with params %{params} not found" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:95 +#, elixir-format +msgid "Could not delete" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:141 +#, elixir-format +msgid "Could not favorite" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:370 +#, elixir-format +msgid "Could not pin" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:112 +#, elixir-format +msgid "Could not repeat" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:188 +#, elixir-format +msgid "Could not unfavorite" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:380 +#, elixir-format +msgid "Could not unpin" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:126 +#, elixir-format +msgid "Could not unrepeat" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:428 +#: lib/pleroma/web/common_api/common_api.ex:437 +#, elixir-format +msgid "Could not update state" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:202 +#, elixir-format +msgid "Error." +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:106 +#, elixir-format +msgid "Invalid CAPTCHA" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:117 +#: lib/pleroma/web/oauth/oauth_controller.ex:569 +#, elixir-format +msgid "Invalid credentials" +msgstr "" + +#: lib/pleroma/plugs/ensure_authenticated_plug.ex:38 +#, elixir-format +msgid "Invalid credentials." +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:265 +#, elixir-format +msgid "Invalid indices" +msgstr "" + +#: lib/pleroma/web/admin_api/admin_api_controller.ex:1147 +#, elixir-format +msgid "Invalid parameters" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:411 +#, elixir-format +msgid "Invalid password." +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:187 +#, elixir-format +msgid "Invalid request" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:109 +#, elixir-format +msgid "Kocaptcha service unavailable" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:113 +#, elixir-format +msgid "Missing parameters" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:540 +#, elixir-format +msgid "No such conversation" +msgstr "" + +#: lib/pleroma/web/admin_api/admin_api_controller.ex:439 +#: lib/pleroma/web/admin_api/admin_api_controller.ex:465 lib/pleroma/web/admin_api/admin_api_controller.ex:507 +#, elixir-format +msgid "No such permission_group" +msgstr "" + +#: lib/pleroma/plugs/uploaded_media.ex:74 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:485 lib/pleroma/web/admin_api/admin_api_controller.ex:1135 +#: lib/pleroma/web/feed/user_controller.ex:73 lib/pleroma/web/ostatus/ostatus_controller.ex:143 +#, elixir-format +msgid "Not found" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:241 +#, elixir-format +msgid "Poll's author can't vote" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:20 +#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:37 lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:49 +#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:50 lib/pleroma/web/mastodon_api/controllers/status_controller.ex:290 +#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:71 +#, elixir-format +msgid "Record not found" +msgstr "" + +#: lib/pleroma/web/admin_api/admin_api_controller.ex:1153 +#: lib/pleroma/web/feed/user_controller.ex:79 lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:32 +#: lib/pleroma/web/ostatus/ostatus_controller.ex:149 +#, elixir-format +msgid "Something went wrong" +msgstr "" + +#: lib/pleroma/web/common_api/activity_draft.ex:107 +#, elixir-format +msgid "The message visibility must be direct" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:566 +#, elixir-format +msgid "The status is over the character limit" +msgstr "" + +#: lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex:31 +#, elixir-format +msgid "This resource requires authentication." +msgstr "" + +#: lib/pleroma/plugs/rate_limiter/rate_limiter.ex:206 +#, elixir-format +msgid "Throttled" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:266 +#, elixir-format +msgid "Too many choices" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:442 +#, elixir-format +msgid "Unhandled activity type" +msgstr "" + +#: lib/pleroma/web/admin_api/admin_api_controller.ex:536 +#, elixir-format +msgid "You can't revoke your own admin status." +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:218 +#: lib/pleroma/web/oauth/oauth_controller.ex:309 +#, elixir-format +msgid "Your account is currently disabled" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:180 +#: lib/pleroma/web/oauth/oauth_controller.ex:332 +#, elixir-format +msgid "Your login is missing a confirmed e-mail address" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:389 +#, elixir-format +msgid "can't read inbox of %{nickname} as %{as_nickname}" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:472 +#, elixir-format +msgid "can't update outbox of %{nickname} as %{as_nickname}" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:388 +#, elixir-format +msgid "conversation is already muted" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:316 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:491 +#, elixir-format +msgid "error" +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:29 +#, elixir-format +msgid "mascots can only be images" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:60 +#, elixir-format +msgid "not found" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:395 +#, elixir-format +msgid "Bad OAuth request." +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:115 +#, elixir-format +msgid "CAPTCHA already used" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:112 +#, elixir-format +msgid "CAPTCHA expired" +msgstr "" + +#: lib/pleroma/plugs/uploaded_media.ex:55 +#, elixir-format +msgid "Failed" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:411 +#, elixir-format +msgid "Failed to authenticate: %{message}." +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:442 +#, elixir-format +msgid "Failed to set up user account." +msgstr "" + +#: lib/pleroma/plugs/oauth_scopes_plug.ex:38 +#, elixir-format +msgid "Insufficient permissions: %{permissions}." +msgstr "" + +#: lib/pleroma/plugs/uploaded_media.ex:94 +#, elixir-format +msgid "Internal Error" +msgstr "" + +#: lib/pleroma/web/oauth/fallback_controller.ex:22 +#: lib/pleroma/web/oauth/fallback_controller.ex:29 +#, elixir-format +msgid "Invalid Username/Password" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:118 +#, elixir-format +msgid "Invalid answer data" +msgstr "" + +#: lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:128 +#, elixir-format +msgid "Nodeinfo schema version not handled" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:169 +#, elixir-format +msgid "This action is outside the authorized scopes" +msgstr "" + +#: lib/pleroma/web/oauth/fallback_controller.ex:14 +#, elixir-format +msgid "Unknown error, please check the details and try again." +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:116 +#: lib/pleroma/web/oauth/oauth_controller.ex:155 +#, elixir-format +msgid "Unlisted redirect_uri." +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:391 +#, elixir-format +msgid "Unsupported OAuth provider: %{provider}." +msgstr "" + +#: lib/pleroma/uploaders/uploader.ex:72 +#, elixir-format +msgid "Uploader callback timeout" +msgstr "" + +#: lib/pleroma/web/uploader_controller.ex:23 +#, elixir-format +msgid "bad request" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:103 +#, elixir-format +msgid "CAPTCHA Error" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:200 +#, elixir-format +msgid "Could not add reaction emoji" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:211 +#, elixir-format +msgid "Could not remove reaction emoji" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:129 +#, elixir-format +msgid "Invalid CAPTCHA (Missing parameter: %{name})" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/list_controller.ex:92 +#, elixir-format +msgid "List not found" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:124 +#, elixir-format +msgid "Missing parameter: %{name}" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:207 +#: lib/pleroma/web/oauth/oauth_controller.ex:322 +#, elixir-format +msgid "Password reset is required" +msgstr "" + +#: lib/pleroma/tests/auth_test_controller.ex:9 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:6 lib/pleroma/web/admin_api/admin_api_controller.ex:6 +#: lib/pleroma/web/controller_helper.ex:6 lib/pleroma/web/fallback_redirect_controller.ex:6 +#: lib/pleroma/web/feed/tag_controller.ex:6 lib/pleroma/web/feed/user_controller.ex:6 +#: lib/pleroma/web/mailer/subscription_controller.ex:2 lib/pleroma/web/masto_fe_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/app_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/auth_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/filter_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/instance_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/list_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/marker_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex:14 lib/pleroma/web/mastodon_api/controllers/media_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/notification_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/report_controller.ex:8 lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/search_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/status_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:7 lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:6 lib/pleroma/web/media_proxy/media_proxy_controller.ex:6 +#: lib/pleroma/web/mongooseim/mongoose_im_controller.ex:6 lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:6 +#: lib/pleroma/web/oauth/fallback_controller.ex:6 lib/pleroma/web/oauth/mfa_controller.ex:10 +#: lib/pleroma/web/oauth/oauth_controller.ex:6 lib/pleroma/web/ostatus/ostatus_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/account_controller.ex:6 lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:2 +#: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:6 lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex:7 lib/pleroma/web/static_fe/static_fe_controller.ex:6 +#: lib/pleroma/web/twitter_api/controllers/password_controller.ex:10 lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex:6 +#: lib/pleroma/web/twitter_api/controllers/util_controller.ex:6 lib/pleroma/web/twitter_api/twitter_api_controller.ex:6 +#: lib/pleroma/web/uploader_controller.ex:6 lib/pleroma/web/web_finger/web_finger_controller.ex:6 +#, elixir-format +msgid "Security violation: OAuth scopes check was neither handled nor explicitly skipped." +msgstr "" + +#: lib/pleroma/plugs/ensure_authenticated_plug.ex:28 +#, elixir-format +msgid "Two-factor authentication enabled, you must use a access token." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:210 +#, elixir-format +msgid "Unexpected error occurred while adding file to pack." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:138 +#, elixir-format +msgid "Unexpected error occurred while creating pack." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:278 +#, elixir-format +msgid "Unexpected error occurred while removing file from pack." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:250 +#, elixir-format +msgid "Unexpected error occurred while updating file in pack." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:179 +#, elixir-format +msgid "Unexpected error occurred while updating pack metadata." +msgstr "" + +#: lib/pleroma/plugs/user_is_admin_plug.ex:40 +#, elixir-format +msgid "User is not an admin or OAuth admin scope is not granted." +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:61 +#, elixir-format +msgid "Web push subscription is disabled on this Pleroma instance" +msgstr "" + +#: lib/pleroma/web/admin_api/admin_api_controller.ex:502 +#, elixir-format +msgid "You can't revoke your own admin/moderator status." +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:105 +#, elixir-format +msgid "authorization required for timeline view" +msgstr "" From 68c812eb2ecbfb1d582925c15d90bd1bd4e62b4b Mon Sep 17 00:00:00 2001 From: Ben Is <srsbzns@cock.li> Date: Fri, 19 Jun 2020 14:35:01 +0000 Subject: [PATCH 338/401] Translated using Weblate (Italian) Currently translated at 0.9% (1 of 106 strings) Translation: Pleroma/Pleroma backend Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma/it/ --- priv/gettext/it/LC_MESSAGES/errors.po | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/priv/gettext/it/LC_MESSAGES/errors.po b/priv/gettext/it/LC_MESSAGES/errors.po index 18ec03c83..726be628b 100644 --- a/priv/gettext/it/LC_MESSAGES/errors.po +++ b/priv/gettext/it/LC_MESSAGES/errors.po @@ -3,14 +3,16 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-06-19 14:33+0000\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" +"PO-Revision-Date: 2020-06-19 20:38+0000\n" +"Last-Translator: Ben Is <srsbzns@cock.li>\n" +"Language-Team: Italian <https://translate.pleroma.social/projects/pleroma/" +"pleroma/it/>\n" "Language: it\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Translate Toolkit 2.5.1\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.0.4\n" ## This file is a PO Template file. ## @@ -23,7 +25,7 @@ msgstr "" ## effect: edit them in PO (`.po`) files instead. ## From Ecto.Changeset.cast/4 msgid "can't be blank" -msgstr "" +msgstr "non può essere nullo" ## From Ecto.Changeset.unique_constraint/3 msgid "has already been taken" From e785cd5caeab2c610f12a9071cade31a6b4549a4 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Mon, 22 Jun 2020 13:59:45 +0200 Subject: [PATCH 339/401] ActivityPub: Remove `update` and switch to pipeline. --- lib/pleroma/web/activity_pub/activity_pub.ex | 22 -------- lib/pleroma/web/activity_pub/side_effects.ex | 18 +++++-- .../controllers/account_controller.ex | 53 +++++++++++-------- test/web/activity_pub/activity_pub_test.exs | 46 ---------------- test/web/activity_pub/side_effects_test.exs | 9 ++++ 5 files changed, 52 insertions(+), 96 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 3e4f3ad30..4cc9fe16c 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -321,28 +321,6 @@ defp accept_or_reject(type, %{to: to, actor: actor, object: object} = params) do end end - @spec update(map()) :: {:ok, Activity.t()} | {:error, any()} - def update(%{to: to, cc: cc, actor: actor, object: object} = params) do - local = !(params[:local] == false) - activity_id = params[:activity_id] - - data = - %{ - "to" => to, - "cc" => cc, - "type" => "Update", - "actor" => actor, - "object" => object - } - |> Maps.put_if_present("id", activity_id) - - with {:ok, activity} <- insert(data, local), - _ <- notify_and_stream(activity), - :ok <- maybe_federate(activity) do - {:ok, activity} - end - end - @spec follow(User.t(), User.t(), String.t() | nil, boolean(), keyword()) :: {:ok, Activity.t()} | {:error, any()} def follow(follower, followed, activity_id \\ nil, local \\ true, opts \\ []) do diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 09fd7d7c9..de143b8f0 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -21,13 +21,21 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do def handle(object, meta \\ []) # Tasks this handles: - # Update the user + # - Update the user + # + # For a local user, we also get a changeset with the full information, so we + # can update non-federating, non-activitypub settings as well. def handle(%{data: %{"type" => "Update", "object" => updated_object}} = object, meta) do - {:ok, new_user_data} = ActivityPub.user_data_from_user_object(updated_object) + if changeset = Keyword.get(meta, :user_update_changeset) do + changeset + |> User.update_and_set_cache() + else + {:ok, new_user_data} = ActivityPub.user_data_from_user_object(updated_object) - User.get_by_ap_id(updated_object["id"]) - |> User.remote_user_changeset(new_user_data) - |> User.update_and_set_cache() + User.get_by_ap_id(updated_object["id"]) + |> User.remote_user_changeset(new_user_data) + |> User.update_and_set_cache() + end {:ok, object, meta} end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index c38c2b895..f0499621a 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -20,6 +20,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do alias Pleroma.Plugs.RateLimiter alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Pipeline + alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.ListView alias Pleroma.Web.MastodonAPI.MastodonAPI @@ -179,34 +181,39 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p |> Maps.put_if_present(:default_scope, params["source"]["privacy"]) |> Maps.put_if_present(:actor_type, params[:actor_type]) - changeset = User.update_changeset(user, user_params) - - with {:ok, user} <- User.update_and_set_cache(changeset) do - user - |> build_update_activity_params() - |> ActivityPub.update() - - render(conn, "show.json", user: user, for: user, with_pleroma_settings: true) + # What happens here: + # + # We want to update the user through the pipeline, but the ActivityPub + # update information is not quite enough for this, because this also + # contains local settings that don't federate and don't even appear + # in the Update activity. + # + # So we first build the normal local changeset, then apply it to the + # user data, but don't persist it. With this, we generate the object + # data for our update activity. We feed this and the changeset as meta + # inforation into the pipeline, where they will be properly updated and + # federated. + with changeset <- User.update_changeset(user, user_params), + {:ok, unpersisted_user} <- Ecto.Changeset.apply_action(changeset, :update), + updated_object <- + Pleroma.Web.ActivityPub.UserView.render("user.json", user: user) + |> Map.delete("@context"), + {:ok, update_data, []} <- Builder.update(user, updated_object), + {:ok, _update, _} <- + Pipeline.common_pipeline(update_data, + local: true, + user_update_changeset: changeset + ) do + render(conn, "show.json", + user: unpersisted_user, + for: unpersisted_user, + with_pleroma_settings: true + ) else _e -> render_error(conn, :forbidden, "Invalid request") end end - # Hotfix, handling will be redone with the pipeline - defp build_update_activity_params(user) do - object = - Pleroma.Web.ActivityPub.UserView.render("user.json", user: user) - |> Map.delete("@context") - - %{ - local: true, - to: [user.follower_address], - cc: [], - object: object, - actor: user.ap_id - } - end - defp normalize_fields_attributes(fields) do if Enum.all?(fields, &is_tuple/1) do Enum.map(fields, fn {_, v} -> v end) diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 7693f6400..ce35c9605 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -1092,52 +1092,6 @@ test "it filters broken threads" do end end - describe "update" do - setup do: clear_config([:instance, :max_pinned_statuses]) - - test "it creates an update activity with the new user data" do - user = insert(:user) - {:ok, user} = User.ensure_keys_present(user) - user_data = Pleroma.Web.ActivityPub.UserView.render("user.json", %{user: user}) - - {:ok, update} = - ActivityPub.update(%{ - actor: user_data["id"], - to: [user.follower_address], - cc: [], - object: user_data - }) - - assert update.data["actor"] == user.ap_id - assert update.data["to"] == [user.follower_address] - assert embedded_object = update.data["object"] - assert embedded_object["id"] == user_data["id"] - assert embedded_object["type"] == user_data["type"] - end - end - - test "returned pinned statuses" do - Config.put([:instance, :max_pinned_statuses], 3) - user = insert(:user) - - {:ok, activity_one} = CommonAPI.post(user, %{status: "HI!!!"}) - {:ok, activity_two} = CommonAPI.post(user, %{status: "HI!!!"}) - {:ok, activity_three} = CommonAPI.post(user, %{status: "HI!!!"}) - - CommonAPI.pin(activity_one.id, user) - user = refresh_record(user) - - CommonAPI.pin(activity_two.id, user) - user = refresh_record(user) - - CommonAPI.pin(activity_three.id, user) - user = refresh_record(user) - - activities = ActivityPub.fetch_user_activities(user, nil, %{pinned: true}) - - assert 3 = length(activities) - end - describe "flag/1" do setup do reporter = insert(:user) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 1d7c2736b..12c9ef1da 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -78,6 +78,15 @@ test "it updates the user", %{user: user, update: update} do user = User.get_by_id(user.id) assert user.name == "new name!" end + + test "it uses a given changeset to update", %{user: user, update: update} do + changeset = Ecto.Changeset.change(user, %{default_scope: "direct"}) + + assert user.default_scope == "public" + {:ok, _, _} = SideEffects.handle(update, user_update_changeset: changeset) + user = User.get_by_id(user.id) + assert user.default_scope == "direct" + end end describe "delete objects" do From b05f795326b77edd881ffea2c004d7ca0ddd7df9 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Mon, 22 Jun 2020 14:02:29 +0200 Subject: [PATCH 340/401] Credo fixes --- .../web/mastodon_api/controllers/account_controller.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index f0499621a..d4605c518 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -20,8 +20,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do alias Pleroma.Plugs.RateLimiter alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.ListView alias Pleroma.Web.MastodonAPI.MastodonAPI @@ -186,8 +186,8 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p # We want to update the user through the pipeline, but the ActivityPub # update information is not quite enough for this, because this also # contains local settings that don't federate and don't even appear - # in the Update activity. - # + # in the Update activity. + # # So we first build the normal local changeset, then apply it to the # user data, but don't persist it. With this, we generate the object # data for our update activity. We feed this and the changeset as meta From a3b10a4f643d574b84ecee51fb891e26e7f0dbc2 Mon Sep 17 00:00:00 2001 From: Ilja <domainepublic@spectraltheorem.be> Date: Mon, 22 Jun 2020 14:13:30 +0200 Subject: [PATCH 341/401] Fix 1586 Docs: provide a index.md * I renamed the introduction.md to index.md * I moved over the FE parts to an index file in the FE repo (will do an MR in the FE repo to actually add it) * While I was at it, I also fixed some broken links --- docs/index.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 docs/index.md diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 000000000..fb9e32816 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,26 @@ +# Introduction to Pleroma +## What is Pleroma? +Pleroma is a federated social networking platform, compatible with Mastodon and other ActivityPub implementations. It is free software licensed under the AGPLv3. +It actually consists of two components: a backend, named simply Pleroma, and a user-facing frontend, named Pleroma-FE. It also includes the Mastodon frontend, if that's your thing. +It's part of what we call the fediverse, a federated network of instances which speak common protocols and can communicate with each other. +One account on an instance is enough to talk to the entire fediverse! + +## How can I use it? + +Pleroma instances are already widely deployed, a list can be found at <https://the-federation.info/pleroma> and <https://fediverse.network/pleroma>. + +If you don't feel like joining an existing instance, but instead prefer to deploy your own instance, that's easy too! +Installation instructions can be found in the installation section of these docs. + +## I got an account, now what? +Great! Now you can explore the fediverse! Open the login page for your Pleroma instance (e.g. <https://pleroma.soykaf.com>) and login with your username and password. (If you don't have an account yet, click on Register) + +### Pleroma-FE +The default front-end used by Pleroma is Pleroma-FE. You can find more information on what it is and how to use it in the [Introduction to Pleroma-FE](../frontend). + +### Mastodon interface +If the Pleroma interface isn't your thing, or you're just trying something new but you want to keep using the familiar Mastodon interface, we got that too! +Just add a "/web" after your instance url (e.g. <https://pleroma.soycaf.com/web>) and you'll end on the Mastodon web interface, but with a Pleroma backend! MAGIC! +The Mastodon interface is from the Glitch-soc fork. For more information on the Mastodon interface you can check the [Mastodon](https://docs.joinmastodon.org/) and [Glitch-soc](https://glitch-soc.github.io/docs/) documentation. + +Remember, what you see is only the frontend part of Mastodon, the backend is still Pleroma. From 1e089cdf2905309a5450e2acb32aa6b35a928c29 Mon Sep 17 00:00:00 2001 From: Ilja <domainepublic@spectraltheorem.be> Date: Mon, 22 Jun 2020 14:18:55 +0200 Subject: [PATCH 342/401] I forgot to git add some files, oops (should be squashed with MR) --- .../howto_theming_your_instance.md | 2 +- docs/dev.md | 2 +- docs/introduction.md | 65 ------------------- 3 files changed, 2 insertions(+), 67 deletions(-) delete mode 100644 docs/introduction.md diff --git a/docs/configuration/howto_theming_your_instance.md b/docs/configuration/howto_theming_your_instance.md index d0daf5b25..cfa00f538 100644 --- a/docs/configuration/howto_theming_your_instance.md +++ b/docs/configuration/howto_theming_your_instance.md @@ -60,7 +60,7 @@ Example of `my-awesome-theme.json` where we add the name "My Awesome Theme" ### Set as default theme -Now we can set the new theme as default in the [Pleroma FE configuration](General-tips-for-customizing-Pleroma-FE.md). +Now we can set the new theme as default in the [Pleroma FE configuration](../../../frontend/CONFIGURATION). Example of adding the new theme in the back-end config files ```elixir diff --git a/docs/dev.md b/docs/dev.md index f1b4cbf8b..9c749c17c 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -20,4 +20,4 @@ This document contains notes and guidelines for Pleroma developers. ## Auth-related configuration, OAuth consumer mode etc. -See `Authentication` section of [`docs/configuration/cheatsheet.md`](docs/configuration/cheatsheet.md#authentication). +See `Authentication` section of [the configuration cheatsheet](configuration/cheatsheet.md#authentication). diff --git a/docs/introduction.md b/docs/introduction.md deleted file mode 100644 index a915c143c..000000000 --- a/docs/introduction.md +++ /dev/null @@ -1,65 +0,0 @@ -# Introduction to Pleroma -## What is Pleroma? -Pleroma is a federated social networking platform, compatible with GNU social, Mastodon and other OStatus and ActivityPub implementations. It is free software licensed under the AGPLv3. -It actually consists of two components: a backend, named simply Pleroma, and a user-facing frontend, named Pleroma-FE. It also includes the Mastodon frontend, if that's your thing. -It's part of what we call the fediverse, a federated network of instances which speak common protocols and can communicate with each other. -One account on an instance is enough to talk to the entire fediverse! - -## How can I use it? - -Pleroma instances are already widely deployed, a list can be found at <http://distsn.org/pleroma-instances.html>. Information on all existing fediverse instances can be found at <https://fediverse.network/>. - -If you don't feel like joining an existing instance, but instead prefer to deploy your own instance, that's easy too! -Installation instructions can be found in the installation section of these docs. - -## I got an account, now what? -Great! Now you can explore the fediverse! Open the login page for your Pleroma instance (e.g. <https://pleroma.soykaf.com>) and login with your username and password. (If you don't have an account yet, click on Register) - -At this point you will have two columns in front of you. - -### Left column - -- first block: here you can see your avatar, your nickname and statistics (Statuses, Following, Followers). Clicking your profile pic will open your profile. -Under that you have a text form which allows you to post new statuses. The number on the bottom of the text form is a character counter, every instance can have a different character limit (the default is 5000). -If you want to mention someone, type @ + name of the person. A drop-down menu will help you in finding the right person. -Under the text form there are also several visibility options and there is the option to use rich text. -Under that the icon on the left is for uploading media files and attach them to your post. There is also an emoji-picker and an option to post a poll. -To post your status, simply press Submit. -On the top right you will also see a wrench icon. This opens your personal settings. - -- second block: Here you can switch between the different timelines: - - Timeline: all the people that you follow - - Interactions: here you can switch between different timelines where there was interaction with your account. There is Mentions, Repeats and Favorites, and New follows - - Direct Messages: these are the Direct Messages sent to you - - Public Timeline: all the statutes from the local instance - - The Whole Known Network: all public posts the instance knows about, both local and remote! - - About: This isn't a Timeline but shows relevant info about the instance. You can find a list of the moderators and admins, Terms of Service, MRF policies and enabled features. -- Optional third block: This is the Instance panel that can be activated, but is deactivated by default. It's fully customisable and by default has links to the pleroma-fe and Mastodon-fe. -- fourth block: This is the Notifications block, here you will get notified whenever somebody mentions you, follows you, repeats or favorites one of your statuses. - -### Right column -This is where the interesting stuff happens! -Depending on the timeline you will see different statuses, but each status has a standard structure: - -- Profile pic, name and link to profile. An optional left-arrow if it's a reply to another status (hovering will reveal the reply-to status). Clicking on the profile pic will uncollapse the user's profile. -- A `+` button on the right allows you to Expand/Collapse an entire discussion thread. It also updates in realtime! -- An arrow icon allows you to open the status on the instance where it's originating from. -- The text of the status, including mentions and attachements. If you click on a mention, it will automatically open the profile page of that person. -- Three buttons (left to right): Reply, Repeat, Favorite. There is also a forth button, this is a dropdown menu for simple moderation like muting the conversation or, if you have moderation rights, delete the status from the server. - -### Top right - -- The magnifier icon opens the search screen where you can search for statuses, people and hashtags. It's also possible to import statusses from remote servers by pasting the url to the post in the search field. -- The gear icon gives you general settings -- If you have admin rights, you'll see an icon that opens the admin interface -- The last icon is to log out - -### Bottom right -On the bottom right you have a chatbox. Here you can communicate with people on the same instance in realtime. It is local-only, for now, but there are plans to make it extendable to the entire fediverse! - -### Mastodon interface -If the Pleroma interface isn't your thing, or you're just trying something new but you want to keep using the familiar Mastodon interface, we got that too! -Just add a "/web" after your instance url (e.g. <https://pleroma.soycaf.com/web>) and you'll end on the Mastodon web interface, but with a Pleroma backend! MAGIC! -The Mastodon interface is from the Glitch-soc fork. For more information on the Mastodon interface you can check the [Mastodon](https://docs.joinmastodon.org/) and [Glitch-soc](https://glitch-soc.github.io/docs/) documentation. - -Remember, what you see is only the frontend part of Mastodon, the backend is still Pleroma. From 499324f7bee55de4e08647f71fd4adbfd4bd039f Mon Sep 17 00:00:00 2001 From: Ilja <domainepublic@spectraltheorem.be> Date: Mon, 22 Jun 2020 14:22:23 +0200 Subject: [PATCH 343/401] Removed a space that was too much --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index fb9e32816..1a90d0a8d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,4 +23,4 @@ If the Pleroma interface isn't your thing, or you're just trying something new b Just add a "/web" after your instance url (e.g. <https://pleroma.soycaf.com/web>) and you'll end on the Mastodon web interface, but with a Pleroma backend! MAGIC! The Mastodon interface is from the Glitch-soc fork. For more information on the Mastodon interface you can check the [Mastodon](https://docs.joinmastodon.org/) and [Glitch-soc](https://glitch-soc.github.io/docs/) documentation. -Remember, what you see is only the frontend part of Mastodon, the backend is still Pleroma. +Remember, what you see is only the frontend part of Mastodon, the backend is still Pleroma. From b0a40fc2e42a186fc6bb383621f291411b2a81be Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Mon, 22 Jun 2020 17:27:49 +0300 Subject: [PATCH 344/401] added verify RUM settings before start app --- lib/pleroma/application.ex | 2 +- lib/pleroma/application_requirements.ex | 103 ++++++++++++++++++ lib/pleroma/repo.ex | 37 +------ ...510135645_add_fts_index_to_objects_two.exs | 39 +++---- test/application_requirements_test.exs | 67 ++++++++++++ test/repo_test.exs | 34 ------ 6 files changed, 189 insertions(+), 93 deletions(-) create mode 100644 lib/pleroma/application_requirements.ex create mode 100644 test/application_requirements_test.exs diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 9d3d92b38..c30e5aadf 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -39,7 +39,7 @@ def start(_type, _args) do Pleroma.HTML.compile_scrubbers() Config.DeprecationWarnings.warn() Pleroma.Plugs.HTTPSecurityPlug.warn_if_disabled() - Pleroma.Repo.check_migrations_applied!() + Pleroma.ApplicationRequirements.verify!() setup_instrumenters() load_custom_modules() diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex new file mode 100644 index 000000000..3bba70b7b --- /dev/null +++ b/lib/pleroma/application_requirements.ex @@ -0,0 +1,103 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ApplicationRequirements do + @moduledoc """ + The module represents the collection of validations to runs before start server. + """ + + defmodule VerifyError, do: defexception([:message]) + + import Ecto.Query + + require Logger + + @spec verify!() :: :ok | VerifyError.t() + def verify! do + :ok + |> check_migrations_applied!() + |> check_rum!() + |> handle_result() + end + + defp handle_result(:ok), do: :ok + defp handle_result({:error, message}), do: raise(VerifyError, message: message) + + defp check_migrations_applied!(:ok) do + unless Pleroma.Config.get( + [:i_am_aware_this_may_cause_data_loss, :disable_migration_check], + false + ) do + {_, res, _} = + Ecto.Migrator.with_repo(Pleroma.Repo, fn repo -> + down_migrations = + Ecto.Migrator.migrations(repo) + |> Enum.reject(fn + {:up, _, _} -> true + {:down, _, _} -> false + end) + + if length(down_migrations) > 0 do + down_migrations_text = + Enum.map(down_migrations, fn {:down, id, name} -> "- #{name} (#{id})\n" end) + + Logger.error( + "The following migrations were not applied:\n#{down_migrations_text}If you want to start Pleroma anyway, set\nconfig :pleroma, :i_am_aware_this_may_cause_data_loss, disable_migration_check: true" + ) + + {:error, "Unapplied Migrations detected"} + else + :ok + end + end) + + res + else + :ok + end + end + + defp check_migrations_applied!(result), do: result + + defp check_rum!(:ok) do + {_, res, _} = + Ecto.Migrator.with_repo(Pleroma.Repo, fn repo -> + migrate = + from(o in "columns", + where: o.table_name == "objects", + where: o.column_name == "fts_content" + ) + |> repo.exists?(prefix: "information_schema") + + setting = Pleroma.Config.get([:database, :rum_enabled], false) + + do_check_rum!(setting, migrate) + end) + + res + end + + defp check_rum!(result), do: result + + defp do_check_rum!(setting, migrate) do + case {setting, migrate} do + {true, false} -> + Logger.error( + "Use `RUM` index is enabled, but were not applied migrations for it.\nIf you want to start Pleroma anyway, set\nconfig :pleroma, :database, rum_enabled: false\nOtherwise apply the following migrations:\n`mix ecto.migrate --migrations-path priv/repo/optional_migrations/rum_indexing/`" + ) + + {:error, "Unapplied RUM Migrations detected"} + + {false, true} -> + Logger.error( + "Detected applied migrations to use `RUM` index, but `RUM` isn't enable in settings.\nIf you want to use `RUM`, set\nconfig :pleroma, :database, rum_enabled: true\nOtherwise roll `RUM` migrations back.\n`mix ecto.rollback --migrations-path priv/repo/optional_migrations/rum_indexing/`" + ) + + {:error, "RUM Migrations detected"} + + _ -> + :ok + end + end +end diff --git a/lib/pleroma/repo.ex b/lib/pleroma/repo.ex index 6d85d70bc..f317e4d58 100644 --- a/lib/pleroma/repo.ex +++ b/lib/pleroma/repo.ex @@ -11,9 +11,7 @@ defmodule Pleroma.Repo do import Ecto.Query require Logger - defmodule Instrumenter do - use Prometheus.EctoInstrumenter - end + defmodule Instrumenter, do: use(Prometheus.EctoInstrumenter) @doc """ Dynamically loads the repository url from the @@ -51,35 +49,6 @@ def get_assoc(resource, association) do end end - def check_migrations_applied!() do - unless Pleroma.Config.get( - [:i_am_aware_this_may_cause_data_loss, :disable_migration_check], - false - ) do - Ecto.Migrator.with_repo(__MODULE__, fn repo -> - down_migrations = - Ecto.Migrator.migrations(repo) - |> Enum.reject(fn - {:up, _, _} -> true - {:down, _, _} -> false - end) - - if length(down_migrations) > 0 do - down_migrations_text = - Enum.map(down_migrations, fn {:down, id, name} -> "- #{name} (#{id})\n" end) - - Logger.error( - "The following migrations were not applied:\n#{down_migrations_text}If you want to start Pleroma anyway, set\nconfig :pleroma, :i_am_aware_this_may_cause_data_loss, disable_migration_check: true" - ) - - raise Pleroma.Repo.UnappliedMigrationsError - end - end) - else - :ok - end - end - def chunk_stream(query, chunk_size) do # We don't actually need start and end funcitons of resource streaming, # but it seems to be the only way to not fetch records one-by-one and @@ -107,7 +76,3 @@ def chunk_stream(query, chunk_size) do ) end end - -defmodule Pleroma.Repo.UnappliedMigrationsError do - defexception message: "Unapplied Migrations detected" -end diff --git a/priv/repo/optional_migrations/rum_indexing/20190510135645_add_fts_index_to_objects_two.exs b/priv/repo/optional_migrations/rum_indexing/20190510135645_add_fts_index_to_objects_two.exs index 79bde163d..757afa129 100644 --- a/priv/repo/optional_migrations/rum_indexing/20190510135645_add_fts_index_to_objects_two.exs +++ b/priv/repo/optional_migrations/rum_indexing/20190510135645_add_fts_index_to_objects_two.exs @@ -2,29 +2,24 @@ defmodule Pleroma.Repo.Migrations.AddFtsIndexToObjectsTwo do use Ecto.Migration def up do - if Pleroma.Config.get([:database, :rum_enabled]) do - execute("create extension if not exists rum") - drop_if_exists index(:objects, ["(to_tsvector('english', data->>'content'))"], using: :gin, name: :objects_fts) - alter table(:objects) do - add(:fts_content, :tsvector) - end - - execute("CREATE FUNCTION objects_fts_update() RETURNS trigger AS $$ - begin - new.fts_content := to_tsvector('english', new.data->>'content'); - return new; - end - $$ LANGUAGE plpgsql") - execute("create index if not exists objects_fts on objects using RUM (fts_content rum_tsvector_addon_ops, inserted_at) with (attach = 'inserted_at', to = 'fts_content');") - - execute("CREATE TRIGGER tsvectorupdate BEFORE INSERT OR UPDATE ON objects - FOR EACH ROW EXECUTE PROCEDURE objects_fts_update()") - - execute("UPDATE objects SET updated_at = NOW()") - else - raise Ecto.MigrationError, - message: "Migration is not allowed. You can change this behavior by setting `database/rum_enabled` to true." + execute("create extension if not exists rum") + drop_if_exists index(:objects, ["(to_tsvector('english', data->>'content'))"], using: :gin, name: :objects_fts) + alter table(:objects) do + add(:fts_content, :tsvector) end + + execute("CREATE FUNCTION objects_fts_update() RETURNS trigger AS $$ + begin + new.fts_content := to_tsvector('english', new.data->>'content'); + return new; + end + $$ LANGUAGE plpgsql") + execute("create index if not exists objects_fts on objects using RUM (fts_content rum_tsvector_addon_ops, inserted_at) with (attach = 'inserted_at', to = 'fts_content');") + + execute("CREATE TRIGGER tsvectorupdate BEFORE INSERT OR UPDATE ON objects + FOR EACH ROW EXECUTE PROCEDURE objects_fts_update()") + + execute("UPDATE objects SET updated_at = NOW()") end def down do diff --git a/test/application_requirements_test.exs b/test/application_requirements_test.exs new file mode 100644 index 000000000..0981fcdeb --- /dev/null +++ b/test/application_requirements_test.exs @@ -0,0 +1,67 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.RepoTest do + use Pleroma.DataCase + import ExUnit.CaptureLog + import Mock + + describe "check_rum!" do + setup_with_mocks([ + {Ecto.Migrator, [], + [ + with_repo: fn repo, fun -> passthrough([repo, fun]) end, + migrations: fn Pleroma.Repo -> [] end + ]} + ]) do + :ok + end + + setup do: clear_config([:database, :rum_enabled]) + + test "raises if rum is enabled and detects unapplied rum migrations" do + Pleroma.Config.put([:database, :rum_enabled], true) + + assert_raise Pleroma.ApplicationRequirements.VerifyError, + "Unapplied RUM Migrations detected", + fn -> + capture_log(&Pleroma.ApplicationRequirements.verify!/0) + end + end + end + + describe "check_migrations_applied!" do + setup_with_mocks([ + {Ecto.Migrator, [], + [ + with_repo: fn repo, fun -> passthrough([repo, fun]) end, + migrations: fn Pleroma.Repo -> + [ + {:up, 20_191_128_153_944, "fix_missing_following_count"}, + {:up, 20_191_203_043_610, "create_report_notes"}, + {:down, 20_191_220_174_645, "add_scopes_to_pleroma_feo_auth_records"} + ] + end + ]} + ]) do + :ok + end + + setup do: clear_config([:i_am_aware_this_may_cause_data_loss, :disable_migration_check]) + + test "raises if it detects unapplied migrations" do + assert_raise Pleroma.ApplicationRequirements.VerifyError, + "Unapplied Migrations detected", + fn -> + capture_log(&Pleroma.ApplicationRequirements.verify!/0) + end + end + + test "doesn't do anything if disabled" do + Pleroma.Config.put([:i_am_aware_this_may_cause_data_loss, :disable_migration_check], true) + + assert :ok == Pleroma.ApplicationRequirements.verify!() + end + end +end diff --git a/test/repo_test.exs b/test/repo_test.exs index daffc6542..92e827c95 100644 --- a/test/repo_test.exs +++ b/test/repo_test.exs @@ -4,9 +4,7 @@ defmodule Pleroma.RepoTest do use Pleroma.DataCase - import ExUnit.CaptureLog import Pleroma.Factory - import Mock alias Pleroma.User @@ -49,36 +47,4 @@ test "return error if has not assoc " do assert Repo.get_assoc(token, :user) == {:error, :not_found} end end - - describe "check_migrations_applied!" do - setup_with_mocks([ - {Ecto.Migrator, [], - [ - with_repo: fn repo, fun -> passthrough([repo, fun]) end, - migrations: fn Pleroma.Repo -> - [ - {:up, 20_191_128_153_944, "fix_missing_following_count"}, - {:up, 20_191_203_043_610, "create_report_notes"}, - {:down, 20_191_220_174_645, "add_scopes_to_pleroma_feo_auth_records"} - ] - end - ]} - ]) do - :ok - end - - setup do: clear_config([:i_am_aware_this_may_cause_data_loss, :disable_migration_check]) - - test "raises if it detects unapplied migrations" do - assert_raise Pleroma.Repo.UnappliedMigrationsError, fn -> - capture_log(&Repo.check_migrations_applied!/0) - end - end - - test "doesn't do anything if disabled" do - Pleroma.Config.put([:i_am_aware_this_may_cause_data_loss, :disable_migration_check], true) - - assert :ok == Repo.check_migrations_applied!() - end - end end From 7e6f43c0d7c625a03ee0216c2d9474253ef87b5a Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn <egor@kislitsyn.com> Date: Mon, 22 Jun 2020 19:03:04 +0400 Subject: [PATCH 345/401] Add `is_muted` to notifications --- .../mastodon_api/views/notification_view.ex | 8 ++--- .../views/notification_view_test.exs | 36 +++++++++++++++---- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 3865be280..c97e6d32f 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -84,12 +84,7 @@ def render( # Note: :relationships contain user mutes (needed for :muted flag in :status) status_render_opts = %{relationships: opts[:relationships]} - - account = - AccountView.render( - "show.json", - %{user: actor, for: reading_user} - ) + account = AccountView.render("show.json", %{user: actor, for: reading_user}) response = %{ id: to_string(notification.id), @@ -97,6 +92,7 @@ def render( created_at: CommonAPI.Utils.to_masto_date(notification.inserted_at), account: account, pleroma: %{ + is_muted: User.mutes?(reading_user, actor), is_seen: notification.seen } } diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index 9c399b2df..8e0e58538 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -49,7 +49,7 @@ test "ChatMessage notification" do expected = %{ id: to_string(notification.id), - pleroma: %{is_seen: false}, + pleroma: %{is_seen: false, is_muted: false}, type: "pleroma:chat_mention", account: AccountView.render("show.json", %{user: user, for: recipient}), chat_message: MessageReferenceView.render("show.json", %{chat_message_reference: cm_ref}), @@ -68,7 +68,7 @@ test "Mention notification" do expected = %{ id: to_string(notification.id), - pleroma: %{is_seen: false}, + pleroma: %{is_seen: false, is_muted: false}, type: "mention", account: AccountView.render("show.json", %{ @@ -92,7 +92,7 @@ test "Favourite notification" do expected = %{ id: to_string(notification.id), - pleroma: %{is_seen: false}, + pleroma: %{is_seen: false, is_muted: false}, type: "favourite", account: AccountView.render("show.json", %{user: another_user, for: user}), status: StatusView.render("show.json", %{activity: create_activity, for: user}), @@ -112,7 +112,7 @@ test "Reblog notification" do expected = %{ id: to_string(notification.id), - pleroma: %{is_seen: false}, + pleroma: %{is_seen: false, is_muted: false}, type: "reblog", account: AccountView.render("show.json", %{user: another_user, for: user}), status: StatusView.render("show.json", %{activity: reblog_activity, for: user}), @@ -130,7 +130,7 @@ test "Follow notification" do expected = %{ id: to_string(notification.id), - pleroma: %{is_seen: false}, + pleroma: %{is_seen: false, is_muted: false}, type: "follow", account: AccountView.render("show.json", %{user: follower, for: followed}), created_at: Utils.to_masto_date(notification.inserted_at) @@ -171,7 +171,7 @@ test "Move notification" do expected = %{ id: to_string(notification.id), - pleroma: %{is_seen: false}, + pleroma: %{is_seen: false, is_muted: false}, type: "move", account: AccountView.render("show.json", %{user: old_user, for: follower}), target: AccountView.render("show.json", %{user: new_user, for: follower}), @@ -196,7 +196,7 @@ test "EmojiReact notification" do expected = %{ id: to_string(notification.id), - pleroma: %{is_seen: false}, + pleroma: %{is_seen: false, is_muted: false}, type: "pleroma:emoji_reaction", emoji: "☕", account: AccountView.render("show.json", %{user: other_user, for: user}), @@ -206,4 +206,26 @@ test "EmojiReact notification" do test_notifications_rendering([notification], user, [expected]) end + + test "muted notification" do + user = insert(:user) + another_user = insert(:user) + + {:ok, _} = Pleroma.UserRelationship.create_mute(user, another_user) + {:ok, create_activity} = CommonAPI.post(user, %{status: "hey"}) + {:ok, favorite_activity} = CommonAPI.favorite(another_user, create_activity.id) + {:ok, [notification]} = Notification.create_notifications(favorite_activity) + create_activity = Activity.get_by_id(create_activity.id) + + expected = %{ + id: to_string(notification.id), + pleroma: %{is_seen: false, is_muted: true}, + type: "favourite", + account: AccountView.render("show.json", %{user: another_user, for: user}), + status: StatusView.render("show.json", %{activity: create_activity, for: user}), + created_at: Utils.to_masto_date(notification.inserted_at) + } + + test_notifications_rendering([notification], user, [expected]) + end end From b3a549e916c2a721da16f60e7665b6eb64d756dd Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn <egor@kislitsyn.com> Date: Mon, 22 Jun 2020 19:18:33 +0400 Subject: [PATCH 346/401] Update NotificationOperation spec --- .../web/api_spec/operations/notification_operation.ex | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/notification_operation.ex b/lib/pleroma/web/api_spec/operations/notification_operation.ex index 41328b5f2..f09be64cb 100644 --- a/lib/pleroma/web/api_spec/operations/notification_operation.ex +++ b/lib/pleroma/web/api_spec/operations/notification_operation.ex @@ -163,6 +163,13 @@ def notification do description: "Status that was the object of the notification, e.g. in mentions, reblogs, favourites, or polls.", nullable: true + }, + pleroma: %Schema{ + type: :object, + properties: %{ + is_seen: %Schema{type: :boolean}, + is_muted: %Schema{type: :boolean} + } } }, example: %{ @@ -170,7 +177,8 @@ def notification do "type" => "mention", "created_at" => "2019-11-23T07:49:02.064Z", "account" => Account.schema().example, - "status" => Status.schema().example + "status" => Status.schema().example, + "pleroma" => %{"is_seen" => false, "is_muted" => false} } } end From 8f6ba4b22f48dcd0256d6a9cf7259aa475895b84 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Mon, 22 Jun 2020 23:45:29 +0200 Subject: [PATCH 347/401] Add warning against parsing/reusing MastoFE settings blob --- lib/pleroma/web/masto_fe_controller.ex | 2 +- lib/pleroma/web/router.ex | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/masto_fe_controller.ex b/lib/pleroma/web/masto_fe_controller.ex index d0d8bc8eb..43ec70021 100644 --- a/lib/pleroma/web/masto_fe_controller.ex +++ b/lib/pleroma/web/masto_fe_controller.ex @@ -49,7 +49,7 @@ def manifest(conn, _params) do |> render("manifest.json") end - @doc "PUT /api/web/settings" + @doc "PUT /api/web/settings: Backend-obscure settings blob for MastoFE, don't parse/reuse elsewhere" def put_settings(%{assigns: %{user: user}} = conn, %{"data" => settings} = _params) do with {:ok, _} <- User.mastodon_settings_update(user, settings) do json(conn, %{}) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index eda74a171..419aa55e4 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -467,6 +467,7 @@ defmodule Pleroma.Web.Router do scope "/api/web", Pleroma.Web do pipe_through(:authenticated_api) + # Backend-obscure settings blob for MastoFE, don't parse/reuse elsewhere put("/settings", MastoFEController, :put_settings) end From bf8310f3802c46e6305fcb3832bca297582990d9 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Mon, 22 Jun 2020 17:35:02 -0500 Subject: [PATCH 348/401] Add missing default config value for :instance, instance_thumbnail Follows up on b7fc61e17b --- config/config.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/config/config.exs b/config/config.exs index 4bf31f3fc..e0888fa9a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -186,6 +186,7 @@ notify_email: "noreply@example.com", description: "Pleroma: An efficient and flexible fediverse server", background_image: "/images/city.jpg", + instance_thumbnail: "/instance/thumbnail.jpeg", limit: 5_000, chat_limit: 5_000, remote_limit: 100_000, From df5e048cbb7d349b34203ccba49a8f646e4d93a3 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Mon, 22 Jun 2020 17:39:02 -0500 Subject: [PATCH 349/401] Do not need a function to provide fallback value with default defined in config.exs --- lib/pleroma/web/mastodon_api/views/instance_view.ex | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index c498fe632..c6b54e570 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -23,7 +23,7 @@ def render("show.json", _) do streaming_api: Pleroma.Web.Endpoint.websocket_url() }, stats: Pleroma.Stats.get_stats(), - thumbnail: instance_thumbnail(), + thumbnail: Keyword.get(instance, :instance_thumbnail), languages: ["en"], registrations: Keyword.get(instance, :registrations_open), # Extra (not present in Mastodon): @@ -88,9 +88,4 @@ def federation do end |> Map.put(:enabled, Config.get([:instance, :federating])) end - - defp instance_thumbnail do - Pleroma.Config.get([:instance, :instance_thumbnail]) || - "#{Pleroma.Web.base_url()}/instance/thumbnail.jpeg" - end end From c116b6d6d6e4b12d9d751481926183f19cdb5248 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Tue, 23 Jun 2020 04:42:44 +0200 Subject: [PATCH 350/401] ActivityPubController: Update upload_media @doc Small cherry-pick from https://git.pleroma.social/pleroma/pleroma/-/merge_requests/1810 --- lib/pleroma/web/activity_pub/activity_pub_controller.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index f0b5c6e93..220c4fe52 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -514,7 +514,6 @@ defp ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user) do {new_user, for_user} end - # TODO: Add support for "object" field @doc """ Endpoint based on <https://www.w3.org/wiki/SocialCG/ActivityPub/MediaUpload> @@ -525,6 +524,8 @@ defp ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user) do Response: - HTTP Code: 201 Created - HTTP Body: ActivityPub object to be inserted into another's `attachment` field + + Note: Will not point to a URL with a `Location` header because no standalone Activity has been created. """ def upload_media(%{assigns: %{user: %User{} = user}} = conn, %{"file" => file} = data) do with {:ok, object} <- From 2715c40e1d36cc844be1dd7d41a0c6a16ca5f7b7 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Tue, 23 Jun 2020 06:56:17 +0300 Subject: [PATCH 351/401] added tests --- lib/pleroma/application_requirements.ex | 8 +++-- test/application_requirements_test.exs | 48 +++++++++++++++++++++---- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex index 3bba70b7b..88575a498 100644 --- a/lib/pleroma/application_requirements.ex +++ b/lib/pleroma/application_requirements.ex @@ -24,7 +24,9 @@ def verify! do defp handle_result(:ok), do: :ok defp handle_result({:error, message}), do: raise(VerifyError, message: message) - defp check_migrations_applied!(:ok) do + # Checks for pending migrations. + # + def check_migrations_applied!(:ok) do unless Pleroma.Config.get( [:i_am_aware_this_may_cause_data_loss, :disable_migration_check], false @@ -58,8 +60,10 @@ defp check_migrations_applied!(:ok) do end end - defp check_migrations_applied!(result), do: result + def check_migrations_applied!(result), do: result + # Checks for settings of RUM indexes. + # defp check_rum!(:ok) do {_, res, _} = Ecto.Migrator.with_repo(Pleroma.Repo, fn repo -> diff --git a/test/application_requirements_test.exs b/test/application_requirements_test.exs index 0981fcdeb..b8d073e11 100644 --- a/test/application_requirements_test.exs +++ b/test/application_requirements_test.exs @@ -2,25 +2,22 @@ # Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.RepoTest do +defmodule Pleroma.ApplicationRequirementsTest do use Pleroma.DataCase import ExUnit.CaptureLog import Mock describe "check_rum!" do setup_with_mocks([ - {Ecto.Migrator, [], - [ - with_repo: fn repo, fun -> passthrough([repo, fun]) end, - migrations: fn Pleroma.Repo -> [] end - ]} + {Pleroma.ApplicationRequirements, [:passthrough], + [check_migrations_applied!: fn _ -> :ok end]} ]) do :ok end setup do: clear_config([:database, :rum_enabled]) - test "raises if rum is enabled and detects unapplied rum migrations" do + test "raises if rum is enabled and detects unapplied rum migrations" do Pleroma.Config.put([:database, :rum_enabled], true) assert_raise Pleroma.ApplicationRequirements.VerifyError, @@ -29,6 +26,43 @@ test "raises if rum is enabled and detects unapplied rum migrations" do capture_log(&Pleroma.ApplicationRequirements.verify!/0) end end + + test "raises if rum is disabled and detects rum migrations" do + Pleroma.Config.put([:database, :rum_enabled], false) + + with_mocks([ + { + Pleroma.Repo, + [:passthrough], + [exists?: fn _, _ -> true end] + } + ]) do + assert_raise Pleroma.ApplicationRequirements.VerifyError, + "RUM Migrations detected", + fn -> + capture_log(&Pleroma.ApplicationRequirements.verify!/0) + end + end + end + + test "doesn't do anything if rum enabled and applied migrations" do + Pleroma.Config.put([:database, :rum_enabled], true) + + with_mocks([ + { + Pleroma.Repo, + [:passthrough], + [exists?: fn _, _ -> true end] + } + ]) do + assert Pleroma.ApplicationRequirements.verify!() == :ok + end + end + + test "doesn't do anything if rum disabled" do + Pleroma.Config.put([:database, :rum_enabled], false) + assert Pleroma.ApplicationRequirements.verify!() == :ok + end end describe "check_migrations_applied!" do From 84aa9c78dd314e93a5153e3584af38b8c218caed Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Tue, 23 Jun 2020 09:08:24 +0300 Subject: [PATCH 352/401] fix tests --- test/application_requirements_test.exs | 37 +++++++++++--------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/test/application_requirements_test.exs b/test/application_requirements_test.exs index b8d073e11..481cdfd73 100644 --- a/test/application_requirements_test.exs +++ b/test/application_requirements_test.exs @@ -7,6 +7,8 @@ defmodule Pleroma.ApplicationRequirementsTest do import ExUnit.CaptureLog import Mock + alias Pleroma.Repo + describe "check_rum!" do setup_with_mocks([ {Pleroma.ApplicationRequirements, [:passthrough], @@ -20,23 +22,19 @@ defmodule Pleroma.ApplicationRequirementsTest do test "raises if rum is enabled and detects unapplied rum migrations" do Pleroma.Config.put([:database, :rum_enabled], true) - assert_raise Pleroma.ApplicationRequirements.VerifyError, - "Unapplied RUM Migrations detected", - fn -> - capture_log(&Pleroma.ApplicationRequirements.verify!/0) - end + with_mocks([{Repo, [:passthrough], [exists?: fn _, _ -> false end]}]) do + assert_raise Pleroma.ApplicationRequirements.VerifyError, + "Unapplied RUM Migrations detected", + fn -> + capture_log(&Pleroma.ApplicationRequirements.verify!/0) + end + end end test "raises if rum is disabled and detects rum migrations" do Pleroma.Config.put([:database, :rum_enabled], false) - with_mocks([ - { - Pleroma.Repo, - [:passthrough], - [exists?: fn _, _ -> true end] - } - ]) do + with_mocks([{Repo, [:passthrough], [exists?: fn _, _ -> true end]}]) do assert_raise Pleroma.ApplicationRequirements.VerifyError, "RUM Migrations detected", fn -> @@ -48,20 +46,17 @@ test "raises if rum is disabled and detects rum migrations" do test "doesn't do anything if rum enabled and applied migrations" do Pleroma.Config.put([:database, :rum_enabled], true) - with_mocks([ - { - Pleroma.Repo, - [:passthrough], - [exists?: fn _, _ -> true end] - } - ]) do + with_mocks([{Repo, [:passthrough], [exists?: fn _, _ -> true end]}]) do assert Pleroma.ApplicationRequirements.verify!() == :ok end end test "doesn't do anything if rum disabled" do Pleroma.Config.put([:database, :rum_enabled], false) - assert Pleroma.ApplicationRequirements.verify!() == :ok + + with_mocks([{Repo, [:passthrough], [exists?: fn _, _ -> false end]}]) do + assert Pleroma.ApplicationRequirements.verify!() == :ok + end end end @@ -70,7 +65,7 @@ test "doesn't do anything if rum disabled" do {Ecto.Migrator, [], [ with_repo: fn repo, fun -> passthrough([repo, fun]) end, - migrations: fn Pleroma.Repo -> + migrations: fn Repo -> [ {:up, 20_191_128_153_944, "fix_missing_following_count"}, {:up, 20_191_203_043_610, "create_report_notes"}, From 2737809bbf249696d06d4a351837a405d79d47e3 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Tue, 23 Jun 2020 11:03:32 +0200 Subject: [PATCH 353/401] An act of desperation. --- test/web/activity_pub/activity_pub_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index e490a5744..6ea50fd96 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -665,7 +665,7 @@ test "it accepts announces with to as string instead of array", %{conn: conn} do |> post("/users/#{user.nickname}/inbox", data) assert "ok" == json_response(conn, 200) - ObanHelpers.perform(all_enqueued(worker: ReceiverWorker)) + ObanHelpers.perform_all() %Activity{} = activity = Activity.get_by_ap_id(data["id"]) assert "https://www.w3.org/ns/activitystreams#Public" in activity.recipients end From d93e01137b0682dd97b95b848f7b8656de89e3cf Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Tue, 23 Jun 2020 11:43:20 +0200 Subject: [PATCH 354/401] ActivityPubControllerTest: Testing changes. --- .../web/activity_pub/activity_pub_controller_test.exs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index 6ea50fd96..e5f801b22 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -648,11 +648,14 @@ test "it accepts messages with bcc as string instead of array", %{conn: conn, da test "it accepts announces with to as string instead of array", %{conn: conn} do user = insert(:user) + {:ok, post} = CommonAPI.post(user, %{status: "hey"}) + announcer = insert(:user, local: false) + data = %{ "@context" => "https://www.w3.org/ns/activitystreams", - "actor" => "http://mastodon.example.org/users/admin", - "id" => "http://mastodon.example.org/users/admin/statuses/19512778738411822/activity", - "object" => "https://mastodon.social/users/emelie/statuses/101849165031453009", + "actor" => announcer.ap_id, + "id" => "#{announcer.ap_id}/statuses/19512778738411822/activity", + "object" => post.data["object"], "to" => "https://www.w3.org/ns/activitystreams#Public", "cc" => [user.ap_id], "type" => "Announce" @@ -665,7 +668,7 @@ test "it accepts announces with to as string instead of array", %{conn: conn} do |> post("/users/#{user.nickname}/inbox", data) assert "ok" == json_response(conn, 200) - ObanHelpers.perform_all() + ObanHelpers.perform(all_enqueued(worker: ReceiverWorker)) %Activity{} = activity = Activity.get_by_ap_id(data["id"]) assert "https://www.w3.org/ns/activitystreams#Public" in activity.recipients end From adc199c6a8932f893bc1098acbf222e64cdb07d9 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Tue, 23 Jun 2020 12:04:51 +0200 Subject: [PATCH 355/401] ActivityPubControllerTest: Capture error log --- test/web/activity_pub/activity_pub_controller_test.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index e5f801b22..e722f7c04 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -536,6 +536,7 @@ test "accept follow activity", %{conn: conn} do assert_receive {:mix_shell, :info, ["relay.mastodon.host"]} end + @tag capture_log: true test "without valid signature, " <> "it only accepts Create activities and requires enabled federation", %{conn: conn} do From aee815b478aea5d74959c5a445c6c5d87f25168e Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Tue, 23 Jun 2020 12:37:05 +0200 Subject: [PATCH 356/401] ObjectValidator: Clarify type of object. --- lib/pleroma/web/activity_pub/object_validator.ex | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 804a9d06e..2c657b467 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -24,13 +24,13 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) - def validate(%{"type" => "Update"} = object, meta) do - with {:ok, object} <- - object + def validate(%{"type" => "Update"} = update_activity, meta) do + with {:ok, update_activity} <- + update_activity |> UpdateValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do - object = stringify_keys(object) - {:ok, object, meta} + update_activity = stringify_keys(update_activity) + {:ok, update_activity, meta} end end From 54039100fe05e8daf03274ea5c56ca8dab341e9b Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Tue, 23 Jun 2020 11:17:26 -0500 Subject: [PATCH 357/401] Remove reference to defunct distsn.org --- config/description.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index f9523936a..bc781e4c8 100644 --- a/config/description.exs +++ b/config/description.exs @@ -979,7 +979,7 @@ key: :instance_thumbnail, type: :string, description: - "The instance thumbnail image. It will appear in [Pleroma Instances](http://distsn.org/pleroma-instances.html)", + "The instance thumbnail is the Mastodon landing page image and used by some apps to identify the instance.", suggestions: ["/instance/thumbnail.jpeg"] } ] From cb96c82f70e94e24bdf71e832db4548086f4e7c5 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Tue, 23 Jun 2020 20:18:27 +0300 Subject: [PATCH 358/401] moving to mrf namespace migration fix --- ...rf_config_move_from_instance_namespace.exs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/priv/repo/migrations/20200323122421_mrf_config_move_from_instance_namespace.exs b/priv/repo/migrations/20200323122421_mrf_config_move_from_instance_namespace.exs index 6f6094613..ef36c4eb7 100644 --- a/priv/repo/migrations/20200323122421_mrf_config_move_from_instance_namespace.exs +++ b/priv/repo/migrations/20200323122421_mrf_config_move_from_instance_namespace.exs @@ -5,13 +5,11 @@ defmodule Pleroma.Repo.Migrations.MrfConfigMoveFromInstanceNamespace do @old_keys [:rewrite_policy, :mrf_transparency, :mrf_transparency_exclusions] def change do - config = ConfigDB.get_by_params(%{group: ":pleroma", key: ":instance"}) + config = ConfigDB.get_by_params(%{group: :pleroma, key: :instance}) if config do - old_instance = ConfigDB.from_binary(config.value) - mrf = - old_instance + config.value |> Keyword.take(@old_keys) |> Keyword.new(fn {:rewrite_policy, policies} -> {:policies, policies} @@ -21,15 +19,17 @@ def change do if mrf != [] do {:ok, _} = - ConfigDB.create( - %{group: ":pleroma", key: ":mrf", value: ConfigDB.to_binary(mrf)}, - false - ) + %ConfigDB{} + |> ConfigDB.changeset(%{group: :pleroma, key: :mrf, value: mrf}) + |> Pleroma.Repo.insert() - new_instance = Keyword.drop(old_instance, @old_keys) + new_instance = Keyword.drop(config.value, @old_keys) if new_instance != [] do - {:ok, _} = ConfigDB.update(config, %{value: ConfigDB.to_binary(new_instance)}, false) + {:ok, _} = + config + |> ConfigDB.changeset(%{value: new_instance}) + |> Pleroma.Repo.update() else {:ok, _} = ConfigDB.delete(config) end From 721fc7c554425ccc7df693776c282c30e95ae2bb Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Wed, 24 Jun 2020 09:12:32 +0300 Subject: [PATCH 359/401] added wrapper Pleroma.HTTP for Tzdata.HTTPClient --- config/config.exs | 2 ++ lib/pleroma/http/http.ex | 8 ++++++-- lib/pleroma/http/tzdata.ex | 25 +++++++++++++++++++++++++ mix.exs | 2 +- mix.lock | 2 +- test/http/tzdata_test.exs | 35 +++++++++++++++++++++++++++++++++++ test/http_test.exs | 9 +++++++++ 7 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 lib/pleroma/http/tzdata.ex create mode 100644 test/http/tzdata_test.exs diff --git a/config/config.exs b/config/config.exs index a81ffcd3b..bd559c835 100644 --- a/config/config.exs +++ b/config/config.exs @@ -695,6 +695,8 @@ transparency: true, transparency_exclusions: [] +config :tzdata, :http_client, Pleroma.HTTP.Tzdata + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/lib/pleroma/http/http.ex b/lib/pleroma/http/http.ex index 583b56484..66ca75367 100644 --- a/lib/pleroma/http/http.ex +++ b/lib/pleroma/http/http.ex @@ -16,6 +16,7 @@ defmodule Pleroma.HTTP do require Logger @type t :: __MODULE__ + @type method() :: :get | :post | :put | :delete | :head @doc """ Performs GET request. @@ -28,6 +29,9 @@ def get(url, headers \\ [], options \\ []) def get(nil, _, _), do: nil def get(url, headers, options), do: request(:get, url, "", headers, options) + @spec head(Request.url(), Request.headers(), keyword()) :: {:ok, Env.t()} | {:error, any()} + def head(url, headers \\ [], options \\ []), do: request(:head, url, "", headers, options) + @doc """ Performs POST request. @@ -42,7 +46,7 @@ def post(url, body, headers \\ [], options \\ []), Builds and performs http request. # Arguments: - `method` - :get, :post, :put, :delete + `method` - :get, :post, :put, :delete, :head `url` - full url `body` - request body `headers` - a keyworld list of headers, e.g. `[{"content-type", "text/plain"}]` @@ -52,7 +56,7 @@ def post(url, body, headers \\ [], options \\ []), `{:ok, %Tesla.Env{}}` or `{:error, error}` """ - @spec request(atom(), Request.url(), String.t(), Request.headers(), keyword()) :: + @spec request(method(), Request.url(), String.t(), Request.headers(), keyword()) :: {:ok, Env.t()} | {:error, any()} def request(method, url, body, headers, options) when is_binary(url) do uri = URI.parse(url) diff --git a/lib/pleroma/http/tzdata.ex b/lib/pleroma/http/tzdata.ex new file mode 100644 index 000000000..34bb253a7 --- /dev/null +++ b/lib/pleroma/http/tzdata.ex @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.Tzdata do + @moduledoc false + + @behaviour Tzdata.HTTPClient + + alias Pleroma.HTTP + + @impl true + def get(url, headers, options) do + with {:ok, %Tesla.Env{} = env} <- HTTP.get(url, headers, options) do + {:ok, {env.status, env.headers, env.body}} + end + end + + @impl true + def head(url, headers, options) do + with {:ok, %Tesla.Env{} = env} <- HTTP.head(url, headers, options) do + {:ok, {env.status, env.headers}} + end + end +end diff --git a/mix.exs b/mix.exs index 4d13e95d7..b638be541 100644 --- a/mix.exs +++ b/mix.exs @@ -117,7 +117,7 @@ defp oauth_deps do defp deps do [ {:phoenix, "~> 1.4.8"}, - {:tzdata, "~> 0.5.21"}, + {:tzdata, "~> 1.0.3"}, {:plug_cowboy, "~> 2.0"}, {:phoenix_pubsub, "~> 1.1"}, {:phoenix_ecto, "~> 4.0"}, diff --git a/mix.lock b/mix.lock index 5383c2c6e..5ad49391d 100644 --- a/mix.lock +++ b/mix.lock @@ -110,7 +110,7 @@ "tesla": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/tesla.git", "61b7503cef33f00834f78ddfafe0d5d9dec2270b", [ref: "61b7503cef33f00834f78ddfafe0d5d9dec2270b"]}, "timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "f354efb2400dd7a80fd9eb6c8419068c4f632da4ac47f3d8822d6e33f08bc852"}, "trailing_format_plug": {:hex, :trailing_format_plug, "0.0.7", "64b877f912cf7273bed03379936df39894149e35137ac9509117e59866e10e45", [:mix], [{:plug, "> 0.12.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bd4fde4c15f3e993a999e019d64347489b91b7a9096af68b2bdadd192afa693f"}, - "tzdata": {:hex, :tzdata, "0.5.22", "f2ba9105117ee0360eae2eca389783ef7db36d533899b2e84559404dbc77ebb8", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "cd66c8a1e6a9e121d1f538b01bef459334bb4029a1ffb4eeeb5e4eae0337e7b6"}, + "tzdata": {:hex, :tzdata, "1.0.3", "73470ad29dde46e350c60a66e6b360d3b99d2d18b74c4c349dbebbc27a09a3eb", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a6e1ee7003c4d04ecbd21dd3ec690d4c6662db5d3bbdd7262d53cdf5e7c746c1"}, "ueberauth": {:hex, :ueberauth, "0.6.2", "25a31111249d60bad8b65438b2306a4dc91f3208faa62f5a8c33e8713989b2e8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "db9fbfb5ac707bc4f85a297758406340bf0358b4af737a88113c1a9eee120ac7"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"}, "unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm", "6c7729a2d214806450d29766abc2afaa7a2cbecf415be64f36a6691afebb50e5"}, diff --git a/test/http/tzdata_test.exs b/test/http/tzdata_test.exs new file mode 100644 index 000000000..4b37299cd --- /dev/null +++ b/test/http/tzdata_test.exs @@ -0,0 +1,35 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.TzdaraTest do + use ExUnit.Case + + import Tesla.Mock + alias Pleroma.HTTP + @url "https://data.iana.org/time-zones/tzdata-latest.tar.gz" + + setup do + mock(fn + %{method: :head, url: @url} -> + %Tesla.Env{status: 200, body: ""} + + %{method: :get, url: @url} -> + %Tesla.Env{status: 200, body: "hello"} + end) + + :ok + end + + describe "head/1" do + test "returns successfully result" do + assert HTTP.Tzdata.head(@url, [], []) == {:ok, {200, []}} + end + end + + describe "get/1" do + test "returns successfully result" do + assert HTTP.Tzdata.get(@url, [], []) == {:ok, {200, [], "hello"}} + end + end +end diff --git a/test/http_test.exs b/test/http_test.exs index 618485b55..d394bb942 100644 --- a/test/http_test.exs +++ b/test/http_test.exs @@ -17,6 +17,9 @@ defmodule Pleroma.HTTPTest do } -> json(%{"my" => "data"}) + %{method: :head, url: "http://example.com/hello"} -> + %Tesla.Env{status: 200, body: ""} + %{method: :get, url: "http://example.com/hello"} -> %Tesla.Env{status: 200, body: "hello"} @@ -27,6 +30,12 @@ defmodule Pleroma.HTTPTest do :ok end + describe "head/1" do + test "returns successfully result" do + assert HTTP.head("http://example.com/hello") == {:ok, %Tesla.Env{status: 200, body: ""}} + end + end + describe "get/1" do test "returns successfully result" do assert HTTP.get("http://example.com/hello") == { From 65f3eb333b001586771247ea9949e40bfec0a947 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Wed, 24 Jun 2020 08:50:33 +0000 Subject: [PATCH 360/401] Apply suggestion to test/http/tzdata_test.exs --- test/http/tzdata_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/http/tzdata_test.exs b/test/http/tzdata_test.exs index 4b37299cd..3e605d33b 100644 --- a/test/http/tzdata_test.exs +++ b/test/http/tzdata_test.exs @@ -2,7 +2,7 @@ # Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.HTTP.TzdaraTest do +defmodule Pleroma.HTTP.TzdataTest do use ExUnit.Case import Tesla.Mock From 35f6770436837e2e500971a54d51984bd059adfd Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Wed, 24 Jun 2020 13:29:08 +0200 Subject: [PATCH 361/401] StatusView: Add pleroma.parent_visible --- lib/pleroma/web/activity_pub/visibility.ex | 6 ++++-- .../web/mastodon_api/views/status_view.ex | 5 +++-- .../mastodon_api/views/status_view_test.exs | 19 ++++++++++++++++++- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/activity_pub/visibility.ex b/lib/pleroma/web/activity_pub/visibility.ex index 453a6842e..343f41caa 100644 --- a/lib/pleroma/web/activity_pub/visibility.ex +++ b/lib/pleroma/web/activity_pub/visibility.ex @@ -47,6 +47,10 @@ def is_list?(_), do: false @spec visible_for_user?(Activity.t(), User.t() | nil) :: boolean() def visible_for_user?(%{actor: ap_id}, %User{ap_id: ap_id}), do: true + def visible_for_user?(nil, _), do: false + + def visible_for_user?(%{data: %{"listMessage" => _}}, nil), do: false + def visible_for_user?(%{data: %{"listMessage" => list_ap_id}} = activity, %User{} = user) do user.ap_id in activity.data["to"] || list_ap_id @@ -54,8 +58,6 @@ def visible_for_user?(%{data: %{"listMessage" => list_ap_id}} = activity, %User{ |> Pleroma.List.member?(user) end - def visible_for_user?(%{data: %{"listMessage" => _}}, nil), do: false - def visible_for_user?(%{local: local} = activity, nil) do cfg_key = if local, diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 2c49bedb3..6ee17f4dd 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -21,7 +21,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MediaProxy - import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1] + import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1, visible_for_user?: 2] # TODO: Add cached version. defp get_replied_to_activities([]), do: %{} @@ -364,7 +364,8 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} expires_at: expires_at, direct_conversation_id: direct_conversation_id, thread_muted: thread_muted?, - emoji_reactions: emoji_reactions + emoji_reactions: emoji_reactions, + parent_visible: visible_for_user?(reply_to, opts[:for]) } } end diff --git a/test/web/mastodon_api/views/status_view_test.exs b/test/web/mastodon_api/views/status_view_test.exs index 5cbadf0fc..f90a0c273 100644 --- a/test/web/mastodon_api/views/status_view_test.exs +++ b/test/web/mastodon_api/views/status_view_test.exs @@ -226,7 +226,8 @@ test "a note activity" do expires_at: nil, direct_conversation_id: nil, thread_muted: false, - emoji_reactions: [] + emoji_reactions: [], + parent_visible: false } } @@ -620,4 +621,20 @@ test "visibility/list" do assert status.visibility == "list" end + + test "has a field for parent visibility" do + user = insert(:user) + poster = insert(:user) + + {:ok, invisible} = CommonAPI.post(poster, %{status: "hey", visibility: "private"}) + + {:ok, visible} = + CommonAPI.post(poster, %{status: "hey", visibility: "private", in_reply_to_id: invisible.id}) + + status = StatusView.render("show.json", activity: visible, for: user) + refute status.pleroma.parent_visible + + status = StatusView.render("show.json", activity: visible, for: poster) + assert status.pleroma.parent_visible + end end From 637bae42b4ac59e54164f2b9545017b3f8d2960f Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Wed, 24 Jun 2020 13:31:42 +0200 Subject: [PATCH 362/401] Docs: Document added parent_visible field. --- docs/API/differences_in_mastoapi_responses.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index be3c802af..f6e8a6800 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -27,6 +27,7 @@ Has these additional fields under the `pleroma` object: - `expires_at`: a datetime (iso8601) that states when the post will expire (be deleted automatically), or empty if the post won't expire - `thread_muted`: true if the thread the post belongs to is muted - `emoji_reactions`: A list with emoji / reaction maps. The format is `{name: "☕", count: 1, me: true}`. Contains no information about the reacting users, for that use the `/statuses/:id/reactions` endpoint. +- `parent_visible`: If the parent of this post is visible to the user or not. ## Media Attachments From 79ee914bc2956f005c83046be53dbd38771d29ef Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Wed, 24 Jun 2020 13:32:14 +0200 Subject: [PATCH 363/401] Changelog: Add info about parent_visible field --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fc2231d1..cfffc279b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- StatusView: Add pleroma.parents_visible field. - Chats: Added support for federated chats. For details, see the docs. - ActivityPub: Added support for existing AP ids for instances migrated from Mastodon. - Instance: Add `background_image` to configuration and `/api/v1/instance` From 1702239428ea7e3b49fcf8985f1d2fbbadb020b5 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Wed, 24 Jun 2020 14:30:12 +0200 Subject: [PATCH 364/401] Changelog: Put info under API header. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfffc279b..f04e12ade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added -- StatusView: Add pleroma.parents_visible field. - Chats: Added support for federated chats. For details, see the docs. - ActivityPub: Added support for existing AP ids for instances migrated from Mastodon. - Instance: Add `background_image` to configuration and `/api/v1/instance` @@ -43,6 +42,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). <details> <summary>API Changes</summary> +- Mastodon API: Add pleroma.parents_visible field to statuses. - Mastodon API: Extended `/api/v1/instance`. - Mastodon API: Support for `include_types` in `/api/v1/notifications`. - Mastodon API: Added `/api/v1/notifications/:id/dismiss` endpoint. From 4c5fb831b3b59309a475a141eb73cc440533d0ff Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Wed, 24 Jun 2020 14:33:00 +0200 Subject: [PATCH 365/401] Status schema: Add parent_visible. --- lib/pleroma/web/api_spec/schemas/status.ex | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index 8b87cb25b..28cde963e 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -184,6 +184,10 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do thread_muted: %Schema{ type: :boolean, description: "`true` if the thread the post belongs to is muted" + }, + parent_visible: %Schema{ + type: :boolean, + description: "`true` if the parent post is visible to the user" } } }, From aae1af8cf1d860243eb8c8a642682441294967e1 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Wed, 24 Jun 2020 18:06:30 +0300 Subject: [PATCH 366/401] fix for emoji pagination in pack show --- lib/pleroma/emoji/pack.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index 787ff8141..d076ae312 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -45,6 +45,7 @@ def show(opts) do shortcodes = pack.files |> Map.keys() + |> Enum.sort() |> paginate(opts[:page], opts[:page_size]) pack = Map.put(pack, :files, Map.take(pack.files, shortcodes)) From 67ab5805536ed64ca842998bfd4b3b0e63d13dd3 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Wed, 24 Jun 2020 17:18:53 -0500 Subject: [PATCH 367/401] Filter outstanding follower requests from deactivated accounts --- lib/pleroma/following_relationship.ex | 1 + test/user_test.exs | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index 093b1f405..c2020d30a 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -124,6 +124,7 @@ def get_follow_requests(%User{id: id}) do |> join(:inner, [r], f in assoc(r, :follower)) |> where([r], r.state == ^:follow_pending) |> where([r], r.following_id == ^id) + |> where([r, f], f.deactivated != true) |> select([r, f], f) |> Repo.all() end diff --git a/test/user_test.exs b/test/user_test.exs index 311b6c683..9b66f3f51 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -199,6 +199,16 @@ test "doesn't return already accepted or duplicate follow requests" do assert [^pending_follower] = User.get_follow_requests(locked) end + test "doesn't return follow requests for deactivated accounts" do + locked = insert(:user, locked: true) + pending_follower = insert(:user, %{deactivated: true}) + + CommonAPI.follow(pending_follower, locked) + + assert true == pending_follower.deactivated + assert [] = User.get_follow_requests(locked) + end + test "clears follow requests when requester is blocked" do followed = insert(:user, locked: true) follower = insert(:user) From 439a1a0218fe032ac35bb2e84516a8a4bf8563b4 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Thu, 25 Jun 2020 07:12:29 +0300 Subject: [PATCH 368/401] added wrapper Pleroma.HTTP for ExAws.S3 --- config/config.exs | 2 ++ lib/pleroma/http/ex_aws.ex | 22 ++++++++++++++++ test/http/ex_aws_test.exs | 54 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 lib/pleroma/http/ex_aws.ex create mode 100644 test/http/ex_aws_test.exs diff --git a/config/config.exs b/config/config.exs index bd559c835..5aad26e95 100644 --- a/config/config.exs +++ b/config/config.exs @@ -697,6 +697,8 @@ config :tzdata, :http_client, Pleroma.HTTP.Tzdata +config :ex_aws, http_client: Pleroma.HTTP.ExAws + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/lib/pleroma/http/ex_aws.ex b/lib/pleroma/http/ex_aws.ex new file mode 100644 index 000000000..e53e64077 --- /dev/null +++ b/lib/pleroma/http/ex_aws.ex @@ -0,0 +1,22 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.ExAws do + @moduledoc false + + @behaviour ExAws.Request.HttpClient + + alias Pleroma.HTTP + + @impl true + def request(method, url, body \\ "", headers \\ [], http_opts \\ []) do + case HTTP.request(method, url, body, headers, http_opts) do + {:ok, env} -> + {:ok, %{status_code: env.status, headers: env.headers, body: env.body}} + + {:error, reason} -> + {:error, %{reason: reason}} + end + end +end diff --git a/test/http/ex_aws_test.exs b/test/http/ex_aws_test.exs new file mode 100644 index 000000000..d0b00ca26 --- /dev/null +++ b/test/http/ex_aws_test.exs @@ -0,0 +1,54 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.ExAwsTest do + use ExUnit.Case + + import Tesla.Mock + alias Pleroma.HTTP + + @url "https://s3.amazonaws.com/test_bucket/test_image.jpg" + + setup do + mock(fn + %{method: :get, url: @url, headers: [{"x-amz-bucket-region", "us-east-1"}]} -> + %Tesla.Env{ + status: 200, + body: "image-content", + headers: [{"x-amz-bucket-region", "us-east-1"}] + } + + %{method: :post, url: @url, body: "image-content-2"} -> + %Tesla.Env{status: 200, body: "image-content-2"} + end) + + :ok + end + + describe "request" do + test "get" do + assert HTTP.ExAws.request(:get, @url, "", [{"x-amz-bucket-region", "us-east-1"}]) == { + :ok, + %{ + body: "image-content", + headers: [{"x-amz-bucket-region", "us-east-1"}], + status_code: 200 + } + } + end + + test "post" do + assert HTTP.ExAws.request(:post, @url, "image-content-2", [ + {"x-amz-bucket-region", "us-east-1"} + ]) == { + :ok, + %{ + body: "image-content-2", + headers: [], + status_code: 200 + } + } + end + end +end From d137f934dfed199141ee7cb4215520b64e3ecb4f Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Thu, 25 Jun 2020 10:54:00 +0200 Subject: [PATCH 369/401] Transmogrifier Test: Extract block handling. --- .../transmogrifier/block_handling_test.exs | 63 +++++++++++++++++++ test/web/activity_pub/transmogrifier_test.exs | 50 --------------- 2 files changed, 63 insertions(+), 50 deletions(-) create mode 100644 test/web/activity_pub/transmogrifier/block_handling_test.exs diff --git a/test/web/activity_pub/transmogrifier/block_handling_test.exs b/test/web/activity_pub/transmogrifier/block_handling_test.exs new file mode 100644 index 000000000..71f1a0ed5 --- /dev/null +++ b/test/web/activity_pub/transmogrifier/block_handling_test.exs @@ -0,0 +1,63 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier.BlockHandlingTest do + use Pleroma.DataCase + + alias Pleroma.Activity + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Transmogrifier + + import Pleroma.Factory + + test "it works for incoming blocks" do + user = insert(:user) + + data = + File.read!("test/fixtures/mastodon-block-activity.json") + |> Poison.decode!() + |> Map.put("object", user.ap_id) + + blocker = insert(:user, ap_id: data["actor"]) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["type"] == "Block" + assert data["object"] == user.ap_id + assert data["actor"] == "http://mastodon.example.org/users/admin" + + assert User.blocks?(blocker, user) + end + + test "incoming blocks successfully tear down any follow relationship" do + blocker = insert(:user) + blocked = insert(:user) + + data = + File.read!("test/fixtures/mastodon-block-activity.json") + |> Poison.decode!() + |> Map.put("object", blocked.ap_id) + |> Map.put("actor", blocker.ap_id) + + {:ok, blocker} = User.follow(blocker, blocked) + {:ok, blocked} = User.follow(blocked, blocker) + + assert User.following?(blocker, blocked) + assert User.following?(blocked, blocker) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["type"] == "Block" + assert data["object"] == blocked.ap_id + assert data["actor"] == blocker.ap_id + + blocker = User.get_cached_by_ap_id(data["actor"]) + blocked = User.get_cached_by_ap_id(data["object"]) + + assert User.blocks?(blocker, blocked) + + refute User.following?(blocker, blocked) + refute User.following?(blocked, blocker) + end +end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 100821056..6a53fd3f0 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -445,56 +445,6 @@ test "it works for incoming follows to locked account" do assert [^pending_follower] = User.get_follow_requests(user) end - test "it works for incoming blocks" do - user = insert(:user) - - data = - File.read!("test/fixtures/mastodon-block-activity.json") - |> Poison.decode!() - |> Map.put("object", user.ap_id) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["type"] == "Block" - assert data["object"] == user.ap_id - assert data["actor"] == "http://mastodon.example.org/users/admin" - - blocker = User.get_cached_by_ap_id(data["actor"]) - - assert User.blocks?(blocker, user) - end - - test "incoming blocks successfully tear down any follow relationship" do - blocker = insert(:user) - blocked = insert(:user) - - data = - File.read!("test/fixtures/mastodon-block-activity.json") - |> Poison.decode!() - |> Map.put("object", blocked.ap_id) - |> Map.put("actor", blocker.ap_id) - - {:ok, blocker} = User.follow(blocker, blocked) - {:ok, blocked} = User.follow(blocked, blocker) - - assert User.following?(blocker, blocked) - assert User.following?(blocked, blocker) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["type"] == "Block" - assert data["object"] == blocked.ap_id - assert data["actor"] == blocker.ap_id - - blocker = User.get_cached_by_ap_id(data["actor"]) - blocked = User.get_cached_by_ap_id(data["object"]) - - assert User.blocks?(blocker, blocked) - - refute User.following?(blocker, blocked) - refute User.following?(blocked, blocker) - end - test "it works for incoming accepts which were pre-accepted" do follower = insert(:user) followed = insert(:user) From 89e5b2046bd15b3fead7a6194a2b9cecd2fedbd3 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Thu, 25 Jun 2020 11:13:35 +0200 Subject: [PATCH 370/401] ObjectValidator: Basic `Block` support. --- lib/pleroma/web/activity_pub/builder.ex | 12 ++++++ .../web/activity_pub/object_validator.ex | 11 +++++ .../object_validators/block_validator.ex | 42 +++++++++++++++++++ .../activity_pub/object_validator_test.exs | 27 ++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 lib/pleroma/web/activity_pub/object_validators/block_validator.ex diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 135a5c431..cabc28de9 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -138,6 +138,18 @@ def update(actor, object) do }, []} end + @spec block(User.t(), User.t()) :: {:ok, map(), keyword()} + def block(blocker, blocked) do + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "type" => "Block", + "actor" => blocker.ap_id, + "object" => blocked.ap_id, + "to" => [blocked.ap_id] + }, []} + end + @spec announce(User.t(), Object.t(), keyword()) :: {:ok, map(), keyword()} def announce(actor, object, options \\ []) do public? = Keyword.get(options, :public, false) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 2c657b467..737c0fd64 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -13,6 +13,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.BlockValidator alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator @@ -24,6 +25,16 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) + def validate(%{"type" => "Block"} = block_activity, meta) do + with {:ok, block_activity} <- + block_activity + |> BlockValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + block_activity = stringify_keys(block_activity) + {:ok, block_activity, meta} + end + end + def validate(%{"type" => "Update"} = update_activity, meta) do with {:ok, update_activity} <- update_activity diff --git a/lib/pleroma/web/activity_pub/object_validators/block_validator.ex b/lib/pleroma/web/activity_pub/object_validators/block_validator.ex new file mode 100644 index 000000000..1dde77198 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/block_validator.ex @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.BlockValidator do + use Ecto.Schema + + alias Pleroma.EctoType.ActivityPub.ObjectValidators + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + field(:id, ObjectValidators.ObjectID, primary_key: true) + field(:type, :string) + field(:actor, ObjectValidators.ObjectID) + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) + field(:object, ObjectValidators.ObjectID) + end + + def cast_data(data) do + %__MODULE__{} + |> cast(data, __schema__(:fields)) + end + + def validate_data(cng) do + cng + |> validate_required([:id, :type, :actor, :to, :cc, :object]) + |> validate_inclusion(:type, ["Block"]) + |> validate_actor_presence() + |> validate_actor_presence(field_name: :object) + end + + def cast_and_validate(data) do + data + |> cast_data + |> validate_data + end +end diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 770a8dcf8..e96552763 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -654,4 +654,31 @@ test "returns an error if the object can't be updated by the actor", %{ assert {:error, _cng} = ObjectValidator.validate(update, []) end end + + describe "blocks" do + setup do + user = insert(:user) + blocked = insert(:user) + + {:ok, valid_block, []} = Builder.block(user, blocked) + + %{user: user, valid_block: valid_block} + end + + test "validates a basic object", %{ + valid_block: valid_block + } do + assert {:ok, _block, []} = ObjectValidator.validate(valid_block, []) + end + + test "returns an error if we don't know the blocked user", %{ + valid_block: valid_block + } do + block = + valid_block + |> Map.put("object", "https://gensokyo.2hu/users/raymoo") + + assert {:error, _cng} = ObjectValidator.validate(block, []) + end + end end From e38293c8f1adca40447ba39f4919b2b08bf0329a Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Thu, 25 Jun 2020 11:33:54 +0200 Subject: [PATCH 371/401] Transmogrifier: Switch to pipeline for Blocks. --- lib/pleroma/web/activity_pub/side_effects.ex | 16 ++++++++++++ .../web/activity_pub/transmogrifier.ex | 22 +++------------- test/web/activity_pub/side_effects_test.exs | 25 +++++++++++++++++++ 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index de143b8f0..48350d2b3 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -20,6 +20,22 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do def handle(object, meta \\ []) + # Tasks this handles: + # - Unfollow and block + def handle( + %{data: %{"type" => "Block", "object" => blocked_user, "actor" => blocking_user}} = + object, + meta + ) do + with %User{} = blocker <- User.get_cached_by_ap_id(blocking_user), + %User{} = blocked <- User.get_cached_by_ap_id(blocked_user) do + User.unfollow(blocker, blocked) + User.block(blocker, blocked) + end + + {:ok, object, meta} + end + # Tasks this handles: # - Update the user # diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 4e318e89c..278fbbeab 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -673,7 +673,7 @@ def handle_incoming( end def handle_incoming(%{"type" => type} = data, _options) - when type in ["Like", "EmojiReact", "Announce"] do + when type in ~w{Like EmojiReact Announce} do with :ok <- ObjectValidator.fetch_actor_and_object(data), {:ok, activity, _meta} <- Pipeline.common_pipeline(data, local: false) do @@ -684,9 +684,10 @@ def handle_incoming(%{"type" => type} = data, _options) end def handle_incoming( - %{"type" => "Update"} = data, + %{"type" => type} = data, _options - ) do + ) + when type in ~w{Update Block} do with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do {:ok, activity} @@ -765,21 +766,6 @@ def handle_incoming( end end - def handle_incoming( - %{"type" => "Block", "object" => blocked, "actor" => blocker, "id" => id} = _data, - _options - ) do - with %User{local: true} = blocked = User.get_cached_by_ap_id(blocked), - {:ok, %User{} = blocker} = User.get_or_fetch_by_ap_id(blocker), - {:ok, activity} <- ActivityPub.block(blocker, blocked, id, false) do - User.unfollow(blocker, blocked) - User.block(blocker, blocked) - {:ok, activity} - else - _e -> :error - end - end - def handle_incoming( %{ "type" => "Move", diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 12c9ef1da..5e883bb09 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -64,6 +64,31 @@ test "it streams out notifications and streams" do end end + describe "blocking users" do + setup do + user = insert(:user) + blocked = insert(:user) + User.follow(blocked, user) + User.follow(user, blocked) + + {:ok, block_data, []} = Builder.block(user, blocked) + {:ok, block, _meta} = ActivityPub.persist(block_data, local: true) + + %{user: user, blocked: blocked, block: block} + end + + test "it unfollows and blocks", %{user: user, blocked: blocked, block: block} do + assert User.following?(user, blocked) + assert User.following?(blocked, user) + + {:ok, _, _} = SideEffects.handle(block) + + refute User.following?(user, blocked) + refute User.following?(blocked, user) + assert User.blocks?(user, blocked) + end + end + describe "update users" do setup do user = insert(:user) From 8cfb58a8c0a2ee0c69eb727cc810e8571289f813 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Thu, 25 Jun 2020 11:44:04 +0200 Subject: [PATCH 372/401] AccountController: Extract blocking to CommonAPI. --- lib/pleroma/web/common_api/common_api.ex | 7 +++++++ .../web/mastodon_api/controllers/account_controller.ex | 3 +-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 04e081a8e..fd7149079 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -25,6 +25,13 @@ defmodule Pleroma.Web.CommonAPI do require Pleroma.Constants require Logger + def block(blocker, blocked) do + with {:ok, block_data, _} <- Builder.block(blocker, blocked), + {:ok, block, _} <- Pipeline.common_pipeline(block_data, local: true) do + {:ok, block} + end + end + def post_chat_message(%User{} = user, %User{} = recipient, content, opts \\ []) do with maybe_attachment <- opts[:media_id] && Object.get_by_id(opts[:media_id]), :ok <- validate_chat_content_length(content, !!maybe_attachment), diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 7a88a847c..b5008d69b 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -385,8 +385,7 @@ def unmute(%{assigns: %{user: muter, account: muted}} = conn, _params) do @doc "POST /api/v1/accounts/:id/block" def block(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do - with {:ok, _user_block} <- User.block(blocker, blocked), - {:ok, _activity} <- ActivityPub.block(blocker, blocked) do + with {:ok, _activity} <- CommonAPI.block(blocker, blocked) do render(conn, "relationship.json", user: blocker, target: blocked) else {:error, message} -> json_response(conn, :forbidden, %{error: message}) From 44bb7cfccdf2c25ae641b4cffa8e5c7fdedc3f54 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Thu, 25 Jun 2020 11:51:33 +0200 Subject: [PATCH 373/401] ActivityPub: Remove `block`. --- lib/pleroma/user.ex | 3 +- lib/pleroma/web/activity_pub/activity_pub.ex | 27 ----------- test/web/activity_pub/activity_pub_test.exs | 48 -------------------- test/web/activity_pub/side_effects_test.exs | 3 +- test/web/activity_pub/utils_test.exs | 16 ++----- 5 files changed, 5 insertions(+), 92 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 1d70a37ef..c3e2a89ad 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1527,8 +1527,7 @@ def perform(:blocks_import, %User{} = blocker, blocked_identifiers) blocked_identifiers, fn blocked_identifier -> with {:ok, %User{} = blocked} <- get_or_fetch(blocked_identifier), - {:ok, _user_block} <- block(blocker, blocked), - {:ok, _} <- ActivityPub.block(blocker, blocked) do + {:ok, _block} <- CommonAPI.block(blocker, blocked) do blocked else err -> diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 7cd3eab39..05bd824f5 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -366,33 +366,6 @@ defp do_unfollow(follower, followed, activity_id, local) do end end - @spec block(User.t(), User.t(), String.t() | nil, boolean()) :: - {:ok, Activity.t()} | {:error, any()} - def block(blocker, blocked, activity_id \\ nil, local \\ true) do - with {:ok, result} <- - Repo.transaction(fn -> do_block(blocker, blocked, activity_id, local) end) do - result - end - end - - defp do_block(blocker, blocked, activity_id, local) do - unfollow_blocked = Config.get([:activitypub, :unfollow_blocked]) - - if unfollow_blocked and fetch_latest_follow(blocker, blocked) do - unfollow(blocker, blocked, nil, local) - end - - block_data = make_block_data(blocker, blocked, activity_id) - - with {:ok, activity} <- insert(block_data, local), - _ <- notify_and_stream(activity), - :ok <- maybe_federate(activity) do - {:ok, activity} - else - {:error, error} -> Repo.rollback(error) - end - end - @spec flag(map()) :: {:ok, Activity.t()} | {:error, any()} def flag( %{ diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index be7ab2ae4..575e0c5db 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -992,54 +992,6 @@ test "creates an undo activity for a pending follow request" do end end - describe "blocking" do - test "reverts block activity on error" do - [blocker, blocked] = insert_list(2, :user) - - with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do - assert {:error, :reverted} = ActivityPub.block(blocker, blocked) - end - - assert Repo.aggregate(Activity, :count, :id) == 0 - assert Repo.aggregate(Object, :count, :id) == 0 - end - - test "creates a block activity" do - clear_config([:instance, :federating], true) - blocker = insert(:user) - blocked = insert(:user) - - with_mock Pleroma.Web.Federator, - publish: fn _ -> nil end do - {:ok, activity} = ActivityPub.block(blocker, blocked) - - assert activity.data["type"] == "Block" - assert activity.data["actor"] == blocker.ap_id - assert activity.data["object"] == blocked.ap_id - - assert called(Pleroma.Web.Federator.publish(activity)) - end - end - - test "works with outgoing blocks disabled, but doesn't federate" do - clear_config([:instance, :federating], true) - clear_config([:activitypub, :outgoing_blocks], false) - blocker = insert(:user) - blocked = insert(:user) - - with_mock Pleroma.Web.Federator, - publish: fn _ -> nil end do - {:ok, activity} = ActivityPub.block(blocker, blocked) - - assert activity.data["type"] == "Block" - assert activity.data["actor"] == blocker.ap_id - assert activity.data["object"] == blocked.ap_id - - refute called(Pleroma.Web.Federator.publish(:_)) - end - end - end - describe "timeline post-processing" do test "it filters broken threads" do user1 = insert(:user) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 5e883bb09..36792f015 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -267,8 +267,7 @@ test "when activation is required", %{delete: delete, user: user} do {:ok, like} = CommonAPI.favorite(user, post.id) {:ok, reaction} = CommonAPI.react_with_emoji(post.id, user, "👍") {:ok, announce} = CommonAPI.repeat(post.id, user) - {:ok, block} = ActivityPub.block(user, poster) - User.block(user, poster) + {:ok, block} = CommonAPI.block(user, poster) {:ok, undo_data, _meta} = Builder.undo(user, like) {:ok, like_undo, _meta} = ActivityPub.persist(undo_data, local: true) diff --git a/test/web/activity_pub/utils_test.exs b/test/web/activity_pub/utils_test.exs index 15f03f193..2f9ecb5a3 100644 --- a/test/web/activity_pub/utils_test.exs +++ b/test/web/activity_pub/utils_test.exs @@ -27,16 +27,6 @@ test "fetches the latest Follow activity" do end end - describe "fetch the latest Block" do - test "fetches the latest Block activity" do - blocker = insert(:user) - blocked = insert(:user) - {:ok, activity} = ActivityPub.block(blocker, blocked) - - assert activity == Utils.fetch_latest_block(blocker, blocked) - end - end - describe "determine_explicit_mentions()" do test "works with an object that has mentions" do object = %{ @@ -344,9 +334,9 @@ test "fetches last block activities" do user1 = insert(:user) user2 = insert(:user) - assert {:ok, %Activity{} = _} = ActivityPub.block(user1, user2) - assert {:ok, %Activity{} = _} = ActivityPub.block(user1, user2) - assert {:ok, %Activity{} = activity} = ActivityPub.block(user1, user2) + assert {:ok, %Activity{} = _} = CommonAPI.block(user1, user2) + assert {:ok, %Activity{} = _} = CommonAPI.block(user1, user2) + assert {:ok, %Activity{} = activity} = CommonAPI.block(user1, user2) assert Utils.fetch_latest_block(user1, user2) == activity end From 84f9ca19568777861ff9520cbef09a0259efd536 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Thu, 25 Jun 2020 12:03:14 +0200 Subject: [PATCH 374/401] Blocking: Don't federate if the options is set. --- .../web/activity_pub/object_validator.ex | 9 ++++ test/web/common_api/common_api_test.exs | 46 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 737c0fd64..bb6324460 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -31,6 +31,15 @@ def validate(%{"type" => "Block"} = block_activity, meta) do |> BlockValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do block_activity = stringify_keys(block_activity) + outgoing_blocks = Pleroma.Config.get([:activitypub, :outgoing_blocks]) + + meta = + if !outgoing_blocks do + Keyword.put(meta, :do_not_federate, true) + else + meta + end + {:ok, block_activity, meta} end end diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 6bd26050e..fc3bb845d 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -25,6 +25,52 @@ defmodule Pleroma.Web.CommonAPITest do setup do: clear_config([:instance, :limit]) setup do: clear_config([:instance, :max_pinned_statuses]) + describe "blocking" do + setup do + blocker = insert(:user) + blocked = insert(:user) + User.follow(blocker, blocked) + User.follow(blocked, blocker) + %{blocker: blocker, blocked: blocked} + end + + test "it blocks and federates", %{blocker: blocker, blocked: blocked} do + clear_config([:instance, :federating], true) + + with_mock Pleroma.Web.Federator, + publish: fn _ -> nil end do + assert {:ok, block} = CommonAPI.block(blocker, blocked) + + assert block.local + assert User.blocks?(blocker, blocked) + refute User.following?(blocker, blocked) + refute User.following?(blocked, blocker) + + assert called(Pleroma.Web.Federator.publish(block)) + end + end + + test "it blocks and does not federate if outgoing blocks are disabled", %{ + blocker: blocker, + blocked: blocked + } do + clear_config([:instance, :federating], true) + clear_config([:activitypub, :outgoing_blocks], false) + + with_mock Pleroma.Web.Federator, + publish: fn _ -> nil end do + assert {:ok, block} = CommonAPI.block(blocker, blocked) + + assert block.local + assert User.blocks?(blocker, blocked) + refute User.following?(blocker, blocked) + refute User.following?(blocked, blocker) + + refute called(Pleroma.Web.Federator.publish(block)) + end + end + end + describe "posting chat messages" do setup do: clear_config([:instance, :chat_limit]) From f585622f852f4204f8f8dcaa6626ed4cd025edfe Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Thu, 25 Jun 2020 10:17:16 +0000 Subject: [PATCH 375/401] Apply suggestion to config/description.exs --- config/description.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index bc781e4c8..5ed7f753e 100644 --- a/config/description.exs +++ b/config/description.exs @@ -979,7 +979,7 @@ key: :instance_thumbnail, type: :string, description: - "The instance thumbnail is the Mastodon landing page image and used by some apps to identify the instance.", + "The instance thumbnail can be any image that represents your instance and is used by some apps or services when they display information about your instance.", suggestions: ["/instance/thumbnail.jpeg"] } ] From 04abee782b8745b21d0f9e58b27a805db6a94aa7 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Thu, 25 Jun 2020 12:40:39 +0200 Subject: [PATCH 376/401] AntiSpamLinkPolicy: Exempt local users. --- .../activity_pub/mrf/anti_link_spam_policy.ex | 5 ++++- .../mrf/anti_link_spam_policy_test.exs | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex index 9e7800997..a7e187b5e 100644 --- a/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex @@ -27,11 +27,14 @@ defp contains_links?(_), do: false @impl true def filter(%{"type" => "Create", "actor" => actor, "object" => object} = message) do - with {:ok, %User{} = u} <- User.get_or_fetch_by_ap_id(actor), + with {:ok, %User{local: false} = u} <- User.get_or_fetch_by_ap_id(actor), {:contains_links, true} <- {:contains_links, contains_links?(object)}, {:old_user, true} <- {:old_user, old_user?(u)} do {:ok, message} else + {:ok, %User{local: true}} -> + {:ok, message} + {:contains_links, false} -> {:ok, message} diff --git a/test/web/activity_pub/mrf/anti_link_spam_policy_test.exs b/test/web/activity_pub/mrf/anti_link_spam_policy_test.exs index 1a13699be..6867c9853 100644 --- a/test/web/activity_pub/mrf/anti_link_spam_policy_test.exs +++ b/test/web/activity_pub/mrf/anti_link_spam_policy_test.exs @@ -33,7 +33,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicyTest do describe "with new user" do test "it allows posts without links" do - user = insert(:user) + user = insert(:user, local: false) assert user.note_count == 0 @@ -45,7 +45,7 @@ test "it allows posts without links" do end test "it disallows posts with links" do - user = insert(:user) + user = insert(:user, local: false) assert user.note_count == 0 @@ -55,6 +55,18 @@ test "it disallows posts with links" do {:reject, _} = AntiLinkSpamPolicy.filter(message) end + + test "it allows posts with links for local users" do + user = insert(:user) + + assert user.note_count == 0 + + message = + @linkful_message + |> Map.put("actor", user.ap_id) + + {:ok, _message} = AntiLinkSpamPolicy.filter(message) + end end describe "with old user" do From 28d4e60f668ec5fa6c5be21ee28612d01b51a6a0 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Thu, 25 Jun 2020 12:32:06 -0500 Subject: [PATCH 377/401] MastoAPI differences: Document not implemented features --- docs/API/differences_in_mastoapi_responses.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index be3c802af..7c3546f4f 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -234,3 +234,43 @@ Has these additional fields under the `pleroma` object: ## Streaming There is an additional `user:pleroma_chat` stream. Incoming chat messages will make the current chat be sent to this `user` stream. The `event` of an incoming chat message is `pleroma:chat_update`. The payload is the updated chat with the incoming chat message in the `last_message` field. + +## Not implemented + +Pleroma is generally compatible with the Mastodon 2.7.2 API, but some newer features and non-essential features are omitted. These features usually return an HTTP 200 status code, but with an empty response. While they may be added in the future, they are considered low priority. + +### Suggestions + +*Added in Mastodon 2.4.3* + +- `GET /api/v1/suggestions`: Returns an empty array, `[]` + +### Trends + +*Added in Mastodon 3.0.0* + +- `GET /api/v1/trends`: Returns an empty array, `[]` + +### Identity proofs + +*Added in Mastodon 2.8.0* + +- `GET /api/v1/identity_proofs`: Returns an empty array, `[]` + +### Endorsements + +*Added in Mastodon 2.5.0* + +- `GET /api/v1/endorsements`: Returns an empty array, `[]` + +### Profile directory + +*Added in Mastodon 3.0.0* + +- `GET /api/v1/directory`: Returns HTTP 404 + +### Featured tags + +*Added in Mastodon 3.0.0* + +- `GET /api/v1/featured_tags`: Returns HTTP 404 From d9e462362823c9178e354ebe9b7c6761f94d387f Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Thu, 25 Jun 2020 14:55:00 -0500 Subject: [PATCH 378/401] Update AdminFE build --- .../{app.796ca6d4.css => app.6684eb28.css} | Bin ...8.af0d89cd.css => chunk-070d.d2dd6533.css} | Bin priv/static/adminfe/chunk-0cbc.60bba79b.css | Bin 0 -> 3385 bytes ...8.0cc00484.css => chunk-143c.43ada4fc.css} | Bin 692 -> 692 bytes priv/static/adminfe/chunk-1609.408dae86.css | Bin 0 -> 1381 bytes priv/static/adminfe/chunk-176e.5d7d957b.css | Bin 0 -> 2163 bytes priv/static/adminfe/chunk-22d2.813009b9.css | Bin 6282 -> 0 bytes priv/static/adminfe/chunk-43ca.0de86b6d.css | Bin 0 -> 23710 bytes ...1.d3692214.css => chunk-4e7e.5afe1978.css} | Bin priv/static/adminfe/chunk-5882.f65db7f2.css | Bin 0 -> 4401 bytes ...1.0e80d020.css => chunk-6e81.ca3b222f.css} | Bin priv/static/adminfe/chunk-7506.f01f6c2a.css | Bin 0 -> 3290 bytes priv/static/adminfe/chunk-7637.941c4edb.css | Bin 1347 -> 0 bytes ...8.d9e7180a.css => chunk-7c6b.d9e7180a.css} | Bin priv/static/adminfe/chunk-7e30.f2b9674a.css | Bin 23982 -> 0 bytes priv/static/adminfe/chunk-970d.f59cca8c.css | Bin 6173 -> 0 bytes ...4.d50ed383.css => chunk-c5f4.0827b1ce.css} | Bin 5605 -> 5669 bytes .../static/adminfe/chunk-commons.7f6d2d11.css | Bin 0 -> 2495 bytes priv/static/adminfe/chunk-d38a.cabdc22e.css | Bin 3332 -> 0 bytes priv/static/adminfe/chunk-e404.a56021ae.css | Bin 0 -> 5063 bytes priv/static/adminfe/chunk-e458.6c0703cb.css | Bin 3863 -> 0 bytes priv/static/adminfe/index.html | 2 +- priv/static/adminfe/static/js/app.0146039c.js | Bin 190274 -> 0 bytes .../adminfe/static/js/app.0146039c.js.map | Bin 421137 -> 0 bytes priv/static/adminfe/static/js/app.3fcec8f6.js | Bin 0 -> 192591 bytes .../adminfe/static/js/app.3fcec8f6.js.map | Bin 0 -> 426204 bytes ...558.75954137.js => chunk-070d.7e10a520.js} | Bin 7919 -> 7919 bytes ...4137.js.map => chunk-070d.7e10a520.js.map} | Bin 17438 -> 17438 bytes .../static/js/chunk-0778.b17650df.js.map | Bin 32393 -> 0 bytes .../adminfe/static/js/chunk-0cbc.43ff796f.js | Bin 0 -> 21585 bytes .../static/js/chunk-0cbc.43ff796f.js.map | Bin 0 -> 86326 bytes .../adminfe/static/js/chunk-143c.fc1825bf.js | Bin 0 -> 13814 bytes .../static/js/chunk-143c.fc1825bf.js.map | Bin 0 -> 37014 bytes .../adminfe/static/js/chunk-1609.98da6b01.js | Bin 0 -> 10740 bytes .../static/js/chunk-1609.98da6b01.js.map | Bin 0 -> 46790 bytes .../adminfe/static/js/chunk-176e.c4995511.js | Bin 0 -> 10092 bytes .../static/js/chunk-176e.c4995511.js.map | Bin 0 -> 32132 bytes .../adminfe/static/js/chunk-22d2.a0cf7976.js | Bin 30624 -> 0 bytes .../static/js/chunk-22d2.a0cf7976.js.map | Bin 103450 -> 0 bytes .../adminfe/static/js/chunk-3384.b2ebeeca.js | Bin 24591 -> 0 bytes .../static/js/chunk-3384.b2ebeeca.js.map | Bin 86706 -> 0 bytes .../adminfe/static/js/chunk-43ca.3debeff7.js | Bin 0 -> 119060 bytes .../static/js/chunk-43ca.3debeff7.js.map | Bin 0 -> 402101 bytes ...961.ef33e81b.js => chunk-4e7e.91b5e73a.js} | Bin 5112 -> 5112 bytes ...e81b.js.map => chunk-4e7e.91b5e73a.js.map} | Bin 19744 -> 19744 bytes ...f9e.c49aa694.js => chunk-5118.7c48ad58.js} | Bin 24606 -> 24606 bytes ...a694.js.map => chunk-5118.7c48ad58.js.map} | Bin 74431 -> 74431 bytes .../adminfe/static/js/chunk-5882.7cbc4c1b.js | Bin 0 -> 24347 bytes .../static/js/chunk-5882.7cbc4c1b.js.map | Bin 0 -> 81471 bytes .../adminfe/static/js/chunk-6b68.fbc0f684.js | Bin 14790 -> 0 bytes .../static/js/chunk-6b68.fbc0f684.js.map | Bin 40172 -> 0 bytes ...e81.3733ace2.js => chunk-6e81.6efb01f4.js} | Bin 2080 -> 2080 bytes ...ace2.js.map => chunk-6e81.6efb01f4.js.map} | Bin 9090 -> 9090 bytes .../adminfe/static/js/chunk-7506.a3364e53.js | Bin 0 -> 17041 bytes .../static/js/chunk-7506.a3364e53.js.map | Bin 0 -> 58197 bytes .../adminfe/static/js/chunk-7637.8f5fb36e.js | Bin 10877 -> 0 bytes .../static/js/chunk-7637.8f5fb36e.js.map | Bin 44563 -> 0 bytes ...778.b17650df.js => chunk-7c6b.e63ae1da.js} | Bin 9756 -> 8606 bytes .../static/js/chunk-7c6b.e63ae1da.js.map | Bin 0 -> 28838 bytes .../adminfe/static/js/chunk-7e30.ec42e302.js | Bin 119434 -> 0 bytes .../static/js/chunk-7e30.ec42e302.js.map | Bin 403603 -> 0 bytes .../adminfe/static/js/chunk-970d.2457e066.js | Bin 26608 -> 0 bytes .../static/js/chunk-970d.2457e066.js.map | Bin 100000 -> 0 bytes .../adminfe/static/js/chunk-c5f4.304479e7.js | Bin 0 -> 23657 bytes .../static/js/chunk-c5f4.304479e7.js.map | Bin 0 -> 83935 bytes .../static/js/chunk-commons.5a106955.js | Bin 0 -> 9443 bytes .../static/js/chunk-commons.5a106955.js.map | Bin 0 -> 33718 bytes .../adminfe/static/js/chunk-d38a.a851004a.js | Bin 20205 -> 0 bytes .../static/js/chunk-d38a.a851004a.js.map | Bin 81345 -> 0 bytes .../adminfe/static/js/chunk-e404.554bc2e3.js | Bin 0 -> 19723 bytes .../static/js/chunk-e404.554bc2e3.js.map | Bin 0 -> 75596 bytes .../adminfe/static/js/chunk-e458.bb460d81.js | Bin 17199 -> 0 bytes .../static/js/chunk-e458.bb460d81.js.map | Bin 57478 -> 0 bytes .../adminfe/static/js/runtime.5bae86dc.js | Bin 0 -> 4229 bytes .../adminfe/static/js/runtime.5bae86dc.js.map | Bin 0 -> 17240 bytes .../adminfe/static/js/runtime.b08eb412.js | Bin 4032 -> 0 bytes .../adminfe/static/js/runtime.b08eb412.js.map | Bin 16879 -> 0 bytes 77 files changed, 1 insertion(+), 1 deletion(-) rename priv/static/adminfe/{app.796ca6d4.css => app.6684eb28.css} (100%) rename priv/static/adminfe/{chunk-0558.af0d89cd.css => chunk-070d.d2dd6533.css} (100%) create mode 100644 priv/static/adminfe/chunk-0cbc.60bba79b.css rename priv/static/adminfe/{chunk-6b68.0cc00484.css => chunk-143c.43ada4fc.css} (53%) create mode 100644 priv/static/adminfe/chunk-1609.408dae86.css create mode 100644 priv/static/adminfe/chunk-176e.5d7d957b.css delete mode 100644 priv/static/adminfe/chunk-22d2.813009b9.css create mode 100644 priv/static/adminfe/chunk-43ca.0de86b6d.css rename priv/static/adminfe/{chunk-0961.d3692214.css => chunk-4e7e.5afe1978.css} (100%) create mode 100644 priv/static/adminfe/chunk-5882.f65db7f2.css rename priv/static/adminfe/{chunk-6e81.0e80d020.css => chunk-6e81.ca3b222f.css} (100%) create mode 100644 priv/static/adminfe/chunk-7506.f01f6c2a.css delete mode 100644 priv/static/adminfe/chunk-7637.941c4edb.css rename priv/static/adminfe/{chunk-0778.d9e7180a.css => chunk-7c6b.d9e7180a.css} (100%) delete mode 100644 priv/static/adminfe/chunk-7e30.f2b9674a.css delete mode 100644 priv/static/adminfe/chunk-970d.f59cca8c.css rename priv/static/adminfe/{chunk-3384.d50ed383.css => chunk-c5f4.0827b1ce.css} (77%) create mode 100644 priv/static/adminfe/chunk-commons.7f6d2d11.css delete mode 100644 priv/static/adminfe/chunk-d38a.cabdc22e.css create mode 100644 priv/static/adminfe/chunk-e404.a56021ae.css delete mode 100644 priv/static/adminfe/chunk-e458.6c0703cb.css delete mode 100644 priv/static/adminfe/static/js/app.0146039c.js delete mode 100644 priv/static/adminfe/static/js/app.0146039c.js.map create mode 100644 priv/static/adminfe/static/js/app.3fcec8f6.js create mode 100644 priv/static/adminfe/static/js/app.3fcec8f6.js.map rename priv/static/adminfe/static/js/{chunk-0558.75954137.js => chunk-070d.7e10a520.js} (98%) rename priv/static/adminfe/static/js/{chunk-0558.75954137.js.map => chunk-070d.7e10a520.js.map} (99%) delete mode 100644 priv/static/adminfe/static/js/chunk-0778.b17650df.js.map create mode 100644 priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js create mode 100644 priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js.map create mode 100644 priv/static/adminfe/static/js/chunk-143c.fc1825bf.js create mode 100644 priv/static/adminfe/static/js/chunk-143c.fc1825bf.js.map create mode 100644 priv/static/adminfe/static/js/chunk-1609.98da6b01.js create mode 100644 priv/static/adminfe/static/js/chunk-1609.98da6b01.js.map create mode 100644 priv/static/adminfe/static/js/chunk-176e.c4995511.js create mode 100644 priv/static/adminfe/static/js/chunk-176e.c4995511.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js delete mode 100644 priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js delete mode 100644 priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js.map create mode 100644 priv/static/adminfe/static/js/chunk-43ca.3debeff7.js create mode 100644 priv/static/adminfe/static/js/chunk-43ca.3debeff7.js.map rename priv/static/adminfe/static/js/{chunk-0961.ef33e81b.js => chunk-4e7e.91b5e73a.js} (97%) rename priv/static/adminfe/static/js/{chunk-0961.ef33e81b.js.map => chunk-4e7e.91b5e73a.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-7f9e.c49aa694.js => chunk-5118.7c48ad58.js} (99%) rename priv/static/adminfe/static/js/{chunk-7f9e.c49aa694.js.map => chunk-5118.7c48ad58.js.map} (99%) create mode 100644 priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js create mode 100644 priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js delete mode 100644 priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js.map rename priv/static/adminfe/static/js/{chunk-6e81.3733ace2.js => chunk-6e81.6efb01f4.js} (97%) rename priv/static/adminfe/static/js/{chunk-6e81.3733ace2.js.map => chunk-6e81.6efb01f4.js.map} (98%) create mode 100644 priv/static/adminfe/static/js/chunk-7506.a3364e53.js create mode 100644 priv/static/adminfe/static/js/chunk-7506.a3364e53.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js delete mode 100644 priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js.map rename priv/static/adminfe/static/js/{chunk-0778.b17650df.js => chunk-7c6b.e63ae1da.js} (83%) create mode 100644 priv/static/adminfe/static/js/chunk-7c6b.e63ae1da.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-7e30.ec42e302.js delete mode 100644 priv/static/adminfe/static/js/chunk-7e30.ec42e302.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-970d.2457e066.js delete mode 100644 priv/static/adminfe/static/js/chunk-970d.2457e066.js.map create mode 100644 priv/static/adminfe/static/js/chunk-c5f4.304479e7.js create mode 100644 priv/static/adminfe/static/js/chunk-c5f4.304479e7.js.map create mode 100644 priv/static/adminfe/static/js/chunk-commons.5a106955.js create mode 100644 priv/static/adminfe/static/js/chunk-commons.5a106955.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-d38a.a851004a.js delete mode 100644 priv/static/adminfe/static/js/chunk-d38a.a851004a.js.map create mode 100644 priv/static/adminfe/static/js/chunk-e404.554bc2e3.js create mode 100644 priv/static/adminfe/static/js/chunk-e404.554bc2e3.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-e458.bb460d81.js delete mode 100644 priv/static/adminfe/static/js/chunk-e458.bb460d81.js.map create mode 100644 priv/static/adminfe/static/js/runtime.5bae86dc.js create mode 100644 priv/static/adminfe/static/js/runtime.5bae86dc.js.map delete mode 100644 priv/static/adminfe/static/js/runtime.b08eb412.js delete mode 100644 priv/static/adminfe/static/js/runtime.b08eb412.js.map diff --git a/priv/static/adminfe/app.796ca6d4.css b/priv/static/adminfe/app.6684eb28.css similarity index 100% rename from priv/static/adminfe/app.796ca6d4.css rename to priv/static/adminfe/app.6684eb28.css diff --git a/priv/static/adminfe/chunk-0558.af0d89cd.css b/priv/static/adminfe/chunk-070d.d2dd6533.css similarity index 100% rename from priv/static/adminfe/chunk-0558.af0d89cd.css rename to priv/static/adminfe/chunk-070d.d2dd6533.css diff --git a/priv/static/adminfe/chunk-0cbc.60bba79b.css b/priv/static/adminfe/chunk-0cbc.60bba79b.css new file mode 100644 index 0000000000000000000000000000000000000000..c6280f7efeb53788f7af1a2631e7ad0b4e536078 GIT binary patch literal 3385 zcmd5<e{0(?5dA8KG1y=VPMWTn3}fG8jH1YATZ=6jNp6ybe)pYzII&~5w6u(ozarmR zcTexVlNSbR;Fc5u>ilQUEF%x(Zd0%uzFPk}?L}@YdUKn$hsPfc<bowi*2lzf4ImRH z^W;;*4kW~_HyHQ%w0OxomudPjYnU#DBwJ;zYUnL)^0<JS;5M}?@9(!NKb~5a=R%fr z<!V^qzRUt)oU2Y^*-AG=SZGe;S3Mt$<=RShmV~6M-1HTl#&S9XI#wMa+uVM9WwFop z{kUjU4%)Tu_u6)dm^@iHSi)*iN?Jo<v%zs?J#I^n+gv)~N?Il)=u<9CTeBl2d)V%T z#S=MX=e5R=qK3n8&Hc{?!K7t;M}K$5isG1&m@!_BPyMbSEj5?}WD9nWRC)#GbFD<w z9jTB)j9a0kA+JWMPkbXV6^OF3^yX{(z95Yd568|CNzfO>N2~simxrM-Ffg8`$r9C; z1a3^aPP_=UYIC)hy*eDh=q2F+h-Kx8t6FLLVV!=1qR0v)6tcp1f0w4=BtoQrr$Ar{ zuYm8a6KlmR)h;ob0=>ifQvA6>gDb>>NG;@!R_XzH%R6mw|5gbP!>F2T{e5>``3l;) zGksfrPj5YomKn2GIwu+oSYp)<`tA`htYqyfnTr~0{}xHO2=6!tyPAVsbKnYvj0B~f zL`~xpcw9JD8&UzomG{);h!1C7j#g(_@~+B`y`43cRLcmyr_=e6vnZRjH>?22CCMG0 zW!oC=@3q>`9)FMGAgN_IUp(0TmMh2mG#fZ)P{vHMhOJWQg~4mb%0WJi95!0-a_!kV zDc%6nyoxU6$7xnfC{G3wo4lbAH46Q$)cF6MDad`&zFWZNapp(oC^Z~|-x%=+=xT%7 zeEALKtjmGa$0GIv{JM+_bJ5$bA6}20FpT|3dOG&Z)whperG*=-Xp2^GR*!~7E;Qcq zoaacqF}a8Rh`u~Q^XwXlyo3Wn(Z|Qt3;R)>33~HP|Gm93jS3}xcKCtd4`{iX%^3+} zcqjB)m_zwpg|NY@MnL^L5a$7OGd14g5a{zAOh`8-$hKB|7YAg2YsR58ox2lmdiN}Y ziI#-qOqQU(OnG?~XFATonEnzT9Lh;;nmO;5Gl8MD$*7AAb7SA#GLk0<<FcPM0>`r# iIKd`e;<rltg28W){tL#fp01?)1;1zp{=NQ&m(0I>^6`rR literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/chunk-6b68.0cc00484.css b/priv/static/adminfe/chunk-143c.43ada4fc.css similarity index 53% rename from priv/static/adminfe/chunk-6b68.0cc00484.css rename to priv/static/adminfe/chunk-143c.43ada4fc.css index 7061b3d03b3023484f1736d388aa22e32dbd6e25..b580e0699600bda09e49226c2e94d2b90f6f3a39 100644 GIT binary patch delta 99 zcmdnOx`lOuIEPV+WtwraY3f9IA2@q*K4bI5Z=wjkFrzYpW5=k8;N&x2M{xX@)DWB+ GCJO+?t{o%* delta 99 zcmdnOx`lOuIES%?sezfHQPM<tA2@q*K4bI5Z=wjkFrzYpW5=k8;N&x2M{xX@)DWB+ GCJO-hNEq$_ diff --git a/priv/static/adminfe/chunk-1609.408dae86.css b/priv/static/adminfe/chunk-1609.408dae86.css new file mode 100644 index 0000000000000000000000000000000000000000..483d885451de29c8701c41702b0f1cf748911c44 GIT binary patch literal 1381 zcmcgr%}&EG41SefkU%0;I#x8L7v2MLNE@#WOPwmo*v8a%$NkwD>lkq1(u3>R-=AY= z*8v<e8dv)O+rShQ*2$nP3bY7qTw4W0ff-w}!eWpb!96P(Bzt3zJWm&Cv3%Lr(v=zo zkCL?6wpH?dM;n7-LDxWg;oG(ujfv*nheteXq*5A7!6%Ri2(U&XnCYmER-qCPDJMkd zR4R}}?HVJtfqbO{%k7p)qe~P8AnBE{+OwsBvufYMbb#6)8b?hM+D|NcVg3x`{`>{X zw`-`C_KlG{ae_WO)<)pO=veEBWGBs}Ww8>kYajG-_eu3jb^P1IAW4L$zrb<`8vvXr z?LoSt`kOmzPu_SiP=x8jW7K#wBWbfQHap0zCDYa+Se(8+1gY~RI}92pBwgIvc))v2 z<Nj8LNH|?qp^Y0!;qMP;y7HuZ2vGY}gVHjxXm*h=9Elc5RMBlMw`?FYUl6{#{iQQn z(hV}N-$Bgg7jpgcJtUmpOicQ%;Jt1G#x$Ldx$gCx7XO0$f+s|NltklElCHWxKZl?B KUc&i*bUy&S*X?-# literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/chunk-176e.5d7d957b.css b/priv/static/adminfe/chunk-176e.5d7d957b.css new file mode 100644 index 0000000000000000000000000000000000000000..0bedf3773dbe8635e14154ad8935705271db3465 GIT binary patch literal 2163 zcmcIl?QYvJ415&>#ef!=45v+lO$LU&iY(Ew7FjYRx^)rc-A8}La#I9ZhyD~rQpe+w zN&^aP0|X|WMWHSDV<VK(cF(h9^W1c&^wK4%Hpg1{y|#RlHC=MWbBd1APk~t<MZr=2 z3WNf`xF#tWR<wxDraG|35I$Zy*@o{6sGEW+_d<mg&X4(x^ZZUpRl+WpZaD3--Q$i< ztq*jmpcJjaQz|Qvhr)FWsHt#PIKox)k)l!?jFBIe4g~j&vsWk%8p*rM&$c?S(m)s6 z`g2}^-dD(<c1@Q@fvgeo!2h%Xb@`UFDSB<4ZU>lPkq3$aRsg;N*i|xN^xkqwPT-eh zE9-Dz8leue<J7dr$Ee#L3g@V_p+RTW^gYez9_O!GVZ}GwQAl7wBGZ->WhETP%cq}I z?}ZAkbog`R#)Ph_JrlaJe!xMT;eT5a)!PV~BZ1o4g+rn%$#R4aS&ZuRy9T92;;eZ~ zg7nneL|B#F*P>%1o7*46|HJVH;SRI+wZe5?_hO{kLoJ0_=}>7;VxX$Ib-wN|XJIcl zG@EU0^L{wkg>|)nh|f`6r3RxWWo@$T+a$$#T(9V`_m0}~ey<$k_J3(%@k3+xX<j@o z1jWW!rvp!e@0B{DrONi+wM|6UZDET{NkYjkB~GU~f4<7##mm2z{mx7612S1@>fW@c z#|_I2l*qhLx(z(L4dGZ=oZdRTkKcG8)se-B`O$~8@%lv|EzNPCO(4)U%=c?~hOqr$ ySuMZA)f#UB(H2I_cNjh(f>7iOLVj14<!7l`IGBmQs5$$mx7Po{YD|;tbovW^Y%UxC literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/chunk-22d2.813009b9.css b/priv/static/adminfe/chunk-22d2.813009b9.css deleted file mode 100644 index f0a98583e59dd947126cbeb683627f927da831df..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6282 zcmd5=?QWwu6nzz>)kwS2I3j7ACgjh)$7)p>a1u`eBV#7XM0xkU*B@XoNz-<#U5(nA zF}~)0opX<wHHxaXAsLnVxukN(E0PMOM48Ov`goan&!ZX5GSOD*tk_YJoMl2%#YL60 zRnBC=E4IO3WY73+uad>GK5n+6Ql#O(STcVI&f_KBvGalFYM(6PZ=<*Arx7oAcE>yp zwlsTjAf=Fuq%AmIxeyArRmoHEJ{yb+Bj-(B&{IMVEPdq)0zYoLwXz}Gf*t)e{Qt(= zOlZM(Rg$qvF}WeuVz{ce5UyC+*o~toolifhqLN^=&IMlSY^H_CUN<I5bBJkU)2sh& zOD1)~-wK#iv}DuAn{$%b9llsxOI|ASQ+I}!jQ3?uNDf&`eln?eMvFmKb1qq?`>GPM z#D^}k{gX@-ZCP!wB4oTeo#1TGvRA04YY~&O=WAhR(6`{>+3R1-{kXrU-<7k1DVAGf zbt+iG6?8(jvGr1-PC_`?R@gHNaQb|XYnr_#KiWp|?Wxb<VAVb)Hrk|tr+_7<4vbaP zu^qVDi&i<4FviQDCCfRDL{*%kCX;YdM5{ddy`)EC+W2t=-15hH#tKrhra=-Yfq2Zr z6{5U<f~d3vpq~r~3AQ0~y}a0*)ilp}wbR@c&BNltenoe-1+W6qBmCgs@pQD7Ds;3I zxJ|nDu_So|a@fJmkeBouuf9nee6Z{eAqPq81b-Q5bpM@>?%(RPFGcfZRtmraRgAO^ zlP;rr1{nfIwzmZN@C2Q;@!Me9+$*S~+YfWu08t%R4OVaC?0&#ZX2OmJoQ_atmbH?r z6KR-I00kKBz+|R%KN9K96QhErtZ?J`>6@D#4>0+s48eU`L<nvmh(hdMUi37v<s!(6 zR&@kX^rl-F3VNV^p9t1&t7+35gv?<U!-N@O76<1I?>bvv)$Q08!5jG2v}uX*!AOv8 zA*f39cyUzdQQs;y)EA!8M`)5`)4IM=)_%K=QD;^E>*Xb9K)bv~J~`_wq>|CsME@qZ z;HxrLPK9Jo*SSWdb+lyEh&RS&izms-4Gd%<li$|y`eD7k7-IP=2TenMlKe5vp7S{V z`|M$CD3dza3?x-3o_dw>9?fRe4T_p5cI-g3NYu?>+>Q40&|@czq8R?@=2SE-*wu}= z#q7l{(|e;C>Mp=x5n4G}2UeFEH5$<Fwx|tlJ_6%`z#8QE-puQntm7dtk|i*5;8{Yq zAcVgozU{`H6m9+HzHmdzp!3){1qFjgR5@l3`b$Y#fku&_%q}y1KL~6sZ1i=ayFpyr zWv&$12ykgZ)z!0QX!0L5um9S?Q0Xc+SiL=JW|bgDL9r%`(85{+W3hrkYEiw6my6}{ zGOI;VMD0Wy7rcR69tvHePBlv^0e3++g=06HQP@eA3gNgi^uXC5jGS0DIMoP&ivg6r zi{MtZK8=#E6g4&@%V(XDVNBP!)8pLfA!!Ftm{iYlfqC@=gT3ERHG?Zf9H9|%8Vm}& zZ`YCoQB6>wId_2*ZZno={>5nt!rT?tT*L4U!_&JOUk#`4QX8L@<mRkVr@Vd2Wv10- z4VMwi1)<HSduo8x58$`QRD^>IilPhZQ^W_!eH{GcFm&;oFB2|fQfxT@A1`8I%1mt% z>!|6FhqzOU&hstkCwmEXA`-sq{2~+U-qP3ur*uO%oagR+{%0Lm2Y(GeER0USJ@)|| zCWST|aLo+8=#qiAPW*|TLcHU$e5X1Xg26DwonU}(aNv_xFh(Pf>~7I*&nXt!P;yIC z9%sCXYHOw2+GX?>B0G|DZv|>G(9rlW9*ldtkHg$Wq^NUosKR=gpw@7)=_+TAMeO-! z-v<G-u;dG8y6`;PNKGKfIDubG#unGx-D`95m2>QJNB^$kIehG~pMbirJdZ0}J%b(? zOuw)OHuJ;Q#91`5?28(nVe9zp)zM4|f1bUoH_?G{TZQkv-kmxe<HLY9?k7=KVvi1O TZw16@!uK{h-(Bnd9~$W&pg0gB diff --git a/priv/static/adminfe/chunk-43ca.0de86b6d.css b/priv/static/adminfe/chunk-43ca.0de86b6d.css new file mode 100644 index 0000000000000000000000000000000000000000..817a6be44d8f4860932111a76a2f9146fa1a40df GIT binary patch literal 23710 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 literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/chunk-0961.d3692214.css b/priv/static/adminfe/chunk-4e7e.5afe1978.css similarity index 100% rename from priv/static/adminfe/chunk-0961.d3692214.css rename to priv/static/adminfe/chunk-4e7e.5afe1978.css diff --git a/priv/static/adminfe/chunk-5882.f65db7f2.css b/priv/static/adminfe/chunk-5882.f65db7f2.css new file mode 100644 index 0000000000000000000000000000000000000000..b5e2a00b019f8d43b76b6ca72efb98bbaef0b1f1 GIT binary patch literal 4401 zcmc&%?T*_v5PTH@MSvCv1=*ML+0viBi9uOwo3SJc6n(ZM=-s=^4~dc;<JxOl1UcMM z{My->*=1bG0u<N4&MhdGpS6~Cr{Ss@FU6rwOW5n|A_{#>UtW{unl-#AM14pX_(vZ< zTF!Q|)?Cz}x<a%~$!{sUKz<UM<?@;hV^z!cC0xTX`a26Ivxe_Z>GyN1#r~Etw_-Z# zzM4~^)@h4tfaRbs0CoRo1%=>|tjk-}?i3D1ye^{OD}H4*tZ!?K<d5!QD4m_MNRlso zAGfeOE1_?!1+5W-_LGr5-7zY84yC`q>zC{Z;&9aI%ZhNnzY1o8mr)EQ!+1-1*lu%K z+&Y&ore)(&_H5~1s)`8>)z_b&(D1gsNL3&~K<TdH>L6;&EHbA`7q3rmH>?$PbDpnF z9);DO^NNULiMcN&*Qp{Pd}f18t`nQdXAxR(9ITt4q;<s7#Ar}8ziqb#Ad5t!1<J&r zkSpM))cj;LknNHEtQ9{XT|o6HB)7Z4nv^!EBroOelsP~tVe#@+L6z-fDOLL0CfU4f zHvJJeO12ACVx_u6_JbN#G8r|0nAEnZn>YyZ?}*dhE?q90Ym|61VB``u_x5(tap8!O zCK3HJZlQTzf*+?5`-v!HK1FfOD?m|=v^OTh-?`?BxsU&vw@mOEyk#<ak6Ir;yp5rV zK;t8!F+<R9B*GGtSfU}aK*K((t7NrYt@>Eh?|53#*<>FS+<FqT0fwQx6ed08d(80H zVEmxuxtR~AIUu>9%h5b(LR}E1NFl9wjrORJD0pDS58#BbL?PILNK^-JI^=r(Uba4H zeh9)^e8YNR9a7XsKx_0S#m`!1K^1X~GlID&-h_I&9MN!?aqYB5|KVjH6Q2+)B6{Gl z{}biIV?SMw;##s+bB*P;_1<aJvs@#XPU)T#gIy}Xl7rMbLGATpRD}uX<43bx5pbD& zr9?KeETePTm7+y|(zg<rzBPcD*WiVmCN7^Zv_e5H<>0+x;m@2&S=}3vDxf3}u?1=S zM?MpRE|s9d{*L8F?07k85DI|@lN&qkjP^HvK~&FGol$dvxcK>Q)be-rX5yWO;G8xv zWZU%EYz(lYvQM^UDqttEnWEdE=s=O??<;^WX2HHogwP>xdX0FXh(|q%Xbu@BZg`Xz z)+m9tI0~3{2oy(|nRw3X4JWuGjX$Nd;dHYhd-u$AD_xnlOJ^y+Thzy<scg_^oS27F z7o_*UZ3rC%wt!K$5}8tF5-?yrVc4V~e7sD|L#LAHl^|-%3_C~3y+ASUxGEfkSTxOr zEH_b$-d8<rdu)lQ%SKrYpDOu-Js1y$d_MevpK8-*n@wuFcM(?=?xr5bIMKl(`k(lI z?gO3r!wT%#=NnN&ZYrRn0#kdbt=(>^`ZU-+`M%gX7<%tVe02SK?-D)~N|<SLBHIJc z#1Z)6_@>CLs?wRJ=Q2b(m~P3c&RRpTpWnnbS$Ec9yAjj;yYp<jW@4VR)Z>DMz-^1R zVIp!r46O52<7MmvM9HR*7i^HtyK#e$m+_7yIiox4rB5V46PzjH7Bdo&S|VMzBj^b? zpD?i7iv}N>>{phO{sR<VCkuRRx+f?;n=C@#)->8-!JQPK&Y?D?7FB@R#1VfF`C9<F xqfJmWo)L_;pXc`pE6m;O2~Sc<6I?2NHUeMrU*-;G!3ie%o81|h-}Kj~_%BC?aAN=f literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/chunk-6e81.0e80d020.css b/priv/static/adminfe/chunk-6e81.ca3b222f.css similarity index 100% rename from priv/static/adminfe/chunk-6e81.0e80d020.css rename to priv/static/adminfe/chunk-6e81.ca3b222f.css diff --git a/priv/static/adminfe/chunk-7506.f01f6c2a.css b/priv/static/adminfe/chunk-7506.f01f6c2a.css new file mode 100644 index 0000000000000000000000000000000000000000..93d3eac8477746d92e2cc33a728fe2134345968d GIT binary patch literal 3290 zcmc&$+is&U5d9UTRi$02MkH<1ta;f#i5!?D-hhpa(<DXw`;Kp1653R4rF{c?uxHMk zbB4H-IVh%q9BNQxeb8EpM#D)HR`9K$1#EPB<hkC_ho_`Er4`F_F1B=rFKs;aImx8Z zj0;dr&g-gRXG)H+-g8aX@|1RKWlc5(oQ7-WJskv-ie-EH_fTuTIj6*bF<$k%_Er#@ z*7yyOHRvONX#bRua~4TaoTEBZxD+vwM?Xt;BCf2*C06pQnJlGoA+sd8YulK;FwGPZ ztc35Z(E*)d?x6Y_N2~UQa1CWmUEmwpL@^YEV*`km6;XV<)6E9kL2CXAbPi=&U}MM* z%np56U_&!mNJW1vlEr+n7$3U>hV1xU7yT8FhBL#Cj_T?<I$E8omWvl$&KsBQR>8TA zg(S6R8kt)+B~x22h@mT;FR#n0SY02b%87y+G@`Qy;Xk5Dlti;fL+7a|g8V7u)>GjL znGh>jONnfDY8fgb9p-`Pnpo4YF43Miq~6Kn=dc~(DHS)Ye*vX=#)=WvImg>Imx2nZ zO0yXh)bAjmhf<_Qd01(PcdNIuw`|kA1WPJI&-)woMwk2J##v8!AF(JlxRFY6R3mh_ zUY!#<v6g6QC7t`DP4l}hW7>OXb~$7cZO}k<5XgB`5LPb_s7W#zCZvgQ{Og<Q(?Cki zo~je_%H7-1G;qaj;C|A^)_E(oHM0D?-iDm`s%v`e=zNYf*h1`+sipJ(WW=<Qemj<p zR;N^bS$edab&BA^G$Ev7G>V{_F1~5$sDJO*%Q?y!J>9p*GNzwOz4y;-V7P-Fv~1r# z{%T@O&jarOo_B6cqA8DLvRWM#t19$XPY7pRGc>L?HXi1wFd){jzav~figrJE|JyMB zfWI!EtiO&tGl<IY&r-m0@|HObW_M`tfJB!*)FW+u`MaGBW&RagGk#)<w+P@_!FHI+ zZKfGMtX6B8p97(&k!>H?{gF9#`t!m*&d<O@`^#W|?mTFYncbaGUqjQg*>k@?WWwD# z3k+R(4klw9)9J<6H!#J%KZCx8{L#@n_v{00qPdb)E|0>06;!n0ML|%%ykP4BQfC{a zl&@EdwN{|BT}ak9!lL^Dt5_y8G$Q}Y+4<As(yTT&DB)B7{e0J7?#9V2DqY%5GyE$- xPrpsZiyQm_c8c~2G{fs6ut(p^XmVKJzdOS($$mK`0#|7gaOoIi^<gn;{{iOp&eQ+^ literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/chunk-7637.941c4edb.css b/priv/static/adminfe/chunk-7637.941c4edb.css deleted file mode 100644 index be1d183a984c8bffa271e431edaf059001594512..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1347 zcmcgr&2ED*41N_ZlQwBVp#`ae%id$!!GH@{LxM>9w~BY4p_H}I()O|o2V*<mpWQ;I zSb-H<F{v|_Xk|ettUgn)z&6a|^EigZ{OeFal?fGc#|z3`oqM-CY8NH5qCiQTZnn*6 zjpH{T?(nFDG!-ffdF>7$5fEf0D3s$>Tans4oCll`4d-$MqBg~dZ82U6%VND_AVsFa z1eL`qVufK#iGym}!i2@boEke#JbIs4w8A_M<9#@xU^9g}6{eK156{p?eXRpdjQUzb zBsyy*Et6IFy*5@X_kUC`s@>lf21ybc`im?^yG2wZr42%r<j=XYHsp;#iHRK#_EF=` zjHHb&Y_2fYnoL_;tJQe+6r{$J=(MQY;5Bip-6P&9jV<nM7l}$_xvkw2DE#f6jGsK~ zJ_e}%C@>Y^XeIZKNh%V}0hKzh1?<?7jK3g!Z-;AVw4@tkM*Ltn^RMLk{ylh{zl=<J qRPa`}BgP;YubKARr-vUPzu+E`?<5hbm!zp4hUajf?=_tNNB0*S(B8HH diff --git a/priv/static/adminfe/chunk-0778.d9e7180a.css b/priv/static/adminfe/chunk-7c6b.d9e7180a.css similarity index 100% rename from priv/static/adminfe/chunk-0778.d9e7180a.css rename to priv/static/adminfe/chunk-7c6b.d9e7180a.css diff --git a/priv/static/adminfe/chunk-7e30.f2b9674a.css b/priv/static/adminfe/chunk-7e30.f2b9674a.css deleted file mode 100644 index a4a56712e4f1769009fee52ac1921b98d2fd293b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23982 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 NwFH9<Bki|G{|5jM5}yD7 diff --git a/priv/static/adminfe/chunk-970d.f59cca8c.css b/priv/static/adminfe/chunk-970d.f59cca8c.css deleted file mode 100644 index 15511f12f3b51b534a28c41450eb357386b270d7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6173 zcmdT|+iv4F5d9SaivqjA(vY1vO{JIqN3mGY5^b}QM1i8?xQ73|GeeH<&N{nEfg(s8 z^D^Xc&YU@<JUjK`M6NPprPHQL4ppvfp(}M+RK_W>R}vb|;;1%H+KG*M5wg&`D%r?J z;kZ<kjo21y(oVyKcB*W;!F#25dzakD^-EYlX3p4iuA91$r=+(|+O~xSVC}XI7fgGD z!=^G-Ic3LN3a$MO`KJCBL?uggE~VY+Dv8su(bcNk=(BXv3SI8bN1eNUvRv0MJdXbN zlZ7Q0p);7@nEdqX4jQY$x#U?{r!!d$oX@pY8HKMhw!}`3N!OE^;!sv8MQYp{HkwJB zhZBH6J7-Grk$VlCZRO5e_!N!)NA)`cOxlojaA`xvdwMmDwalK9zYmSm+f(Popf&W0 z%<m^@pjU~Faz~}A%b%snwTw(voT4VP&=kokk3N_3MfkAYKLYr^oENGP=<xUNVehrf zb6xF{C3Hv2-`nto?f?j!Jb(>uLqZ<d(aCl3f3LIoPy}2Sjg#)s(3y-}!4nzuM+=!{ z<^W*0N*^h51F#3>!s-DsrH7lli9KD)oeFuiihmoeM!iul^Ww>nW^az7G!~F>0OVHe zLZP-Up;+|%i`ZFq<Ck&kIq}W(GrOmbXLwt>qSM?ICVNV6CZAd3K5m}uX3ftt;GC`h z1Fy~PJKL-530R0B;`y2qes=gQ?@jPMf^4vqt3o-I2hwz>LM7S(Sh6$}3uo$Nnhs(1 zdojm=KbeQcp!IcD<$OWHY?J4x-QFA=6Y<o?w~=wFng&Ur)Ky^WN6d1d>Cl7XBTxkO z41#q7n~%pkxeE}$2t@bri=Xj)Bm-5Dw^#XoutfL*0tbXuGvp;&@#;!6;U^^nSN@}w zb%K9<$#1{Td-q;Q``{YIe0{a-To$sUPu5DTSV_|SfIqu>k?UuT(l-zyDm=tSt}vjm z2xK*ADr1wKRVTu!mlH&bt(YKM8G~~JDraY*faU-vH1TOG@h5Gkp$1t|*~Pi0C}iEJ z^Tt@%5o@!`pi1xclh|(c3`^}%?FE?6O$uu29CpBK@b)$I3u!GBS)$7!cft~6lSXBQ zfvpZa!K3rC3S3IFgKfa)+GrH(m;F5-ajUw$`m(Av)<p~d8EzuI{NwV~y9WHXVsoIu zy#jcLTaoVLD(#gCI%O0BfdrSJg82Rsryd*00?)cJh0dc-YNOP49ZZl~M*vsqDDY|* z)qN=su<#1)=q1}a9z>%xLJS$T>UeN5Z1yrY$0Uy6(iBm;M0U54pX0CivsiwCO}>a9 zj|x8X6@=2DGPLPtT4mCH76z;!PM_4t5AJ!?)1Z_GVYt(-=<a?OMjTy$w{W9kfO-zV zLktsZ(>U4}kMLf{DG`*11mz3QXrSuC1(WXDJZD9d`cwFJ>Q`xm(<R-tdBz>m!#z%; zKg&NLc${!;QBh48E>L6O)`9vDn-XvFULtb>d0iQJ4niBnbpMIu!I{ef7l%?-*1US` zfztHVOfLqv-@v?kADG}DS&atCPf`jF$-2>{)whks6<xx^@b0D@%GAh=2a#{2|HG3? z&qiQ+A1*#rppg(!;0=gq3)=R)hr;A*z;xR^l1Fhw{KMucWqM>h%vs`xYv{$gM&+PO z3I=$D<&3|Ca)!#KhC0lZi7GVJM!+-3>&%I*Rz?0-4(~JKS*R?#d)Th!w?LD3Yb21{ zQF)6^qTZp)m|r)n@)$4S^&z}Tv`Y8>5nInU`LhO9+?`ze{D5)W7{kfUr?Hg}@pAnT zf8+C`_l-0_BW5*(JaWY-7GZed(*+dkF5px9!6b1c(GtF$MfjDuiA6;VMR;xC*As@_ z4J$EHXJ>mM+u5D&lUeVlWrNSiH{SK9bnz>%Tp?F~@&q_il!sRWzh6#IjzKdE6e!S~ z@Zr8THPBUw06;;>jP+~QrZ)(|gr}xgt#x&M_4uY_=Ph?Qe8q7rjb6rHIkX?8ARi#m fF<2m5Hzwe<Gw3-^FV$0jTBzKd!BG8?F)#lDh1maY diff --git a/priv/static/adminfe/chunk-3384.d50ed383.css b/priv/static/adminfe/chunk-c5f4.0827b1ce.css similarity index 77% rename from priv/static/adminfe/chunk-3384.d50ed383.css rename to priv/static/adminfe/chunk-c5f4.0827b1ce.css index 70ae2a26b301dbfb2f28404a8271ed37d27c5479..eb59ca31ada433142cf0adb48ad621a23755b071 100644 GIT binary patch delta 77 zcmaE=y;Ns|oS?XMd1gvUhLy2_s&#H+g)W3=Zm?Ne(3FMIdh$Y1XF0vh+=Bd~65WEt Y<ZRuf(vp(=ylRL#O9R!~$vvWx0O_e3EdT%j delta 22 ecmZ3g^Hh6-oZw~)K_iyQ3ZmAGI+M?cMgjm<i3ach diff --git a/priv/static/adminfe/chunk-commons.7f6d2d11.css b/priv/static/adminfe/chunk-commons.7f6d2d11.css new file mode 100644 index 0000000000000000000000000000000000000000..42f5e0ee93739a360ab3f0234eeb2e6f9d19dae3 GIT binary patch literal 2495 zcmcImVQ<<n5dABWrb(MLqtLCia=z@JRK`9MkJyfEr@#>ZedpLA2?VOD-4_IB`+R=) z-a8h~Gv7MGnH5LHY$-Ka8}E&x%e>haytU3)+8C)l*wbRPJzFr&O{@K}mKsO}vaCFP zSmCLS(VjT@7wGb5NMdTt@nw~-9#*UQ9JDg8V2>X5o(SN^GB1s$trlQI`ioNn829p( zc@W2#y|i#BCWSoX&6v|m&T7JHS!$}J5cOp)saOfeofN*J5Bb-No4VCZsxs|Z=2u&+ zX%QQOmv65gpv_h7t};8Kj0Li85oq1RUc<)I$7X*~KtjC7T1eN_>_Ev5)?4YZS^MG~ zQ;uwE*pFl3cQM+Wkl+ydS&(#M8tuR$(t~m|ZoE+wHh@_Y!0Ahny6eHZHhfz|Oqckk zBC(_Uo@iz}zP$BdqM3sE^f3?3$8-yKb?n<kIzHz4eC=x^4(GHZeBKA9u@Zr#J<TRS z3uysKiY(T~Dpn6*4=c#sR_P)%iCjkK>kdLitufTa$IFkZ_!KVi3iuX@*Rwfx_vsd$ zIXVO)cN{rIXx&7>=p;8Vun?3_*yQ|MPLqTrpoU-@@n(Ohfl7}+@FMZjn}%Yp`Kvd^ zpl*IleK2MItgbYSZ|R@bdAT_Zq?tt1DoHR>hpXr(;CBFe!op8%v1g?_b1n*f9;BW0 z6?$S{-8Iyw-wK3enbGwjbKGLl8Pg*Brr4fD*ZB2}<LCQv0X0$JT(D?`vneh<2M-=r zf(a&j?p7v43bzH#yu76LG)xGo%Wk}7%WHz-etc-6aUeo=!$FcGcdukPqHIt7G(Yb+ zTW|$8wg3A!bq7WhXmF`_je*|nca}9Yd_?CNevXWgObB-#6qD#(8qRYg3aE!FXf6ED pxVQJN*v19_Pcxp;6tc|vL7!1*G1z@i1GMw@E<FFp54gtZ^bfg&ubBV< literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/chunk-d38a.cabdc22e.css b/priv/static/adminfe/chunk-d38a.cabdc22e.css deleted file mode 100644 index 4a2bf472b1fdbbd2da544df3686ccf35964f5ad1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3332 zcmd5<e{0(?5dA8KQP`jgPMfr;3}fG8jH1ZrSc@$gNp6ybeD|GxIEiDoG_;J-KT2es zbocb$J85E|0&Ynmpvr&d%rf#qW=sC`uy{y+?KdK~CB2`g&Gzu4hFq{j$!eDvt^s7i zWS)Gj*_MQPvjxukwx7Ib&1st6WHr+(A<0r%t7<yOO%4;N2yRoU)${XG<-2{u@?6Lj zy>mHCa9?JDFwRvgF>R%5A}rMV{!(vG#&m6|T1!IEMQnOSr7@k>fR0&5$QJXPE0cY* z?!%;3IcQh9-)q?-VsdX`YYD5wO414no1Gn3*5NkwxQ&GquB2r`g5KxCG!@%XvVrAV zSUiz!b{wk>DJs~WkGbF3*<{i%zNWuhV@0vcNQ~%@#;<-?kd_*Z0kQ<UK`LE=`B*6t zbw?_s5aU)TX~@+mb%7rQrUbDnExmteJ{P1O;^EjiA_@A0cyHAI@bWM;P7I8vX);B% zC4n1*suM3lt(shIWTy^CFnS7j0b<!|&sC)~{W43RpeV8e35Bfi)jy_bIEgURuPG2% z!b{-m%fL!8OSKD(ra&JtzZCymqQMnnLZlLMO-uEHyydMnxPPOBhoP6vrT)IVs(c1b z)tas>zo)mJiBpy#B`{RlXKJH&CP!`bMn_KSQ`I$Tm@ylrbE3h3C04DWJ6R+HqC9gb zkIm})+8hNo32PiDyYmQElml02QYb#!N<<OAz#-y#)KpSVBlw<9$3xDdY-ZlD!eN|( zFgI)!e(f&$&QUnc&P*_ne7GZKyi^KpE!bvX1XzC0ETfA~XP$V2JPa_+%V;>hGiJqr zz2r<SgB1!<p=LkIu>ap=f^rAty8$d7+jlg9m4;og4kP{my;~p^-+$RS((sJgea`w` ze3{0HglJQjA1^yd=&QcFygv8H)sJ^mrG@K|=x|nWR`q;87aA{YPGS--Kkj4KZBJj& z2isaAFJX&Nbo1lriTx<F1Z{St{>~|x)@nn18}Q!l4`_Nf8q*WT`Ss3gVGQL@6~Y3u z>H+odKpY3qwa0iNL!gh>DIuLJl4Yg%I`+Em0*oF!Y@q{g;@RNRu9@LKLUJZopl^q~ zyqITN&cT@OJ{=s&No^Q8?v^7HLvDkP85!m}xVtbUuMoyXzi9-HXYX)=MLNZ2M|_On YbE12UxY7NElyC8Ia^~N&C%iBI1vz}&N&o-= diff --git a/priv/static/adminfe/chunk-e404.a56021ae.css b/priv/static/adminfe/chunk-e404.a56021ae.css new file mode 100644 index 0000000000000000000000000000000000000000..7d8596ef656a60cf8fb093481d3a803eb8763750 GIT binary patch literal 5063 zcmd5=e{bV94E-tw4hP%;Rp6ve*4h8;dklwzY%7UY+cIRONoM%FkEBG~Nt!gb>kUJI zw$TrYBHw$YDm$kRP82FPRyu9!WLFo;mbzB`tTs-Gt&%X%iM`5Rv=f;*2wCb)on*3A zxGoh{E7qmD7^h`IJ5{yg;;qt~txKNb<`52$xihvabla5jm`u(|hp}(~oIQ-;f$3y% za4J`|Q}*1YFgkU}4^3SVwX9TENxRW?5~sn@#q;Rst8~%|U2VF(F5EVmFPj6eqrd%T zVd;u68O&#<IR5+vjn#0u<W*Uxb6K8TzR*_Xl)l>73MV-y<4kVKT~())sq@j`XfACL zA^?eY&Qx?qo;48L+C7}$Vhw|$)qkt$%7B?6e<OB={IP>KbK1!KCHZUDI=w!QS2`ID zvm*D-B`wS<ktw%Vsy>O4N9_*+gwu;KNa?P758!1S_kUEX&@wW0d5qfJ!hw;ji|9)w z55gnl=^1eTwVSC@U~IpC50PnPQRsS;%wam3|27~cOb6_(MhJP51bl_mjI$pV?_UYA zH*(Peo!ppLpCCUtr79tohWmc|biq&`KAGocSGx`s2{elsxL9%0JU}1eE3m-W<kKo% zEmo_37Pci@1H8JWg>oc6Gmv$Vh?2f73H_|luNnn32n*^&6vMEBlxi@liq8AAvFg~5 zNFmV}a6%9{M^Og!*jTuB0r015ttEcaVH?70K~jr*O<BsORb6H*c*G2^4Ql;lo+NvK zDhu{mET0gwmzx3&yWjzz!R~9A7t&g@_aez+;}sK*Ae*!*FAcbQauW=lXAE2^&3Cqi zR5nJV<o7c@4P+Xq;!Kl}S;Pg)b{v0ZUdO2qMlwU=ttoX8eNvfH>t!%Ox`r96)m~sl zjGAsDdJu<mU0~Y?%)tm?FN&jq>D{z+wOd)3eG*3tSVZ`U>?V_6;s^Z9=3l|dgLr?{ zsXSn$P`SzJHu&t0rAo8|dgXx%9(GnW#=2<ce}|$^qADS9wqq2K&9EqN=OJvmzWM)H z2mV#}(sO)RZKhd7nMxL;oHeG^z;Fd)q+r0*eX=+=F^Ta{hsg`*huq9JLlc|>EYER1 zTm|X;qK@y;f;C~%9t9*h?aHzB8thX4xrT-&$V8wbk}+(A7(2o@ukh@NQ|fAcJm@&y z(TYYwe@Cq@?^)2M=@xDd{VA<*x}sK>cRT_Oq9@!&Kjyzfa9F9(IkN*r5+FG*7bV53 ztv9R+0cFkYTGa;20`&?@L_ceHS%v-f4%)DK=pM7u<S%KcC7>ptDJV0M+&R<F&`+>f zHSB2YbJkv={rwG$clZ}l{6A|wsC)EH2KS(V3FtHZ*m*xWHXk3+z;J2s>EfV5<q8>v zQUw_u@IAyEdj(Us0Sy<5>*R(l_&P%+kIfUiCOUIkCa5I&rI$d*-hoQ)$RFlHgw>zF zgqC4%Wz#^lV5XO9?Goxg0MDv~CyH2WRTh61@RAa*Lgo46YQ2=-LS=qjVtu(EmG{`3 zW3@lMGV<$=Q=a2FH1DCM*Y(iS<7ju+eU|a(evBOUMpR8Pt(h@QZjQ!UuHyM}6@TOS z(eohq4lmNsVWQ+*F^V-D-r~AM`5BJ8v>(t$y(lTz0y__S!gA`u8)JZCJKAGZ-f<Em z_08S>)iT<>^z(x%;*Y-hrd9pqv3V*O{4hP2jFTtm%}l)?0nQZh@Jiyh^UKJEeQ&1^ zdO{{V)QeVb)TM#(O~T|MGGnQ`aV1D5Ji=~T8+!`+*UJ55XO{<DM>j5xHPJs6t_1Bp i3UUBJj==)ix|4v{VlcTlu<b?=@8A`yJyd^G%>ExoJ6O#C literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/chunk-e458.6c0703cb.css b/priv/static/adminfe/chunk-e458.6c0703cb.css deleted file mode 100644 index 6d2a5d996b7dc2a50052d7bba8b9e372522dfc28..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3863 zcmcInYmb{c5dAAiRi&;}M<lyVdyQ1}pHz-*g1cZNW18ef`R{l90u$JDv%UKv5B!`t zbLPyj>I^Y`hbYnP+z`DN677^Rs=@2LJziGHe6}K#s$QD2;YWiQr%Dqel*GMcT-QSK z!hWHOi+yGAX4@W%Qb~h4@h`{g$Ju$JA$xv42xcn0$v<A*=4La|?8Az6K3mdN>VO)h zIokDLw2Us)yfpaB?7rYkbc2bmt;q?a1K&Lh1CAeyVXWy;S@UB$W}k~>F(S3tOH8>m zTo)+pn2x$7NEqI9Vd0gR(Wc+T5RI(2(F7qKn!8%j=fZ=u2A_%mulpZDrXApaau`X{ z@a5zEIXI7pZ#FN1w^QsioRNTWA96|yKn?%q+6YSO393wJP93kL(v6*r$c{5q)qNui z8xeFpx(qO_IDLj_Mv8dIzP{up1>S;%=Y+o&`)htL-^F;%4QFAqIn^8s15BVrh?h|< zo~J{gLd6NB)8Av<68emP^_>yrX~b|c8fH=O7I!cUxx~$ZbGhu>L6}PQCT^Mgc=^$A zCP=1aeabqj;iQa6mVIi-5qTOveFJTIIInn(8s2qQ1R4+?Kl#mO`8Z><G6;a1nFtar zv&!}JXTYo_j0w4S+Lf*6!G-mP>;naC1hOai*x!8F+aMLrTLRjqBYlEM-bgvi;BKl* z?#35yavR<l_Lw0DO<HWfd}<7j%ijH8OncXCj;3>{QjnBB3Ng@mW@T_`Nmi|6oNXn` zZmb-N+;Du9L%S;}gJ6P(Y_ue^RQ1JYB{~H4L`YmJ+IOGBv9H}!DNw`nPH9MfIKNgJ ze_)iAd|QO4ZNA-X;i=ahOfp`QzBZRtRJPHmE0TJMN>M|ZN*2XBbC-yR;`3;0d$JKt zWMvTS$>>e7nH=_}x>|&w+0r#cTl1Y#P}zNV5Lshm8kgs>v_5$vfpk-Ygan<DG&U!X z|A4;<%RCEiMEH$qJT@ZJE@A+5g3P?Pn4mZTe>h<>dNU-6zb%2r(Di!f0YiW*%L~^E zrdHh{|9>W~%f0s{?t5xi<d_#Az|)hf6uf3hQyZ4W#`^{j(-wF^SI%ihzN=&DMLznF zFWQW4Mmm`C)OBobZ9=abqKfuf^=$&y>$Z(Fwc5X30j43(zl<*t3kW81?r;7+>N_rl z?x69h1Vk_Bv~L5DecwQuFwu8-sPFe4FO~U}-tmClNKN9U535!ScuyGKc7hSmO(NIM zMiZc^c^yo1A75iczk^mbYU;~CfEC4PA^uR7<y);9F^Is=e9rH>Yg!jV<p_O3e!2Vy D>v6Ix diff --git a/priv/static/adminfe/index.html b/priv/static/adminfe/index.html index 73e680115..c8f62d0c7 100644 --- a/priv/static/adminfe/index.html +++ b/priv/static/adminfe/index.html @@ -1 +1 @@ -<!DOCTYPE html><html><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge,chrome=1"><meta name=renderer content=webkit><meta name=viewport content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"><title>Admin FE
\ No newline at end of file +Admin FE
\ No newline at end of file diff --git a/priv/static/adminfe/static/js/app.0146039c.js b/priv/static/adminfe/static/js/app.0146039c.js deleted file mode 100644 index ab08475ad53edb4085f6145168cc7186d5c4c662..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 190274 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%0ACVv!FL`2DxhB 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 jyT97pywjexhuk+K4(Vls*Q@^r*^&_5 diff --git a/priv/static/adminfe/static/js/app.0146039c.js.map b/priv/static/adminfe/static/js/app.0146039c.js.map deleted file mode 100644 index 178715dc68bdf1c3b6d2a0ec617f0c1d6750f9c8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 421137 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 diff --git a/priv/static/adminfe/static/js/app.3fcec8f6.js b/priv/static/adminfe/static/js/app.3fcec8f6.js new file mode 100644 index 0000000000000000000000000000000000000000..9a6fb1307826d2f15d288dda7ed09d49b36ea7dc GIT binary patch literal 192591 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 literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/app.3fcec8f6.js.map b/priv/static/adminfe/static/js/app.3fcec8f6.js.map new file mode 100644 index 0000000000000000000000000000000000000000..cc4ce87b3ca199fd7f5fe5004cef177b827c8b35 GIT binary patch literal 426204 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
Ni+nz=@N?SVeMD5u~#_3jS)z3%i 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 N-(TKw-;Nz;|9_29b|?S< literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/chunk-0558.75954137.js b/priv/static/adminfe/static/js/chunk-070d.7e10a520.js similarity index 98% rename from priv/static/adminfe/static/js/chunk-0558.75954137.js rename to priv/static/adminfe/static/js/chunk-070d.7e10a520.js index 7b29707fa93484e8882d4d539f813d1bd6bfe4ea..8726dbcd35241533f7e957a0695ea20a2da7c33a 100644 GIT binary patch delta 34 ocmaEF``&hfEwi~n%0`Dda>77b&pg%8AkoywKrgFUFE_CO0McR$7XSbN delta 34 ocmaEF``&hfEwibq#YTrYa>77b&)n3~)Wp!(TraCwFE_CO0LR!0c>n+a diff --git a/priv/static/adminfe/static/js/chunk-0558.75954137.js.map b/priv/static/adminfe/static/js/chunk-070d.7e10a520.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-0558.75954137.js.map rename to priv/static/adminfe/static/js/chunk-070d.7e10a520.js.map index e9e2affb61fe6b9c7c3bab9917684ed7e47935f3..6b75a215e6a733797b9266bd51b0baa08d9e22e8 100644 GIT binary patch delta 27 icmbQ&!8osjal<|i9&>{fJ@ZsUgG5s!gUzQnLaYITJ_yqQ delta 27 icmbQ&!8osjal<|i9#c~bJ#$k_QxijD^UbF?LaYINo(Nt5 diff --git a/priv/static/adminfe/static/js/chunk-0778.b17650df.js.map b/priv/static/adminfe/static/js/chunk-0778.b17650df.js.map deleted file mode 100644 index 1f96c3236b2240e02df4ea6dc37e07427de1dfa8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32393 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 diff --git a/priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js b/priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js new file mode 100644 index 0000000000000000000000000000000000000000..232f0d447a1c38d3b5c722365585b362d209934c GIT binary patch literal 21585 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 V{m(y}Ouqd3i;4ehi5FGo{|}ghm-_$! literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js.map b/priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js.map new file mode 100644 index 0000000000000000000000000000000000000000..dbca0ba8e2bd1c5f49be594f7e5c8a570b84bf26 GIT binary patch literal 86326 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 ZNN&6@-AH;v>=l05>kV$K-MI1P{{xXU;u!z{ literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/chunk-143c.fc1825bf.js b/priv/static/adminfe/static/js/chunk-143c.fc1825bf.js new file mode 100644 index 0000000000000000000000000000000000000000..6fbc5b1ed89cc98ab9fcf8b88b1b591600abc167 GIT binary patch literal 13814 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 literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/chunk-176e.c4995511.js.map b/priv/static/adminfe/static/js/chunk-176e.c4995511.js.map new file mode 100644 index 0000000000000000000000000000000000000000..f0caa5f6261ed90da8e9352b3bca406ddf25c664 GIT binary patch literal 32132 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 literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js b/priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js deleted file mode 100644 index 903f553b00ab80354c8f06b642ed99455ddf6f16..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30624 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 diff --git a/priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js.map b/priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js.map deleted file mode 100644 index 68735ed263baf97d446ac0485dd949a24a163d9a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 103450 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 K+qrh_^ZyUfCGxZY diff --git a/priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js b/priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js deleted file mode 100644 index 6a161a0c6d0814bead1874b3a5dd50a46ea9ddb3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24591 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 diff --git a/priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js.map b/priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js.map deleted file mode 100644 index b08db9d6e93734164f7998335f18798d51f8d6b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 86706 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�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

ZvjH~a$4DyS;8y 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 Y8N5G=ccQ@<4<)}D4B|T*ckX=tfBo8yY5)KL literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js b/priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js deleted file mode 100644 index bfdf936f8720ace8cde0ea0fa02b82c8ebb69d3a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14790 zcmd^GZFAeW5&qs^p`e)zIiVOQX;N1nHRGI<>D|?r8z=5`#?@#j5|VhP03Hb1ab^AY zJ^=V8l8R|N&Pi!9c`*rqi`@r{-F*P-GGqb2bT8>7_Wbjo63*gb%d_k2vytt_X)?FY z&WJxx*?Ir<*nxPWO z1$7hYN$PrW><08A^eIGKP&wxTX0^a$HNA>3O4m{k;Lu_1IURbIZ6&VfE%Vh=t0l(sl zdQ?lP&GE!6T~Hirvcric#&Q!D{WnX2m^#+Mp_9`5;VUPpO+6 z27#8HI7_T67@Rpm&qb^X4VSu@h%|<#%>&{9LQuzjRNY0`)L?SD%N0B2pMtT zi=I4rL|wrnnw*KzlPAT*4dm5K-7t7G1kS<$6Qx!Nm;w>N9zUl$Yt19LCTYa~?hJY- zdn#{m0WVkgW8zkj3TQq~&;?7v>Ac8GNAcBsV}bR`BG0s-_m=_~+q1&$6}HY?#rH53 z)_gh6Zkp<-(8CrROV9pdm0M*tCu8~ zTm&aHp_OdNfb8awtAI!^a~j$gY~4n1(lS|soH0*Gi%i7(L((Of!{=-oip8Pt^Azc7 zxh34$5-RM?M)j2MAIC_kLpIw^3;kf`ulMSA3ond{MYc^Y8>s*gY!}a9%?FVS^3Pe-LmU}uDxZe=$IynH>0|+J~Fbh13=kK(< zv%Xz~DiVjrhuQ`6*Q1Ng`r3erTK_I0*1FW>Qj5L0m)=^g<`7*(Grjb#)go3Vc;@ZP zmG_tAbVN{}3V784$G4l66 zQthHThxMMAScv9*8ibGymUPD#7WohBWxvQ%r4kFODh#oJs}|6jsa4tb)1o=CNBIan zpyEs4^WYXQ`--6KD|^4A5lqsL?>ue1X=?^q3o|4|}E+OGR7`Nd6L9 zN$L-jaw2UY zCroTeKROFp_J3e88`;S%L{xnrRJ^uyqqBT%oo{RFwWxye{v4%zq$o|`ns@=OqaG)q z?7bnZSPxlLJBfHwMIJhqXoL?_E`0h+GQGqk8u0@5HWTdhXM(43n-pvBjl4y#%8svN zGi<(LSNGSRJXu7`J7!wo76)kJq?X-WpX{j^Dr>lT%J&g|bCPxU379ckXE1V}+xky6 zKx>bn+M)LiaHh`i#m9(iy&VCX7I?fc67M9^?mUAXhnjW~rz{E+9KYNVv>CUS7DDeN z;O;Yt9mibav0%bK?+DroX?GH4cana`@s%CBInf7mK32}o-v>w!+12-f(8d|J-G=A=rqc46Od6qo_;JV(btR(7y!KV#r(d{~nDKFF|=663h!1S$q6;3T-V z#Ld+BF>42E%t-RL=G7!HDEYxUspspQo$WcVMk92}?;Dl(lnPx#Vs-VY?UEAg>8smp zhIwB&Eb<*iG1I`Q|WulN7r{+6gyIR67o6E?5_ diff --git a/priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js.map b/priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js.map deleted file mode 100644 index d1d728b80195167c0b5ac2340fc506eb45cb146e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40172 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 S^N0T6#WDPlh3$of5B~?s2iXt+ diff --git a/priv/static/adminfe/static/js/chunk-6e81.3733ace2.js b/priv/static/adminfe/static/js/chunk-6e81.6efb01f4.js similarity index 97% rename from priv/static/adminfe/static/js/chunk-6e81.3733ace2.js rename to priv/static/adminfe/static/js/chunk-6e81.6efb01f4.js index c888ce03f978fc0f6b761341a244d9a743fc5605..f40d3187943e18a145e7de87467cb631b2b1f6f9 100644 GIT binary patch delta 23 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 literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/chunk-7506.a3364e53.js.map b/priv/static/adminfe/static/js/chunk-7506.a3364e53.js.map new file mode 100644 index 0000000000000000000000000000000000000000..c8e9db8e0c8d939f6c459f7afab98be63f0a7e3e GIT binary patch literal 58197 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 literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js b/priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js deleted file mode 100644 index b38644b982da2c3c3b603ebfd89912e205afb5b7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10877 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

C>)Wc0 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 BGS&b9 diff --git a/priv/static/adminfe/static/js/chunk-7c6b.e63ae1da.js.map b/priv/static/adminfe/static/js/chunk-7c6b.e63ae1da.js.map new file mode 100644 index 0000000000000000000000000000000000000000..2114a3c52ceb6af8dc33131a427856800515e0aa GIT binary patch literal 28838 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 literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/chunk-7e30.ec42e302.js b/priv/static/adminfe/static/js/chunk-7e30.ec42e302.js deleted file mode 100644 index 0a0e1ca347ace596dd018d9c5d2fcdf2dddb36bf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 119434 zcmeHwdw1Kmw&(xzDO^scSDMnQleB5WEKOFLN9!g|eBvZMJsvL&B~cb@iBw1`j_de- z_W5nRL4b~I({7yl-gQ!ocx`NK>^FeUVVaHe!`@*sT*RY;-^x5&^gZ7`e%#&b_7=-> z+S%O=M$=_>u>NCmXCv4PkArV-XMY6IWSNbsG|xI!n1pHfI9Qg+T3Hq8s0!}CkBhak zpLT-T`0m@Fdq3@EowD1DPr~5ack%YBt?zEUd}z1c$j7DI8r|N0clV`R;0J1FR36#g z-r3CGtlIkS+aGhg_4hB{ymngysPBjOKDY&bpbqy(HK5q+9Xz9327#~O#~&A!J04ox z`!%1q1%5!X(VySh^SihB{+`?VBz(MGZ8%`~JNv7{hdo# zC*hm%&i76?{(1a<_}d$69&g@_Ua#1euxPifo<4Hgf*1V$e(PHoPCr1eff=h)g3#0H zAJ2Y$;`=<>eCB+%MoN1{C+TW=k#FR;f^L}gxx@%#5+XHWkRh4G@Wlt7wqcoIXqun5q1=K7IA_%Y|*gFa1 zWi?H*Djmfn-#q=Hyw(x>5=66cyr7

f$#3+MXpvK9769C&S)Dd}IHJzH>HMfU5cW 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±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%0DmT(?fK$;2n<^FnPG3FB*LE1Hu; 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 diff --git a/priv/static/adminfe/static/js/chunk-970d.2457e066.js.map b/priv/static/adminfe/static/js/chunk-970d.2457e066.js.map deleted file mode 100644 index 6896407b0d80225bf8a6f452960bbc462aa7981b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 100000 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 B<^%u$ diff --git a/priv/static/adminfe/static/js/chunk-c5f4.304479e7.js b/priv/static/adminfe/static/js/chunk-c5f4.304479e7.js new file mode 100644 index 0000000000000000000000000000000000000000..4220621be645378c58349d128deda10edad9ca4e GIT binary patch literal 23657 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 literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/chunk-commons.5a106955.js b/priv/static/adminfe/static/js/chunk-commons.5a106955.js new file mode 100644 index 0000000000000000000000000000000000000000..a6cf2ce52ef4d95c0431a6836d1917968cc7124c GIT binary patch literal 9443 zcmd^F`*Yj45&pe@g@R^o#0FyIrs?G5(PVPT-6XxdCQhd9xE>Eg!WPaX!2+Nh*U|sp z4=<9EC_70ry*`|b;*h{%_uD6SHNF&8E-%wdzNlGt@h2^-dOq;%)2HK;B&{3093LN( zY}r&7lT4PStTZ{H*FRrADrRR*l^G$cv7wssZG^)B@uh;a9n{4Sxa{a^Ai%J_- zWjv7kLCS)&YLMQYgEiGldHHn7vx|lNxOdC6Wx;K6?C)cDx~4TdkH^1m$^}=0r(+>B zp$i#+F*M-1=+!jhY%%%O^m)xxks z{Dc1_lr}H9FDjpJnd~c%4E$6y@olNY+CA`~w|Kf|0o=W|4nv5yZ#H(7=!1Xk;w&xmJsguqu@HRJ>;Nz%+2)TWqQq(pTl2k1E50+}_1 zLFPr!?Ga#c!F0nXyk>bR%$RtMO>|rc_+cNO#yi;O%6GPLsp%YC50K`Qz#A-gslleg3iH|&YZVz5He=h*GVP)N8rwt#~ zI2oHHJ(H?r0PU~%nY?;6dG>7d{Q0ab2}H8Vfh%+M<42*5?h)}>EO5min`3?Q-FN1g zpUmv9HY3RS7#YnbKcpY~+w=S$NfN5&KDmr#%9635Ed(AU_?L3tj?H zYT-MDO3%{M?9(S5Awj5f{+W80%^%||UQ9Bk^58Qfds;B9XVEO&t2I5w!d$Gdh#8}x zRb5E9@shKgE5!P(|8~pmdD}GGT{kZaSr+9%WtgbkIiIxy{MdENKkShy2yd4GrPbI% zg^#AepRCU7#?y=y@_iANI)FDu$iIvQeZ0{%A<#Z`H{Cys3E=QsGEc_=|&( z?BZ*0I%1nic%pzdCOBZC#Q75VKq*05Yk*eEpW&{w0%t4!W>)|N*BCd`od_Q!!5RNm zo(_%lD&iQ?LV>&uu~+ycYhODpy9%yT^Dat9E^4;96qXqb$|^rilR>wE5`lPP zx%BCCQV3X^uNd1nx$!DktdX;jWIvz=*Sk43vg9)CdcO^zB;IGlt?DgIEjJM}B#B8E zx=p%YM7BaLue_L|v?bsWLBDF^7R0QgGMIb7HuVd>7~l#%Yp-qo{2M}TOfYV7^x+VS zA`g+6%>Z4HMbz6becbYO=eIG$M-u-@a7l4FYKN_R1M6)l?|2=?-rry+R&>L%KQ zx6>}Kb+kRD;HfEf=C}$PMSXFZ_Akb$cKx!yFUXATLkaTk4!0fIkBLO z;(@(cy=VvG5J0}I#O*mPurzWpR59MD(!l+&GKZv9E}H-9;s!RpT;8~G(0T_1 zZmI%zZlteYS-~wIUo3LB^D|}s7rfNpe~(;E+mAqB%ygg7o>1e80b3BbqF^KRK!Pv0 zJ?mPEf~!-5{-JePx7C&m7z{|;W<<2^?()IM`kSfY>Zldc7cWjEaN zePcZT`gUWFp|+^|59u=^n#9h4y-ayzxJK-V447qwv@Rb1mFz6 ztgh2;3zA_wd`9#}pUXU{dn2PbnUNS6RVhnLrY_pi)yRz7gY3&o#p?DY=1bR@FO3fm z6OD#FX3MtA#882jfu~lOlI`PQIU6HVLnC((z>5@3vD-jgyxiA5ZDS6Ps|_!C!Am)?x)E7FT17 z5JeZLaY+zV9Hqt@j~}_wB2L^IJ8{dpJQ?-sLEfCU`FVM7;AYx^o1J`i#`9SF{t>?7 zVMN{aBEk$N;5UE`ObnKd)=TE*do!|-7^asKi;XSMJl)}fP&qf)E?Bo_?7eY^ldv=G zK;B1Kd(hs#3}!0Ww#3xJn|8&jWs z(Wfom_C=qfjXvv>I}`_~G5l`NsE=(ueF|`p54dK1_enaO3)) D*i_M) literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/chunk-commons.5a106955.js.map b/priv/static/adminfe/static/js/chunk-commons.5a106955.js.map new file mode 100644 index 0000000000000000000000000000000000000000..d924490e5f65a50200b69e2d0a5265a56221face GIT binary patch literal 33718 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 H=K22v0)?pf diff --git a/priv/static/adminfe/static/js/chunk-d38a.a851004a.js.map b/priv/static/adminfe/static/js/chunk-d38a.a851004a.js.map deleted file mode 100644 index 6779f6dc1e57a7e924a9af491da29c3cd3cb41ab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 81345 zcmeHw3qu=6vhH7*_;>@vNIZ;RY`lpO7!w`>)zm@ie{Z^dbT77ftB0e~4woX2b9kAM}RZR=l81->1b!6A*n7U({MH zG#{)d%}(z~I&R;+cXtXHuSorqq%(;7()?iM;o()yGb-=LhX+^H4mexv?0)m%a=!Tl zY733??IsHJtF`>${_@>>Q*>M^pht^K5A!f45M2f#-*_VBe?apeR_-<*T!liWA&brB zNBP=CYJJ^&wNbjRT`|zOF}8fT(w;_+W1!rBaKB*VMcS^!IO`0Ll5V4&q=QDM*MygAyifCOSlu28yIG+hA2ybj8cX?_ zaEY#FCpoaMX(Jt6cH*CV>EL;J2WNN`bi$^^j+XNsrOgbdtfv66yZQPB#60w%ymv?@yax}J9q-p4IgVCin$;i#PIN59wOgC~d^sK@x) zlgfL&-9bO;5~s6%Z_uL-c7rum=f&!EfMiw|9ZR>&*c{ zB&kQ|yxAEFR0sUU@bn<=2bG>DC#=wFcF4gb-4$NpYj1EY6$Qi&3F^mZon}jWSaZx8 z#3{*3JBd8efC&Zh$Fp94zyRqGl;h)~nXsZ+Gro12>7dp>VikVfJ5Kl&6u#D){Wu+V z*kq8L#{I3f)vE*3J(Sqbt?qg6Bz6UHv#l%z82tignsoH*9+qXjQycw&3{IesbmvO5hjTxX*X65T958Oynl5#khXefal0|-BbvA> zG|0ldMu15chqU_18Dchu8VJZ>d+p|+Nz25nbvO{8kfiHzdNSypq4;(>==H@j4Ag(a z)v!@~ZZ0(*9QWEZe0YoAdAxImuq+wGIuB&|q09)=Uxv-@AQ{k0oy3=XFywqOtAKBv zxO*fcNo&~e!=Ge8ZX1>|T<;t{?_o$l1N)hM+xUptXE%IS!@zbf!vZJNJBRBr;@9LH z#L$f?a(lg(f;6}i;keXXMMQ927Gi=2MYDp zKO+wi_Xn5ZgCsp`4qEhsUU=d67rp*zvy=QT1772t;X#9yZ@19HAl~T2^t~)48VwuS zQ^+f6)iHRH$IZb&crE_7dC(z4WV}4?br8Jc*KI;L1**xJJe6RebqqaWU5uRSzcGj$ zAoIZnx6knxBPoVL$&qlRJdj!qK54Mu2H8jefiB{gr!#F7UD*Up<>Um75s;h_c3UzP zPr#jDlQdD9)_OgRFbp~}#&_cj_RfkZ?jK zw(l{4SWCo4M@KFIVa0gEV7baaXE2jA!|y02n{PW3Fx=v2tJ6zk&AYsYMoYAWiqFZ- z*GXF};&yy05ek?c4~&$CjkUMCn3nBE{}kw19m0u^bNBmG6j-lAk1Uh3?{iwrT#MMYSEVt5GYpf!ZwIYqIm z@9>N~UdzsiNNj219VD@jXKnJh<@=5WwE>;XK!-&Xdsh^Td9 z2~g9Hgv2fQ0W!2m1&Sou*AVwLL0dhw>}qs+$U?I=v5oDxJ4D$_B=LHKwc%hOSf>b= z#52IE5yQuAsf&7Q`?;Lmj$;MLa_XmAWluOI(txe&T#CZ0Gw}zYT|{yHW+(7Xi2R!! z>C@Gr_B2`fFow^{5E6E{N@viUU3e^1bH??Oiu*k2lBc<{Jw(V9A>v>@8`w`90)AEv zOw#S%fqD`rUgZFd7EY?&iPtUtxhpK&>S8n_RLPBkBF&8kqutteW{rFO z0NZ|iR8q=;`?T37-x{1G#iRtoktA|;T)*fysrN8GkoS=+fy1VMrqjR&>`b0>9tWrM zBvA$l*|#w>~2 zAY($md0}o-$JSlurvSGP|AmkS-?U4BulRwJez<=gSNtO)(n*e}@eR?tq?>sxiSu^h zfao(C^al#~kkus$#OI)gKbY$`PsP#8aFMF9bu2Dw=YkyPMf(^V*+f)93_d{ zGRfCRGUSC#L6~LYa($tgv!|`@A!T+N51JOoGFpiH8(q2%a^-dtKgAqj0kt;rCX8D} zPnk4D4UIk}niXVyk!Fqtl0U*<3K&!V&af@A)@HAugfEk$WA=d1MLv&{c3h#V+CZmy zn;mLelL+9GQJ**>d~YY`Xh2+{KAvR_=!=ah5?03*B7#KnDXa}+4OYu;U2N8BwfQal zsm*WTPhI}b|57M;GLL%c;ry^(uMG~R{-)G?QeMB#`os48@>;!i-k#qT$eXoS+Ld*Z zuGKbOhg;d#lc2bn6@Mr%ZuuH@bmQSAYv}S3;PTeZ5;_C!iHQ3A@0^w)SQ z0DcvGUQ3mAl&oymq`wU*CmT_b;$a`P+B%9+pyFXM`sXu|;&1p`!`GD!g`s^>bZF8e zLnveGm|szEvv!OEg64O89csPJ+F&hLu3bt$!aTO80O#vy`czOPhikQ1X!%J>KCYqY z;actfnpAtPU666V^NI9dt6lKBrvhwKinpb7{*(y4kncM}_|Nv8l=C|fkP`ByR@;&8 z(GmVb0lE;%2>@_kOYshhwU1u};({Uol&$L{W!)n!zq2MVf6cJp75ESI@x$sU=?;^wMV~# zSrBQh_TS1ZGPo%U)?Y_M7%%lCRX!JxsMQXnQ44?d`!g!|Q_)z;mO|&ldTmdtP`d8B zN*6pMx0F%r5Ed^zZb<`E9xAzX)b8@pQBHQoaKMb&N#(Zs`Av$D&soMmhmJ z5oS<-Z#O+R|5J4aKkTrhem^0G`wG>Ot_TcBAovT2$AQnQGW~e zLur%qbt$1lwqOO6bY43DjheA?18nmap|)+@SW!kOhe!1@A?4h4pja`6GJGFD@AvNt6g}ejEDr?GpW0cV~Lf^!bU7<#0tEx_l zlMQ+pa%-T~P`3uhB~st1t&0NGYJuboU>({|_Nbab#V85IlzdUIQ*1`AK0`AR>c>rt z6V4!LOyaUh=CSevt;wBV7%a%kW05HB?aLQNIC-S@UBZRae$(%k}yD>-FE^<%okqd1T!dgOK|&AomXE zKOhP@Q_!l33u9V5lorFIwfWNx1S0r6*{avl7f_D+hqVDdpMlFj7aE6Qyw+XE_sv>w zT{O6kzwRl9ZZeV%`?b(b{MCy1SI7TdfT+`jx>lq~F}$}Stv~`nbacoFA(3?oiGPWV zHiXWy#ZaANXG5yJhK|&07s^p}n42<^0TI`bZjEYAE<*Hm6Q=5;71K`D?WlOII7YD; z37rj{5Ur8$P|ObG*^c~`KN=|ED;|+#1*tu4NC|PQn`{3?IH9-;9RfolOoeQtQ~{jQ zL$~F^AnicGDYF1cB&p`I0dsBDx}uM8nQ&gCoP_?d5Snp8QxNECb)}47Q85Zbv`a&l zu2u&*Ck~bvQRG=yJ5rG^uL%SVP2dV1uGJsZ>4CuRqqUA&)~T{lM9^iNRd1g_&+B`? zaEJlMvaJ$yQl}kg zs8wU^!XLxB)KZV7(a`)6U95(w?BM;4bM7G?1~{qDG1=;je5cE)IUHhEeQ3dyy-ktSeY!e668C81 z)mH7^I-dT@{$J~eaDN@rWId`R6oFsqsO_tia>&;{o(keyVj@fG>X$ckdTBlFqV99) zTE}gxg>eBI0#7UGu+mQgY9j=N(M+aZPqfx=wP&?1D-fq^k5mjUk_`b)`*b4}y}+@~ zR1@X$A(^+Ke~upeE)>T() zeK5mA1VZk;LXZbYh??ltOsuTQIEzprdpaIL-1#>V1j7uO$rGX1wLK0|=v0h00QMjR zcB!3L4ap-s4qbbU>O@T z%>rluJ^*REwY1LYhD3cKa8LDzO|hn9#>3aAI;n$dF!6P5LGZd$uVIES5ydu@_kuzH zN9FYeImy%pI_l!tZtZlPV`ElZBI|RlJ;Xp)??2tbjK5i{{q~;GHwKo`@qH1)bbfqC z6hh8?#qnK-4aAOgHILMkQXS$E{L>3bkE&Y78X4HAC%GZCj9H19f)7sz5==ZBuB&Sj zQxZ!!slzuBb%Tu*5VZ3H!V&7_nLfKO3>mI1si{y`kJb?6&<6-A3(SdOQlioEfdGVJ z(&jamK~Tp|z*$-X`XT`^Fc!i9`3v=hDpXi`Vja`+fQ-dD953N9(9^?uS@0mi{~fg` z!s@N}wFe@SSR^4+IiO8@gBFRi0v$;mV+tS_q8%eb_@(*LjCG6a8b>VZ2!bJk)VLOe zFEh4f1ZMm}gRk|}{n)g!BqkAp>(?YW23SWgx_DThKrhv3N%fp0JX|{x4I4u*rBtMM zIFVk$7kV!oy@=GoR;rVmMqfqmVjXow!1vd0Ea2aD;1ZrM8o}g0W{XXHh@!5;p#BtL zqmv25gJ(%y4LwyQqCP?e#P~=-{JUu|P(N9d{KFOukK%ctPcWB20uFO~GtO&7#gb_q z8?~aRp#_QoE0eW{PiXJYtamX3$>G5pkxFwT;+Vl@j-$gmhBw;8HE>E0Ti%10%3@0i zFn17YKyU!E`(B4}7hJ;0)wjh1Eovv2NSxFWT`oUoclF`ZrR|UA3m8IPuEPs(ZY$yC zrZ9~zlu?;5#JJ{1K5 zQrl>`&L2KMAT2Gv8|9#D$piq2`b}cC*9cv-OT;eaY`~b_L<{ivm867UV@_UUJUl6% z#3=oUjL>WvX0IxUN29%{2syn#V9!C3A=_(=h`LOK(2saM)JX}xBSb;nGf|r!{&KAS zT{ZnYjFFR{=4Ili@Wm|YbL*TV(e^B(C+Oh;j1?I|){D2|<;f7C85 zlqvI))&tiwn>!BK?I^|Yvq58pvi-9rv5>|fP7q{{D-qOnX0d@OC9y>tV(ZdbtgXFx z7K0K}?&2`QP?oEImWP@%G2f1idTqeK+L^vXRVE!PPthypX*-ywb+p!84KDvAj#s)j&l*;BJTN7q`#USsK2uzh zf+iFPG7+y}CisOIcS%r#B`k3npw6I@e71ZnB74IBoy^&&Ex#ZyABg>7vUB`gs(OuL zm1LbjbI>TS2jNA z@&IAf?ugGhsy|Y{OCa&FPVdgu4`Loh8V0&Nwfs!F3Xg0ddUnPC)R9mn_`8b5iV%<@ zQv&woHu$Jd0TaM=+V(wdKy_WTEX1O0yxFz@SinLXf$;q`EXb38uz#|N2`o6sH9kw% zP@jcHOy04s=re7A5Uh!cNQ?#bMHbd5lqct$woKu~Nwm|Sy=LBDJJuy-(a)S?oq&YK zL1)_1tRFlu<9F@=%48)_3Gnyi>13U7BD=m$(275-pQsMqSbE^n2a}$0=|p!{D91NN zh~iK_toMyuG-@*%$S*(4PGFqEG9bn$j&FI+$*k3u%HWfGTc;Z7A>gCX5#^KFus!~A zNRk&v4!_VS?uY85fW&A`e4;NFx?e$xEy39m}57)gIMw9=(P9jdX`$|Tm`|z^b8L3 z2+74Sa;_3MxZy%)YPLwDTs*g25c{fQaO9W=mJ93esx&ZRJq4i;b%6#6kdUK6*x3Z1 z>t8s0|H_eJH=o`gKG8WI)=A0Yd{Up7aVrUpX8sO6+Sb)ceFsrC*dcVf)RFU^(u=ea zL-}F-k@+ZkeY0^o6cf#Qzrwd7{7*h;LaJ4fr7g9creVuyAzeSro9Rl|`H2}U@$)4h*s zQEJ@XJ&TLj+lSnQN2^JwnA-Sosd~Mj=pb>*u%61$Z%^GXu3H9)f#4UjKI7rJummT+%u|hbr65b{DqA`M4A+ z@}b)>L82q&$RPJN>St@%vQ@9$e}O6VW_@wbgVqrvw0iB4PMH_E1qgYmpR^X|?svan2iM694z`wG!l=$!KUS28sav#` zC02xqEX(JzB$tBKD6S47kb{Ol=T!6=))WO$H=O0W6osrmVf1;1Mn|;7mu&M(?U)V& zTZhWp#ONA^K^=v+p+u2dAeRsylktpD6cl2%8oK?>)wRE((Cufsg6eR#>0 zyn*CssMDL=lTKacVl3HUQ38I^^WD`#OYAQx{?ZXd!5$uLi%gT}I)PI;O3Fre^Fp2~ zM`<)EWy5srvaE_Sg}aM1K+XD7M~Du&Z16}s%=1HxaI!FHfVu9SE=hPXxh)lAg$D8- z=Cq5+1kHuaLLn8z61>>@LjBgQFM5gq$){13Z=zDZL%<=o|C!gvu_$B2zZL-Ejr=VI zE{H=L78G%m5^)>Yu++@%Wy%ig(b-0x!=u&%5VW4dYvN4jnkTv(z)`*a;HTPM8S*FX z>{~>7=xHu_H&7@1b&_|W_tM6Y58LYGMdH|oOMwc}h?vdGA{?-Fb*-vn>au3yu>*p- z!nzOLdNRGookF_is$?J3&doc*IU~?ncEcde{b*hJfZ>u3!2-kb$XEt$8=VW-HksP6 z!C%7;8(u+%Pe!4;`!3l||TEJ=8k7!4-J{c3|PCWLPfOFL|P=_G*E{ zFgmB>E?JC4Ey;xb?jea5HHkgaKGK;wR0%jrnS<`x$cz;`H`pQkFB^}Yng&W!t^x#U z-Ca#?Z~KeLr7rYtNH2max;?o6fLo;Q9Iredw7_IrI8}MiXX1?(3xJFP9v!|g-}h3SkR&OvrL2)!rmopGouj;?mf#1m!VLT z%j8#zaOJtq5$}bh-I!QQ&Atl%MOHS{uBP5$y(yE3}ktKtS zb}+kXLR9ZHfo4SJVkxT7hjn;iRR85GS|LVu2)fqyMkr31gW}Y34ECRwQqao!p%?dy zW$bLl0s$B&b$0UDb+mqcwsFWw*Tv?pa!BQ@U~D|KA_PNDDJvjM$8xgb)U6x-p{yV! zmtVpPCO}TFJ$8*cu2NQzJ-AG-^2?oDrhg6Cc7vNn_ zDJ+07$Rkl&vN46tz>TFFUt%)=aI|sVk=zvJh-o*CcuU!Hr=Tb&KUn}go zKPFu-tQTH2H(bd$a$`!Cx2_j1+<$ISlt&zc=+q8jIft>#oqpT=?Y%6nHf?2m`tlS=#U!)CSl@ZQqm;!3kB*U8%z z__+CG!0Weg6Mllb?)!rXH-$up{oZNx_EzPaYIVW?3onmc;5E_}+;m*Q)kC~Sx}w)e zFYp>^++xpbq|wj#_H3%v{2li{{DA9GDwU>9DbArKJYM`3 zcTsDJ+(rF2y^H!=UJ&`1cTs=)_SWAcy^treJM}1SU-sR9x>lWQKdnjU8c%1>xuylYuP|nPj5a^~dG?z_cUq^NYy2OPrC*xgVN87)7>xVrcuQH_KzcuRZ&{EI-c;_qTm1NDvGS-{Iedq1lCj;$3kD{F)f&Wm@!-wk(nN4Q zzRzJ2G1{Xv>32(ls>-gcMyQP^{qqI@6AMRUcI2GJ@Ug^TiMQ8JqcL z2=^ul^yA(n;ptyj_Ocqd*iU$GVYILBw%FGQ@C7algc0Up>N_X^k%4FLFwz#$V`z#h z%1rd_x31KGtE&2;+T+N+`e#?5gnKp*3v}(GD8qmiiv3F&df;by-+bbGcJ|%OI2}O+ zY3@d!(w4D;+y)9{pK;`0|B_$3#0KA;Gnk#}$Ae+N3t&k)GdDYz@06b7V63#W{Q4Ug_OV9cgBwkCimaXawLIMja#QpsG8ieP8Zbd zh-;PN<@?JE52}j`|CPqAim||VS{>kZJ$$9eoPdWem^GT~q}S6n?%gB6JfH*Z^-52} zUWT|aT2D4X?=g~4|3xrq^E8fldp8}kj#D=F(JvRguYb1*P~f{ws(4|?Jn+PS{_Q6I zQPR!vb2A>O=jY2e;A>CMBG5|R!0pO-y9s}tCY??KCiS{))@=@=<0e=icbaD$I#Rx} zL%76due!;{2qO->=Im1RQ?on7i(sOqM-LXG#l=8%kUf~k9PEJo_+!{QHJC}wD@jX>-flJ~wveCX}EB4tRBH@HuhyVbVNX96y z^ldiLLF_2v$b|}mSr9=i(A@1!I#G-l**J?+1`E$H2r54uB_^7W`C*HWh7dGLWxXg4x$3hNQh+lCf zG=?U;aa1)e!QI>z${)moi#SHqM&GImXk2Pkt%Eo~`WpU!vAQ(k|DTxu5d|yAaIp=2s`o?vd_^QQ{~5>J}VTb z+7@2qaK)Pwpf%mBRn?T}vFOh`sT}QM8kHQHZ=!ChQ}I)h(1Dm*bNW0@r;7UCz`W)3~po%hkIHD?$YIF*}qknwgiUMW8xE!|t3(}+RX2TZnv zowWFFdmHuzdq0L9$(84Ed)`q+Ex;>M%!?D{PMSjnsR||p6c=foN5#RPkM1rm&ihbj z#`JGy6&`jNGlcvOG31OB3Ccw?rTlXN_l<5kr2jtPgc-5VzVb=*Lw&185lfi1Fp}h; z=Xe9|XEZR_$sRloS?FNTN6Yt!cQj+V49u|XvlxB02#hdv@dq&z^5Nr2`DuOSnWa9!HIGSNQgwMloT!H5PF7XSB8cED{gl&?GrSatuc!*UsA?pm zJt6G#p35O2LF#-&H#P4@O$!7`F-K&)Y%jIgKazqy%*=d&vCIF~5#9f!nc?Mt{x<>E zA(=gt=L%I(Sq%OzW{)gQf;g2Upf9I?fzB3-J?AjrhQ3L}8gb~JwduF`@NY2rcAS_o zY9nwS@39ck`i#|VAqypZj6OqKg=6S`(0d`1nptvijuTntkhDwpqQ_ZtlXJc+vkJ8T z<{RfjtxQ@+;y;BxuaEQ)RyV6duL~%~jUU|Ptod=)Bj(_npcF@y_*}HgrUYUj1O8<; z8uT3f5Mb;p)41VVAP@lq{*VQZ!=k$<@e>T1vs4Gq1u7T<%@hQT=o!T!?|HO`)Hwhr zTvbA9W$+ANneCL@E0B!+?cHw6u@F;fF{TdAvG?2 zWR}#yjAoEm*V#~eBzx+;0)4ZvKCgHGP~D(gRAi#ETHi;D;y7nbKaeUdQGy7-%+58G z;ZQ-S-W9TuGe~58K53WEB47lP4HApeF=SM6d@Jvq9LF*jWB_EKiXzAgjwtU-jBSF? zWUo%~hW)XE*1UTdI=q51-{W-@T!4GN4|6D0pmF}2}T7z4?^ zsOSNvgvvpX=pq_u?0^mpTFv>l5h@{o%aA~N4)!QF6G0>!t@6Bd?I0?C<*T@)=OaPirDm_f{_xp+kjBI>BO1f7X#KZRGt zl#*(z69kz{HsPP}mMg4^_s+}&2*%6kdQk$Km~;cPn24$=;&u2iB{BxN)V89`{B6nu zWl$RvX?m5aitgf}e9AKe=&PU*UCBZ=oXdAn{WwNii<5|bS+Lo+p;I2d;Wn`$UiBvH zdw!AV;^Lxu?S&#%Oy#Y_6$h=t=}PJsiflC+%X+hM^{9#nwU5;LydPKaRntNE!uKS8 zjPYfkvs9a%EY2YT%HdZEbs1@PW@c_*x)4fkm@|70`nA2~%f9=p>spfX5{5C`gBNAC zPN<0AML#OTvAE*XRWEIIWnWUY1y(bWD#@^UtPf#D%W|OxteAaiRQ1E#8UQeaGQ)LT#?g^xQhlU=u>kWQkEOD5roVS=Ys) z?mo%XH5@7!!#*sEDAxPV`6Dy))?N$!_LfLaRe=eMq@~}>*XgiIH||c1o># z4!+G^PxGYeRN5ykN;J?bRzbI={&Z?6O+r;P_ZX;KsF9)8UCVLGt2mRxgfk1#6x}DY zkA)_N^W0s8kYD-#KR{8Mk#7FEww5Tlv-(lRb{~1YWX?^DI4Wy1(>!Ro45NA;FZaAd zvHB%a91&f5d3w<6%=p<4qM99XwxJ%t#i&5s#srM9ozASmN0g&buuXz=ZcDK5m6B&Nf^vwKxH64Ac z+gmRhzt*0=+Q_xJ7gz2suaq^z+fs2(U~rkM?h4Bq%%K0gW*9=DUU;lRy^uIfcP2@( z*t2pW%Z!=}kP0#cNhrpqNt)9&ADdbW?5r!+SM4(0@%1_0>7JDQfLE11v3CPXmZfYl zsgQE_6!U~tEs@?L8?+uHP_ym?_8i*HYA<&p1V3mbzw#*NOoW+Y-LzBA4dBI{gN6?P z-YGCieqt0?eETsLKwB|VN_+5j^F%^AC;3Ub=61}Fn~{eAej?(K4`qXsuQwpy+l6hEM^IyCof+#9qu@ zcF)FDdu=i3bWi%l z1nBnavAeIccd@T0O>9M~qWPb=!@h|8f27XCUxDW3&Q*Q2lTlw+>i;qAk2x{@n;e6} zj@Ewq1Y4r9&@{oSllJ44gVv+_5AR=n1`ekPuoxh_E&g*JZV(JHJb0CPT6Yxl{})Ao z17PfeU0OWBqRXkWGm5EOK53kvK9-!S{5XD$-__eK**m(STT1>ePg?S%A^Xo8t$+Qi zar}1+bsDYNnRaqMGyh4G1$Cq!)77`PhE(zjkJ@lcS^w>=&+~64*FDBwg1BGO|Fw;DM`$XMKw!gN-!9?Z^2pH=xgGdFi9S9flnz2opkw-i-$i=BCjrRxcu zg*|k8eH_)mE}ym-aGed<$sGQ@!~fti!qZ&1NeqX7=X%)OgmDyvn-J{)<5 zdnjRs@>L(tltGI-*#3St9E^~e#?5}~`0cIv=3vlIS3kYICDHD2uLHKfy|tR>qSi(a zs0Oo1K2{P46V=|{Vh{LUaR5Kh_qqfp-5Mb5uSD8=-8KX6O4nON>+?K<(YUh;^}|`0 zRgs+nnw#gemCKQXF*vDo%YG}+?XaG`9dq;1oS?17RBMTTX(+dYgD%V*BH{V@vzbMyVrtt; zQyY5|N=8KMB!c7WIIZkMLlm^6g1w8UmGdM`a3aXT z7Y8>Y6Niy9Bn!oIt+HZRMyrD}9Is`kASz8Mf5{F(0Rm}x#{q#3c!UY`w39wKiNssF z`sNc4h(P6eIG@Y&x90oJix*mUE~odkG_WnJI+wQ@58(5=d04me*L&;}P}P<14lX+} z4f`T#58yaZ3w!SVMm_X$#^K7Q@kbN^Ou;s-%1L}F^^-PhtC^_uA<#+--r>jzLp_C; zc3qy)EW|Y=>3W==V6Vg65A@%TFEHEA@RY9HW5UFM^y&KnA;1QEeER1xoD~&PbqoUo zA7x~%tVjX8`;odh9P@Vn(qS94t=Sz2i%18HxgBI% zh+!s0DV4-kz8t&!<33KJ!MRGL;8p6~=g;rvrWNyannhC{;-uc!5w+M~8Amf2wPV?* zgq^N!FleStQ}53pCpD7}TTJxMj5F>lc%BEG=Wy_>g#UsNjDXxD?6OSADn(`qJkQP z(&`;{e;3%mLB}kX4Zn&7+Jo){v!S7C8G2o4NUg3?gNj4A>>s$~5w};0orG zm4+T2$N!rhqe7+=l4PNR4Eso8;XXDsa&~M*KcfT{_@H939G~*{E{n|Iv0rt5GBe>E|O!=JLxCad^)+RXg{XBFY~Z9eDO!jaH;UFMKDL;tmIaFb}h zG=(i2<@9#8_h031wtrvJAf)fHpB`DXLCiaQqVFLCoo8x}^1Q-ftb?11WI~SMKo)&h zCBMO`HlFy9z&{PLa(M0?xN%#OY(I0ES`&9ONIxoAmyyw5;>poo@u^@(PY3$1&wf;e z#?wLX=%^DbXk?Cr3Z+BOB~@rRXVI#}0VUWRTv8MEF!d%L);t8DE9+<}b0bvw^eHNm z7?R4a6pZ$; z0h~ccDwt!fRrXepLGxRiIdWESrukOUwdrFyT;=@98f?Ttvn5HvWbhBt}v>h?d7khN`Nyn!};) z?h*23V(WUH2u|?m0A^h>`zutDfx1n`c750m57$iG_|D;TNjG52bPh_@p3csg1?9W6 zryG`hK%e89XBJZmp(g3a3GJOSm$qIH@&V&f$SJRb!!1k&oRzmC_JWlBW+m#HOHX0A zQCJN7=y>{%sItTxo{G$3c@AQz8GF5#qDWv#PC)-~KRwTRE>C*%C9AGP zWa`j^yz`<>&twyj(&+lb339I=qXe0l3KWIxpOPpK&}axjdBVa%Pn)*<0^(d#lVhGa z`cORy&P;?uSZK>d3v)&{#tFDCRnYk!UDMlvz2f4YA+~AFVs!>P{l#OI)3 zO6eOdey6AFaaJ8C*gty-hJFnf44?5B{p`%1kQ2mL$d~4kF#zi3WP{iD3*Jl9V>4|J z3+Oz?-Z*@fYjaTpnKvAY!mYV8`duc^ZFqJptuZ&QF@kzz$yTpMckeBpeRP}1ew1r- zaB2&eCZIpw_rV?IJaea+?$wotsBTtM0u-mRxsV_y_x}c{w98Y>OOPo)z#SyL)79vn zbSGm4=mT9O04Qeh@PwdQuX)1;_Jx3b`5hK+Z2LCcT%n9`n&IV6o*kSW3F-N{AL^m% zE%x%|?Jc*XhO&U@(Uc*ZOk4x@;$a%#*Iz z2q1Mz_5s)&%dOz}nS@69VVa>dtNBDXWQ8-D5l|rz>kh<9^aYBuJX4TQDe}%jwJ|v) zk92uYE>OE}XRf|2Je6-h@qJFqC^rkbCYv-$ye{fmTM;iTW!Q>9R^J@ zb}|2pf#`pCGAG;8py}`YkU26w`J46KR1irHU*<==32#~WvoktV(pQ?+v1J2^SzNxm znqGEWNNptPuWow@$C4;o#X08-*{Ysq#R zzlNc`{m4}e)Q|-66AY(5E5Upex4$45JVb?Ve8DkHt3<0VH&rO^4-qmF96xdOv*vy@ z!b35#$j_NAnPXfb&0R#Ln+Z!1Y&H zt3Z*_Z2O;dCl5h-0$pb^AVpcaEg-2Hz-5>Do|?D>{1CTkr*h}FBF`L@$Nu^>#k$m( z5hMlp`BkZLK#dglllvM#nf?57V%#bKvwBWIuIw5lL0jZn=Zc2+Q^_mn#;6KPjO?I< z5@g)f!3EIZK<^N%AXm^Y2`@_6g!?_$=c5#RU;y9{8#cMNm=nBi_=|p&oqnh!rmfD> z?f%LoHtmpmmWERRWiTC$IB`b80l4uRHsOCV%bcQ(;sFR+B zZgph%JyoKQe8&!s7x6`I*W8Q*(KKN0=27$44v4mSMgIhIb*SlTw5Hb}N?V$ojU%{g z4vX3hyTe($&*iwolwzbZ`OT#iC#NC<{srhD{x_!s+LRW79*G?l%AC%z*f~0vg8-8B zmSaG_an%X@6EorouNI2IU$+_)M-vJ{wr^V2l$tXf(|wna8pJw)2%>g0C`F3x6QnUao<;c#YW zq@wyYI2#$$ouc~~Bg9bKa_Mvr<}`~(NQ0R-pw8-Z#31RLoFjWzRd8IIN(06e`V9Oa zo%RcuNRXKgfV-#fyh+XSL}h~ajN}nw7T6vHXgWlV39>mNb9hL@X;+d04E%`~s82GN zz_jZnXLFC7Kb(pC{a!!xKjfMET*6%N+8u5ZdoRoj?jaDX543b?tCFJXDpto&Z9{5Rh1QvK$? zZvXAuneXnreY^1ZhyT;q|MaiV|F`n)&W~A@ce6jNX1^-$J}u7QU;3Ptyqo(0HQv6h z21QplnfvZGE+n8Wd27Ows~mAx#7h+3@oTngGMh-<+qZ4L8D>l;9)$J74~&xdccTRk z#JE*V<#gUqC5)LdCnahg!3u_jH^7qN@3iBaF zqX5Q3dE1?GrDZfV;9Rk-w>-S}H{)d#T|wK(ii}Zb!$vtnBW$0V#L_^FH(SrihT-*+ z!|>svPV3>Lc%`+xj8m{<7jh;;8_9S1G{4VnRTriPF=k2U3OKoSovYSzNb&#j^&7vC zGtIh98IQ@Uf1kx0by2xYwo53=wn9H`!7@vhTEyavEpd(6@mokNqH&m0l+BN$t^?_E zJFj)U&AlGCU^_1plD2f@Ck(+N0o^Hx)pKXvFe#aBXO-ln!3e5=2FptdW8^!^WI;z$ zl-4FUog&+0$5wWR)m8=cNSXKVz={=c$*YmlfhOO++}J?80(r}!Ck3#L)N-pui~piNFe zmW3XdTgF*D6k+8!E06;Xun~XqJb$!YHoS8^N#h?)&RntJ?32*r*P4BA{un_fFPD&Z z!@<~7?l$ozidk-=kg_g0)yeu27zD9d%!Ooi&Hgza2TF z%c?jn;upt;NPg!oW3aW7??)?*-Vk7J1{aL7%Gn~RyEh|y7F_fJIPgZfYd*4ij$@+`eiYQu zSWoqr-2aiS&W&XXw|*3^=E%6>eWX_%S6!W1xPq1ofYo#Ih#Yrw^0MJ;jU-*o3viy}8 zmh`g?x@gA>F|v;2UJR7FTQMAi)f)+m4LR&8ara)JzVHABIeL5uS3QJV3&_A|5>yFH z0WtG!3?V}h*>uHQ4bXF`Az9t8$Zl6akCkoETYvx57nl>y8#g)e*{_T-k3}(!A`$1 zH&o>M6fQ`lFlumlapwVNJPyj?`$O)#U5@3v2fYc@Lax=vp+VggB~9c!?S$JZGEbh9 zPbftIHx3zD8H0k`EXp}Svc22MsaSSz1u`(+@c zVi}8ZP)*MiUdKudYrVE2T4jibZS=6;j1$2f4jX>=m|j$YdU{Pop!}kU<75W-yz(M< ze+8VXDZhG<0xXG9%EcI9HkmcKieyeUY6!aAOqcf_;ENP0V*-@E(5;LP5f&KAC?VHW zfT?y#McC~WV}vO}cq>Jo-c`m3=*HVpIE<1Zt2}6q3U*CJ@v-dXEV*(4Etph&IeJ$` z-qNSwp&1%nwlRb5ARpdMjF*bLU?tQ0v+V-Pg6)jLlC7^Uqat+W@1)4ug-L7h!L=#j zQq~5W=yda~e3r7!s{p5%{s*-4%!l`7UBSgTBC&Kk_6`X+NY^?ePCVXOuCISgcTt27 z)PQF*TIHn=0ZXJOUid)6mkS^8&|UZ7sS>P<7fH~g=MW|s!M7RWyO3d@o4(PaRd=km z%-d~X`NqfE9tT)~zZAm5rve5KJgtbva4iH#kpNt-gvcZfld|J+aybNQ*-zfkkgG<^ zlPe;GV|qPASRuG4!gsCrM6ki=EfJaJ|L8C%1ocEgFNw(daZzr@KKCtWyFAAyS1Euy zwsz?5Y}YeaIP=?XrpGu3t?%2${x5erYl!!h;dZjae(yAz!OH^pD|`Hkb3#4rVpXcy zX{eMlmH80l5T5s#{4zIgzR{@o^AFY)^cq#X= zuE{m5*7nDc=|B$-su9#7KbDP!5l5-_6P5s9n+85U%oE5(^SLIq@6Wh}kn}{3454au$ znUXHXp?w(RK(AdETmnh@n*v+`(>z4_HePo#1w7ZJ2uLr&@Zt$6%{V+jr$EXt6kKsr?j5 zL%n|q2g8FssnNZ~MX^Ao@RW&p-WW1^NT9r<7jfKHFX{}ryxtR*j!`2gbB0tV2f%9 zEt6D<#(TSiL(!X3u7px*$hgf{>{}33s}4bz@n_dv%&d{JZQ{+SP#R>{eJ*wZvt$=| z9_E-?@)W9WuXhVvE4N&&XSdjB7&*&h1)fP0BN*Om%$Eiu%dIY(AMm^QcjJw%mfNuX z0*c50x!j86>R0iG<-@_>*^jM5xSKqJt(r@I$0=?rDXh3DK&lL8K=8Spm|I+<=s4zU zYjH8ieY>dRJ?@Q64liW|6X`9;{|r}?RSx381uh7YHiD6CpM?k2B?Kwg{jaNdKi3k@ zuS7&gzR5$`XzHTK@MSb@&I}+b4uRwJYEoqI{gd=oC&yqqCfNrzwYV-iOFB&ECNW~jcAvZRz9^?M0 zzVBt&Pl@DNFTvQ>H<%@T16pCYg2y$nY=X8l9lnRV=cR#+Q~3;rRul_p$E_Z+!I&B)*!|%2BCg81nX(hNo2Y}TXJBTB$V1i0lMy(9+irW<&oZpywvwfL<=akh8Be&~b zEwG??V3Jq8j65f2ArDM<&RO?2I3j&Fd?kaCS!^-V9nRX0xODQI{vAZfiza9`;2_?v zU{C|h)JfCXQTNm;oso)kFjufc+M^pA3*7G4J1_D{a$2!M_& z6`Mt54EMvlam*<_F)@KfQPy5B>7S~(6ML!ea`J++H*8(?Nh1-Fb3uXAv55MT4fY;; z8Pf7*&pQ)k%nuA7V&$N3rk{yh(7_-4yN(BVxVXfN&Gfo6x4~gGT6rkNpTvomo2jY7 z$|JQ=>9^c12USoDf#J=L%9QLZbPHU(^z2^qLA9E;+OF)LPVx4& z4)1NdW%naUsa1E!OR$!QYuM#H183WMKdd;N8GV}rm+nW-AHa{gw+z_D>e*dh;GBB< z;>#Seb^L5u7#o=H4Y@aT9D^UsMOh6d% z0I5;L2nUP=eVvz8;t`YJ{UwE?F=Z`e8km3IpYmJ4jHfzoC%kq&c)^P2IR}OOd-z89 zE;nZsIL}6-Z*1YsVdJrw$a`LZ@0mVk zLKI4yT0G}pN#feb#2Vt{75li_SYE{EO~~D+;$BE+X$nHqtN304=1WfLMQ_*x-g<2t zbizIRIh_t)ipVC|>W0ft^zApucIG$sW8D<*nh;uKCrSP(hU)IX9tM^pvh&<7T>mDh z{-q2u)yJI;bJH@(Hyy*MZMT(a9J7K7Q!{KdiRJVPDXQ_REgY^>dlHdu^P8zriNE5#BO31LW8?4VUmXYwhd&S?rybS#CiFH(nvfF(s=q=ZO~+ z;L5C9(kT&91O9zmY>gzOPanA`~x>4L5T*0VnGwAzA8?XY&AnB zKT3|gp6;ti0xMw2Swn9A7Ew&An;K>m7@Gq+C1<7WHUdWM&$vEob3OjM@K*I}hjv2{CW)b0H@@vZ+Cs3W=-S;TjM7$*Ki2l zw_!&WF7z5ZGEe8Lk=W#`j2e8c!XR$o2(TuL5Sx5Gj!*1lN+}pD?pGMZyE$~PFPDi3 z-9+)dA1|BjWbxKl7Fx4=`3FmEw1=CCtJb~zQdy0n@>dAztHr(8tKxRsFy0&JW;!2? zyR&E4n?hxwODWjgSUWNPyRMU&HjwDYABI?<%2Dk5nwG{gU=v7Wy669ko8T?E5bU)6 z+IJLGs|&dl`}Efo6d@Fn^qTR0pZ@Se_P|~#mwr=Sn3}@TYYT!bA5LI|S!IR=KZ~v{(_u;Z zXr43Rf{d~@`d@`s5DK{Q*iso`h!GBRyolHPkqNGTDo*7Rgj2FA*LFOw!p!9q%ql>S ziebfH!L4GZZ%T$0n~&$2)XZ{`Q?PBckADl!`F!4#Ec5Ng^U7D5f=j;5A7_T4ACO@w zbVF0w=)|1cl>D3sYdnu9 zBe2q}+^h_)KG8RxWu>ii)7USXQwSO(w*6yavRIaM6ADJAPFZ|W*5-P6=LzD}SYn1; zF-pc>x=`3GDSLeF1^}Ct1zEDP_h6AJ!E1(i5zlJ0bMPK-TJ~q@ulMGwPd;&DU)*U$ zW>!YJ*u{!%_h_^WcjJr%`2tA6j2zJSdlkb&b7K#t5T+?~nxXD4bO}_kirA{Qm)_S?qeDsDo z@viig=sI`fa61pH1P2StyO7$l?J<=@)NF*qbqaqzYVh|K&Pwx>rw@y7`cVSM%;&D6 z&nSDvJ{@JBq^oIAD6{M$@h#i}I>3*!$I*?McQZ^2OigA!v;5EM>G1W7@lQU8@Javd8RX)qUPgp@b+9T z-Ipax9T0P=_7pQSpCa+Y^Ehk@1Ts;Y{rbFs^vuSinGU9ba^^6$+6efN9-e-WWz*`& z`ByVDopTnGL>W-g-Yn5$r)SB|A9ClU5l)>oafp9*#$esRG6>bZYO8qKJY$jQ{EaNG zIKRe3J&=x2nxDI~g437r1nZ&uz2;FFQUEc*7fhnK)^6VjrW=^b^TM)^y-2m>gWt%b zcURM^j!$+hmr*QWZcg)!;p%WI$BAxwq8sPnEKQtS3USsDo*(sRyR(yK_p+mAqsK!9 zyHe=c%8BcTwv<2eeXV1g(#3SIExLkWPaDrSnm^(Pdb;u1sL{YJzq}b9lv;VXvb1vl zQESpcsjJ{!>(tZ@fQ&gpbtR;~;5^k8tF`|Fk5rX&nMsYpl)>K)R@DdTt#|Xc(%ukT QTVLWO!?#v%-TM6h0LSrR$p8QV diff --git a/priv/static/adminfe/static/js/chunk-e404.554bc2e3.js b/priv/static/adminfe/static/js/chunk-e404.554bc2e3.js new file mode 100644 index 0000000000000000000000000000000000000000..769e9f4f9d0f72774334d74aa44408d3597d1c88 GIT binary patch literal 19723 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 zss∋+7h9Y!+|}!{+DscknDnLZF*o{ur|?kvnn8biai^rr&on#`v06l=@yobe5Re zJ1L=dRc+dI;-1m~{ zFJS!=s^7x*&1CvrOkbS#yEuMI7eoB-ri;~N;bOJymSitkXPt?;WDSao)q%>QA#aBZ zQkvl2h$P4%E(PPf9ke`m8O8RQTioE!B_XK!gy>IKL%E_PSx$Rbh5x0fPr)?+9Y*B6HrftONWH{N}U(ZwLT07)-mzT{i# zV)-p}7Di)R<17PbKVVtkw4~BFFvr|PeOwj$Iw?v=RU(Sll(?v={Oio0ii6w0VHGvCu?$IJR-%6aCkNF;qZ_@DKA_atB?$B9+jbbj)>#B; z)-84+Hdk6D+0>UG6zkdo*HpJMK{2uF)g&oGvV@XXMPA<2PLRB=6jSP`6Dm1WNk_`E z_^M)oXHn^>l%-i#qu|BsoGBM5Dy!o5Cf6KFtKuZ;h-${3p1T~dp<%_G}4w-rhUX$>ZqjmY7U_D=`= zpOH_*>Qu=-aNv9O>D+LkyD>vzEx6R1JWz41+A8f|&)_N%vuQ*K8kIxJJkotx0T%B* z4hpo%uUo02ey3Hco=QuXEK zM(1=E816^^vei*FZ&B%@e4y4_m8OIns@i238)Pq0*qs}(#3N`Iq9?4n2F$UnBC3Z_ z#vaacm5tO?*-%oUt;9AEzjZlGd#;Rbxbw@3=w=3(bf}xMNjERzmf|=!FVw~D9N%3P zHzejA^@>9)&UvveZm2D|<2#c3DV6b?s_=7SO3VKV^OeMLl@^cj!c$d4@PScP(Fy@C zeD)v{J*&Pg3`FS=J{k`m&bpC@Hy*~rnKJs)(mPoQ)W50ThD3=-Ho5S#o9rg=yLgkN zbgHPt8xpI%A_C4*RZUyo1{ctd3Cyu>41!WHl90p--rZ2KTMk4 z6mQbv_QvDUC7HjUV1wz2j|x?PrUeGkUuu6rf3@{h3;2H9OZ0!p@~Zi({(XgXueQ6i zn1YnLL##gaGO?dv1yLaWm(F*a)m5DR4x39{{C{b2R~QBy%#Bf!2%7K(%9v$of#%p$8lIG7Uv+DhamnNQ<)Fu%?v^GZz~g?z;MM zqoKc%nN5}P`yoD3iqSApk;Ch>f`jVMmVMH#Fpye{SE`DQMLKS5``D zWR8HCJeL$x8*8Gf8{5r=3Eq?0qft4jK^o!80JfIzlacTy>R~qgD?fpjcr5K9pXIo4 z*my?!095H-6SeW~J{r@xsI8vRW%U#(`6%k*uUoMBPTY1eM=mJU%uL~I8?wxJ@{S8^ zPA|yR^W6M|!#<*f!0FDnhf$hKwQaj&^R5!K=U-2j@86XaGjyTyPIAeGo2Gh?GlGNF z@r8<~^byf-bzY^jLV}&kLwMjri6XMSH%evRgsg{@IY2>|^$IFo!pz-KRc^rig$j=A zXg#4}`0%HqH?Biqld?(FLVuvzlS@a|$NS?Xpa)@*hbQ(3VR1SWw?>^Zx0XNX}_eWGPm!;#m8{GPYGmQ6RkR0CD)O$Cr*K#@CGF+_JM4YC=W6~j!Y#_SYk zL+6aG-rQ6gI~0=FiZX+$oe?KmyltCmHL%1>_E1A zj2G7^`7^0@U;oINX1fc8GLNW#s63z02QXA(E+qV>B6~s~%#=i*T(c<3QHy5Uep<7x z-qU5u^i>p9m-%ic+M}YQSr`r}rUw9c#aOERl}F>9js8^Wm4Fuu#4dd+bd$!u7et!) zeq)zKrFN%Er|GW~n$zhmE0y1`(<>aOt*bhjS3m7U*`rDCGC_#d+4y35JC?8wxuwvV6E*4V0dRz3? z8?F;M0|YU*((K|X83L7qHy%I12hZ9HP8X~oJ~b7WZ2j;RA-@ml zObZJ*bLsEsvYM>_>aiIfzC8P{wYC4Q<>?sjnD@NV2v3iW58id`UtQLE{P K^V?SJjs6esca59? literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/chunk-e404.554bc2e3.js.map b/priv/static/adminfe/static/js/chunk-e404.554bc2e3.js.map new file mode 100644 index 0000000000000000000000000000000000000000..e8214adbb42b6fa50e5c7d443424905148bcb185 GIT binary patch literal 75596 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 O2%m4_(bG%((Z2v_2pr)6 diff --git a/priv/static/adminfe/static/js/chunk-e458.bb460d81.js.map b/priv/static/adminfe/static/js/chunk-e458.bb460d81.js.map deleted file mode 100644 index 89f05fb991b8168a2835b4bc2f60b0d4afb046e0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 57478 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 F{}0OeWOM)k diff --git a/priv/static/adminfe/static/js/runtime.5bae86dc.js b/priv/static/adminfe/static/js/runtime.5bae86dc.js new file mode 100644 index 0000000000000000000000000000000000000000..e5fb1554bddfdbab29f3a874b18cb631e4e277f6 GIT binary patch literal 4229 zcmbW4TaVkg6@cISD?BV3G z>*{2Z1quwtyoiVALVkzmPBog_!e~$8Q{SFxPeadC-fyg$SyB;}>b-v;3VwnE$>C>t zAVunvGTk3`#VbZjsXy9$S1cB|O4+_RkY-;U=JS#sn>zPg2PEL@XzK5ra62pXOyQri zPm)`Dtc*4G`6y}5>YtzVKPq#Sm3^k1m8GYM#q+r*_x6w~_=m3&#C$G5)m_zDDqHWIOrE-- z^i*s$DYth4=_a?Q%cXaibc?QD=lxp3$=GJ#Jz6>s3}Y8YW!h`vj*{C;JN2r)JiONJ zA#iQHb$)TJ`TT@<-ssQwe&P$X4M^9GDYyx(q%5*5R$AVB2b`QLtcL3A4h6t0+Hq2hyxHXJDSvy3I1Y#5J%V11)< zr(zo~k5vthiAAoADCBn!A4#)XO@Iun<4^4k!jNF-ttC8IuV ztPzW1e;Ke*PH+u63jMWUpwb|}jXqLwB(g*;hw=6pAms-t=1b7iym|lpk{+?}U(&BV zHz;CC2AgD!T%;k&ysi=4enyWA3-?|a-aJZe=|^ebmC`lqAD(|gZ1;XH?1{C~n zBWs&lNL>xSfC?@=X+{3zWaN?wgdx4S3@EjDn-`_<1ioNubE!un;gh3s>R&MKQbhxO z=YL0Rj-kPE$2AW5AMa#Y%iidh8E5f{!?^>odrj=&f_D=Escknh@U9q)_#!?vuZp8} zhlKK+RkgG!YOK0nBciCwil(t?u=EL|n$PEo!WWa{t&MhsaFZAoKoG`&L6+){iMwME zdu9v%@Tq=4i~p0x5-<|*P1w^|Z88={u76A8LbIPfHf|=bo+KRj-oK*OF1U0j>a2+aSYBG zzVB$9K<*EDFjBpvFn9F4CsN3t&(RdSNkxW*sr}3v6 zUEo~aHasEd;mf0=Np6GIY0i;AZbIGXl(^>V{HkcoBE+C+cbe4dNJmOeOq> z_bmJuV^rYz?p{`m8V{O~)VlIQNL6Ur7Zq-foTP@7TCuE@TX@U@ueaQ(%IE~^Knc|7 zcC|ovsWZ{2*@x%H%s>ikf2lPeAf@V$kdNo{Z|(T?w*?|G7LpO`eweW%y*V!cJbN5HboyJ8j z@I395YZ>inIO^Dm1Bby;{)XzDAn+b*N{hbqcx$`_tCv@W>Z=b&CrdokpsrMu7$WU) zV##poAnEoc6BI2MniU!fLU#o%WU+3FJ0IzZkNWcD3u|8wVn?a8)BUo2VRERLoL-tB zprXb^yX_{NaL2IMvUA|^yS1)c;A9+APQlhf-hlZ!}lx z!}9@3{T6T{&3CS<_Mp7p{pI1|ud~`T6_-E3y92JqzyA4S+EubN%G}FZaQdmHkL>tA Dw)$dl literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/runtime.5bae86dc.js.map b/priv/static/adminfe/static/js/runtime.5bae86dc.js.map new file mode 100644 index 0000000000000000000000000000000000000000..46c6380d92bf58780ed94c3252154f463052d502 GIT binary patch literal 17240 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 diff --git a/priv/static/adminfe/static/js/runtime.b08eb412.js.map b/priv/static/adminfe/static/js/runtime.b08eb412.js.map deleted file mode 100644 index 62f70ee3e8e43bcab24ae3bdb524e4db3292185e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16879 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 Date: Fri, 26 Jun 2020 05:33:59 +0200 Subject: [PATCH 379/401] nodeinfo: Fix MRF transparency --- lib/pleroma/web/nodeinfo/nodeinfo.ex | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/lib/pleroma/web/nodeinfo/nodeinfo.ex b/lib/pleroma/web/nodeinfo/nodeinfo.ex index d26b7c938..f7ab6d86a 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo.ex @@ -6,30 +6,19 @@ defmodule Pleroma.Web.Nodeinfo.Nodeinfo do alias Pleroma.Config alias Pleroma.Stats alias Pleroma.User - alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.Federator.Publisher + alias Pleroma.Web.MastodonAPI.InstanceView # returns a nodeinfo 2.0 map, since 2.1 just adds a repository field # under software. def get_nodeinfo("2.0") do stats = Stats.get_stats() - quarantined = Config.get([:instance, :quarantined_instances], []) - staff_accounts = User.all_superusers() |> Enum.map(fn u -> u.ap_id end) - federation_response = - if Config.get([:instance, :mrf_transparency]) do - {:ok, data} = MRF.describe() - - data - |> Map.merge(%{quarantined_instances: quarantined}) - else - %{} - end - |> Map.put(:enabled, Config.get([:instance, :federating])) + federation = InstanceView.federation() features = [ @@ -86,7 +75,7 @@ def get_nodeinfo("2.0") do enabled: false }, staffAccounts: staff_accounts, - federation: federation_response, + federation: federation, pollLimits: Config.get([:instance, :poll_limits]), postFormats: Config.get([:instance, :allowed_post_formats]), uploadLimits: %{ From c3383d4fab6181d9f605a6058805333611534398 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 26 Jun 2020 11:58:40 +0200 Subject: [PATCH 380/401] BlockValidator: Restore old behavior for incoming blocks. --- .../object_validators/block_validator.ex | 13 +++++++++++++ lib/pleroma/web/activity_pub/side_effects.ex | 1 - test/web/activity_pub/object_validator_test.exs | 8 +++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/block_validator.ex b/lib/pleroma/web/activity_pub/object_validators/block_validator.ex index 1dde77198..1989585b7 100644 --- a/lib/pleroma/web/activity_pub/object_validators/block_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/block_validator.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.BlockValidator do use Ecto.Schema alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.User import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @@ -32,6 +33,7 @@ def validate_data(cng) do |> validate_inclusion(:type, ["Block"]) |> validate_actor_presence() |> validate_actor_presence(field_name: :object) + |> validate_block_acceptance() end def cast_and_validate(data) do @@ -39,4 +41,15 @@ def cast_and_validate(data) do |> cast_data |> validate_data end + + def validate_block_acceptance(cng) do + actor = get_field(cng, :actor) |> User.get_cached_by_ap_id() + + if actor.local || Pleroma.Config.get([:activitypub, :unfollow_blocked], true) do + cng + else + cng + |> add_error(:actor, "Not accepting remote blocks") + end + end end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 48350d2b3..5cc2eb378 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -29,7 +29,6 @@ def handle( ) do with %User{} = blocker <- User.get_cached_by_ap_id(blocking_user), %User{} = blocked <- User.get_cached_by_ap_id(blocked_user) do - User.unfollow(blocker, blocked) User.block(blocker, blocked) end diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index e96552763..a3d43ef3c 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -657,7 +657,7 @@ test "returns an error if the object can't be updated by the actor", %{ describe "blocks" do setup do - user = insert(:user) + user = insert(:user, local: false) blocked = insert(:user) {:ok, valid_block, []} = Builder.block(user, blocked) @@ -680,5 +680,11 @@ test "returns an error if we don't know the blocked user", %{ assert {:error, _cng} = ObjectValidator.validate(block, []) end + + test "returns an error if don't accept remote blocks", %{valid_block: valid_block} do + clear_config([:activitypub, :unfollow_blocked], false) + + assert {:error, _cng} = ObjectValidator.validate(valid_block, []) + end end end From 15a8b703185c685fc3d25a381fcb9dee522c78bf Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 26 Jun 2020 12:06:00 +0200 Subject: [PATCH 381/401] User: Don't unfollow on block when the relevant setting is set. --- lib/pleroma/user.ex | 3 ++- .../object_validators/block_validator.ex | 13 ------------- test/web/activity_pub/object_validator_test.exs | 6 ------ test/web/activity_pub/side_effects_test.exs | 16 ++++++++++++++++ 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index c3e2a89ad..9d5c61e79 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1309,7 +1309,8 @@ def block(%User{} = blocker, %User{} = blocked) do unsubscribe(blocked, blocker) - if following?(blocked, blocker), do: unfollow(blocked, blocker) + unfollowing_blocked = Config.get([:activitypub, :unfollow_blocked], true) + if unfollowing_blocked && following?(blocked, blocker), do: unfollow(blocked, blocker) {:ok, blocker} = update_follower_count(blocker) {:ok, blocker, _} = Participation.mark_all_as_read(blocker, blocked) diff --git a/lib/pleroma/web/activity_pub/object_validators/block_validator.ex b/lib/pleroma/web/activity_pub/object_validators/block_validator.ex index 1989585b7..1dde77198 100644 --- a/lib/pleroma/web/activity_pub/object_validators/block_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/block_validator.ex @@ -6,7 +6,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.BlockValidator do use Ecto.Schema alias Pleroma.EctoType.ActivityPub.ObjectValidators - alias Pleroma.User import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @@ -33,7 +32,6 @@ def validate_data(cng) do |> validate_inclusion(:type, ["Block"]) |> validate_actor_presence() |> validate_actor_presence(field_name: :object) - |> validate_block_acceptance() end def cast_and_validate(data) do @@ -41,15 +39,4 @@ def cast_and_validate(data) do |> cast_data |> validate_data end - - def validate_block_acceptance(cng) do - actor = get_field(cng, :actor) |> User.get_cached_by_ap_id() - - if actor.local || Pleroma.Config.get([:activitypub, :unfollow_blocked], true) do - cng - else - cng - |> add_error(:actor, "Not accepting remote blocks") - end - end end diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index a3d43ef3c..f38bf7e08 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -680,11 +680,5 @@ test "returns an error if we don't know the blocked user", %{ assert {:error, _cng} = ObjectValidator.validate(block, []) end - - test "returns an error if don't accept remote blocks", %{valid_block: valid_block} do - clear_config([:activitypub, :unfollow_blocked], false) - - assert {:error, _cng} = ObjectValidator.validate(valid_block, []) - end end end diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 36792f015..af27c34b4 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -87,6 +87,22 @@ test "it unfollows and blocks", %{user: user, blocked: blocked, block: block} do refute User.following?(blocked, user) assert User.blocks?(user, blocked) end + + test "it blocks but does not unfollow if the relevant setting is set", %{ + user: user, + blocked: blocked, + block: block + } do + clear_config([:activitypub, :unfollow_blocked], false) + assert User.following?(user, blocked) + assert User.following?(blocked, user) + + {:ok, _, _} = SideEffects.handle(block) + + refute User.following?(user, blocked) + assert User.following?(blocked, user) + assert User.blocks?(user, blocked) + end end describe "update users" do From 7ed229641667f52dd82eb7c388ea28e79e09e507 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 26 Jun 2020 13:04:15 +0200 Subject: [PATCH 382/401] Nodeinfo: Add chat information back in. --- lib/pleroma/web/nodeinfo/nodeinfo.ex | 30 +--------------------------- test/web/node_info_test.exs | 3 ++- 2 files changed, 3 insertions(+), 30 deletions(-) diff --git a/lib/pleroma/web/nodeinfo/nodeinfo.ex b/lib/pleroma/web/nodeinfo/nodeinfo.ex index f7ab6d86a..47fa46376 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo.ex @@ -19,35 +19,7 @@ def get_nodeinfo("2.0") do |> Enum.map(fn u -> u.ap_id end) federation = InstanceView.federation() - - features = - [ - "pleroma_api", - "mastodon_api", - "mastodon_api_streaming", - "polls", - "pleroma_explicit_addressing", - "shareable_emoji_packs", - "multifetch", - "pleroma:api/v1/notifications:include_types_filter", - if Config.get([:media_proxy, :enabled]) do - "media_proxy" - end, - if Config.get([:gopher, :enabled]) do - "gopher" - end, - if Config.get([:chat, :enabled]) do - "chat" - end, - if Config.get([:instance, :allow_relay]) do - "relay" - end, - if Config.get([:instance, :safe_dm_mentions]) do - "safe_dm_mentions" - end, - "pleroma_emoji_reactions" - ] - |> Enum.filter(& &1) + features = InstanceView.features() %{ version: "2.0", diff --git a/test/web/node_info_test.exs b/test/web/node_info_test.exs index 8b3b6177d..06b33607f 100644 --- a/test/web/node_info_test.exs +++ b/test/web/node_info_test.exs @@ -142,7 +142,8 @@ test "it shows default features flags", %{conn: conn} do "shareable_emoji_packs", "multifetch", "pleroma_emoji_reactions", - "pleroma:api/v1/notifications:include_types_filter" + "pleroma:api/v1/notifications:include_types_filter", + "pleroma_chat_messages" ] assert MapSet.subset?( From 4a7a34ae8c2ad12b2b9903c1d70bfe85d10af49e Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 26 Jun 2020 14:47:38 +0200 Subject: [PATCH 383/401] Preloading: Return correct data for statusnet stuff. --- lib/pleroma/web/preload/status_net.ex | 9 +++++---- test/web/preload/status_net_test.exs | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/preload/status_net.ex b/lib/pleroma/web/preload/status_net.ex index 367442d5c..810ad512b 100644 --- a/lib/pleroma/web/preload/status_net.ex +++ b/lib/pleroma/web/preload/status_net.ex @@ -4,7 +4,7 @@ defmodule Pleroma.Web.Preload.Providers.StatusNet do alias Pleroma.Web.Preload.Providers.Provider - alias Pleroma.Web.TwitterAPI.UtilView + alias Pleroma.Web.TwitterAPI.UtilController @behaviour Provider @config_url :"/api/statusnet/config.json" @@ -16,9 +16,10 @@ def generate_terms(_params) do end defp build_config_tag(acc) do - instance = Pleroma.Config.get(:instance) - info_data = UtilView.status_net_config(instance) + resp = + Plug.Test.conn(:get, @config_url |> to_string()) + |> UtilController.config(nil) - Map.put(acc, @config_url, info_data) + Map.put(acc, @config_url, resp.resp_body) end end diff --git a/test/web/preload/status_net_test.exs b/test/web/preload/status_net_test.exs index ab6823a7e..2cdc82930 100644 --- a/test/web/preload/status_net_test.exs +++ b/test/web/preload/status_net_test.exs @@ -9,6 +9,7 @@ defmodule Pleroma.Web.Preload.Providers.StatusNetTest do setup do: {:ok, StatusNet.generate_terms(nil)} test "it renders the info", %{"/api/statusnet/config.json": info} do - assert info =~ "Pleroma" + assert {:ok, res} = Jason.decode(info) + assert res["site"] end end From a2002ebb6393d53030d5fc565bae90f3fedd48a8 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 26 Jun 2020 14:48:23 +0200 Subject: [PATCH 384/401] Preloading: Fix nodeinfo url. --- lib/pleroma/web/preload/instance.ex | 2 +- test/web/preload/instance_test.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/preload/instance.ex b/lib/pleroma/web/preload/instance.ex index 0b6fd3313..3b95fe403 100644 --- a/lib/pleroma/web/preload/instance.ex +++ b/lib/pleroma/web/preload/instance.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Web.Preload.Providers.Instance do @behaviour Provider @instance_url :"/api/v1/instance" @panel_url :"/instance/panel.html" - @nodeinfo_url :"/nodeinfo/2.0" + @nodeinfo_url :"/nodeinfo/2.0.json" @impl Provider def generate_terms(_params) do diff --git a/test/web/preload/instance_test.exs b/test/web/preload/instance_test.exs index 42a0d87bc..51b9dc549 100644 --- a/test/web/preload/instance_test.exs +++ b/test/web/preload/instance_test.exs @@ -25,7 +25,7 @@ test "it renders the panel", %{"/instance/panel.html": panel} do ) end - test "it renders the node_info", %{"/nodeinfo/2.0": nodeinfo} do + test "it renders the node_info", %{"/nodeinfo/2.0.json": nodeinfo} do %{ metadata: metadata, version: "2.0" From 1566543bec70e6497df77ed83bf4d3cc39c116eb Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 26 Jun 2020 20:10:47 +0200 Subject: [PATCH 385/401] object/fetcher: Pass full Transmogrifier error --- lib/pleroma/object/fetcher.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 263ded5dd..3e2949ee2 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -83,8 +83,8 @@ def fetch_object_from_id(id, options \\ []) do {:transmogrifier, {:error, {:reject, nil}}} -> {:reject, nil} - {:transmogrifier, _} -> - {:error, "Transmogrifier failure."} + {:transmogrifier, _} = e -> + {:error, e} {:object, data, nil} -> reinject_object(%Object{}, data) From 0313520cd2164e8abe671c7a0663246366ee30e9 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 27 Jun 2020 12:18:37 +0200 Subject: [PATCH 386/401] Config: Reduce default preloaders to configuration endpoints. Fetching the timeline is a bit heavy to do by default. --- config/config.exs | 2 -- 1 file changed, 2 deletions(-) diff --git a/config/config.exs b/config/config.exs index 5b1c576e7..9b550920c 100644 --- a/config/config.exs +++ b/config/config.exs @@ -437,8 +437,6 @@ config :pleroma, Pleroma.Web.Preload, providers: [ Pleroma.Web.Preload.Providers.Instance, - Pleroma.Web.Preload.Providers.User, - Pleroma.Web.Preload.Providers.Timelines, Pleroma.Web.Preload.Providers.StatusNet ] From efb5d64e5089ab59d8304f49f7c92fcab6e00b86 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 29 Jun 2020 02:39:26 +0200 Subject: [PATCH 387/401] differences_in_mastoapi_responses: Update account fields --- docs/API/differences_in_mastoapi_responses.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 7c3546f4f..c100ae83b 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -51,11 +51,14 @@ The `id` parameter can also be the `nickname` of the user. This only works in th Has these additional fields under the `pleroma` object: +- `ap_id`: nullable URL string, ActivityPub id of the user +- `background_image`: nullable URL string, background image of the user - `tags`: Lists an array of tags for the user -- `relationship{}`: Includes fields as documented for Mastodon API https://docs.joinmastodon.org/entities/relationship/ +- `relationship` (object): Includes fields as documented for Mastodon API https://docs.joinmastodon.org/entities/relationship/ - `is_moderator`: boolean, nullable, true if user is a moderator - `is_admin`: boolean, nullable, true if user is an admin - `confirmation_pending`: boolean, true if a new user account is waiting on email confirmation to be activated +- `hide_favorites`: boolean, true when the user has hiding favorites enabled - `hide_followers`: boolean, true when the user has follower hiding enabled - `hide_follows`: boolean, true when the user has follow hiding enabled - `hide_followers_count`: boolean, true when the user has follower stat hiding enabled @@ -66,6 +69,7 @@ Has these additional fields under the `pleroma` object: - `allow_following_move`: boolean, true when the user allows automatically follow moved following accounts - `unread_conversation_count`: The count of unread conversations. Only returned to the account owner. - `unread_notifications_count`: The count of unread notifications. Only returned to the account owner. +- `notification_settings`: object, can be absent. See `/api/pleroma/notification_settings` for the parameters/keys returned. ### Source From 9f51b03eed85d4a3ea24e1d449fcb4969f299096 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 29 Jun 2020 03:31:33 +0200 Subject: [PATCH 388/401] ApiSpec.Schemas.Account: import description from differences_in_mastoapi_responses --- lib/pleroma/web/api_spec/schemas/account.ex | 83 +++++++++++++++++---- 1 file changed, 67 insertions(+), 16 deletions(-) diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index d54e2158d..84f18f1b6 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -40,20 +40,53 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do pleroma: %Schema{ type: :object, properties: %{ - allow_following_move: %Schema{type: :boolean}, - background_image: %Schema{type: :string, nullable: true}, + allow_following_move: %Schema{ + type: :boolean, + description: "whether the user allows automatically follow moved following accounts" + }, + background_image: %Schema{type: :string, nullable: true, format: :uri}, chat_token: %Schema{type: :string}, - confirmation_pending: %Schema{type: :boolean}, + confirmation_pending: %Schema{ + type: :boolean, + description: + "whether the user account is waiting on email confirmation to be activated" + }, hide_favorites: %Schema{type: :boolean}, - hide_followers_count: %Schema{type: :boolean}, - hide_followers: %Schema{type: :boolean}, - hide_follows_count: %Schema{type: :boolean}, - hide_follows: %Schema{type: :boolean}, - is_admin: %Schema{type: :boolean}, - is_moderator: %Schema{type: :boolean}, + hide_followers_count: %Schema{ + type: :boolean, + description: "whether the user has follower stat hiding enabled" + }, + hide_followers: %Schema{ + type: :boolean, + description: "whether the user has follower hiding enabled" + }, + hide_follows_count: %Schema{ + type: :boolean, + description: "whether the user has follow stat hiding enabled" + }, + hide_follows: %Schema{ + type: :boolean, + description: "whether the user has follow hiding enabled" + }, + is_admin: %Schema{ + type: :boolean, + description: "whether the user is an admin of the local instance" + }, + is_moderator: %Schema{ + type: :boolean, + description: "whether the user is a moderator of the local instance" + }, skip_thread_containment: %Schema{type: :boolean}, - tags: %Schema{type: :array, items: %Schema{type: :string}}, - unread_conversation_count: %Schema{type: :integer}, + tags: %Schema{ + type: :array, + items: %Schema{type: :string}, + description: + "List of tags being used for things like extra roles or moderation(ie. marking all media as nsfw all)." + }, + unread_conversation_count: %Schema{ + type: :integer, + description: "The count of unread conversations. Only returned to the account owner." + }, notification_settings: %Schema{ type: :object, properties: %{ @@ -66,7 +99,9 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do }, relationship: AccountRelationship, settings_store: %Schema{ - type: :object + type: :object, + description: + "A generic map of settings for frontends. Opaque to the backend. Only returned in `verify_credentials` and `update_credentials`" } } }, @@ -74,16 +109,32 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do type: :object, properties: %{ fields: %Schema{type: :array, items: AccountField}, - note: %Schema{type: :string}, + note: %Schema{ + type: :string, + description: + "Plaintext version of the bio without formatting applied by the backend, used for editing the bio." + }, privacy: VisibilityScope, sensitive: %Schema{type: :boolean}, pleroma: %Schema{ type: :object, properties: %{ actor_type: ActorType, - discoverable: %Schema{type: :boolean}, - no_rich_text: %Schema{type: :boolean}, - show_role: %Schema{type: :boolean} + discoverable: %Schema{ + type: :boolean, + description: + "whether the user allows discovery of the account in search results and other services." + }, + no_rich_text: %Schema{ + type: :boolean, + description: + "whether the HTML tags for rich-text formatting are stripped from all statuses requested from the API." + }, + show_role: %Schema{ + type: :boolean, + description: + "whether the user wants their role (e.g admin, moderator) to be shown" + } } } } From a19f8778afddb7f504b08cedde752e37da52dc96 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 29 Jun 2020 11:06:20 +0200 Subject: [PATCH 389/401] User preloader: Put user info at correct key --- lib/pleroma/web/preload/user.ex | 11 ++++++----- test/web/preload/user_test.exs | 14 +++++++------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/web/preload/user.ex b/lib/pleroma/web/preload/user.ex index 3a244845b..7fef0a4ac 100644 --- a/lib/pleroma/web/preload/user.ex +++ b/lib/pleroma/web/preload/user.ex @@ -3,11 +3,12 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Preload.Providers.User do + alias Pleroma.User alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.Preload.Providers.Provider @behaviour Provider - @account_url :"/api/v1/accounts" + @account_url_base :"/api/v1/accounts" @impl Provider def generate_terms(%{user: user}) do @@ -16,10 +17,10 @@ def generate_terms(%{user: user}) do def generate_terms(_params), do: %{} - def build_accounts_tag(acc, nil), do: acc - - def build_accounts_tag(acc, user) do + def build_accounts_tag(acc, %User{} = user) do account_data = AccountView.render("show.json", %{user: user, for: user}) - Map.put(acc, @account_url, account_data) + Map.put(acc, :"#{@account_url_base}/#{user.id}", account_data) end + + def build_accounts_tag(acc, _), do: acc end diff --git a/test/web/preload/user_test.exs b/test/web/preload/user_test.exs index 99232cdfa..68d69d977 100644 --- a/test/web/preload/user_test.exs +++ b/test/web/preload/user_test.exs @@ -9,13 +9,11 @@ defmodule Pleroma.Web.Preload.Providers.UserTest do describe "returns empty when user doesn't exist" do test "nil user specified" do - refute User.generate_terms(%{user: nil}) - |> Map.has_key?("/api/v1/accounts") + assert User.generate_terms(%{user: nil}) == %{} end test "missing user specified" do - refute User.generate_terms(%{user: :not_a_user}) - |> Map.has_key?("/api/v1/accounts") + assert User.generate_terms(%{user: :not_a_user}) == %{} end end @@ -23,11 +21,13 @@ test "missing user specified" do setup do user = insert(:user) - {:ok, User.generate_terms(%{user: user})} + terms = User.generate_terms(%{user: user}) + %{terms: terms, user: user} end - test "account is rendered", %{"/api/v1/accounts": accounts} do - assert %{acct: user, username: user} = accounts + test "account is rendered", %{terms: terms, user: user} do + account = terms[:"/api/v1/accounts/#{user.id}"] + assert %{acct: user, username: user} = account end end end From 8630a6c7f52a68ab32025b1c80a6398599908c68 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 29 Jun 2020 11:41:00 +0200 Subject: [PATCH 390/401] Preloaders: Use strings as keys. --- lib/pleroma/web/preload/instance.ex | 6 +++--- lib/pleroma/web/preload/status_net.ex | 2 +- lib/pleroma/web/preload/timelines.ex | 2 +- lib/pleroma/web/preload/user.ex | 4 ++-- test/web/preload/instance_test.exs | 6 +++--- test/web/preload/status_net_test.exs | 2 +- test/web/preload/timeline_test.exs | 2 +- test/web/preload/user_test.exs | 2 +- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/web/preload/instance.ex b/lib/pleroma/web/preload/instance.ex index 3b95fe403..b34d7cf37 100644 --- a/lib/pleroma/web/preload/instance.ex +++ b/lib/pleroma/web/preload/instance.ex @@ -8,9 +8,9 @@ defmodule Pleroma.Web.Preload.Providers.Instance do alias Pleroma.Web.Preload.Providers.Provider @behaviour Provider - @instance_url :"/api/v1/instance" - @panel_url :"/instance/panel.html" - @nodeinfo_url :"/nodeinfo/2.0.json" + @instance_url "/api/v1/instance" + @panel_url "/instance/panel.html" + @nodeinfo_url "/nodeinfo/2.0.json" @impl Provider def generate_terms(_params) do diff --git a/lib/pleroma/web/preload/status_net.ex b/lib/pleroma/web/preload/status_net.ex index 810ad512b..9b62f87a2 100644 --- a/lib/pleroma/web/preload/status_net.ex +++ b/lib/pleroma/web/preload/status_net.ex @@ -7,7 +7,7 @@ defmodule Pleroma.Web.Preload.Providers.StatusNet do alias Pleroma.Web.TwitterAPI.UtilController @behaviour Provider - @config_url :"/api/statusnet/config.json" + @config_url "/api/statusnet/config.json" @impl Provider def generate_terms(_params) do diff --git a/lib/pleroma/web/preload/timelines.ex b/lib/pleroma/web/preload/timelines.ex index e531b8960..57de04051 100644 --- a/lib/pleroma/web/preload/timelines.ex +++ b/lib/pleroma/web/preload/timelines.ex @@ -8,7 +8,7 @@ defmodule Pleroma.Web.Preload.Providers.Timelines do alias Pleroma.Web.Preload.Providers.Provider @behaviour Provider - @public_url :"/api/v1/timelines/public" + @public_url "/api/v1/timelines/public" @impl Provider def generate_terms(params) do diff --git a/lib/pleroma/web/preload/user.ex b/lib/pleroma/web/preload/user.ex index 7fef0a4ac..b3d2e9b8d 100644 --- a/lib/pleroma/web/preload/user.ex +++ b/lib/pleroma/web/preload/user.ex @@ -8,7 +8,7 @@ defmodule Pleroma.Web.Preload.Providers.User do alias Pleroma.Web.Preload.Providers.Provider @behaviour Provider - @account_url_base :"/api/v1/accounts" + @account_url_base "/api/v1/accounts" @impl Provider def generate_terms(%{user: user}) do @@ -19,7 +19,7 @@ def generate_terms(_params), do: %{} def build_accounts_tag(acc, %User{} = user) do account_data = AccountView.render("show.json", %{user: user, for: user}) - Map.put(acc, :"#{@account_url_base}/#{user.id}", account_data) + Map.put(acc, "#{@account_url_base}/#{user.id}", account_data) end def build_accounts_tag(acc, _), do: acc diff --git a/test/web/preload/instance_test.exs b/test/web/preload/instance_test.exs index 51b9dc549..5bb6c5981 100644 --- a/test/web/preload/instance_test.exs +++ b/test/web/preload/instance_test.exs @@ -8,7 +8,7 @@ defmodule Pleroma.Web.Preload.Providers.InstanceTest do setup do: {:ok, Instance.generate_terms(nil)} - test "it renders the info", %{"/api/v1/instance": info} do + test "it renders the info", %{"/api/v1/instance" => info} do assert %{ description: description, email: "admin@example.com", @@ -18,14 +18,14 @@ test "it renders the info", %{"/api/v1/instance": info} do assert String.equivalent?(description, "Pleroma: An efficient and flexible fediverse server") end - test "it renders the panel", %{"/instance/panel.html": panel} do + test "it renders the panel", %{"/instance/panel.html" => panel} do assert String.contains?( panel, "

Welcome to Pleroma!

" ) end - test "it renders the node_info", %{"/nodeinfo/2.0.json": nodeinfo} do + test "it renders the node_info", %{"/nodeinfo/2.0.json" => nodeinfo} do %{ metadata: metadata, version: "2.0" diff --git a/test/web/preload/status_net_test.exs b/test/web/preload/status_net_test.exs index 2cdc82930..df7acdb11 100644 --- a/test/web/preload/status_net_test.exs +++ b/test/web/preload/status_net_test.exs @@ -8,7 +8,7 @@ defmodule Pleroma.Web.Preload.Providers.StatusNetTest do setup do: {:ok, StatusNet.generate_terms(nil)} - test "it renders the info", %{"/api/statusnet/config.json": info} do + test "it renders the info", %{"/api/statusnet/config.json" => info} do assert {:ok, res} = Jason.decode(info) assert res["site"] end diff --git a/test/web/preload/timeline_test.exs b/test/web/preload/timeline_test.exs index da6a3aded..fea95a6a4 100644 --- a/test/web/preload/timeline_test.exs +++ b/test/web/preload/timeline_test.exs @@ -9,7 +9,7 @@ defmodule Pleroma.Web.Preload.Providers.TimelineTest do alias Pleroma.Web.CommonAPI alias Pleroma.Web.Preload.Providers.Timelines - @public_url :"/api/v1/timelines/public" + @public_url "/api/v1/timelines/public" describe "unauthenticated timeliness when restricted" do setup do diff --git a/test/web/preload/user_test.exs b/test/web/preload/user_test.exs index 68d69d977..83f065e27 100644 --- a/test/web/preload/user_test.exs +++ b/test/web/preload/user_test.exs @@ -26,7 +26,7 @@ test "missing user specified" do end test "account is rendered", %{terms: terms, user: user} do - account = terms[:"/api/v1/accounts/#{user.id}"] + account = terms["/api/v1/accounts/#{user.id}"] assert %{acct: user, username: user} = account end end From e64d08439ea171f1090e0bfa927f3c83cb9522b0 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 29 Jun 2020 12:40:23 +0200 Subject: [PATCH 391/401] UpdateCredentialsTest: Add test for removing profile images. --- .../update_credentials_test.exs | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs index f67d294ba..31f0edf97 100644 --- a/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs +++ b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs @@ -216,10 +216,20 @@ test "updates the user's avatar", %{user: user, conn: conn} do filename: "an_image.jpg" } - conn = patch(conn, "/api/v1/accounts/update_credentials", %{"avatar" => new_avatar}) + res = + conn + |> patch("/api/v1/accounts/update_credentials", %{"avatar" => new_avatar}) - assert user_response = json_response_and_validate_schema(conn, 200) + assert user_response = json_response_and_validate_schema(res, 200) assert user_response["avatar"] != User.avatar_url(user) + + # Also removes it + res = + conn + |> patch("/api/v1/accounts/update_credentials", %{"avatar" => nil}) + + assert user_response = json_response_and_validate_schema(res, 200) + assert user_response["avatar"] == User.avatar_url(user) end test "updates the user's banner", %{user: user, conn: conn} do @@ -229,10 +239,21 @@ test "updates the user's banner", %{user: user, conn: conn} do filename: "an_image.jpg" } - conn = patch(conn, "/api/v1/accounts/update_credentials", %{"header" => new_header}) + res = + conn + |> patch("/api/v1/accounts/update_credentials", %{"header" => new_header}) - assert user_response = json_response_and_validate_schema(conn, 200) + assert user_response = json_response_and_validate_schema(res, 200) assert user_response["header"] != User.banner_url(user) + + # Also removes it + + res = + conn + |> patch("/api/v1/accounts/update_credentials", %{"header" => nil}) + + assert user_response = json_response_and_validate_schema(res, 200) + assert user_response["header"] == User.banner_url(user) end test "updates the user's background", %{conn: conn} do @@ -242,13 +263,25 @@ test "updates the user's background", %{conn: conn} do filename: "an_image.jpg" } - conn = - patch(conn, "/api/v1/accounts/update_credentials", %{ + res = + conn + |> patch("/api/v1/accounts/update_credentials", %{ "pleroma_background_image" => new_header }) - assert user_response = json_response_and_validate_schema(conn, 200) + assert user_response = json_response_and_validate_schema(res, 200) assert user_response["pleroma"]["background_image"] + + # Also removes it + + res = + conn + |> patch("/api/v1/accounts/update_credentials", %{ + "pleroma_background_image" => nil + }) + + assert user_response = json_response_and_validate_schema(res, 200) + refute user_response["pleroma"]["background_image"] end test "requires 'write:accounts' permission" do From 6512ef6879a5f857f02479da1bad7242e916d918 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 29 Jun 2020 15:25:57 +0300 Subject: [PATCH 392/401] excluding attachment links from RichMedia --- lib/pleroma/html.ex | 2 +- test/html_test.exs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/html.ex b/lib/pleroma/html.ex index d78c5f202..dc1b9b840 100644 --- a/lib/pleroma/html.ex +++ b/lib/pleroma/html.ex @@ -109,7 +109,7 @@ def extract_first_external_url(object, content) do result = content |> Floki.parse_fragment!() - |> Floki.filter_out("a.mention,a.hashtag,a[rel~=\"tag\"]") + |> Floki.filter_out("a.mention,a.hashtag,a.attachment,a[rel~=\"tag\"]") |> Floki.attribute("a", "href") |> Enum.at(0) diff --git a/test/html_test.exs b/test/html_test.exs index 0a4b4ebbc..f8907c8b4 100644 --- a/test/html_test.exs +++ b/test/html_test.exs @@ -237,5 +237,19 @@ test "does not crash when there is an HTML entity in a link" do assert {:ok, nil} = HTML.extract_first_external_url(object, object.data["content"]) end + + test "skips attachment links" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: + "image.png" + }) + + object = Object.normalize(activity) + + assert {:ok, nil} = HTML.extract_first_external_url(object, object.data["content"]) + end end end From dc60b1ee583e59ab1a6808700b45992a41fecd8f Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Mon, 29 Jun 2020 16:22:54 +0300 Subject: [PATCH 393/401] updated swoosh --- mix.exs | 5 ++++- mix.lock | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/mix.exs b/mix.exs index b638be541..e2ab53bde 100644 --- a/mix.exs +++ b/mix.exs @@ -159,7 +159,10 @@ defp deps do {:cors_plug, "~> 1.5"}, {:ex_doc, "~> 0.21", only: :dev, runtime: false}, {:web_push_encryption, "~> 0.2.1"}, - {:swoosh, "~> 0.23.2"}, + {:swoosh, + git: "https://github.com/swoosh/swoosh", + ref: "c96e0ca8a00d8f211ec1f042a4626b09f249caa5", + override: true}, {:phoenix_swoosh, "~> 0.2"}, {:gen_smtp, "~> 0.13"}, {:websocket_client, git: "https://github.com/jeremyong/websocket_client.git", only: :test}, diff --git a/mix.lock b/mix.lock index 5ad49391d..4f2777fa7 100644 --- a/mix.lock +++ b/mix.lock @@ -104,9 +104,9 @@ "sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"}, - "swoosh": {:hex, :swoosh, "0.23.5", "bfd9404bbf5069b1be2ffd317923ce57e58b332e25dbca2a35dedd7820dfee5a", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "e3928e1d2889a308aaf3e42755809ac21cffd77cb58eef01cbfdab4ce2fd1e21"}, + "swoosh": {:git, "https://github.com/swoosh/swoosh", "c96e0ca8a00d8f211ec1f042a4626b09f249caa5", [ref: "c96e0ca8a00d8f211ec1f042a4626b09f249caa5"]}, "syslog": {:hex, :syslog, "1.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"}, - "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"}, + "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, "tesla": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/tesla.git", "61b7503cef33f00834f78ddfafe0d5d9dec2270b", [ref: "61b7503cef33f00834f78ddfafe0d5d9dec2270b"]}, "timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "f354efb2400dd7a80fd9eb6c8419068c4f632da4ac47f3d8822d6e33f08bc852"}, "trailing_format_plug": {:hex, :trailing_format_plug, "0.0.7", "64b877f912cf7273bed03379936df39894149e35137ac9509117e59866e10e45", [:mix], [{:plug, "> 0.12.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bd4fde4c15f3e993a999e019d64347489b91b7a9096af68b2bdadd192afa693f"}, From 979f02ec947443835f480d13bd1dbcf521743a71 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 29 Jun 2020 17:33:00 +0400 Subject: [PATCH 394/401] Fix CastAndValidate plug --- lib/pleroma/web/api_spec/cast_and_validate.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/cast_and_validate.ex b/lib/pleroma/web/api_spec/cast_and_validate.ex index bd9026237..fbfc27d6f 100644 --- a/lib/pleroma/web/api_spec/cast_and_validate.ex +++ b/lib/pleroma/web/api_spec/cast_and_validate.ex @@ -40,7 +40,7 @@ def call(%{private: %{open_api_spex: private_data}} = conn, %{ |> List.first() _ -> - nil + "application/json" end private_data = Map.put(private_data, :operation_id, operation_id) From 3aa04b81c4d558dfa8d3c35ab7db6041671ac121 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 29 Jun 2020 19:47:04 +0400 Subject: [PATCH 395/401] Test default "content-type" for CastAndValidate --- test/web/mastodon_api/controllers/account_controller_test.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index ebfcedd01..260ad2306 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -780,7 +780,6 @@ test "with notifications", %{conn: conn} do assert %{"id" => _id, "muting" => true, "muting_notifications" => true} = conn - |> put_req_header("content-type", "application/json") |> post("/api/v1/accounts/#{other_user.id}/mute") |> json_response_and_validate_schema(200) From 90083a754dc0bfe0c8a04fbaa3e78f68a848035e Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 29 Jun 2020 17:48:18 +0200 Subject: [PATCH 396/401] Notifications: Never return `nil` in the notification list. --- lib/pleroma/notification.ex | 1 + test/notification_test.exs | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 9ee9606be..58dcf880a 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -367,6 +367,7 @@ defp do_create_notifications(%Activity{} = activity, options) do do_send = do_send && user in enabled_receivers create_notification(activity, user, do_send) end) + |> Enum.filter(& &1) {:ok, notifications} end diff --git a/test/notification_test.exs b/test/notification_test.exs index 526f43fab..5389dabca 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -21,7 +21,19 @@ defmodule Pleroma.NotificationTest do alias Pleroma.Web.Push alias Pleroma.Web.Streamer + # TODO: Test there's no nil notifications + describe "create_notifications" do + test "never returns nil" do + user = insert(:user) + other_user = insert(:user, %{invisible: true}) + + {:ok, activity} = CommonAPI.post(user, %{status: "yeah"}) + {:ok, activity} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") + + refute {:ok, [nil]} == Notification.create_notifications(activity) + end + test "creates a notification for an emoji reaction" do user = insert(:user) other_user = insert(:user) From c01f4ca07f3a3e47fb6532c55128c427fbc1f77e Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 29 Jun 2020 17:52:56 +0200 Subject: [PATCH 397/401] Notification: Remove TODO. --- test/notification_test.exs | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/notification_test.exs b/test/notification_test.exs index 5389dabca..6add3f7eb 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -21,8 +21,6 @@ defmodule Pleroma.NotificationTest do alias Pleroma.Web.Push alias Pleroma.Web.Streamer - # TODO: Test there's no nil notifications - describe "create_notifications" do test "never returns nil" do user = insert(:user) From 09c5991f82e91878a940f5957ac993e1fca72545 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 29 Jun 2020 16:04:14 +0000 Subject: [PATCH 398/401] Apply suggestion to lib/pleroma/notification.ex --- lib/pleroma/notification.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 58dcf880a..2ef1a80c5 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -367,7 +367,7 @@ defp do_create_notifications(%Activity{} = activity, options) do do_send = do_send && user in enabled_receivers create_notification(activity, user, do_send) end) - |> Enum.filter(& &1) + |> Enum.reject(&is_nil/1) {:ok, notifications} end From 27542f19c60589d8deb5d9d7a59d2019b75026fa Mon Sep 17 00:00:00 2001 From: normandy Date: Tue, 30 Jun 2020 03:12:30 +0000 Subject: [PATCH 399/401] Use correct PostgreSQL version command in bug template --- .gitlab/issue_templates/Bug.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md index 66fbc510e..9ce9b6918 100644 --- a/.gitlab/issue_templates/Bug.md +++ b/.gitlab/issue_templates/Bug.md @@ -14,7 +14,7 @@ * Pleroma version (could be found in the "Version" tab of settings in Pleroma-FE): * Elixir version (`elixir -v` for from source installations, N/A for OTP): * Operating system: -* PostgreSQL version (`postgres -V`): +* PostgreSQL version (`psql -V`): ### Bug description From 2382a2a1511e1042d960946aacfde7a49fac9dd0 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 30 Jun 2020 11:35:54 +0200 Subject: [PATCH 400/401] Preload: Load the correct instance panel --- lib/pleroma/web/preload/instance.ex | 3 ++- test/fixtures/preload_static/instance/panel.html | 1 + test/web/preload/instance_test.exs | 11 +++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/preload_static/instance/panel.html diff --git a/lib/pleroma/web/preload/instance.ex b/lib/pleroma/web/preload/instance.ex index 0b6fd3313..5c6e33e47 100644 --- a/lib/pleroma/web/preload/instance.ex +++ b/lib/pleroma/web/preload/instance.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.Preload.Providers.Instance do alias Pleroma.Web.MastodonAPI.InstanceView alias Pleroma.Web.Nodeinfo.Nodeinfo alias Pleroma.Web.Preload.Providers.Provider + alias Pleroma.Plugs.InstanceStatic @behaviour Provider @instance_url :"/api/v1/instance" @@ -27,7 +28,7 @@ defp build_info_tag(acc) do end defp build_panel_tag(acc) do - instance_path = Path.join(:code.priv_dir(:pleroma), "static/instance/panel.html") + instance_path = InstanceStatic.file_path(@panel_url |> to_string()) if File.exists?(instance_path) do panel_data = File.read!(instance_path) diff --git a/test/fixtures/preload_static/instance/panel.html b/test/fixtures/preload_static/instance/panel.html new file mode 100644 index 000000000..fc58e4e93 --- /dev/null +++ b/test/fixtures/preload_static/instance/panel.html @@ -0,0 +1 @@ +HEY! diff --git a/test/web/preload/instance_test.exs b/test/web/preload/instance_test.exs index 42a0d87bc..df150d7be 100644 --- a/test/web/preload/instance_test.exs +++ b/test/web/preload/instance_test.exs @@ -25,6 +25,17 @@ test "it renders the panel", %{"/instance/panel.html": panel} do ) end + test "it works with overrides" do + clear_config([:instance, :static_dir], "test/fixtures/preload_static") + + %{"/instance/panel.html": panel} = Instance.generate_terms(nil) + + assert String.contains?( + panel, + "HEY!" + ) + end + test "it renders the node_info", %{"/nodeinfo/2.0": nodeinfo} do %{ metadata: metadata, From 8b7055e25e76565cd3376c0b5dda5e54d24881f0 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 30 Jun 2020 11:55:58 +0200 Subject: [PATCH 401/401] Credo fixes --- lib/pleroma/web/preload/instance.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/preload/instance.ex b/lib/pleroma/web/preload/instance.ex index 3d16f290b..50d1f3382 100644 --- a/lib/pleroma/web/preload/instance.ex +++ b/lib/pleroma/web/preload/instance.ex @@ -3,10 +3,10 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Preload.Providers.Instance do + alias Pleroma.Plugs.InstanceStatic alias Pleroma.Web.MastodonAPI.InstanceView alias Pleroma.Web.Nodeinfo.Nodeinfo alias Pleroma.Web.Preload.Providers.Provider - alias Pleroma.Plugs.InstanceStatic @behaviour Provider @instance_url "/api/v1/instance"

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 JSid(l8LOszvBna$ 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 U{9RV=eijd_8>q>Yeay0_KLod*r~m)} literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/chunk-0961.ef33e81b.js b/priv/static/adminfe/static/js/chunk-4e7e.91b5e73a.js similarity index 97% rename from priv/static/adminfe/static/js/chunk-0961.ef33e81b.js rename to priv/static/adminfe/static/js/chunk-4e7e.91b5e73a.js index e090bb93ca1c12523e6a62c72485fe31ec8dc23b..0fdf0de502fd1e8c34fe30f4922db90b301f8e37 100644 GIT binary patch delta 36 pcmeyN{zH9&4U0*tdFn=oCBh;ghMuKil4+{BaiU&Uv0iRs0RZh$3=04N delta 36 pcmeyN{zH9&4U2)Lnc+rS}@hDn>VrM4OY0EhVrDgXcg diff --git a/priv/static/adminfe/static/js/chunk-7f9e.c49aa694.js b/priv/static/adminfe/static/js/chunk-5118.7c48ad58.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-7f9e.c49aa694.js rename to priv/static/adminfe/static/js/chunk-5118.7c48ad58.js index 9fb60af236b6e13625297b5019e880249f5d0a87..2357e225d90f2f7bcf65ee11786bec48e0cec32d 100644 GIT binary patch delta 38 rcmbPtfN|ad#tAkoriO+V8y!x?i+~t<=E)`&i7BQQdRfJKxrqe;`hW~L delta 38 rcmbPtfN|ad#tAko=4qCx8y!x?i+~t<$tISGiDs52dRfJKxrqe;4hjto diff --git a/priv/static/adminfe/static/js/chunk-7f9e.c49aa694.js.map b/priv/static/adminfe/static/js/chunk-5118.7c48ad58.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-7f9e.c49aa694.js.map rename to priv/static/adminfe/static/js/chunk-5118.7c48ad58.js.map index 241c6cc21cda3fcc217f312a2530781878fb8f31..c29b4b170785f64e8e33c46eb1068c7fbec159cb 100644 GIT binary patch delta 28 kcmdmglx6=>mJRGFyrzbR7JBB%CKib)rWTt;QdaK+0F*Hb0ssI2 delta 28 kcmdmglx6=>mJRGFyyj_^sd~vKmWhdGmL{7;QdaK+0GnzGYXATM diff --git a/priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js b/priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js new file mode 100644 index 0000000000000000000000000000000000000000..a29b6daab41b9f286b7b8ff49188d22de4297782 GIT binary patch literal 24347 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 literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js.map b/priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js.map new file mode 100644 index 0000000000000000000000000000000000000000..d1aa2037fc464f0734b0c69ac8ec65be1396e6bd GIT binary patch literal 81471 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