Merge branch 'mediaproxy-decode' into 'develop'
authorrinpatch <rin@patch.cx>
Fri, 19 Mar 2021 08:52:12 +0000 (08:52 +0000)
committerrinpatch <rin@patch.cx>
Fri, 19 Mar 2021 08:52:12 +0000 (08:52 +0000)
Simple way to decode mediaproxy URLs

See merge request pleroma/pleroma!3364

74 files changed:
.gitlab-ci.yml
CHANGELOG.md
config/config.exs
config/description.exs
docs/configuration/cheatsheet.md
lib/mix/tasks/pleroma/database.ex
lib/pleroma/activity.ex
lib/pleroma/activity/ir/topics.ex
lib/pleroma/application.ex
lib/pleroma/config.ex
lib/pleroma/data_migration.ex [new file with mode: 0644]
lib/pleroma/delivery.ex
lib/pleroma/ecto_enums.ex
lib/pleroma/hashtag.ex [new file with mode: 0644]
lib/pleroma/migrators/hashtags_table_migrator.ex [new file with mode: 0644]
lib/pleroma/migrators/support/base_migrator.ex [new file with mode: 0644]
lib/pleroma/migrators/support/base_migrator_state.ex [new file with mode: 0644]
lib/pleroma/object.ex
lib/pleroma/pagination.ex
lib/pleroma/repo.ex
lib/pleroma/user.ex
lib/pleroma/web/activity_pub/activity_pub.ex
lib/pleroma/web/activity_pub/mrf.ex
lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex [new file with mode: 0644]
lib/pleroma/web/activity_pub/mrf/simple_policy.ex
lib/pleroma/web/activity_pub/mrf/tag_policy.ex
lib/pleroma/web/activity_pub/transmogrifier.ex
lib/pleroma/web/admin_api/controllers/user_controller.ex
lib/pleroma/web/admin_api/views/account_view.ex
lib/pleroma/web/api_spec.ex
lib/pleroma/web/api_spec/cast_and_validate.ex
lib/pleroma/web/api_spec/operations/admin/user_operation.ex [new file with mode: 0644]
lib/pleroma/web/api_spec/operations/status_operation.ex
lib/pleroma/web/api_spec/schemas/boolean_like.ex
lib/pleroma/web/common_api/activity_draft.ex
lib/pleroma/web/common_api/utils.ex
lib/pleroma/web/feed/feed_view.ex
lib/pleroma/web/mastodon_api/controllers/instance_controller.ex
lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex
lib/pleroma/web/mastodon_api/views/status_view.ex
lib/pleroma/web/pleroma_api/controllers/backup_controller.ex
lib/pleroma/web/pleroma_api/controllers/chat_controller.ex
lib/pleroma/web/pleroma_api/controllers/user_import_controller.ex
lib/pleroma/web/router.ex
lib/pleroma/web/templates/feed/feed/_activity.atom.eex
lib/pleroma/web/templates/feed/feed/_activity.rss.eex
lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex
mix.exs
mix.lock
priv/gettext/it/LC_MESSAGES/errors.po
priv/repo/migrations/20190711042021_create_safe_jsonb_set.exs
priv/repo/migrations/20201221202251_create_hashtags.exs [new file with mode: 0644]
priv/repo/migrations/20201221202252_remove_data_from_hashtags.exs [new file with mode: 0644]
priv/repo/migrations/20201221203824_create_hashtags_objects.exs [new file with mode: 0644]
priv/repo/migrations/20210105195018_create_data_migrations.exs [new file with mode: 0644]
priv/repo/migrations/20210106183301_data_migration_create_populate_hashtags_table.exs [new file with mode: 0644]
priv/repo/migrations/20210111172254_create_data_migration_failed_ids.exs [new file with mode: 0644]
priv/repo/migrations/20210222183840_remove_hashtags_objects_duplicate_index.exs [new file with mode: 0644]
priv/repo/migrations/20210222184616_change_hashtags_name_to_text.exs [new file with mode: 0644]
test/fixtures/bridgy/actor.json [new file with mode: 0644]
test/pleroma/activity/ir/topics_test.exs
test/pleroma/hashtag_test.exs [new file with mode: 0644]
test/pleroma/object_test.exs
test/pleroma/web/activity_pub/activity_pub_test.exs
test/pleroma/web/activity_pub/mrf/hashtag_policy_test.exs [new file with mode: 0644]
test/pleroma/web/activity_pub/mrf/simple_policy_test.exs
test/pleroma/web/activity_pub/mrf/tag_policy_test.exs
test/pleroma/web/activity_pub/mrf_test.exs
test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs
test/pleroma/web/activity_pub/transmogrifier_test.exs
test/pleroma/web/admin_api/controllers/user_controller_test.exs
test/pleroma/web/common_api_test.exs
test/pleroma/web/mastodon_api/views/status_view_test.exs
test/support/conn_case.ex

index c7e8291d8161005471895583e3736aed095e517d..2bc5719718d80d141536e14d7a5d9b32e17e457e 100644 (file)
@@ -25,13 +25,13 @@ before_script:
   - apt-get update && apt-get install -y cmake
   - mix local.hex --force
   - mix local.rebar --force
+  - mix deps.get
   - apt-get -qq update
   - apt-get install -y libmagic-dev
 
 build:
   stage: build
   script:
-  - mix deps.get
   - mix compile --force
 
 spec-build:
@@ -52,7 +52,6 @@ benchmark:
     alias: postgres
     command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"]
   script:
-    - mix deps.get
     - mix ecto.create
     - mix ecto.migrate
     - mix pleroma.load_testing
@@ -70,7 +69,6 @@ unit-testing:
     command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"]
   script:
     - apt-get update && apt-get install -y libimage-exiftool-perl ffmpeg
-    - mix deps.get
     - mix ecto.create
     - mix ecto.migrate
     - mix coveralls --preload-modules
@@ -104,7 +102,6 @@ unit-testing-rum:
     RUM_ENABLED: "true"
   script:
     - apt-get update && apt-get install -y libimage-exiftool-perl ffmpeg
-    - mix deps.get
     - mix ecto.create
     - mix ecto.migrate
     - "mix ecto.migrate --migrations-path priv/repo/optional_migrations/rum_indexing/"
@@ -120,7 +117,6 @@ analysis:
   stage: test
   cache: *testing_cache_policy
   script:
-    - mix deps.get
     - mix credo --strict --only=warnings,todo,fixme,consistency,readability
 
 docs-deploy:
@@ -393,4 +389,4 @@ docker-adhoc:
   tags:
     - dind
   only:
-    - /^build-docker/.*$/@pleroma/pleroma
\ No newline at end of file
+    - /^build-docker/.*$/@pleroma/pleroma
index ed08701fd68d185f16ea1548a72e424719cd6338..a1fa22398605d170cbcc0c2f126aeb2d0b7a313a 100644 (file)
@@ -24,6 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 
 - **Breaking**: Changed `mix pleroma.user toggle_confirmed` to `mix pleroma.user confirm`
 - **Breaking**: Changed `mix pleroma.user toggle_activated` to `mix pleroma.user activate/deactivate`
+- **Breaking:** NSFW hashtag is no longer added on sensitive posts
 - Polls now always return a `voters_count`, even if they are single-choice.
 - Admin Emails: The ap id is used as the user link in emails now.
 - Improved registration workflow for email confirmation and account approval modes.
@@ -50,6 +51,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Pleroma API: Reroute `/api/pleroma/*` to `/api/v1/pleroma/*`
 
 </details>
+- Improved hashtag timeline performance (requires a background migration). 
 
 ### Added
 
@@ -73,6 +75,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 <details>
   <summary>API Changes</summary>
 - Admin API: (`GET /api/pleroma/admin/users`) filter users by `unconfirmed` status and `actor_type`.
+- Admin API: OpenAPI spec for the user-related operations
 - Pleroma API: `GET /api/v2/pleroma/chats` added. It is exactly like `GET /api/v1/pleroma/chats` except supports pagination.
 - Pleroma API: Add `idempotency_key` to the chat message entity that can be used for optimistic message sending.
 - Pleroma API: (`GET /api/v1/pleroma/federation_status`) Add a way to get a list of unreachable instances.
@@ -504,7 +507,6 @@ switched to a new configuration mechanism, however it was not officially removed
 - Static-FE: Fix remote posts not being sanitized
 
 ### Fixed
-=======
 - Rate limiter crashes when there is no explicitly specified ip in the config
 - 500 errors when no `Accept` header is present if Static-FE is enabled
 - Instance panel not being updated immediately due to wrong `Cache-Control` headers
index 66aee3264a0fa0a305a6748ceba77d1b846d1369..8d1e17b42bd44d58accaf52e24b9f742e67fcbfd 100644 (file)
@@ -391,6 +391,11 @@ config :pleroma, :mrf_keyword,
   federated_timeline_removal: [],
   replace: []
 
+config :pleroma, :mrf_hashtag,
+  sensitive: ["nsfw"],
+  reject: [],
+  federated_timeline_removal: []
+
 config :pleroma, :mrf_subchain, match_actor: %{}
 
 config :pleroma, :mrf_activity_expiration, days: 365
@@ -654,6 +659,10 @@ config :pleroma, :oauth2,
 
 config :pleroma, :database, rum_enabled: false
 
+config :pleroma, :features, improved_hashtag_timeline: :auto
+
+config :pleroma, :populate_hashtags_table, fault_rate_allowance: 0.01
+
 config :pleroma, :env, Mix.env()
 
 config :http_signatures,
index d9b15e68418eeed6e515e2a6fb871b9ef49d1759..41e5e4056e6b3e79e7dee9ce470f546239c5e209 100644 (file)
@@ -459,6 +459,42 @@ config :pleroma, :config_description, [
       }
     ]
   },
+  %{
+    group: :pleroma,
+    key: :features,
+    type: :group,
+    description: "Customizable features",
+    children: [
+      %{
+        key: :improved_hashtag_timeline,
+        type: {:dropdown, :atom},
+        description:
+          "Setting to force toggle / force disable improved hashtags timeline. `:enabled` forces hashtags to be fetched from `hashtags` table for hashtags timeline. `:disabled` forces object-embedded hashtags to be used (slower). Keep it `:auto` for automatic behaviour (it is auto-set to `:enabled` [unless overridden] when HashtagsTableMigrator completes).",
+        suggestions: [:auto, :enabled, :disabled]
+      }
+    ]
+  },
+  %{
+    group: :pleroma,
+    key: :populate_hashtags_table,
+    type: :group,
+    description: "`populate_hashtags_table` background migration settings",
+    children: [
+      %{
+        key: :fault_rate_allowance,
+        type: :float,
+        description:
+          "Max accepted rate of objects that failed in the migration. Any value from 0.0 which tolerates no errors to 1.0 which will enable the feature even if hashtags transfer failed for all records.",
+        suggestions: [0.01]
+      },
+      %{
+        key: :sleep_interval_ms,
+        type: :integer,
+        description:
+          "Sleep interval between each chunk of processed records in order to decrease the load on the system (defaults to 0 and should be keep default on most instances)."
+      }
+    ]
+  },
   %{
     group: :pleroma,
     key: :instance,
index 028c5e91dd3dd6ab45d031c5253ea5c9a1bd456d..8f2c4347ee869fa37bca1d699ab310919867493d 100644 (file)
@@ -65,6 +65,13 @@ To add configuration to your config file, you can copy it from the base config.
 * `show_reactions`: Let favourites and emoji reactions be viewed through the API (default: `true`).
 * `password_reset_token_validity`: The time after which reset tokens aren't accepted anymore, in seconds (default: one day).
 
+## :database
+* `improved_hashtag_timeline`: Setting to force toggle / force disable improved hashtags timeline. `:enabled` forces hashtags to be fetched from `hashtags` table for hashtags timeline. `:disabled` forces object-embedded hashtags to be used (slower). Keep it `:auto` for automatic behaviour (it is auto-set to `:enabled` [unless overridden] when HashtagsTableMigrator completes).
+
+## Background migrations
+* `populate_hashtags_table/sleep_interval_ms`: Sleep interval between each chunk of processed records in order to decrease the load on the system (defaults to 0 and should be keep default on most instances).
+* `populate_hashtags_table/fault_rate_allowance`: Max rate of failed objects to actually processed objects in order to enable the feature (any value from 0.0 which tolerates no errors to 1.0 which will enable the feature even if hashtags transfer failed for all records).
+
 ## Welcome
 * `direct_message`: - welcome message sent as a direct message.
   * `enabled`: Enables the send a direct message to a newly registered user. Defaults to `false`.
@@ -203,6 +210,16 @@ config :pleroma, :mrf_user_allowlist, %{
 
 * `days`: Default global expiration time for all local Create activities (in days)
 
+#### :mrf_hashtag
+
+* `sensitive`: List of hashtags to mark activities as sensitive (default: `nsfw`)
+* `federated_timeline_removal`: List of hashtags to remove activities from the federated timeline (aka TWNK)
+* `reject`: List of hashtags to reject activities from
+
+Notes:
+- The hashtags in the configuration do not have a leading `#`.
+- This MRF Policy is always enabled, if you want to disable it you have to set empty lists
+
 ### :activitypub
 * `unfollow_blocked`: Whether blocks result in people getting unfollowed
 * `outgoing_blocks`: Whether to federate blocks to other instances
index 2403ed5813f40c6a311669f6b82fafe8f7aee184..e7f4b67a463d5b8b1dd540901b0db47e853d0a3b 100644 (file)
@@ -8,10 +8,13 @@ defmodule Mix.Tasks.Pleroma.Database do
   alias Pleroma.Object
   alias Pleroma.Repo
   alias Pleroma.User
+
   require Logger
   require Pleroma.Constants
+
   import Ecto.Query
   import Mix.Pleroma
+
   use Mix.Task
 
   @shortdoc "A collection of database related tasks"
@@ -214,4 +217,32 @@ defmodule Mix.Tasks.Pleroma.Database do
       shell_info('Done.')
     end
   end
+
+  # Rolls back a specific migration (leaving subsequent migrations applied).
+  # WARNING: imposes a risk of unrecoverable data loss — proceed at your own responsibility.
+  # Based on https://stackoverflow.com/a/53825840
+  def run(["rollback", version]) do
+    prompt = "SEVERE WARNING: this operation may result in unrecoverable data loss. Continue?"
+
+    if shell_prompt(prompt, "n") in ~w(Yn Y y) do
+      {_, result, _} =
+        Ecto.Migrator.with_repo(Pleroma.Repo, fn repo ->
+          version = String.to_integer(version)
+          re = ~r/^#{version}_.*\.exs/
+          path = Ecto.Migrator.migrations_path(repo)
+
+          with {_, "" <> file} <- {:find, Enum.find(File.ls!(path), &String.match?(&1, re))},
+               {_, [{mod, _} | _]} <- {:compile, Code.compile_file(Path.join(path, file))},
+               {_, :ok} <- {:rollback, Ecto.Migrator.down(repo, version, mod)} do
+            {:ok, "Reversed migration: #{file}"}
+          else
+            {:find, _} -> {:error, "No migration found with version prefix: #{version}"}
+            {:compile, e} -> {:error, "Problem compiling migration module: #{inspect(e)}"}
+            {:rollback, e} -> {:error, "Problem reversing migration: #{inspect(e)}"}
+          end
+        end)
+
+      shell_info(inspect(result))
+    end
+  end
 end
index 6542e684e1c82ef04794010889b4b774188a331a..d594038849bbd6ed68bd7f774adb52cacec7b911 100644 (file)
@@ -113,6 +113,7 @@ defmodule Pleroma.Activity do
     from([a] in query,
       left_join: b in Bookmark,
       on: b.user_id == ^user.id and b.activity_id == a.id,
+      as: :bookmark,
       preload: [bookmark: b]
     )
   end
@@ -123,6 +124,7 @@ defmodule Pleroma.Activity do
     from([a] in query,
       left_join: r in ReportNote,
       on: a.id == r.activity_id,
+      as: :report_note,
       preload: [report_notes: r]
     )
   end
index d94395fc175c7f97f39ca2abdf6db6c99b765302..7a603a61524a46deb07ebb8f4b6a48036bc6eddc 100644 (file)
@@ -48,14 +48,12 @@ defmodule Pleroma.Activity.Ir.Topics do
     tags
   end
 
-  defp hashtags_to_topics(%{data: %{"tag" => tags}}) do
-    tags
-    |> Enum.filter(&is_bitstring(&1))
-    |> Enum.map(fn tag -> "hashtag:" <> tag end)
+  defp hashtags_to_topics(object) do
+    object
+    |> Object.hashtags()
+    |> Enum.map(fn hashtag -> "hashtag:" <> hashtag end)
   end
 
-  defp hashtags_to_topics(_), do: []
-
   defp remote_topics(%{local: true}), do: []
 
   defp remote_topics(%{actor: actor}) when is_binary(actor),
index c853a2bb4564409c22bae36d5fdc1a622913a35a..06d399b2e3631493e0e7ca76ddc1236fd5fdb07d 100644 (file)
@@ -103,9 +103,7 @@ defmodule Pleroma.Application do
         task_children(@mix_env) ++
         dont_run_in_test(@mix_env) ++
         chat_child(chat_enabled?()) ++
-        [
-          Pleroma.Gopher.Server
-        ]
+        [Pleroma.Gopher.Server]
 
     # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html
     # for other strategies and supported options
@@ -230,6 +228,12 @@ defmodule Pleroma.Application do
          keys: :duplicate,
          partitions: System.schedulers_online()
        ]}
+    ] ++ background_migrators()
+  end
+
+  defp background_migrators do
+    [
+      Pleroma.Migrators.HashtagsTableMigrator
     ]
   end
 
index 2e15a37193ada875eeb73746d30b2bc37e464a68..54e332595d73762f155ce5c438ea8bfd494184c0 100644 (file)
@@ -99,4 +99,8 @@ defmodule Pleroma.Config do
   def oauth_consumer_strategies, do: get([:auth, :oauth_consumer_strategies], [])
 
   def oauth_consumer_enabled?, do: oauth_consumer_strategies() != []
+
+  def feature_enabled?(feature_name) do
+    get([:features, feature_name]) not in [nil, false, :disabled, :auto]
+  end
 end
diff --git a/lib/pleroma/data_migration.ex b/lib/pleroma/data_migration.ex
new file mode 100644 (file)
index 0000000..1377af1
--- /dev/null
@@ -0,0 +1,45 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.DataMigration do
+  use Ecto.Schema
+
+  alias Pleroma.DataMigration
+  alias Pleroma.DataMigration.State
+  alias Pleroma.Repo
+
+  import Ecto.Changeset
+  import Ecto.Query
+
+  schema "data_migrations" do
+    field(:name, :string)
+    field(:state, State, default: :pending)
+    field(:feature_lock, :boolean, default: false)
+    field(:params, :map, default: %{})
+    field(:data, :map, default: %{})
+
+    timestamps()
+  end
+
+  def changeset(data_migration, params \\ %{}) do
+    data_migration
+    |> cast(params, [:name, :state, :feature_lock, :params, :data])
+    |> validate_required([:name])
+    |> unique_constraint(:name)
+  end
+
+  def update_one_by_id(id, params \\ %{}) do
+    with {1, _} <-
+           from(dm in DataMigration, where: dm.id == ^id)
+           |> Repo.update_all(set: params) do
+      :ok
+    end
+  end
+
+  def get_by_name(name) do
+    Repo.get_by(DataMigration, name: name)
+  end
+
+  def populate_hashtags_table, do: get_by_name("populate_hashtags_table")
+end
index e8d53676742e871dda026151cc6e106708372e04..511d5cf58258abb773e2434f449834837afacb93 100644 (file)
@@ -9,7 +9,6 @@ defmodule Pleroma.Delivery do
   alias Pleroma.Object
   alias Pleroma.Repo
   alias Pleroma.User
-  alias Pleroma.User
 
   import Ecto.Changeset
   import Ecto.Query
index f198cccb750cd26d2cb32c47c6def9f85dc2ab12..2a9addabcecff4553bff21c483e9db83c8beb270 100644 (file)
@@ -17,3 +17,11 @@ defenum(Pleroma.FollowingRelationship.State,
   follow_accept: 2,
   follow_reject: 3
 )
+
+defenum(Pleroma.DataMigration.State,
+  pending: 1,
+  running: 2,
+  complete: 3,
+  failed: 4,
+  manual: 5
+)
diff --git a/lib/pleroma/hashtag.ex b/lib/pleroma/hashtag.ex
new file mode 100644 (file)
index 0000000..53e2e9c
--- /dev/null
@@ -0,0 +1,106 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Hashtag do
+  use Ecto.Schema
+
+  import Ecto.Changeset
+  import Ecto.Query
+
+  alias Ecto.Multi
+  alias Pleroma.Hashtag
+  alias Pleroma.Object
+  alias Pleroma.Repo
+
+  schema "hashtags" do
+    field(:name, :string)
+
+    many_to_many(:objects, Object, join_through: "hashtags_objects", on_replace: :delete)
+
+    timestamps()
+  end
+
+  def normalize_name(name) do
+    name
+    |> String.downcase()
+    |> String.trim()
+  end
+
+  def get_or_create_by_name(name) do
+    changeset = changeset(%Hashtag{}, %{name: name})
+
+    Repo.insert(
+      changeset,
+      on_conflict: [set: [name: get_field(changeset, :name)]],
+      conflict_target: :name,
+      returning: true
+    )
+  end
+
+  def get_or_create_by_names(names) when is_list(names) do
+    names = Enum.map(names, &normalize_name/1)
+    timestamp = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
+
+    structs =
+      Enum.map(names, fn name ->
+        %Hashtag{}
+        |> changeset(%{name: name})
+        |> Map.get(:changes)
+        |> Map.merge(%{inserted_at: timestamp, updated_at: timestamp})
+      end)
+
+    try do
+      with {:ok, %{query_op: hashtags}} <-
+             Multi.new()
+             |> Multi.insert_all(:insert_all_op, Hashtag, structs,
+               on_conflict: :nothing,
+               conflict_target: :name
+             )
+             |> Multi.run(:query_op, fn _repo, _changes ->
+               {:ok, Repo.all(from(ht in Hashtag, where: ht.name in ^names))}
+             end)
+             |> Repo.transaction() do
+        {:ok, hashtags}
+      else
+        {:error, _name, value, _changes_so_far} -> {:error, value}
+      end
+    rescue
+      e -> {:error, e}
+    end
+  end
+
+  def changeset(%Hashtag{} = struct, params) do
+    struct
+    |> cast(params, [:name])
+    |> update_change(:name, &normalize_name/1)
+    |> validate_required([:name])
+    |> unique_constraint(:name)
+  end
+
+  def unlink(%Object{id: object_id}) do
+    with {_, hashtag_ids} <-
+           from(hto in "hashtags_objects",
+             where: hto.object_id == ^object_id,
+             select: hto.hashtag_id
+           )
+           |> Repo.delete_all(),
+         {:ok, unreferenced_count} <- delete_unreferenced(hashtag_ids) do
+      {:ok, length(hashtag_ids), unreferenced_count}
+    end
+  end
+
+  @delete_unreferenced_query """
+  DELETE FROM hashtags WHERE id IN
+    (SELECT hashtags.id FROM hashtags
+      LEFT OUTER JOIN hashtags_objects
+        ON hashtags_objects.hashtag_id = hashtags.id
+      WHERE hashtags_objects.hashtag_id IS NULL AND hashtags.id = ANY($1));
+  """
+
+  def delete_unreferenced(ids) do
+    with {:ok, %{num_rows: deleted_count}} <- Repo.query(@delete_unreferenced_query, [ids]) do
+      {:ok, deleted_count}
+    end
+  end
+end
diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex
new file mode 100644 (file)
index 0000000..b84058e
--- /dev/null
@@ -0,0 +1,208 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Migrators.HashtagsTableMigrator do
+  defmodule State do
+    use Pleroma.Migrators.Support.BaseMigratorState
+
+    @impl Pleroma.Migrators.Support.BaseMigratorState
+    defdelegate data_migration(), to: Pleroma.DataMigration, as: :populate_hashtags_table
+  end
+
+  use Pleroma.Migrators.Support.BaseMigrator
+
+  alias Pleroma.Hashtag
+  alias Pleroma.Migrators.Support.BaseMigrator
+  alias Pleroma.Object
+
+  @impl BaseMigrator
+  def feature_config_path, do: [:features, :improved_hashtag_timeline]
+
+  @impl BaseMigrator
+  def fault_rate_allowance, do: Config.get([:populate_hashtags_table, :fault_rate_allowance], 0)
+
+  @impl BaseMigrator
+  def perform do
+    data_migration_id = data_migration_id()
+    max_processed_id = get_stat(:max_processed_id, 0)
+
+    Logger.info("Transferring embedded hashtags to `hashtags` (from oid: #{max_processed_id})...")
+
+    query()
+    |> where([object], object.id > ^max_processed_id)
+    |> Repo.chunk_stream(100, :batches, timeout: :infinity)
+    |> Stream.each(fn objects ->
+      object_ids = Enum.map(objects, & &1.id)
+
+      results = Enum.map(objects, &transfer_object_hashtags(&1))
+
+      failed_ids =
+        results
+        |> Enum.filter(&(elem(&1, 0) == :error))
+        |> Enum.map(&elem(&1, 1))
+
+      # Count of objects with hashtags: `{:noop, id}` is returned for objects having other AS2 tags
+      chunk_affected_count =
+        results
+        |> Enum.filter(&(elem(&1, 0) == :ok))
+        |> length()
+
+      for failed_id <- failed_ids do
+        _ =
+          Repo.query(
+            "INSERT INTO data_migration_failed_ids(data_migration_id, record_id) " <>
+              "VALUES ($1, $2) ON CONFLICT DO NOTHING;",
+            [data_migration_id, failed_id]
+          )
+      end
+
+      _ =
+        Repo.query(
+          "DELETE FROM data_migration_failed_ids " <>
+            "WHERE data_migration_id = $1 AND record_id = ANY($2)",
+          [data_migration_id, object_ids -- failed_ids]
+        )
+
+      max_object_id = Enum.at(object_ids, -1)
+
+      put_stat(:max_processed_id, max_object_id)
+      increment_stat(:iteration_processed_count, length(object_ids))
+      increment_stat(:processed_count, length(object_ids))
+      increment_stat(:failed_count, length(failed_ids))
+      increment_stat(:affected_count, chunk_affected_count)
+      put_stat(:records_per_second, records_per_second())
+      persist_state()
+
+      # A quick and dirty approach to controlling the load this background migration imposes
+      sleep_interval = Config.get([:populate_hashtags_table, :sleep_interval_ms], 0)
+      Process.sleep(sleep_interval)
+    end)
+    |> Stream.run()
+  end
+
+  @impl BaseMigrator
+  def query do
+    # Note: most objects have Mention-type AS2 tags and no hashtags (but we can't filter them out)
+    # Note: not checking activity type, expecting remove_non_create_objects_hashtags/_ to clean up
+    from(
+      object in Object,
+      where:
+        fragment("(?)->'tag' IS NOT NULL AND (?)->'tag' != '[]'::jsonb", object.data, object.data),
+      select: %{
+        id: object.id,
+        tag: fragment("(?)->'tag'", object.data)
+      }
+    )
+    |> join(:left, [o], hashtags_objects in fragment("SELECT object_id FROM hashtags_objects"),
+      on: hashtags_objects.object_id == o.id
+    )
+    |> where([_o, hashtags_objects], is_nil(hashtags_objects.object_id))
+  end
+
+  @spec transfer_object_hashtags(Map.t()) :: {:noop | :ok | :error, integer()}
+  defp transfer_object_hashtags(object) do
+    embedded_tags = if Map.has_key?(object, :tag), do: object.tag, else: object.data["tag"]
+    hashtags = Object.object_data_hashtags(%{"tag" => embedded_tags})
+
+    if Enum.any?(hashtags) do
+      transfer_object_hashtags(object, hashtags)
+    else
+      {:noop, object.id}
+    end
+  end
+
+  defp transfer_object_hashtags(object, hashtags) do
+    Repo.transaction(fn ->
+      with {:ok, hashtag_records} <- Hashtag.get_or_create_by_names(hashtags) do
+        maps = Enum.map(hashtag_records, &%{hashtag_id: &1.id, object_id: object.id})
+        base_error = "ERROR when inserting hashtags_objects for object with id #{object.id}"
+
+        try do
+          with {rows_count, _} when is_integer(rows_count) <-
+                 Repo.insert_all("hashtags_objects", maps, on_conflict: :nothing) do
+            object.id
+          else
+            e ->
+              Logger.error("#{base_error}: #{inspect(e)}")
+              Repo.rollback(object.id)
+          end
+        rescue
+          e ->
+            Logger.error("#{base_error}: #{inspect(e)}")
+            Repo.rollback(object.id)
+        end
+      else
+        e ->
+          error = "ERROR: could not create hashtags for object #{object.id}: #{inspect(e)}"
+          Logger.error(error)
+          Repo.rollback(object.id)
+      end
+    end)
+  end
+
+  @impl BaseMigrator
+  def retry_failed do
+    data_migration_id = data_migration_id()
+
+    failed_objects_query()
+    |> Repo.chunk_stream(100, :one)
+    |> Stream.each(fn object ->
+      with {res, _} when res != :error <- transfer_object_hashtags(object) do
+        _ =
+          Repo.query(
+            "DELETE FROM data_migration_failed_ids " <>
+              "WHERE data_migration_id = $1 AND record_id = $2",
+            [data_migration_id, object.id]
+          )
+      end
+    end)
+    |> Stream.run()
+
+    put_stat(:failed_count, failures_count())
+    persist_state()
+
+    force_continue()
+  end
+
+  defp failed_objects_query do
+    from(o in Object)
+    |> join(:inner, [o], dmf in fragment("SELECT * FROM data_migration_failed_ids"),
+      on: dmf.record_id == o.id
+    )
+    |> where([_o, dmf], dmf.data_migration_id == ^data_migration_id())
+    |> order_by([o], asc: o.id)
+  end
+
+  @doc """
+  Service func to delete `hashtags_objects` for legacy objects not associated with Create activity.
+  Also deletes unreferenced `hashtags` records (might occur after deletion of `hashtags_objects`).
+  """
+  def delete_non_create_activities_hashtags do
+    hashtags_objects_cleanup_query = """
+    DELETE FROM hashtags_objects WHERE object_id IN
+      (SELECT DISTINCT objects.id FROM objects
+        JOIN hashtags_objects ON hashtags_objects.object_id = objects.id LEFT JOIN activities
+          ON COALESCE(activities.data->'object'->>'id', activities.data->>'object') =
+            (objects.data->>'id')
+          AND activities.data->>'type' = 'Create'
+        WHERE activities.id IS NULL);
+    """
+
+    hashtags_cleanup_query = """
+    DELETE FROM hashtags WHERE id IN
+      (SELECT hashtags.id FROM hashtags
+        LEFT OUTER JOIN hashtags_objects
+          ON hashtags_objects.hashtag_id = hashtags.id
+        WHERE hashtags_objects.hashtag_id IS NULL);
+    """
+
+    {:ok, %{num_rows: hashtags_objects_count}} =
+      Repo.query(hashtags_objects_cleanup_query, [], timeout: :infinity)
+
+    {:ok, %{num_rows: hashtags_count}} =
+      Repo.query(hashtags_cleanup_query, [], timeout: :infinity)
+
+    {:ok, hashtags_objects_count, hashtags_count}
+  end
+end
diff --git a/lib/pleroma/migrators/support/base_migrator.ex b/lib/pleroma/migrators/support/base_migrator.ex
new file mode 100644 (file)
index 0000000..1f8a540
--- /dev/null
@@ -0,0 +1,210 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Migrators.Support.BaseMigrator do
+  @moduledoc """
+  Base background migrator functionality.
+  """
+
+  @callback perform() :: any()
+  @callback retry_failed() :: any()
+  @callback feature_config_path() :: list(atom())
+  @callback query() :: Ecto.Query.t()
+  @callback fault_rate_allowance() :: integer() | float()
+
+  defmacro __using__(_opts) do
+    quote do
+      use GenServer
+
+      require Logger
+
+      import Ecto.Query
+
+      alias __MODULE__.State
+      alias Pleroma.Config
+      alias Pleroma.Repo
+
+      @behaviour Pleroma.Migrators.Support.BaseMigrator
+
+      defdelegate data_migration(), to: State
+      defdelegate data_migration_id(), to: State
+      defdelegate state(), to: State
+      defdelegate persist_state(), to: State, as: :persist_to_db
+      defdelegate get_stat(key, value \\ nil), to: State, as: :get_data_key
+      defdelegate put_stat(key, value), to: State, as: :put_data_key
+      defdelegate increment_stat(key, increment), to: State, as: :increment_data_key
+
+      @reg_name {:global, __MODULE__}
+
+      def whereis, do: GenServer.whereis(@reg_name)
+
+      def start_link(_) do
+        case whereis() do
+          nil ->
+            GenServer.start_link(__MODULE__, nil, name: @reg_name)
+
+          pid ->
+            {:ok, pid}
+        end
+      end
+
+      @impl true
+      def init(_) do
+        {:ok, nil, {:continue, :init_state}}
+      end
+
+      @impl true
+      def handle_continue(:init_state, _state) do
+        {:ok, _} = State.start_link(nil)
+
+        data_migration = data_migration()
+        manual_migrations = Config.get([:instance, :manual_data_migrations], [])
+
+        cond do
+          Config.get(:env) == :test ->
+            update_status(:noop)
+
+          is_nil(data_migration) ->
+            message = "Data migration does not exist."
+            update_status(:failed, message)
+            Logger.error("#{__MODULE__}: #{message}")
+
+          data_migration.state == :manual or data_migration.name in manual_migrations ->
+            message = "Data migration is in manual execution or manual fix mode."
+            update_status(:manual, message)
+            Logger.warn("#{__MODULE__}: #{message}")
+
+          data_migration.state == :complete ->
+            on_complete(data_migration)
+
+          true ->
+            send(self(), :perform)
+        end
+
+        {:noreply, nil}
+      end
+
+      @impl true
+      def handle_info(:perform, state) do
+        State.reinit()
+
+        update_status(:running)
+        put_stat(:iteration_processed_count, 0)
+        put_stat(:started_at, NaiveDateTime.utc_now())
+
+        perform()
+
+        fault_rate = fault_rate()
+        put_stat(:fault_rate, fault_rate)
+        fault_rate_allowance = fault_rate_allowance()
+
+        cond do
+          fault_rate == 0 ->
+            set_complete()
+
+          is_float(fault_rate) and fault_rate <= fault_rate_allowance ->
+            message = """
+            Done with fault rate of #{fault_rate} which doesn't exceed #{fault_rate_allowance}.
+            Putting data migration to manual fix mode. Try running `#{__MODULE__}.retry_failed/0`.
+            """
+
+            Logger.warn("#{__MODULE__}: #{message}")
+            update_status(:manual, message)
+            on_complete(data_migration())
+
+          true ->
+            message = "Too many failures. Try running `#{__MODULE__}.retry_failed/0`."
+            Logger.error("#{__MODULE__}: #{message}")
+            update_status(:failed, message)
+        end
+
+        persist_state()
+        {:noreply, state}
+      end
+
+      defp on_complete(data_migration) do
+        if data_migration.feature_lock || feature_state() == :disabled do
+          Logger.warn(
+            "#{__MODULE__}: migration complete but feature is locked; consider enabling."
+          )
+
+          :noop
+        else
+          Config.put(feature_config_path(), :enabled)
+          :ok
+        end
+      end
+
+      @doc "Approximate count for current iteration (including processed records count)"
+      def count(force \\ false, timeout \\ :infinity) do
+        stored_count = get_stat(:count)
+
+        if stored_count && !force do
+          stored_count
+        else
+          processed_count = get_stat(:processed_count, 0)
+          max_processed_id = get_stat(:max_processed_id, 0)
+          query = where(query(), [entity], entity.id > ^max_processed_id)
+
+          count = Repo.aggregate(query, :count, :id, timeout: timeout) + processed_count
+          put_stat(:count, count)
+          persist_state()
+
+          count
+        end
+      end
+
+      def failures_count do
+        with {:ok, %{rows: [[count]]}} <-
+               Repo.query(
+                 "SELECT COUNT(record_id) FROM data_migration_failed_ids WHERE data_migration_id = $1;",
+                 [data_migration_id()]
+               ) do
+          count
+        end
+      end
+
+      def feature_state, do: Config.get(feature_config_path())
+
+      def force_continue do
+        send(whereis(), :perform)
+      end
+
+      def force_restart do
+        :ok = State.reset()
+        force_continue()
+      end
+
+      def set_complete do
+        update_status(:complete)
+        persist_state()
+        on_complete(data_migration())
+      end
+
+      defp update_status(status, message \\ nil) do
+        put_stat(:state, status)
+        put_stat(:message, message)
+      end
+
+      defp fault_rate do
+        with failures_count when is_integer(failures_count) <- failures_count() do
+          failures_count / Enum.max([get_stat(:affected_count, 0), 1])
+        else
+          _ -> :error
+        end
+      end
+
+      defp records_per_second do
+        get_stat(:iteration_processed_count, 0) / Enum.max([running_time(), 1])
+      end
+
+      defp running_time do
+        NaiveDateTime.diff(
+          NaiveDateTime.utc_now(),
+          get_stat(:started_at, NaiveDateTime.utc_now())
+        )
+      end
+    end
+  end
+end
diff --git a/lib/pleroma/migrators/support/base_migrator_state.ex b/lib/pleroma/migrators/support/base_migrator_state.ex
new file mode 100644 (file)
index 0000000..b698587
--- /dev/null
@@ -0,0 +1,117 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Migrators.Support.BaseMigratorState do
+  @moduledoc """
+  Base background migrator state functionality.
+  """
+
+  @callback data_migration() :: Pleroma.DataMigration.t()
+
+  defmacro __using__(_opts) do
+    quote do
+      use Agent
+
+      alias Pleroma.DataMigration
+
+      @behaviour Pleroma.Migrators.Support.BaseMigratorState
+      @reg_name {:global, __MODULE__}
+
+      def start_link(_) do
+        Agent.start_link(fn -> load_state_from_db() end, name: @reg_name)
+      end
+
+      def data_migration, do: raise("data_migration/0 is not implemented")
+      defoverridable data_migration: 0
+
+      defp load_state_from_db do
+        data_migration = data_migration()
+
+        data =
+          if data_migration do
+            Map.new(data_migration.data, fn {k, v} -> {String.to_atom(k), v} end)
+          else
+            %{}
+          end
+
+        %{
+          data_migration_id: data_migration && data_migration.id,
+          data: data
+        }
+      end
+
+      def persist_to_db do
+        %{data_migration_id: data_migration_id, data: data} = state()
+
+        if data_migration_id do
+          DataMigration.update_one_by_id(data_migration_id, data: data)
+        else
+          {:error, :nil_data_migration_id}
+        end
+      end
+
+      def reset do
+        %{data_migration_id: data_migration_id} = state()
+
+        with false <- is_nil(data_migration_id),
+             :ok <-
+               DataMigration.update_one_by_id(data_migration_id,
+                 state: :pending,
+                 data: %{}
+               ) do
+          reinit()
+        else
+          true -> {:error, :nil_data_migration_id}
+          e -> e
+        end
+      end
+
+      def reinit do
+        Agent.update(@reg_name, fn _state -> load_state_from_db() end)
+      end
+
+      def state do
+        Agent.get(@reg_name, & &1)
+      end
+
+      def get_data_key(key, default \\ nil) do
+        get_in(state(), [:data, key]) || default
+      end
+
+      def put_data_key(key, value) do
+        _ = persist_non_data_change(key, value)
+
+        Agent.update(@reg_name, fn state ->
+          put_in(state, [:data, key], value)
+        end)
+      end
+
+      def increment_data_key(key, increment \\ 1) do
+        Agent.update(@reg_name, fn state ->
+          initial_value = get_in(state, [:data, key]) || 0
+          updated_value = initial_value + increment
+          put_in(state, [:data, key], updated_value)
+        end)
+      end
+
+      defp persist_non_data_change(:state, value) do
+        with true <- get_data_key(:state) != value,
+             true <- value in Pleroma.DataMigration.State.__valid_values__(),
+             %{data_migration_id: data_migration_id} when not is_nil(data_migration_id) <-
+               state() do
+          DataMigration.update_one_by_id(data_migration_id, state: value)
+        else
+          false -> :ok
+          _ -> {:error, :nil_data_migration_id}
+        end
+      end
+
+      defp persist_non_data_change(_, _) do
+        nil
+      end
+
+      def data_migration_id, do: Map.get(state(), :data_migration_id)
+    end
+  end
+end
index aaf12384004c37911063e4e82e9b8a3ec49a88e1..3ba749d1a36944e1120c53a07b28aca8d21e9003 100644 (file)
@@ -10,6 +10,7 @@ defmodule Pleroma.Object do
 
   alias Pleroma.Activity
   alias Pleroma.Config
+  alias Pleroma.Hashtag
   alias Pleroma.Object
   alias Pleroma.Object.Fetcher
   alias Pleroma.ObjectTombstone
@@ -28,6 +29,8 @@ defmodule Pleroma.Object do
   schema "objects" do
     field(:data, :map)
 
+    many_to_many(:hashtags, Hashtag, join_through: "hashtags_objects", on_replace: :delete)
+
     timestamps()
   end
 
@@ -49,7 +52,8 @@ defmodule Pleroma.Object do
   end
 
   def create(data) do
-    Object.change(%Object{}, %{data: data})
+    %Object{}
+    |> Object.change(%{data: data})
     |> Repo.insert()
   end
 
@@ -58,8 +62,41 @@ defmodule Pleroma.Object do
     |> cast(params, [:data])
     |> validate_required([:data])
     |> unique_constraint(:ap_id, name: :objects_unique_apid_index)
+    # Expecting `maybe_handle_hashtags_change/1` to run last:
+    |> maybe_handle_hashtags_change(struct)
+  end
+
+  # Note: not checking activity type (assuming non-legacy objects are associated with Create act.)
+  defp maybe_handle_hashtags_change(changeset, struct) do
+    with %Ecto.Changeset{valid?: true} <- changeset,
+         data_hashtags_change = get_change(changeset, :data),
+         {_, true} <- {:changed, hashtags_changed?(struct, data_hashtags_change)},
+         {:ok, hashtag_records} <-
+           data_hashtags_change
+           |> object_data_hashtags()
+           |> Hashtag.get_or_create_by_names() do
+      put_assoc(changeset, :hashtags, hashtag_records)
+    else
+      %{valid?: false} ->
+        changeset
+
+      {:changed, false} ->
+        changeset
+
+      {:error, _} ->
+        validate_change(changeset, :data, fn _, _ ->
+          [data: "error referencing hashtags"]
+        end)
+    end
+  end
+
+  defp hashtags_changed?(%Object{} = struct, %{"tag" => _} = data) do
+    Enum.sort(embedded_hashtags(struct)) !=
+      Enum.sort(object_data_hashtags(data))
   end
 
+  defp hashtags_changed?(_, _), do: false
+
   def get_by_id(nil), do: nil
   def get_by_id(id), do: Repo.get(Object, id)
 
@@ -187,9 +224,13 @@ defmodule Pleroma.Object do
   def swap_object_with_tombstone(object) do
     tombstone = make_tombstone(object)
 
-    object
-    |> Object.change(%{data: tombstone})
-    |> Repo.update()
+    with {:ok, object} <-
+           object
+           |> Object.change(%{data: tombstone})
+           |> Repo.update() do
+      Hashtag.unlink(object)
+      {:ok, object}
+    end
   end
 
   def delete(%Object{data: %{"id" => id}} = object) do
@@ -349,4 +390,39 @@ defmodule Pleroma.Object do
 
   def self_replies(object, opts \\ []),
     do: replies(object, Keyword.put(opts, :self_only, true))
+
+  def tags(%Object{data: %{"tag" => tags}}) when is_list(tags), do: tags
+
+  def tags(_), do: []
+
+  def hashtags(%Object{} = object) do
+    # Note: always using embedded hashtags regardless whether they are migrated to hashtags table
+    #   (embedded hashtags stay in sync anyways, and we avoid extra joins and preload hassle)
+    embedded_hashtags(object)
+  end
+
+  def embedded_hashtags(%Object{data: data}) do
+    object_data_hashtags(data)
+  end
+
+  def embedded_hashtags(_), do: []
+
+  def object_data_hashtags(%{"tag" => tags}) when is_list(tags) do
+    tags
+    |> Enum.filter(fn
+      %{"type" => "Hashtag"} = data -> Map.has_key?(data, "name")
+      plain_text when is_bitstring(plain_text) -> true
+      _ -> false
+    end)
+    |> Enum.map(fn
+      %{"name" => "#" <> hashtag} -> String.downcase(hashtag)
+      %{"name" => hashtag} -> String.downcase(hashtag)
+      hashtag when is_bitstring(hashtag) -> String.downcase(hashtag)
+    end)
+    |> Enum.uniq()
+    # Note: "" elements (plain text) might occur in `data.tag` for incoming objects
+    |> Enum.filter(&(&1 not in [nil, ""]))
+  end
+
+  def object_data_hashtags(_), do: []
 end
index 0d24e10101755f6c6fb2e4eb8954b1f8bf616e43..33e45a0eb45b8b83a3a00fae90f22dd562c94ed1 100644 (file)
@@ -93,6 +93,7 @@ defmodule Pleroma.Pagination do
       max_id: :string,
       offset: :integer,
       limit: :integer,
+      skip_extra_order: :boolean,
       skip_order: :boolean
     }
 
@@ -114,6 +115,8 @@ defmodule Pleroma.Pagination do
 
   defp restrict(query, :order, %{skip_order: true}, _), do: query
 
+  defp restrict(%{order_bys: [_ | _]} = query, :order, %{skip_extra_order: true}, _), do: query
+
   defp restrict(query, :order, %{min_id: _}, table_binding) do
     order_by(
       query,
index 4556352d073bc079c7b4f9a0e2a903c351b9741b..b8ea06e33c9a33effe0bce819e0135ae77cc1bf2 100644 (file)
@@ -8,6 +8,8 @@ defmodule Pleroma.Repo do
     adapter: Ecto.Adapters.Postgres,
     migration_timestamps: [type: :naive_datetime_usec]
 
+  use Ecto.Explain
+
   import Ecto.Query
   require Logger
 
@@ -63,8 +65,8 @@ defmodule Pleroma.Repo do
   iex> Pleroma.Repo.chunk_stream(Pleroma.Activity.Queries.by_actor(ap_id), 500, :batches)
   """
   @spec chunk_stream(Ecto.Query.t(), integer(), atom()) :: Enumerable.t()
-  def chunk_stream(query, chunk_size, returns_as \\ :one) do
-    # We don't actually need start and end funcitons of resource streaming,
+  def chunk_stream(query, chunk_size, returns_as \\ :one, query_options \\ []) do
+    # We don't actually need start and end functions 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
@@ -76,7 +78,7 @@ defmodule Pleroma.Repo do
           |> order_by(asc: :id)
           |> where([r], r.id > ^last_id)
           |> limit(^chunk_size)
-          |> all()
+          |> all(query_options)
           |> case do
             [] ->
               {:halt, last_id}
index 9942617d87d655e9ff507e2a1074b5ecf83b2856..c1aa0f716a3c082ec310730e1f9e7b36f8066f8d 100644 (file)
@@ -2255,13 +2255,6 @@ defmodule Pleroma.User do
     |> update_and_set_cache()
   end
 
-  def roles(%{is_moderator: is_moderator, is_admin: is_admin}) do
-    %{
-      admin: is_admin,
-      moderator: is_moderator
-    }
-  end
-
   def validate_fields(changeset, remote? \\ false) do
     limit_name = if remote?, do: :max_remote_account_fields, else: :max_account_fields
     limit = Config.get([:instance, limit_name], 0)
index 5b45e2ca1dca30c5488b53c4477d73958c25aeb3..efbf92c70c2539a479784a8091bf799fc8a2084e 100644 (file)
@@ -10,6 +10,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
   alias Pleroma.Conversation
   alias Pleroma.Conversation.Participation
   alias Pleroma.Filter
+  alias Pleroma.Hashtag
   alias Pleroma.Maps
   alias Pleroma.Notification
   alias Pleroma.Object
@@ -465,6 +466,23 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     |> Repo.one()
   end
 
+  defp fetch_paginated_optimized(query, opts, pagination) do
+    # Note: tag-filtering funcs may apply "ORDER BY objects.id DESC",
+    #   and extra sorting on "activities.id DESC NULLS LAST" would worse the query plan
+    opts = Map.put(opts, :skip_extra_order, true)
+
+    Pagination.fetch_paginated(query, opts, pagination)
+  end
+
+  def fetch_activities(recipients, opts \\ %{}, pagination \\ :keyset) do
+    list_memberships = Pleroma.List.memberships(opts[:user])
+
+    fetch_activities_query(recipients ++ list_memberships, opts)
+    |> fetch_paginated_optimized(opts, pagination)
+    |> Enum.reverse()
+    |> maybe_update_cc(list_memberships, opts[:user])
+  end
+
   @spec fetch_public_or_unlisted_activities(map(), Pagination.type()) :: [Activity.t()]
   def fetch_public_or_unlisted_activities(opts \\ %{}, pagination \\ :keyset) do
     opts = Map.delete(opts, :user)
@@ -472,7 +490,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     [Constants.as_public()]
     |> fetch_activities_query(opts)
     |> restrict_unlisted(opts)
-    |> Pagination.fetch_paginated(opts, pagination)
+    |> fetch_paginated_optimized(opts, pagination)
   end
 
   @spec fetch_public_activities(map(), Pagination.type()) :: [Activity.t()]
@@ -693,51 +711,143 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
 
   defp restrict_since(query, _), do: query
 
-  defp restrict_tag_reject(_query, %{tag_reject: _tag_reject, skip_preload: true}) do
-    raise "Can't use the child object without preloading!"
+  defp restrict_embedded_tag_all(_query, %{tag_all: _tag_all, skip_preload: true}) do
+    raise_on_missing_preload()
+  end
+
+  defp restrict_embedded_tag_all(query, %{tag_all: [_ | _] = tag_all}) do
+    from(
+      [_activity, object] in query,
+      where: fragment("(?)->'tag' \\?& (?)", object.data, ^tag_all)
+    )
+  end
+
+  defp restrict_embedded_tag_all(query, %{tag_all: tag}) when is_binary(tag) do
+    restrict_embedded_tag_any(query, %{tag: tag})
+  end
+
+  defp restrict_embedded_tag_all(query, _), do: query
+
+  defp restrict_embedded_tag_any(_query, %{tag: _tag, skip_preload: true}) do
+    raise_on_missing_preload()
+  end
+
+  defp restrict_embedded_tag_any(query, %{tag: [_ | _] = tag_any}) do
+    from(
+      [_activity, object] in query,
+      where: fragment("(?)->'tag' \\?| (?)", object.data, ^tag_any)
+    )
   end
 
-  defp restrict_tag_reject(query, %{tag_reject: [_ | _] = tag_reject}) do
+  defp restrict_embedded_tag_any(query, %{tag: tag}) when is_binary(tag) do
+    restrict_embedded_tag_any(query, %{tag: [tag]})
+  end
+
+  defp restrict_embedded_tag_any(query, _), do: query
+
+  defp restrict_embedded_tag_reject_any(_query, %{tag_reject: _tag_reject, skip_preload: true}) do
+    raise_on_missing_preload()
+  end
+
+  defp restrict_embedded_tag_reject_any(query, %{tag_reject: [_ | _] = tag_reject}) do
     from(
       [_activity, object] in query,
       where: fragment("not (?)->'tag' \\?| (?)", object.data, ^tag_reject)
     )
   end
 
-  defp restrict_tag_reject(query, _), do: query
+  defp restrict_embedded_tag_reject_any(query, %{tag_reject: tag_reject})
+       when is_binary(tag_reject) do
+    restrict_embedded_tag_reject_any(query, %{tag_reject: [tag_reject]})
+  end
+
+  defp restrict_embedded_tag_reject_any(query, _), do: query
 
-  defp restrict_tag_all(_query, %{tag_all: _tag_all, skip_preload: true}) do
-    raise "Can't use the child object without preloading!"
+  defp object_ids_query_for_tags(tags) do
+    from(hto in "hashtags_objects")
+    |> join(:inner, [hto], ht in Pleroma.Hashtag, on: hto.hashtag_id == ht.id)
+    |> where([hto, ht], ht.name in ^tags)
+    |> select([hto], hto.object_id)
+    |> distinct([hto], true)
   end
 
-  defp restrict_tag_all(query, %{tag_all: [_ | _] = tag_all}) do
+  defp restrict_hashtag_all(_query, %{tag_all: _tag, skip_preload: true}) do
+    raise_on_missing_preload()
+  end
+
+  defp restrict_hashtag_all(query, %{tag_all: [single_tag]}) do
+    restrict_hashtag_any(query, %{tag: single_tag})
+  end
+
+  defp restrict_hashtag_all(query, %{tag_all: [_ | _] = tags}) do
     from(
       [_activity, object] in query,
-      where: fragment("(?)->'tag' \\?& (?)", object.data, ^tag_all)
+      where:
+        fragment(
+          """
+          (SELECT array_agg(hashtags.name) FROM hashtags JOIN hashtags_objects
+            ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?)
+              AND hashtags_objects.object_id = ?) @> ?
+          """,
+          ^tags,
+          object.id,
+          ^tags
+        )
     )
   end
 
-  defp restrict_tag_all(query, _), do: query
+  defp restrict_hashtag_all(query, %{tag_all: tag}) when is_binary(tag) do
+    restrict_hashtag_all(query, %{tag_all: [tag]})
+  end
 
-  defp restrict_tag(_query, %{tag: _tag, skip_preload: true}) do
-    raise "Can't use the child object without preloading!"
+  defp restrict_hashtag_all(query, _), do: query
+
+  defp restrict_hashtag_any(_query, %{tag: _tag, skip_preload: true}) do
+    raise_on_missing_preload()
   end
 
-  defp restrict_tag(query, %{tag: tag}) when is_list(tag) do
+  defp restrict_hashtag_any(query, %{tag: [_ | _] = tags}) do
+    hashtag_ids =
+      from(ht in Hashtag, where: ht.name in ^tags, select: ht.id)
+      |> Repo.all()
+
+    # Note: NO extra ordering should be done on "activities.id desc nulls last" for optimal plan
     from(
       [_activity, object] in query,
-      where: fragment("(?)->'tag' \\?| (?)", object.data, ^tag)
+      join: hto in "hashtags_objects",
+      on: hto.object_id == object.id,
+      where: hto.hashtag_id in ^hashtag_ids,
+      distinct: [desc: object.id],
+      order_by: [desc: object.id]
     )
   end
 
-  defp restrict_tag(query, %{tag: tag}) when is_binary(tag) do
+  defp restrict_hashtag_any(query, %{tag: tag}) when is_binary(tag) do
+    restrict_hashtag_any(query, %{tag: [tag]})
+  end
+
+  defp restrict_hashtag_any(query, _), do: query
+
+  defp restrict_hashtag_reject_any(_query, %{tag_reject: _tag_reject, skip_preload: true}) do
+    raise_on_missing_preload()
+  end
+
+  defp restrict_hashtag_reject_any(query, %{tag_reject: [_ | _] = tags_reject}) do
     from(
       [_activity, object] in query,
-      where: fragment("(?)->'tag' \\? (?)", object.data, ^tag)
+      where: object.id not in subquery(object_ids_query_for_tags(tags_reject))
     )
   end
 
-  defp restrict_tag(query, _), do: query
+  defp restrict_hashtag_reject_any(query, %{tag_reject: tag_reject}) when is_binary(tag_reject) do
+    restrict_hashtag_reject_any(query, %{tag_reject: [tag_reject]})
+  end
+
+  defp restrict_hashtag_reject_any(query, _), do: query
+
+  defp raise_on_missing_preload do
+    raise "Can't use the child object without preloading!"
+  end
 
   defp restrict_recipients(query, [], _user), do: query
 
@@ -1098,6 +1208,26 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
 
   defp maybe_order(query, _), do: query
 
+  defp normalize_fetch_activities_query_opts(opts) do
+    Enum.reduce([:tag, :tag_all, :tag_reject], opts, fn key, opts ->
+      case opts[key] do
+        value when is_bitstring(value) ->
+          Map.put(opts, key, Hashtag.normalize_name(value))
+
+        value when is_list(value) ->
+          normalized_value =
+            value
+            |> Enum.map(&Hashtag.normalize_name/1)
+            |> Enum.uniq()
+
+          Map.put(opts, key, normalized_value)
+
+        _ ->
+          opts
+      end
+    end)
+  end
+
   defp fetch_activities_query_ap_ids_ops(opts) do
     source_user = opts[:muting_user]
     ap_id_relationships = if source_user, do: [:mute, :reblog_mute], else: []
@@ -1121,6 +1251,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
   end
 
   def fetch_activities_query(recipients, opts \\ %{}) do
+    opts = normalize_fetch_activities_query_opts(opts)
+
     {restrict_blocked_opts, restrict_muted_opts, restrict_muted_reblogs_opts} =
       fetch_activities_query_ap_ids_ops(opts)
 
@@ -1128,50 +1260,51 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
       skip_thread_containment: Config.get([:instance, :skip_thread_containment])
     }
 
-    Activity
-    |> maybe_preload_objects(opts)
-    |> maybe_preload_bookmarks(opts)
-    |> maybe_preload_report_notes(opts)
-    |> maybe_set_thread_muted_field(opts)
-    |> maybe_order(opts)
-    |> restrict_recipients(recipients, opts[:user])
-    |> restrict_replies(opts)
-    |> restrict_tag(opts)
-    |> restrict_tag_reject(opts)
-    |> restrict_tag_all(opts)
-    |> restrict_since(opts)
-    |> restrict_local(opts)
-    |> restrict_remote(opts)
-    |> restrict_actor(opts)
-    |> restrict_type(opts)
-    |> restrict_state(opts)
-    |> restrict_favorited_by(opts)
-    |> restrict_blocked(restrict_blocked_opts)
-    |> restrict_muted(restrict_muted_opts)
-    |> restrict_filtered(opts)
-    |> restrict_media(opts)
-    |> restrict_visibility(opts)
-    |> restrict_thread_visibility(opts, config)
-    |> restrict_reblogs(opts)
-    |> restrict_pinned(opts)
-    |> restrict_muted_reblogs(restrict_muted_reblogs_opts)
-    |> restrict_instance(opts)
-    |> restrict_announce_object_actor(opts)
-    |> restrict_filtered(opts)
-    |> Activity.restrict_deactivated_users()
-    |> exclude_poll_votes(opts)
-    |> exclude_chat_messages(opts)
-    |> exclude_invisible_actors(opts)
-    |> exclude_visibility(opts)
-  end
-
-  def fetch_activities(recipients, opts \\ %{}, pagination \\ :keyset) do
-    list_memberships = Pleroma.List.memberships(opts[:user])
-
-    fetch_activities_query(recipients ++ list_memberships, opts)
-    |> Pagination.fetch_paginated(opts, pagination)
-    |> Enum.reverse()
-    |> maybe_update_cc(list_memberships, opts[:user])
+    query =
+      Activity
+      |> maybe_preload_objects(opts)
+      |> maybe_preload_bookmarks(opts)
+      |> maybe_preload_report_notes(opts)
+      |> maybe_set_thread_muted_field(opts)
+      |> maybe_order(opts)
+      |> restrict_recipients(recipients, opts[:user])
+      |> restrict_replies(opts)
+      |> restrict_since(opts)
+      |> restrict_local(opts)
+      |> restrict_remote(opts)
+      |> restrict_actor(opts)
+      |> restrict_type(opts)
+      |> restrict_state(opts)
+      |> restrict_favorited_by(opts)
+      |> restrict_blocked(restrict_blocked_opts)
+      |> restrict_muted(restrict_muted_opts)
+      |> restrict_filtered(opts)
+      |> restrict_media(opts)
+      |> restrict_visibility(opts)
+      |> restrict_thread_visibility(opts, config)
+      |> restrict_reblogs(opts)
+      |> restrict_pinned(opts)
+      |> restrict_muted_reblogs(restrict_muted_reblogs_opts)
+      |> restrict_instance(opts)
+      |> restrict_announce_object_actor(opts)
+      |> restrict_filtered(opts)
+      |> Activity.restrict_deactivated_users()
+      |> exclude_poll_votes(opts)
+      |> exclude_chat_messages(opts)
+      |> exclude_invisible_actors(opts)
+      |> exclude_visibility(opts)
+
+    if Config.feature_enabled?(:improved_hashtag_timeline) do
+      query
+      |> restrict_hashtag_any(opts)
+      |> restrict_hashtag_all(opts)
+      |> restrict_hashtag_reject_any(opts)
+    else
+      query
+      |> restrict_embedded_tag_any(opts)
+      |> restrict_embedded_tag_all(opts)
+      |> restrict_embedded_tag_reject_any(opts)
+    end
   end
 
   @doc """
@@ -1250,21 +1383,17 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
 
   defp get_actor_url(_url), do: nil
 
-  defp object_to_user_data(data) do
-    avatar =
-      data["icon"]["url"] &&
-        %{
-          "type" => "Image",
-          "url" => [%{"href" => data["icon"]["url"]}]
-        }
+  defp normalize_image(%{"url" => url}) do
+    %{
+      "type" => "Image",
+      "url" => [%{"href" => url}]
+    }
+  end
 
-    banner =
-      data["image"]["url"] &&
-        %{
-          "type" => "Image",
-          "url" => [%{"href" => data["image"]["url"]}]
-        }
+  defp normalize_image(urls) when is_list(urls), do: urls |> List.first() |> normalize_image()
+  defp normalize_image(_), do: nil
 
+  defp object_to_user_data(data) do
     fields =
       data
       |> Map.get("attachment", [])
@@ -1308,13 +1437,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
       ap_id: data["id"],
       uri: get_actor_url(data["url"]),
       ap_enabled: true,
-      banner: banner,
+      banner: normalize_image(data["image"]),
       fields: fields,
       emoji: emojis,
       is_locked: is_locked,
       is_discoverable: is_discoverable,
       invisible: invisible,
-      avatar: avatar,
+      avatar: normalize_image(data["icon"]),
       name: data["name"],
       follower_address: data["followers"],
       following_address: data["following"],
index ef5a09a937be2386feaadbc80c48c26345d8715a..f2fec3ff61fa37a45149fa1de14b09b8bd376b51 100644 (file)
@@ -92,7 +92,9 @@ defmodule Pleroma.Web.ActivityPub.MRF do
   end
 
   def get_policies do
-    Pleroma.Config.get([:mrf, :policies], []) |> get_policies()
+    Pleroma.Config.get([:mrf, :policies], [])
+    |> get_policies()
+    |> Enum.concat([Pleroma.Web.ActivityPub.MRF.HashtagPolicy])
   end
 
   defp get_policies(policy) when is_atom(policy), do: [policy]
diff --git a/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex b/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex
new file mode 100644 (file)
index 0000000..def0c43
--- /dev/null
@@ -0,0 +1,116 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.HashtagPolicy do
+  require Pleroma.Constants
+
+  alias Pleroma.Config
+  alias Pleroma.Object
+
+  @moduledoc """
+  Reject, TWKN-remove or Set-Sensitive messsages with specific hashtags (without the leading #)
+
+  Note: This MRF Policy is always enabled, if you want to disable it you have to set empty lists.
+  """
+
+  @behaviour Pleroma.Web.ActivityPub.MRF
+
+  defp check_reject(message, hashtags) do
+    if Enum.any?(Config.get([:mrf_hashtag, :reject]), fn match -> match in hashtags end) do
+      {:reject, "[HashtagPolicy] Matches with rejected keyword"}
+    else
+      {:ok, message}
+    end
+  end
+
+  defp check_ftl_removal(%{"to" => to} = message, hashtags) do
+    if Pleroma.Constants.as_public() in to and
+         Enum.any?(Config.get([:mrf_hashtag, :federated_timeline_removal]), fn match ->
+           match in hashtags
+         end) do
+      to = List.delete(to, Pleroma.Constants.as_public())
+      cc = [Pleroma.Constants.as_public() | message["cc"] || []]
+
+      message =
+        message
+        |> Map.put("to", to)
+        |> Map.put("cc", cc)
+        |> Kernel.put_in(["object", "to"], to)
+        |> Kernel.put_in(["object", "cc"], cc)
+
+      {:ok, message}
+    else
+      {:ok, message}
+    end
+  end
+
+  defp check_ftl_removal(message, _hashtags), do: {:ok, message}
+
+  defp check_sensitive(message, hashtags) do
+    if Enum.any?(Config.get([:mrf_hashtag, :sensitive]), fn match -> match in hashtags end) do
+      {:ok, Kernel.put_in(message, ["object", "sensitive"], true)}
+    else
+      {:ok, message}
+    end
+  end
+
+  @impl true
+  def filter(%{"type" => "Create", "object" => object} = message) do
+    hashtags = Object.hashtags(%Object{data: object})
+
+    if hashtags != [] do
+      with {:ok, message} <- check_reject(message, hashtags),
+           {:ok, message} <- check_ftl_removal(message, hashtags),
+           {:ok, message} <- check_sensitive(message, hashtags) do
+        {:ok, message}
+      end
+    else
+      {:ok, message}
+    end
+  end
+
+  @impl true
+  def filter(message), do: {:ok, message}
+
+  @impl true
+  def describe do
+    mrf_hashtag =
+      Config.get(:mrf_hashtag)
+      |> Enum.into(%{})
+
+    {:ok, %{mrf_hashtag: mrf_hashtag}}
+  end
+
+  @impl true
+  def config_description do
+    %{
+      key: :mrf_hashtag,
+      related_policy: "Pleroma.Web.ActivityPub.MRF.HashtagPolicy",
+      label: "MRF Hashtag",
+      description: @moduledoc,
+      children: [
+        %{
+          key: :reject,
+          type: {:list, :string},
+          description: "A list of hashtags which result in message being rejected.",
+          suggestions: ["foo"]
+        },
+        %{
+          key: :federated_timeline_removal,
+          type: {:list, :string},
+          description:
+            "A list of hashtags which result in message being removed from federated timelines (a.k.a unlisted).",
+          suggestions: ["foo"]
+        },
+        %{
+          key: :sensitive,
+          type: {:list, :string},
+          description:
+            "A list of hashtags which result in message being set as sensitive (a.k.a NSFW/R-18)",
+          suggestions: ["nsfw", "r18"]
+        }
+      ]
+    }
+  end
+end
index bb3838d2c18f009efefee8c2f18d1f373fac0abb..62024c58c90929f9073ef72f00db9e2d0953ac5b 100644 (file)
@@ -64,20 +64,16 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do
          %{host: actor_host} = _actor_info,
          %{
            "type" => "Create",
-           "object" => child_object
+           "object" => %{} = _child_object
          } = object
-       )
-       when is_map(child_object) do
+       ) do
     media_nsfw =
       Config.get([:mrf_simple, :media_nsfw])
       |> MRF.subdomains_regex()
 
     object =
       if MRF.subdomain_match?(media_nsfw, actor_host) do
-        tags = (child_object["tag"] || []) ++ ["nsfw"]
-        child_object = Map.put(child_object, "tag", tags)
-        child_object = Map.put(child_object, "sensitive", true)
-        Map.put(object, "object", child_object)
+        Kernel.put_in(object, ["object", "sensitive"], true)
       else
         object
       end
index 5739cee6319a3c0c2423a5406fb2b751222a0cd0..528093ac00e4ae78ee741246a6662f3583768c54 100644 (file)
@@ -28,20 +28,11 @@ defmodule Pleroma.Web.ActivityPub.MRF.TagPolicy do
          "mrf_tag:media-force-nsfw",
          %{
            "type" => "Create",
-           "object" => %{"attachment" => child_attachment} = object
+           "object" => %{"attachment" => child_attachment}
          } = message
        )
        when length(child_attachment) > 0 do
-    tags = (object["tag"] || []) ++ ["nsfw"]
-
-    object =
-      object
-      |> Map.put("tag", tags)
-      |> Map.put("sensitive", true)
-
-    message = Map.put(message, "object", object)
-
-    {:ok, message}
+    {:ok, Kernel.put_in(message, ["object", "sensitive"], true)}
   end
 
   defp process_tag(
index 4d9a5617eb9b5b3fcdebfb44b0e3cf0ee9c8aed3..8c7d6a7478c28ada25aeb7baf55b9fed5c9d2471 100644 (file)
@@ -32,18 +32,17 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
   """
   def fix_object(object, options \\ []) do
     object
-    |> strip_internal_fields
-    |> fix_actor
-    |> fix_url
-    |> fix_attachments
-    |> fix_context
+    |> strip_internal_fields()
+    |> fix_actor()
+    |> fix_url()
+    |> fix_attachments()
+    |> fix_context()
     |> fix_in_reply_to(options)
-    |> fix_emoji
-    |> fix_tag
-    |> set_sensitive
-    |> fix_content_map
-    |> fix_addressing
-    |> fix_summary
+    |> fix_emoji()
+    |> fix_tag()
+    |> fix_content_map()
+    |> fix_addressing()
+    |> fix_summary()
     |> fix_type(options)
   end
 
@@ -315,10 +314,9 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
     tags =
       tag
       |> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end)
-      |> Enum.map(fn %{"name" => name} ->
-        name
-        |> String.slice(1..-1)
-        |> String.downcase()
+      |> Enum.map(fn
+        %{"name" => "#" <> hashtag} -> String.downcase(hashtag)
+        %{"name" => hashtag} -> String.downcase(hashtag)
       end)
 
     Map.put(object, "tag", tag ++ tags)
@@ -742,7 +740,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
   # Prepares the object of an outgoing create activity.
   def prepare_object(object) do
     object
-    |> set_sensitive
     |> add_hashtags
     |> add_mention_tags
     |> add_emoji_tags
@@ -933,15 +930,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
     Map.put(object, "conversation", object["context"])
   end
 
-  def set_sensitive(%{"sensitive" => _} = object) do
-    object
-  end
-
-  def set_sensitive(object) do
-    tags = object["tag"] || []
-    Map.put(object, "sensitive", "nsfw" in tags)
-  end
-
   def set_type(%{"type" => "Answer"} = object) do
     Map.put(object, "type", "Note")
   end
index 65bc63cb9e66bb224e8390d6908d5f7d570c8592..d3e4c18a35f624dd4c818e643d1021a0e2ecaf03 100644 (file)
@@ -13,16 +13,17 @@ defmodule Pleroma.Web.AdminAPI.UserController do
   alias Pleroma.Web.ActivityPub.Builder
   alias Pleroma.Web.ActivityPub.Pipeline
   alias Pleroma.Web.AdminAPI
-  alias Pleroma.Web.AdminAPI.AccountView
   alias Pleroma.Web.AdminAPI.Search
   alias Pleroma.Web.Plugs.OAuthScopesPlug
 
   @users_page_size 50
 
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
+
   plug(
     OAuthScopesPlug,
     %{scopes: ["admin:read:accounts"]}
-    when action in [:list, :show]
+    when action in [:index, :show]
   )
 
   plug(
@@ -44,13 +45,19 @@ defmodule Pleroma.Web.AdminAPI.UserController do
     when action in [:follow, :unfollow]
   )
 
+  plug(:put_view, Pleroma.Web.AdminAPI.AccountView)
+
   action_fallback(AdminAPI.FallbackController)
 
-  def delete(conn, %{"nickname" => nickname}) do
-    delete(conn, %{"nicknames" => [nickname]})
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.UserOperation
+
+  def delete(conn, %{nickname: nickname}) do
+    conn
+    |> Map.put(:body_params, %{nicknames: [nickname]})
+    |> delete(%{})
   end
 
-  def delete(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do
+  def delete(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = conn, _) do
     users = Enum.map(nicknames, &User.get_cached_by_nickname/1)
 
     Enum.each(users, fn user ->
@@ -67,10 +74,16 @@ defmodule Pleroma.Web.AdminAPI.UserController do
     json(conn, nicknames)
   end
 
-  def follow(%{assigns: %{user: admin}} = conn, %{
-        "follower" => follower_nick,
-        "followed" => followed_nick
-      }) do
+  def follow(
+        %{
+          assigns: %{user: admin},
+          body_params: %{
+            follower: follower_nick,
+            followed: followed_nick
+          }
+        } = conn,
+        _
+      ) do
     with %User{} = follower <- User.get_cached_by_nickname(follower_nick),
          %User{} = followed <- User.get_cached_by_nickname(followed_nick) do
       User.follow(follower, followed)
@@ -86,10 +99,16 @@ defmodule Pleroma.Web.AdminAPI.UserController do
     json(conn, "ok")
   end
 
-  def unfollow(%{assigns: %{user: admin}} = conn, %{
-        "follower" => follower_nick,
-        "followed" => followed_nick
-      }) do
+  def unfollow(
+        %{
+          assigns: %{user: admin},
+          body_params: %{
+            follower: follower_nick,
+            followed: followed_nick
+          }
+        } = conn,
+        _
+      ) do
     with %User{} = follower <- User.get_cached_by_nickname(follower_nick),
          %User{} = followed <- User.get_cached_by_nickname(followed_nick) do
       User.unfollow(follower, followed)
@@ -105,9 +124,10 @@ defmodule Pleroma.Web.AdminAPI.UserController do
     json(conn, "ok")
   end
 
-  def create(%{assigns: %{user: admin}} = conn, %{"users" => users}) do
+  def create(%{assigns: %{user: admin}, body_params: %{users: users}} = conn, _) do
     changesets =
-      Enum.map(users, fn %{"nickname" => nickname, "email" => email, "password" => password} ->
+      users
+      |> Enum.map(fn %{nickname: nickname, email: email, password: password} ->
         user_data = %{
           nickname: nickname,
           name: nickname,
@@ -124,52 +144,49 @@ defmodule Pleroma.Web.AdminAPI.UserController do
       end)
 
     case Pleroma.Repo.transaction(changesets) do
-      {:ok, users} ->
-        res =
-          users
+      {:ok, users_map} ->
+        users =
+          users_map
           |> Map.values()
           |> Enum.map(fn user ->
             {:ok, user} = User.post_register_action(user)
 
             user
           end)
-          |> Enum.map(&AccountView.render("created.json", %{user: &1}))
 
         ModerationLog.insert_log(%{
           actor: admin,
-          subjects: Map.values(users),
+          subjects: users,
           action: "create"
         })
 
-        json(conn, res)
+        render(conn, "created_many.json", users: users)
 
       {:error, id, changeset, _} ->
-        res =
+        changesets =
           Enum.map(changesets.operations, fn
-            {current_id, {:changeset, _current_changeset, _}} when current_id == id ->
-              AccountView.render("create-error.json", %{changeset: changeset})
+            {^id, {:changeset, _current_changeset, _}} ->
+              changeset
 
             {_, {:changeset, current_changeset, _}} ->
-              AccountView.render("create-error.json", %{changeset: current_changeset})
+              current_changeset
           end)
 
         conn
         |> put_status(:conflict)
-        |> json(res)
+        |> render("create_errors.json", changesets: changesets)
     end
   end
 
-  def show(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do
+  def show(%{assigns: %{user: admin}} = conn, %{nickname: nickname}) do
     with %User{} = user <- User.get_cached_by_nickname_or_id(nickname, for: admin) do
-      conn
-      |> put_view(AccountView)
-      |> render("show.json", %{user: user})
+      render(conn, "show.json", %{user: user})
     else
       _ -> {:error, :not_found}
     end
   end
 
-  def toggle_activation(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do
+  def toggle_activation(%{assigns: %{user: admin}} = conn, %{nickname: nickname}) do
     user = User.get_cached_by_nickname(nickname)
 
     {:ok, updated_user} = User.set_activation(user, !user.is_active)
@@ -182,12 +199,10 @@ defmodule Pleroma.Web.AdminAPI.UserController do
       action: action
     })
 
-    conn
-    |> put_view(AccountView)
-    |> render("show.json", %{user: updated_user})
+    render(conn, "show.json", user: updated_user)
   end
 
-  def activate(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do
+  def activate(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = conn, _) do
     users = Enum.map(nicknames, &User.get_cached_by_nickname/1)
     {:ok, updated_users} = User.set_activation(users, true)
 
@@ -197,12 +212,10 @@ defmodule Pleroma.Web.AdminAPI.UserController do
       action: "activate"
     })
 
-    conn
-    |> put_view(AccountView)
-    |> render("index.json", %{users: Keyword.values(updated_users)})
+    render(conn, "index.json", users: Keyword.values(updated_users))
   end
 
-  def deactivate(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do
+  def deactivate(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = conn, _) do
     users = Enum.map(nicknames, &User.get_cached_by_nickname/1)
     {:ok, updated_users} = User.set_activation(users, false)
 
@@ -212,12 +225,10 @@ defmodule Pleroma.Web.AdminAPI.UserController do
       action: "deactivate"
     })
 
-    conn
-    |> put_view(AccountView)
-    |> render("index.json", %{users: Keyword.values(updated_users)})
+    render(conn, "index.json", users: Keyword.values(updated_users))
   end
 
-  def approve(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do
+  def approve(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = conn, _) do
     users = Enum.map(nicknames, &User.get_cached_by_nickname/1)
     {:ok, updated_users} = User.approve(users)
 
@@ -227,36 +238,27 @@ defmodule Pleroma.Web.AdminAPI.UserController do
       action: "approve"
     })
 
-    conn
-    |> put_view(AccountView)
-    |> render("index.json", %{users: updated_users})
+    render(conn, "index.json", users: updated_users)
   end
 
-  def list(conn, params) do
+  def index(conn, params) do
     {page, page_size} = page_params(params)
-    filters = maybe_parse_filters(params["filters"])
+    filters = maybe_parse_filters(params[:filters])
 
     search_params =
       %{
-        query: params["query"],
+        query: params[:query],
         page: page,
         page_size: page_size,
-        tags: params["tags"],
-        name: params["name"],
-        email: params["email"],
-        actor_types: params["actor_types"]
+        tags: params[:tags],
+        name: params[:name],
+        email: params[:email],
+        actor_types: params[:actor_types]
       }
       |> Map.merge(filters)
 
     with {:ok, users, count} <- Search.user(search_params) do
-      json(
-        conn,
-        AccountView.render("index.json",
-          users: users,
-          count: count,
-          page_size: page_size
-        )
-      )
+      render(conn, "index.json", users: users, count: count, page_size: page_size)
     end
   end
 
@@ -274,8 +276,8 @@ defmodule Pleroma.Web.AdminAPI.UserController do
 
   defp page_params(params) do
     {
-      fetch_integer_param(params, "page", 1),
-      fetch_integer_param(params, "page_size", @users_page_size)
+      fetch_integer_param(params, :page, 1),
+      fetch_integer_param(params, :page_size, @users_page_size)
     }
   end
 end
index d7c63d385d803b52af1bd6c2f676d4302922acaf..e053a9b67249ab25a976113b1268874eaadc3bb2 100644 (file)
@@ -75,7 +75,7 @@ defmodule Pleroma.Web.AdminAPI.AccountView do
       "display_name" => display_name,
       "is_active" => user.is_active,
       "local" => user.local,
-      "roles" => User.roles(user),
+      "roles" => roles(user),
       "tags" => user.tags || [],
       "is_confirmed" => user.is_confirmed,
       "is_approved" => user.is_approved,
@@ -85,6 +85,10 @@ defmodule Pleroma.Web.AdminAPI.AccountView do
     }
   end
 
+  def render("created_many.json", %{users: users}) do
+    render_many(users, AccountView, "created.json", as: :user)
+  end
+
   def render("created.json", %{user: user}) do
     %{
       type: "success",
@@ -96,7 +100,11 @@ defmodule Pleroma.Web.AdminAPI.AccountView do
     }
   end
 
-  def render("create-error.json", %{changeset: %Ecto.Changeset{changes: changes, errors: errors}}) do
+  def render("create_errors.json", %{changesets: changesets}) do
+    render_many(changesets, AccountView, "create_error.json", as: :changeset)
+  end
+
+  def render("create_error.json", %{changeset: %Ecto.Changeset{changes: changes, errors: errors}}) do
     %{
       type: "error",
       code: 409,
@@ -140,4 +148,11 @@ defmodule Pleroma.Web.AdminAPI.AccountView do
 
   defp image_url(%{"url" => [%{"href" => href} | _]}), do: href
   defp image_url(_), do: nil
+
+  defp roles(%{is_moderator: is_moderator, is_admin: is_admin}) do
+    %{
+      admin: is_admin,
+      moderator: is_moderator
+    }
+  end
 end
index adc8762dc573e25682a6c412046f4112cdbbeddb..528cd9cf4fc43eeddbc760874322c2c4a4dd86b8 100644 (file)
@@ -92,9 +92,10 @@ defmodule Pleroma.Web.ApiSpec do
               "Invites",
               "MediaProxy cache",
               "OAuth application managment",
-              "Report managment",
               "Relays",
-              "Status administration"
+              "Report managment",
+              "Status administration",
+              "User administration"
             ]
           },
           %{"name" => "Applications", "tags" => ["Applications", "Push subscriptions"]},
index a3da856fface8516304ade068d3b43453a59c765..d23a7dcb6c4fd8b57fdd5cfc6f1d1c88e89290dd 100644 (file)
@@ -15,6 +15,7 @@ defmodule Pleroma.Web.ApiSpec.CastAndValidate do
 
   @behaviour Plug
 
+  alias OpenApiSpex.Plug.PutApiSpec
   alias Plug.Conn
 
   @impl Plug
@@ -25,12 +26,10 @@ defmodule Pleroma.Web.ApiSpec.CastAndValidate do
   end
 
   @impl Plug
-  def call(%{private: %{open_api_spex: private_data}} = conn, %{
-        operation_id: operation_id,
-        render_error: render_error
-      }) do
-    spec = private_data.spec
-    operation = private_data.operation_lookup[operation_id]
+
+  def call(conn, %{operation_id: operation_id, render_error: render_error}) do
+    {spec, operation_lookup} = PutApiSpec.get_spec_and_operation_lookup(conn)
+    operation = operation_lookup[operation_id]
 
     content_type =
       case Conn.get_req_header(conn, "content-type") do
@@ -43,8 +42,7 @@ defmodule Pleroma.Web.ApiSpec.CastAndValidate do
           "application/json"
       end
 
-    private_data = Map.put(private_data, :operation_id, operation_id)
-    conn = Conn.put_private(conn, :open_api_spex, private_data)
+    conn = Conn.put_private(conn, :operation_id, operation_id)
 
     case cast_and_validate(spec, operation, conn, content_type, strict?()) do
       {:ok, conn} ->
@@ -64,25 +62,22 @@ defmodule Pleroma.Web.ApiSpec.CastAndValidate do
           private: %{
             phoenix_controller: controller,
             phoenix_action: action,
-            open_api_spex: private_data
+            open_api_spex: %{spec_module: spec_module}
           }
         } = conn,
         opts
       ) do
+    {spec, operation_lookup} = PutApiSpec.get_spec_and_operation_lookup(conn)
+
     operation =
-      case private_data.operation_lookup[{controller, action}] do
+      case operation_lookup[{controller, action}] do
         nil ->
           operation_id = controller.open_api_operation(action).operationId
-          operation = private_data.operation_lookup[operation_id]
+          operation = operation_lookup[operation_id]
 
-          operation_lookup =
-            private_data.operation_lookup
-            |> Map.put({controller, action}, operation)
+          operation_lookup = Map.put(operation_lookup, {controller, action}, operation)
 
-          OpenApiSpex.Plug.Cache.adapter().put(
-            private_data.spec_module,
-            {private_data.spec, operation_lookup}
-          )
+          OpenApiSpex.Plug.Cache.adapter().put(spec_module, {spec, operation_lookup})
 
           operation
 
diff --git a/lib/pleroma/web/api_spec/operations/admin/user_operation.ex b/lib/pleroma/web/api_spec/operations/admin/user_operation.ex
new file mode 100644 (file)
index 0000000..c9d0bfd
--- /dev/null
@@ -0,0 +1,389 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Admin.UserOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Schemas.ActorType
+  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: ["User administration"],
+      summary: "List users",
+      operationId: "AdminAPI.UserController.index",
+      security: [%{"oAuth" => ["admin:read:accounts"]}],
+      parameters: [
+        Operation.parameter(:filters, :query, :string, "Comma separated list of filters"),
+        Operation.parameter(:query, :query, :string, "Search users query"),
+        Operation.parameter(:name, :query, :string, "Search by display name"),
+        Operation.parameter(:email, :query, :string, "Search by email"),
+        Operation.parameter(:page, :query, :integer, "Page Number"),
+        Operation.parameter(:page_size, :query, :integer, "Number of users to return per page"),
+        Operation.parameter(
+          :actor_types,
+          :query,
+          %Schema{type: :array, items: ActorType},
+          "Filter by actor type"
+        ),
+        Operation.parameter(
+          :tags,
+          :query,
+          %Schema{type: :array, items: %Schema{type: :string}},
+          "Filter by tags"
+        )
+        | admin_api_params()
+      ],
+      responses: %{
+        200 =>
+          Operation.response(
+            "Response",
+            "application/json",
+            %Schema{
+              type: :object,
+              properties: %{
+                users: %Schema{type: :array, items: user()},
+                count: %Schema{type: :integer},
+                page_size: %Schema{type: :integer}
+              }
+            }
+          ),
+        403 => Operation.response("Forbidden", "application/json", ApiError)
+      }
+    }
+  end
+
+  def create_operation do
+    %Operation{
+      tags: ["User administration"],
+      summary: "Create a single or multiple users",
+      operationId: "AdminAPI.UserController.create",
+      security: [%{"oAuth" => ["admin:write:accounts"]}],
+      parameters: admin_api_params(),
+      requestBody:
+        request_body(
+          "Parameters",
+          %Schema{
+            description: "POST body for creating users",
+            type: :object,
+            properties: %{
+              users: %Schema{
+                type: :array,
+                items: %Schema{
+                  type: :object,
+                  properties: %{
+                    nickname: %Schema{type: :string},
+                    email: %Schema{type: :string},
+                    password: %Schema{type: :string}
+                  }
+                }
+              }
+            }
+          }
+        ),
+      responses: %{
+        200 =>
+          Operation.response("Response", "application/json", %Schema{
+            type: :array,
+            items: %Schema{
+              type: :object,
+              properties: %{
+                code: %Schema{type: :integer},
+                type: %Schema{type: :string},
+                data: %Schema{
+                  type: :object,
+                  properties: %{
+                    email: %Schema{type: :string, format: :email},
+                    nickname: %Schema{type: :string}
+                  }
+                }
+              }
+            }
+          }),
+        403 => Operation.response("Forbidden", "application/json", ApiError),
+        409 =>
+          Operation.response("Conflict", "application/json", %Schema{
+            type: :array,
+            items: %Schema{
+              type: :object,
+              properties: %{
+                code: %Schema{type: :integer},
+                error: %Schema{type: :string},
+                type: %Schema{type: :string},
+                data: %Schema{
+                  type: :object,
+                  properties: %{
+                    email: %Schema{type: :string, format: :email},
+                    nickname: %Schema{type: :string}
+                  }
+                }
+              }
+            }
+          })
+      }
+    }
+  end
+
+  def show_operation do
+    %Operation{
+      tags: ["User administration"],
+      summary: "Show user",
+      operationId: "AdminAPI.UserController.show",
+      security: [%{"oAuth" => ["admin:read:accounts"]}],
+      parameters: [
+        Operation.parameter(
+          :nickname,
+          :path,
+          :string,
+          "User nickname or ID"
+        )
+        | admin_api_params()
+      ],
+      responses: %{
+        200 => Operation.response("Response", "application/json", user()),
+        403 => Operation.response("Forbidden", "application/json", ApiError),
+        404 => Operation.response("Not Found", "application/json", ApiError)
+      }
+    }
+  end
+
+  def follow_operation do
+    %Operation{
+      tags: ["User administration"],
+      summary: "Follow",
+      operationId: "AdminAPI.UserController.follow",
+      security: [%{"oAuth" => ["admin:write:follows"]}],
+      parameters: admin_api_params(),
+      requestBody:
+        request_body(
+          "Parameters",
+          %Schema{
+            type: :object,
+            properties: %{
+              follower: %Schema{type: :string, description: "Follower nickname"},
+              followed: %Schema{type: :string, description: "Followed nickname"}
+            }
+          }
+        ),
+      responses: %{
+        200 => Operation.response("Response", "application/json", %Schema{type: :string}),
+        403 => Operation.response("Forbidden", "application/json", ApiError)
+      }
+    }
+  end
+
+  def unfollow_operation do
+    %Operation{
+      tags: ["User administration"],
+      summary: "Unfollow",
+      operationId: "AdminAPI.UserController.unfollow",
+      security: [%{"oAuth" => ["admin:write:follows"]}],
+      parameters: admin_api_params(),
+      requestBody:
+        request_body(
+          "Parameters",
+          %Schema{
+            type: :object,
+            properties: %{
+              follower: %Schema{type: :string, description: "Follower nickname"},
+              followed: %Schema{type: :string, description: "Followed nickname"}
+            }
+          }
+        ),
+      responses: %{
+        200 => Operation.response("Response", "application/json", %Schema{type: :string}),
+        403 => Operation.response("Forbidden", "application/json", ApiError)
+      }
+    }
+  end
+
+  def approve_operation do
+    %Operation{
+      tags: ["User administration"],
+      summary: "Approve multiple users",
+      operationId: "AdminAPI.UserController.approve",
+      security: [%{"oAuth" => ["admin:write:accounts"]}],
+      parameters: admin_api_params(),
+      requestBody:
+        request_body(
+          "Parameters",
+          %Schema{
+            description: "POST body for deleting multiple users",
+            type: :object,
+            properties: %{
+              nicknames: %Schema{
+                type: :array,
+                items: %Schema{type: :string}
+              }
+            }
+          }
+        ),
+      responses: %{
+        200 =>
+          Operation.response("Response", "application/json", %Schema{
+            type: :object,
+            properties: %{user: %Schema{type: :array, items: user()}}
+          }),
+        403 => Operation.response("Forbidden", "application/json", ApiError)
+      }
+    }
+  end
+
+  def toggle_activation_operation do
+    %Operation{
+      tags: ["User administration"],
+      summary: "Toggle user activation",
+      operationId: "AdminAPI.UserController.toggle_activation",
+      security: [%{"oAuth" => ["admin:write:accounts"]}],
+      parameters: [
+        Operation.parameter(:nickname, :path, :string, "User nickname")
+        | admin_api_params()
+      ],
+      responses: %{
+        200 => Operation.response("Response", "application/json", user()),
+        403 => Operation.response("Forbidden", "application/json", ApiError)
+      }
+    }
+  end
+
+  def activate_operation do
+    %Operation{
+      tags: ["User administration"],
+      summary: "Activate multiple users",
+      operationId: "AdminAPI.UserController.activate",
+      security: [%{"oAuth" => ["admin:write:accounts"]}],
+      parameters: admin_api_params(),
+      requestBody:
+        request_body(
+          "Parameters",
+          %Schema{
+            description: "POST body for deleting multiple users",
+            type: :object,
+            properties: %{
+              nicknames: %Schema{
+                type: :array,
+                items: %Schema{type: :string}
+              }
+            }
+          }
+        ),
+      responses: %{
+        200 =>
+          Operation.response("Response", "application/json", %Schema{
+            type: :object,
+            properties: %{user: %Schema{type: :array, items: user()}}
+          }),
+        403 => Operation.response("Forbidden", "application/json", ApiError)
+      }
+    }
+  end
+
+  def deactivate_operation do
+    %Operation{
+      tags: ["User administration"],
+      summary: "Deactivates multiple users",
+      operationId: "AdminAPI.UserController.deactivate",
+      security: [%{"oAuth" => ["admin:write:accounts"]}],
+      parameters: admin_api_params(),
+      requestBody:
+        request_body(
+          "Parameters",
+          %Schema{
+            description: "POST body for deleting multiple users",
+            type: :object,
+            properties: %{
+              nicknames: %Schema{
+                type: :array,
+                items: %Schema{type: :string}
+              }
+            }
+          }
+        ),
+      responses: %{
+        200 =>
+          Operation.response("Response", "application/json", %Schema{
+            type: :object,
+            properties: %{user: %Schema{type: :array, items: user()}}
+          }),
+        403 => Operation.response("Forbidden", "application/json", ApiError)
+      }
+    }
+  end
+
+  def delete_operation do
+    %Operation{
+      tags: ["User administration"],
+      summary: "Removes a single or multiple users",
+      operationId: "AdminAPI.UserController.delete",
+      security: [%{"oAuth" => ["admin:write:accounts"]}],
+      parameters: [
+        Operation.parameter(
+          :nickname,
+          :query,
+          :string,
+          "User nickname"
+        )
+        | admin_api_params()
+      ],
+      requestBody:
+        request_body(
+          "Parameters",
+          %Schema{
+            description: "POST body for deleting multiple users",
+            type: :object,
+            properties: %{
+              nicknames: %Schema{
+                type: :array,
+                items: %Schema{type: :string}
+              }
+            }
+          }
+        ),
+      responses: %{
+        200 =>
+          Operation.response("Response", "application/json", %Schema{
+            description: "Array of nicknames",
+            type: :array,
+            items: %Schema{type: :string}
+          }),
+        403 => Operation.response("Forbidden", "application/json", ApiError)
+      }
+    }
+  end
+
+  defp user do
+    %Schema{
+      type: :object,
+      properties: %{
+        id: %Schema{type: :string},
+        email: %Schema{type: :string, format: :email},
+        avatar: %Schema{type: :string, format: :uri},
+        nickname: %Schema{type: :string},
+        display_name: %Schema{type: :string},
+        is_active: %Schema{type: :boolean},
+        local: %Schema{type: :boolean},
+        roles: %Schema{
+          type: :object,
+          properties: %{
+            admin: %Schema{type: :boolean},
+            moderator: %Schema{type: :boolean}
+          }
+        },
+        tags: %Schema{type: :array, items: %Schema{type: :string}},
+        is_confirmed: %Schema{type: :boolean},
+        is_approved: %Schema{type: :boolean},
+        url: %Schema{type: :string, format: :uri},
+        registration_reason: %Schema{type: :string, nullable: true},
+        actor_type: %Schema{type: :string}
+      }
+    }
+  end
+end
index 40edc747d9083ad79229ceadcca18ec8dd332cf4..4bdb8e281ff8a7fa1c57b142593e38c4ba9b418a 100644 (file)
@@ -59,7 +59,7 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
           Operation.response(
             "Status. When `scheduled_at` is present, ScheduledStatus is returned instead",
             "application/json",
-            %Schema{oneOf: [Status, ScheduledStatus]}
+            %Schema{anyOf: [Status, ScheduledStatus]}
           ),
         422 => Operation.response("Bad Request / MRF Rejection", "application/json", ApiError)
       }
index eb001c5bb35e2d31af499f05792734f91470c35f..778158f66f79790655ce6ac159feff9058d09c5e 100644 (file)
@@ -3,6 +3,7 @@
 # SPDX-License-Identifier: AGPL-3.0-only
 
 defmodule Pleroma.Web.ApiSpec.Schemas.BooleanLike do
+  alias OpenApiSpex.Cast
   alias OpenApiSpex.Schema
 
   require OpenApiSpex
@@ -27,10 +28,13 @@ defmodule Pleroma.Web.ApiSpec.Schemas.BooleanLike do
       %Schema{type: :boolean},
       %Schema{type: :string},
       %Schema{type: :integer}
-    ]
+    ],
+    "x-validate": __MODULE__
   })
 
-  def after_cast(value, _schmea) do
-    {:ok, Pleroma.Web.ControllerHelper.truthy_param?(value)}
+  def cast(%Cast{value: value} = context) do
+    context
+    |> Map.put(:value, Pleroma.Web.ControllerHelper.truthy_param?(value))
+    |> Cast.ok()
   end
 end
index 73f1b09313ba935fb9d574039e232383a813e0ea..8668b600ebd5b69f75bbf2556986c89fe1dae21f 100644 (file)
@@ -179,7 +179,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do
   end
 
   defp sensitive(draft) do
-    sensitive = draft.params[:sensitive] || Enum.member?(draft.tags, {"#nsfw", "nsfw"})
+    sensitive = draft.params[:sensitive]
     %__MODULE__{draft | sensitive: sensitive}
   end
 
index 9587dfa2511aed24214f528ef263f95de76dfa34..4e6a3feb04143ca7888029dafbf8f9b825ca4b17 100644 (file)
@@ -217,7 +217,6 @@ defmodule Pleroma.Web.CommonAPI.Utils do
     draft.status
     |> format_input(content_type, options)
     |> maybe_add_attachments(draft.attachments, attachment_links)
-    |> maybe_add_nsfw_tag(draft.params)
   end
 
   defp get_content_type(content_type) do
@@ -228,13 +227,6 @@ defmodule Pleroma.Web.CommonAPI.Utils do
     end
   end
 
-  defp maybe_add_nsfw_tag({text, mentions, tags}, %{"sensitive" => sensitive})
-       when sensitive in [true, "True", "true", "1"] do
-    {text, mentions, [{"#nsfw", "nsfw"} | tags]}
-  end
-
-  defp maybe_add_nsfw_tag(data, _), do: data
-
   def make_context(_, %Participation{} = participation) do
     Repo.preload(participation, :conversation).conversation.ap_id
   end
index df97d2f46e1476df600585816a9940d70773a350..66940f3110a1abb7cbc46ccc9382851c1dd62da7 100644 (file)
@@ -32,6 +32,7 @@ defmodule Pleroma.Web.Feed.FeedView do
 
     %{
       activity: activity,
+      object: object,
       data: Map.get(object, :data),
       actor: actor
     }
index 267d0f03b5620c549ed5c228b9c2a724492af349..c7a5267d423002f486f87b233866f79cdb1477c1 100644 (file)
@@ -5,7 +5,7 @@
 defmodule Pleroma.Web.MastodonAPI.InstanceController do
   use Pleroma.Web, :controller
 
-  plug(OpenApiSpex.Plug.CastAndValidate)
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
 
   plug(
     :skip_plug,
index cef299aa49a1579719b3f017aade8edfced9076a..c611958be09ac3f603e67ccd3cbcab2cf0f93747 100644 (file)
@@ -133,34 +133,25 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do
   end
 
   defp hashtag_fetching(params, user, local_only) do
-    tags =
+    # Note: not sanitizing tag options at this stage (may be mix-cased, have duplicates etc.)
+    tags_any =
       [params[:tag], params[:any]]
       |> List.flatten()
-      |> Enum.uniq()
-      |> Enum.reject(&is_nil/1)
-      |> Enum.map(&String.downcase/1)
-
-    tag_all =
-      params
-      |> Map.get(:all, [])
-      |> Enum.map(&String.downcase/1)
-
-    tag_reject =
-      params
-      |> Map.get(:none, [])
-      |> Enum.map(&String.downcase/1)
-
-    _activities =
-      params
-      |> Map.put(:type, "Create")
-      |> Map.put(:local_only, local_only)
-      |> Map.put(:blocking_user, user)
-      |> Map.put(:muting_user, user)
-      |> Map.put(:user, user)
-      |> Map.put(:tag, tags)
-      |> Map.put(:tag_all, tag_all)
-      |> Map.put(:tag_reject, tag_reject)
-      |> ActivityPub.fetch_public_activities()
+      |> Enum.filter(& &1)
+
+    tag_all = Map.get(params, :all, [])
+    tag_reject = Map.get(params, :none, [])
+
+    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_any)
+    |> Map.put(:tag_all, tag_all)
+    |> Map.put(:tag_reject, tag_reject)
+    |> ActivityPub.fetch_public_activities()
   end
 
   # GET /api/v1/timelines/tag/:tag
index f3f54e03dc00cf8c927e05923e705c634abeabf6..d30c9fa68b444b99c9a433768727b81377442c07 100644 (file)
@@ -198,8 +198,10 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
     like_count = object.data["like_count"] || 0
     announcement_count = object.data["announcement_count"] || 0
 
-    tags = object.data["tag"] || []
-    sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
+    hashtags = Object.hashtags(object)
+    sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
+
+    tags = Object.tags(object)
 
     tag_mentions =
       tags
index 315657e9c2465321b3683799e073903bcf7ba9ad..fc5d16771fa0e122c46880db1258fa03e043e921 100644 (file)
@@ -10,7 +10,7 @@ defmodule Pleroma.Web.PleromaAPI.BackupController do
 
   action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
   plug(OAuthScopesPlug, %{scopes: ["read:accounts"]} when action in [:index, :create])
-  plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError)
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
 
   defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaBackupOperation
 
index 4adc685fe9eb60591e6d6538848c9f867ca495df..dcd54b1afccdcadad2ab1755e0831cae57721af9 100644 (file)
@@ -38,7 +38,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do
     %{scopes: ["read:chats"]} when action in [:messages, :index, :index2, :show]
   )
 
-  plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError)
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
 
   defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ChatOperation
 
index 6d9a11fb6757f558ab1fb789739f5bb6bf05f3b8..078d470d96eba056a1de8f4a4a1f213b6e4095ae 100644 (file)
@@ -15,7 +15,7 @@ defmodule Pleroma.Web.PleromaAPI.UserImportController do
   plug(OAuthScopesPlug, %{scopes: ["follow", "write:blocks"]} when action == :blocks)
   plug(OAuthScopesPlug, %{scopes: ["follow", "write:mutes"]} when action == :mutes)
 
-  plug(OpenApiSpex.Plug.CastAndValidate)
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
   defdelegate open_api_operation(action), to: ApiSpec.UserImportOperation
 
   def follow(%{body_params: %{list: %Plug.Upload{path: path}}} = conn, _) do
index 72ad14f052a39e56f10e8262e07406f52620f97b..de0bd27d73fa7ce5dcc7edaf6303b9f04d53d534 100644 (file)
@@ -204,7 +204,7 @@ defmodule Pleroma.Web.Router do
     get("/users/:nickname/credentials", AdminAPIController, :show_user_credentials)
     patch("/users/:nickname/credentials", AdminAPIController, :update_user_credentials)
 
-    get("/users", UserController, :list)
+    get("/users", UserController, :index)
     get("/users/:nickname", UserController, :show)
     get("/users/:nickname/statuses", AdminAPIController, :list_user_statuses)
     get("/users/:nickname/chats", AdminAPIController, :list_user_chats)
index 3fd150c4e7570b6d54e449a87ffac7d1adc74d7a..6688830baa954d081ac736703fe870e514535259 100644 (file)
@@ -22,7 +22,7 @@
     <link type="text/html" href='<%= @data["external_url"] %>' rel="alternate"/>
   <% end %>
 
-  <%= for tag <- @data["tag"] || [] do %>
+  <%= for tag <- Pleroma.Object.hashtags(@object) do %>
     <category term="<%= tag %>"></category>
   <% end %>
 
index 947bbb09970fa6f01788cf813414a1554eaf44a4..592b9dcdc4331b86ab4ee5c2c2c52203c0745593 100644 (file)
@@ -22,7 +22,7 @@
 
   <link rel="ostatus:conversation"><%= activity_context(@activity) %></link>
 
-  <%= for tag <- @data["tag"] || [] do %>
+  <%= for tag <- Pleroma.Object.hashtags(@object) do %>
     <category term="<%= tag %>"></category>
   <% end %>
 
index cf5874a91341cb8108631829aa150ec9fd70e15b..c2de28fe48888bea7d8f3fc8c71d6a576cc5a89b 100644 (file)
@@ -41,7 +41,7 @@
       <% end %>
     <% end %>
 
-    <%= for tag <- @data["tag"] || [] do %>
+    <%= for tag <- Pleroma.Object.hashtags(@object) do %>
       <category term="<%= tag %>"></category>
     <% end %>
 
diff --git a/mix.exs b/mix.exs
index ec6e92df74221355046da968e35bf568e45e3119..ae74f50a3ce3939284e60349d9c221e32a78abef 100644 (file)
--- a/mix.exs
+++ b/mix.exs
@@ -121,6 +121,7 @@ defmodule Pleroma.Mixfile do
       {:phoenix_pubsub, "~> 2.0"},
       {:phoenix_ecto, "~> 4.0"},
       {:ecto_enum, "~> 1.4"},
+      {:ecto_explain, "~> 0.1.2"},
       {:ecto_sql, "~> 3.4.4"},
       {:postgrex, ">= 0.15.5"},
       {:oban, "~> 2.3.4"},
@@ -195,9 +196,7 @@ defmodule Pleroma.Mixfile do
       {:majic,
        git: "https://git.pleroma.social/pleroma/elixir-libraries/majic.git",
        ref: "289cda1b6d0d70ccb2ba508a2b0bd24638db2880"},
-      {:open_api_spex,
-       git: "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git",
-       ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"},
+      {:open_api_spex, "~> 3.10"},
 
       ## dev & test
       {:ex_doc, "~> 0.22", only: :dev, runtime: false},
index 99be8182628b4beebed9b5f8297b236542e0f713..6034ce5a8282c79098e4823f58813aaf7e451594 100644 (file)
--- a/mix.lock
+++ b/mix.lock
@@ -31,6 +31,7 @@
   "earmark_parser": {:hex, :earmark_parser, "1.4.10", "6603d7a603b9c18d3d20db69921527f82ef09990885ed7525003c7fe7dc86c56", [:mix], [], "hexpm", "8e2d5370b732385db2c9b22215c3f59c84ac7dda7ed7e544d7c459496ae519c0"},
   "ecto": {:hex, :ecto, "3.4.6", "08f7afad3257d6eb8613309af31037e16c36808dfda5a3cd0cb4e9738db030e4", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6f13a9e2a62e75c2dcfc7207bfc65645ab387af8360db4c89fee8b5a4bf3f70b"},
   "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"},
+  "ecto_explain": {:hex, :ecto_explain, "0.1.2", "a9d504cbd4adc809911f796d5ef7ebb17a576a6d32286c3d464c015bd39d5541", [:mix], [], "hexpm", "1d0e7798ae30ecf4ce34e912e5354a0c1c832b7ebceba39298270b9a9f316330"},
   "ecto_sql": {:hex, :ecto_sql, "3.4.5", "30161f81b167d561a9a2df4329c10ae05ff36eca7ccc84628f2c8b9fa1e43323", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "31990c6a3579b36a3c0841d34a94c275e727de8b84f58509da5f1b2032c98ac2"},
   "eimp": {:hex, :eimp, "1.0.14", "fc297f0c7e2700457a95a60c7010a5f1dcb768a083b6d53f49cd94ab95a28f22", [:rebar3], [{:p1_utils, "1.0.18", [hex: :p1_utils, repo: "hexpm", optional: false]}], "hexpm", "501133f3112079b92d9e22da8b88bf4f0e13d4d67ae9c15c42c30bd25ceb83b6"},
   "elixir_make": {:hex, :elixir_make, "0.6.2", "7dffacd77dec4c37b39af867cedaabb0b59f6a871f89722c25b28fcd4bd70530", [:mix], [], "hexpm", "03e49eadda22526a7e5279d53321d1cced6552f344ba4e03e619063de75348d9"},
@@ -82,7 +83,7 @@
   "nimble_pool": {:hex, :nimble_pool, "0.1.0", "ffa9d5be27eee2b00b0c634eb649aa27f97b39186fec3c493716c2a33e784ec6", [:mix], [], "hexpm", "343a1eaa620ddcf3430a83f39f2af499fe2370390d4f785cd475b4df5acaf3f9"},
   "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]},
   "oban": {:hex, :oban, "2.3.4", "ec7509b9af2524d55f529cb7aee93d36131ae0bf0f37706f65d2fe707f4d9fd8", [:mix], [{:ecto_sql, ">= 3.4.3", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c70ca0434758fd1805422ea4446af5e910ddc697c0c861549c8f0eb0cfbd2fdf"},
-  "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "f296ac0924ba3cf79c7a588c4c252889df4c2edd", [ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"]},
+  "open_api_spex": {:hex, :open_api_spex, "3.10.0", "94e9521ad525b3fcf6dc77da7c45f87fdac24756d4de588cb0816b413e7c1844", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.1", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "2dbb2bde3d2b821f06936e8dfaf3284331186556291946d84eeba3750ac28765"},
   "p1_utils": {:hex, :p1_utils, "1.0.18", "3fe224de5b2e190d730a3c5da9d6e8540c96484cf4b4692921d1e28f0c32b01c", [:rebar3], [], "hexpm", "1fc8773a71a15553b179c986b22fbeead19b28fe486c332d4929700ffeb71f88"},
   "parse_trans": {:git, "https://github.com/uwiger/parse_trans.git", "76abb347c3c1d00fb0ccf9e4b43e22b3d2288484", [tag: "3.3.0"]},
   "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "1.2.1", "9cbe354b58121075bd20eb83076900a3832324b7dd171a6895fab57b6bb2752c", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "d3b40a4a4630f0b442f19eca891fcfeeee4c40871936fed2f68e1c4faa30481f"},
index cd0cd6c658b1b7af72b2165374213ef9afe1bbdb..6a6ec058e06b088e0cb7cd1e1a27eeceb08ff390 100644 (file)
@@ -3,8 +3,8 @@ 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: 2020-07-09 14:40+0000\n"
-"Last-Translator: Ben Is <srsbzns@cock.li>\n"
+"PO-Revision-Date: 2021-03-13 09:40+0000\n"
+"Last-Translator: Ben Is <spambenis@fastwebnet.it>\n"
 "Language-Team: Italian <https://translate.pleroma.social/projects/pleroma/"
 "pleroma/it/>\n"
 "Language: it\n"
@@ -45,7 +45,7 @@ msgstr "ha una voce invalida"
 
 ## From Ecto.Changeset.validate_exclusion/3
 msgid "is reserved"
-msgstr "è vietato"
+msgstr "è riservato"
 
 ## From Ecto.Changeset.validate_confirmation/3
 msgid "does not match confirmation"
@@ -123,7 +123,7 @@ msgstr "Richiesta invalida"
 #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:425
 #, elixir-format
 msgid "Can't delete object"
-msgstr "Non puoi eliminare quest'oggetto"
+msgstr "Oggetto non eliminabile"
 
 #: lib/pleroma/web/mastodon_api/controllers/status_controller.ex:196
 #, elixir-format
@@ -160,12 +160,12 @@ msgstr "Non puoi pubblicare un messaggio vuoto senza allegati"
 #: lib/pleroma/web/common_api/utils.ex:504
 #, elixir-format
 msgid "Comment must be up to %{max_size} characters"
-msgstr "I commenti posso al massimo consistere di %{max_size} caratteri"
+msgstr "I commenti posso al massimo contenere %{max_size} caratteri"
 
 #: lib/pleroma/config/config_db.ex:222
 #, elixir-format
 msgid "Config with params %{params} not found"
-msgstr "Configurazione con parametri %{max_size} non trovata"
+msgstr "Configurazione con parametri %{params} non trovata"
 
 #: lib/pleroma/web/common_api/common_api.ex:95
 #, elixir-format
@@ -200,7 +200,7 @@ msgstr "Non de-intestato"
 #: lib/pleroma/web/common_api/common_api.ex:126
 #, elixir-format
 msgid "Could not unrepeat"
-msgstr "Non de-ripetuto"
+msgstr "Non de-condiviso"
 
 #: lib/pleroma/web/common_api/common_api.ex:428
 #: lib/pleroma/web/common_api/common_api.ex:437
@@ -310,12 +310,12 @@ msgstr "Il messaggio ha superato la lunghezza massima"
 #: lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex:31
 #, elixir-format
 msgid "This resource requires authentication."
-msgstr "Accedi per leggere."
+msgstr "Accedi per poter leggere."
 
 #: lib/pleroma/plugs/rate_limiter/rate_limiter.ex:206
 #, elixir-format
 msgid "Throttled"
-msgstr "Strozzato"
+msgstr "Limitato"
 
 #: lib/pleroma/web/common_api/common_api.ex:266
 #, elixir-format
@@ -347,17 +347,17 @@ msgstr "Devi aggiungere un indirizzo email valido"
 #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:389
 #, elixir-format
 msgid "can't read inbox of %{nickname} as %{as_nickname}"
-msgstr "non puoi leggere i messaggi privati di %{nickname} come %{as_nickname}"
+msgstr "non puoi leggere i messaggi di %{nickname} come %{as_nickname}"
 
 #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:472
 #, elixir-format
 msgid "can't update outbox of %{nickname} as %{as_nickname}"
-msgstr "non puoi aggiornare gli inviati di %{nickname} come %{as_nickname}"
+msgstr "non puoi inviare da %{nickname} come %{as_nickname}"
 
 #: lib/pleroma/web/common_api/common_api.ex:388
 #, elixir-format
 msgid "conversation is already muted"
-msgstr "la conversazione è già zittita"
+msgstr "la conversazione è già silenziata"
 
 #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:316
 #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:491
@@ -419,7 +419,7 @@ msgstr "Errore interno"
 #: lib/pleroma/web/oauth/fallback_controller.ex:29
 #, elixir-format
 msgid "Invalid Username/Password"
-msgstr "Nome utente/parola d'ordine invalidi"
+msgstr "Nome utente/password invalidi"
 
 #: lib/pleroma/web/twitter_api/twitter_api.ex:118
 #, elixir-format
@@ -455,7 +455,7 @@ msgstr "Gestore OAuth non supportato: %{provider}."
 #: lib/pleroma/uploaders/uploader.ex:72
 #, elixir-format
 msgid "Uploader callback timeout"
-msgstr "Callback caricatmento scaduta"
+msgstr "Callback caricamento scaduta"
 
 #: lib/pleroma/web/uploader_controller.ex:23
 #, elixir-format
@@ -496,7 +496,7 @@ msgstr "Parametro mancante: %{name}"
 #: lib/pleroma/web/oauth/oauth_controller.ex:322
 #, elixir-format
 msgid "Password reset is required"
-msgstr "Necessario reimpostare parola d'ordine"
+msgstr "Necessario reimpostare password"
 
 #: 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
@@ -540,34 +540,32 @@ msgstr ""
 #: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:210
 #, elixir-format
 msgid "Unexpected error occurred while adding file to pack."
-msgstr "Errore inaspettato durante l'aggiunta del file al pacchetto."
+msgstr "Errore inatteso durante l'aggiunta del file al pacchetto."
 
 #: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:138
 #, elixir-format
 msgid "Unexpected error occurred while creating pack."
-msgstr "Errore inaspettato durante la creazione del pacchetto."
+msgstr "Errore inatteso durante la creazione del pacchetto."
 
 #: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:278
 #, elixir-format
 msgid "Unexpected error occurred while removing file from pack."
-msgstr "Errore inaspettato durante la rimozione del file dal pacchetto."
+msgstr "Errore inatteso durante la rimozione del file dal pacchetto."
 
 #: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:250
 #, elixir-format
 msgid "Unexpected error occurred while updating file in pack."
-msgstr "Errore inaspettato durante l'aggiornamento del file nel pacchetto."
+msgstr "Errore inatteso durante l'aggiornamento del file nel pacchetto."
 
 #: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:179
 #, elixir-format
 msgid "Unexpected error occurred while updating pack metadata."
-msgstr "Errore inaspettato durante l'aggiornamento dei metadati del pacchetto."
+msgstr "Errore inatteso durante l'aggiornamento dei metadati del pacchetto."
 
 #: lib/pleroma/plugs/user_is_admin_plug.ex:21
 #, elixir-format
 msgid "User is not an admin."
-msgstr ""
-"L'utente non è un amministratore."
-"OAuth."
+msgstr "L'utente non è un amministratore."
 
 #: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:61
 #, elixir-format
index 43d61670555ad019d4fe1b7659d12d23f508b79d..bfac09f9e397e1bfbc734ec636f6f8ffff244e33 100644 (file)
@@ -9,7 +9,7 @@ defmodule Pleroma.Repo.Migrations.CreateSafeJsonbSet do
     begin
       result := jsonb_set(target, path, coalesce(new_value, 'null'::jsonb), create_missing);
       if result is NULL then
-        raise 'jsonb_set tried to wipe the object, please report this incindent to Pleroma bug tracker. https://git.pleroma.social/pleroma/pleroma/issues/new';
+        raise 'jsonb_set tried to wipe the object, please report this incident to Pleroma bug tracker. https://git.pleroma.social/pleroma/pleroma/issues/new';
         return target;
       else
         return result;
diff --git a/priv/repo/migrations/20201221202251_create_hashtags.exs b/priv/repo/migrations/20201221202251_create_hashtags.exs
new file mode 100644 (file)
index 0000000..8d2e9ae
--- /dev/null
@@ -0,0 +1,13 @@
+defmodule Pleroma.Repo.Migrations.CreateHashtags do
+  use Ecto.Migration
+
+  def change do
+    create_if_not_exists table(:hashtags) do
+      add(:name, :citext, null: false)
+
+      timestamps()
+    end
+
+    create_if_not_exists(unique_index(:hashtags, [:name]))
+  end
+end
diff --git a/priv/repo/migrations/20201221202252_remove_data_from_hashtags.exs b/priv/repo/migrations/20201221202252_remove_data_from_hashtags.exs
new file mode 100644 (file)
index 0000000..0442c3b
--- /dev/null
@@ -0,0 +1,15 @@
+defmodule Pleroma.Repo.Migrations.RemoveDataFromHashtags do
+  use Ecto.Migration
+
+  def up do
+    alter table(:hashtags) do
+      remove_if_exists(:data, :map)
+    end
+  end
+
+  def down do
+    alter table(:hashtags) do
+      add_if_not_exists(:data, :map, default: %{})
+    end
+  end
+end
diff --git a/priv/repo/migrations/20201221203824_create_hashtags_objects.exs b/priv/repo/migrations/20201221203824_create_hashtags_objects.exs
new file mode 100644 (file)
index 0000000..581f32b
--- /dev/null
@@ -0,0 +1,13 @@
+defmodule Pleroma.Repo.Migrations.CreateHashtagsObjects do
+  use Ecto.Migration
+
+  def change do
+    create_if_not_exists table(:hashtags_objects, primary_key: false) do
+      add(:hashtag_id, references(:hashtags), null: false, primary_key: true)
+      add(:object_id, references(:objects), null: false, primary_key: true)
+    end
+
+    # Note: PK index: "hashtags_objects_pkey" PRIMARY KEY, btree (hashtag_id, object_id)
+    create_if_not_exists(index(:hashtags_objects, [:object_id]))
+  end
+end
diff --git a/priv/repo/migrations/20210105195018_create_data_migrations.exs b/priv/repo/migrations/20210105195018_create_data_migrations.exs
new file mode 100644 (file)
index 0000000..5f2e8d9
--- /dev/null
@@ -0,0 +1,17 @@
+defmodule Pleroma.Repo.Migrations.CreateDataMigrations do
+  use Ecto.Migration
+
+  def change do
+    create_if_not_exists table(:data_migrations) do
+      add(:name, :string, null: false)
+      add(:state, :integer, default: 1)
+      add(:feature_lock, :boolean, default: false)
+      add(:params, :map, default: %{})
+      add(:data, :map, default: %{})
+
+      timestamps()
+    end
+
+    create_if_not_exists(unique_index(:data_migrations, [:name]))
+  end
+end
diff --git a/priv/repo/migrations/20210106183301_data_migration_create_populate_hashtags_table.exs b/priv/repo/migrations/20210106183301_data_migration_create_populate_hashtags_table.exs
new file mode 100644 (file)
index 0000000..cf3cf26
--- /dev/null
@@ -0,0 +1,16 @@
+defmodule Pleroma.Repo.Migrations.DataMigrationCreatePopulateHashtagsTable do
+  use Ecto.Migration
+
+  def up do
+    dt = NaiveDateTime.utc_now()
+
+    execute(
+      "INSERT INTO data_migrations(name, inserted_at, updated_at) " <>
+        "VALUES ('populate_hashtags_table', '#{dt}', '#{dt}') ON CONFLICT DO NOTHING;"
+    )
+  end
+
+  def down do
+    execute("DELETE FROM data_migrations WHERE name = 'populate_hashtags_table';")
+  end
+end
diff --git a/priv/repo/migrations/20210111172254_create_data_migration_failed_ids.exs b/priv/repo/migrations/20210111172254_create_data_migration_failed_ids.exs
new file mode 100644 (file)
index 0000000..18afa74
--- /dev/null
@@ -0,0 +1,14 @@
+defmodule Pleroma.Repo.Migrations.CreateDataMigrationFailedIds do
+  use Ecto.Migration
+
+  def change do
+    create_if_not_exists table(:data_migration_failed_ids, primary_key: false) do
+      add(:data_migration_id, references(:data_migrations), null: false, primary_key: true)
+      add(:record_id, :bigint, null: false, primary_key: true)
+    end
+
+    create_if_not_exists(
+      unique_index(:data_migration_failed_ids, [:data_migration_id, :record_id])
+    )
+  end
+end
diff --git a/priv/repo/migrations/20210222183840_remove_hashtags_objects_duplicate_index.exs b/priv/repo/migrations/20210222183840_remove_hashtags_objects_duplicate_index.exs
new file mode 100644 (file)
index 0000000..6c4a2df
--- /dev/null
@@ -0,0 +1,11 @@
+defmodule Pleroma.Repo.Migrations.RemoveHashtagsObjectsDuplicateIndex do
+  use Ecto.Migration
+
+  @moduledoc "Removes `hashtags_objects_hashtag_id_object_id_index` index (duplicate of PK index)."
+
+  def up do
+    drop_if_exists(unique_index(:hashtags_objects, [:hashtag_id, :object_id]))
+  end
+
+  def down, do: nil
+end
diff --git a/priv/repo/migrations/20210222184616_change_hashtags_name_to_text.exs b/priv/repo/migrations/20210222184616_change_hashtags_name_to_text.exs
new file mode 100644 (file)
index 0000000..8940b6c
--- /dev/null
@@ -0,0 +1,15 @@
+defmodule Pleroma.Repo.Migrations.ChangeHashtagsNameToText do
+  use Ecto.Migration
+
+  def up do
+    alter table(:hashtags) do
+      modify(:name, :text)
+    end
+  end
+
+  def down do
+    alter table(:hashtags) do
+      modify(:name, :citext)
+    end
+  end
+end
diff --git a/test/fixtures/bridgy/actor.json b/test/fixtures/bridgy/actor.json
new file mode 100644 (file)
index 0000000..5b2d898
--- /dev/null
@@ -0,0 +1,80 @@
+{
+  "id": "https://fed.brid.gy/jk.nipponalba.scot",
+  "url": "https://fed.brid.gy/r/https://jk.nipponalba.scot",
+  "urls": [
+    {
+      "value": "https://jk.nipponalba.scot"
+    },
+    {
+      "value": "https://social.nipponalba.scot/jk"
+    },
+    {
+      "value": "https://px.nipponalba.scot/jk"
+    }
+  ],
+  "@context": "https://www.w3.org/ns/activitystreams",
+  "type": "Person",
+  "name": "J K 🇯🇵🏴󠁧󠁢󠁳󠁣󠁴󠁿",
+  "image": [
+    {
+      "url": "https://jk.nipponalba.scot/images/profile.jpg",
+      "type": "Image",
+      "name": "profile picture"
+    }
+  ],
+  "tag": [
+    {
+      "type": "Tag",
+      "name": "Craft Beer"
+    },
+    {
+      "type": "Tag",
+      "name": "Single Malt Whisky"
+    },
+    {
+      "type": "Tag",
+      "name": "Homebrewing"
+    },
+    {
+      "type": "Tag",
+      "name": "Scottish Politics"
+    },
+    {
+      "type": "Tag",
+      "name": "Scottish History"
+    },
+    {
+      "type": "Tag",
+      "name": "Japanese History"
+    },
+    {
+      "type": "Tag",
+      "name": "Tech"
+    },
+    {
+      "type": "Tag",
+      "name": "Veganism"
+    },
+    {
+      "type": "Tag",
+      "name": "Cooking"
+    }
+  ],
+  "icon": [
+    {
+      "url": "https://jk.nipponalba.scot/images/profile.jpg",
+      "type": "Image",
+      "name": "profile picture"
+    }
+  ],
+  "preferredUsername": "jk.nipponalba.scot",
+  "summary": "",
+  "publicKey": {
+    "id": "jk.nipponalba.scot",
+    "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDdarxwzxnNbJ2hneWOYHkYJowk\npyigQtxlUd0VjgSQHwxU9kWqfbrHBVADyTtcqi/4dAzQd3UnCI1TPNnn4LPZY9PW\noiWd3Zl1/EfLFxO7LU9GS7fcSLQkyj5JNhSlN3I8QPudZbybrgRDVZYooDe1D+52\n5KLGqC2ajrIVOiDRTQIDAQAB\n-----END PUBLIC KEY-----"
+  },
+  "inbox": "https://fed.brid.gy/jk.nipponalba.scot/inbox",
+  "outbox": "https://fed.brid.gy/jk.nipponalba.scot/outbox",
+  "following": "https://fed.brid.gy/jk.nipponalba.scot/following",
+  "followers": "https://fed.brid.gy/jk.nipponalba.scot/followers"
+}
index 6b848e04d868f166ff8c88c34d49551bca0679e6..9c8e5d93276a5d1674ee216d8af238b024c55568 100644 (file)
@@ -11,6 +11,8 @@ defmodule Pleroma.Activity.Ir.TopicsTest do
 
   require Pleroma.Constants
 
+  import Mock
+
   describe "poll answer" do
     test "produce no topics" do
       activity = %Activity{object: %Object{data: %{"type" => "Answer"}}}
@@ -77,14 +79,13 @@ defmodule Pleroma.Activity.Ir.TopicsTest do
       refute Enum.member?(topics, "public:local:media")
     end
 
-    test "converts tags to hash tags", %{activity: %{object: %{data: data} = object} = activity} do
-      tagged_data = Map.put(data, "tag", ["foo", "bar"])
-      activity = %{activity | object: %{object | data: tagged_data}}
-
-      topics = Topics.get_activity_topics(activity)
+    test "converts tags to hash tags", %{activity: activity} do
+      with_mock(Object, [:passthrough], hashtags: fn _ -> ["foo", "bar"] end) do
+        topics = Topics.get_activity_topics(activity)
 
-      assert Enum.member?(topics, "hashtag:foo")
-      assert Enum.member?(topics, "hashtag:bar")
+        assert Enum.member?(topics, "hashtag:foo")
+        assert Enum.member?(topics, "hashtag:bar")
+      end
     end
 
     test "only converts strings to hash tags", %{
diff --git a/test/pleroma/hashtag_test.exs b/test/pleroma/hashtag_test.exs
new file mode 100644 (file)
index 0000000..0264dea
--- /dev/null
@@ -0,0 +1,17 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.HashtagTest do
+  use Pleroma.DataCase
+
+  alias Pleroma.Hashtag
+
+  describe "changeset validations" do
+    test "ensure non-blank :name" do
+      changeset = Hashtag.changeset(%Hashtag{}, %{name: ""})
+
+      assert {:name, {"can't be blank", [validation: :required]}} in changeset.errors
+    end
+  end
+end
index db7678d5d183ce9ab41079792924fccefc6513b9..8320660a50cacc3ae51acbb96ba5e9948745da31 100644 (file)
@@ -5,10 +5,13 @@
 defmodule Pleroma.ObjectTest do
   use Pleroma.DataCase
   use Oban.Testing, repo: Pleroma.Repo
+
   import ExUnit.CaptureLog
   import Pleroma.Factory
   import Tesla.Mock
+
   alias Pleroma.Activity
+  alias Pleroma.Hashtag
   alias Pleroma.Object
   alias Pleroma.Repo
   alias Pleroma.Tests.ObanHelpers
@@ -417,4 +420,28 @@ defmodule Pleroma.ObjectTest do
       assert updated_object.data["like_count"] == 1
     end
   end
+
+  describe ":hashtags association" do
+    test "Hashtag records are created with Object record and updated on its change" do
+      user = insert(:user)
+
+      {:ok, %{object: object}} =
+        CommonAPI.post(user, %{status: "some text #hashtag1 #hashtag2 ..."})
+
+      assert [%Hashtag{name: "hashtag1"}, %Hashtag{name: "hashtag2"}] =
+               Enum.sort_by(object.hashtags, & &1.name)
+
+      {:ok, object} = Object.update_data(object, %{"tag" => []})
+
+      assert [] = object.hashtags
+
+      object = Object.get_by_id(object.id) |> Repo.preload(:hashtags)
+      assert [] = object.hashtags
+
+      {:ok, object} = Object.update_data(object, %{"tag" => ["abc", "def"]})
+
+      assert [%Hashtag{name: "abc"}, %Hashtag{name: "def"}] =
+               Enum.sort_by(object.hashtags, & &1.name)
+    end
+  end
 end
index f4023856cec81971bb6a0ff10255ede627a86c31..c7fa452f7370c3adc405bad8d07b89a353e4a3c1 100644 (file)
@@ -208,37 +208,96 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
       assert user.name == "Bernie2020 group"
       assert user.actor_type == "Group"
     end
+
+    test "works for bridgy actors" do
+      user_id = "https://fed.brid.gy/jk.nipponalba.scot"
+
+      Tesla.Mock.mock(fn
+        %{method: :get, url: ^user_id} ->
+          %Tesla.Env{
+            status: 200,
+            body: File.read!("test/fixtures/bridgy/actor.json"),
+            headers: [{"content-type", "application/activity+json"}]
+          }
+      end)
+
+      {:ok, user} = ActivityPub.make_user_from_ap_id(user_id)
+
+      assert user.actor_type == "Person"
+
+      assert user.avatar == %{
+               "type" => "Image",
+               "url" => [%{"href" => "https://jk.nipponalba.scot/images/profile.jpg"}]
+             }
+
+      assert user.banner == %{
+               "type" => "Image",
+               "url" => [%{"href" => "https://jk.nipponalba.scot/images/profile.jpg"}]
+             }
+    end
   end
 
   test "it fetches the appropriate tag-restricted posts" do
     user = insert(:user)
 
-    {:ok, status_one} = CommonAPI.post(user, %{status: ". #test"})
+    {:ok, status_one} = CommonAPI.post(user, %{status: ". #TEST"})
     {:ok, status_two} = CommonAPI.post(user, %{status: ". #essais"})
-    {:ok, status_three} = CommonAPI.post(user, %{status: ". #test #reject"})
+    {:ok, status_three} = CommonAPI.post(user, %{status: ". #test #Reject"})
 
-    fetch_one = ActivityPub.fetch_activities([], %{type: "Create", tag: "test"})
+    {:ok, status_four} = CommonAPI.post(user, %{status: ". #Any1 #any2"})
+    {:ok, status_five} = CommonAPI.post(user, %{status: ". #Any2 #any1"})
 
-    fetch_two = ActivityPub.fetch_activities([], %{type: "Create", tag: ["test", "essais"]})
+    for hashtag_timeline_strategy <- [:enabled, :disabled] do
+      clear_config([:features, :improved_hashtag_timeline], hashtag_timeline_strategy)
 
-    fetch_three =
-      ActivityPub.fetch_activities([], %{
-        type: "Create",
-        tag: ["test", "essais"],
-        tag_reject: ["reject"]
-      })
+      fetch_one = ActivityPub.fetch_activities([], %{type: "Create", tag: "test"})
 
-    fetch_four =
-      ActivityPub.fetch_activities([], %{
-        type: "Create",
-        tag: ["test"],
-        tag_all: ["test", "reject"]
-      })
+      fetch_two = ActivityPub.fetch_activities([], %{type: "Create", tag: ["TEST", "essais"]})
+
+      fetch_three =
+        ActivityPub.fetch_activities([], %{
+          type: "Create",
+          tag: ["test", "Essais"],
+          tag_reject: ["reject"]
+        })
 
-    assert fetch_one == [status_one, status_three]
-    assert fetch_two == [status_one, status_two, status_three]
-    assert fetch_three == [status_one, status_two]
-    assert fetch_four == [status_three]
+      fetch_four =
+        ActivityPub.fetch_activities([], %{
+          type: "Create",
+          tag: ["test"],
+          tag_all: ["test", "REJECT"]
+        })
+
+      # Testing that deduplication (if needed) is done on DB (not Ecto) level; :limit is important
+      fetch_five =
+        ActivityPub.fetch_activities([], %{
+          type: "Create",
+          tag: ["ANY1", "any2"],
+          limit: 2
+        })
+
+      fetch_six =
+        ActivityPub.fetch_activities([], %{
+          type: "Create",
+          tag: ["any1", "Any2"],
+          tag_all: [],
+          tag_reject: []
+        })
+
+      # Regression test: passing empty lists as filter options shouldn't affect the results
+      assert fetch_five == fetch_six
+
+      [fetch_one, fetch_two, fetch_three, fetch_four, fetch_five] =
+        Enum.map([fetch_one, fetch_two, fetch_three, fetch_four, fetch_five], fn statuses ->
+          Enum.map(statuses, fn s -> Repo.preload(s, object: :hashtags) end)
+        end)
+
+      assert fetch_one == [status_one, status_three]
+      assert fetch_two == [status_one, status_two, status_three]
+      assert fetch_three == [status_one, status_two]
+      assert fetch_four == [status_three]
+      assert fetch_five == [status_four, status_five]
+    end
   end
 
   describe "insertion" do
diff --git a/test/pleroma/web/activity_pub/mrf/hashtag_policy_test.exs b/test/pleroma/web/activity_pub/mrf/hashtag_policy_test.exs
new file mode 100644 (file)
index 0000000..13415bb
--- /dev/null
@@ -0,0 +1,31 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.HashtagPolicyTest do
+  use Oban.Testing, repo: Pleroma.Repo
+  use Pleroma.DataCase
+
+  alias Pleroma.Web.ActivityPub.Transmogrifier
+  alias Pleroma.Web.CommonAPI
+
+  import Pleroma.Factory
+
+  test "it sets the sensitive property with relevant hashtags" do
+    user = insert(:user)
+
+    {:ok, activity} = CommonAPI.post(user, %{status: "#nsfw hey"})
+    {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
+
+    assert modified["object"]["sensitive"]
+  end
+
+  test "it doesn't sets the sensitive property with irrelevant hashtags" do
+    user = insert(:user)
+
+    {:ok, activity} = CommonAPI.post(user, %{status: "#cofe hey"})
+    {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
+
+    refute modified["object"]["sensitive"]
+  end
+end
index f48e5b39bcebb22d59939c5a089fb83445aeb6aa..5c0aff26eb3bb6f1f714613cda67f8ea4b87d337 100644 (file)
@@ -75,10 +75,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do
       local_message = build_local_message()
 
       assert SimplePolicy.filter(media_message) ==
-               {:ok,
-                media_message
-                |> put_in(["object", "tag"], ["foo", "nsfw"])
-                |> put_in(["object", "sensitive"], true)}
+               {:ok, put_in(media_message, ["object", "sensitive"], true)}
 
       assert SimplePolicy.filter(local_message) == {:ok, local_message}
     end
@@ -89,10 +86,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do
       local_message = build_local_message()
 
       assert SimplePolicy.filter(media_message) ==
-               {:ok,
-                media_message
-                |> put_in(["object", "tag"], ["foo", "nsfw"])
-                |> put_in(["object", "sensitive"], true)}
+               {:ok, put_in(media_message, ["object", "sensitive"], true)}
 
       assert SimplePolicy.filter(local_message) == {:ok, local_message}
     end
index 66e98b7ee28eaec28f965c85d183d4f2a9c6f896..faaadff798fd706d7cee3c70a863fa7c7063f4af 100644 (file)
@@ -114,7 +114,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.TagPolicyTest do
       except_message = %{
         "actor" => actor.ap_id,
         "type" => "Create",
-        "object" => %{"tag" => ["test", "nsfw"], "attachment" => ["file1"], "sensitive" => true}
+        "object" => %{"tag" => ["test"], "attachment" => ["file1"], "sensitive" => true}
       }
 
       assert TagPolicy.filter(message) == {:ok, except_message}
index 7c1eef7e0847fed73a82d138e26c89302dbd5ee5..61d308b978a20d16d7e8dd5905e2dcc81391a13a 100644 (file)
@@ -68,7 +68,12 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do
       clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.NoOpPolicy])
 
       expected = %{
-        mrf_policies: ["NoOpPolicy"],
+        mrf_policies: ["NoOpPolicy", "HashtagPolicy"],
+        mrf_hashtag: %{
+          federated_timeline_removal: [],
+          reject: [],
+          sensitive: ["nsfw"]
+        },
         exclusions: false
       }
 
@@ -79,8 +84,13 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do
       clear_config([:mrf, :policies], [MRFModuleMock])
 
       expected = %{
-        mrf_policies: ["MRFModuleMock"],
+        mrf_policies: ["MRFModuleMock", "HashtagPolicy"],
         mrf_module_mock: "some config data",
+        mrf_hashtag: %{
+          federated_timeline_removal: [],
+          reject: [],
+          sensitive: ["nsfw"]
+        },
         exclusions: false
       }
 
index 31586abc90ec6cfd408cf07678176a33250ebba0..deb956410f3fa5544e02d1080303908eecc9d5cb 100644 (file)
@@ -39,7 +39,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.NoteHandlingTest do
       {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
       object = Object.normalize(data["object"], fetch: false)
 
-      assert "test" in object.data["tag"]
+      assert "test" in Object.tags(object)
+      assert Object.hashtags(object) == ["test"]
     end
 
     test "it cleans up incoming notices which are not really DMs" do
@@ -220,7 +221,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.NoteHandlingTest do
       {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
       object = Object.normalize(data["object"], fetch: false)
 
-      assert Enum.at(object.data["tag"], 2) == "moo"
+      assert Enum.at(Object.tags(object), 2) == "moo"
+      assert Object.hashtags(object) == ["moo"]
     end
 
     test "it works for incoming notices with contentMap" do
index 211e535a52f8dc020d644c23c663a1ca8373d48d..4c3fcb44a295e99f801d8b666600ee411464e2a8 100644 (file)
@@ -153,15 +153,6 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
       end
     end
 
-    test "it adds the sensitive property" do
-      user = insert(:user)
-
-      {:ok, activity} = CommonAPI.post(user, %{status: "#nsfw hey"})
-      {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
-
-      assert modified["object"]["sensitive"]
-    end
-
     test "it adds the json-ld context and the conversation property" do
       user = insert(:user)
 
index beb8a5d58c454b02af963fd9ea4657afe7557b47..31319b5e5480bdf097aded9a50a692969d436462 100644 (file)
@@ -44,7 +44,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
 
     conn = get(build_conn(), "/api/pleroma/admin/users/#{user.nickname}?admin_token=password123")
 
-    assert json_response(conn, 200)
+    assert json_response_and_validate_schema(conn, 200)
   end
 
   test "GET /api/pleroma/admin/users/:nickname requires admin:read:accounts or broader scope",
@@ -67,7 +67,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
         |> assign(:token, good_token)
         |> get(url)
 
-      assert json_response(conn, 200)
+      assert json_response_and_validate_schema(conn, 200)
     end
 
     for good_token <- [good_token1, good_token2, good_token3] do
@@ -87,7 +87,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
         |> assign(:token, bad_token)
         |> get(url)
 
-      assert json_response(conn, :forbidden)
+      assert json_response_and_validate_schema(conn, :forbidden)
     end
   end
 
@@ -131,7 +131,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
         assert ModerationLog.get_log_entry_message(log_entry) ==
                  "@#{admin.nickname} deleted users: @#{user.nickname}"
 
-        assert json_response(conn, 200) == [user.nickname]
+        assert json_response_and_validate_schema(conn, 200) == [user.nickname]
 
         user = Repo.get(User, user.id)
         refute user.is_active
@@ -152,28 +152,30 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
       user_one = insert(:user)
       user_two = insert(:user)
 
-      conn =
+      response =
         conn
         |> put_req_header("accept", "application/json")
+        |> put_req_header("content-type", "application/json")
         |> delete("/api/pleroma/admin/users", %{
           nicknames: [user_one.nickname, user_two.nickname]
         })
+        |> json_response_and_validate_schema(200)
 
       log_entry = Repo.one(ModerationLog)
 
       assert ModerationLog.get_log_entry_message(log_entry) ==
                "@#{admin.nickname} deleted users: @#{user_one.nickname}, @#{user_two.nickname}"
 
-      response = json_response(conn, 200)
       assert response -- [user_one.nickname, user_two.nickname] == []
     end
   end
 
   describe "/api/pleroma/admin/users" do
     test "Create", %{conn: conn} do
-      conn =
+      response =
         conn
         |> put_req_header("accept", "application/json")
+        |> put_req_header("content-type", "application/json")
         |> post("/api/pleroma/admin/users", %{
           "users" => [
             %{
@@ -188,8 +190,9 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
             }
           ]
         })
+        |> json_response_and_validate_schema(200)
+        |> Enum.map(&Map.get(&1, "type"))
 
-      response = json_response(conn, 200) |> Enum.map(&Map.get(&1, "type"))
       assert response == ["success", "success"]
 
       log_entry = Repo.one(ModerationLog)
@@ -203,6 +206,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
       conn =
         conn
         |> put_req_header("accept", "application/json")
+        |> put_req_header("content-type", "application/json")
         |> post("/api/pleroma/admin/users", %{
           "users" => [
             %{
@@ -213,7 +217,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
           ]
         })
 
-      assert json_response(conn, 409) == [
+      assert json_response_and_validate_schema(conn, 409) == [
                %{
                  "code" => 409,
                  "data" => %{
@@ -232,6 +236,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
       conn =
         conn
         |> put_req_header("accept", "application/json")
+        |> put_req_header("content-type", "application/json")
         |> post("/api/pleroma/admin/users", %{
           "users" => [
             %{
@@ -242,7 +247,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
           ]
         })
 
-      assert json_response(conn, 409) == [
+      assert json_response_and_validate_schema(conn, 409) == [
                %{
                  "code" => 409,
                  "data" => %{
@@ -261,6 +266,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
       conn =
         conn
         |> put_req_header("accept", "application/json")
+        |> put_req_header("content-type", "application/json")
         |> post("/api/pleroma/admin/users", %{
           "users" => [
             %{
@@ -276,7 +282,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
           ]
         })
 
-      assert json_response(conn, 409) == [
+      assert json_response_and_validate_schema(conn, 409) == [
                %{
                  "code" => 409,
                  "data" => %{
@@ -307,7 +313,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
 
       conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}")
 
-      assert user_response(user) == json_response(conn, 200)
+      assert user_response(user) == json_response_and_validate_schema(conn, 200)
     end
 
     test "when the user doesn't exist", %{conn: conn} do
@@ -315,7 +321,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
 
       conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}")
 
-      assert %{"error" => "Not found"} == json_response(conn, 404)
+      assert %{"error" => "Not found"} == json_response_and_validate_schema(conn, 404)
     end
   end
 
@@ -326,6 +332,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
 
       conn
       |> put_req_header("accept", "application/json")
+      |> put_req_header("content-type", "application/json")
       |> post("/api/pleroma/admin/users/follow", %{
         "follower" => follower.nickname,
         "followed" => user.nickname
@@ -352,6 +359,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
 
       conn
       |> put_req_header("accept", "application/json")
+      |> put_req_header("content-type", "application/json")
       |> post("/api/pleroma/admin/users/unfollow", %{
         "follower" => follower.nickname,
         "followed" => user.nickname
@@ -395,7 +403,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
         ]
         |> Enum.sort_by(& &1["nickname"])
 
-      assert json_response(conn, 200) == %{
+      assert json_response_and_validate_schema(conn, 200) == %{
                "count" => 3,
                "page_size" => 50,
                "users" => users
@@ -410,7 +418,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
       assert %{"count" => 26, "page_size" => 10, "users" => users1} =
                conn
                |> get("/api/pleroma/admin/users?page=1&filters=", %{page_size: "10"})
-               |> json_response(200)
+               |> json_response_and_validate_schema(200)
 
       assert Enum.count(users1) == 10
       assert service1 not in users1
@@ -418,7 +426,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
       assert %{"count" => 26, "page_size" => 10, "users" => users2} =
                conn
                |> get("/api/pleroma/admin/users?page=2&filters=", %{page_size: "10"})
-               |> json_response(200)
+               |> json_response_and_validate_schema(200)
 
       assert Enum.count(users2) == 10
       assert service1 not in users2
@@ -426,7 +434,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
       assert %{"count" => 26, "page_size" => 10, "users" => users3} =
                conn
                |> get("/api/pleroma/admin/users?page=3&filters=", %{page_size: "10"})
-               |> json_response(200)
+               |> json_response_and_validate_schema(200)
 
       assert Enum.count(users3) == 6
       assert service1 not in users3
@@ -437,7 +445,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
 
       conn = get(conn, "/api/pleroma/admin/users?page=2")
 
-      assert json_response(conn, 200) == %{
+      assert json_response_and_validate_schema(conn, 200) == %{
                "count" => 2,
                "page_size" => 50,
                "users" => []
@@ -449,7 +457,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
 
       conn = get(conn, "/api/pleroma/admin/users?query=bo")
 
-      assert json_response(conn, 200) == %{
+      assert json_response_and_validate_schema(conn, 200) == %{
                "count" => 1,
                "page_size" => 50,
                "users" => [user_response(user, %{"local" => true})]
@@ -462,7 +470,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
 
       conn = get(conn, "/api/pleroma/admin/users?query=domain.com")
 
-      assert json_response(conn, 200) == %{
+      assert json_response_and_validate_schema(conn, 200) == %{
                "count" => 1,
                "page_size" => 50,
                "users" => [user_response(user)]
@@ -475,7 +483,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
 
       conn = get(conn, "/api/pleroma/admin/users?query=nickname@domain.com")
 
-      assert json_response(conn, 200) == %{
+      assert json_response_and_validate_schema(conn, 200) == %{
                "count" => 1,
                "page_size" => 50,
                "users" => [user_response(user)]
@@ -488,7 +496,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
 
       conn = get(conn, "/api/pleroma/admin/users?name=display")
 
-      assert json_response(conn, 200) == %{
+      assert json_response_and_validate_schema(conn, 200) == %{
                "count" => 1,
                "page_size" => 50,
                "users" => [user_response(user)]
@@ -501,7 +509,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
 
       conn = get(conn, "/api/pleroma/admin/users?email=email@example.com")
 
-      assert json_response(conn, 200) == %{
+      assert json_response_and_validate_schema(conn, 200) == %{
                "count" => 1,
                "page_size" => 50,
                "users" => [user_response(user)]
@@ -514,7 +522,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
 
       conn1 = get(conn, "/api/pleroma/admin/users?query=a&page_size=1&page=1")
 
-      assert json_response(conn1, 200) == %{
+      assert json_response_and_validate_schema(conn1, 200) == %{
                "count" => 2,
                "page_size" => 1,
                "users" => [user_response(user)]
@@ -522,7 +530,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
 
       conn2 = get(conn, "/api/pleroma/admin/users?query=a&page_size=1&page=2")
 
-      assert json_response(conn2, 200) == %{
+      assert json_response_and_validate_schema(conn2, 200) == %{
                "count" => 2,
                "page_size" => 1,
                "users" => [user_response(user2)]
@@ -542,7 +550,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
         |> assign(:token, token)
         |> get("/api/pleroma/admin/users?query=bo&filters=local")
 
-      assert json_response(conn, 200) == %{
+      assert json_response_and_validate_schema(conn, 200) == %{
                "count" => 1,
                "page_size" => 50,
                "users" => [user_response(user)]
@@ -570,7 +578,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
         ]
         |> Enum.sort_by(& &1["nickname"])
 
-      assert json_response(conn, 200) == %{
+      assert json_response_and_validate_schema(conn, 200) == %{
                "count" => 3,
                "page_size" => 50,
                "users" => users
@@ -587,7 +595,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
       result =
         conn
         |> get("/api/pleroma/admin/users?filters=unconfirmed")
-        |> json_response(200)
+        |> json_response_and_validate_schema(200)
 
       users =
         Enum.map([old_user, sad_user], fn user ->
@@ -620,7 +628,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
         )
       ]
 
-      assert json_response(conn, 200) == %{
+      assert json_response_and_validate_schema(conn, 200) == %{
                "count" => 1,
                "page_size" => 50,
                "users" => users
@@ -647,7 +655,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
         ]
         |> Enum.sort_by(& &1["nickname"])
 
-      assert json_response(conn, 200) == %{
+      assert json_response_and_validate_schema(conn, 200) == %{
                "count" => 2,
                "page_size" => 50,
                "users" => users
@@ -661,7 +669,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
 
       conn = get(conn, "/api/pleroma/admin/users?filters=is_moderator")
 
-      assert json_response(conn, 200) == %{
+      assert json_response_and_validate_schema(conn, 200) == %{
                "count" => 1,
                "page_size" => 50,
                "users" => [
@@ -682,8 +690,8 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
 
       response =
         conn
-        |> get(user_path(conn, :list), %{actor_types: ["Person"]})
-        |> json_response(200)
+        |> get(user_path(conn, :index), %{actor_types: ["Person"]})
+        |> json_response_and_validate_schema(200)
 
       users =
         [
@@ -705,8 +713,8 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
 
       response =
         conn
-        |> get(user_path(conn, :list), %{actor_types: ["Person", "Service"]})
-        |> json_response(200)
+        |> get(user_path(conn, :index), %{actor_types: ["Person", "Service"]})
+        |> json_response_and_validate_schema(200)
 
       users =
         [
@@ -728,8 +736,8 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
 
       response =
         conn
-        |> get(user_path(conn, :list), %{actor_types: ["Service"]})
-        |> json_response(200)
+        |> get(user_path(conn, :index), %{actor_types: ["Service"]})
+        |> json_response_and_validate_schema(200)
 
       users = [user_response(user_service, %{"actor_type" => "Service"})]
 
@@ -751,7 +759,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
         ]
         |> Enum.sort_by(& &1["nickname"])
 
-      assert json_response(conn, 200) == %{
+      assert json_response_and_validate_schema(conn, 200) == %{
                "count" => 2,
                "page_size" => 50,
                "users" => users
@@ -776,7 +784,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
                  %{"id" => ^admin_id},
                  %{"id" => ^user_id}
                ]
-             } = json_response(conn, 200)
+             } = json_response_and_validate_schema(conn, 200)
     end
 
     test "it works with multiple filters" do
@@ -793,7 +801,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
         |> assign(:token, token)
         |> get("/api/pleroma/admin/users?filters=deactivated,external")
 
-      assert json_response(conn, 200) == %{
+      assert json_response_and_validate_schema(conn, 200) == %{
                "count" => 1,
                "page_size" => 50,
                "users" => [user_response(user)]
@@ -805,7 +813,7 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
 
       conn = get(conn, "/api/pleroma/admin/users")
 
-      assert json_response(conn, 200) == %{
+      assert json_response_and_validate_schema(conn, 200) == %{
                "count" => 1,
                "page_size" => 50,
                "users" => [
@@ -820,13 +828,14 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
     user_two = insert(:user, is_active: false)
 
     conn =
-      patch(
-        conn,
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> patch(
         "/api/pleroma/admin/users/activate",
         %{nicknames: [user_one.nickname, user_two.nickname]}
       )
 
-    response = json_response(conn, 200)
+    response = json_response_and_validate_schema(conn, 200)
     assert Enum.map(response["users"], & &1["is_active"]) == [true, true]
 
     log_entry = Repo.one(ModerationLog)
@@ -840,13 +849,14 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
     user_two = insert(:user, is_active: true)
 
     conn =
-      patch(
-        conn,
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> patch(
         "/api/pleroma/admin/users/deactivate",
         %{nicknames: [user_one.nickname, user_two.nickname]}
       )
 
-    response = json_response(conn, 200)
+    response = json_response_and_validate_schema(conn, 200)
     assert Enum.map(response["users"], & &1["is_active"]) == [false, false]
 
     log_entry = Repo.one(ModerationLog)
@@ -860,13 +870,14 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
     user_two = insert(:user, is_approved: false)
 
     conn =
-      patch(
-        conn,
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> patch(
         "/api/pleroma/admin/users/approve",
         %{nicknames: [user_one.nickname, user_two.nickname]}
       )
 
-    response = json_response(conn, 200)
+    response = json_response_and_validate_schema(conn, 200)
     assert Enum.map(response["users"], & &1["is_approved"]) == [true, true]
 
     log_entry = Repo.one(ModerationLog)
@@ -878,9 +889,12 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
   test "PATCH /api/pleroma/admin/users/:nickname/toggle_activation", %{admin: admin, conn: conn} do
     user = insert(:user)
 
-    conn = patch(conn, "/api/pleroma/admin/users/#{user.nickname}/toggle_activation")
+    conn =
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> patch("/api/pleroma/admin/users/#{user.nickname}/toggle_activation")
 
-    assert json_response(conn, 200) ==
+    assert json_response_and_validate_schema(conn, 200) ==
              user_response(
                user,
                %{"is_active" => !user.is_active}
index adfe58def268babb03fbdeb771a956dc00bfaf66..9d005697cd0efc6d4dbe812d99513161dea30f9c 100644 (file)
@@ -493,7 +493,7 @@ defmodule Pleroma.Web.CommonAPITest do
 
     object = Object.normalize(activity, fetch: false)
 
-    assert object.data["tag"] == ["2hu"]
+    assert Object.tags(object) == ["2hu"]
   end
 
   test "it adds emoji in the object" do
index 2de3afc4fb15f59dfb3f2b58ba584409fd09f678..4172cc2945ecb280720f3d06f30763ba99f8190e 100644 (file)
@@ -262,8 +262,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
       mentions: [],
       tags: [
         %{
-          name: "#{object_data["tag"]}",
-          url: "http://localhost:4001/tag/#{object_data["tag"]}"
+          name: "#{hd(object_data["tag"])}",
+          url: "http://localhost:4001/tag/#{hd(object_data["tag"])}"
         }
       ],
       application: nil,
index 953aa010abccb094762261c0fe7b8a118af7e5ec..deee9859976e681b0feceb4ffc824b4cb72592a4 100644 (file)
@@ -67,13 +67,11 @@ defmodule Pleroma.Web.ConnCase do
       end
 
       defp json_response_and_validate_schema(
-             %{
-               private: %{
-                 open_api_spex: %{operation_id: op_id, operation_lookup: lookup, spec: spec}
-               }
-             } = conn,
+             %{private: %{operation_id: op_id}} = conn,
              status
            ) do
+        {spec, lookup} = OpenApiSpex.Plug.PutApiSpec.get_spec_and_operation_lookup(conn)
+
         content_type =
           conn
           |> Plug.Conn.get_resp_header("content-type")