From afa8b469ed0a71247f27efec08d6eeac24b6674f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 1 Jul 2020 21:12:59 -0500 Subject: Allow restricting public timeline by instance --- lib/pleroma/web/activity_pub/activity_pub.ex | 12 ++---------- lib/pleroma/web/api_spec/operations/timeline_operation.ex | 10 ++++++++++ .../web/mastodon_api/controllers/timeline_controller.ex | 1 + 3 files changed, 13 insertions(+), 10 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index bc7b5d95a..9ce2b04dd 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -927,16 +927,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_muted_reblogs(query, _), do: query - defp restrict_instance(query, %{instance: instance}) do - users = - from( - u in User, - select: u.ap_id, - where: fragment("? LIKE ?", u.nickname, ^"%@#{instance}") - ) - |> Repo.all() - - from(activity in query, where: activity.actor in ^users) + defp restrict_instance(query, %{instance: instance}) when is_binary(instance) do + from(activity in query, where: ilike(activity.actor, ^"%://#{instance}/%")) end defp restrict_instance(query, _), do: query diff --git a/lib/pleroma/web/api_spec/operations/timeline_operation.ex b/lib/pleroma/web/api_spec/operations/timeline_operation.ex index 8e19bace7..83cdbad69 100644 --- a/lib/pleroma/web/api_spec/operations/timeline_operation.ex +++ b/lib/pleroma/web/api_spec/operations/timeline_operation.ex @@ -59,6 +59,7 @@ defmodule Pleroma.Web.ApiSpec.TimelineOperation do security: [%{"oAuth" => ["read:statuses"]}], parameters: [ local_param(), + instance_param(), only_media_param(), with_muted_param(), exclude_visibilities_param(), @@ -158,6 +159,15 @@ defmodule Pleroma.Web.ApiSpec.TimelineOperation do ) end + defp instance_param do + Operation.parameter( + :instance, + :query, + %Schema{type: :string}, + "Show only statuses from the given domain" + ) + end + defp with_muted_param do Operation.parameter(:with_muted, :query, BooleanLike, "Includeactivities by muted users") end diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index ab7b1d6aa..7dccc0005 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -110,6 +110,7 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do |> Map.put(:blocking_user, user) |> Map.put(:muting_user, user) |> Map.put(:reply_filtering_user, user) + |> Map.put(:instance, params[:instance]) |> ActivityPub.fetch_public_activities() conn -- cgit v1.2.3 From ad9c925efb77287316f5dbac26f6a1b16662910a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 5 Aug 2020 13:08:13 -0500 Subject: Speed up instance timeline query --- lib/pleroma/web/activity_pub/activity_pub.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 9ce2b04dd..76fc9c3ee 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -928,7 +928,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_muted_reblogs(query, _), do: query defp restrict_instance(query, %{instance: instance}) when is_binary(instance) do - from(activity in query, where: ilike(activity.actor, ^"%://#{instance}/%")) + from( + activity in query, + where: fragment("split_part(actor::text, '/'::text, 3) = ?", ^instance) + ) end defp restrict_instance(query, _), do: query -- cgit v1.2.3 From 24ce9c011caf7401fb261c7df4196b2ef9ba3d90 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 5 Aug 2020 19:33:51 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- lib/pleroma/web/api_spec/operations/timeline_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/api_spec/operations/timeline_operation.ex b/lib/pleroma/web/api_spec/operations/timeline_operation.ex index 83cdbad69..95720df9f 100644 --- a/lib/pleroma/web/api_spec/operations/timeline_operation.ex +++ b/lib/pleroma/web/api_spec/operations/timeline_operation.ex @@ -169,7 +169,7 @@ defmodule Pleroma.Web.ApiSpec.TimelineOperation do end defp with_muted_param do - Operation.parameter(:with_muted, :query, BooleanLike, "Includeactivities by muted users") + Operation.parameter(:with_muted, :query, BooleanLike, "Include activities by muted users") end defp exclude_visibilities_param do -- cgit v1.2.3 From 0d5088c2b83fafd9d8da1f1b04936f831ac5ee87 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Tue, 1 Sep 2020 09:37:08 +0300 Subject: remove `unread_conversation_count` from User --- lib/pleroma/conversation.ex | 6 +--- lib/pleroma/conversation/participation.ex | 27 ++++++-------- lib/pleroma/user.ex | 42 ---------------------- lib/pleroma/web/mastodon_api/views/account_view.ex | 2 +- 4 files changed, 12 insertions(+), 65 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/conversation.ex b/lib/pleroma/conversation.ex index e76eb0087..77933f0be 100644 --- a/lib/pleroma/conversation.ex +++ b/lib/pleroma/conversation.ex @@ -43,7 +43,7 @@ defmodule Pleroma.Conversation do def maybe_create_recipientships(participation, activity) do participation = Repo.preload(participation, :recipients) - if participation.recipients |> Enum.empty?() do + if Enum.empty?(participation.recipients) do recipients = User.get_all_by_ap_id(activity.recipients) RecipientShip.create(recipients, participation) end @@ -69,10 +69,6 @@ defmodule Pleroma.Conversation do Enum.map(users, fn user -> invisible_conversation = Enum.any?(users, &User.blocks?(user, &1)) - unless invisible_conversation do - User.increment_unread_conversation_count(conversation, user) - end - opts = Keyword.put(opts, :invisible_conversation, invisible_conversation) {:ok, participation} = diff --git a/lib/pleroma/conversation/participation.ex b/lib/pleroma/conversation/participation.ex index 8bc3e85d6..4c32b273a 100644 --- a/lib/pleroma/conversation/participation.ex +++ b/lib/pleroma/conversation/participation.ex @@ -63,21 +63,10 @@ defmodule Pleroma.Conversation.Participation do end end - def mark_as_read(participation) do - __MODULE__ - |> where(id: ^participation.id) - |> update(set: [read: true]) - |> select([p], p) - |> Repo.update_all([]) - |> case do - {1, [participation]} -> - participation = Repo.preload(participation, :user) - User.set_unread_conversation_count(participation.user) - {:ok, participation} - - error -> - error - end + def mark_as_read(%__MODULE__{} = participation) do + participation + |> change(read: true) + |> Repo.update() end def mark_all_as_read(%User{local: true} = user, %User{} = target_user) do @@ -93,7 +82,6 @@ defmodule Pleroma.Conversation.Participation do |> update([p], set: [read: true]) |> Repo.update_all([]) - {:ok, user} = User.set_unread_conversation_count(user) {:ok, user, []} end @@ -108,7 +96,6 @@ defmodule Pleroma.Conversation.Participation do |> select([p], p) |> Repo.update_all([]) - {:ok, user} = User.set_unread_conversation_count(user) {:ok, user, participations} end @@ -220,6 +207,12 @@ defmodule Pleroma.Conversation.Participation do {:ok, Repo.preload(participation, :recipients, force: true)} end + @spec unread_count(User.t()) :: integer() + def unread_count(%User{id: user_id}) do + from(q in __MODULE__, where: q.user_id == ^user_id and q.read == false) + |> Repo.aggregate(:count, :id) + end + def unread_conversation_count_for_user(user) do from(p in __MODULE__, where: p.user_id == ^user.id, diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index d2ad9516f..7fc7a533e 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -129,7 +129,6 @@ defmodule Pleroma.User do field(:hide_followers, :boolean, default: false) field(:hide_follows, :boolean, default: false) field(:hide_favorites, :boolean, default: true) - field(:unread_conversation_count, :integer, default: 0) field(:pinned_activities, {:array, :string}, default: []) field(:email_notifications, :map, default: %{"digest" => false}) field(:mascot, :map, default: nil) @@ -1295,47 +1294,6 @@ defmodule Pleroma.User do |> update_and_set_cache() end - def set_unread_conversation_count(%User{local: true} = user) do - unread_query = Participation.unread_conversation_count_for_user(user) - - User - |> join(:inner, [u], p in subquery(unread_query)) - |> update([u, p], - set: [unread_conversation_count: p.count] - ) - |> where([u], u.id == ^user.id) - |> select([u], u) - |> Repo.update_all([]) - |> case do - {1, [user]} -> set_cache(user) - _ -> {:error, user} - end - end - - def set_unread_conversation_count(user), do: {:ok, user} - - def increment_unread_conversation_count(conversation, %User{local: true} = user) do - unread_query = - Participation.unread_conversation_count_for_user(user) - |> where([p], p.conversation_id == ^conversation.id) - - User - |> join(:inner, [u], p in subquery(unread_query)) - |> update([u, p], - inc: [unread_conversation_count: 1] - ) - |> where([u], u.id == ^user.id) - |> where([u, p], p.count == 0) - |> select([u], u) - |> Repo.update_all([]) - |> case do - {1, [user]} -> set_cache(user) - _ -> {:error, user} - end - end - - def increment_unread_conversation_count(_, user), do: {:ok, user} - @spec get_users_from_set([String.t()], keyword()) :: [User.t()] def get_users_from_set(ap_ids, opts \\ []) do local_only = Keyword.get(opts, :local_only, true) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 864c0417f..1bf53600c 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -386,7 +386,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do data |> Kernel.put_in( [:pleroma, :unread_conversation_count], - user.unread_conversation_count + Pleroma.Conversation.Participation.unread_count(user) ) end -- cgit v1.2.3 From c56e3d4f3bfb090d19bdbe93dac6cede7616cc4d Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Tue, 8 Sep 2020 13:26:44 +0300 Subject: Add expires_in param for account mutes --- lib/pleroma/user.ex | 45 ++++++++++++---------- .../web/api_spec/operations/account_operation.ex | 15 +++++++- .../mastodon_api/controllers/account_controller.ex | 2 +- lib/pleroma/workers/mute_expire_worker.ex | 22 +++++++++++ 4 files changed, 61 insertions(+), 23 deletions(-) create mode 100644 lib/pleroma/workers/mute_expire_worker.ex (limited to 'lib') diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 94c96de8d..040db8d80 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1356,14 +1356,34 @@ defmodule Pleroma.User do |> Repo.all() end - @spec mute(User.t(), User.t(), boolean()) :: + @spec mute(User.t(), User.t(), map()) :: {:ok, list(UserRelationship.t())} | {:error, String.t()} - def mute(%User{} = muter, %User{} = mutee, notifications? \\ true) do - add_to_mutes(muter, mutee, notifications?) + def mute(%User{} = muter, %User{} = mutee, params \\ %{}) do + notifications? = Map.get(params, :notifications, true) + expires_in = Map.get(params, :expires_in, 0) + + with {:ok, user_mute} <- UserRelationship.create_mute(muter, mutee), + {:ok, user_notification_mute} <- + (notifications? && UserRelationship.create_notification_mute(muter, mutee)) || + {:ok, nil} do + with seconds when seconds > 0 <- expires_in do + Pleroma.Workers.MuteExpireWorker.enqueue( + "unmute", + %{"muter" => muter.id, "mutee" => mutee.id}, + schedule_in: expires_in + ) + end + + {:ok, Enum.filter([user_mute, user_notification_mute], & &1)} + end end def unmute(%User{} = muter, %User{} = mutee) do - remove_from_mutes(muter, mutee) + with {:ok, user_mute} <- UserRelationship.delete_mute(muter, mutee), + {:ok, user_notification_mute} <- + UserRelationship.delete_notification_mute(muter, mutee) do + {:ok, [user_mute, user_notification_mute]} + end end def subscribe(%User{} = subscriber, %User{} = target) do @@ -2379,23 +2399,6 @@ defmodule Pleroma.User do UserRelationship.delete_block(user, blocked) end - defp add_to_mutes(%User{} = user, %User{} = muted_user, notifications?) do - with {:ok, user_mute} <- UserRelationship.create_mute(user, muted_user), - {:ok, user_notification_mute} <- - (notifications? && UserRelationship.create_notification_mute(user, muted_user)) || - {:ok, nil} do - {:ok, Enum.filter([user_mute, user_notification_mute], & &1)} - end - end - - defp remove_from_mutes(user, %User{} = muted_user) do - with {:ok, user_mute} <- UserRelationship.delete_mute(user, muted_user), - {:ok, user_notification_mute} <- - UserRelationship.delete_notification_mute(user, muted_user) do - {:ok, [user_mute, user_notification_mute]} - end - end - def set_invisible(user, invisible) do params = %{invisible: invisible} diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index aaebc9b5c..de715a077 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -262,6 +262,12 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do :query, %Schema{allOf: [BooleanLike], default: true}, "Mute notifications in addition to statuses? Defaults to `true`." + ), + Operation.parameter( + :expires_in, + :query, + %Schema{type: :integer, default: 0}, + "Expire the mute in `expires_in` seconds. Default 0 for infinity" ) ], responses: %{ @@ -718,10 +724,17 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do nullable: true, description: "Mute notifications in addition to statuses? Defaults to true.", default: true + }, + expires_in: %Schema{ + type: :integer, + nullable: true, + description: "Expire the mute in `expires_in` seconds. Default 0 for infinity", + default: 0 } }, example: %{ - "notifications" => true + "notifications" => true, + "expires_in" => 86_400 } } end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 95d8452df..ca1a79f5e 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -394,7 +394,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do @doc "POST /api/v1/accounts/:id/mute" def mute(%{assigns: %{user: muter, account: muted}, body_params: params} = conn, _params) do - with {:ok, _user_relationships} <- User.mute(muter, muted, params.notifications) do + with {:ok, _user_relationships} <- User.mute(muter, muted, params) do render(conn, "relationship.json", user: muter, target: muted) else {:error, message} -> json_response(conn, :forbidden, %{error: message}) diff --git a/lib/pleroma/workers/mute_expire_worker.ex b/lib/pleroma/workers/mute_expire_worker.ex new file mode 100644 index 000000000..b8ec939a9 --- /dev/null +++ b/lib/pleroma/workers/mute_expire_worker.ex @@ -0,0 +1,22 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.MuteExpireWorker do + use Pleroma.Workers.WorkerHelper, queue: "mute_expire" + + require Logger + + @impl Oban.Worker + def perform(%Job{args: %{"op" => "unmute", "muter" => muter_id, "mutee" => mutee_id}}) do + muter = Pleroma.User.get_by_id(muter_id) + mutee = Pleroma.User.get_by_id(mutee_id) + Pleroma.User.unmute(muter, mutee) + :ok + end + + def perform(any) do + Logger.error("Got call to perform(#{inspect(any)})") + :ok + end +end -- cgit v1.2.3 From e3f845b24363cd867ab85b7297f2d34bfa16b13f Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Tue, 8 Sep 2020 15:13:50 +0300 Subject: Add expiring mutes for activities --- lib/pleroma/user.ex | 6 +++--- .../web/api_spec/operations/status_operation.ex | 22 +++++++++++++++++++++- lib/pleroma/web/common_api/common_api.ex | 12 +++++++++++- lib/pleroma/workers/mute_expire_worker.ex | 10 +++++++--- 4 files changed, 42 insertions(+), 8 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 040db8d80..46e03553c 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1366,10 +1366,10 @@ defmodule Pleroma.User do {:ok, user_notification_mute} <- (notifications? && UserRelationship.create_notification_mute(muter, mutee)) || {:ok, nil} do - with seconds when seconds > 0 <- expires_in do + if expires_in > 0 do Pleroma.Workers.MuteExpireWorker.enqueue( - "unmute", - %{"muter" => muter.id, "mutee" => mutee.id}, + "unmute_user", + %{"muter_id" => muter.id, "mutee_id" => mutee.id}, schedule_in: expires_in ) end diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index 5bd4619d5..6589a16f3 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -223,7 +223,27 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do security: [%{"oAuth" => ["write:mutes"]}], description: "Do not receive notifications for the thread that this status is part of.", operationId: "StatusController.mute_conversation", - parameters: [id_param()], + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + expires_in: %Schema{ + type: :integer, + nullable: true, + description: "Expire the mute in `expires_in` seconds. Default 0 for infinity", + default: 0 + } + } + }), + parameters: [ + id_param(), + Operation.parameter( + :expires_in, + :query, + %Schema{type: :integer, default: 0}, + "Expire the mute in `expires_in` seconds. Default 0 for infinity" + ) + ], responses: %{ 200 => status_response(), 400 => Operation.response("Error", "application/json", ApiError) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 4ab533658..b217c4d10 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -451,9 +451,19 @@ defmodule Pleroma.Web.CommonAPI do end end - def add_mute(user, activity) do + def add_mute(user, activity, params \\ %{}) do + expires_in = Map.get(params, :expires_in, 0) + with {:ok, _} <- ThreadMute.add_mute(user.id, activity.data["context"]), _ <- Pleroma.Notification.mark_context_as_read(user, activity.data["context"]) do + if expires_in > 0 do + Pleroma.Workers.MuteExpireWorker.enqueue( + "unmute_conversation", + %{"user_id" => user.id, "activity_id" => activity.id}, + schedule_in: expires_in + ) + end + {:ok, activity} else {:error, _} -> {:error, dgettext("errors", "conversation is already muted")} diff --git a/lib/pleroma/workers/mute_expire_worker.ex b/lib/pleroma/workers/mute_expire_worker.ex index b8ec939a9..622fdbadd 100644 --- a/lib/pleroma/workers/mute_expire_worker.ex +++ b/lib/pleroma/workers/mute_expire_worker.ex @@ -8,15 +8,19 @@ defmodule Pleroma.Workers.MuteExpireWorker do require Logger @impl Oban.Worker - def perform(%Job{args: %{"op" => "unmute", "muter" => muter_id, "mutee" => mutee_id}}) do + def perform(%Job{args: %{"op" => "unmute_user", "muter_id" => muter_id, "mutee_id" => mutee_id}}) do muter = Pleroma.User.get_by_id(muter_id) mutee = Pleroma.User.get_by_id(mutee_id) Pleroma.User.unmute(muter, mutee) :ok end - def perform(any) do - Logger.error("Got call to perform(#{inspect(any)})") + def perform(%Job{ + args: %{"op" => "unmute_conversation", "user_id" => user_id, "activity_id" => activity_id} + }) do + user = Pleroma.User.get_by_id(user_id) + activity = Pleroma.Activity.get_by_id(activity_id) + Pleroma.Web.CommonAPI.remove_mute(user, activity) :ok end end -- cgit v1.2.3 From 91b9985e1c0ef1766eb1705364e1aebe69d9b9bd Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Tue, 8 Sep 2020 15:26:06 +0300 Subject: Pass expires_in param from status controller --- lib/pleroma/web/mastodon_api/controllers/status_controller.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index ecfa38489..da14c0b6c 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -285,9 +285,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do end @doc "POST /api/v1/statuses/:id/mute" - def mute_conversation(%{assigns: %{user: user}} = conn, %{id: id}) do + def mute_conversation(%{assigns: %{user: user}, body_params: params} = conn, %{id: id}) do with %Activity{} = activity <- Activity.get_by_id(id), - {:ok, activity} <- CommonAPI.add_mute(user, activity) do + {:ok, activity} <- CommonAPI.add_mute(user, activity, params) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) end end -- cgit v1.2.3 From de2499e54b33a1746e5f6a5b79f1422d31c11570 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 9 Sep 2020 09:52:07 +0300 Subject: don't run update in tests --- lib/pleroma/stats.ex | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/stats.ex b/lib/pleroma/stats.ex index e5c9c668b..48afe901e 100644 --- a/lib/pleroma/stats.ex +++ b/lib/pleroma/stats.ex @@ -23,7 +23,6 @@ defmodule Pleroma.Stats do @impl true def init(_args) do - if Pleroma.Config.get(:env) == :test, do: :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo) {:ok, nil, {:continue, :calculate_stats}} end @@ -32,11 +31,6 @@ defmodule Pleroma.Stats do GenServer.call(__MODULE__, :force_update) end - @doc "Performs collect stats" - def do_collect do - GenServer.cast(__MODULE__, :run_update) - end - @doc "Returns stats data" @spec get_stats() :: %{ domain_count: non_neg_integer(), @@ -111,7 +105,11 @@ defmodule Pleroma.Stats do @impl true def handle_continue(:calculate_stats, _) do stats = calculate_stat_data() - Process.send_after(self(), :run_update, @interval) + + unless Pleroma.Config.get(:env) == :test do + Process.send_after(self(), :run_update, @interval) + end + {:noreply, stats} end @@ -126,13 +124,6 @@ defmodule Pleroma.Stats do {:reply, state, state} end - @impl true - def handle_cast(:run_update, _state) do - new_stats = calculate_stat_data() - - {:noreply, new_stats} - end - @impl true def handle_info(:run_update, _) do new_stats = calculate_stat_data() -- cgit v1.2.3 From 527afb813af6c64337d02ddf1a2f159fe557acbc Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sun, 13 Sep 2020 12:23:45 +0300 Subject: Remove unused require --- lib/pleroma/workers/mute_expire_worker.ex | 2 -- 1 file changed, 2 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/workers/mute_expire_worker.ex b/lib/pleroma/workers/mute_expire_worker.ex index 622fdbadd..c8b44894e 100644 --- a/lib/pleroma/workers/mute_expire_worker.ex +++ b/lib/pleroma/workers/mute_expire_worker.ex @@ -5,8 +5,6 @@ defmodule Pleroma.Workers.MuteExpireWorker do use Pleroma.Workers.WorkerHelper, queue: "mute_expire" - require Logger - @impl Oban.Worker def perform(%Job{args: %{"op" => "unmute_user", "muter_id" => muter_id, "mutee_id" => mutee_id}}) do muter = Pleroma.User.get_by_id(muter_id) -- cgit v1.2.3 From 28d0986f839651df7d305da8932f7b5c48a4fbfb Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sun, 20 Sep 2020 20:51:20 +0300 Subject: Refactor mutes removing in CommonAPI and User --- lib/pleroma/user.ex | 14 ++++++++++++++ lib/pleroma/web/common_api/common_api.ex | 18 +++++++++++++++++- lib/pleroma/workers/mute_expire_worker.ex | 8 ++------ 3 files changed, 33 insertions(+), 7 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 83eb4d5ff..83e89a12c 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1385,6 +1385,20 @@ defmodule Pleroma.User do end end + def unmute(muter_id, mutee_id) do + with {:muter, %User{} = muter} <- {:muter, User.get_by_id(muter_id)}, + {:mutee, %User{} = mutee} <- {:mutee, User.get_by_id(mutee_id)} do + unmute(muter, mutee) + else + {who, result} = error -> + Logger.warn( + "User.unmute/2 failed. #{who}: #{result}, muter_id: #{muter_id}, mutee_id: #{mutee_id}" + ) + + {:error, error} + end + end + def subscribe(%User{} = subscriber, %User{} = target) do deny_follow_blocked = Config.get([:user, :deny_follow_blocked]) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index fca9246a5..aa4c6ddab 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -472,11 +472,27 @@ defmodule Pleroma.Web.CommonAPI do end end - def remove_mute(user, activity) do + def remove_mute(%User{} = user, %Activity{} = activity) do ThreadMute.remove_mute(user.id, activity.data["context"]) {:ok, activity} end + def remove_mute(user_id, activity_id) do + with {:user, %User{} = user} <- {:user, User.get_by_id(user_id)}, + {:activity, %Activity{} = activity} <- {:activity, Activity.get_by_id(activity_id)} do + remove_mute(user, activity) + else + {what, result} = error -> + Logger.warn( + "CommonAPI.remove_mute/2 failed. #{what}: #{result}, user_id: #{user_id}, activity_id: #{ + activity_id + }" + ) + + {:error, error} + end + end + def thread_muted?(%User{id: user_id}, %{data: %{"context" => context}}) when is_binary(context) do ThreadMute.exists?(user_id, context) diff --git a/lib/pleroma/workers/mute_expire_worker.ex b/lib/pleroma/workers/mute_expire_worker.ex index c8b44894e..32a12ba85 100644 --- a/lib/pleroma/workers/mute_expire_worker.ex +++ b/lib/pleroma/workers/mute_expire_worker.ex @@ -7,18 +7,14 @@ defmodule Pleroma.Workers.MuteExpireWorker do @impl Oban.Worker def perform(%Job{args: %{"op" => "unmute_user", "muter_id" => muter_id, "mutee_id" => mutee_id}}) do - muter = Pleroma.User.get_by_id(muter_id) - mutee = Pleroma.User.get_by_id(mutee_id) - Pleroma.User.unmute(muter, mutee) + Pleroma.User.unmute(muter_id, mutee_id) :ok end def perform(%Job{ args: %{"op" => "unmute_conversation", "user_id" => user_id, "activity_id" => activity_id} }) do - user = Pleroma.User.get_by_id(user_id) - activity = Pleroma.Activity.get_by_id(activity_id) - Pleroma.Web.CommonAPI.remove_mute(user, activity) + Pleroma.Web.CommonAPI.remove_mute(user_id, activity_id) :ok end end -- cgit v1.2.3 From 7efadc3cbd46369e960f31c33a2c555f718ca8c5 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 1 Oct 2020 21:34:45 +0300 Subject: No auth check in OStatusController, even on non-federating instances. --- lib/pleroma/web/ostatus/ostatus_controller.ex | 4 ---- 1 file changed, 4 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/ostatus/ostatus_controller.ex b/lib/pleroma/web/ostatus/ostatus_controller.ex index de1b0b3f0..8646d2c1c 100644 --- a/lib/pleroma/web/ostatus/ostatus_controller.ex +++ b/lib/pleroma/web/ostatus/ostatus_controller.ex @@ -16,10 +16,6 @@ defmodule Pleroma.Web.OStatus.OStatusController do alias Pleroma.Web.Metadata.PlayerView alias Pleroma.Web.Router - plug(Pleroma.Plugs.EnsureAuthenticatedPlug, - unless_func: &Pleroma.Web.FederatingPlug.federating?/1 - ) - plug( RateLimiter, [name: :ap_routes, params: ["uuid"]] when action in [:object, :activity] -- cgit v1.2.3 From 0d575735bfd280b878bdecc6d018d8cca23ad09f Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 1 Oct 2020 21:41:22 +0300 Subject: No auth check in UserController.feed_redirect/2, even on non-federating instances. --- lib/pleroma/web/feed/user_controller.ex | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex index 71eb1ea7e..09ecdedb4 100644 --- a/lib/pleroma/web/feed/user_controller.ex +++ b/lib/pleroma/web/feed/user_controller.ex @@ -23,12 +23,7 @@ defmodule Pleroma.Web.Feed.UserController do def feed_redirect(%{assigns: %{format: format}} = conn, _params) when format in ["json", "activity+json"] do - with %{halted: false} = conn <- - Pleroma.Plugs.EnsureAuthenticatedPlug.call(conn, - unless_func: &Pleroma.Web.FederatingPlug.federating?/1 - ) do - ActivityPubController.call(conn, :user) - end + ActivityPubController.call(conn, :user) end def feed_redirect(conn, %{"nickname" => nickname}) do -- cgit v1.2.3 From f6024252ae8601d41bea943bb3cae5c656416eb9 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 2 Oct 2020 22:18:02 +0300 Subject: [#3053] No auth check in StaticFEController, even on non-federating instances. Adjusted tests. --- lib/pleroma/web/feed/tag_controller.ex | 4 +- lib/pleroma/web/feed/user_controller.ex | 4 +- lib/pleroma/web/router.ex | 11 +- lib/pleroma/web/static_fe/static_fe_controller.ex | 124 +++++++++++----------- 4 files changed, 75 insertions(+), 68 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/feed/tag_controller.ex b/lib/pleroma/web/feed/tag_controller.ex index 93a8294b7..c348b32c2 100644 --- a/lib/pleroma/web/feed/tag_controller.ex +++ b/lib/pleroma/web/feed/tag_controller.ex @@ -10,14 +10,14 @@ defmodule Pleroma.Web.Feed.TagController do alias Pleroma.Web.Feed.FeedView def feed(conn, params) do - unless Pleroma.Config.restrict_unauthenticated_access?(:activities, :local) do + unless Config.restrict_unauthenticated_access?(:activities, :local) do render_feed(conn, params) else render_error(conn, :not_found, "Not found") end end - def render_feed(conn, %{"tag" => raw_tag} = params) do + defp render_feed(conn, %{"tag" => raw_tag} = params) do {format, tag} = parse_tag(raw_tag) activities = diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex index 09ecdedb4..5fbcd82d7 100644 --- a/lib/pleroma/web/feed/user_controller.ex +++ b/lib/pleroma/web/feed/user_controller.ex @@ -40,11 +40,11 @@ defmodule Pleroma.Web.Feed.UserController do end end - def render_feed(conn, %{"nickname" => nickname} = params) do + defp render_feed(conn, %{"nickname" => nickname} = params) do format = get_format(conn) format = - if format in ["rss", "atom"] do + if format in ["atom", "rss"] do format else "atom" diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 42a9db21d..e0e92549f 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -561,12 +561,17 @@ defmodule Pleroma.Web.Router do plug(Pleroma.Plugs.StaticFEPlug) end + pipeline :ostatus_no_html do + plug(:accepts, ["xml", "rss", "atom", "activity+json", "json"]) + end + pipeline :oembed do plug(:accepts, ["json", "xml"]) end scope "/", Pleroma.Web do - pipe_through([:ostatus, :http_signature]) + # Note: no authentication plugs, all endpoints below should only yield public objects + pipe_through(:ostatus) get("/objects/:uuid", OStatus.OStatusController, :object) get("/activities/:uuid", OStatus.OStatusController, :activity) @@ -579,6 +584,10 @@ defmodule Pleroma.Web.Router do get("/users/:nickname/feed", Feed.UserController, :feed, as: :user_feed) get("/users/:nickname", Feed.UserController, :feed_redirect, as: :user_feed) + end + + scope "/", Pleroma.Web do + pipe_through(:ostatus_no_html) get("/tags/:tag", Feed.TagController, :feed, as: :tag_feed) end diff --git a/lib/pleroma/web/static_fe/static_fe_controller.ex b/lib/pleroma/web/static_fe/static_fe_controller.ex index a7a891b13..b1c62f5b0 100644 --- a/lib/pleroma/web/static_fe/static_fe_controller.ex +++ b/lib/pleroma/web/static_fe/static_fe_controller.ex @@ -17,70 +17,9 @@ defmodule Pleroma.Web.StaticFE.StaticFEController do plug(:put_view, Pleroma.Web.StaticFE.StaticFEView) plug(:assign_id) - plug(Pleroma.Plugs.EnsureAuthenticatedPlug, - unless_func: &Pleroma.Web.FederatingPlug.federating?/1 - ) - @page_keys ["max_id", "min_id", "limit", "since_id", "order"] - defp get_title(%Object{data: %{"name" => name}}) when is_binary(name), - do: name - - defp get_title(%Object{data: %{"summary" => summary}}) when is_binary(summary), - do: summary - - defp get_title(_), do: nil - - defp not_found(conn, message) do - conn - |> put_status(404) - |> render("error.html", %{message: message, meta: ""}) - end - - defp get_counts(%Activity{} = activity) do - %Object{data: data} = Object.normalize(activity) - - %{ - likes: data["like_count"] || 0, - replies: data["repliesCount"] || 0, - announces: data["announcement_count"] || 0 - } - end - - defp represent(%Activity{} = activity), do: represent(activity, false) - - defp represent(%Activity{object: %Object{data: data}} = activity, selected) do - {:ok, user} = User.get_or_fetch(activity.object.data["actor"]) - - link = - case user.local do - true -> Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity) - _ -> data["url"] || data["external_url"] || data["id"] - end - - content = - if data["content"] do - data["content"] - |> Pleroma.HTML.filter_tags() - |> Pleroma.Emoji.Formatter.emojify(Map.get(data, "emoji", %{})) - else - nil - end - - %{ - user: User.sanitize_html(user), - title: get_title(activity.object), - content: content, - attachment: data["attachment"], - link: link, - published: data["published"], - sensitive: data["sensitive"], - selected: selected, - counts: get_counts(activity), - id: activity.id - } - end - + @doc "Renders requested local public activity" def show(%{assigns: %{notice_id: notice_id}} = conn, _params) do with %Activity{local: true} = activity <- Activity.get_by_id_with_object(notice_id), @@ -106,6 +45,7 @@ defmodule Pleroma.Web.StaticFE.StaticFEController do end end + @doc "Renders public activities of requested user" def show(%{assigns: %{username_or_id: username_or_id}} = conn, params) do case User.get_cached_by_nickname_or_id(username_or_id) do %User{} = user -> @@ -118,7 +58,7 @@ defmodule Pleroma.Web.StaticFE.StaticFEController do timeline = user - |> ActivityPub.fetch_user_activities(nil, params) + |> ActivityPub.fetch_user_activities(_reading_user = nil, params) |> Enum.map(&represent/1) prev_page_id = @@ -166,6 +106,64 @@ defmodule Pleroma.Web.StaticFE.StaticFEController do end end + defp get_title(%Object{data: %{"name" => name}}) when is_binary(name), + do: name + + defp get_title(%Object{data: %{"summary" => summary}}) when is_binary(summary), + do: summary + + defp get_title(_), do: nil + + defp not_found(conn, message) do + conn + |> put_status(404) + |> render("error.html", %{message: message, meta: ""}) + end + + defp get_counts(%Activity{} = activity) do + %Object{data: data} = Object.normalize(activity) + + %{ + likes: data["like_count"] || 0, + replies: data["repliesCount"] || 0, + announces: data["announcement_count"] || 0 + } + end + + defp represent(%Activity{} = activity), do: represent(activity, false) + + defp represent(%Activity{object: %Object{data: data}} = activity, selected) do + {:ok, user} = User.get_or_fetch(activity.object.data["actor"]) + + link = + case user.local do + true -> Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity) + _ -> data["url"] || data["external_url"] || data["id"] + end + + content = + if data["content"] do + data["content"] + |> Pleroma.HTML.filter_tags() + |> Pleroma.Emoji.Formatter.emojify(Map.get(data, "emoji", %{})) + else + nil + end + + %{ + user: User.sanitize_html(user), + title: get_title(activity.object), + content: content, + attachment: data["attachment"], + link: link, + published: data["published"], + sensitive: data["sensitive"], + selected: selected, + counts: get_counts(activity), + id: activity.id + } + end + defp assign_id(%{path_info: ["notice", notice_id]} = conn, _opts), do: assign(conn, :notice_id, notice_id) -- cgit v1.2.3 From f497eb034df6647fef9086a6e2ef03e61e2efc47 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 5 Oct 2020 21:11:00 +0200 Subject: activity_pub_controller.ex: Remove unused @doc block [ci skip] --- lib/pleroma/web/activity_pub/activity_pub_controller.ex | 13 ------------- 1 file changed, 13 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 732c44271..832155643 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -523,19 +523,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do {new_user, for_user} end - @doc """ - Endpoint based on - - Parameters: - - (required) `file`: data of the media - - (optionnal) `description`: description of the media, intended for accessibility - - Response: - - HTTP Code: 201 Created - - HTTP Body: ActivityPub object to be inserted into another's `attachment` field - - Note: Will not point to a URL with a `Location` header because no standalone Activity has been created. - """ def upload_media(%{assigns: %{user: %User{} = user}} = conn, %{"file" => file} = data) do with {:ok, object} <- ActivityPub.upload( -- cgit v1.2.3 From 094edde7c4ddf65f46e5d692a5ef5b859587d1e1 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Mon, 5 Oct 2020 23:48:00 +0300 Subject: [#3053] Unauthenticated access control for OStatus-related controllers and ActivityPubController (base actions: :user, :object, :activity). Tests adjustments. --- .../web/activity_pub/activity_pub_controller.ex | 56 ++++++++++++---------- lib/pleroma/web/activity_pub/visibility.ex | 39 +++++++++++---- lib/pleroma/web/feed/tag_controller.ex | 15 +++--- lib/pleroma/web/feed/user_controller.ex | 19 ++++---- lib/pleroma/web/ostatus/ostatus_controller.ex | 26 +++++----- lib/pleroma/web/router.ex | 46 +++++++++++++----- lib/pleroma/web/static_fe/static_fe_controller.ex | 56 +++++++++++----------- 7 files changed, 154 insertions(+), 103 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 732c44271..c78edfb4c 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -32,17 +32,23 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do @federating_only_actions [:internal_fetch, :relay, :relay_following, :relay_followers] + # Note: :following and :followers must be served even without authentication (as via :api) + @auth_only_actions [:read_inbox, :update_outbox, :whoami, :upload_media] + + # Always accessible actions (must perform entity accessibility checks) + @no_auth_no_federation_actions [:user, :activity, :object] + + @authenticated_or_federating_actions @federating_only_actions ++ + @auth_only_actions ++ @no_auth_no_federation_actions + plug(FederatingPlug when action in @federating_only_actions) - plug( - EnsureAuthenticatedPlug, - [unless_func: &FederatingPlug.federating?/1] when action not in @federating_only_actions - ) + plug(EnsureAuthenticatedPlug when action in @auth_only_actions) - # Note: :following and :followers must be served even without authentication (as via :api) plug( - EnsureAuthenticatedPlug - when action in [:read_inbox, :update_outbox, :whoami, :upload_media] + EnsureAuthenticatedPlug, + [unless_func: &FederatingPlug.federating?/1] + when action not in @authenticated_or_federating_actions ) plug( @@ -66,21 +72,22 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do def user(conn, %{"nickname" => nickname}) do with %User{local: true} = user <- User.get_cached_by_nickname(nickname), + {_, :visible} <- {:visibility, User.visible_for(user, _reading_user = nil)}, {:ok, user} <- User.ensure_keys_present(user) do conn |> put_resp_content_type("application/activity+json") |> put_view(UserView) |> render("user.json", %{user: user}) else - nil -> {:error, :not_found} - %{local: false} -> {:error, :not_found} + _ -> {:error, :not_found} end end def object(conn, _) do with ap_id <- Endpoint.url() <> conn.request_path, %Object{} = object <- Object.get_cached_by_ap_id(ap_id), - {_, true} <- {:public?, Visibility.is_public?(object)} do + {_, true} <- {:public?, Visibility.is_public?(object)}, + {_, false} <- {:restricted?, Visibility.restrict_unauthenticated_access?(object)} do conn |> assign(:tracking_fun_data, object.id) |> set_cache_ttl_for(object) @@ -88,25 +95,15 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do |> put_view(ObjectView) |> render("object.json", object: object) else - {:public?, false} -> - {:error, :not_found} + _ -> {:error, :not_found} end end - def track_object_fetch(conn, nil), do: conn - - def track_object_fetch(conn, object_id) do - with %{assigns: %{user: %User{id: user_id}}} <- conn do - Delivery.create(object_id, user_id) - end - - conn - end - def activity(conn, _params) do with ap_id <- Endpoint.url() <> conn.request_path, %Activity{} = activity <- Activity.normalize(ap_id), - {_, true} <- {:public?, Visibility.is_public?(activity)} do + {_, true} <- {:public?, Visibility.is_public?(activity)}, + {_, true} <- {:visible?, Visibility.visible_for_user?(activity, _reading_user = nil)} do conn |> maybe_set_tracking_data(activity) |> set_cache_ttl_for(activity) @@ -114,8 +111,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do |> put_view(ObjectView) |> render("object.json", object: activity) else - {:public?, false} -> {:error, :not_found} - nil -> {:error, :not_found} + _ -> {:error, :not_found} end end @@ -550,4 +546,14 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do |> json(object.data) end end + + def track_object_fetch(conn, nil), do: conn + + def track_object_fetch(conn, object_id) do + with %{assigns: %{user: %User{id: user_id}}} <- conn do + Delivery.create(object_id, user_id) + end + + conn + end end diff --git a/lib/pleroma/web/activity_pub/visibility.ex b/lib/pleroma/web/activity_pub/visibility.ex index 5c349bb7a..76bd54a42 100644 --- a/lib/pleroma/web/activity_pub/visibility.ex +++ b/lib/pleroma/web/activity_pub/visibility.ex @@ -44,29 +44,30 @@ defmodule Pleroma.Web.ActivityPub.Visibility do def is_list?(%{data: %{"listMessage" => _}}), do: true def is_list?(_), do: false - @spec visible_for_user?(Activity.t(), User.t() | nil) :: boolean() - def visible_for_user?(%{actor: ap_id}, %User{ap_id: ap_id}), do: true + @spec visible_for_user?(Activity.t() | nil, User.t() | nil) :: boolean() + def visible_for_user?(%Activity{actor: ap_id}, %User{ap_id: ap_id}), do: true def visible_for_user?(nil, _), do: false - def visible_for_user?(%{data: %{"listMessage" => _}}, nil), do: false + def visible_for_user?(%Activity{data: %{"listMessage" => _}}, nil), do: false - def visible_for_user?(%{data: %{"listMessage" => list_ap_id}} = activity, %User{} = user) do + def visible_for_user?( + %Activity{data: %{"listMessage" => list_ap_id}} = activity, + %User{} = user + ) do user.ap_id in activity.data["to"] || list_ap_id |> Pleroma.List.get_by_ap_id() |> Pleroma.List.member?(user) end - def visible_for_user?(%{local: local} = activity, nil) do - cfg_key = if local, do: :local, else: :remote - - if Pleroma.Config.restrict_unauthenticated_access?(:activities, cfg_key), + def visible_for_user?(%Activity{} = activity, nil) do + if restrict_unauthenticated_access?(activity), do: false, else: is_public?(activity) end - def visible_for_user?(activity, user) do + def visible_for_user?(%Activity{} = activity, user) do x = [user.ap_id | User.following(user)] y = [activity.actor] ++ activity.data["to"] ++ (activity.data["cc"] || []) is_public?(activity) || Enum.any?(x, &(&1 in y)) @@ -82,6 +83,26 @@ defmodule Pleroma.Web.ActivityPub.Visibility do result end + def restrict_unauthenticated_access?(%Activity{local: local}) do + restrict_unauthenticated_access_to_activity?(local) + end + + def restrict_unauthenticated_access?(%Object{} = object) do + object + |> Object.local?() + |> restrict_unauthenticated_access_to_activity?() + end + + def restrict_unauthenticated_access?(%User{} = user) do + User.visible_for(user, _reading_user = nil) + end + + defp restrict_unauthenticated_access_to_activity?(local?) when is_boolean(local?) do + cfg_key = if local?, do: :local, else: :remote + + Pleroma.Config.restrict_unauthenticated_access?(:activities, cfg_key) + end + def get_visibility(object) do to = object.data["to"] || [] cc = object.data["cc"] || [] diff --git a/lib/pleroma/web/feed/tag_controller.ex b/lib/pleroma/web/feed/tag_controller.ex index c348b32c2..218cdbdf3 100644 --- a/lib/pleroma/web/feed/tag_controller.ex +++ b/lib/pleroma/web/feed/tag_controller.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Web.Feed.TagController do alias Pleroma.Web.Feed.FeedView def feed(conn, params) do - unless Config.restrict_unauthenticated_access?(:activities, :local) do + if Config.get!([:instance, :public]) do render_feed(conn, params) else render_error(conn, :not_found, "Not found") @@ -36,12 +36,13 @@ defmodule Pleroma.Web.Feed.TagController do end @spec parse_tag(binary() | any()) :: {format :: String.t(), tag :: String.t()} - defp parse_tag(raw_tag) when is_binary(raw_tag) do - case Enum.reverse(String.split(raw_tag, ".")) do - [format | tag] when format in ["atom", "rss"] -> {format, Enum.join(tag, ".")} - _ -> {"rss", raw_tag} + defp parse_tag(raw_tag) do + case is_binary(raw_tag) && Enum.reverse(String.split(raw_tag, ".")) do + [format | tag] when format in ["rss", "atom"] -> + {format, Enum.join(tag, ".")} + + _ -> + {"atom", raw_tag} end end - - defp parse_tag(raw_tag), do: {"rss", raw_tag} end diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex index 5fbcd82d7..f1d2bb7be 100644 --- a/lib/pleroma/web/feed/user_controller.ex +++ b/lib/pleroma/web/feed/user_controller.ex @@ -6,6 +6,8 @@ defmodule Pleroma.Web.Feed.UserController do use Pleroma.Web, :controller alias Fallback.RedirectController + + alias Pleroma.Config alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPubController @@ -32,15 +34,7 @@ defmodule Pleroma.Web.Feed.UserController do end end - def feed(conn, params) do - unless Pleroma.Config.restrict_unauthenticated_access?(:profiles, :local) do - render_feed(conn, params) - else - errors(conn, {:error, :not_found}) - end - end - - defp render_feed(conn, %{"nickname" => nickname} = params) do + def feed(conn, %{"nickname" => nickname} = params) do format = get_format(conn) format = @@ -50,7 +44,8 @@ defmodule Pleroma.Web.Feed.UserController do "atom" end - with {_, %User{local: true} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)} do + with {_, %User{local: true} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)}, + {_, :visible} <- {:visibility, User.visible_for(user, _reading_user = nil)} do activities = %{ type: ["Create"], @@ -65,7 +60,7 @@ defmodule Pleroma.Web.Feed.UserController do |> render("user.#{format}", user: user, activities: activities, - feed_config: Pleroma.Config.get([:feed]) + feed_config: Config.get([:feed]) ) end end @@ -77,6 +72,8 @@ defmodule Pleroma.Web.Feed.UserController do def errors(conn, {:fetch_user, %User{local: false}}), do: errors(conn, {:error, :not_found}) def errors(conn, {:fetch_user, nil}), do: errors(conn, {:error, :not_found}) + def errors(conn, {:visibility, _}), do: errors(conn, {:error, :not_found}) + def errors(conn, _) do render_error(conn, :internal_server_error, "Something went wrong") end diff --git a/lib/pleroma/web/ostatus/ostatus_controller.ex b/lib/pleroma/web/ostatus/ostatus_controller.ex index 8646d2c1c..b4dc2a87f 100644 --- a/lib/pleroma/web/ostatus/ostatus_controller.ex +++ b/lib/pleroma/web/ostatus/ostatus_controller.ex @@ -33,16 +33,15 @@ defmodule Pleroma.Web.OStatus.OStatusController do ActivityPubController.call(conn, :object) end - def object(%{assigns: %{format: format}} = conn, _params) do + def object(conn, _params) do with id <- Endpoint.url() <> conn.request_path, {_, %Activity{} = activity} <- {:activity, Activity.get_create_by_object_ap_id_with_object(id)}, - {_, true} <- {:public?, Visibility.is_public?(activity)} do - case format do - _ -> redirect(conn, to: "/notice/#{activity.id}") - end + {_, true} <- {:public?, Visibility.is_public?(activity)}, + {_, true} <- {:visible?, Visibility.visible_for_user?(activity, _reading_user = nil)} do + redirect(conn, to: "/notice/#{activity.id}") else - reason when reason in [{:public?, false}, {:activity, nil}] -> + reason when reason in [{:public?, false}, {:visible?, false}, {:activity, nil}] -> {:error, :not_found} e -> @@ -55,15 +54,14 @@ defmodule Pleroma.Web.OStatus.OStatusController do ActivityPubController.call(conn, :activity) end - def activity(%{assigns: %{format: format}} = conn, _params) do + def activity(conn, _params) do with id <- Endpoint.url() <> conn.request_path, {_, %Activity{} = activity} <- {:activity, Activity.normalize(id)}, - {_, true} <- {:public?, Visibility.is_public?(activity)} do - case format do - _ -> redirect(conn, to: "/notice/#{activity.id}") - end + {_, true} <- {:public?, Visibility.is_public?(activity)}, + {_, true} <- {:visible?, Visibility.visible_for_user?(activity, _reading_user = nil)} do + redirect(conn, to: "/notice/#{activity.id}") else - reason when reason in [{:public?, false}, {:activity, nil}] -> + reason when reason in [{:public?, false}, {:visible?, false}, {:activity, nil}] -> {:error, :not_found} e -> @@ -74,6 +72,7 @@ defmodule Pleroma.Web.OStatus.OStatusController do def notice(%{assigns: %{format: format}} = conn, %{"id" => id}) do with {_, %Activity{} = activity} <- {:activity, Activity.get_by_id_with_object(id)}, {_, true} <- {:public?, Visibility.is_public?(activity)}, + {_, true} <- {:visible?, Visibility.visible_for_user?(activity, _reading_user = nil)}, %User{} = user <- User.get_cached_by_ap_id(activity.data["actor"]) do cond do format in ["json", "activity+json"] -> @@ -101,7 +100,7 @@ defmodule Pleroma.Web.OStatus.OStatusController do RedirectController.redirector(conn, nil) end else - reason when reason in [{:public?, false}, {:activity, nil}] -> + reason when reason in [{:public?, false}, {:visible?, false}, {:activity, nil}] -> conn |> put_status(404) |> RedirectController.redirector(nil, 404) @@ -115,6 +114,7 @@ defmodule Pleroma.Web.OStatus.OStatusController do def notice_player(conn, %{"id" => id}) do with %Activity{data: %{"type" => "Create"}} = activity <- Activity.get_by_id_with_object(id), true <- Visibility.is_public?(activity), + {_, true} <- {:visible?, Visibility.visible_for_user?(activity, _reading_user = nil)}, %Object{} = object <- Object.normalize(activity), %{data: %{"attachment" => [%{"url" => [url | _]} | _]}} <- object, true <- String.starts_with?(url["mediaType"], ["audio", "video"]) do diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index e0e92549f..6439a1c39 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -5,6 +5,14 @@ defmodule Pleroma.Web.Router do use Pleroma.Web, :router + pipeline :accepts_html do + plug(:accepts, ["html"]) + end + + pipeline :accepts_xml_rss_atom do + plug(:accepts, ["xml", "rss", "atom"]) + end + pipeline :browser do plug(:accepts, ["html"]) plug(:fetch_session) @@ -556,39 +564,55 @@ defmodule Pleroma.Web.Router do ) end - pipeline :ostatus do - plug(:accepts, ["html", "xml", "rss", "atom", "activity+json", "json"]) + pipeline :ostatus_html_json do + plug(:accepts, ["html", "activity+json", "json"]) plug(Pleroma.Plugs.StaticFEPlug) end - pipeline :ostatus_no_html do - plug(:accepts, ["xml", "rss", "atom", "activity+json", "json"]) + pipeline :ostatus_html_xml do + plug(:accepts, ["html", "xml", "rss", "atom"]) + plug(Pleroma.Plugs.StaticFEPlug) end - pipeline :oembed do - plug(:accepts, ["json", "xml"]) + pipeline :ostatus_html_xml_json do + plug(:accepts, ["html", "xml", "rss", "atom", "activity+json", "json"]) + plug(Pleroma.Plugs.StaticFEPlug) end scope "/", Pleroma.Web do - # Note: no authentication plugs, all endpoints below should only yield public objects - pipe_through(:ostatus) + # Note: html format is supported only if static FE is enabled + pipe_through(:ostatus_html_json) get("/objects/:uuid", OStatus.OStatusController, :object) get("/activities/:uuid", OStatus.OStatusController, :activity) get("/notice/:id", OStatus.OStatusController, :notice) - get("/notice/:id/embed_player", OStatus.OStatusController, :notice_player) # Mastodon compatibility routes get("/users/:nickname/statuses/:id", OStatus.OStatusController, :object) get("/users/:nickname/statuses/:id/activity", OStatus.OStatusController, :activity) + end - get("/users/:nickname/feed", Feed.UserController, :feed, as: :user_feed) + scope "/", Pleroma.Web do + # Note: html format is supported only if static FE is enabled + pipe_through(:ostatus_html_xml_json) + + # Note: for json format responds with user profile (not user feed) get("/users/:nickname", Feed.UserController, :feed_redirect, as: :user_feed) end scope "/", Pleroma.Web do - pipe_through(:ostatus_no_html) + # Note: html format is supported only if static FE is enabled + pipe_through(:ostatus_html_xml) + get("/users/:nickname/feed", Feed.UserController, :feed, as: :user_feed) + end + scope "/", Pleroma.Web do + pipe_through(:accepts_html) + get("/notice/:id/embed_player", OStatus.OStatusController, :notice_player) + end + + scope "/", Pleroma.Web do + pipe_through(:accepts_xml_rss_atom) get("/tags/:tag", Feed.TagController, :feed, as: :tag_feed) end diff --git a/lib/pleroma/web/static_fe/static_fe_controller.ex b/lib/pleroma/web/static_fe/static_fe_controller.ex index b1c62f5b0..76b82589f 100644 --- a/lib/pleroma/web/static_fe/static_fe_controller.ex +++ b/lib/pleroma/web/static_fe/static_fe_controller.ex @@ -24,6 +24,7 @@ defmodule Pleroma.Web.StaticFE.StaticFEController do with %Activity{local: true} = activity <- Activity.get_by_id_with_object(notice_id), true <- Visibility.is_public?(activity.object), + {_, true} <- {:visible?, Visibility.visible_for_user?(activity, _reading_user = nil)}, %User{} = user <- User.get_by_ap_id(activity.object.data["actor"]) do meta = Metadata.build_tags(%{activity_id: notice_id, object: activity.object, user: user}) @@ -47,34 +48,35 @@ defmodule Pleroma.Web.StaticFE.StaticFEController do @doc "Renders public activities of requested user" def show(%{assigns: %{username_or_id: username_or_id}} = conn, params) do - case User.get_cached_by_nickname_or_id(username_or_id) do - %User{} = user -> - meta = Metadata.build_tags(%{user: user}) - - params = - params - |> Map.take(@page_keys) - |> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end) - - timeline = - user - |> ActivityPub.fetch_user_activities(_reading_user = nil, params) - |> Enum.map(&represent/1) - - prev_page_id = - (params["min_id"] || params["max_id"]) && - List.first(timeline) && List.first(timeline).id - - next_page_id = List.last(timeline) && List.last(timeline).id - - render(conn, "profile.html", %{ - user: User.sanitize_html(user), - timeline: timeline, - prev_page_id: prev_page_id, - next_page_id: next_page_id, - meta: meta - }) + with {_, %User{local: true} = user} <- + {:fetch_user, User.get_cached_by_nickname_or_id(username_or_id)}, + {_, :visible} <- {:visibility, User.visible_for(user, _reading_user = nil)} do + meta = Metadata.build_tags(%{user: user}) + params = + params + |> Map.take(@page_keys) + |> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end) + + timeline = + user + |> ActivityPub.fetch_user_activities(_reading_user = nil, params) + |> Enum.map(&represent/1) + + prev_page_id = + (params["min_id"] || params["max_id"]) && + List.first(timeline) && List.first(timeline).id + + next_page_id = List.last(timeline) && List.last(timeline).id + + render(conn, "profile.html", %{ + user: User.sanitize_html(user), + timeline: timeline, + prev_page_id: prev_page_id, + next_page_id: next_page_id, + meta: meta + }) + else _ -> not_found(conn, "User not found.") end -- cgit v1.2.3 From 257e059e61b89752bcde9544cb5ae645b167c96b Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 19 Aug 2020 15:31:33 +0400 Subject: Add account export --- lib/pleroma/export.ex | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 lib/pleroma/export.ex (limited to 'lib') diff --git a/lib/pleroma/export.ex b/lib/pleroma/export.ex new file mode 100644 index 000000000..82a4b7ace --- /dev/null +++ b/lib/pleroma/export.ex @@ -0,0 +1,118 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Export do + alias Pleroma.Activity + alias Pleroma.Bookmark + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.ActivityPub.UserView + + import Ecto.Query + + def run(user) do + with {:ok, dir} <- create_dir(), + :ok <- actor(dir, user), + :ok <- statuses(dir, user), + :ok <- likes(dir, user), + :ok <- bookmarks(dir, user) do + IO.inspect({"DONE", dir}) + else + err -> IO.inspect({"export error", err}) + end + end + + def actor(dir, user) do + with {:ok, json} <- + UserView.render("user.json", %{user: user}) + |> Map.merge(%{"likes" => "likes.json", "bookmarks" => "bookmarks.json"}) + |> Jason.encode() do + File.write(dir <> "/actor.json", json) + end + end + + defp create_dir do + datetime = Calendar.NaiveDateTime.Format.iso8601_basic(NaiveDateTime.utc_now()) + dir = Path.join(System.tmp_dir!(), "archive-" <> datetime) + + with :ok <- File.mkdir(dir), do: {:ok, dir} + end + + defp write_header(file, name) do + IO.write( + file, + """ + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "#{name}.json", + "type": "OrderedCollection", + "orderedItems": [ + """ + ) + end + + defp write(query, dir, name, fun) do + path = dir <> "/#{name}.json" + + with {:ok, file} <- File.open(path, [:write, :utf8]), + :ok <- write_header(file, name) do + counter = :counters.new(1, []) + + query + |> Pleroma.RepoStreamer.chunk_stream(100) + |> Stream.each(fn items -> + Enum.each(items, fn i -> + with {:ok, str} <- fun.(i), + :ok <- IO.write(file, str <> ",\n") do + :counters.add(counter, 1, 1) + end + end) + end) + |> Stream.run() + + total = :counters.get(counter, 1) + + with :ok <- :file.pwrite(file, {:eof, -2}, "\n],\n \"totalItems\": #{total}}") do + File.close(file) + end + end + end + + def bookmarks(dir, %{id: user_id} = _user) do + Bookmark + |> where(user_id: ^user_id) + |> join(:inner, [b], activity in assoc(b, :activity)) + |> select([b, a], %{id: b.id, object: fragment("(?)->>'object'", a.data)}) + |> write(dir, "bookmarks", fn a -> {:ok, "\"#{a.object}\""} end) + end + + def likes(dir, user) do + user.ap_id + |> Activity.Queries.by_actor() + |> Activity.Queries.by_type("Like") + |> select([like], %{id: like.id, object: fragment("(?)->>'object'", like.data)}) + |> write(dir, "likes", fn a -> {:ok, "\"#{a.object}\""} end) + end + + def statuses(dir, user) do + opts = + %{} + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:reply_filtering_user, user) + |> Map.put(:announce_filtering_user, user) + |> Map.put(:user, user) + + [[user.ap_id], User.following(user), Pleroma.List.memberships(user)] + |> Enum.concat() + |> ActivityPub.fetch_activities_query(opts) + |> write(dir, "outbox", fn a -> + with {:ok, activity} <- Transmogrifier.prepare_outgoing(a.data) do + activity |> Map.delete("@context") |> Jason.encode() + end + end) + end +end -- cgit v1.2.3 From 9d564ffc2988f145bc9cf26477eea93b1bf01cb0 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 24 Aug 2020 20:59:57 +0400 Subject: Zip exported files --- lib/pleroma/export.ex | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/export.ex b/lib/pleroma/export.ex index 82a4b7ace..f0f1ef093 100644 --- a/lib/pleroma/export.ex +++ b/lib/pleroma/export.ex @@ -12,15 +12,17 @@ defmodule Pleroma.Export do import Ecto.Query + @files ['actor.json', 'outbox.json', 'likes.json', 'bookmarks.json'] + def run(user) do - with {:ok, dir} <- create_dir(), - :ok <- actor(dir, user), - :ok <- statuses(dir, user), - :ok <- likes(dir, user), - :ok <- bookmarks(dir, user) do - IO.inspect({"DONE", dir}) - else - err -> IO.inspect({"export error", err}) + with {:ok, path} <- create_dir(user), + :ok <- actor(path, user), + :ok <- statuses(path, user), + :ok <- likes(path, user), + :ok <- bookmarks(path, user), + {:ok, zip_path} <- :zip.create('#{path}.zip', @files, cwd: path), + {:ok, _} <- File.rm_rf(path) do + {:ok, zip_path} end end @@ -33,9 +35,9 @@ defmodule Pleroma.Export do end end - defp create_dir do + defp create_dir(user) do datetime = Calendar.NaiveDateTime.Format.iso8601_basic(NaiveDateTime.utc_now()) - dir = Path.join(System.tmp_dir!(), "archive-" <> datetime) + dir = Path.join(System.tmp_dir!(), "archive-#{user.id}-#{datetime}") with :ok <- File.mkdir(dir), do: {:ok, dir} end -- cgit v1.2.3 From c01a81804835fb92c145b90e3a264c5d4cf9c886 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 25 Aug 2020 18:51:09 +0400 Subject: Add tests --- lib/pleroma/export.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/export.ex b/lib/pleroma/export.ex index f0f1ef093..45b8ce749 100644 --- a/lib/pleroma/export.ex +++ b/lib/pleroma/export.ex @@ -26,7 +26,7 @@ defmodule Pleroma.Export do end end - def actor(dir, user) do + defp actor(dir, user) do with {:ok, json} <- UserView.render("user.json", %{user: user}) |> Map.merge(%{"likes" => "likes.json", "bookmarks" => "bookmarks.json"}) @@ -82,7 +82,7 @@ defmodule Pleroma.Export do end end - def bookmarks(dir, %{id: user_id} = _user) do + defp bookmarks(dir, %{id: user_id} = _user) do Bookmark |> where(user_id: ^user_id) |> join(:inner, [b], activity in assoc(b, :activity)) @@ -90,7 +90,7 @@ defmodule Pleroma.Export do |> write(dir, "bookmarks", fn a -> {:ok, "\"#{a.object}\""} end) end - def likes(dir, user) do + defp likes(dir, user) do user.ap_id |> Activity.Queries.by_actor() |> Activity.Queries.by_type("Like") @@ -98,7 +98,7 @@ defmodule Pleroma.Export do |> write(dir, "likes", fn a -> {:ok, "\"#{a.object}\""} end) end - def statuses(dir, user) do + defp statuses(dir, user) do opts = %{} |> Map.put(:type, ["Create", "Announce"]) -- cgit v1.2.3 From be42ab70dc9538df54ac6f30ee123666223b7287 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 31 Aug 2020 20:31:21 +0400 Subject: Add backup upload --- lib/pleroma/export.ex | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/export.ex b/lib/pleroma/export.ex index 45b8ce749..b84eccd78 100644 --- a/lib/pleroma/export.ex +++ b/lib/pleroma/export.ex @@ -22,7 +22,25 @@ defmodule Pleroma.Export do :ok <- bookmarks(path, user), {:ok, zip_path} <- :zip.create('#{path}.zip', @files, cwd: path), {:ok, _} <- File.rm_rf(path) do - {:ok, zip_path} + {:ok, :binary.list_to_bin(zip_path)} + end + end + + def upload(zip_path) do + uploader = Pleroma.Config.get([Pleroma.Upload, :uploader]) + file_name = zip_path |> String.split("/") |> List.last() + id = Ecto.UUID.generate() + + upload = %Pleroma.Upload{ + id: id, + name: file_name, + tempfile: zip_path, + content_type: "application/zip", + path: id <> "/" <> file_name + } + + with :ok <- uploader.put_file(upload), :ok <- File.rm(zip_path) do + {:ok, upload} end end -- cgit v1.2.3 From 75e07ba206b94155c5210151a49e29a11bce6e50 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 31 Aug 2020 23:07:14 +0400 Subject: Fix tests --- lib/pleroma/export.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/export.ex b/lib/pleroma/export.ex index b84eccd78..8b1bfefe2 100644 --- a/lib/pleroma/export.ex +++ b/lib/pleroma/export.ex @@ -39,7 +39,8 @@ defmodule Pleroma.Export do path: id <> "/" <> file_name } - with :ok <- uploader.put_file(upload), :ok <- File.rm(zip_path) do + with {:ok, _} <- Pleroma.Uploaders.Uploader.put_file(uploader, upload), + :ok <- File.rm(zip_path) do {:ok, upload} end end -- cgit v1.2.3 From 4f3a6337454807f4145bbc1830c3d55dd883d46d Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 2 Sep 2020 20:21:33 +0400 Subject: Add `backups` table --- lib/pleroma/backup.ex | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++ lib/pleroma/export.ex | 139 ---------------------------------- 2 files changed, 201 insertions(+), 139 deletions(-) create mode 100644 lib/pleroma/backup.ex delete mode 100644 lib/pleroma/export.ex (limited to 'lib') diff --git a/lib/pleroma/backup.ex b/lib/pleroma/backup.ex new file mode 100644 index 000000000..4580d8f92 --- /dev/null +++ b/lib/pleroma/backup.ex @@ -0,0 +1,201 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Backup do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Pleroma.Activity + alias Pleroma.Bookmark + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.ActivityPub.UserView + + schema "backups" do + field(:content_type, :string) + field(:file_name, :string) + field(:file_size, :integer, default: 0) + field(:processed, :boolean, default: false) + + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + + timestamps() + end + + def create(user) do + with :ok <- validate_limit(user), + {:ok, backup} <- user |> new() |> Repo.insert() do + {:ok, backup} + end + end + + def new(user) do + rand_str = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) + datetime = Calendar.NaiveDateTime.Format.iso8601_basic(NaiveDateTime.utc_now()) + name = "archive-#{user.nickname}-#{datetime}-#{rand_str}.zip" + + %__MODULE__{ + user_id: user.id, + content_type: "application/zip", + file_name: name + } + end + + defp validate_limit(user) do + case get_last(user.id) do + %__MODULE__{inserted_at: inserted_at} -> + days = 7 + diff = Timex.diff(NaiveDateTime.utc_now(), inserted_at, :days) + + if diff > days do + :ok + else + {:error, "Last export was less than #{days} days ago"} + end + + nil -> + :ok + end + end + + def get_last(user_id) do + __MODULE__ + |> where(user_id: ^user_id) + |> order_by(desc: :id) + |> limit(1) + |> Repo.one() + end + + def process(%__MODULE__{} = backup) do + with {:ok, zip_file} <- zip(backup), + {:ok, %{size: size}} <- File.stat(zip_file), + {:ok, _upload} <- upload(backup, zip_file) do + backup + |> cast(%{file_size: size, processed: true}, [:file_size, :processed]) + |> Repo.update() + end + end + + @files ['actor.json', 'outbox.json', 'likes.json', 'bookmarks.json'] + def zip(%__MODULE__{} = backup) do + backup = Repo.preload(backup, :user) + name = String.trim_trailing(backup.file_name, ".zip") + dir = Path.join(System.tmp_dir!(), name) + + with :ok <- File.mkdir(dir), + :ok <- actor(dir, backup.user), + :ok <- statuses(dir, backup.user), + :ok <- likes(dir, backup.user), + :ok <- bookmarks(dir, backup.user), + {:ok, zip_path} <- :zip.create(String.to_charlist(dir <> ".zip"), @files, cwd: dir), + {:ok, _} <- File.rm_rf(dir) do + {:ok, :binary.list_to_bin(zip_path)} + end + end + + def upload(%__MODULE__{} = backup, zip_path) do + uploader = Pleroma.Config.get([Pleroma.Upload, :uploader]) + + upload = %Pleroma.Upload{ + name: backup.file_name, + tempfile: zip_path, + content_type: backup.content_type, + path: "backups/" <> backup.file_name + } + + with {:ok, _} <- Pleroma.Uploaders.Uploader.put_file(uploader, upload), + :ok <- File.rm(zip_path) do + {:ok, upload} + end + end + + defp actor(dir, user) do + with {:ok, json} <- + UserView.render("user.json", %{user: user}) + |> Map.merge(%{"likes" => "likes.json", "bookmarks" => "bookmarks.json"}) + |> Jason.encode() do + File.write(dir <> "/actor.json", json) + end + end + + defp write_header(file, name) do + IO.write( + file, + """ + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "#{name}.json", + "type": "OrderedCollection", + "orderedItems": [ + """ + ) + end + + defp write(query, dir, name, fun) do + path = dir <> "/#{name}.json" + + with {:ok, file} <- File.open(path, [:write, :utf8]), + :ok <- write_header(file, name) do + counter = :counters.new(1, []) + + query + |> Pleroma.RepoStreamer.chunk_stream(100) + |> Stream.each(fn items -> + Enum.each(items, fn i -> + with {:ok, str} <- fun.(i), + :ok <- IO.write(file, str <> ",\n") do + :counters.add(counter, 1, 1) + end + end) + end) + |> Stream.run() + + total = :counters.get(counter, 1) + + with :ok <- :file.pwrite(file, {:eof, -2}, "\n],\n \"totalItems\": #{total}}") do + File.close(file) + end + end + end + + defp bookmarks(dir, %{id: user_id} = _user) do + Bookmark + |> where(user_id: ^user_id) + |> join(:inner, [b], activity in assoc(b, :activity)) + |> select([b, a], %{id: b.id, object: fragment("(?)->>'object'", a.data)}) + |> write(dir, "bookmarks", fn a -> {:ok, "\"#{a.object}\""} end) + end + + defp likes(dir, user) do + user.ap_id + |> Activity.Queries.by_actor() + |> Activity.Queries.by_type("Like") + |> select([like], %{id: like.id, object: fragment("(?)->>'object'", like.data)}) + |> write(dir, "likes", fn a -> {:ok, "\"#{a.object}\""} end) + end + + defp statuses(dir, user) do + opts = + %{} + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:reply_filtering_user, user) + |> Map.put(:announce_filtering_user, user) + |> Map.put(:user, user) + + [[user.ap_id], User.following(user), Pleroma.List.memberships(user)] + |> Enum.concat() + |> ActivityPub.fetch_activities_query(opts) + |> write(dir, "outbox", fn a -> + with {:ok, activity} <- Transmogrifier.prepare_outgoing(a.data) do + activity |> Map.delete("@context") |> Jason.encode() + end + end) + end +end diff --git a/lib/pleroma/export.ex b/lib/pleroma/export.ex deleted file mode 100644 index 8b1bfefe2..000000000 --- a/lib/pleroma/export.ex +++ /dev/null @@ -1,139 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Export do - alias Pleroma.Activity - alias Pleroma.Bookmark - alias Pleroma.User - alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.ActivityPub.Transmogrifier - alias Pleroma.Web.ActivityPub.UserView - - import Ecto.Query - - @files ['actor.json', 'outbox.json', 'likes.json', 'bookmarks.json'] - - def run(user) do - with {:ok, path} <- create_dir(user), - :ok <- actor(path, user), - :ok <- statuses(path, user), - :ok <- likes(path, user), - :ok <- bookmarks(path, user), - {:ok, zip_path} <- :zip.create('#{path}.zip', @files, cwd: path), - {:ok, _} <- File.rm_rf(path) do - {:ok, :binary.list_to_bin(zip_path)} - end - end - - def upload(zip_path) do - uploader = Pleroma.Config.get([Pleroma.Upload, :uploader]) - file_name = zip_path |> String.split("/") |> List.last() - id = Ecto.UUID.generate() - - upload = %Pleroma.Upload{ - id: id, - name: file_name, - tempfile: zip_path, - content_type: "application/zip", - path: id <> "/" <> file_name - } - - with {:ok, _} <- Pleroma.Uploaders.Uploader.put_file(uploader, upload), - :ok <- File.rm(zip_path) do - {:ok, upload} - end - end - - defp actor(dir, user) do - with {:ok, json} <- - UserView.render("user.json", %{user: user}) - |> Map.merge(%{"likes" => "likes.json", "bookmarks" => "bookmarks.json"}) - |> Jason.encode() do - File.write(dir <> "/actor.json", json) - end - end - - defp create_dir(user) do - datetime = Calendar.NaiveDateTime.Format.iso8601_basic(NaiveDateTime.utc_now()) - dir = Path.join(System.tmp_dir!(), "archive-#{user.id}-#{datetime}") - - with :ok <- File.mkdir(dir), do: {:ok, dir} - end - - defp write_header(file, name) do - IO.write( - file, - """ - { - "@context": "https://www.w3.org/ns/activitystreams", - "id": "#{name}.json", - "type": "OrderedCollection", - "orderedItems": [ - """ - ) - end - - defp write(query, dir, name, fun) do - path = dir <> "/#{name}.json" - - with {:ok, file} <- File.open(path, [:write, :utf8]), - :ok <- write_header(file, name) do - counter = :counters.new(1, []) - - query - |> Pleroma.RepoStreamer.chunk_stream(100) - |> Stream.each(fn items -> - Enum.each(items, fn i -> - with {:ok, str} <- fun.(i), - :ok <- IO.write(file, str <> ",\n") do - :counters.add(counter, 1, 1) - end - end) - end) - |> Stream.run() - - total = :counters.get(counter, 1) - - with :ok <- :file.pwrite(file, {:eof, -2}, "\n],\n \"totalItems\": #{total}}") do - File.close(file) - end - end - end - - defp bookmarks(dir, %{id: user_id} = _user) do - Bookmark - |> where(user_id: ^user_id) - |> join(:inner, [b], activity in assoc(b, :activity)) - |> select([b, a], %{id: b.id, object: fragment("(?)->>'object'", a.data)}) - |> write(dir, "bookmarks", fn a -> {:ok, "\"#{a.object}\""} end) - end - - defp likes(dir, user) do - user.ap_id - |> Activity.Queries.by_actor() - |> Activity.Queries.by_type("Like") - |> select([like], %{id: like.id, object: fragment("(?)->>'object'", like.data)}) - |> write(dir, "likes", fn a -> {:ok, "\"#{a.object}\""} end) - end - - defp statuses(dir, user) do - opts = - %{} - |> Map.put(:type, ["Create", "Announce"]) - |> Map.put(:blocking_user, user) - |> Map.put(:muting_user, user) - |> Map.put(:reply_filtering_user, user) - |> Map.put(:announce_filtering_user, user) - |> Map.put(:user, user) - - [[user.ap_id], User.following(user), Pleroma.List.memberships(user)] - |> Enum.concat() - |> ActivityPub.fetch_activities_query(opts) - |> write(dir, "outbox", fn a -> - with {:ok, activity} <- Transmogrifier.prepare_outgoing(a.data) do - activity |> Map.delete("@context") |> Jason.encode() - end - end) - end -end -- cgit v1.2.3 From a0ad9bd734e9af0ce912c32c7480a60ff87a4368 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 2 Sep 2020 21:45:22 +0400 Subject: Add BackupWorker --- lib/pleroma/backup.ex | 11 ++++++++++- lib/pleroma/workers/backup_worker.ex | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 lib/pleroma/workers/backup_worker.ex (limited to 'lib') diff --git a/lib/pleroma/backup.ex b/lib/pleroma/backup.ex index 4580d8f92..9b5d2625f 100644 --- a/lib/pleroma/backup.ex +++ b/lib/pleroma/backup.ex @@ -30,7 +30,7 @@ defmodule Pleroma.Backup do def create(user) do with :ok <- validate_limit(user), {:ok, backup} <- user |> new() |> Repo.insert() do - {:ok, backup} + Pleroma.Workers.BackupWorker.enqueue("process", %{"backup_id" => backup.id}) end end @@ -71,6 +71,15 @@ defmodule Pleroma.Backup do |> Repo.one() end + def remove_outdated(%__MODULE__{id: latest_id, user_id: user_id}) do + __MODULE__ + |> where(user_id: ^user_id) + |> where([b], b.id != ^latest_id) + |> Repo.delete_all() + end + + def get(id), do: Repo.get(__MODULE__, id) + def process(%__MODULE__{} = backup) do with {:ok, zip_file} <- zip(backup), {:ok, %{size: size}} <- File.stat(zip_file), diff --git a/lib/pleroma/workers/backup_worker.ex b/lib/pleroma/workers/backup_worker.ex new file mode 100644 index 000000000..c982ffa3a --- /dev/null +++ b/lib/pleroma/workers/backup_worker.ex @@ -0,0 +1,17 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.BackupWorker do + alias Pleroma.Backup + + use Pleroma.Workers.WorkerHelper, queue: "backup" + + @impl Oban.Worker + def perform(%Job{args: %{"op" => "process", "backup_id" => backup_id}}) do + with {:ok, %Backup{} = backup} <- + backup_id |> Backup.get() |> Backup.process() do + {:ok, backup} + end + end +end -- cgit v1.2.3 From 3ad7492f9dd1c76cdbc64ad2246f8e9c8c5c4ae6 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 4 Sep 2020 18:30:39 +0400 Subject: Add config for Pleroma.Backup --- lib/pleroma/backup.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/backup.ex b/lib/pleroma/backup.ex index 9b5d2625f..e384b6b00 100644 --- a/lib/pleroma/backup.ex +++ b/lib/pleroma/backup.ex @@ -49,7 +49,7 @@ defmodule Pleroma.Backup do defp validate_limit(user) do case get_last(user.id) do %__MODULE__{inserted_at: inserted_at} -> - days = 7 + days = Pleroma.Config.get([Pleroma.Backup, :limit_days]) diff = Timex.diff(NaiveDateTime.utc_now(), inserted_at, :days) if diff > days do -- cgit v1.2.3 From 739cb1463ba07513f047b2ac8f7e22a16c89ef4e Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 4 Sep 2020 21:48:52 +0400 Subject: Add backups deletion --- lib/pleroma/backup.ex | 14 ++++++++++++-- lib/pleroma/workers/backup_worker.ex | 37 +++++++++++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 5 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/backup.ex b/lib/pleroma/backup.ex index e384b6b00..bd50fd910 100644 --- a/lib/pleroma/backup.ex +++ b/lib/pleroma/backup.ex @@ -15,6 +15,7 @@ defmodule Pleroma.Backup do alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.UserView + alias Pleroma.Workers.BackupWorker schema "backups" do field(:content_type, :string) @@ -30,7 +31,7 @@ defmodule Pleroma.Backup do def create(user) do with :ok <- validate_limit(user), {:ok, backup} <- user |> new() |> Repo.insert() do - Pleroma.Workers.BackupWorker.enqueue("process", %{"backup_id" => backup.id}) + BackupWorker.process(backup) end end @@ -46,6 +47,14 @@ defmodule Pleroma.Backup do } end + def delete(backup) do + uploader = Pleroma.Config.get([Pleroma.Upload, :uploader]) + + with :ok <- uploader.delete_file("backups/" <> backup.file_name) do + Repo.delete(backup) + end + end + defp validate_limit(user) do case get_last(user.id) do %__MODULE__{inserted_at: inserted_at} -> @@ -75,7 +84,8 @@ defmodule Pleroma.Backup do __MODULE__ |> where(user_id: ^user_id) |> where([b], b.id != ^latest_id) - |> Repo.delete_all() + |> Repo.all() + |> Enum.each(&BackupWorker.delete/1) end def get(id), do: Repo.get(__MODULE__, id) diff --git a/lib/pleroma/workers/backup_worker.ex b/lib/pleroma/workers/backup_worker.ex index c982ffa3a..f40020794 100644 --- a/lib/pleroma/workers/backup_worker.ex +++ b/lib/pleroma/workers/backup_worker.ex @@ -3,15 +3,46 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Workers.BackupWorker do + use Oban.Worker, queue: :backup, max_attempts: 1 + + alias Oban.Job alias Pleroma.Backup - use Pleroma.Workers.WorkerHelper, queue: "backup" + def process(backup) do + %{"op" => "process", "backup_id" => backup.id} + |> new() + |> Oban.insert() + end + + def schedule_deletion(backup) do + days = Pleroma.Config.get([Pleroma.Backup, :purge_after_days]) + time = 60 * 60 * 24 * days + scheduled_at = Calendar.NaiveDateTime.add!(backup.inserted_at, time) + + %{"op" => "delete", "backup_id" => backup.id} + |> new(scheduled_at: scheduled_at) + |> Oban.insert() + end + + def delete(backup) do + %{"op" => "delete", "backup_id" => backup.id} + |> new() + |> Oban.insert() + end - @impl Oban.Worker def perform(%Job{args: %{"op" => "process", "backup_id" => backup_id}}) do with {:ok, %Backup{} = backup} <- - backup_id |> Backup.get() |> Backup.process() do + backup_id |> Backup.get() |> Backup.process(), + {:ok, _job} <- schedule_deletion(backup), + :ok <- Backup.remove_outdated(backup) do {:ok, backup} end end + + def perform(%Job{args: %{"op" => "delete", "backup_id" => backup_id}}) do + case Backup.get(backup_id) do + %Backup{} = backup -> Backup.delete(backup) + nil -> :ok + end + end end -- cgit v1.2.3 From 2c73bfe1227065fa203b0b78c9eb12cf86ab3948 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 9 Sep 2020 01:04:00 +0400 Subject: Add API endpoints for Backups --- lib/pleroma/backup.ex | 7 ++ .../operations/pleroma_backup_operation.ex | 79 ++++++++++++++++++++++ .../pleroma_api/controllers/backup_controller.ex | 27 ++++++++ lib/pleroma/web/pleroma_api/views/backup_view.ex | 24 +++++++ lib/pleroma/web/router.ex | 3 + 5 files changed, 140 insertions(+) create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/backup_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/views/backup_view.ex (limited to 'lib') diff --git a/lib/pleroma/backup.ex b/lib/pleroma/backup.ex index bd50fd910..348e537a8 100644 --- a/lib/pleroma/backup.ex +++ b/lib/pleroma/backup.ex @@ -80,6 +80,13 @@ defmodule Pleroma.Backup do |> Repo.one() end + def list(%User{id: user_id}) do + __MODULE__ + |> where(user_id: ^user_id) + |> order_by(desc: :id) + |> Repo.all() + end + def remove_outdated(%__MODULE__{id: latest_id, user_id: user_id}) do __MODULE__ |> where(user_id: ^user_id) diff --git a/lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex new file mode 100644 index 000000000..f877ca31b --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex @@ -0,0 +1,79 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaBackupOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Backups"], + summary: "List backups", + security: [%{"oAuth" => ["read:account"]}], + operationId: "PleromaAPI.BackupController.index", + responses: %{ + 200 => + Operation.response( + "An array of backups", + "application/json", + %Schema{ + type: :array, + items: backup() + } + ), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def create_operation do + %Operation{ + tags: ["Backups"], + summary: "Create a backup", + security: [%{"oAuth" => ["read:account"]}], + operationId: "PleromaAPI.BackupController.create", + responses: %{ + 200 => + Operation.response( + "An array of backups", + "application/json", + %Schema{ + type: :array, + items: backup() + } + ), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + defp backup do + %Schema{ + title: "Backup", + description: "Response schema for a backup", + type: :object, + properties: %{ + inserted_at: %Schema{type: :string, format: :"date-time"}, + content_type: %Schema{type: :string}, + file_name: %Schema{type: :string}, + file_size: %Schema{type: :integer}, + processed: %Schema{type: :boolean} + }, + example: %{ + "content_type" => "application/zip", + "file_name" => + "archive-cofe-20200908T195819-1lWrJyJqpsj8-KuHFr7N03lfsYYa5nf2NL-7A9-ddFU.zip", + "file_size" => 1024, + "inserted_at" => "2020-09-08T19:58:20", + "processed" => true + } + } + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex b/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex new file mode 100644 index 000000000..e52c77ff2 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex @@ -0,0 +1,27 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.BackupController do + use Pleroma.Web, :controller + + alias Pleroma.Plugs.OAuthScopesPlug + + 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) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaBackupOperation + + def index(%{assigns: %{user: user}} = conn, _params) do + backups = Pleroma.Backup.list(user) + render(conn, "index.json", backups: backups) + end + + def create(%{assigns: %{user: user}} = conn, _params) do + with {:ok, _} <- Pleroma.Backup.create(user) do + backups = Pleroma.Backup.list(user) + render(conn, "index.json", backups: backups) + end + end +end diff --git a/lib/pleroma/web/pleroma_api/views/backup_view.ex b/lib/pleroma/web/pleroma_api/views/backup_view.ex new file mode 100644 index 000000000..02b94ce4f --- /dev/null +++ b/lib/pleroma/web/pleroma_api/views/backup_view.ex @@ -0,0 +1,24 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.BackupView do + use Pleroma.Web, :view + + alias Pleroma.Backup + alias Pleroma.Web.CommonAPI.Utils + + def render("show.json", %{backup: %Backup{} = backup}) do + %{ + content_type: backup.content_type, + file_name: backup.file_name, + file_size: backup.file_size, + processed: backup.processed, + inserted_at: Utils.to_masto_date(backup.inserted_at) + } + end + + def render("index.json", %{backups: backups}) do + render_many(backups, __MODULE__, "show.json") + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index e22b31b4c..a1a5a1cb5 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -293,6 +293,9 @@ defmodule Pleroma.Web.Router do get("/accounts/mfa/setup/:method", TwoFactorAuthenticationController, :setup) post("/accounts/mfa/confirm/:method", TwoFactorAuthenticationController, :confirm) delete("/accounts/mfa/:method", TwoFactorAuthenticationController, :disable) + + get("/backups", BackupController, :index) + post("/backups", BackupController, :create) end scope "/oauth", Pleroma.Web.OAuth do -- cgit v1.2.3 From 86ce4afd9338d81f741fa57f962509a6f0f50aff Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 9 Sep 2020 20:02:20 +0400 Subject: Improve backup urls --- lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex | 6 +++--- lib/pleroma/web/pleroma_api/views/backup_view.ex | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex index f877ca31b..6993794db 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex @@ -69,9 +69,9 @@ defmodule Pleroma.Web.ApiSpec.PleromaBackupOperation do example: %{ "content_type" => "application/zip", "file_name" => - "archive-cofe-20200908T195819-1lWrJyJqpsj8-KuHFr7N03lfsYYa5nf2NL-7A9-ddFU.zip", - "file_size" => 1024, - "inserted_at" => "2020-09-08T19:58:20", + "https://cofe.fe:4000/media/backups/archive-foobar-20200908T164207-Yr7vuT5Wycv-sN3kSN2iJ0k-9pMo60j9qmvRCdDqIew.zip", + "file_size" => 4105, + "inserted_at" => "2020-09-08T16:42:07.000Z", "processed" => true } } diff --git a/lib/pleroma/web/pleroma_api/views/backup_view.ex b/lib/pleroma/web/pleroma_api/views/backup_view.ex index 02b94ce4f..bf40a001e 100644 --- a/lib/pleroma/web/pleroma_api/views/backup_view.ex +++ b/lib/pleroma/web/pleroma_api/views/backup_view.ex @@ -11,7 +11,7 @@ defmodule Pleroma.Web.PleromaAPI.BackupView do def render("show.json", %{backup: %Backup{} = backup}) do %{ content_type: backup.content_type, - file_name: backup.file_name, + url: download_url(backup), file_size: backup.file_size, processed: backup.processed, inserted_at: Utils.to_masto_date(backup.inserted_at) @@ -21,4 +21,8 @@ defmodule Pleroma.Web.PleromaAPI.BackupView do def render("index.json", %{backups: backups}) do render_many(backups, __MODULE__, "show.json") end + + def download_url(%Backup{file_name: file_name}) do + Pleroma.Web.Endpoint.url() <> "/media/backups/" <> file_name + end end -- cgit v1.2.3 From cd13613db3f675b6a9171dea56fc5b03e43ae6b0 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 10 Sep 2020 20:53:06 +0400 Subject: Fix query --- lib/pleroma/backup.ex | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/backup.ex b/lib/pleroma/backup.ex index 348e537a8..ce54a413a 100644 --- a/lib/pleroma/backup.ex +++ b/lib/pleroma/backup.ex @@ -8,6 +8,8 @@ defmodule Pleroma.Backup do import Ecto.Changeset import Ecto.Query + require Pleroma.Constants + alias Pleroma.Activity alias Pleroma.Bookmark alias Pleroma.Repo @@ -158,6 +160,7 @@ defmodule Pleroma.Backup do "id": "#{name}.json", "type": "OrderedCollection", "orderedItems": [ + """ ) end @@ -209,13 +212,13 @@ defmodule Pleroma.Backup do opts = %{} |> Map.put(:type, ["Create", "Announce"]) - |> Map.put(:blocking_user, user) - |> Map.put(:muting_user, user) - |> Map.put(:reply_filtering_user, user) - |> Map.put(:announce_filtering_user, user) - |> Map.put(:user, user) + |> Map.put(:actor_id, user.ap_id) - [[user.ap_id], User.following(user), Pleroma.List.memberships(user)] + [ + [Pleroma.Constants.as_public(), user.ap_id], + User.following(user), + Pleroma.List.memberships(user) + ] |> Enum.concat() |> ActivityPub.fetch_activities_query(opts) |> write(dir, "outbox", fn a -> -- cgit v1.2.3 From 27bc121ec00a7b088030d6fb36c7e731f5b072b6 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 15 Sep 2020 18:07:28 +0400 Subject: Require email --- lib/pleroma/backup.ex | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/backup.ex b/lib/pleroma/backup.ex index ce54a413a..3b85dd1c1 100644 --- a/lib/pleroma/backup.ex +++ b/lib/pleroma/backup.ex @@ -31,7 +31,9 @@ defmodule Pleroma.Backup do end def create(user) do - with :ok <- validate_limit(user), + with :ok <- validate_email_enabled(), + :ok <- validate_user_email(user), + :ok <- validate_limit(user), {:ok, backup} <- user |> new() |> Repo.insert() do BackupWorker.process(backup) end @@ -74,6 +76,17 @@ defmodule Pleroma.Backup do end end + defp validate_email_enabled do + if Pleroma.Config.get([Pleroma.Emails.Mailer, :enabled]) do + :ok + else + {:error, "Backups require enabled email"} + end + end + + defp validate_user_email(%User{email: nil}), do: {:error, "Email is required"} + defp validate_user_email(%User{email: email}) when is_binary(email), do: :ok + def get_last(user_id) do __MODULE__ |> where(user_id: ^user_id) @@ -100,7 +113,7 @@ defmodule Pleroma.Backup do def get(id), do: Repo.get(__MODULE__, id) def process(%__MODULE__{} = backup) do - with {:ok, zip_file} <- zip(backup), + with {:ok, zip_file} <- export(backup), {:ok, %{size: size}} <- File.stat(zip_file), {:ok, _upload} <- upload(backup, zip_file) do backup @@ -110,7 +123,7 @@ defmodule Pleroma.Backup do end @files ['actor.json', 'outbox.json', 'likes.json', 'bookmarks.json'] - def zip(%__MODULE__{} = backup) do + def export(%__MODULE__{} = backup) do backup = Repo.preload(backup, :user) name = String.trim_trailing(backup.file_name, ".zip") dir = Path.join(System.tmp_dir!(), name) -- cgit v1.2.3 From e52dd62e14a956a28a706124464f3ac4b985080d Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 16 Sep 2020 23:21:13 +0400 Subject: Add configurable temporary directory --- lib/pleroma/backup.ex | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/backup.ex b/lib/pleroma/backup.ex index 3b85dd1c1..450dd5b84 100644 --- a/lib/pleroma/backup.ex +++ b/lib/pleroma/backup.ex @@ -126,7 +126,7 @@ defmodule Pleroma.Backup do def export(%__MODULE__{} = backup) do backup = Repo.preload(backup, :user) name = String.trim_trailing(backup.file_name, ".zip") - dir = Path.join(System.tmp_dir!(), name) + dir = dir(name) with :ok <- File.mkdir(dir), :ok <- actor(dir, backup.user), @@ -139,6 +139,11 @@ defmodule Pleroma.Backup do end end + def dir(name) do + dir = Pleroma.Config.get([__MODULE__, :dir]) || System.tmp_dir!() + Path.join(dir, name) + end + def upload(%__MODULE__{} = backup, zip_path) do uploader = Pleroma.Config.get([Pleroma.Upload, :uploader]) -- cgit v1.2.3 From 7fdd81d000d857cbcd5bf442f68c91b1c5b1cebb Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 17 Sep 2020 18:42:24 +0400 Subject: Add "Your backup is ready" email --- lib/pleroma/emails/user_email.ex | 16 ++++++++++++++++ lib/pleroma/workers/backup_worker.ex | 6 +++++- 2 files changed, 21 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/emails/user_email.ex b/lib/pleroma/emails/user_email.ex index 1d8c72ae9..f943dda0d 100644 --- a/lib/pleroma/emails/user_email.ex +++ b/lib/pleroma/emails/user_email.ex @@ -189,4 +189,20 @@ defmodule Pleroma.Emails.UserEmail do Router.Helpers.subscription_url(Endpoint, :unsubscribe, token) end + + def backup_is_ready_email(backup) do + %{user: user} = Pleroma.Repo.preload(backup, :user) + download_url = Pleroma.Web.PleromaAPI.BackupView.download_url(backup) + + html_body = """ +

You requested a full backup of your Pleroma account. It's ready for download:

+

+ """ + + new() + |> to(recipient(user)) + |> from(sender()) + |> subject("Your account archive is ready") + |> html_body(html_body) + end end diff --git a/lib/pleroma/workers/backup_worker.ex b/lib/pleroma/workers/backup_worker.ex index f40020794..405d55269 100644 --- a/lib/pleroma/workers/backup_worker.ex +++ b/lib/pleroma/workers/backup_worker.ex @@ -34,7 +34,11 @@ defmodule Pleroma.Workers.BackupWorker do with {:ok, %Backup{} = backup} <- backup_id |> Backup.get() |> Backup.process(), {:ok, _job} <- schedule_deletion(backup), - :ok <- Backup.remove_outdated(backup) do + :ok <- Backup.remove_outdated(backup), + {:ok, _} <- + backup + |> Pleroma.Emails.UserEmail.backup_is_ready_email() + |> Pleroma.Emails.Mailer.deliver() do {:ok, backup} end end -- cgit v1.2.3 From 7c22c9afb410668d87dcd4a90651d62d9a1e9e4d Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 18 Sep 2020 22:18:34 +0400 Subject: Allow admins request user backups --- lib/pleroma/backup.ex | 4 ++-- lib/pleroma/emails/user_email.ex | 20 +++++++++++++++----- .../admin_api/controllers/admin_api_controller.ex | 12 +++++++++++- lib/pleroma/web/router.ex | 2 ++ lib/pleroma/workers/backup_worker.ex | 10 ++++++---- 5 files changed, 36 insertions(+), 12 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/backup.ex b/lib/pleroma/backup.ex index 450dd5b84..d589f12f1 100644 --- a/lib/pleroma/backup.ex +++ b/lib/pleroma/backup.ex @@ -30,12 +30,12 @@ defmodule Pleroma.Backup do timestamps() end - def create(user) do + def create(user, admin_user_id \\ nil) do with :ok <- validate_email_enabled(), :ok <- validate_user_email(user), :ok <- validate_limit(user), {:ok, backup} <- user |> new() |> Repo.insert() do - BackupWorker.process(backup) + BackupWorker.process(backup, admin_user_id) end end diff --git a/lib/pleroma/emails/user_email.ex b/lib/pleroma/emails/user_email.ex index f943dda0d..5745794ec 100644 --- a/lib/pleroma/emails/user_email.ex +++ b/lib/pleroma/emails/user_email.ex @@ -190,14 +190,24 @@ defmodule Pleroma.Emails.UserEmail do Router.Helpers.subscription_url(Endpoint, :unsubscribe, token) end - def backup_is_ready_email(backup) do + def backup_is_ready_email(backup, admin_user_id \\ nil) do %{user: user} = Pleroma.Repo.preload(backup, :user) download_url = Pleroma.Web.PleromaAPI.BackupView.download_url(backup) - html_body = """ -

You requested a full backup of your Pleroma account. It's ready for download:

-

- """ + html_body = + if is_nil(admin_user_id) do + """ +

You requested a full backup of your Pleroma account. It's ready for download:

+

+ """ + else + admin = Pleroma.Repo.get(User, admin_user_id) + + """ +

Admin @#{admin.nickname} requested a full backup of your Pleroma account. It's ready for download:

+

+ """ + end new() |> to(recipient(user)) diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index d5713c3dd..f7d2fe5b1 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -23,12 +23,14 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Web.Endpoint alias Pleroma.Web.Router + require Logger + @users_page_size 50 plug( OAuthScopesPlug, %{scopes: ["read:accounts"], admin: true} - when action in [:list_users, :user_show, :right_get, :show_user_credentials] + when action in [:list_users, :user_show, :right_get, :show_user_credentials, :create_backup] ) plug( @@ -681,6 +683,14 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do json(conn, %{"status_visibility" => counters}) end + def create_backup(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do + with %User{} = user <- User.get_by_nickname(nickname), + {:ok, _} <- Pleroma.Backup.create(user, admin.id) do + Logger.info("Admin @#{admin.nickname} requested account backup for @{nickname}") + json(conn, "") + end + end + defp page_params(params) do {get_page(params["page"]), get_page_size(params["page_size"])} end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index a1a5a1cb5..e539eeeeb 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -129,6 +129,8 @@ defmodule Pleroma.Web.Router do scope "/api/pleroma/admin", Pleroma.Web.AdminAPI do pipe_through(:admin_api) + post("/backups", AdminAPIController, :create_backup) + post("/users/follow", AdminAPIController, :user_follow) post("/users/unfollow", AdminAPIController, :user_unfollow) diff --git a/lib/pleroma/workers/backup_worker.ex b/lib/pleroma/workers/backup_worker.ex index 405d55269..65754b6a2 100644 --- a/lib/pleroma/workers/backup_worker.ex +++ b/lib/pleroma/workers/backup_worker.ex @@ -8,8 +8,8 @@ defmodule Pleroma.Workers.BackupWorker do alias Oban.Job alias Pleroma.Backup - def process(backup) do - %{"op" => "process", "backup_id" => backup.id} + def process(backup, admin_user_id \\ nil) do + %{"op" => "process", "backup_id" => backup.id, "admin_user_id" => admin_user_id} |> new() |> Oban.insert() end @@ -30,14 +30,16 @@ defmodule Pleroma.Workers.BackupWorker do |> Oban.insert() end - def perform(%Job{args: %{"op" => "process", "backup_id" => backup_id}}) do + def perform(%Job{ + args: %{"op" => "process", "backup_id" => backup_id, "admin_user_id" => admin_user_id} + }) do with {:ok, %Backup{} = backup} <- backup_id |> Backup.get() |> Backup.process(), {:ok, _job} <- schedule_deletion(backup), :ok <- Backup.remove_outdated(backup), {:ok, _} <- backup - |> Pleroma.Emails.UserEmail.backup_is_ready_email() + |> Pleroma.Emails.UserEmail.backup_is_ready_email(admin_user_id) |> Pleroma.Emails.Mailer.deliver() do {:ok, backup} end -- cgit v1.2.3 From e50314d9d342dbf9a03ca484654b07717592d4bd Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 18 Sep 2020 22:33:12 +0400 Subject: Fix export --- lib/pleroma/backup.ex | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/backup.ex b/lib/pleroma/backup.ex index d589f12f1..242773bdb 100644 --- a/lib/pleroma/backup.ex +++ b/lib/pleroma/backup.ex @@ -191,16 +191,13 @@ defmodule Pleroma.Backup do counter = :counters.new(1, []) query - |> Pleroma.RepoStreamer.chunk_stream(100) - |> Stream.each(fn items -> - Enum.each(items, fn i -> - with {:ok, str} <- fun.(i), - :ok <- IO.write(file, str <> ",\n") do - :counters.add(counter, 1, 1) - end - end) + |> Pleroma.Repo.chunk_stream(100) + |> Enum.each(fn i -> + with {:ok, str} <- fun.(i), + :ok <- IO.write(file, str <> ",\n") do + :counters.add(counter, 1, 1) + end end) - |> Stream.run() total = :counters.get(counter, 1) -- cgit v1.2.3 From a9efd441e242f1d8ac608b866d0cfafe4833243a Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Sun, 20 Sep 2020 19:57:09 +0400 Subject: Use `Pleroma.Repo.chunk_stream/2` instead of `Pleroma.RepoStreamer.chunk_stream/2` --- lib/pleroma/backup.ex | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/backup.ex b/lib/pleroma/backup.ex index 242773bdb..f5f39431d 100644 --- a/lib/pleroma/backup.ex +++ b/lib/pleroma/backup.ex @@ -188,18 +188,17 @@ defmodule Pleroma.Backup do with {:ok, file} <- File.open(path, [:write, :utf8]), :ok <- write_header(file, name) do - counter = :counters.new(1, []) - - query - |> Pleroma.Repo.chunk_stream(100) - |> Enum.each(fn i -> - with {:ok, str} <- fun.(i), - :ok <- IO.write(file, str <> ",\n") do - :counters.add(counter, 1, 1) - end - end) - - total = :counters.get(counter, 1) + total = + query + |> Pleroma.Repo.chunk_stream(100) + |> Enum.reduce(0, fn i, acc -> + with {:ok, str} <- fun.(i), + :ok <- IO.write(file, str <> ",\n") do + acc + 1 + else + _ -> acc + end + end) with :ok <- :file.pwrite(file, {:eof, -2}, "\n],\n \"totalItems\": #{total}}") do File.close(file) -- cgit v1.2.3 From 17562bf4147ab03e171b1f1d365a512f2e5b3202 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Sun, 20 Sep 2020 20:43:27 +0400 Subject: Move API endpoints to `/api/v1/pleroma/backups` --- lib/pleroma/web/router.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index e539eeeeb..ad7e315c7 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -295,9 +295,6 @@ defmodule Pleroma.Web.Router do get("/accounts/mfa/setup/:method", TwoFactorAuthenticationController, :setup) post("/accounts/mfa/confirm/:method", TwoFactorAuthenticationController, :confirm) delete("/accounts/mfa/:method", TwoFactorAuthenticationController, :disable) - - get("/backups", BackupController, :index) - post("/backups", BackupController, :create) end scope "/oauth", Pleroma.Web.OAuth do @@ -358,6 +355,9 @@ defmodule Pleroma.Web.Router do put("/mascot", MascotController, :update) post("/scrobble", ScrobbleController, :create) + + get("/backups", BackupController, :index) + post("/backups", BackupController, :create) end scope [] do -- cgit v1.2.3 From e4792ce76af3094d378a3a201ca429ae38203696 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Sun, 20 Sep 2020 21:06:16 +0400 Subject: Do not limit admins --- lib/pleroma/backup.ex | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/backup.ex b/lib/pleroma/backup.ex index f5f39431d..e2673db80 100644 --- a/lib/pleroma/backup.ex +++ b/lib/pleroma/backup.ex @@ -30,12 +30,12 @@ defmodule Pleroma.Backup do timestamps() end - def create(user, admin_user_id \\ nil) do + def create(user, admin_id \\ nil) do with :ok <- validate_email_enabled(), :ok <- validate_user_email(user), - :ok <- validate_limit(user), + :ok <- validate_limit(user, admin_id), {:ok, backup} <- user |> new() |> Repo.insert() do - BackupWorker.process(backup, admin_user_id) + BackupWorker.process(backup, admin_id) end end @@ -59,7 +59,9 @@ defmodule Pleroma.Backup do end end - defp validate_limit(user) do + defp validate_limit(_user, admin_id) when is_binary(admin_id), do: :ok + + defp validate_limit(user, nil) do case get_last(user.id) do %__MODULE__{inserted_at: inserted_at} -> days = Pleroma.Config.get([Pleroma.Backup, :limit_days]) -- cgit v1.2.3 From 8baee855d90530def46dc62b81e6a0cb0c315914 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 21 Sep 2020 21:47:36 +0400 Subject: Fix emails --- lib/pleroma/emails/user_email.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/emails/user_email.ex b/lib/pleroma/emails/user_email.ex index 5745794ec..806a61fd2 100644 --- a/lib/pleroma/emails/user_email.ex +++ b/lib/pleroma/emails/user_email.ex @@ -198,14 +198,14 @@ defmodule Pleroma.Emails.UserEmail do if is_nil(admin_user_id) do """

You requested a full backup of your Pleroma account. It's ready for download:

-

+

#{download_url}

""" else admin = Pleroma.Repo.get(User, admin_user_id) """

Admin @#{admin.nickname} requested a full backup of your Pleroma account. It's ready for download:

-

+

#{download_url}

""" end -- cgit v1.2.3 From d7a5291b4fa3b7568674c0f7643fe287fcd21eff Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Sat, 26 Sep 2020 21:24:35 +0400 Subject: Use `Jason.encode/1` for likes and bookmarks --- lib/pleroma/backup.ex | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/backup.ex b/lib/pleroma/backup.ex index e2673db80..b43dc94d6 100644 --- a/lib/pleroma/backup.ex +++ b/lib/pleroma/backup.ex @@ -194,7 +194,8 @@ defmodule Pleroma.Backup do query |> Pleroma.Repo.chunk_stream(100) |> Enum.reduce(0, fn i, acc -> - with {:ok, str} <- fun.(i), + with {:ok, data} <- fun.(i), + {:ok, str} <- Jason.encode(data), :ok <- IO.write(file, str <> ",\n") do acc + 1 else @@ -213,7 +214,7 @@ defmodule Pleroma.Backup do |> where(user_id: ^user_id) |> join(:inner, [b], activity in assoc(b, :activity)) |> select([b, a], %{id: b.id, object: fragment("(?)->>'object'", a.data)}) - |> write(dir, "bookmarks", fn a -> {:ok, "\"#{a.object}\""} end) + |> write(dir, "bookmarks", fn a -> {:ok, a.object} end) end defp likes(dir, user) do @@ -221,7 +222,7 @@ defmodule Pleroma.Backup do |> Activity.Queries.by_actor() |> Activity.Queries.by_type("Like") |> select([like], %{id: like.id, object: fragment("(?)->>'object'", like.data)}) - |> write(dir, "likes", fn a -> {:ok, "\"#{a.object}\""} end) + |> write(dir, "likes", fn a -> {:ok, a.object} end) end defp statuses(dir, user) do @@ -239,7 +240,7 @@ defmodule Pleroma.Backup do |> ActivityPub.fetch_activities_query(opts) |> write(dir, "outbox", fn a -> with {:ok, activity} <- Transmogrifier.prepare_outgoing(a.data) do - activity |> Map.delete("@context") |> Jason.encode() + {:ok, Map.delete(activity, "@context")} end end) end -- cgit v1.2.3 From 9af9f02f4b3c4eac859a69ab9b2f546a91110287 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Sat, 26 Sep 2020 21:45:03 +0400 Subject: Use Gettext for error messages --- lib/pleroma/backup.ex | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/backup.ex b/lib/pleroma/backup.ex index b43dc94d6..0ebaf02e5 100644 --- a/lib/pleroma/backup.ex +++ b/lib/pleroma/backup.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Backup do import Ecto.Changeset import Ecto.Query + import Pleroma.Web.Gettext require Pleroma.Constants @@ -70,7 +71,14 @@ defmodule Pleroma.Backup do if diff > days do :ok else - {:error, "Last export was less than #{days} days ago"} + {:error, + dngettext( + "errors", + "Last export was less than a day ago", + "Last export was less than %{days} days ago", + days, + days: days + )} end nil -> @@ -82,11 +90,14 @@ defmodule Pleroma.Backup do if Pleroma.Config.get([Pleroma.Emails.Mailer, :enabled]) do :ok else - {:error, "Backups require enabled email"} + {:error, dgettext("errors", "Backups require enabled email")} end end - defp validate_user_email(%User{email: nil}), do: {:error, "Email is required"} + defp validate_user_email(%User{email: nil}) do + {:error, dgettext("errors", "Email is required")} + end + defp validate_user_email(%User{email: email}) when is_binary(email), do: :ok def get_last(user_id) do -- cgit v1.2.3 From 08972dd135c200073f5de0c8731b886cc2e72eeb Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Sat, 26 Sep 2020 21:50:31 +0400 Subject: Use Path.join/2 --- lib/pleroma/backup.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/backup.ex b/lib/pleroma/backup.ex index 0ebaf02e5..cee51d7c1 100644 --- a/lib/pleroma/backup.ex +++ b/lib/pleroma/backup.ex @@ -55,7 +55,7 @@ defmodule Pleroma.Backup do def delete(backup) do uploader = Pleroma.Config.get([Pleroma.Upload, :uploader]) - with :ok <- uploader.delete_file("backups/" <> backup.file_name) do + with :ok <- uploader.delete_file(Path.join("backups", backup.file_name)) do Repo.delete(backup) end end @@ -164,7 +164,7 @@ defmodule Pleroma.Backup do name: backup.file_name, tempfile: zip_path, content_type: backup.content_type, - path: "backups/" <> backup.file_name + path: Path.join("backups", backup.file_name) } with {:ok, _} <- Pleroma.Uploaders.Uploader.put_file(uploader, upload), @@ -178,7 +178,7 @@ defmodule Pleroma.Backup do UserView.render("user.json", %{user: user}) |> Map.merge(%{"likes" => "likes.json", "bookmarks" => "bookmarks.json"}) |> Jason.encode() do - File.write(dir <> "/actor.json", json) + File.write(Path.join(dir, "actor.json"), json) end end @@ -197,7 +197,7 @@ defmodule Pleroma.Backup do end defp write(query, dir, name, fun) do - path = dir <> "/#{name}.json" + path = Path.join(dir, "#{name}.json") with {:ok, file} <- File.open(path, [:write, :utf8]), :ok <- write_header(file, name) do -- cgit v1.2.3 From 8545d533ddee2978e9bf7f3284cc7dcb822a77e6 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Sat, 26 Sep 2020 21:53:04 +0400 Subject: Use to_string/1 instead of :binary.list_to_bin/1 --- lib/pleroma/backup.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/backup.ex b/lib/pleroma/backup.ex index cee51d7c1..629e879a7 100644 --- a/lib/pleroma/backup.ex +++ b/lib/pleroma/backup.ex @@ -148,7 +148,7 @@ defmodule Pleroma.Backup do :ok <- bookmarks(dir, backup.user), {:ok, zip_path} <- :zip.create(String.to_charlist(dir <> ".zip"), @files, cwd: dir), {:ok, _} <- File.rm_rf(dir) do - {:ok, :binary.list_to_bin(zip_path)} + {:ok, to_string(zip_path)} end end -- cgit v1.2.3 From bc3db724030707e9903d161a70b10fe217a83212 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Sat, 26 Sep 2020 23:16:56 +0400 Subject: Use ModerationLog instead of Logger --- lib/pleroma/moderation_log.ex | 10 ++++++++++ lib/pleroma/web/admin_api/controllers/admin_api_controller.ex | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/moderation_log.ex b/lib/pleroma/moderation_log.ex index 47036a6f6..be1e81467 100644 --- a/lib/pleroma/moderation_log.ex +++ b/lib/pleroma/moderation_log.ex @@ -651,6 +651,16 @@ defmodule Pleroma.ModerationLog do "@#{actor_nickname} deleted chat message ##{subject_id}" end + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "create_backup", + "subject" => %{"nickname" => user_nickname} + } + }) do + "@#{actor_nickname} requested account backup for @#{user_nickname}" + end + defp nicknames_to_string(nicknames) do nicknames |> Enum.map(&"@#{&1}") diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index f7d2fe5b1..8b5310d80 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -686,7 +686,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do def create_backup(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do with %User{} = user <- User.get_by_nickname(nickname), {:ok, _} <- Pleroma.Backup.create(user, admin.id) do - Logger.info("Admin @#{admin.nickname} requested account backup for @{nickname}") + ModerationLog.insert_log(%{actor: admin, subject: user, action: "create_backup"}) + json(conn, "") end end -- cgit v1.2.3 From 636c00037d797161c4ecd654345a436452f99415 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 7 Oct 2020 09:58:45 -0500 Subject: Fix duplicate @doc entries --- lib/pleroma/web/activity_pub/publisher.ex | 4 +--- lib/pleroma/web/common_api/utils.ex | 11 +---------- lib/pleroma/web/mastodon_api/controllers/auth_controller.ex | 4 ++-- lib/pleroma/web/mastodon_api/controllers/status_controller.ex | 8 ++------ 4 files changed, 6 insertions(+), 21 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/publisher.ex b/lib/pleroma/web/activity_pub/publisher.ex index 9c3956683..a2930c1cd 100644 --- a/lib/pleroma/web/activity_pub/publisher.ex +++ b/lib/pleroma/web/activity_pub/publisher.ex @@ -242,9 +242,7 @@ defmodule Pleroma.Web.ActivityPub.Publisher do end) end - @doc """ - Publishes an activity to all relevant peers. - """ + # Publishes an activity to all relevant peers. def publish(%User{} = actor, %Activity{} = activity) do public = is_public?(activity) diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 9d7b24eb2..85dcd89dc 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -274,7 +274,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do def format_input(text, format, options \\ []) @doc """ - Formatting text to plain text. + Formatting text to plain text, BBCode, HTML, or Markdown """ def format_input(text, "text/plain", options) do text @@ -285,9 +285,6 @@ defmodule Pleroma.Web.CommonAPI.Utils do end).() end - @doc """ - Formatting text as BBCode. - """ def format_input(text, "text/bbcode", options) do text |> String.replace(~r/\r/, "") @@ -297,18 +294,12 @@ defmodule Pleroma.Web.CommonAPI.Utils do |> Formatter.linkify(options) end - @doc """ - Formatting text to html. - """ def format_input(text, "text/html", options) do text |> Formatter.html_escape("text/html") |> Formatter.linkify(options) end - @doc """ - Formatting text to markdown. - """ def format_input(text, "text/markdown", options) do text |> Formatter.mentions_escape(options) diff --git a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex index 57c0be5fe..a278ca622 100644 --- a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex @@ -24,7 +24,7 @@ defmodule Pleroma.Web.MastodonAPI.AuthController do redirect(conn, to: local_mastodon_root_path(conn)) end - @doc "Local Mastodon FE login init action" + # Local Mastodon FE login init action def login(conn, %{"code" => auth_token}) do with {:ok, app} <- get_or_make_app(), {:ok, auth} <- Authorization.get_by_token(app, auth_token), @@ -35,7 +35,7 @@ defmodule Pleroma.Web.MastodonAPI.AuthController do end end - @doc "Local Mastodon FE callback action" + # Local Mastodon FE callback action def login(conn, _) do with {:ok, app} <- get_or_make_app() do path = diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index ecfa38489..c85757f26 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -123,9 +123,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do @doc """ POST /api/v1/statuses - - Creates a scheduled status when `scheduled_at` param is present and it's far enough """ + # Creates a scheduled status when `scheduled_at` param is present and it's far enough def create( %{ assigns: %{user: user}, @@ -156,11 +155,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do end end - @doc """ - POST /api/v1/statuses - Creates a regular status - """ + # Creates a regular status def create(%{assigns: %{user: user}, body_params: %{status: _} = params} = conn, _) do params = Map.put(params, :in_reply_to_status_id, params[:in_reply_to_id]) -- cgit v1.2.3 From d3106c69c80af58244faa0373b01c618371f84e0 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 7 Oct 2020 10:02:20 -0500 Subject: Fix incompatible type (Elixir 1.11) --- lib/pleroma/web/media_proxy/invalidations/http.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/media_proxy/invalidations/http.ex b/lib/pleroma/web/media_proxy/invalidations/http.ex index bb81d8888..694eb559b 100644 --- a/lib/pleroma/web/media_proxy/invalidations/http.ex +++ b/lib/pleroma/web/media_proxy/invalidations/http.ex @@ -30,7 +30,7 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.Http do {:ok, %{status: status} = env} when 400 <= status and status < 500 -> {:error, env} - {:error, error} = error -> + {:error, error} -> error _ -> -- cgit v1.2.3 From 218a3e61e1692058aaf5f16506cbf276d2260722 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 7 Oct 2020 10:04:25 -0500 Subject: Fix incompatible types warning (Elixir 1.11) --- lib/pleroma/web/activity_pub/activity_pub_controller.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 732c44271..8916aba5f 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -412,7 +412,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do object = object |> Map.merge(Map.take(params, ["to", "cc"])) - |> Map.put("attributedTo", user.ap_id()) + |> Map.put("attributedTo", user.ap_id) |> Transmogrifier.fix_object() ActivityPub.create(%{ @@ -456,7 +456,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do %{assigns: %{user: %User{nickname: nickname} = user}} = conn, %{"nickname" => nickname} = params ) do - actor = user.ap_id() + actor = user.ap_id params = params -- cgit v1.2.3 From 6d1666a080ad97ba1233d50ba36d3b8a136f75a7 Mon Sep 17 00:00:00 2001 From: feld Date: Wed, 7 Oct 2020 16:44:52 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- lib/pleroma/web/media_proxy/invalidations/http.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/media_proxy/invalidations/http.ex b/lib/pleroma/web/media_proxy/invalidations/http.ex index 694eb559b..0b0cde68c 100644 --- a/lib/pleroma/web/media_proxy/invalidations/http.ex +++ b/lib/pleroma/web/media_proxy/invalidations/http.ex @@ -30,7 +30,7 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.Http do {:ok, %{status: status} = env} when 400 <= status and status < 500 -> {:error, env} - {:error, error} -> + {:error, _} = error -> error _ -> -- cgit v1.2.3 From 8caa6cf91d0d354ce64c1d923112d358295222a2 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 7 Oct 2020 11:47:10 -0500 Subject: Transport.connect/7 is deprecated --- lib/transports.ex | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/transports.ex b/lib/transports.ex index aab7fad99..1ed3a942d 100644 --- a/lib/transports.ex +++ b/lib/transports.ex @@ -31,7 +31,12 @@ defmodule Phoenix.Transports.WebSocket.Raw do case conn do %{halted: false} = conn -> - case Transport.connect(endpoint, handler, transport, __MODULE__, nil, conn.params) do + case Transport.connect(%{ + endpoint: endpoint, + transport: transport, + options: [serializer: nil], + params: conn.params + }) do {:ok, socket} -> {:ok, conn, {__MODULE__, {socket, opts}}} -- cgit v1.2.3 From 42e78a08b28f3faee21b803c1e621e96bbf5c731 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 7 Oct 2020 12:30:55 -0500 Subject: Fix rendering of reports --- lib/pleroma/web/admin_api/views/report_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/admin_api/views/report_view.ex b/lib/pleroma/web/admin_api/views/report_view.ex index 773f798fe..535556370 100644 --- a/lib/pleroma/web/admin_api/views/report_view.ex +++ b/lib/pleroma/web/admin_api/views/report_view.ex @@ -52,7 +52,7 @@ defmodule Pleroma.Web.AdminAPI.ReportView do end def render("index_notes.json", %{notes: notes}) when is_list(notes) do - Enum.map(notes, &render(__MODULE__, "show_note.json", &1)) + Enum.map(notes, &render(__MODULE__, "show_note.json", Map.from_struct(&1))) end def render("index_notes.json", _), do: [] -- cgit v1.2.3 From 70880d54f85a96d07b6c72adfbf3f1a7c50f95a2 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 7 Oct 2020 12:55:16 -0500 Subject: @env is not used --- lib/pleroma/application.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index e73d89350..02dd39939 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -99,7 +99,7 @@ defmodule Pleroma.Application do ] ++ task_children(@env) ++ dont_run_in_test(@env) ++ - chat_child(@env, chat_enabled?()) ++ + chat_child(chat_enabled?()) ++ [ Pleroma.Web.Endpoint, Pleroma.Gopher.Server @@ -201,11 +201,11 @@ defmodule Pleroma.Application do ] end - defp chat_child(_env, true) do + defp chat_child(true) do [Pleroma.Web.ChatChannel.ChatChannelState] end - defp chat_child(_, _), do: [] + defp chat_child(_), do: [] defp task_children(:test) do [ -- cgit v1.2.3 From 8156940a49df17c00c05bfe60223b165f9dc034b Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 7 Oct 2020 13:28:39 -0500 Subject: Compatibility with phoenix_pubsub 2.0.0 --- lib/pleroma/application.ex | 5 ++++- lib/pleroma/web/web.ex | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 02dd39939..fe94b56f4 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -202,7 +202,10 @@ defmodule Pleroma.Application do end defp chat_child(true) do - [Pleroma.Web.ChatChannel.ChatChannelState] + [ + Pleroma.Web.ChatChannel.ChatChannelState, + {Phoenix.PubSub, [name: Pleroma.PubSub, adapter: Phoenix.PubSub.PG2]} + ] end defp chat_child(_), do: [] diff --git a/lib/pleroma/web/web.ex b/lib/pleroma/web/web.ex index 4f9281851..c319d223c 100644 --- a/lib/pleroma/web/web.ex +++ b/lib/pleroma/web/web.ex @@ -177,7 +177,7 @@ defmodule Pleroma.Web do def channel do quote do # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse - use Phoenix.Channel + import Phoenix.Channel import Pleroma.Web.Gettext end end -- cgit v1.2.3 From 822e4472f3de1492aed6948514bc357c9adb934c Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 7 Oct 2020 15:06:16 -0500 Subject: Fix incorrect use of connect/1 Hint from Phoenix 1.4.17, which has a connect/7 shim: lib/phoenix/socket/transport.ex: def connect(endpoint, handler, _transport_name, transport, serializers, params, _pid \\ self()) do IO.warn "Phoenix.Socket.Transport.connect/7 is deprecated" handler.connect(%{ endpoint: endpoint, transport: transport, options: [serializer: serializers], params: params }) end --- lib/transports.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/transports.ex b/lib/transports.ex index 1ed3a942d..c3665bebe 100644 --- a/lib/transports.ex +++ b/lib/transports.ex @@ -31,7 +31,7 @@ defmodule Phoenix.Transports.WebSocket.Raw do case conn do %{halted: false} = conn -> - case Transport.connect(%{ + case handler.connect(%{ endpoint: endpoint, transport: transport, options: [serializer: nil], -- cgit v1.2.3 From ed6511a086694fc163b488d807f17d246f80ad5b Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 7 Oct 2020 15:28:29 -0500 Subject: Lint --- lib/pleroma/web/mastodon_api/controllers/status_controller.ex | 1 - 1 file changed, 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index c85757f26..a47a7af95 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -155,7 +155,6 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do end end - # Creates a regular status def create(%{assigns: %{user: user}, body_params: %{status: _} = params} = conn, _) do params = Map.put(params, :in_reply_to_status_id, params[:in_reply_to_id]) -- cgit v1.2.3 From 9c672ecbb5d4477cd16d2139a2cb66d3923ac5c8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 8 Oct 2020 20:01:48 -0500 Subject: Remote Timeline: add Streaming support --- lib/pleroma/activity/ir/topics.ex | 13 ++++++++++++- lib/pleroma/web/streamer/streamer.ex | 9 +++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/activity/ir/topics.ex b/lib/pleroma/activity/ir/topics.ex index 9e65bedad..fe2e8cb5c 100644 --- a/lib/pleroma/activity/ir/topics.ex +++ b/lib/pleroma/activity/ir/topics.ex @@ -40,7 +40,8 @@ defmodule Pleroma.Activity.Ir.Topics do end defp item_creation_tags(tags, object, %{data: %{"type" => "Create"}} = activity) do - tags ++ hashtags_to_topics(object) ++ attachment_topics(object, activity) + tags ++ + remote_topics(activity) ++ hashtags_to_topics(object) ++ attachment_topics(object, activity) end defp item_creation_tags(tags, _, _) do @@ -55,9 +56,19 @@ defmodule Pleroma.Activity.Ir.Topics do defp hashtags_to_topics(_), do: [] + defp remote_topics(%{local: true}), do: [] + + defp remote_topics(%{actor: actor}) when is_binary(actor), + do: ["public:remote:" <> URI.parse(actor).host] + + defp remote_topics(_), do: [] + defp attachment_topics(%{data: %{"attachment" => []}}, _act), do: [] defp attachment_topics(_object, %{local: true}), do: ["public:media", "public:local:media"] + defp attachment_topics(_object, %{actor: actor}) when is_binary(actor), + do: ["public:media", "public:remote:media:" <> URI.parse(actor).host] + defp attachment_topics(_object, _act), do: ["public:media"] end diff --git a/lib/pleroma/web/streamer/streamer.ex b/lib/pleroma/web/streamer/streamer.ex index 5475f18a6..d774f0dd9 100644 --- a/lib/pleroma/web/streamer/streamer.ex +++ b/lib/pleroma/web/streamer/streamer.ex @@ -57,6 +57,15 @@ defmodule Pleroma.Web.Streamer do {:ok, "hashtag:" <> tag} end + # Allow remote instance streams. + def get_topic("public:remote", _user, _oauth_token, %{"instance" => instance} = _params) do + {:ok, "public:remote:" <> instance} + end + + def get_topic("public:remote:media", _user, _oauth_token, %{"instance" => instance} = _params) do + {:ok, "public:remote:media:" <> instance} + end + # Expand user streams. def get_topic( stream, -- cgit v1.2.3 From e1eb54d3899883b5af6a43687a2345543d69ad4a Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sun, 11 Oct 2020 13:37:19 +0300 Subject: [#3053] Rollback of access control changes in ActivityPubController (base actions: :user, :object, :activity). --- .../web/activity_pub/activity_pub_controller.ex | 56 ++++++++++------------ 1 file changed, 25 insertions(+), 31 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index c78edfb4c..732c44271 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -32,23 +32,17 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do @federating_only_actions [:internal_fetch, :relay, :relay_following, :relay_followers] - # Note: :following and :followers must be served even without authentication (as via :api) - @auth_only_actions [:read_inbox, :update_outbox, :whoami, :upload_media] - - # Always accessible actions (must perform entity accessibility checks) - @no_auth_no_federation_actions [:user, :activity, :object] - - @authenticated_or_federating_actions @federating_only_actions ++ - @auth_only_actions ++ @no_auth_no_federation_actions - plug(FederatingPlug when action in @federating_only_actions) - plug(EnsureAuthenticatedPlug when action in @auth_only_actions) - plug( EnsureAuthenticatedPlug, - [unless_func: &FederatingPlug.federating?/1] - when action not in @authenticated_or_federating_actions + [unless_func: &FederatingPlug.federating?/1] when action not in @federating_only_actions + ) + + # Note: :following and :followers must be served even without authentication (as via :api) + plug( + EnsureAuthenticatedPlug + when action in [:read_inbox, :update_outbox, :whoami, :upload_media] ) plug( @@ -72,22 +66,21 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do def user(conn, %{"nickname" => nickname}) do with %User{local: true} = user <- User.get_cached_by_nickname(nickname), - {_, :visible} <- {:visibility, User.visible_for(user, _reading_user = nil)}, {:ok, user} <- User.ensure_keys_present(user) do conn |> put_resp_content_type("application/activity+json") |> put_view(UserView) |> render("user.json", %{user: user}) else - _ -> {:error, :not_found} + nil -> {:error, :not_found} + %{local: false} -> {:error, :not_found} end end def object(conn, _) do with ap_id <- Endpoint.url() <> conn.request_path, %Object{} = object <- Object.get_cached_by_ap_id(ap_id), - {_, true} <- {:public?, Visibility.is_public?(object)}, - {_, false} <- {:restricted?, Visibility.restrict_unauthenticated_access?(object)} do + {_, true} <- {:public?, Visibility.is_public?(object)} do conn |> assign(:tracking_fun_data, object.id) |> set_cache_ttl_for(object) @@ -95,15 +88,25 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do |> put_view(ObjectView) |> render("object.json", object: object) else - _ -> {:error, :not_found} + {:public?, false} -> + {:error, :not_found} end end + def track_object_fetch(conn, nil), do: conn + + def track_object_fetch(conn, object_id) do + with %{assigns: %{user: %User{id: user_id}}} <- conn do + Delivery.create(object_id, user_id) + end + + conn + end + def activity(conn, _params) do with ap_id <- Endpoint.url() <> conn.request_path, %Activity{} = activity <- Activity.normalize(ap_id), - {_, true} <- {:public?, Visibility.is_public?(activity)}, - {_, true} <- {:visible?, Visibility.visible_for_user?(activity, _reading_user = nil)} do + {_, true} <- {:public?, Visibility.is_public?(activity)} do conn |> maybe_set_tracking_data(activity) |> set_cache_ttl_for(activity) @@ -111,7 +114,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do |> put_view(ObjectView) |> render("object.json", object: activity) else - _ -> {:error, :not_found} + {:public?, false} -> {:error, :not_found} + nil -> {:error, :not_found} end end @@ -546,14 +550,4 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do |> json(object.data) end end - - def track_object_fetch(conn, nil), do: conn - - def track_object_fetch(conn, object_id) do - with %{assigns: %{user: %User{id: user_id}}} <- conn do - Delivery.create(object_id, user_id) - end - - conn - end end -- cgit v1.2.3 From 89c595b772eaaa8809f5339d708d7dc22e51b662 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sun, 11 Oct 2020 22:34:28 +0300 Subject: [#3053] Removed target accessibility checks for OStatus endpoints delegating to RedirectController. Added tests. --- lib/pleroma/web/ostatus/ostatus_controller.ex | 13 ++++----- lib/pleroma/web/router.ex | 38 +++++++++++++-------------- 2 files changed, 24 insertions(+), 27 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/ostatus/ostatus_controller.ex b/lib/pleroma/web/ostatus/ostatus_controller.ex index b4dc2a87f..e03ca8c0a 100644 --- a/lib/pleroma/web/ostatus/ostatus_controller.ex +++ b/lib/pleroma/web/ostatus/ostatus_controller.ex @@ -37,11 +37,10 @@ defmodule Pleroma.Web.OStatus.OStatusController do with id <- Endpoint.url() <> conn.request_path, {_, %Activity{} = activity} <- {:activity, Activity.get_create_by_object_ap_id_with_object(id)}, - {_, true} <- {:public?, Visibility.is_public?(activity)}, - {_, true} <- {:visible?, Visibility.visible_for_user?(activity, _reading_user = nil)} do + {_, true} <- {:public?, Visibility.is_public?(activity)} do redirect(conn, to: "/notice/#{activity.id}") else - reason when reason in [{:public?, false}, {:visible?, false}, {:activity, nil}] -> + reason when reason in [{:public?, false}, {:activity, nil}] -> {:error, :not_found} e -> @@ -57,11 +56,10 @@ defmodule Pleroma.Web.OStatus.OStatusController do def activity(conn, _params) do with id <- Endpoint.url() <> conn.request_path, {_, %Activity{} = activity} <- {:activity, Activity.normalize(id)}, - {_, true} <- {:public?, Visibility.is_public?(activity)}, - {_, true} <- {:visible?, Visibility.visible_for_user?(activity, _reading_user = nil)} do + {_, true} <- {:public?, Visibility.is_public?(activity)} do redirect(conn, to: "/notice/#{activity.id}") else - reason when reason in [{:public?, false}, {:visible?, false}, {:activity, nil}] -> + reason when reason in [{:public?, false}, {:activity, nil}] -> {:error, :not_found} e -> @@ -72,7 +70,6 @@ defmodule Pleroma.Web.OStatus.OStatusController do def notice(%{assigns: %{format: format}} = conn, %{"id" => id}) do with {_, %Activity{} = activity} <- {:activity, Activity.get_by_id_with_object(id)}, {_, true} <- {:public?, Visibility.is_public?(activity)}, - {_, true} <- {:visible?, Visibility.visible_for_user?(activity, _reading_user = nil)}, %User{} = user <- User.get_cached_by_ap_id(activity.data["actor"]) do cond do format in ["json", "activity+json"] -> @@ -100,7 +97,7 @@ defmodule Pleroma.Web.OStatus.OStatusController do RedirectController.redirector(conn, nil) end else - reason when reason in [{:public?, false}, {:visible?, false}, {:activity, nil}] -> + reason when reason in [{:public?, false}, {:activity, nil}] -> conn |> put_status(404) |> RedirectController.redirector(nil, 404) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 97fcaafd5..ef56360ed 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -9,6 +9,18 @@ defmodule Pleroma.Web.Router do plug(:accepts, ["html"]) end + pipeline :accepts_html_xml do + plug(:accepts, ["html", "xml", "rss", "atom"]) + end + + pipeline :accepts_html_json do + plug(:accepts, ["html", "activity+json", "json"]) + end + + pipeline :accepts_html_xml_json do + plug(:accepts, ["html", "xml", "rss", "atom", "activity+json", "json"]) + end + pipeline :accepts_xml_rss_atom do plug(:accepts, ["xml", "rss", "atom"]) end @@ -574,24 +586,10 @@ defmodule Pleroma.Web.Router do ) end - pipeline :ostatus_html_json do - plug(:accepts, ["html", "activity+json", "json"]) - plug(Pleroma.Plugs.StaticFEPlug) - end - - pipeline :ostatus_html_xml do - plug(:accepts, ["html", "xml", "rss", "atom"]) - plug(Pleroma.Plugs.StaticFEPlug) - end - - pipeline :ostatus_html_xml_json do - plug(:accepts, ["html", "xml", "rss", "atom", "activity+json", "json"]) - plug(Pleroma.Plugs.StaticFEPlug) - end - scope "/", Pleroma.Web do # Note: html format is supported only if static FE is enabled - pipe_through(:ostatus_html_json) + # Note: http signature is only considered for json requests (no auth for non-json requests) + pipe_through([:accepts_html_json, :http_signature, Pleroma.Plugs.StaticFEPlug]) get("/objects/:uuid", OStatus.OStatusController, :object) get("/activities/:uuid", OStatus.OStatusController, :activity) @@ -604,15 +602,17 @@ defmodule Pleroma.Web.Router do scope "/", Pleroma.Web do # Note: html format is supported only if static FE is enabled - pipe_through(:ostatus_html_xml_json) + # Note: http signature is only considered for json requests (no auth for non-json requests) + pipe_through([:accepts_html_xml_json, :http_signature, Pleroma.Plugs.StaticFEPlug]) - # Note: for json format responds with user profile (not user feed) + # Note: returns user _profile_ for json requests, redirects to user _feed_ for non-json ones get("/users/:nickname", Feed.UserController, :feed_redirect, as: :user_feed) end scope "/", Pleroma.Web do # Note: html format is supported only if static FE is enabled - pipe_through(:ostatus_html_xml) + pipe_through([:accepts_html_xml, Pleroma.Plugs.StaticFEPlug]) + get("/users/:nickname/feed", Feed.UserController, :feed, as: :user_feed) end -- cgit v1.2.3 From b2fed59209a92624884df38a477837cba9a8dbd9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 11 Oct 2020 18:52:35 -0500 Subject: Handle User.post_register_action/1 in steps --- lib/pleroma/user.ex | 30 ++++++++++++++++++++-- .../web/twitter_api/twitter_api_controller.ex | 3 ++- 2 files changed, 30 insertions(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 09ea80793..3f40ac9b3 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -773,12 +773,37 @@ defmodule Pleroma.User do end def post_register_action(%User{} = user) do + instance_config = + Config.get(:instance) + |> Enum.into(%{}) + + do_post_register_action(user, instance_config) + end + + defp do_post_register_action(%User{confirmation_pending: true} = user, %{ + account_activation_required: true + }) do + with {:ok, _} <- try_send_confirmation_email(user) do + {:ok, user} + end + end + + defp do_post_register_action(%User{approval_pending: true} = user, %{ + account_approval_required: true + }) do + # TODO: Send approval explanation email + {:ok, user} + end + + defp do_post_register_action( + %User{approval_pending: false, confirmation_pending: false} = user, + _instance_config + ) do with {:ok, user} <- autofollow_users(user), {:ok, user} <- set_cache(user), {:ok, _} <- send_welcome_email(user), {:ok, _} <- send_welcome_message(user), - {:ok, _} <- send_welcome_chat_message(user), - {:ok, _} <- try_send_confirmation_email(user) do + {:ok, _} <- send_welcome_chat_message(user) do {:ok, user} end end @@ -1570,6 +1595,7 @@ defmodule Pleroma.User do def approve(%User{} = user) do change(user, approval_pending: false) |> update_and_set_cache() + |> post_register_action() end def update_notification_settings(%User{} = user, settings) do diff --git a/lib/pleroma/web/twitter_api/twitter_api_controller.ex b/lib/pleroma/web/twitter_api/twitter_api_controller.ex index c2de26b0b..25dad547c 100644 --- a/lib/pleroma/web/twitter_api/twitter_api_controller.ex +++ b/lib/pleroma/web/twitter_api/twitter_api_controller.ex @@ -34,7 +34,8 @@ defmodule Pleroma.Web.TwitterAPI.Controller do {:ok, _} <- user |> User.confirmation_changeset(need_confirmation: false) - |> User.update_and_set_cache() do + |> User.update_and_set_cache() + |> User.post_register_action() do redirect(conn, to: "/") end end -- cgit v1.2.3 From c69b20540958b4de9cc4fd66595d43f26bcd5fd1 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 11 Oct 2020 19:25:34 -0500 Subject: Registration: user state is separate from instance state --- lib/pleroma/user.ex | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 3f40ac9b3..3a4f031b1 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -772,33 +772,18 @@ defmodule Pleroma.User do end end - def post_register_action(%User{} = user) do - instance_config = - Config.get(:instance) - |> Enum.into(%{}) - - do_post_register_action(user, instance_config) - end - - defp do_post_register_action(%User{confirmation_pending: true} = user, %{ - account_activation_required: true - }) do + def post_register_action(%User{confirmation_pending: true} = user) do with {:ok, _} <- try_send_confirmation_email(user) do {:ok, user} end end - defp do_post_register_action(%User{approval_pending: true} = user, %{ - account_approval_required: true - }) do + def post_register_action(%User{approval_pending: true} = user) do # TODO: Send approval explanation email {:ok, user} end - defp do_post_register_action( - %User{approval_pending: false, confirmation_pending: false} = user, - _instance_config - ) do + def post_register_action(%User{approval_pending: false, confirmation_pending: false} = user) do with {:ok, user} <- autofollow_users(user), {:ok, user} <- set_cache(user), {:ok, _} <- send_welcome_email(user), -- cgit v1.2.3 From 28005563f00028981cf516cceb16c2b55bd0e97c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 11 Oct 2020 20:50:09 -0500 Subject: Send approval pending email during registration --- lib/pleroma/emails/user_email.ex | 13 +++++++++++++ lib/pleroma/user.ex | 6 +++++- 2 files changed, 18 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/emails/user_email.ex b/lib/pleroma/emails/user_email.ex index 1d8c72ae9..831e5464f 100644 --- a/lib/pleroma/emails/user_email.ex +++ b/lib/pleroma/emails/user_email.ex @@ -93,6 +93,19 @@ defmodule Pleroma.Emails.UserEmail do |> html_body(html_body) end + def approval_pending_email(user) do + html_body = """ +

Awaiting Approval

+

Your account at #{instance_name()} is being reviewed by staff. You will receive another email once your account is approved.

+ """ + + new() + |> to(recipient(user)) + |> from(sender()) + |> subject("Your account is awaiting approval") + |> html_body(html_body) + end + @doc """ Email used in digest email notifications Includes Mentions and New Followers data diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 3a4f031b1..cde9ff0eb 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -779,7 +779,11 @@ defmodule Pleroma.User do end def post_register_action(%User{approval_pending: true} = user) do - # TODO: Send approval explanation email + # Send approval pending email + user + |> Pleroma.Emails.UserEmail.approval_pending_email() + |> Pleroma.Emails.Mailer.deliver_async() + {:ok, user} end -- cgit v1.2.3 From 521e965884d916c21d76ff12544e678b7fcdb1d4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 11 Oct 2020 21:38:01 -0500 Subject: Registration tests --- lib/pleroma/user.ex | 8 +++++--- lib/pleroma/web/twitter_api/twitter_api_controller.ex | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index cde9ff0eb..ae2fe93fc 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1582,9 +1582,11 @@ defmodule Pleroma.User do end def approve(%User{} = user) do - change(user, approval_pending: false) - |> update_and_set_cache() - |> post_register_action() + with chg <- change(user, approval_pending: false), + {:ok, user} <- update_and_set_cache(chg) do + post_register_action(user) + {:ok, user} + end end def update_notification_settings(%User{} = user, settings) do diff --git a/lib/pleroma/web/twitter_api/twitter_api_controller.ex b/lib/pleroma/web/twitter_api/twitter_api_controller.ex index 25dad547c..6961118ca 100644 --- a/lib/pleroma/web/twitter_api/twitter_api_controller.ex +++ b/lib/pleroma/web/twitter_api/twitter_api_controller.ex @@ -34,8 +34,8 @@ defmodule Pleroma.Web.TwitterAPI.Controller do {:ok, _} <- user |> User.confirmation_changeset(need_confirmation: false) - |> User.update_and_set_cache() - |> User.post_register_action() do + |> User.update_and_set_cache() do + User.post_register_action(user) redirect(conn, to: "/") end end -- cgit v1.2.3 From bb8c0614efa978ad33d15fdafd452792180a1c15 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 11 Oct 2020 21:44:22 -0500 Subject: Move admin approval email logic into User.post_register_action/1 --- lib/pleroma/user.ex | 11 ++++++++++- lib/pleroma/web/twitter_api/twitter_api.ex | 13 ------------- 2 files changed, 10 insertions(+), 14 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index ae2fe93fc..b56620c54 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -779,11 +779,20 @@ defmodule Pleroma.User do end def post_register_action(%User{approval_pending: true} = user) do - # Send approval pending email + # Send approval pending email to user user |> Pleroma.Emails.UserEmail.approval_pending_email() |> Pleroma.Emails.Mailer.deliver_async() + # Notify admins + all_superusers() + |> Enum.filter(fn user -> not is_nil(user.email) end) + |> Enum.each(fn superuser -> + superuser + |> Pleroma.Emails.AdminEmail.new_unapproved_registration(user) + |> Pleroma.Emails.Mailer.deliver_async() + end) + {:ok, user} end diff --git a/lib/pleroma/web/twitter_api/twitter_api.ex b/lib/pleroma/web/twitter_api/twitter_api.ex index 5d7948507..8e20b0d55 100644 --- a/lib/pleroma/web/twitter_api/twitter_api.ex +++ b/lib/pleroma/web/twitter_api/twitter_api.ex @@ -45,7 +45,6 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do case User.register(changeset) do {:ok, user} -> - maybe_notify_admins(user) {:ok, user} {:error, changeset} -> @@ -58,18 +57,6 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do end end - defp maybe_notify_admins(%User{} = account) do - if Pleroma.Config.get([:instance, :account_approval_required]) do - User.all_superusers() - |> Enum.filter(fn user -> not is_nil(user.email) end) - |> Enum.each(fn superuser -> - superuser - |> Pleroma.Emails.AdminEmail.new_unapproved_registration(account) - |> Pleroma.Emails.Mailer.deliver_async() - end) - end - end - def password_reset(nickname_or_email) do with true <- is_binary(nickname_or_email), %User{local: true, email: email, deactivated: false} = user when is_binary(email) <- -- cgit v1.2.3 From 6ebec50df643325a524926858371d43f44e4c6da Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 12 Oct 2020 16:32:34 -0500 Subject: Refactor User.confirm/1, add more tests --- lib/pleroma/user.ex | 16 ++++++++++++++++ lib/pleroma/web/twitter_api/twitter_api_controller.ex | 6 +----- 2 files changed, 17 insertions(+), 5 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index b56620c54..4329fde12 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1598,6 +1598,22 @@ defmodule Pleroma.User do end end + def confirm(users) when is_list(users) do + Repo.transaction(fn -> + Enum.map(users, fn user -> + with {:ok, user} <- confirm(user), do: user + end) + end) + end + + def confirm(%User{} = user) do + with chg <- confirmation_changeset(user, need_confirmation: false), + {:ok, user} <- update_and_set_cache(chg) do + post_register_action(user) + {:ok, user} + end + end + def update_notification_settings(%User{} = user, settings) do user |> cast(%{notification_settings: settings}, []) diff --git a/lib/pleroma/web/twitter_api/twitter_api_controller.ex b/lib/pleroma/web/twitter_api/twitter_api_controller.ex index 6961118ca..b12606472 100644 --- a/lib/pleroma/web/twitter_api/twitter_api_controller.ex +++ b/lib/pleroma/web/twitter_api/twitter_api_controller.ex @@ -31,11 +31,7 @@ defmodule Pleroma.Web.TwitterAPI.Controller do def confirm_email(conn, %{"user_id" => uid, "token" => token}) do with %User{} = user <- User.get_cached_by_id(uid), true <- user.local and user.confirmation_pending and user.confirmation_token == token, - {:ok, _} <- - user - |> User.confirmation_changeset(need_confirmation: false) - |> User.update_and_set_cache() do - User.post_register_action(user) + {:ok, _} <- User.confirm(user) do redirect(conn, to: "/") end end -- cgit v1.2.3 From cb29769a224104882ed7572087f8cd2db48475ef Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 12 Oct 2020 16:42:59 -0500 Subject: Make User.confirm/1 and User.approve/1 idempotent --- lib/pleroma/user.ex | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 4329fde12..c6767cfca 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1590,7 +1590,7 @@ defmodule Pleroma.User do end) end - def approve(%User{} = user) do + def approve(%User{approval_pending: true} = user) do with chg <- change(user, approval_pending: false), {:ok, user} <- update_and_set_cache(chg) do post_register_action(user) @@ -1598,6 +1598,8 @@ defmodule Pleroma.User do end end + def approve(%User{} = user), do: {:ok, user} + def confirm(users) when is_list(users) do Repo.transaction(fn -> Enum.map(users, fn user -> @@ -1606,7 +1608,7 @@ defmodule Pleroma.User do end) end - def confirm(%User{} = user) do + def confirm(%User{confirmation_pending: true} = user) do with chg <- confirmation_changeset(user, need_confirmation: false), {:ok, user} <- update_and_set_cache(chg) do post_register_action(user) @@ -1614,6 +1616,8 @@ defmodule Pleroma.User do end end + def confirm(%User{} = user), do: {:ok, user} + def update_notification_settings(%User{} = user, settings) do user |> cast(%{notification_settings: settings}, []) -- cgit v1.2.3 From 66e00ace7c0708f2f9361bc6e1008ccea08cb6ef Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 12 Oct 2020 17:21:08 -0500 Subject: Refactor User.post_register_action/1 emails --- lib/pleroma/user.ex | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index c6767cfca..0978cc02c 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -779,12 +779,31 @@ defmodule Pleroma.User do end def post_register_action(%User{approval_pending: true} = user) do - # Send approval pending email to user + with {:ok, _} <- send_user_approval_email(user), + {:ok, _} <- send_admin_approval_emails(user) do + {:ok, user} + end + end + + def post_register_action(%User{approval_pending: false, confirmation_pending: false} = user) do + with {:ok, user} <- autofollow_users(user), + {:ok, user} <- set_cache(user), + {:ok, _} <- send_welcome_email(user), + {:ok, _} <- send_welcome_message(user), + {:ok, _} <- send_welcome_chat_message(user) do + {:ok, user} + end + end + + defp send_user_approval_email(user) do user |> Pleroma.Emails.UserEmail.approval_pending_email() |> Pleroma.Emails.Mailer.deliver_async() - # Notify admins + {:ok, :enqueued} + end + + defp send_admin_approval_emails(user) do all_superusers() |> Enum.filter(fn user -> not is_nil(user.email) end) |> Enum.each(fn superuser -> @@ -793,17 +812,7 @@ defmodule Pleroma.User do |> Pleroma.Emails.Mailer.deliver_async() end) - {:ok, user} - end - - def post_register_action(%User{approval_pending: false, confirmation_pending: false} = user) do - with {:ok, user} <- autofollow_users(user), - {:ok, user} <- set_cache(user), - {:ok, _} <- send_welcome_email(user), - {:ok, _} <- send_welcome_message(user), - {:ok, _} <- send_welcome_chat_message(user) do - {:ok, user} - end + {:ok, :enqueued} end def send_welcome_message(user) do -- cgit v1.2.3 From 8bacdc36806efd01a7897359ff0fd2c8e24730d2 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 13 Oct 2020 09:45:08 -0500 Subject: Change user.discoverable field to user.is_discoverable --- lib/pleroma/user.ex | 8 ++++---- lib/pleroma/user/search.ex | 2 +- lib/pleroma/web/activity_pub/activity_pub.ex | 4 ++-- lib/pleroma/web/activity_pub/views/user_view.ex | 2 +- lib/pleroma/web/admin_api/views/account_view.ex | 2 +- lib/pleroma/web/api_spec/operations/account_operation.ex | 4 ++-- lib/pleroma/web/api_spec/operations/chat_operation.ex | 2 +- lib/pleroma/web/api_spec/schemas/account.ex | 4 ++-- lib/pleroma/web/api_spec/schemas/chat.ex | 2 +- lib/pleroma/web/api_spec/schemas/status.ex | 2 +- lib/pleroma/web/mastodon_api/controllers/account_controller.ex | 2 +- lib/pleroma/web/mastodon_api/views/account_view.ex | 2 +- lib/pleroma/web/metadata/restrict_indexing.ex | 2 +- 13 files changed, 19 insertions(+), 19 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 09ea80793..c4640f928 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -136,7 +136,7 @@ defmodule Pleroma.User do field(:pleroma_settings_store, :map, default: %{}) field(:fields, {:array, :map}, default: []) field(:raw_fields, {:array, :map}, default: []) - field(:discoverable, :boolean, default: false) + field(:is_discoverable, :boolean, default: false) field(:invisible, :boolean, default: false) field(:allow_following_move, :boolean, default: true) field(:skip_thread_containment, :boolean, default: false) @@ -448,7 +448,7 @@ defmodule Pleroma.User do :follower_count, :fields, :following_count, - :discoverable, + :is_discoverable, :invisible, :actor_type, :also_known_as, @@ -495,7 +495,7 @@ defmodule Pleroma.User do :fields, :raw_fields, :pleroma_settings_store, - :discoverable, + :is_discoverable, :actor_type, :also_known_as, :accepts_chat_messages @@ -1618,7 +1618,7 @@ defmodule Pleroma.User do pleroma_settings_store: %{}, fields: [], raw_fields: [], - discoverable: false, + is_discoverable: false, also_known_as: [] }) end diff --git a/lib/pleroma/user/search.ex b/lib/pleroma/user/search.ex index 35a828008..2dab67211 100644 --- a/lib/pleroma/user/search.ex +++ b/lib/pleroma/user/search.ex @@ -164,7 +164,7 @@ defmodule Pleroma.User.Search do end defp filter_discoverable_users(query) do - from(q in query, where: q.discoverable == true) + from(q in query, where: q.is_discoverable == true) end defp filter_internal_users(query) do diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index eb44cffec..236fefbb6 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1232,7 +1232,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do capabilities = data["capabilities"] || %{} accepts_chat_messages = capabilities["acceptsChatMessages"] data = Transmogrifier.maybe_fix_user_object(data) - discoverable = data["discoverable"] || false + is_discoverable = data["is_discoverable"] || false invisible = data["invisible"] || false actor_type = data["type"] || "Person" @@ -1258,7 +1258,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do fields: fields, emoji: emojis, locked: locked, - discoverable: discoverable, + is_discoverable: is_discoverable, invisible: invisible, avatar: avatar, name: data["name"], diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index 3a4564912..81cd7e81d 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -110,7 +110,7 @@ defmodule Pleroma.Web.ActivityPub.UserView do "endpoints" => endpoints, "attachment" => fields, "tag" => emoji_tags, - "discoverable" => user.discoverable, + "is_discoverable" => user.is_discoverable, "capabilities" => capabilities } |> Map.merge(maybe_make_image(&User.avatar_url/2, "icon", user)) diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index 9c477feab..5f3a78a0f 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -52,7 +52,7 @@ defmodule Pleroma.Web.AdminAPI.AccountView do :skip_thread_containment, :pleroma_settings_store, :raw_fields, - :discoverable, + :is_discoverable, :actor_type ]) |> Map.merge(%{ diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index d90ddb787..1696b19a5 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -606,7 +606,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do description: "Sets the background image of the user.", format: :binary }, - discoverable: %Schema{ + is_discoverable: %Schema{ allOf: [BooleanLike], nullable: true, description: @@ -630,7 +630,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do pleroma_settings_store: %{"pleroma-fe" => %{"key" => "val"}}, skip_thread_containment: false, allow_following_move: false, - discoverable: false, + is_discoverable: false, actor_type: "Person" } } diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 0dcfdb354..18693a5d7 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -253,7 +253,7 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do "sensitive" => false, "note" => "lain", "pleroma" => %{ - "discoverable" => false, + "is_discoverable" => false, "actor_type" => "Person" }, "fields" => [] diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index ca79f0747..459e7dba6 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -124,7 +124,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do type: :object, properties: %{ actor_type: ActorType, - discoverable: %Schema{ + is_discoverable: %Schema{ type: :boolean, description: "whether the user allows discovery of the account in search results and other services." @@ -205,7 +205,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do "note" => "foobar", "pleroma" => %{ "actor_type" => "Person", - "discoverable" => false, + "is_discoverable" => false, "no_rich_text" => false, "show_role" => true }, diff --git a/lib/pleroma/web/api_spec/schemas/chat.ex b/lib/pleroma/web/api_spec/schemas/chat.ex index b4986b734..da245d0de 100644 --- a/lib/pleroma/web/api_spec/schemas/chat.ex +++ b/lib/pleroma/web/api_spec/schemas/chat.ex @@ -44,7 +44,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Chat do "sensitive" => false, "note" => "lain", "pleroma" => %{ - "discoverable" => false, + "is_discoverable" => false, "actor_type" => "Person" }, "fields" => [] diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index 947e42890..52b870d63 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -284,7 +284,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do "source" => %{ "fields" => [], "note" => "Tester Number 6", - "pleroma" => %{"actor_type" => "Person", "discoverable" => false}, + "pleroma" => %{"actor_type" => "Person", "is_discoverable" => false}, "sensitive" => false }, "statuses_count" => 1, diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 95d8452df..e06ff9307 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -186,7 +186,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do :show_role, :skip_thread_containment, :allow_following_move, - :discoverable, + :is_discoverable, :accepts_chat_messages ] |> Enum.reduce(%{}, fn key, acc -> diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 121ba1693..0636d9cc1 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -261,7 +261,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do sensitive: false, fields: user.raw_fields, pleroma: %{ - discoverable: user.discoverable, + is_discoverable: user.is_discoverable, actor_type: user.actor_type } }, diff --git a/lib/pleroma/web/metadata/restrict_indexing.ex b/lib/pleroma/web/metadata/restrict_indexing.ex index a1dcb6e15..900c2434d 100644 --- a/lib/pleroma/web/metadata/restrict_indexing.ex +++ b/lib/pleroma/web/metadata/restrict_indexing.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Web.Metadata.Providers.RestrictIndexing do """ @impl true - def build_tags(%{user: %{local: true, discoverable: true}}), do: [] + def build_tags(%{user: %{local: true, is_discoverable: true}}), do: [] def build_tags(_) do [ -- cgit v1.2.3 From 33f4f39b1cf3a6d8ce350da194696b19ca6f3a05 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 13 Oct 2020 21:39:41 +0400 Subject: Add pagination for Blocks --- lib/pleroma/web/api_spec/operations/account_operation.ex | 1 + .../web/mastodon_api/controllers/account_controller.ex | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index d90ddb787..9cd516f05 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -348,6 +348,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do operationId: "AccountController.blocks", description: "View your blocks. See also accounts/:id/{block,unblock}", security: [%{"oAuth" => ["read:blocks"]}], + parameters: pagination_params(), responses: %{ 200 => Operation.response("Accounts", "application/json", array_of_accounts()) } diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 4f9696d52..1b221e3a1 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -448,9 +448,15 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do end @doc "GET /api/v1/blocks" - def blocks(%{assigns: %{user: user}} = conn, _) do - users = User.blocked_users(user, _restrict_deactivated = true) - render(conn, "index.json", users: users, for: user, as: :user) + def blocks(%{assigns: %{user: user}} = conn, params) do + users = + user + |> User.blocked_users_relation(_restrict_deactivated = true) + |> Pleroma.Pagination.fetch_paginated(Map.put(params, :skip_order, true)) + + conn + |> add_link_headers(users) + |> render("index.json", users: users, for: user, as: :user) end @doc "GET /api/v1/endorsements" -- cgit v1.2.3 From 6734abcbd448b92d57ec376b796fea1fad18b792 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 13 Oct 2020 21:58:18 +0400 Subject: Add pagination for Mutes --- lib/pleroma/web/api_spec/operations/account_operation.ex | 1 + .../web/mastodon_api/controllers/account_controller.ex | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 9cd516f05..4934b7788 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -335,6 +335,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do operationId: "AccountController.mutes", description: "Accounts the user has muted.", security: [%{"oAuth" => ["follow", "read:mutes"]}], + parameters: pagination_params(), responses: %{ 200 => Operation.response("Accounts", "application/json", array_of_accounts()) } diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 1b221e3a1..c8606e5d6 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -442,9 +442,15 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do end @doc "GET /api/v1/mutes" - def mutes(%{assigns: %{user: user}} = conn, _) do - users = User.muted_users(user, _restrict_deactivated = true) - render(conn, "index.json", users: users, for: user, as: :user) + def mutes(%{assigns: %{user: user}} = conn, params) do + users = + user + |> User.muted_users_relation(_restrict_deactivated = true) + |> Pleroma.Pagination.fetch_paginated(Map.put(params, :skip_order, true)) + + conn + |> add_link_headers(users) + |> render("index.json", users: users, for: user, as: :user) end @doc "GET /api/v1/blocks" -- cgit v1.2.3 From dc38dc847207e4724265fbeb111d0a236b75f93f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 13 Oct 2020 21:52:06 -0500 Subject: Replace User.toggle_confirmation/1 with User.confirm/1, fixes #2235 --- lib/mix/tasks/pleroma/user.ex | 4 ++-- lib/pleroma/user.ex | 12 ------------ .../web/admin_api/controllers/admin_api_controller.ex | 2 +- 3 files changed, 3 insertions(+), 15 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex index e06262804..c454f1d28 100644 --- a/lib/mix/tasks/pleroma/user.ex +++ b/lib/mix/tasks/pleroma/user.ex @@ -345,11 +345,11 @@ defmodule Mix.Tasks.Pleroma.User do end end - def run(["toggle_confirmed", nickname]) do + def run(["confirm", nickname]) do start_pleroma() with %User{} = user <- User.get_cached_by_nickname(nickname) do - {:ok, user} = User.toggle_confirmation(user) + {:ok, user} = User.confirm(user) message = if user.confirmation_pending, do: "needs", else: "doesn't need" diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 0978cc02c..0dabb2a1e 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -2113,18 +2113,6 @@ defmodule Pleroma.User do updated_user end - @spec toggle_confirmation(User.t()) :: {:ok, User.t()} | {:error, Changeset.t()} - def toggle_confirmation(%User{} = user) do - user - |> confirmation_changeset(need_confirmation: !user.confirmation_pending) - |> update_and_set_cache() - end - - @spec toggle_confirmation([User.t()]) :: [{:ok, User.t()} | {:error, Changeset.t()}] - def toggle_confirmation(users) do - Enum.map(users, &toggle_confirmation/1) - end - @spec need_confirmation(User.t(), boolean()) :: {:ok, User.t()} | {:error, Changeset.t()} def need_confirmation(%User{} = user, bool) do user diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index bdd3e195d..c2bd441ee 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -655,7 +655,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do def confirm_email(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do users = Enum.map(nicknames, &User.get_cached_by_nickname/1) - User.toggle_confirmation(users) + User.confirm(users) ModerationLog.insert_log(%{actor: admin, subject: users, action: "confirm_email"}) -- cgit v1.2.3 From 3242cfef20ea05bae53761ffaf54347c4c6f7bda Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 14 Oct 2020 09:54:07 -0500 Subject: Undo API breaking changes --- lib/pleroma/web/api_spec/operations/account_operation.ex | 4 ++-- lib/pleroma/web/api_spec/operations/chat_operation.ex | 2 +- lib/pleroma/web/api_spec/schemas/account.ex | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 1696b19a5..d90ddb787 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -606,7 +606,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do description: "Sets the background image of the user.", format: :binary }, - is_discoverable: %Schema{ + discoverable: %Schema{ allOf: [BooleanLike], nullable: true, description: @@ -630,7 +630,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do pleroma_settings_store: %{"pleroma-fe" => %{"key" => "val"}}, skip_thread_containment: false, allow_following_move: false, - is_discoverable: false, + discoverable: false, actor_type: "Person" } } diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 18693a5d7..0dcfdb354 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -253,7 +253,7 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do "sensitive" => false, "note" => "lain", "pleroma" => %{ - "is_discoverable" => false, + "discoverable" => false, "actor_type" => "Person" }, "fields" => [] diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index 459e7dba6..ca79f0747 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -124,7 +124,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do type: :object, properties: %{ actor_type: ActorType, - is_discoverable: %Schema{ + discoverable: %Schema{ type: :boolean, description: "whether the user allows discovery of the account in search results and other services." @@ -205,7 +205,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do "note" => "foobar", "pleroma" => %{ "actor_type" => "Person", - "is_discoverable" => false, + "discoverable" => false, "no_rich_text" => false, "show_role" => true }, -- cgit v1.2.3 From b001237b79d50eef1f0636951cddc7b6da20260d Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 14 Oct 2020 10:44:18 -0500 Subject: Finish undoing API breakage --- lib/pleroma/web/activity_pub/views/user_view.ex | 2 +- lib/pleroma/web/mastodon_api/controllers/account_controller.ex | 2 +- lib/pleroma/web/mastodon_api/views/account_view.ex | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index 81cd7e81d..e57c91149 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -110,7 +110,7 @@ defmodule Pleroma.Web.ActivityPub.UserView do "endpoints" => endpoints, "attachment" => fields, "tag" => emoji_tags, - "is_discoverable" => user.is_discoverable, + "discoverable" => user.is_discoverable, "capabilities" => capabilities } |> Map.merge(maybe_make_image(&User.avatar_url/2, "icon", user)) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 6453880dc..9d2f42da9 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -186,7 +186,6 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do :show_role, :skip_thread_containment, :allow_following_move, - :is_discoverable, :accepts_chat_messages ] |> Enum.reduce(%{}, fn key, acc -> @@ -210,6 +209,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do if bot, do: {:ok, "Service"}, else: {:ok, "Person"} end) |> Maps.put_if_present(:actor_type, params[:actor_type]) + |> Maps.put_if_present(:is_discoverable, params[:discoverable]) # What happens here: # diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 0636d9cc1..d57537ee3 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -261,7 +261,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do sensitive: false, fields: user.raw_fields, pleroma: %{ - is_discoverable: user.is_discoverable, + discoverable: user.is_discoverable, actor_type: user.actor_type } }, -- cgit v1.2.3 From 77bca415951be516f1b48dc6346600856c96ef5c Mon Sep 17 00:00:00 2001 From: feld Date: Wed, 14 Oct 2020 19:33:54 +0000 Subject: Apply 3 suggestion(s) to 3 file(s) --- lib/pleroma/web/activity_pub/activity_pub.ex | 2 +- lib/pleroma/web/api_spec/schemas/chat.ex | 2 +- lib/pleroma/web/api_spec/schemas/status.ex | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 236fefbb6..b04b72106 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1232,7 +1232,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do capabilities = data["capabilities"] || %{} accepts_chat_messages = capabilities["acceptsChatMessages"] data = Transmogrifier.maybe_fix_user_object(data) - is_discoverable = data["is_discoverable"] || false + is_discoverable = data["discoverable"] || false invisible = data["invisible"] || false actor_type = data["type"] || "Person" diff --git a/lib/pleroma/web/api_spec/schemas/chat.ex b/lib/pleroma/web/api_spec/schemas/chat.ex index da245d0de..b4986b734 100644 --- a/lib/pleroma/web/api_spec/schemas/chat.ex +++ b/lib/pleroma/web/api_spec/schemas/chat.ex @@ -44,7 +44,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Chat do "sensitive" => false, "note" => "lain", "pleroma" => %{ - "is_discoverable" => false, + "discoverable" => false, "actor_type" => "Person" }, "fields" => [] diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index 52b870d63..947e42890 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -284,7 +284,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do "source" => %{ "fields" => [], "note" => "Tester Number 6", - "pleroma" => %{"actor_type" => "Person", "is_discoverable" => false}, + "pleroma" => %{"actor_type" => "Person", "discoverable" => false}, "sensitive" => false }, "statuses_count" => 1, -- cgit v1.2.3 From 7a2f100061913ec59db9957893eb92bb2d150dc4 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 15 Oct 2020 12:28:25 +0200 Subject: ActivityPub: Show own replies to muted users. Aligns mute with block behavior. --- lib/pleroma/web/activity_pub/activity_pub.ex | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 8022f0402..ff7b9e778 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -827,7 +827,14 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do query = from([activity] in query, where: fragment("not (? = ANY(?))", activity.actor, ^mutes), - where: fragment("not (?->'to' \\?| ?)", activity.data, ^mutes) + where: + fragment( + "not (?->'to' \\?| ?) or ? = ?", + activity.data, + ^mutes, + activity.actor, + ^user.ap_id + ) ) unless opts[:skip_preload] do -- cgit v1.2.3 From 3985c1b4501010b72b94072951f2ff64c0fd6310 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 15 Oct 2020 16:54:59 +0400 Subject: Fix warnings --- lib/pleroma/web/templates/layout/app.html.eex | 2 +- lib/pleroma/web/templates/layout/email_styled.html.eex | 2 +- lib/pleroma/web/templates/layout/metadata_player.html.eex | 2 +- lib/pleroma/web/templates/layout/static_fe.html.eex | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/templates/layout/app.html.eex b/lib/pleroma/web/templates/layout/app.html.eex index 51603fe0c..3f28f1920 100644 --- a/lib/pleroma/web/templates/layout/app.html.eex +++ b/lib/pleroma/web/templates/layout/app.html.eex @@ -228,7 +228,7 @@

<%= Pleroma.Config.get([:instance, :name]) %>

- <%= render @view_module, @view_template, assigns %> + <%= @inner_content %>
diff --git a/lib/pleroma/web/templates/layout/email_styled.html.eex b/lib/pleroma/web/templates/layout/email_styled.html.eex index ca2caaf4d..82cabd889 100644 --- a/lib/pleroma/web/templates/layout/email_styled.html.eex +++ b/lib/pleroma/web/templates/layout/email_styled.html.eex @@ -181,7 +181,7 @@ <% end %> - <%= render @view_module, @view_template, assigns %> + <%= @inner_content %> diff --git a/lib/pleroma/web/templates/layout/metadata_player.html.eex b/lib/pleroma/web/templates/layout/metadata_player.html.eex index 460f28094..c00f6fa21 100644 --- a/lib/pleroma/web/templates/layout/metadata_player.html.eex +++ b/lib/pleroma/web/templates/layout/metadata_player.html.eex @@ -10,7 +10,7 @@ video, audio { } -<%= render @view_module, @view_template, assigns %> +<%= @inner_content %> diff --git a/lib/pleroma/web/templates/layout/static_fe.html.eex b/lib/pleroma/web/templates/layout/static_fe.html.eex index dc0ee2a5c..e6adb526b 100644 --- a/lib/pleroma/web/templates/layout/static_fe.html.eex +++ b/lib/pleroma/web/templates/layout/static_fe.html.eex @@ -9,7 +9,7 @@
- <%= render @view_module, @view_template, assigns %> + <%= @inner_content %>
-- cgit v1.2.3 From 4f79bbbc31c10c1d55c9fee4002f36ef16b95dbf Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 2 Oct 2020 21:00:50 +0400 Subject: Add local-only statuses --- lib/pleroma/activity.ex | 10 ++ lib/pleroma/web/activity_pub/builder.ex | 3 + .../object_validators/announce_validator.ex | 7 +- lib/pleroma/web/activity_pub/pipeline.ex | 2 +- lib/pleroma/web/activity_pub/utils.ex | 3 +- lib/pleroma/web/activity_pub/visibility.ex | 6 +- .../web/api_spec/operations/status_operation.ex | 4 + lib/pleroma/web/common_api.ex | 25 +---- lib/pleroma/web/common_api/activity_draft.ex | 77 +++++++------- lib/pleroma/web/common_api/utils.ex | 113 ++++++++------------- lib/pleroma/web/mastodon_api/views/status_view.ex | 3 +- 11 files changed, 122 insertions(+), 131 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 17af04257..789655ba2 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -18,6 +18,8 @@ defmodule Pleroma.Activity do import Ecto.Changeset import Ecto.Query + require Pleroma.Constants + @type t :: %__MODULE__{} @type actor :: String.t() @@ -343,4 +345,12 @@ defmodule Pleroma.Activity do actor = user_actor(activity) activity.id in actor.pinned_activities end + + def local_only?(activity) do + recipients = Enum.concat(activity.data["to"], Map.get(activity.data, "cc", [])) + public = Pleroma.Constants.as_public() + local = Pleroma.Web.base_url() <> "/#Public" + + Enum.member?(recipients, local) and not Enum.member?(recipients, public) + end end diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 298aff6b7..236a5b9d1 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -222,6 +222,9 @@ defmodule Pleroma.Web.ActivityPub.Builder do actor.ap_id == Relay.ap_id() -> [actor.follower_address] + public? and Pleroma.Activity.local_only?(object) -> + [actor.follower_address, object.data["actor"], Pleroma.Web.base_url() <> "/#Public"] + public? -> [actor.follower_address, object.data["actor"], Pleroma.Constants.as_public()] diff --git a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex index 6f757f49c..5a963fca7 100644 --- a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex @@ -67,7 +67,12 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator do %Object{} = object <- Object.get_cached_by_ap_id(object), false <- Visibility.is_public?(object) do same_actor = object.data["actor"] == actor.ap_id - is_public = Pleroma.Constants.as_public() in (get_field(cng, :to) ++ get_field(cng, :cc)) + recipients = get_field(cng, :to) ++ get_field(cng, :cc) + local_public = Pleroma.Web.base_url() <> "/#Public" + + is_public = + Enum.member?(recipients, Pleroma.Constants.as_public()) or + Enum.member?(recipients, local_public) cond do same_actor && is_public -> diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 2db86f116..559c8387e 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -55,7 +55,7 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do with {:ok, local} <- Keyword.fetch(meta, :local) do do_not_federate = meta[:do_not_federate] || !Config.get([:instance, :federating]) - if !do_not_federate && local do + if !do_not_federate and local and not Activity.local_only?(activity) do activity = if object = Keyword.get(meta, :object_data) do %{activity | data: Map.put(activity.data, "object", object)} diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index 713b0ca1f..faf3bea00 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -175,7 +175,8 @@ defmodule Pleroma.Web.ActivityPub.Utils do outgoing_blocks = Config.get([:activitypub, :outgoing_blocks]) with true <- Config.get!([:instance, :federating]), - true <- type != "Block" || outgoing_blocks do + true <- type != "Block" || outgoing_blocks, + false <- Activity.local_only?(activity) do Pleroma.Web.Federator.publish(activity) end diff --git a/lib/pleroma/web/activity_pub/visibility.ex b/lib/pleroma/web/activity_pub/visibility.ex index 5c349bb7a..3654b489b 100644 --- a/lib/pleroma/web/activity_pub/visibility.ex +++ b/lib/pleroma/web/activity_pub/visibility.ex @@ -17,7 +17,11 @@ defmodule Pleroma.Web.ActivityPub.Visibility do def is_public?(%Activity{data: %{"type" => "Move"}}), do: true def is_public?(%Activity{data: data}), do: is_public?(data) def is_public?(%{"directMessage" => true}), do: false - def is_public?(data), do: Utils.label_in_message?(Pleroma.Constants.as_public(), data) + + def is_public?(data) do + Utils.label_in_message?(Pleroma.Constants.as_public(), data) or + Utils.label_in_message?(Pleroma.Web.base_url() <> "/#Public", data) + end def is_private?(activity) do with false <- is_public?(activity), diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index d7ebde6f6..e989e4f5f 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -475,6 +475,10 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do type: :string, description: "Will reply to a given conversation, addressing only the people who are part of the recipient set of that conversation. Sets the visibility to `direct`." + }, + local_only: %Schema{ + type: :boolean, + description: "Post the status as local only" } }, example: %{ diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex index 60a50b027..e5c66eea3 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -15,6 +15,7 @@ defmodule Pleroma.Web.CommonAPI do alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.CommonAPI.ActivityDraft import Pleroma.Web.Gettext import Pleroma.Web.CommonAPI.Utils @@ -398,31 +399,13 @@ defmodule Pleroma.Web.CommonAPI do end def listen(user, data) do - visibility = Map.get(data, :visibility, "public") - - with {to, cc} <- get_to_and_cc(user, [], nil, visibility, nil), - listen_data <- - data - |> Map.take([:album, :artist, :title, :length]) - |> Map.new(fn {key, value} -> {to_string(key), value} end) - |> Map.put("type", "Audio") - |> Map.put("to", to) - |> Map.put("cc", cc) - |> Map.put("actor", user.ap_id), - {:ok, activity} <- - ActivityPub.listen(%{ - actor: user, - to: to, - object: listen_data, - context: Utils.generate_context_id(), - additional: %{"cc" => cc} - }) do - {:ok, activity} + with {:ok, draft} <- ActivityDraft.listen(user, data) do + ActivityPub.listen(draft.changes) end end def post(user, %{status: _} = data) do - with {:ok, draft} <- Pleroma.Web.CommonAPI.ActivityDraft.create(user, data) do + with {:ok, draft} <- ActivityDraft.create(user, data) do ActivityPub.create(draft.changes, draft.preview?) end end diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index 548f76609..aa2616d9e 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -22,7 +22,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do in_reply_to_conversation: nil, visibility: nil, expires_at: nil, - poll: nil, + extra: nil, emoji: %{}, content_html: nil, mentions: [], @@ -35,9 +35,14 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do preview?: false, changes: %{} - def create(user, params) do + def new(user, params) do %__MODULE__{user: user} |> put_params(params) + end + + def create(user, params) do + user + |> new(params) |> status() |> summary() |> with_valid(&attachments/1) @@ -57,6 +62,30 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do |> validate() end + def listen(user, params) do + user + |> new(params) + |> visibility() + |> to_and_cc() + |> context() + |> listen_object() + |> with_valid(&changes/1) + |> validate() + end + + defp listen_object(draft) do + object = + draft.params + |> Map.take([:album, :artist, :title, :length]) + |> Map.new(fn {key, value} -> {to_string(key), value} end) + |> Map.put("type", "Audio") + |> Map.put("to", draft.to) + |> Map.put("cc", draft.cc) + |> Map.put("actor", draft.user.ap_id) + + %__MODULE__{draft | object: object} + end + defp put_params(draft, params) do params = Map.put_new(params, :in_reply_to_status_id, params[:in_reply_to_id]) %__MODULE__{draft | params: params} @@ -121,7 +150,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do defp poll(draft) do case Utils.make_poll_data(draft.params) do {:ok, {poll, poll_emoji}} -> - %__MODULE__{draft | poll: poll, emoji: Map.merge(draft.emoji, poll_emoji)} + %__MODULE__{draft | extra: poll, emoji: Map.merge(draft.emoji, poll_emoji)} {:error, message} -> add_error(draft, message) @@ -129,32 +158,18 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do end defp content(draft) do - {content_html, mentions, tags} = - Utils.make_content_html( - draft.status, - draft.attachments, - draft.params, - draft.visibility - ) - - %__MODULE__{draft | content_html: content_html, mentions: mentions, tags: tags} - end + {content_html, mentioned_users, tags} = Utils.make_content_html(draft) - defp to_and_cc(draft) do - addressed_users = - draft.mentions + mentions = + mentioned_users |> Enum.map(fn {_, mentioned_user} -> mentioned_user.ap_id end) |> Utils.get_addressed_users(draft.params[:to]) - {to, cc} = - Utils.get_to_and_cc( - draft.user, - addressed_users, - draft.in_reply_to, - draft.visibility, - draft.in_reply_to_conversation - ) + %__MODULE__{draft | content_html: content_html, mentions: mentions, tags: tags} + end + defp to_and_cc(draft) do + {to, cc} = Utils.get_to_and_cc(draft) %__MODULE__{draft | to: to, cc: cc} end @@ -172,19 +187,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do emoji = Map.merge(Pleroma.Emoji.Formatter.get_emoji_map(draft.full_payload), draft.emoji) object = - Utils.make_note_data( - draft.user.ap_id, - draft.to, - draft.context, - draft.content_html, - draft.attachments, - draft.in_reply_to, - draft.tags, - draft.summary, - draft.cc, - draft.sensitive, - draft.poll - ) + Utils.make_note_data(draft) |> Map.put("emoji", emoji) |> Map.put("source", draft.status) diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 21f4d43e9..7c49c1fb1 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -16,6 +16,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do alias Pleroma.User alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.CommonAPI.ActivityDraft alias Pleroma.Web.MediaProxy alias Pleroma.Web.Plugs.AuthenticationPlug @@ -50,67 +51,60 @@ defmodule Pleroma.Web.CommonAPI.Utils do {_, descs} = Jason.decode(descs_str) Enum.map(ids, fn media_id -> - case Repo.get(Object, media_id) do - %Object{data: data} -> - Map.put(data, "name", descs[media_id]) - - _ -> - nil + with %Object{data: data} <- Repo.get(Object, media_id) do + Map.put(data, "name", descs[media_id]) end end) |> Enum.reject(&is_nil/1) end - @spec get_to_and_cc( - User.t(), - list(String.t()), - Activity.t() | nil, - String.t(), - Participation.t() | nil - ) :: {list(String.t()), list(String.t())} + @spec get_to_and_cc(ActivityDraft.t()) :: {list(String.t()), list(String.t())} - def get_to_and_cc(_, _, _, _, %Participation{} = participation) do + def get_to_and_cc(%{in_reply_to_conversation: %Participation{} = participation}) do participation = Repo.preload(participation, :recipients) {Enum.map(participation.recipients, & &1.ap_id), []} end - def get_to_and_cc(user, mentioned_users, inReplyTo, "public", _) do - to = [Pleroma.Constants.as_public() | mentioned_users] - cc = [user.follower_address] + def get_to_and_cc(%{visibility: "public"} = draft) do + to = [public_uri(draft) | draft.mentions] + cc = [draft.user.follower_address] - if inReplyTo do - {Enum.uniq([inReplyTo.data["actor"] | to]), cc} + if draft.in_reply_to do + {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc} else {to, cc} end end - def get_to_and_cc(user, mentioned_users, inReplyTo, "unlisted", _) do - to = [user.follower_address | mentioned_users] - cc = [Pleroma.Constants.as_public()] + def get_to_and_cc(%{visibility: "unlisted"} = draft) do + to = [draft.user.follower_address | draft.mentions] + cc = [public_uri(draft)] - if inReplyTo do - {Enum.uniq([inReplyTo.data["actor"] | to]), cc} + if draft.in_reply_to do + {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc} else {to, cc} end end - def get_to_and_cc(user, mentioned_users, inReplyTo, "private", _) do - {to, cc} = get_to_and_cc(user, mentioned_users, inReplyTo, "direct", nil) - {[user.follower_address | to], cc} + def get_to_and_cc(%{visibility: "private"} = draft) do + {to, cc} = get_to_and_cc(struct(draft, visibility: "direct")) + {[draft.user.follower_address | to], cc} end - def get_to_and_cc(_user, mentioned_users, inReplyTo, "direct", _) do + def get_to_and_cc(%{visibility: "direct"} = draft) do # If the OP is a DM already, add the implicit actor. - if inReplyTo && Visibility.is_direct?(inReplyTo) do - {Enum.uniq([inReplyTo.data["actor"] | mentioned_users]), []} + if draft.in_reply_to && Visibility.is_direct?(draft.in_reply_to) do + {Enum.uniq([draft.in_reply_to.data["actor"] | draft.mentions]), []} else - {mentioned_users, []} + {draft.mentions, []} end end - def get_to_and_cc(_user, mentions, _inReplyTo, {:list, _}, _), do: {mentions, []} + def get_to_and_cc(%{visibility: {:list, _}, mentions: mentions}), do: {mentions, []} + + defp public_uri(%{params: %{local_only: true}}), do: Pleroma.Web.base_url() <> "/#Public" + defp public_uri(_), do: Pleroma.Constants.as_public() def get_addressed_users(_, to) when is_list(to) do User.get_ap_ids_by_nicknames(to) @@ -203,30 +197,25 @@ defmodule Pleroma.Web.CommonAPI.Utils do end end - def make_content_html( - status, - attachments, - data, - visibility - ) do + def make_content_html(%ActivityDraft{} = draft) do attachment_links = - data + draft.params |> Map.get("attachment_links", Config.get([:instance, :attachment_links])) |> truthy_param?() - content_type = get_content_type(data[:content_type]) + content_type = get_content_type(draft.params[:content_type]) options = - if visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do + if draft.visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do [safe_mention: true] else [] end - status + draft.status |> format_input(content_type, options) - |> maybe_add_attachments(attachments, attachment_links) - |> maybe_add_nsfw_tag(data) + |> maybe_add_attachments(draft.attachments, attachment_links) + |> maybe_add_nsfw_tag(draft.params) end defp get_content_type(content_type) do @@ -317,33 +306,21 @@ defmodule Pleroma.Web.CommonAPI.Utils do |> Formatter.html_escape("text/html") end - def make_note_data( - actor, - to, - context, - content_html, - attachments, - in_reply_to, - tags, - summary \\ nil, - cc \\ [], - sensitive \\ false, - extra_params \\ %{} - ) do + def make_note_data(%ActivityDraft{} = draft) do %{ "type" => "Note", - "to" => to, - "cc" => cc, - "content" => content_html, - "summary" => summary, - "sensitive" => truthy_param?(sensitive), - "context" => context, - "attachment" => attachments, - "actor" => actor, - "tag" => Keyword.values(tags) |> Enum.uniq() + "to" => draft.to, + "cc" => draft.cc, + "content" => draft.content_html, + "summary" => draft.summary, + "sensitive" => draft.sensitive, + "context" => draft.context, + "attachment" => draft.attachments, + "actor" => draft.user.ap_id, + "tag" => Keyword.values(draft.tags) |> Enum.uniq() } - |> add_in_reply_to(in_reply_to) - |> Map.merge(extra_params) + |> add_in_reply_to(draft.in_reply_to) + |> Map.merge(draft.extra) end defp add_in_reply_to(object, nil), do: object diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 435bcde15..0fc78972e 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -368,7 +368,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do direct_conversation_id: direct_conversation_id, thread_muted: thread_muted?, emoji_reactions: emoji_reactions, - parent_visible: visible_for_user?(reply_to, opts[:for]) + parent_visible: visible_for_user?(reply_to, opts[:for]), + local_only: Activity.local_only?(activity) } } end -- cgit v1.2.3 From 2a475622eea5550c9ab455c4e86212fc09ee9c58 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 15 Oct 2020 19:07:00 +0400 Subject: Add Pleroma.Constants.as_local_public/0 --- lib/pleroma/activity.ex | 2 +- lib/pleroma/constants.ex | 2 ++ lib/pleroma/web/activity_pub/builder.ex | 2 +- lib/pleroma/web/activity_pub/object_validators/announce_validator.ex | 2 +- lib/pleroma/web/activity_pub/visibility.ex | 2 +- lib/pleroma/web/common_api/utils.ex | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 789655ba2..3b01f5e31 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -349,7 +349,7 @@ defmodule Pleroma.Activity do def local_only?(activity) do recipients = Enum.concat(activity.data["to"], Map.get(activity.data, "cc", [])) public = Pleroma.Constants.as_public() - local = Pleroma.Web.base_url() <> "/#Public" + local = Pleroma.Constants.as_local_public() Enum.member?(recipients, local) and not Enum.member?(recipients, public) end diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index 13eeaa96b..cf8182d55 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -26,4 +26,6 @@ defmodule Pleroma.Constants do do: ~w(index.html robots.txt static static-fe finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc embed.js embed.css) ) + + def as_local_public, do: Pleroma.Web.base_url() <> "/#Public" end diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 236a5b9d1..c9200a3f0 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -223,7 +223,7 @@ defmodule Pleroma.Web.ActivityPub.Builder do [actor.follower_address] public? and Pleroma.Activity.local_only?(object) -> - [actor.follower_address, object.data["actor"], Pleroma.Web.base_url() <> "/#Public"] + [actor.follower_address, object.data["actor"], Pleroma.Constants.as_local_public()] public? -> [actor.follower_address, object.data["actor"], Pleroma.Constants.as_public()] diff --git a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex index 5a963fca7..338957db8 100644 --- a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex @@ -68,7 +68,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator do false <- Visibility.is_public?(object) do same_actor = object.data["actor"] == actor.ap_id recipients = get_field(cng, :to) ++ get_field(cng, :cc) - local_public = Pleroma.Web.base_url() <> "/#Public" + local_public = Pleroma.Constants.as_local_public() is_public = Enum.member?(recipients, Pleroma.Constants.as_public()) or diff --git a/lib/pleroma/web/activity_pub/visibility.ex b/lib/pleroma/web/activity_pub/visibility.ex index 3654b489b..1a0c9a46c 100644 --- a/lib/pleroma/web/activity_pub/visibility.ex +++ b/lib/pleroma/web/activity_pub/visibility.ex @@ -20,7 +20,7 @@ defmodule Pleroma.Web.ActivityPub.Visibility do def is_public?(data) do Utils.label_in_message?(Pleroma.Constants.as_public(), data) or - Utils.label_in_message?(Pleroma.Web.base_url() <> "/#Public", data) + Utils.label_in_message?(Pleroma.Constants.as_local_public(), data) end def is_private?(activity) do diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 7c49c1fb1..d57ba4209 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -103,7 +103,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do def get_to_and_cc(%{visibility: {:list, _}, mentions: mentions}), do: {mentions, []} - defp public_uri(%{params: %{local_only: true}}), do: Pleroma.Web.base_url() <> "/#Public" + defp public_uri(%{params: %{local_only: true}}), do: Pleroma.Constants.as_local_public() defp public_uri(_), do: Pleroma.Constants.as_public() def get_addressed_users(_, to) when is_list(to) do -- cgit v1.2.3 From 20e68b30f08e0e0eae691dcf541968e344efdaae Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Tue, 14 Jul 2020 07:31:21 +0300 Subject: added generated `pleroma.env` --- lib/mix/tasks/pleroma/release_env.ex | 64 ++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 lib/mix/tasks/pleroma/release_env.ex (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/release_env.ex b/lib/mix/tasks/pleroma/release_env.ex new file mode 100644 index 000000000..cbbbdeff6 --- /dev/null +++ b/lib/mix/tasks/pleroma/release_env.ex @@ -0,0 +1,64 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.ReleaseEnv do + use Mix.Task + import Mix.Pleroma + + @shortdoc "Generate Pleroma environment file." + @moduledoc File.read!("docs/administration/CLI_tasks/release_environments.md") + + def run(["gen" | rest]) do + {options, [], []} = + OptionParser.parse( + rest, + strict: [ + force: :boolean, + path: :string + ], + aliases: [ + p: :path, + f: :force + ] + ) + + env_path = + get_option( + options, + :path, + "Environment file path", + "config/pleroma.env" + ) + |> Path.expand() + + proceed? = + if File.exists?(env_path) do + get_option( + options, + :force, + "Environment file is exist. Do you want overwritten the #{env_path} file? (y/n)", + "n" + ) === "y" + else + true + end + + if proceed? do + do_generate(env_path) + + shell_info( + "The file generated: #{env_path}.\nTo use the enviroment file need to add the line ';EnvironmentFile=#{ + env_path + }' in service file (/installation/pleroma.service)." + ) + end + end + + def do_generate(path) do + content = "RELEASE_COOKIE=#{Base.encode32(:crypto.strong_rand_bytes(32))}" + + File.mkdir_p!(Path.dirname(path)) + File.write!(path, content) + end +end -- cgit v1.2.3 From 14054cd004d91e89644c31d61b08d50ff0df09dd Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Thu, 16 Jul 2020 08:52:14 +0300 Subject: update task messages --- lib/mix/tasks/pleroma/release_env.ex | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/release_env.ex b/lib/mix/tasks/pleroma/release_env.ex index cbbbdeff6..63030c5cc 100644 --- a/lib/mix/tasks/pleroma/release_env.ex +++ b/lib/mix/tasks/pleroma/release_env.ex @@ -23,14 +23,15 @@ defmodule Mix.Tasks.Pleroma.ReleaseEnv do ] ) - env_path = + file_path = get_option( options, :path, "Environment file path", - "config/pleroma.env" + "./config/pleroma.env" ) - |> Path.expand() + + env_path = Path.expand(file_path) proceed? = if File.exists?(env_path) do @@ -45,13 +46,24 @@ defmodule Mix.Tasks.Pleroma.ReleaseEnv do end if proceed? do - do_generate(env_path) + case do_generate(env_path) do + {:error, reason} -> + shell_error( + File.Error.message(%{action: "write to file", reason: reason, path: env_path}) + ) - shell_info( - "The file generated: #{env_path}.\nTo use the enviroment file need to add the line ';EnvironmentFile=#{ - env_path - }' in service file (/installation/pleroma.service)." - ) + _ -> + shell_info("\nThe file generated: #{env_path}.\n") + + shell_info(""" + WARNING: before start pleroma app please to made the file read-only and non-modifiable. + Example: + chmod 0444 #{file_path} + chattr +i #{file_path} + """) + end + else + shell_info("\nThe file is exist. #{env_path}.\n") end end @@ -59,6 +71,6 @@ defmodule Mix.Tasks.Pleroma.ReleaseEnv do content = "RELEASE_COOKIE=#{Base.encode32(:crypto.strong_rand_bytes(32))}" File.mkdir_p!(Path.dirname(path)) - File.write!(path, content) + File.write(path, content) end end -- cgit v1.2.3 From 499df7b73aa723aab9c623754b531307b98cf584 Mon Sep 17 00:00:00 2001 From: Maksim Date: Thu, 16 Jul 2020 13:30:17 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- lib/mix/tasks/pleroma/release_env.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/release_env.ex b/lib/mix/tasks/pleroma/release_env.ex index 63030c5cc..4d8b6ff27 100644 --- a/lib/mix/tasks/pleroma/release_env.ex +++ b/lib/mix/tasks/pleroma/release_env.ex @@ -56,7 +56,7 @@ defmodule Mix.Tasks.Pleroma.ReleaseEnv do shell_info("\nThe file generated: #{env_path}.\n") shell_info(""" - WARNING: before start pleroma app please to made the file read-only and non-modifiable. + WARNING: before start pleroma app please make sure to make the file read-only and non-modifiable. Example: chmod 0444 #{file_path} chattr +i #{file_path} -- cgit v1.2.3 From e2333f757a763f7ec7105df6b4de4585fc3eee05 Mon Sep 17 00:00:00 2001 From: Maksim Date: Thu, 16 Jul 2020 13:30:28 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- lib/mix/tasks/pleroma/release_env.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/release_env.ex b/lib/mix/tasks/pleroma/release_env.ex index 4d8b6ff27..9da74ffcf 100644 --- a/lib/mix/tasks/pleroma/release_env.ex +++ b/lib/mix/tasks/pleroma/release_env.ex @@ -38,7 +38,7 @@ defmodule Mix.Tasks.Pleroma.ReleaseEnv do get_option( options, :force, - "Environment file is exist. Do you want overwritten the #{env_path} file? (y/n)", + "Environment file already exists. Do you want to overwrite the #{env_path} file? (y/n)", "n" ) === "y" else -- cgit v1.2.3 From cf53e300f8e850aeacb88e6e6b4d465378acc199 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Fri, 7 Aug 2020 15:55:08 +0300 Subject: added generate the release env to `pleroma.instance gen` --- lib/mix/tasks/pleroma/instance.ex | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/instance.ex b/lib/mix/tasks/pleroma/instance.ex index fc21ae062..caa7dfbd3 100644 --- a/lib/mix/tasks/pleroma/instance.ex +++ b/lib/mix/tasks/pleroma/instance.ex @@ -36,7 +36,8 @@ defmodule Mix.Tasks.Pleroma.Instance do listen_port: :string, strip_uploads: :string, anonymize_uploads: :string, - dedupe_uploads: :string + dedupe_uploads: :string, + skip_release_env: :boolean ], aliases: [ o: :output, @@ -241,6 +242,16 @@ defmodule Mix.Tasks.Pleroma.Instance do write_robots_txt(static_dir, indexable, template_dir) + if Keyword.get(options, :skip_release_env, false) do + shell_info(""" + Release environment file is skip. Please generate the release env file before start. + `MIX_ENV=#{Mix.env()} mix pleroma.release_env gen` + """) + else + shell_info("Generation the environment file:") + Mix.Tasks.Pleroma.ReleaseEnv.run(["gen"]) + end + shell_info( "\n All files successfully written! Refer to the installation instructions for your platform for next steps." ) -- cgit v1.2.3 From 2030ffd4904b6ab5e99cefa62887154a49aaf4db Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Sat, 8 Aug 2020 06:49:19 +0300 Subject: fix test --- lib/mix/tasks/pleroma/instance.ex | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/instance.ex b/lib/mix/tasks/pleroma/instance.ex index caa7dfbd3..1915aacd9 100644 --- a/lib/mix/tasks/pleroma/instance.ex +++ b/lib/mix/tasks/pleroma/instance.ex @@ -37,7 +37,8 @@ defmodule Mix.Tasks.Pleroma.Instance do strip_uploads: :string, anonymize_uploads: :string, dedupe_uploads: :string, - skip_release_env: :boolean + skip_release_env: :boolean, + release_env_file: :string ], aliases: [ o: :output, @@ -249,7 +250,15 @@ defmodule Mix.Tasks.Pleroma.Instance do """) else shell_info("Generation the environment file:") - Mix.Tasks.Pleroma.ReleaseEnv.run(["gen"]) + + release_env_args = + with path when not is_nil(path) <- Keyword.get(options, :release_env_file) do + ["gen", "--path", path] + else + _ -> ["gen"] + end + + Mix.Tasks.Pleroma.ReleaseEnv.run(release_env_args) end shell_info( -- cgit v1.2.3 From 724e4b7f00041667b9fa61fd1b7dd51c8eeaa102 Mon Sep 17 00:00:00 2001 From: Haelwenn Date: Thu, 15 Oct 2020 21:03:48 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- lib/pleroma/web/admin_api/views/account_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index 5f3a78a0f..9c477feab 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -52,7 +52,7 @@ defmodule Pleroma.Web.AdminAPI.AccountView do :skip_thread_containment, :pleroma_settings_store, :raw_fields, - :is_discoverable, + :discoverable, :actor_type ]) |> Map.merge(%{ -- cgit v1.2.3 From 3b5a7a6b14f4c09d1d371d6fcb49bece84d6c3e1 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 16 Oct 2020 00:32:20 +0200 Subject: federation_status: New endpoint showing unreachable instances --- lib/pleroma/instances.ex | 1 + lib/pleroma/instances/instance.ex | 11 +++++++++++ .../pleroma_api/controllers/instances_controller.ex | 19 +++++++++++++++++++ lib/pleroma/web/router.ex | 1 + 4 files changed, 32 insertions(+) create mode 100644 lib/pleroma/web/pleroma_api/controllers/instances_controller.ex (limited to 'lib') diff --git a/lib/pleroma/instances.ex b/lib/pleroma/instances.ex index 557e8decf..7315bd7cb 100644 --- a/lib/pleroma/instances.ex +++ b/lib/pleroma/instances.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Instances do defdelegate reachable?(url_or_host), to: @adapter defdelegate set_reachable(url_or_host), to: @adapter defdelegate set_unreachable(url_or_host, unreachable_since \\ nil), to: @adapter + defdelegate get_consistently_unreachable(), to: @adapter def set_consistently_unreachable(url_or_host), do: set_unreachable(url_or_host, reachability_datetime_threshold()) diff --git a/lib/pleroma/instances/instance.ex b/lib/pleroma/instances/instance.ex index f0f601469..df471a39d 100644 --- a/lib/pleroma/instances/instance.ex +++ b/lib/pleroma/instances/instance.ex @@ -119,6 +119,17 @@ defmodule Pleroma.Instances.Instance do def set_unreachable(_, _), do: {:error, nil} + def get_consistently_unreachable do + reachability_datetime_threshold = Instances.reachability_datetime_threshold() + + from(i in Instance, + where: ^reachability_datetime_threshold > i.unreachable_since, + order_by: i.unreachable_since, + select: {i.host, i.unreachable_since} + ) + |> Repo.all() + end + defp parse_datetime(datetime) when is_binary(datetime) do NaiveDateTime.from_iso8601(datetime) end diff --git a/lib/pleroma/web/pleroma_api/controllers/instances_controller.ex b/lib/pleroma/web/pleroma_api/controllers/instances_controller.ex new file mode 100644 index 000000000..bd95cb523 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/instances_controller.ex @@ -0,0 +1,19 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.InstancesController do + use Pleroma.Web, :controller + + alias Pleroma.Instances + + # defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaInstancesController + + def show(conn, _params) do + unreachable = + Instances.get_consistently_unreachable() + |> Enum.reduce(%{}, fn {host, date}, acc -> Map.put(acc, host, to_string(date)) end) + + json(conn, %{"unreachable" => unreachable}) + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index d2d939989..5f9a749e4 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -373,6 +373,7 @@ defmodule Pleroma.Web.Router do scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do pipe_through(:api) get("/accounts/:id/scrobbles", ScrobbleController, :index) + get("/federation_status", InstancesController, :show) end scope "/api/v1", Pleroma.Web.MastodonAPI do -- cgit v1.2.3 From aafdc975bdd38f74cdf5d3f8517d41c5dd76c56b Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 16 Oct 2020 01:13:52 +0200 Subject: federation_status: Add ApiSpec --- .../operations/pleroma_instances_operation.ex | 40 ++++++++++++++++++++++ .../controllers/instances_controller.ex | 4 ++- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_instances_operation.ex (limited to 'lib') diff --git a/lib/pleroma/web/api_spec/operations/pleroma_instances_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_instances_operation.ex new file mode 100644 index 000000000..2c455b0df --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_instances_operation.ex @@ -0,0 +1,40 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaInstancesOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def show_operation do + %Operation{ + tags: ["PleromaInstances"], + summary: "Instances federation status", + description: "Information about instances deemed unreachable by the server", + operationId: "PleromaInstances.show", + responses: %{ + 200 => Operation.response("PleromaInstances", "application/json", pleroma_instances()) + } + } + end + + def pleroma_instances do + %Schema{ + type: :object, + properties: %{ + unreachable: %Schema{ + type: :object, + properties: %{hostname: %Schema{type: :string, format: :"date-time"}} + } + }, + example: %{ + "unreachable" => %{"consistently-unreachable.name" => "2020-10-14 22:07:58.216473"} + } + } + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/instances_controller.ex b/lib/pleroma/web/pleroma_api/controllers/instances_controller.ex index bd95cb523..c577f1d1e 100644 --- a/lib/pleroma/web/pleroma_api/controllers/instances_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/instances_controller.ex @@ -7,7 +7,9 @@ defmodule Pleroma.Web.PleromaAPI.InstancesController do alias Pleroma.Instances - # defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaInstancesController + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaInstancesOperation def show(conn, _params) do unreachable = -- cgit v1.2.3 From 1b8fd7e65af980c42b72f584c2a957b12ca5c78b Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 16 Oct 2020 17:32:05 +0000 Subject: Adds feature to permit e.g., local admins and community moderators to automatically follow all newly registered accounts --- lib/pleroma/user.ex | 11 +++++++++++ 1 file changed, 11 insertions(+) (limited to 'lib') diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index dc41d0001..2a3495103 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -765,6 +765,16 @@ defmodule Pleroma.User do follow_all(user, autofollowed_users) end + defp autofollowing_users(user) do + candidates = Config.get([:instance, :autofollowing_nicknames]) + + User.Query.build(%{nickname: candidates, local: true, deactivated: false}) + |> Repo.all() + |> Enum.each(&follow(&1, user, :follow_accept)) + + {:ok, :success} + end + @doc "Inserts provided changeset, performs post-registration actions (confirmation email sending etc.)" def register(%Ecto.Changeset{} = changeset) do with {:ok, user} <- Repo.insert(changeset) do @@ -774,6 +784,7 @@ defmodule Pleroma.User do def post_register_action(%User{} = user) do with {:ok, user} <- autofollow_users(user), + {:ok, _} <- autofollowing_users(user), {:ok, user} <- set_cache(user), {:ok, _} <- send_welcome_email(user), {:ok, _} <- send_welcome_message(user), -- cgit v1.2.3 From d54233760f4c006d89aa80e0ae78cb6910fc74ab Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 17 Oct 2020 13:33:57 +0300 Subject: [#3053] Post-merge fix. --- lib/pleroma/web/feed/user_controller.ex | 1 + 1 file changed, 1 insertion(+) (limited to 'lib') diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex index b66fdf275..a5013d2c0 100644 --- a/lib/pleroma/web/feed/user_controller.ex +++ b/lib/pleroma/web/feed/user_controller.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.Feed.UserController do use Pleroma.Web, :controller + alias Pleroma.Config alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPubController -- cgit v1.2.3 From 524fb0e4c2561f4a2e4c8e58519df991f034c901 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sun, 18 Oct 2020 21:22:21 +0300 Subject: [#1668] Restricted access to app metrics endpoint by default. Added ability to configure IP whitelist for this endpoint. Added tests and documentation. --- lib/pleroma/helpers/inet_helper.ex | 19 ++++++++++++++++++ lib/pleroma/web/endpoint.ex | 40 ++++++++++++++++++++++++++++++++------ 2 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 lib/pleroma/helpers/inet_helper.ex (limited to 'lib') diff --git a/lib/pleroma/helpers/inet_helper.ex b/lib/pleroma/helpers/inet_helper.ex new file mode 100644 index 000000000..126f82381 --- /dev/null +++ b/lib/pleroma/helpers/inet_helper.ex @@ -0,0 +1,19 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Helpers.InetHelper do + def parse_address(ip) when is_tuple(ip) do + {:ok, ip} + end + + def parse_address(ip) when is_binary(ip) do + ip + |> String.to_charlist() + |> parse_address() + end + + def parse_address(ip) do + :inet.parse_address(ip) + end +end diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index 56562c12f..1a8fdd8b9 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -7,6 +7,8 @@ defmodule Pleroma.Web.Endpoint do require Pleroma.Constants + alias Pleroma.Config + socket("/socket", Pleroma.Web.UserSocket) plug(Pleroma.Web.Plugs.SetLocalePlug) @@ -86,19 +88,19 @@ defmodule Pleroma.Web.Endpoint do plug(Plug.Parsers, parsers: [ :urlencoded, - {:multipart, length: {Pleroma.Config, :get, [[:instance, :upload_limit]]}}, + {:multipart, length: {Config, :get, [[:instance, :upload_limit]]}}, :json ], pass: ["*/*"], json_decoder: Jason, - length: Pleroma.Config.get([:instance, :upload_limit]), + length: Config.get([:instance, :upload_limit]), body_reader: {Pleroma.Web.Plugs.DigestPlug, :read_body, []} ) plug(Plug.MethodOverride) plug(Plug.Head) - secure_cookies = Pleroma.Config.get([__MODULE__, :secure_cookie_flag]) + secure_cookies = Config.get([__MODULE__, :secure_cookie_flag]) cookie_name = if secure_cookies, @@ -106,7 +108,7 @@ defmodule Pleroma.Web.Endpoint do else: "pleroma_key" extra = - Pleroma.Config.get([__MODULE__, :extra_cookie_attrs]) + Config.get([__MODULE__, :extra_cookie_attrs]) |> Enum.join(";") # The session will be stored in the cookie and signed, @@ -116,7 +118,7 @@ defmodule Pleroma.Web.Endpoint do Plug.Session, store: :cookie, key: cookie_name, - signing_salt: Pleroma.Config.get([__MODULE__, :signing_salt], "CqaoopA2"), + signing_salt: Config.get([__MODULE__, :signing_salt], "CqaoopA2"), http_only: true, secure: secure_cookies, extra: extra @@ -136,8 +138,34 @@ defmodule Pleroma.Web.Endpoint do use Prometheus.PlugExporter end + defmodule MetricsExporterCaller do + @behaviour Plug + + def init(opts), do: opts + + def call(conn, opts) do + prometheus_config = Application.get_env(:prometheus, MetricsExporter, []) + ip_whitelist = List.wrap(prometheus_config[:ip_whitelist]) + + cond do + !prometheus_config[:enabled] -> + conn + + ip_whitelist != [] and + !Enum.find(ip_whitelist, fn ip -> + Pleroma.Helpers.InetHelper.parse_address(ip) == {:ok, conn.remote_ip} + end) -> + conn + + true -> + MetricsExporter.call(conn, opts) + end + end + end + plug(PipelineInstrumenter) - plug(MetricsExporter) + + plug(MetricsExporterCaller) plug(Pleroma.Web.Router) -- cgit v1.2.3 From 98f32cf8204113c6d019653c22e446e558147248 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 19 Oct 2020 15:30:32 +0400 Subject: Fix tests --- lib/pleroma/web/pleroma_api/controllers/backup_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex b/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex index e52c77ff2..8e3d081f3 100644 --- a/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.PleromaAPI.BackupController do use Pleroma.Web, :controller - alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Web.Plugs.OAuthScopesPlug action_fallback(Pleroma.Web.MastodonAPI.FallbackController) plug(OAuthScopesPlug, %{scopes: ["read:accounts"]} when action in [:index, :create]) -- cgit v1.2.3 From 39fd4d7639a0ab47bc5ae3839b10701fc301a1be Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 19 Oct 2020 15:40:50 +0200 Subject: Transmogrifier: Downcase incoming Hashtags Also, set sensitive to true if we have an nsfw hashtag present. --- lib/pleroma/web/activity_pub/transmogrifier.ex | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index d7dd9fe6b..2f51f9e39 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -40,6 +40,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do |> fix_in_reply_to(options) |> fix_emoji |> fix_tag + |> set_sensitive |> fix_content_map |> fix_addressing |> fix_summary @@ -313,7 +314,11 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do tags = tag |> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end) - |> Enum.map(fn data -> String.slice(data["name"], 1..-1) end) + |> Enum.map(fn %{"name" => name} -> + name + |> String.slice(1..-1) + |> String.downcase() + end) Map.put(object, "tag", tag ++ tags) end @@ -927,7 +932,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do Map.put(object, "conversation", object["context"]) end - def set_sensitive(%{"sensitive" => true} = object) do + def set_sensitive(%{"sensitive" => _} = object) do object end -- cgit v1.2.3 From e97b254c6bb798d200f381e5adbde2177bcbc0df Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 19 Oct 2020 15:46:24 +0200 Subject: Transmogrifier: Refactor and unify incoming tag handling --- lib/pleroma/web/activity_pub/transmogrifier.ex | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 2f51f9e39..39c8f7e39 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -323,14 +323,12 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do Map.put(object, "tag", tag ++ tags) end - def fix_tag(%{"tag" => %{"type" => "Hashtag", "name" => hashtag} = tag} = object) do - combined = [tag, String.slice(hashtag, 1..-1)] - - Map.put(object, "tag", combined) + def fix_tag(%{"tag" => %{} = tag} = object) do + object + |> Map.put("tag", [tag]) + |> fix_tag end - def fix_tag(%{"tag" => %{} = tag} = object), do: Map.put(object, "tag", [tag]) - def fix_tag(object), do: object # content map usually only has one language so this will do for now. -- cgit v1.2.3 From ad605e3e16ba3f6ee3df7a0a3e6705036fef369f Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 20 Oct 2020 17:16:58 +0400 Subject: Rename `Pleroma.Backup` to `Pleroma.User.Backup` --- lib/pleroma/backup.ex | 258 --------------------- lib/pleroma/user/backup.ex | 258 +++++++++++++++++++++ .../admin_api/controllers/admin_api_controller.ex | 2 +- .../pleroma_api/controllers/backup_controller.ex | 7 +- lib/pleroma/web/pleroma_api/views/backup_view.ex | 2 +- lib/pleroma/workers/backup_worker.ex | 4 +- 6 files changed, 266 insertions(+), 265 deletions(-) delete mode 100644 lib/pleroma/backup.ex create mode 100644 lib/pleroma/user/backup.ex (limited to 'lib') diff --git a/lib/pleroma/backup.ex b/lib/pleroma/backup.ex deleted file mode 100644 index 629e879a7..000000000 --- a/lib/pleroma/backup.ex +++ /dev/null @@ -1,258 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Backup do - use Ecto.Schema - - import Ecto.Changeset - import Ecto.Query - import Pleroma.Web.Gettext - - require Pleroma.Constants - - alias Pleroma.Activity - alias Pleroma.Bookmark - alias Pleroma.Repo - alias Pleroma.User - alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.ActivityPub.Transmogrifier - alias Pleroma.Web.ActivityPub.UserView - alias Pleroma.Workers.BackupWorker - - schema "backups" do - field(:content_type, :string) - field(:file_name, :string) - field(:file_size, :integer, default: 0) - field(:processed, :boolean, default: false) - - belongs_to(:user, User, type: FlakeId.Ecto.CompatType) - - timestamps() - end - - def create(user, admin_id \\ nil) do - with :ok <- validate_email_enabled(), - :ok <- validate_user_email(user), - :ok <- validate_limit(user, admin_id), - {:ok, backup} <- user |> new() |> Repo.insert() do - BackupWorker.process(backup, admin_id) - end - end - - def new(user) do - rand_str = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) - datetime = Calendar.NaiveDateTime.Format.iso8601_basic(NaiveDateTime.utc_now()) - name = "archive-#{user.nickname}-#{datetime}-#{rand_str}.zip" - - %__MODULE__{ - user_id: user.id, - content_type: "application/zip", - file_name: name - } - end - - def delete(backup) do - uploader = Pleroma.Config.get([Pleroma.Upload, :uploader]) - - with :ok <- uploader.delete_file(Path.join("backups", backup.file_name)) do - Repo.delete(backup) - end - end - - defp validate_limit(_user, admin_id) when is_binary(admin_id), do: :ok - - defp validate_limit(user, nil) do - case get_last(user.id) do - %__MODULE__{inserted_at: inserted_at} -> - days = Pleroma.Config.get([Pleroma.Backup, :limit_days]) - diff = Timex.diff(NaiveDateTime.utc_now(), inserted_at, :days) - - if diff > days do - :ok - else - {:error, - dngettext( - "errors", - "Last export was less than a day ago", - "Last export was less than %{days} days ago", - days, - days: days - )} - end - - nil -> - :ok - end - end - - defp validate_email_enabled do - if Pleroma.Config.get([Pleroma.Emails.Mailer, :enabled]) do - :ok - else - {:error, dgettext("errors", "Backups require enabled email")} - end - end - - defp validate_user_email(%User{email: nil}) do - {:error, dgettext("errors", "Email is required")} - end - - defp validate_user_email(%User{email: email}) when is_binary(email), do: :ok - - def get_last(user_id) do - __MODULE__ - |> where(user_id: ^user_id) - |> order_by(desc: :id) - |> limit(1) - |> Repo.one() - end - - def list(%User{id: user_id}) do - __MODULE__ - |> where(user_id: ^user_id) - |> order_by(desc: :id) - |> Repo.all() - end - - def remove_outdated(%__MODULE__{id: latest_id, user_id: user_id}) do - __MODULE__ - |> where(user_id: ^user_id) - |> where([b], b.id != ^latest_id) - |> Repo.all() - |> Enum.each(&BackupWorker.delete/1) - end - - def get(id), do: Repo.get(__MODULE__, id) - - def process(%__MODULE__{} = backup) do - with {:ok, zip_file} <- export(backup), - {:ok, %{size: size}} <- File.stat(zip_file), - {:ok, _upload} <- upload(backup, zip_file) do - backup - |> cast(%{file_size: size, processed: true}, [:file_size, :processed]) - |> Repo.update() - end - end - - @files ['actor.json', 'outbox.json', 'likes.json', 'bookmarks.json'] - def export(%__MODULE__{} = backup) do - backup = Repo.preload(backup, :user) - name = String.trim_trailing(backup.file_name, ".zip") - dir = dir(name) - - with :ok <- File.mkdir(dir), - :ok <- actor(dir, backup.user), - :ok <- statuses(dir, backup.user), - :ok <- likes(dir, backup.user), - :ok <- bookmarks(dir, backup.user), - {:ok, zip_path} <- :zip.create(String.to_charlist(dir <> ".zip"), @files, cwd: dir), - {:ok, _} <- File.rm_rf(dir) do - {:ok, to_string(zip_path)} - end - end - - def dir(name) do - dir = Pleroma.Config.get([__MODULE__, :dir]) || System.tmp_dir!() - Path.join(dir, name) - end - - def upload(%__MODULE__{} = backup, zip_path) do - uploader = Pleroma.Config.get([Pleroma.Upload, :uploader]) - - upload = %Pleroma.Upload{ - name: backup.file_name, - tempfile: zip_path, - content_type: backup.content_type, - path: Path.join("backups", backup.file_name) - } - - with {:ok, _} <- Pleroma.Uploaders.Uploader.put_file(uploader, upload), - :ok <- File.rm(zip_path) do - {:ok, upload} - end - end - - defp actor(dir, user) do - with {:ok, json} <- - UserView.render("user.json", %{user: user}) - |> Map.merge(%{"likes" => "likes.json", "bookmarks" => "bookmarks.json"}) - |> Jason.encode() do - File.write(Path.join(dir, "actor.json"), json) - end - end - - defp write_header(file, name) do - IO.write( - file, - """ - { - "@context": "https://www.w3.org/ns/activitystreams", - "id": "#{name}.json", - "type": "OrderedCollection", - "orderedItems": [ - - """ - ) - end - - defp write(query, dir, name, fun) do - path = Path.join(dir, "#{name}.json") - - with {:ok, file} <- File.open(path, [:write, :utf8]), - :ok <- write_header(file, name) do - total = - query - |> Pleroma.Repo.chunk_stream(100) - |> Enum.reduce(0, fn i, acc -> - with {:ok, data} <- fun.(i), - {:ok, str} <- Jason.encode(data), - :ok <- IO.write(file, str <> ",\n") do - acc + 1 - else - _ -> acc - end - end) - - with :ok <- :file.pwrite(file, {:eof, -2}, "\n],\n \"totalItems\": #{total}}") do - File.close(file) - end - end - end - - defp bookmarks(dir, %{id: user_id} = _user) do - Bookmark - |> where(user_id: ^user_id) - |> join(:inner, [b], activity in assoc(b, :activity)) - |> select([b, a], %{id: b.id, object: fragment("(?)->>'object'", a.data)}) - |> write(dir, "bookmarks", fn a -> {:ok, a.object} end) - end - - defp likes(dir, user) do - user.ap_id - |> Activity.Queries.by_actor() - |> Activity.Queries.by_type("Like") - |> select([like], %{id: like.id, object: fragment("(?)->>'object'", like.data)}) - |> write(dir, "likes", fn a -> {:ok, a.object} end) - end - - defp statuses(dir, user) do - opts = - %{} - |> Map.put(:type, ["Create", "Announce"]) - |> Map.put(:actor_id, user.ap_id) - - [ - [Pleroma.Constants.as_public(), user.ap_id], - User.following(user), - Pleroma.List.memberships(user) - ] - |> Enum.concat() - |> ActivityPub.fetch_activities_query(opts) - |> write(dir, "outbox", fn a -> - with {:ok, activity} <- Transmogrifier.prepare_outgoing(a.data) do - {:ok, Map.delete(activity, "@context")} - end - end) - end -end diff --git a/lib/pleroma/user/backup.ex b/lib/pleroma/user/backup.ex new file mode 100644 index 000000000..a9041fd94 --- /dev/null +++ b/lib/pleroma/user/backup.ex @@ -0,0 +1,258 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.User.Backup do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + import Pleroma.Web.Gettext + + require Pleroma.Constants + + alias Pleroma.Activity + alias Pleroma.Bookmark + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.ActivityPub.UserView + alias Pleroma.Workers.BackupWorker + + schema "backups" do + field(:content_type, :string) + field(:file_name, :string) + field(:file_size, :integer, default: 0) + field(:processed, :boolean, default: false) + + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + + timestamps() + end + + def create(user, admin_id \\ nil) do + with :ok <- validate_email_enabled(), + :ok <- validate_user_email(user), + :ok <- validate_limit(user, admin_id), + {:ok, backup} <- user |> new() |> Repo.insert() do + BackupWorker.process(backup, admin_id) + end + end + + def new(user) do + rand_str = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) + datetime = Calendar.NaiveDateTime.Format.iso8601_basic(NaiveDateTime.utc_now()) + name = "archive-#{user.nickname}-#{datetime}-#{rand_str}.zip" + + %__MODULE__{ + user_id: user.id, + content_type: "application/zip", + file_name: name + } + end + + def delete(backup) do + uploader = Pleroma.Config.get([Pleroma.Upload, :uploader]) + + with :ok <- uploader.delete_file(Path.join("backups", backup.file_name)) do + Repo.delete(backup) + end + end + + defp validate_limit(_user, admin_id) when is_binary(admin_id), do: :ok + + defp validate_limit(user, nil) do + case get_last(user.id) do + %__MODULE__{inserted_at: inserted_at} -> + days = Pleroma.Config.get([__MODULE__, :limit_days]) + diff = Timex.diff(NaiveDateTime.utc_now(), inserted_at, :days) + + if diff > days do + :ok + else + {:error, + dngettext( + "errors", + "Last export was less than a day ago", + "Last export was less than %{days} days ago", + days, + days: days + )} + end + + nil -> + :ok + end + end + + defp validate_email_enabled do + if Pleroma.Config.get([Pleroma.Emails.Mailer, :enabled]) do + :ok + else + {:error, dgettext("errors", "Backups require enabled email")} + end + end + + defp validate_user_email(%User{email: nil}) do + {:error, dgettext("errors", "Email is required")} + end + + defp validate_user_email(%User{email: email}) when is_binary(email), do: :ok + + def get_last(user_id) do + __MODULE__ + |> where(user_id: ^user_id) + |> order_by(desc: :id) + |> limit(1) + |> Repo.one() + end + + def list(%User{id: user_id}) do + __MODULE__ + |> where(user_id: ^user_id) + |> order_by(desc: :id) + |> Repo.all() + end + + def remove_outdated(%__MODULE__{id: latest_id, user_id: user_id}) do + __MODULE__ + |> where(user_id: ^user_id) + |> where([b], b.id != ^latest_id) + |> Repo.all() + |> Enum.each(&BackupWorker.delete/1) + end + + def get(id), do: Repo.get(__MODULE__, id) + + def process(%__MODULE__{} = backup) do + with {:ok, zip_file} <- export(backup), + {:ok, %{size: size}} <- File.stat(zip_file), + {:ok, _upload} <- upload(backup, zip_file) do + backup + |> cast(%{file_size: size, processed: true}, [:file_size, :processed]) + |> Repo.update() + end + end + + @files ['actor.json', 'outbox.json', 'likes.json', 'bookmarks.json'] + def export(%__MODULE__{} = backup) do + backup = Repo.preload(backup, :user) + name = String.trim_trailing(backup.file_name, ".zip") + dir = dir(name) + + with :ok <- File.mkdir(dir), + :ok <- actor(dir, backup.user), + :ok <- statuses(dir, backup.user), + :ok <- likes(dir, backup.user), + :ok <- bookmarks(dir, backup.user), + {:ok, zip_path} <- :zip.create(String.to_charlist(dir <> ".zip"), @files, cwd: dir), + {:ok, _} <- File.rm_rf(dir) do + {:ok, to_string(zip_path)} + end + end + + def dir(name) do + dir = Pleroma.Config.get([__MODULE__, :dir]) || System.tmp_dir!() + Path.join(dir, name) + end + + def upload(%__MODULE__{} = backup, zip_path) do + uploader = Pleroma.Config.get([Pleroma.Upload, :uploader]) + + upload = %Pleroma.Upload{ + name: backup.file_name, + tempfile: zip_path, + content_type: backup.content_type, + path: Path.join("backups", backup.file_name) + } + + with {:ok, _} <- Pleroma.Uploaders.Uploader.put_file(uploader, upload), + :ok <- File.rm(zip_path) do + {:ok, upload} + end + end + + defp actor(dir, user) do + with {:ok, json} <- + UserView.render("user.json", %{user: user}) + |> Map.merge(%{"likes" => "likes.json", "bookmarks" => "bookmarks.json"}) + |> Jason.encode() do + File.write(Path.join(dir, "actor.json"), json) + end + end + + defp write_header(file, name) do + IO.write( + file, + """ + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "#{name}.json", + "type": "OrderedCollection", + "orderedItems": [ + + """ + ) + end + + defp write(query, dir, name, fun) do + path = Path.join(dir, "#{name}.json") + + with {:ok, file} <- File.open(path, [:write, :utf8]), + :ok <- write_header(file, name) do + total = + query + |> Pleroma.Repo.chunk_stream(100) + |> Enum.reduce(0, fn i, acc -> + with {:ok, data} <- fun.(i), + {:ok, str} <- Jason.encode(data), + :ok <- IO.write(file, str <> ",\n") do + acc + 1 + else + _ -> acc + end + end) + + with :ok <- :file.pwrite(file, {:eof, -2}, "\n],\n \"totalItems\": #{total}}") do + File.close(file) + end + end + end + + defp bookmarks(dir, %{id: user_id} = _user) do + Bookmark + |> where(user_id: ^user_id) + |> join(:inner, [b], activity in assoc(b, :activity)) + |> select([b, a], %{id: b.id, object: fragment("(?)->>'object'", a.data)}) + |> write(dir, "bookmarks", fn a -> {:ok, a.object} end) + end + + defp likes(dir, user) do + user.ap_id + |> Activity.Queries.by_actor() + |> Activity.Queries.by_type("Like") + |> select([like], %{id: like.id, object: fragment("(?)->>'object'", like.data)}) + |> write(dir, "likes", fn a -> {:ok, a.object} end) + end + + defp statuses(dir, user) do + opts = + %{} + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:actor_id, user.ap_id) + + [ + [Pleroma.Constants.as_public(), user.ap_id], + User.following(user), + Pleroma.List.memberships(user) + ] + |> Enum.concat() + |> ActivityPub.fetch_activities_query(opts) + |> write(dir, "outbox", fn a -> + with {:ok, activity} <- Transmogrifier.prepare_outgoing(a.data) do + {:ok, Map.delete(activity, "@context")} + end + end) + end +end diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index a4f0d7d34..0a27c5861 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -685,7 +685,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do def create_backup(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do with %User{} = user <- User.get_by_nickname(nickname), - {:ok, _} <- Pleroma.Backup.create(user, admin.id) do + {:ok, _} <- Pleroma.User.Backup.create(user, admin.id) do ModerationLog.insert_log(%{actor: admin, subject: user, action: "create_backup"}) json(conn, "") diff --git a/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex b/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex index 8e3d081f3..bd7b36880 100644 --- a/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.PleromaAPI.BackupController do use Pleroma.Web, :controller alias Pleroma.Web.Plugs.OAuthScopesPlug + alias Pleroma.User.Backup action_fallback(Pleroma.Web.MastodonAPI.FallbackController) plug(OAuthScopesPlug, %{scopes: ["read:accounts"]} when action in [:index, :create]) @@ -14,13 +15,13 @@ defmodule Pleroma.Web.PleromaAPI.BackupController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaBackupOperation def index(%{assigns: %{user: user}} = conn, _params) do - backups = Pleroma.Backup.list(user) + backups = Backup.list(user) render(conn, "index.json", backups: backups) end def create(%{assigns: %{user: user}} = conn, _params) do - with {:ok, _} <- Pleroma.Backup.create(user) do - backups = Pleroma.Backup.list(user) + with {:ok, _} <- Backup.create(user) do + backups = Backup.list(user) render(conn, "index.json", backups: backups) end end diff --git a/lib/pleroma/web/pleroma_api/views/backup_view.ex b/lib/pleroma/web/pleroma_api/views/backup_view.ex index bf40a001e..af75876aa 100644 --- a/lib/pleroma/web/pleroma_api/views/backup_view.ex +++ b/lib/pleroma/web/pleroma_api/views/backup_view.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.PleromaAPI.BackupView do use Pleroma.Web, :view - alias Pleroma.Backup + alias Pleroma.User.Backup alias Pleroma.Web.CommonAPI.Utils def render("show.json", %{backup: %Backup{} = backup}) do diff --git a/lib/pleroma/workers/backup_worker.ex b/lib/pleroma/workers/backup_worker.ex index 65754b6a2..5b4985983 100644 --- a/lib/pleroma/workers/backup_worker.ex +++ b/lib/pleroma/workers/backup_worker.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Workers.BackupWorker do use Oban.Worker, queue: :backup, max_attempts: 1 alias Oban.Job - alias Pleroma.Backup + alias Pleroma.User.Backup def process(backup, admin_user_id \\ nil) do %{"op" => "process", "backup_id" => backup.id, "admin_user_id" => admin_user_id} @@ -15,7 +15,7 @@ defmodule Pleroma.Workers.BackupWorker do end def schedule_deletion(backup) do - days = Pleroma.Config.get([Pleroma.Backup, :purge_after_days]) + days = Pleroma.Config.get([Backup, :purge_after_days]) time = 60 * 60 * 24 * days scheduled_at = Calendar.NaiveDateTime.add!(backup.inserted_at, time) -- cgit v1.2.3 From 034ac43f3a91178694d3c621c52ce68207ec4f69 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 20 Oct 2020 17:47:04 +0400 Subject: Fix credo warnings --- lib/pleroma/web/pleroma_api/controllers/backup_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex b/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex index bd7b36880..dd0a2e22f 100644 --- a/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex @@ -5,8 +5,8 @@ defmodule Pleroma.Web.PleromaAPI.BackupController do use Pleroma.Web, :controller - alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.User.Backup + alias Pleroma.Web.Plugs.OAuthScopesPlug action_fallback(Pleroma.Web.MastodonAPI.FallbackController) plug(OAuthScopesPlug, %{scopes: ["read:accounts"]} when action in [:index, :create]) -- cgit v1.2.3 From 2ca98f2d94e2976ae35998aecff27809d4b066cf Mon Sep 17 00:00:00 2001 From: Haelwenn Date: Wed, 21 Oct 2020 19:40:37 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- lib/pleroma/web/pleroma_api/controllers/instances_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/pleroma_api/controllers/instances_controller.ex b/lib/pleroma/web/pleroma_api/controllers/instances_controller.ex index c577f1d1e..9e97480df 100644 --- a/lib/pleroma/web/pleroma_api/controllers/instances_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/instances_controller.ex @@ -14,7 +14,7 @@ defmodule Pleroma.Web.PleromaAPI.InstancesController do def show(conn, _params) do unreachable = Instances.get_consistently_unreachable() - |> Enum.reduce(%{}, fn {host, date}, acc -> Map.put(acc, host, to_string(date)) end) + |> Map.new(fn {host, date} -> {host, to_string(date)} end) json(conn, %{"unreachable" => unreachable}) end -- cgit v1.2.3 From 9ef46ce4103f338d5bb75278013a63d3ae418d7e Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Mon, 21 Sep 2020 09:33:51 +0300 Subject: added 'unconfirmed' filter to admin/users --- lib/pleroma/user/query.ex | 5 ++ .../admin_api/controllers/admin_api_controller.ex | 55 ++++++++-------------- 2 files changed, 25 insertions(+), 35 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex index 2440bf890..2933e7fb4 100644 --- a/lib/pleroma/user/query.ex +++ b/lib/pleroma/user/query.ex @@ -43,6 +43,7 @@ defmodule Pleroma.User.Query do active: boolean(), deactivated: boolean(), need_approval: boolean(), + need_confirmed: boolean(), is_admin: boolean(), is_moderator: boolean(), super_users: boolean(), @@ -156,6 +157,10 @@ defmodule Pleroma.User.Query do where(query, [u], u.approval_pending) end + defp compose_query({:need_confirmed, _}, query) do + where(query, [u], u.confirmation_pending) + end + defp compose_query({:followers, %User{id: id}}, query) do query |> where([u], u.id != ^id) diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index bdd3e195d..acfbeb0c8 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -5,7 +5,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, only: [json_response: 3] + import Pleroma.Web.ControllerHelper, + only: [json_response: 3, fetch_integer_param: 3] alias Pleroma.Config alias Pleroma.MFA @@ -100,12 +101,9 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do end def user_delete(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do - users = - nicknames - |> Enum.map(&User.get_cached_by_nickname/1) + users = Enum.map(nicknames, &User.get_cached_by_nickname/1) - users - |> Enum.each(fn user -> + Enum.each(users, fn user -> {:ok, delete_data, _} = Builder.delete(admin, user.ap_id) Pipeline.common_pipeline(delete_data, local: true) end) @@ -367,16 +365,18 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do {page, page_size} = page_params(params) filters = maybe_parse_filters(params["filters"]) - search_params = %{ - query: params["query"], - page: page, - page_size: page_size, - tags: params["tags"], - name: params["name"], - email: params["email"] - } + search_params = + %{ + query: params["query"], + page: page, + page_size: page_size, + tags: params["tags"], + name: params["name"], + email: params["email"] + } + |> Map.merge(filters) - with {:ok, users, count} <- Search.user(Map.merge(search_params, filters)) do + with {:ok, users, count} <- Search.user(search_params) do json( conn, AccountView.render("index.json", @@ -388,7 +388,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do end end - @filters ~w(local external active deactivated need_approval is_admin is_moderator) + @filters ~w(local external active deactivated need_approval need_confirmed is_admin is_moderator) @spec maybe_parse_filters(String.t()) :: %{required(String.t()) => true} | %{} defp maybe_parse_filters(filters) when is_nil(filters) or filters == "", do: %{} @@ -682,24 +682,9 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do end defp page_params(params) do - {get_page(params["page"]), get_page_size(params["page_size"])} - end - - defp get_page(page_string) when is_nil(page_string), do: 1 - - defp get_page(page_string) do - case Integer.parse(page_string) do - {page, _} -> page - :error -> 1 - end - end - - defp get_page_size(page_size_string) when is_nil(page_size_string), do: @users_page_size - - defp get_page_size(page_size_string) do - case Integer.parse(page_size_string) do - {page_size, _} -> page_size - :error -> @users_page_size - end + { + fetch_integer_param(params, "page", 1), + fetch_integer_param(params, "page_size", @users_page_size) + } end end -- cgit v1.2.3 From cf4f3937946eb0232be8bf8ddba08d365b4e17ee Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Mon, 21 Sep 2020 15:01:03 +0300 Subject: added AdminApi.UserController --- .../admin_api/controllers/admin_api_controller.ex | 242 +----------------- .../web/admin_api/controllers/user_controller.ex | 280 +++++++++++++++++++++ lib/pleroma/web/router.ex | 22 +- 3 files changed, 292 insertions(+), 252 deletions(-) create mode 100644 lib/pleroma/web/admin_api/controllers/user_controller.ex (limited to 'lib') diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index acfbeb0c8..df5817cfa 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -14,12 +14,9 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Stats alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.ActivityPub.Builder - alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.AdminAPI.ModerationLogView - alias Pleroma.Web.AdminAPI.Search alias Pleroma.Web.Endpoint alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.Router @@ -29,7 +26,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do plug( OAuthScopesPlug, %{scopes: ["read:accounts"], admin: true} - when action in [:list_users, :user_show, :right_get, :show_user_credentials] + when action in [:right_get, :show_user_credentials] ) plug( @@ -38,12 +35,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do when action in [ :get_password_reset, :force_password_reset, - :user_delete, - :users_create, - :user_toggle_activation, - :user_activate, - :user_deactivate, - :user_approve, :tag_users, :untag_users, :right_add, @@ -55,12 +46,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do ] ) - plug( - OAuthScopesPlug, - %{scopes: ["write:follows"], admin: true} - when action in [:user_follow, :user_unfollow] - ) - plug( OAuthScopesPlug, %{scopes: ["read:statuses"], admin: true} @@ -96,129 +81,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do action_fallback(AdminAPI.FallbackController) - def user_delete(conn, %{"nickname" => nickname}) do - user_delete(conn, %{"nicknames" => [nickname]}) - end - - def user_delete(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do - users = Enum.map(nicknames, &User.get_cached_by_nickname/1) - - Enum.each(users, fn user -> - {:ok, delete_data, _} = Builder.delete(admin, user.ap_id) - Pipeline.common_pipeline(delete_data, local: true) - end) - - ModerationLog.insert_log(%{ - actor: admin, - subject: users, - action: "delete" - }) - - json(conn, nicknames) - end - - def user_follow(%{assigns: %{user: admin}} = conn, %{ - "follower" => follower_nick, - "followed" => followed_nick - }) 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) - - ModerationLog.insert_log(%{ - actor: admin, - followed: followed, - follower: follower, - action: "follow" - }) - end - - json(conn, "ok") - end - - def user_unfollow(%{assigns: %{user: admin}} = conn, %{ - "follower" => follower_nick, - "followed" => followed_nick - }) 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) - - ModerationLog.insert_log(%{ - actor: admin, - followed: followed, - follower: follower, - action: "unfollow" - }) - end - - json(conn, "ok") - end - - def users_create(%{assigns: %{user: admin}} = conn, %{"users" => users}) do - changesets = - Enum.map(users, fn %{"nickname" => nickname, "email" => email, "password" => password} -> - user_data = %{ - nickname: nickname, - name: nickname, - email: email, - password: password, - password_confirmation: password, - bio: "." - } - - User.register_changeset(%User{}, user_data, need_confirmation: false) - end) - |> Enum.reduce(Ecto.Multi.new(), fn changeset, multi -> - Ecto.Multi.insert(multi, Ecto.UUID.generate(), changeset) - end) - - case Pleroma.Repo.transaction(changesets) do - {:ok, users} -> - res = - users - |> 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), - action: "create" - }) - - json(conn, res) - - {:error, id, changeset, _} -> - res = - Enum.map(changesets.operations, fn - {current_id, {:changeset, _current_changeset, _}} when current_id == id -> - AccountView.render("create-error.json", %{changeset: changeset}) - - {_, {:changeset, current_changeset, _}} -> - AccountView.render("create-error.json", %{changeset: current_changeset}) - end) - - conn - |> put_status(:conflict) - |> json(res) - end - end - - def user_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}) - else - _ -> {:error, :not_found} - end - end - def list_instance_statuses(conn, %{"instance" => instance} = params) do with_reblogs = params["with_reblogs"] == "true" || params["with_reblogs"] == true {page, page_size} = page_params(params) @@ -272,69 +134,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do end end - def user_toggle_activation(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do - user = User.get_cached_by_nickname(nickname) - - {:ok, updated_user} = User.deactivate(user, !user.deactivated) - - action = if user.deactivated, do: "activate", else: "deactivate" - - ModerationLog.insert_log(%{ - actor: admin, - subject: [user], - action: action - }) - - conn - |> put_view(AccountView) - |> render("show.json", %{user: updated_user}) - end - - def user_activate(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do - users = Enum.map(nicknames, &User.get_cached_by_nickname/1) - {:ok, updated_users} = User.deactivate(users, false) - - ModerationLog.insert_log(%{ - actor: admin, - subject: users, - action: "activate" - }) - - conn - |> put_view(AccountView) - |> render("index.json", %{users: Keyword.values(updated_users)}) - end - - def user_deactivate(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do - users = Enum.map(nicknames, &User.get_cached_by_nickname/1) - {:ok, updated_users} = User.deactivate(users, true) - - ModerationLog.insert_log(%{ - actor: admin, - subject: users, - action: "deactivate" - }) - - conn - |> put_view(AccountView) - |> render("index.json", %{users: Keyword.values(updated_users)}) - end - - def user_approve(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do - users = Enum.map(nicknames, &User.get_cached_by_nickname/1) - {:ok, updated_users} = User.approve(users) - - ModerationLog.insert_log(%{ - actor: admin, - subject: users, - action: "approve" - }) - - conn - |> put_view(AccountView) - |> render("index.json", %{users: updated_users}) - end - def tag_users(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames, "tags" => tags}) do with {:ok, _} <- User.tag(nicknames, tags) do ModerationLog.insert_log(%{ @@ -361,45 +160,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do end end - def list_users(conn, params) do - {page, page_size} = page_params(params) - filters = maybe_parse_filters(params["filters"]) - - search_params = - %{ - query: params["query"], - page: page, - page_size: page_size, - tags: params["tags"], - name: params["name"], - email: params["email"] - } - |> 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 - ) - ) - end - end - - @filters ~w(local external active deactivated need_approval need_confirmed is_admin is_moderator) - - @spec maybe_parse_filters(String.t()) :: %{required(String.t()) => true} | %{} - defp maybe_parse_filters(filters) when is_nil(filters) or filters == "", do: %{} - - defp maybe_parse_filters(filters) do - filters - |> String.split(",") - |> Enum.filter(&Enum.member?(@filters, &1)) - |> Map.new(&{String.to_existing_atom(&1), true}) - end - def right_add_multiple(%{assigns: %{user: admin}} = conn, %{ "permission_group" => permission_group, "nicknames" => nicknames diff --git a/lib/pleroma/web/admin_api/controllers/user_controller.ex b/lib/pleroma/web/admin_api/controllers/user_controller.ex new file mode 100644 index 000000000..5e049aa0f --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/user_controller.ex @@ -0,0 +1,280 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.UserController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, + only: [fetch_integer_param: 3] + + alias Pleroma.ModerationLog + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Pipeline + alias Pleroma.Web.AdminAPI + alias Pleroma.Web.AdminAPI.AccountView + alias Pleroma.Web.AdminAPI.Search + + @users_page_size 50 + + plug( + OAuthScopesPlug, + %{scopes: ["read:accounts"], admin: true} + when action in [:list, :show] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:accounts"], admin: true} + when action in [ + :delete, + :create, + :toggle_activation, + :activate, + :deactivate, + :approve + ] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:follows"], admin: true} + when action in [:follow, :unfollow] + ) + + action_fallback(AdminAPI.FallbackController) + + def delete(conn, %{"nickname" => nickname}) do + delete(conn, %{"nicknames" => [nickname]}) + end + + def delete(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do + users = Enum.map(nicknames, &User.get_cached_by_nickname/1) + + Enum.each(users, fn user -> + {:ok, delete_data, _} = Builder.delete(admin, user.ap_id) + Pipeline.common_pipeline(delete_data, local: true) + end) + + ModerationLog.insert_log(%{ + actor: admin, + subject: users, + action: "delete" + }) + + json(conn, nicknames) + end + + def follow(%{assigns: %{user: admin}} = conn, %{ + "follower" => follower_nick, + "followed" => followed_nick + }) 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) + + ModerationLog.insert_log(%{ + actor: admin, + followed: followed, + follower: follower, + action: "follow" + }) + end + + json(conn, "ok") + end + + def unfollow(%{assigns: %{user: admin}} = conn, %{ + "follower" => follower_nick, + "followed" => followed_nick + }) 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) + + ModerationLog.insert_log(%{ + actor: admin, + followed: followed, + follower: follower, + action: "unfollow" + }) + end + + json(conn, "ok") + end + + def create(%{assigns: %{user: admin}} = conn, %{"users" => users}) do + changesets = + Enum.map(users, fn %{"nickname" => nickname, "email" => email, "password" => password} -> + user_data = %{ + nickname: nickname, + name: nickname, + email: email, + password: password, + password_confirmation: password, + bio: "." + } + + User.register_changeset(%User{}, user_data, need_confirmation: false) + end) + |> Enum.reduce(Ecto.Multi.new(), fn changeset, multi -> + Ecto.Multi.insert(multi, Ecto.UUID.generate(), changeset) + end) + + case Pleroma.Repo.transaction(changesets) do + {:ok, users} -> + res = + users + |> 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), + action: "create" + }) + + json(conn, res) + + {:error, id, changeset, _} -> + res = + Enum.map(changesets.operations, fn + {current_id, {:changeset, _current_changeset, _}} when current_id == id -> + AccountView.render("create-error.json", %{changeset: changeset}) + + {_, {:changeset, current_changeset, _}} -> + AccountView.render("create-error.json", %{changeset: current_changeset}) + end) + + conn + |> put_status(:conflict) + |> json(res) + end + end + + 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}) + else + _ -> {:error, :not_found} + end + end + + def toggle_activation(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do + user = User.get_cached_by_nickname(nickname) + + {:ok, updated_user} = User.deactivate(user, !user.deactivated) + + action = if user.deactivated, do: "activate", else: "deactivate" + + ModerationLog.insert_log(%{ + actor: admin, + subject: [user], + action: action + }) + + conn + |> put_view(AccountView) + |> render("show.json", %{user: updated_user}) + end + + def activate(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do + users = Enum.map(nicknames, &User.get_cached_by_nickname/1) + {:ok, updated_users} = User.deactivate(users, false) + + ModerationLog.insert_log(%{ + actor: admin, + subject: users, + action: "activate" + }) + + conn + |> put_view(AccountView) + |> render("index.json", %{users: Keyword.values(updated_users)}) + end + + def deactivate(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do + users = Enum.map(nicknames, &User.get_cached_by_nickname/1) + {:ok, updated_users} = User.deactivate(users, true) + + ModerationLog.insert_log(%{ + actor: admin, + subject: users, + action: "deactivate" + }) + + conn + |> put_view(AccountView) + |> render("index.json", %{users: Keyword.values(updated_users)}) + end + + def approve(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do + users = Enum.map(nicknames, &User.get_cached_by_nickname/1) + {:ok, updated_users} = User.approve(users) + + ModerationLog.insert_log(%{ + actor: admin, + subject: users, + action: "approve" + }) + + conn + |> put_view(AccountView) + |> render("index.json", %{users: updated_users}) + end + + def list(conn, params) do + {page, page_size} = page_params(params) + filters = maybe_parse_filters(params["filters"]) + + search_params = + %{ + query: params["query"], + page: page, + page_size: page_size, + tags: params["tags"], + name: params["name"], + email: params["email"] + } + |> 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 + ) + ) + end + end + + @filters ~w(local external active deactivated need_approval need_confirmed is_admin is_moderator) + + @spec maybe_parse_filters(String.t()) :: %{required(String.t()) => true} | %{} + defp maybe_parse_filters(filters) when is_nil(filters) or filters == "", do: %{} + + defp maybe_parse_filters(filters) do + filters + |> String.split(",") + |> Enum.filter(&Enum.member?(@filters, &1)) + |> Map.new(&{String.to_existing_atom(&1), true}) + end + + defp page_params(params) do + { + fetch_integer_param(params, "page", 1), + fetch_integer_param(params, "page_size", @users_page_size) + } + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index d2d939989..3a9605778 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -129,16 +129,7 @@ defmodule Pleroma.Web.Router do scope "/api/pleroma/admin", Pleroma.Web.AdminAPI do pipe_through(:admin_api) - post("/users/follow", AdminAPIController, :user_follow) - post("/users/unfollow", AdminAPIController, :user_unfollow) - put("/users/disable_mfa", AdminAPIController, :disable_mfa) - delete("/users", AdminAPIController, :user_delete) - post("/users", AdminAPIController, :users_create) - patch("/users/:nickname/toggle_activation", AdminAPIController, :user_toggle_activation) - patch("/users/activate", AdminAPIController, :user_activate) - patch("/users/deactivate", AdminAPIController, :user_deactivate) - patch("/users/approve", AdminAPIController, :user_approve) put("/users/tag", AdminAPIController, :tag_users) delete("/users/tag", AdminAPIController, :untag_users) @@ -161,6 +152,15 @@ defmodule Pleroma.Web.Router do :right_delete_multiple ) + post("/users/follow", UserController, :follow) + post("/users/unfollow", UserController, :unfollow) + delete("/users", UserController, :delete) + post("/users", UserController, :create) + patch("/users/:nickname/toggle_activation", UserController, :toggle_activation) + patch("/users/activate", UserController, :activate) + patch("/users/deactivate", UserController, :deactivate) + patch("/users/approve", UserController, :approve) + get("/relay", RelayController, :index) post("/relay", RelayController, :follow) delete("/relay", RelayController, :unfollow) @@ -175,8 +175,8 @@ defmodule Pleroma.Web.Router do get("/users/:nickname/credentials", AdminAPIController, :show_user_credentials) patch("/users/:nickname/credentials", AdminAPIController, :update_user_credentials) - get("/users", AdminAPIController, :list_users) - get("/users/:nickname", AdminAPIController, :user_show) + get("/users", UserController, :list) + get("/users/:nickname", UserController, :show) get("/users/:nickname/statuses", AdminAPIController, :list_user_statuses) get("/users/:nickname/chats", AdminAPIController, :list_user_chats) -- cgit v1.2.3 From 46b420aa602050d7b3bff33a6b51d54852b2adb3 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Fri, 25 Sep 2020 09:39:49 +0300 Subject: need_confirmed -> unconfirmed --- lib/pleroma/user/query.ex | 4 ++-- lib/pleroma/web/admin_api/controllers/user_controller.ex | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex index 2933e7fb4..711439eef 100644 --- a/lib/pleroma/user/query.ex +++ b/lib/pleroma/user/query.ex @@ -43,7 +43,7 @@ defmodule Pleroma.User.Query do active: boolean(), deactivated: boolean(), need_approval: boolean(), - need_confirmed: boolean(), + unconfirmed: boolean(), is_admin: boolean(), is_moderator: boolean(), super_users: boolean(), @@ -157,7 +157,7 @@ defmodule Pleroma.User.Query do where(query, [u], u.approval_pending) end - defp compose_query({:need_confirmed, _}, query) do + defp compose_query({:unconfirmed, _}, query) do where(query, [u], u.confirmation_pending) end diff --git a/lib/pleroma/web/admin_api/controllers/user_controller.ex b/lib/pleroma/web/admin_api/controllers/user_controller.ex index 5e049aa0f..db6ef3220 100644 --- a/lib/pleroma/web/admin_api/controllers/user_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/user_controller.ex @@ -259,7 +259,7 @@ defmodule Pleroma.Web.AdminAPI.UserController do end end - @filters ~w(local external active deactivated need_approval need_confirmed is_admin is_moderator) + @filters ~w(local external active deactivated need_approval unconfirmed is_admin is_moderator) @spec maybe_parse_filters(String.t()) :: %{required(String.t()) => true} | %{} defp maybe_parse_filters(filters) when is_nil(filters) or filters == "", do: %{} -- cgit v1.2.3 From 60663150b5b936c831bdc0cfeade30867e536317 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Fri, 25 Sep 2020 16:04:01 +0300 Subject: admin user search: added filter by `actor_type` --- lib/pleroma/user/query.ex | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex index 711439eef..7ef2a1455 100644 --- a/lib/pleroma/user/query.ex +++ b/lib/pleroma/user/query.ex @@ -56,7 +56,8 @@ defmodule Pleroma.User.Query do ap_id: [String.t()], order_by: term(), select: term(), - limit: pos_integer() + limit: pos_integer(), + actor_types: [String.t()] } | map() @@ -115,6 +116,10 @@ defmodule Pleroma.User.Query do where(query, [u], u.is_admin == ^bool) end + defp compose_query({:actor_types, actor_types}, query) when is_list(actor_types) do + where(query, [u], u.actor_type in ^actor_types) + end + defp compose_query({:is_moderator, bool}, query) do where(query, [u], u.is_moderator == ^bool) end -- cgit v1.2.3 From 44e5a57d1a1b36ad2971763f3a8cab82bdb94b08 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Fri, 25 Sep 2020 16:37:55 +0300 Subject: admin api: added user filters by `actor_types` --- lib/pleroma/web/admin_api/controllers/user_controller.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/admin_api/controllers/user_controller.ex b/lib/pleroma/web/admin_api/controllers/user_controller.ex index db6ef3220..15cec9b09 100644 --- a/lib/pleroma/web/admin_api/controllers/user_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/user_controller.ex @@ -243,7 +243,8 @@ defmodule Pleroma.Web.AdminAPI.UserController do page_size: page_size, tags: params["tags"], name: params["name"], - email: params["email"] + email: params["email"], + actor_types: params["actor_types"] } |> Map.merge(filters) -- cgit v1.2.3 From ef627b9391e0dde8adf03c0132fbc2eeac6bdede Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Thu, 22 Oct 2020 12:04:23 +0300 Subject: fix module name --- lib/pleroma/web/admin_api/controllers/user_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/admin_api/controllers/user_controller.ex b/lib/pleroma/web/admin_api/controllers/user_controller.ex index 15cec9b09..a2a1c875d 100644 --- a/lib/pleroma/web/admin_api/controllers/user_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/user_controller.ex @@ -9,13 +9,13 @@ defmodule Pleroma.Web.AdminAPI.UserController do only: [fetch_integer_param: 3] alias Pleroma.ModerationLog - alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User 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 -- cgit v1.2.3 From 8d251096fe71d289cf6a50f3d5638fb43b07c24a Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 22 Oct 2020 12:22:08 +0200 Subject: SideEffects: Correctly handle chat messages sent to yourself --- lib/pleroma/web/activity_pub/side_effects.ex | 1 + 1 file changed, 1 insertion(+) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index d421ca7af..0fff5faf2 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -306,6 +306,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do streamables = [[actor, recipient], [recipient, actor]] + |> Enum.uniq() |> Enum.map(fn [user, other_user] -> if user.local do {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) -- cgit v1.2.3 From 8a55de1d785178786b9ae7c185ea1d1f187f7470 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 22 Oct 2020 13:54:15 +0300 Subject: [#3059] Fixed Phoenix 1.5 telemetry processing. --- lib/pleroma/application.ex | 5 ++++- lib/pleroma/web/endpoint.ex | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index d7e3a649f..51e9dda3b 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -151,7 +151,10 @@ defmodule Pleroma.Application do Pleroma.Web.Endpoint.MetricsExporter.setup() Pleroma.Web.Endpoint.PipelineInstrumenter.setup() - Pleroma.Web.Endpoint.Instrumenter.setup() + + # Note: disabled until prometheus-phx is integrated into prometheus-phoenix: + # Pleroma.Web.Endpoint.Instrumenter.setup() + PrometheusPhx.setup() end defp cachex_children do diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index 56562c12f..f15aab8cb 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -9,6 +9,8 @@ defmodule Pleroma.Web.Endpoint do socket("/socket", Pleroma.Web.UserSocket) + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + plug(Pleroma.Web.Plugs.SetLocalePlug) plug(CORSPlug) plug(Pleroma.Web.Plugs.HTTPSecurityPlug) -- cgit v1.2.3 From 8a68673eeda5bcf314051ef83d5a402cf280a52b Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 22 Oct 2020 14:07:33 +0300 Subject: [#3059] Formatting fix. --- lib/pleroma/web/endpoint.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index f15aab8cb..d0e01f3d9 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.Endpoint do socket("/socket", Pleroma.Web.UserSocket) - plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint]) plug(Pleroma.Web.Plugs.SetLocalePlug) plug(CORSPlug) -- cgit v1.2.3 From 60e379ce0b74bbe1b0f40a954aec040beab20e4e Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 23 Oct 2020 13:53:01 +0200 Subject: User: Correctly handle whitespace names. --- lib/pleroma/user.ex | 5 +++-- lib/pleroma/web/activity_pub/activity_pub.ex | 4 ---- 2 files changed, 3 insertions(+), 6 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index dc41d0001..72f507f1e 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -426,7 +426,6 @@ defmodule Pleroma.User do params, [ :bio, - :name, :emoji, :ap_id, :inbox, @@ -455,7 +454,9 @@ defmodule Pleroma.User do :accepts_chat_messages ] ) - |> validate_required([:name, :ap_id]) + |> cast(params, [:name], empty_values: []) + |> validate_required([:ap_id]) + |> validate_required([:name], trim: false) |> unique_constraint(:nickname) |> validate_format(:nickname, @email_regex) |> validate_length(:bio, max: bio_limit) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index d17c892a7..df18db603 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1378,10 +1378,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do {:ok, data} <- user_data_from_user_object(data) do {:ok, maybe_update_follow_information(data)} else - {:error, "Object has been deleted" = e} -> - Logger.debug("Could not decode user at fetch #{ap_id}, #{inspect(e)}") - {:error, e} - {:error, {:reject, reason} = e} -> Logger.info("Rejected user #{ap_id}: #{inspect(reason)}") {:error, e} -- cgit v1.2.3 From de6d49c8cec84a530f2835313c95064ae8df3604 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 26 Oct 2020 16:33:26 +0100 Subject: ActivityPub: Add back debug call + explanation. --- lib/pleroma/web/activity_pub/activity_pub.ex | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index df18db603..13869f897 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1378,6 +1378,11 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do {:ok, data} <- user_data_from_user_object(data) do {:ok, maybe_update_follow_information(data)} else + # If this has been deleted, only log a debug and not an error + {:error, "Object has been deleted" = e} -> + Logger.debug("Could not decode user at fetch #{ap_id}, #{inspect(e)}") + {:error, e} + {:error, {:reject, reason} = e} -> Logger.info("Rejected user #{ap_id}: #{inspect(reason)}") {:error, e} -- cgit v1.2.3 From 03e306785b2013fe6fd47b59d4e578c6ed586b08 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 27 Oct 2020 19:20:04 +0400 Subject: Add an API endpoint to install a new frontend --- lib/mix/tasks/pleroma/frontend.ex | 107 +------------------- lib/pleroma/frontend.ex | 108 +++++++++++++++++++++ .../admin_api/controllers/frontend_controller.ex | 41 ++++++++ lib/pleroma/web/admin_api/views/frontend_view.ex | 21 ++++ .../operations/admin/frontend_operation.ex | 77 +++++++++++++++ lib/pleroma/web/router.ex | 3 + lib/pleroma/workers/frontend_installer_worker.ex | 21 ++++ 7 files changed, 272 insertions(+), 106 deletions(-) create mode 100644 lib/pleroma/frontend.ex create mode 100644 lib/pleroma/web/admin_api/controllers/frontend_controller.ex create mode 100644 lib/pleroma/web/admin_api/views/frontend_view.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex create mode 100644 lib/pleroma/workers/frontend_installer_worker.ex (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/frontend.ex b/lib/mix/tasks/pleroma/frontend.ex index cbce81ab9..f15dbc38b 100644 --- a/lib/mix/tasks/pleroma/frontend.ex +++ b/lib/mix/tasks/pleroma/frontend.ex @@ -17,8 +17,6 @@ defmodule Mix.Tasks.Pleroma.Frontend do end def run(["install", frontend | args]) do - log_level = Logger.level() - Logger.configure(level: :warn) start_pleroma() {options, [], []} = @@ -33,109 +31,6 @@ defmodule Mix.Tasks.Pleroma.Frontend do ] ) - instance_static_dir = - with nil <- options[:static_dir] do - Pleroma.Config.get!([:instance, :static_dir]) - end - - cmd_frontend_info = %{ - "name" => frontend, - "ref" => options[:ref], - "build_url" => options[:build_url], - "build_dir" => options[:build_dir] - } - - config_frontend_info = Pleroma.Config.get([:frontends, :available, frontend], %{}) - - frontend_info = - Map.merge(config_frontend_info, cmd_frontend_info, fn _key, config, cmd -> - # This only overrides things that are actually set - cmd || config - end) - - ref = frontend_info["ref"] - - unless ref do - raise "No ref given or configured" - end - - dest = - Path.join([ - instance_static_dir, - "frontends", - frontend, - ref - ]) - - fe_label = "#{frontend} (#{ref})" - - tmp_dir = Path.join([instance_static_dir, "frontends", "tmp"]) - - with {_, :ok} <- - {:download_or_unzip, download_or_unzip(frontend_info, tmp_dir, options[:file])}, - shell_info("Installing #{fe_label} to #{dest}"), - :ok <- install_frontend(frontend_info, tmp_dir, dest) do - File.rm_rf!(tmp_dir) - shell_info("Frontend #{fe_label} installed to #{dest}") - - Logger.configure(level: log_level) - else - {:download_or_unzip, _} -> - shell_info("Could not download or unzip the frontend") - - _e -> - shell_info("Could not install the frontend") - end - end - - defp download_or_unzip(frontend_info, temp_dir, file) do - if file do - with {:ok, zip} <- File.read(Path.expand(file)) do - unzip(zip, temp_dir) - end - else - download_build(frontend_info, temp_dir) - end - end - - def unzip(zip, dest) do - with {:ok, unzipped} <- :zip.unzip(zip, [:memory]) do - File.rm_rf!(dest) - File.mkdir_p!(dest) - - Enum.each(unzipped, fn {filename, data} -> - path = filename - - new_file_path = Path.join(dest, path) - - new_file_path - |> Path.dirname() - |> File.mkdir_p!() - - File.write!(new_file_path, data) - end) - - :ok - end - end - - defp download_build(frontend_info, dest) do - shell_info("Downloading pre-built bundle for #{frontend_info["name"]}") - url = String.replace(frontend_info["build_url"], "${ref}", frontend_info["ref"]) - - with {:ok, %{status: 200, body: zip_body}} <- - Pleroma.HTTP.get(url, [], pool: :media, recv_timeout: 120_000) do - unzip(zip_body, dest) - else - e -> {:error, e} - end - end - - defp install_frontend(frontend_info, source, dest) do - from = frontend_info["build_dir"] || "dist" - File.rm_rf!(dest) - File.mkdir_p!(dest) - File.cp_r!(Path.join([source, from]), dest) - :ok + Pleroma.Frontend.install(frontend, options) end end diff --git a/lib/pleroma/frontend.ex b/lib/pleroma/frontend.ex new file mode 100644 index 000000000..3413d2fba --- /dev/null +++ b/lib/pleroma/frontend.ex @@ -0,0 +1,108 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Frontend do + alias Pleroma.Config + + require Logger + + def install(name, opts \\ []) do + cmd_frontend_info = %{ + "ref" => opts[:ref], + "build_url" => opts[:build_url], + "build_dir" => opts[:build_dir] + } + + config_frontend_info = Config.get([:frontends, :available, name], %{}) + + frontend_info = + Map.merge(config_frontend_info, cmd_frontend_info, fn _key, config, cmd -> + # This only overrides things that are actually set + cmd || config + end) + + ref = frontend_info["ref"] + + unless ref do + raise "No ref given or configured" + end + + dest = Path.join([dir(), name, ref]) + + label = "#{name} (#{ref})" + tmp_dir = Path.join(dir(), "tmp") + + with {_, :ok} <- + {:download_or_unzip, download_or_unzip(frontend_info, tmp_dir, opts[:file])}, + Logger.info("Installing #{label} to #{dest}"), + :ok <- install_frontend(frontend_info, tmp_dir, dest) do + File.rm_rf!(tmp_dir) + Logger.info("Frontend #{label} installed to #{dest}") + else + {:download_or_unzip, _} -> + Logger.info("Could not download or unzip the frontend") + + _e -> + Logger.info("Could not install the frontend") + end + end + + def dir(opts \\ []) do + if is_nil(opts[:static_dir]) do + Pleroma.Config.get!([:instance, :static_dir]) + else + opts[:static_dir] + end + |> Path.join("frontends") + end + + defp download_or_unzip(frontend_info, temp_dir, nil), + do: download_build(frontend_info, temp_dir) + + defp download_or_unzip(_frontend_info, temp_dir, file) do + with {:ok, zip} <- File.read(Path.expand(file)) do + unzip(zip, temp_dir) + end + end + + def unzip(zip, dest) do + with {:ok, unzipped} <- :zip.unzip(zip, [:memory]) do + File.rm_rf!(dest) + File.mkdir_p!(dest) + + Enum.each(unzipped, fn {filename, data} -> + path = filename + + new_file_path = Path.join(dest, path) + + new_file_path + |> Path.dirname() + |> File.mkdir_p!() + + File.write!(new_file_path, data) + end) + end + end + + defp download_build(frontend_info, dest) do + Logger.info("Downloading pre-built bundle for #{frontend_info["name"]}") + url = String.replace(frontend_info["build_url"], "${ref}", frontend_info["ref"]) + + with {:ok, %{status: 200, body: zip_body}} <- + Pleroma.HTTP.get(url, [], pool: :media, recv_timeout: 120_000) do + unzip(zip_body, dest) + else + {:error, e} -> {:error, e} + e -> {:error, e} + end + end + + defp install_frontend(frontend_info, source, dest) do + from = frontend_info["build_dir"] || "dist" + File.rm_rf!(dest) + File.mkdir_p!(dest) + File.cp_r!(Path.join([source, from]), dest) + :ok + end +end diff --git a/lib/pleroma/web/admin_api/controllers/frontend_controller.ex b/lib/pleroma/web/admin_api/controllers/frontend_controller.ex new file mode 100644 index 000000000..59c69aba1 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/frontend_controller.ex @@ -0,0 +1,41 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.FrontendController do + use Pleroma.Web, :controller + + alias Pleroma.Config + alias Pleroma.Web.Plugs.OAuthScopesPlug + alias Pleroma.Workers.FrontendInstallerWorker + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(OAuthScopesPlug, %{scopes: ["write"], admin: true} when action == :install) + plug(OAuthScopesPlug, %{scopes: ["read"], admin: true} when action == :index) + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.FrontendOperation + + def index(conn, _params) do + installed = installed() + + frontends = + [:frontends, :available] + |> Config.get([]) + |> Enum.map(fn {name, desc} -> + Map.put(desc, "installed", name in installed) + end) + + render(conn, "index.json", frontends: frontends) + end + + def install(%{body_params: params} = conn, _params) do + FrontendInstallerWorker.install(params.name, Map.delete(params, :name)) + + index(conn, %{}) + end + + defp installed do + File.ls!(Pleroma.Frontend.dir()) + end +end diff --git a/lib/pleroma/web/admin_api/views/frontend_view.ex b/lib/pleroma/web/admin_api/views/frontend_view.ex new file mode 100644 index 000000000..374841d0b --- /dev/null +++ b/lib/pleroma/web/admin_api/views/frontend_view.ex @@ -0,0 +1,21 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.FrontendView do + use Pleroma.Web, :view + + def render("index.json", %{frontends: frontends}) do + render_many(frontends, __MODULE__, "show.json") + end + + def render("show.json", %{frontend: frontend}) do + %{ + name: frontend["name"], + git: frontend["git"], + build_url: frontend["build_url"], + ref: frontend["ref"], + installed: frontend["installed"] + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex b/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex new file mode 100644 index 000000000..24d23a4e0 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex @@ -0,0 +1,77 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.FrontendOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Admin", "Reports"], + summary: "Get a list of available frontends", + operationId: "AdminAPI.FrontendController.index", + security: [%{"oAuth" => ["read"]}], + responses: %{ + 200 => Operation.response("Response", "application/json", list_of_frontends()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def install_operation do + %Operation{ + tags: ["Admin", "Reports"], + summary: "Install a frontend", + operationId: "AdminAPI.FrontendController.install", + security: [%{"oAuth" => ["read"]}], + requestBody: request_body("Parameters", install_request(), required: true), + responses: %{ + 200 => Operation.response("Response", "application/json", list_of_frontends()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + defp list_of_frontends do + %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + name: %Schema{type: :string}, + git: %Schema{type: :string, format: :uri, nullable: true}, + build_url: %Schema{type: :string, format: :uri}, + ref: %Schema{type: :string}, + installed: %Schema{type: :boolean} + } + } + } + end + + defp install_request do + %Schema{ + title: "FrontendInstallRequest", + type: :object, + required: [:name], + properties: %{ + name: %Schema{ + type: :string, + nullable: false + }, + ref: %Schema{ + type: :string, + nullable: false + } + } + } + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index d2d939989..aba505a66 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -223,6 +223,9 @@ defmodule Pleroma.Web.Router do get("/chats/:id", ChatController, :show) get("/chats/:id/messages", ChatController, :messages) delete("/chats/:id/messages/:message_id", ChatController, :delete_message) + + get("/frontends", FrontendController, :index) + post("/frontends", FrontendController, :install) end scope "/api/pleroma/emoji", Pleroma.Web.PleromaAPI do diff --git a/lib/pleroma/workers/frontend_installer_worker.ex b/lib/pleroma/workers/frontend_installer_worker.ex new file mode 100644 index 000000000..38688c63b --- /dev/null +++ b/lib/pleroma/workers/frontend_installer_worker.ex @@ -0,0 +1,21 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.FrontendInstallerWorker do + use Oban.Worker, queue: :frontend_installer, max_attempts: 1 + + alias Oban.Job + alias Pleroma.Frontend + + def install(name, opts \\ []) do + %{"name" => name, "opts" => Map.new(opts)} + |> new() + |> Oban.insert() + end + + def perform(%Job{args: %{"name" => name, "opts" => opts}}) do + opts = Keyword.new(opts, fn {key, value} -> {String.to_existing_atom(key), value} end) + Frontend.install(name, opts) + end +end -- cgit v1.2.3 From d28f72a55af9442719ff01fe7052802c285f6ea8 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Tue, 27 Oct 2020 22:58:55 +0300 Subject: FrontStatic plug: excluded invalid url --- lib/pleroma/web/plugs/frontend_static.ex | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/plugs/frontend_static.ex b/lib/pleroma/web/plugs/frontend_static.ex index ceb10dcf8..1b0b36813 100644 --- a/lib/pleroma/web/plugs/frontend_static.ex +++ b/lib/pleroma/web/plugs/frontend_static.ex @@ -34,22 +34,26 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do end def call(conn, opts) do - frontend_type = Map.get(opts, :frontend_type, :primary) - path = file_path("", frontend_type) - - if path do - conn - |> call_static(opts, path) + with false <- invalid_path?(conn.path_info), + frontend_type <- Map.get(opts, :frontend_type, :primary), + path when not is_nil(path) <- file_path("", frontend_type) do + call_static(conn, opts, path) else - conn + _ -> + conn end end - defp call_static(conn, opts, from) do - opts = - opts - |> Map.put(:from, from) + defp invalid_path?(list) do + invalid_path?(list, :binary.compile_pattern(["/", "\\", ":", "\0"])) + end + defp invalid_path?([h | _], _match) when h in [".", "..", ""], do: true + defp invalid_path?([h | t], match), do: String.contains?(h, match) or invalid_path?(t) + defp invalid_path?([], _match), do: false + + defp call_static(conn, opts, from) do + opts = Map.put(opts, :from, from) Plug.Static.call(conn, opts) end end -- cgit v1.2.3 From da4a1e57b11d5600788b90f21d18bbcd97f6849f Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 28 Oct 2020 19:09:38 +0300 Subject: @doc fix. --- lib/pleroma/web/static_fe/static_fe_controller.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/static_fe/static_fe_controller.ex b/lib/pleroma/web/static_fe/static_fe_controller.ex index 76b82589f..bdec0897a 100644 --- a/lib/pleroma/web/static_fe/static_fe_controller.ex +++ b/lib/pleroma/web/static_fe/static_fe_controller.ex @@ -19,7 +19,7 @@ defmodule Pleroma.Web.StaticFE.StaticFEController do @page_keys ["max_id", "min_id", "limit", "since_id", "order"] - @doc "Renders requested local public activity" + @doc "Renders requested local public activity or public activities of requested user" def show(%{assigns: %{notice_id: notice_id}} = conn, _params) do with %Activity{local: true} = activity <- Activity.get_by_id_with_object(notice_id), @@ -46,7 +46,6 @@ defmodule Pleroma.Web.StaticFE.StaticFEController do end end - @doc "Renders public activities of requested user" def show(%{assigns: %{username_or_id: username_or_id}} = conn, params) do with {_, %User{local: true} = user} <- {:fetch_user, User.get_cached_by_nickname_or_id(username_or_id)}, -- cgit v1.2.3 From 9f5f7dc9f956359204fc44a0627e20fd9765d8bd Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 28 Oct 2020 22:29:52 +0300 Subject: Fixed User.is_discoverable attribute rendering in Admin API User view. --- lib/pleroma/web/admin_api/views/account_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index bda7ea19c..8bac24d3e 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -52,7 +52,7 @@ defmodule Pleroma.Web.AdminAPI.AccountView do :skip_thread_containment, :pleroma_settings_store, :raw_fields, - :discoverable, + :is_discoverable, :actor_type ]) |> Map.merge(%{ -- cgit v1.2.3 From d83c2bd330d1ed01b84634b70dfe024020ebfd6c Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 29 Oct 2020 16:37:50 +0400 Subject: Add support for install via `file` and `build_url` params --- lib/pleroma/frontend.ex | 8 ++++---- .../web/api_spec/operations/admin/frontend_operation.ex | 17 ++++++++++++----- 2 files changed, 16 insertions(+), 9 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/frontend.ex b/lib/pleroma/frontend.ex index 3413d2fba..b3d4c3325 100644 --- a/lib/pleroma/frontend.ex +++ b/lib/pleroma/frontend.ex @@ -8,16 +8,16 @@ defmodule Pleroma.Frontend do require Logger def install(name, opts \\ []) do - cmd_frontend_info = %{ + frontend_info = %{ "ref" => opts[:ref], "build_url" => opts[:build_url], "build_dir" => opts[:build_dir] } - config_frontend_info = Config.get([:frontends, :available, name], %{}) - frontend_info = - Map.merge(config_frontend_info, cmd_frontend_info, fn _key, config, cmd -> + [:frontends, :available, name] + |> Config.get(%{}) + |> Map.merge(frontend_info, fn _key, config, cmd -> # This only overrides things that are actually set cmd || config end) diff --git a/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex b/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex index 24d23a4e0..9d7d017a2 100644 --- a/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex @@ -49,7 +49,7 @@ defmodule Pleroma.Web.ApiSpec.Admin.FrontendOperation do properties: %{ name: %Schema{type: :string}, git: %Schema{type: :string, format: :uri, nullable: true}, - build_url: %Schema{type: :string, format: :uri}, + build_url: %Schema{type: :string, format: :uri, nullable: true}, ref: %Schema{type: :string}, installed: %Schema{type: :boolean} } @@ -64,12 +64,19 @@ defmodule Pleroma.Web.ApiSpec.Admin.FrontendOperation do required: [:name], properties: %{ name: %Schema{ - type: :string, - nullable: false + type: :string }, ref: %Schema{ - type: :string, - nullable: false + type: :string + }, + file: %Schema{ + type: :string + }, + build_url: %Schema{ + type: :string + }, + build_dir: %Schema{ + type: :string } } } -- cgit v1.2.3 From 241bd061fc60a5c90c172f46f3b4e576ba660aaf Mon Sep 17 00:00:00 2001 From: Alibek Omarov Date: Fri, 16 Oct 2020 18:28:27 +0000 Subject: ConversationView: add current user to conversations, according to Mastodon behaviour --- lib/pleroma/web/mastodon_api/views/conversation_view.ex | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/mastodon_api/views/conversation_view.ex b/lib/pleroma/web/mastodon_api/views/conversation_view.ex index a91994915..cf34933ab 100644 --- a/lib/pleroma/web/mastodon_api/views/conversation_view.ex +++ b/lib/pleroma/web/mastodon_api/views/conversation_view.ex @@ -33,12 +33,10 @@ defmodule Pleroma.Web.MastodonAPI.ConversationView do end activity = Activity.get_by_id_with_object(last_activity_id) - # Conversations return all users except the current user. - users = Enum.reject(participation.recipients, &(&1.id == user.id)) %{ id: participation.id |> to_string(), - accounts: render(AccountView, "index.json", users: users, for: user), + accounts: render(AccountView, "index.json", users: participation.recipients, for: user), unread: !participation.read, last_status: render(StatusView, "show.json", -- cgit v1.2.3 From 9b93eef71550eabf55b9728b6c8925a4dede222d Mon Sep 17 00:00:00 2001 From: Alibek Omarov Date: Fri, 30 Oct 2020 13:01:58 +0100 Subject: ConversationView: fix last_status.account being empty, fix current user being included in group conversations --- lib/pleroma/web/mastodon_api/views/conversation_view.ex | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/mastodon_api/views/conversation_view.ex b/lib/pleroma/web/mastodon_api/views/conversation_view.ex index cf34933ab..4636c00e3 100644 --- a/lib/pleroma/web/mastodon_api/views/conversation_view.ex +++ b/lib/pleroma/web/mastodon_api/views/conversation_view.ex @@ -34,14 +34,22 @@ defmodule Pleroma.Web.MastodonAPI.ConversationView do activity = Activity.get_by_id_with_object(last_activity_id) + # Conversations return all users except current user when current user is not only participant + users = if length(participation.recipients) > 1 do + Enum.reject(participation.recipients, &(&1.id == user.id)) + else + participation.recipients + end + %{ id: participation.id |> to_string(), - accounts: render(AccountView, "index.json", users: participation.recipients, for: user), + accounts: render(AccountView, "index.json", users: users, for: user), unread: !participation.read, last_status: render(StatusView, "show.json", activity: activity, - direct_conversation_id: participation.id + direct_conversation_id: participation.id, + for: user ) } end -- cgit v1.2.3 From d63ec02f31e5ee7bb278c4247a83900aceb9193a Mon Sep 17 00:00:00 2001 From: Alibek Omarov Date: Fri, 30 Oct 2020 13:25:13 +0100 Subject: ConversationView: fix formatting --- lib/pleroma/web/mastodon_api/views/conversation_view.ex | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/mastodon_api/views/conversation_view.ex b/lib/pleroma/web/mastodon_api/views/conversation_view.ex index 4636c00e3..545778165 100644 --- a/lib/pleroma/web/mastodon_api/views/conversation_view.ex +++ b/lib/pleroma/web/mastodon_api/views/conversation_view.ex @@ -35,11 +35,12 @@ defmodule Pleroma.Web.MastodonAPI.ConversationView do activity = Activity.get_by_id_with_object(last_activity_id) # Conversations return all users except current user when current user is not only participant - users = if length(participation.recipients) > 1 do - Enum.reject(participation.recipients, &(&1.id == user.id)) - else - participation.recipients - end + users = + if length(participation.recipients) > 1 do + Enum.reject(participation.recipients, &(&1.id == user.id)) + else + participation.recipients + end %{ id: participation.id |> to_string(), -- cgit v1.2.3 From d1698267a27bd5084916f5f6f36d66b1ff2ffc5f Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Sat, 31 Oct 2020 00:26:11 +0400 Subject: Fix credo warning --- lib/pleroma/web/router.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 9592d0f38..efe67ad7a 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -148,7 +148,7 @@ defmodule Pleroma.Web.Router do scope "/api/pleroma/admin", Pleroma.Web.AdminAPI do pipe_through(:admin_api) - + put("/users/disable_mfa", AdminAPIController, :disable_mfa) put("/users/tag", AdminAPIController, :tag_users) delete("/users/tag", AdminAPIController, :untag_users) -- cgit v1.2.3 From 8e41baff40555ef7c74c8842d6fbfebc2368631a Mon Sep 17 00:00:00 2001 From: eugenijm Date: Sat, 31 Oct 2020 05:50:48 +0300 Subject: Add idempotency_key to the chat_message entity. --- lib/pleroma/application.ex | 9 ++++++++- lib/pleroma/web/activity_pub/side_effects.ex | 6 ++++++ lib/pleroma/web/common_api.ex | 3 ++- lib/pleroma/web/pleroma_api/controllers/chat_controller.ex | 10 +++++++++- .../web/pleroma_api/views/chat/message_reference_view.ex | 11 +++++++++++ 5 files changed, 36 insertions(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 51e9dda3b..7c4cd9626 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -168,7 +168,11 @@ defmodule Pleroma.Application do build_cachex("web_resp", limit: 2500), build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10), build_cachex("failed_proxy_url", limit: 2500), - build_cachex("banned_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000) + build_cachex("banned_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000), + build_cachex("chat_message_id_idempotency_key", + expiration: chat_message_id_idempotency_key_expiration(), + limit: 500_000 + ) ] end @@ -178,6 +182,9 @@ defmodule Pleroma.Application do defp idempotency_expiration, do: expiration(default: :timer.seconds(6 * 60 * 60), interval: :timer.seconds(60)) + defp chat_message_id_idempotency_key_expiration, + do: expiration(default: :timer.minutes(2), interval: :timer.seconds(60)) + defp seconds_valid_interval, do: :timer.seconds(Config.get!([Pleroma.Captcha, :seconds_valid])) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 0fff5faf2..d552e91fc 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -312,6 +312,12 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) {:ok, cm_ref} = MessageReference.create(chat, object, user.ap_id != actor.ap_id) + Cachex.put( + :chat_message_id_idempotency_key_cache, + cm_ref.id, + meta[:idempotency_key] + ) + { ["user", "user:pleroma_chat"], {user, %{cm_ref | chat: chat, object: object}} diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex index 60a50b027..318ffc5d0 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -45,7 +45,8 @@ defmodule Pleroma.Web.CommonAPI do {_, {:ok, %Activity{} = activity, _meta}} <- {:common_pipeline, Pipeline.common_pipeline(create_activity_data, - local: true + local: true, + idempotency_key: opts[:idempotency_key] )} do {:ok, activity} else diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 6357148d0..2c4d3f135 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -80,7 +80,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do %User{} = recipient <- User.get_cached_by_ap_id(chat.recipient), {:ok, activity} <- CommonAPI.post_chat_message(user, recipient, params[:content], - media_id: params[:media_id] + media_id: params[:media_id], + idempotency_key: idempotency_key(conn) ), message <- Object.normalize(activity, false), cm_ref <- MessageReference.for_chat_and_object(chat, message) do @@ -169,4 +170,11 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do |> render("show.json", chat: chat) end end + + defp idempotency_key(conn) do + case get_req_header(conn, "idempotency-key") do + [key] -> key + _ -> nil + end + end end diff --git a/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex b/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex index d4e08b50d..c058fb340 100644 --- a/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.PleromaAPI.Chat.MessageReferenceView do use Pleroma.Web, :view + alias Pleroma.Maps alias Pleroma.User alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.StatusView @@ -37,6 +38,7 @@ defmodule Pleroma.Web.PleromaAPI.Chat.MessageReferenceView do Pleroma.Web.RichMedia.Helpers.fetch_data_for_object(object) ) } + |> put_idempotency_key() end def render("index.json", opts) do @@ -47,4 +49,13 @@ defmodule Pleroma.Web.PleromaAPI.Chat.MessageReferenceView do Map.put(opts, :as, :chat_message_reference) ) end + + defp put_idempotency_key(data) do + with {:ok, idempotency_key} <- Cachex.get(:chat_message_id_idempotency_key_cache, data.id) do + data + |> Maps.put_if_present(:idempotency_key, idempotency_key) + else + _ -> data + end + end end -- cgit v1.2.3 From 04f6b48ac1a76fe9c6c3fd573427d418bc152adf Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 31 Oct 2020 13:38:35 +0300 Subject: Auth subsystem refactoring and tweaks. Added proper OAuth skipping for SessionAuthenticationPlug. Integrated LegacyAuthenticationPlug into AuthenticationPlug. Adjusted tests & docs. --- lib/pleroma/helpers/auth_helper.ex | 17 ++++++ .../web/plugs/admin_secret_authentication_plug.ex | 4 +- lib/pleroma/web/plugs/authentication_plug.ex | 63 ++++++++++------------ lib/pleroma/web/plugs/basic_auth_decoder_plug.ex | 6 +++ lib/pleroma/web/plugs/ensure_user_key_plug.ex | 5 +- .../web/plugs/legacy_authentication_plug.ex | 41 -------------- .../web/plugs/session_authentication_plug.ex | 10 ++++ lib/pleroma/web/plugs/set_user_session_id_plug.ex | 3 +- lib/pleroma/web/plugs/user_fetcher_plug.ex | 6 +++ lib/pleroma/web/router.ex | 1 - 10 files changed, 73 insertions(+), 83 deletions(-) create mode 100644 lib/pleroma/helpers/auth_helper.ex delete mode 100644 lib/pleroma/web/plugs/legacy_authentication_plug.ex (limited to 'lib') diff --git a/lib/pleroma/helpers/auth_helper.ex b/lib/pleroma/helpers/auth_helper.ex new file mode 100644 index 000000000..6e29c006a --- /dev/null +++ b/lib/pleroma/helpers/auth_helper.ex @@ -0,0 +1,17 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Helpers.AuthHelper do + alias Pleroma.Web.Plugs.OAuthScopesPlug + + @doc """ + Skips OAuth permissions (scopes) checks, assigns nil `:token`. + Intended to be used with explicit authentication and only when OAuth token cannot be determined. + """ + def skip_oauth(conn) do + conn + |> Plug.Conn.assign(:token, nil) + |> OAuthScopesPlug.skip_plug() + end +end diff --git a/lib/pleroma/web/plugs/admin_secret_authentication_plug.ex b/lib/pleroma/web/plugs/admin_secret_authentication_plug.ex index d7d4e4092..ff49801f4 100644 --- a/lib/pleroma/web/plugs/admin_secret_authentication_plug.ex +++ b/lib/pleroma/web/plugs/admin_secret_authentication_plug.ex @@ -5,8 +5,8 @@ defmodule Pleroma.Web.Plugs.AdminSecretAuthenticationPlug do import Plug.Conn + alias Pleroma.Helpers.AuthHelper alias Pleroma.User - alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.Plugs.RateLimiter def init(options) do @@ -51,7 +51,7 @@ defmodule Pleroma.Web.Plugs.AdminSecretAuthenticationPlug do defp assign_admin_user(conn) do conn |> assign(:user, %User{is_admin: true}) - |> OAuthScopesPlug.skip_plug() + |> AuthHelper.skip_oauth() end defp handle_bad_token(conn) do diff --git a/lib/pleroma/web/plugs/authentication_plug.ex b/lib/pleroma/web/plugs/authentication_plug.ex index e2a8b1b69..a7b8a9bfe 100644 --- a/lib/pleroma/web/plugs/authentication_plug.ex +++ b/lib/pleroma/web/plugs/authentication_plug.ex @@ -3,6 +3,9 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Plugs.AuthenticationPlug do + @moduledoc "Password authentication plug." + + alias Pleroma.Helpers.AuthHelper alias Pleroma.User import Plug.Conn @@ -11,6 +14,30 @@ defmodule Pleroma.Web.Plugs.AuthenticationPlug do def init(options), do: options + def call(%{assigns: %{user: %User{}}} = conn, _), do: conn + + def call( + %{ + assigns: %{ + auth_user: %{password_hash: password_hash} = auth_user, + auth_credentials: %{password: password} + } + } = conn, + _ + ) do + if checkpw(password, password_hash) do + {:ok, auth_user} = maybe_update_password(auth_user, password) + + conn + |> assign(:user, auth_user) + |> AuthHelper.skip_oauth() + else + conn + end + end + + def call(conn, _), do: conn + def checkpw(password, "$6" <> _ = password_hash) do :crypt.crypt(password, password_hash) == password_hash end @@ -40,40 +67,6 @@ defmodule Pleroma.Web.Plugs.AuthenticationPlug do def maybe_update_password(user, _), do: {:ok, user} defp do_update_password(user, password) do - user - |> User.password_update_changeset(%{ - "password" => password, - "password_confirmation" => password - }) - |> Pleroma.Repo.update() + User.reset_password(user, %{password: password, password_confirmation: password}) end - - def call(%{assigns: %{user: %User{}}} = conn, _), do: conn - - def call( - %{ - assigns: %{ - auth_user: %{password_hash: password_hash} = auth_user, - auth_credentials: %{password: password} - } - } = conn, - _ - ) do - if checkpw(password, password_hash) do - {:ok, auth_user} = maybe_update_password(auth_user, password) - - conn - |> assign(:user, auth_user) - |> Pleroma.Web.Plugs.OAuthScopesPlug.skip_plug() - else - conn - end - end - - def call(%{assigns: %{auth_credentials: %{password: _}}} = conn, _) do - Pbkdf2.no_user_verify() - conn - end - - def call(conn, _), do: conn end diff --git a/lib/pleroma/web/plugs/basic_auth_decoder_plug.ex b/lib/pleroma/web/plugs/basic_auth_decoder_plug.ex index 4dadfb000..97529aedb 100644 --- a/lib/pleroma/web/plugs/basic_auth_decoder_plug.ex +++ b/lib/pleroma/web/plugs/basic_auth_decoder_plug.ex @@ -3,6 +3,12 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Plugs.BasicAuthDecoderPlug do + @moduledoc """ + Decodes HTTP Basic Auth information and assigns `:auth_credentials`. + + NOTE: no checks are performed at this step, auth_credentials/username could be easily faked. + """ + import Plug.Conn def init(options) do diff --git a/lib/pleroma/web/plugs/ensure_user_key_plug.ex b/lib/pleroma/web/plugs/ensure_user_key_plug.ex index 70d3091f0..31608dbbf 100644 --- a/lib/pleroma/web/plugs/ensure_user_key_plug.ex +++ b/lib/pleroma/web/plugs/ensure_user_key_plug.ex @@ -5,6 +5,8 @@ defmodule Pleroma.Web.Plugs.EnsureUserKeyPlug do import Plug.Conn + @moduledoc "Ensures `conn.assigns.user` is initialized." + def init(opts) do opts end @@ -12,7 +14,6 @@ defmodule Pleroma.Web.Plugs.EnsureUserKeyPlug do def call(%{assigns: %{user: _}} = conn, _), do: conn def call(conn, _) do - conn - |> assign(:user, nil) + assign(conn, :user, nil) end end diff --git a/lib/pleroma/web/plugs/legacy_authentication_plug.ex b/lib/pleroma/web/plugs/legacy_authentication_plug.ex deleted file mode 100644 index 2a54d0b59..000000000 --- a/lib/pleroma/web/plugs/legacy_authentication_plug.ex +++ /dev/null @@ -1,41 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.Plugs.LegacyAuthenticationPlug do - import Plug.Conn - - alias Pleroma.User - - def init(options) do - options - end - - def call(%{assigns: %{user: %User{}}} = conn, _), do: conn - - def call( - %{ - assigns: %{ - auth_user: %{password_hash: "$6$" <> _ = password_hash} = auth_user, - auth_credentials: %{password: password} - } - } = conn, - _ - ) do - with ^password_hash <- :crypt.crypt(password, password_hash), - {:ok, user} <- - User.reset_password(auth_user, %{password: password, password_confirmation: password}) do - conn - |> assign(:auth_user, user) - |> assign(:user, user) - |> Pleroma.Web.Plugs.OAuthScopesPlug.skip_plug() - else - _ -> - conn - end - end - - def call(conn, _) do - conn - end -end diff --git a/lib/pleroma/web/plugs/session_authentication_plug.ex b/lib/pleroma/web/plugs/session_authentication_plug.ex index 6e176d553..51704e273 100644 --- a/lib/pleroma/web/plugs/session_authentication_plug.ex +++ b/lib/pleroma/web/plugs/session_authentication_plug.ex @@ -3,17 +3,27 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Plugs.SessionAuthenticationPlug do + @moduledoc """ + Authenticates user by session-stored `:user_id` and request-contained username. + Username can be provided via HTTP Basic Auth (the password is not checked and can be anything). + """ + import Plug.Conn + alias Pleroma.Helpers.AuthHelper + def init(options) do options end + def call(%{assigns: %{user: %Pleroma.User{}}} = conn, _), do: conn + def call(conn, _) do with saved_user_id <- get_session(conn, :user_id), %{auth_user: %{id: ^saved_user_id}} <- conn.assigns do conn |> assign(:user, conn.assigns.auth_user) + |> AuthHelper.skip_oauth() else _ -> conn end diff --git a/lib/pleroma/web/plugs/set_user_session_id_plug.ex b/lib/pleroma/web/plugs/set_user_session_id_plug.ex index e520159e4..6ddb6b5e5 100644 --- a/lib/pleroma/web/plugs/set_user_session_id_plug.ex +++ b/lib/pleroma/web/plugs/set_user_session_id_plug.ex @@ -11,8 +11,7 @@ defmodule Pleroma.Web.Plugs.SetUserSessionIdPlug do end def call(%{assigns: %{user: %User{id: id}}} = conn, _) do - conn - |> put_session(:user_id, id) + put_session(conn, :user_id, id) end def call(conn, _), do: conn diff --git a/lib/pleroma/web/plugs/user_fetcher_plug.ex b/lib/pleroma/web/plugs/user_fetcher_plug.ex index 4039600da..89e16b49f 100644 --- a/lib/pleroma/web/plugs/user_fetcher_plug.ex +++ b/lib/pleroma/web/plugs/user_fetcher_plug.ex @@ -3,6 +3,12 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Plugs.UserFetcherPlug do + @moduledoc """ + Assigns `:auth_user` basing on `:auth_credentials`. + + NOTE: no checks are performed at this step, auth_credentials/username could be easily faked. + """ + alias Pleroma.User import Plug.Conn diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 76ca2c9b5..9da10f1e5 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -49,7 +49,6 @@ defmodule Pleroma.Web.Router do plug(Pleroma.Web.Plugs.BasicAuthDecoderPlug) plug(Pleroma.Web.Plugs.UserFetcherPlug) plug(Pleroma.Web.Plugs.SessionAuthenticationPlug) - plug(Pleroma.Web.Plugs.LegacyAuthenticationPlug) plug(Pleroma.Web.Plugs.AuthenticationPlug) end -- cgit v1.2.3 From 8f00d90f9199e384fb1befb677c1c0595a0c854c Mon Sep 17 00:00:00 2001 From: Ekaterina Vaartis Date: Sun, 1 Nov 2020 12:05:39 +0300 Subject: Use Pleroma.HTTP instead of Tesla Closes #2275 As discovered in the issue, captcha used Tesla.get instead of Pleroma.HTTP. I've also grep'ed the repo and changed the other place where this was used. --- lib/pleroma/captcha/kocaptcha.ex | 2 +- lib/pleroma/emoji/pack.ex | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/captcha/kocaptcha.ex b/lib/pleroma/captcha/kocaptcha.ex index 337506647..201b55ab4 100644 --- a/lib/pleroma/captcha/kocaptcha.ex +++ b/lib/pleroma/captcha/kocaptcha.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Captcha.Kocaptcha do def new do endpoint = Pleroma.Config.get!([__MODULE__, :endpoint]) - case Tesla.get(endpoint <> "/new") do + case Pleroma.HTTP.get(endpoint <> "/new") do {:error, _} -> %{error: :kocaptcha_service_unavailable} diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index 0670f29f1..ca58e5432 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -594,7 +594,7 @@ defmodule Pleroma.Emoji.Pack do end defp download_archive(url, sha) do - with {:ok, %{body: archive}} <- Tesla.get(url) do + with {:ok, %{body: archive}} <- Pleroma.HTTP.get(url) do if Base.decode16!(sha) == :crypto.hash(:sha256, archive) do {:ok, archive} else @@ -617,7 +617,7 @@ defmodule Pleroma.Emoji.Pack do end defp update_sha_and_save_metadata(pack, data) do - with {:ok, %{body: zip}} <- Tesla.get(data[:"fallback-src"]), + with {:ok, %{body: zip}} <- Pleroma.HTTP.get(data[:"fallback-src"]), :ok <- validate_has_all_files(pack, zip) do fallback_sha = :sha256 |> :crypto.hash(zip) |> Base.encode16() -- cgit v1.2.3 From 4caad4e9101c34debfa90d2e89850d4125a471b3 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 2 Nov 2020 05:43:06 +0100 Subject: =?UTF-8?q?side=5Feffects:=20Don=E2=80=99t=20increase=5Freplies=5F?= =?UTF-8?q?count=20when=20it=E2=80=99s=20an=20Answer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pleroma/web/activity_pub/side_effects.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 0fff5faf2..9b1171d07 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -187,7 +187,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do {:ok, notifications} = Notification.create_notifications(activity, do_send: false) {:ok, _user} = ActivityPub.increase_note_count_if_public(user, object) - if in_reply_to = object.data["inReplyTo"] do + if in_reply_to = object.data["inReplyTo"] && object.data["type"] != "Answer" do Object.increase_replies_count(in_reply_to) end -- cgit v1.2.3 From be52819a112abb66032a56d613eed0233995eef4 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 2 Nov 2020 17:51:54 +0400 Subject: Hide chats from muted users --- .../web/pleroma_api/controllers/chat_controller.ex | 27 +++++++++------------- 1 file changed, 11 insertions(+), 16 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 2c4d3f135..8fc70c15a 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -15,7 +15,6 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do alias Pleroma.User alias Pleroma.Web.CommonAPI alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView - alias Pleroma.Web.PleromaAPI.ChatView alias Pleroma.Web.Plugs.OAuthScopesPlug import Ecto.Query @@ -121,9 +120,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do ) do with {:ok, chat} <- Chat.get_by_user_and_id(user, id), {_n, _} <- MessageReference.set_all_seen_for_chat(chat, last_read_id) do - conn - |> put_view(ChatView) - |> render("show.json", chat: chat) + render(conn, "show.json", chat: chat) end end @@ -142,32 +139,30 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do end def index(%{assigns: %{user: %{id: user_id} = user}} = conn, _params) do - blocked_ap_ids = User.blocked_users_ap_ids(user) + exclude_users = + user + |> User.blocked_users_ap_ids() + |> Enum.concat(User.muted_users_ap_ids(user)) chats = - Chat.for_user_query(user_id) - |> where([c], c.recipient not in ^blocked_ap_ids) + user_id + |> Chat.for_user_query() + |> where([c], c.recipient not in ^exclude_users) |> Repo.all() - conn - |> put_view(ChatView) - |> render("index.json", chats: chats) + render(conn, "index.json", chats: chats) end def create(%{assigns: %{user: user}} = conn, %{id: id}) do with %User{ap_id: recipient} <- User.get_cached_by_id(id), {:ok, %Chat{} = chat} <- Chat.get_or_create(user.id, recipient) do - conn - |> put_view(ChatView) - |> render("show.json", chat: chat) + render(conn, "show.json", chat: chat) end end def show(%{assigns: %{user: user}} = conn, %{id: id}) do with {:ok, chat} <- Chat.get_by_user_and_id(user, id) do - conn - |> put_view(ChatView) - |> render("show.json", chat: chat) + render(conn, "show.json", chat: chat) end end -- cgit v1.2.3 From 7efc074eadae9b3d6d351e769ead0661f1f4c89c Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 2 Nov 2020 12:19:44 -0600 Subject: Permit fetching individual reports with notes preloaded --- lib/pleroma/activity.ex | 13 +++++++++++++ lib/pleroma/web/admin_api/controllers/report_controller.ex | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 17af04257..553834da0 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -14,6 +14,7 @@ defmodule Pleroma.Activity do alias Pleroma.ReportNote alias Pleroma.ThreadMute alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub import Ecto.Changeset import Ecto.Query @@ -153,6 +154,18 @@ defmodule Pleroma.Activity do def get_bookmark(_, _), do: nil + def get_report(activity_id) do + opts = %{ + type: "Flag", + skip_preload: true, + preload_report_notes: true + } + + ActivityPub.fetch_activities_query([], opts) + |> where(id: ^activity_id) + |> Repo.one() + end + def change(struct, params \\ %{}) do struct |> cast(params, [:data, :recipients]) diff --git a/lib/pleroma/web/admin_api/controllers/report_controller.ex b/lib/pleroma/web/admin_api/controllers/report_controller.ex index 86da93893..6a0e56f5f 100644 --- a/lib/pleroma/web/admin_api/controllers/report_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/report_controller.ex @@ -38,7 +38,7 @@ defmodule Pleroma.Web.AdminAPI.ReportController do end def show(conn, %{id: id}) do - with %Activity{} = report <- Activity.get_by_id(id) do + with %Activity{} = report <- Activity.get_report(id) do render(conn, "show.json", Report.extract_report_info(report)) else _ -> {:error, :not_found} -- cgit v1.2.3 From c37118e6f26f0305d540047e4ccb8d594d2c0e6b Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 3 Nov 2020 13:56:12 +0100 Subject: Conversations: A few refactors --- lib/pleroma/web/mastodon_api/views/conversation_view.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/mastodon_api/views/conversation_view.ex b/lib/pleroma/web/mastodon_api/views/conversation_view.ex index 545778165..82fcff062 100644 --- a/lib/pleroma/web/mastodon_api/views/conversation_view.ex +++ b/lib/pleroma/web/mastodon_api/views/conversation_view.ex @@ -34,7 +34,8 @@ defmodule Pleroma.Web.MastodonAPI.ConversationView do activity = Activity.get_by_id_with_object(last_activity_id) - # Conversations return all users except current user when current user is not only participant + # Conversations return all users except the current user, + # except when the current user is the only participant users = if length(participation.recipients) > 1 do Enum.reject(participation.recipients, &(&1.id == user.id)) -- cgit v1.2.3 From 1cfc3278c086c9eaa7b2d1bd170e82c8b2aebd78 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 4 Nov 2020 10:14:00 +0100 Subject: Poll View: Always return `voters_count`. --- lib/pleroma/web/mastodon_api/views/poll_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/mastodon_api/views/poll_view.ex b/lib/pleroma/web/mastodon_api/views/poll_view.ex index 1208dc9a0..4101f21d0 100644 --- a/lib/pleroma/web/mastodon_api/views/poll_view.ex +++ b/lib/pleroma/web/mastodon_api/views/poll_view.ex @@ -19,7 +19,7 @@ defmodule Pleroma.Web.MastodonAPI.PollView do expired: expired, multiple: multiple, votes_count: votes_count, - voters_count: (multiple || nil) && voters_count(object), + voters_count: voters_count(object), options: options, voted: voted?(params), emojis: Pleroma.Web.MastodonAPI.StatusView.build_emojis(object.data["emoji"]) -- cgit v1.2.3 From 92d252f364ed421f2afcdd135507ced3554eb3f0 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 4 Nov 2020 10:20:09 +0100 Subject: Poll Schema: Update and fix. --- lib/pleroma/web/api_spec/schemas/poll.ex | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/api_spec/schemas/poll.ex b/lib/pleroma/web/api_spec/schemas/poll.ex index c62096db0..0dfa60b97 100644 --- a/lib/pleroma/web/api_spec/schemas/poll.ex +++ b/lib/pleroma/web/api_spec/schemas/poll.ex @@ -28,8 +28,11 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Poll do }, votes_count: %Schema{ type: :integer, - nullable: true, - description: "How many votes have been received. Number, or null if `multiple` is false." + description: "How many votes have been received. Number." + }, + voters_count: %Schema{ + type: :integer, + description: "How many unique accounts have voted. Number." }, voted: %Schema{ type: :boolean, @@ -61,7 +64,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Poll do expired: true, multiple: false, votes_count: 10, - voters_count: nil, + voters_count: 10, voted: true, own_votes: [ 1 -- cgit v1.2.3 From ca95cbe0b48b6c64e6e33addf79e4d212d5f9872 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 4 Nov 2020 16:40:12 +0400 Subject: Add `with_muted` param to ChatController.index/2 --- lib/pleroma/web/api_spec/operations/chat_operation.ex | 6 +++++- lib/pleroma/web/api_spec/operations/timeline_operation.ex | 2 +- lib/pleroma/web/pleroma_api/controllers/chat_controller.ex | 7 +++---- 3 files changed, 9 insertions(+), 6 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 0dcfdb354..560b81f17 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike alias Pleroma.Web.ApiSpec.Schemas.Chat alias Pleroma.Web.ApiSpec.Schemas.ChatMessage @@ -132,7 +133,10 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do tags: ["chat"], summary: "Get a list of chats that you participated in", operationId: "ChatController.index", - parameters: pagination_params(), + parameters: [ + Operation.parameter(:with_muted, :query, BooleanLike, "Include chats from muted users") + | pagination_params() + ], responses: %{ 200 => Operation.response("The chats of the user", "application/json", chats_response()) }, diff --git a/lib/pleroma/web/api_spec/operations/timeline_operation.ex b/lib/pleroma/web/api_spec/operations/timeline_operation.ex index 8e19bace7..1b5ad796f 100644 --- a/lib/pleroma/web/api_spec/operations/timeline_operation.ex +++ b/lib/pleroma/web/api_spec/operations/timeline_operation.ex @@ -159,7 +159,7 @@ defmodule Pleroma.Web.ApiSpec.TimelineOperation do end defp with_muted_param do - Operation.parameter(:with_muted, :query, BooleanLike, "Includeactivities by muted users") + Operation.parameter(:with_muted, :query, BooleanLike, "Include activities by muted users") end defp exclude_visibilities_param do diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 8fc70c15a..77564b342 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -138,11 +138,10 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do end end - def index(%{assigns: %{user: %{id: user_id} = user}} = conn, _params) do + def index(%{assigns: %{user: %{id: user_id} = user}} = conn, params) do exclude_users = - user - |> User.blocked_users_ap_ids() - |> Enum.concat(User.muted_users_ap_ids(user)) + User.blocked_users_ap_ids(user) ++ + if params[:with_muted], do: [], else: User.muted_users_ap_ids(user) chats = user_id -- cgit v1.2.3 From 6d850c46dc439e018323fb3580e708131247457b Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 4 Nov 2020 17:12:47 +0100 Subject: AdminEmail: Use AP id as user url. --- lib/pleroma/emails/admin_email.ex | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/emails/admin_email.ex b/lib/pleroma/emails/admin_email.ex index 8979db2f8..02274554f 100644 --- a/lib/pleroma/emails/admin_email.ex +++ b/lib/pleroma/emails/admin_email.ex @@ -18,10 +18,6 @@ defmodule Pleroma.Emails.AdminEmail do Keyword.get(instance_config(), :notify_email, instance_config()[:email]) end - defp user_url(user) do - Helpers.user_feed_url(Pleroma.Web.Endpoint, :feed_redirect, user.id) - end - def test_email(mail_to \\ nil) do html_body = """

Instance Test Email

@@ -69,8 +65,8 @@ defmodule Pleroma.Emails.AdminEmail do end html_body = """ -

Reported by: #{reporter.nickname}

-

Reported Account: #{account.nickname}

+

Reported by: #{reporter.nickname}

+

Reported Account: #{account.nickname}

#{comment_html} #{statuses_html}

@@ -86,7 +82,7 @@ defmodule Pleroma.Emails.AdminEmail do def new_unapproved_registration(to, account) do html_body = """ -

New account for review: @#{account.nickname}

+

New account for review: @#{account.nickname}

#{HTML.strip_tags(account.registration_reason)}
Visit AdminFE """ -- cgit v1.2.3 From cc45c69fff515cb82c4902b67c8edce6b109c819 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sat, 7 Nov 2020 22:09:28 +0300 Subject: Remove release_env While taking a final look at instance.gen before releasing I noticed that the release_env task outputs messages in broken english. Upon further inspection it seems to have even more severe issues which, in my opinion, warrant it's at least temporary removal: - We do not explain what it actually does, anywhere. Neither the task docs nor instance.gen, nor installation instructions. - It does not respect FHS on OTP releases (uses /opt/pleroma/config even though we store the config in /etc/pleroma/config.exs). - It doesn't work on OTP releases, which is the main reason it exists. Neither systemd nor openrc service files for OTP include it. - It is not mentioned in install guides other than the ones for Debian and OTP releases. --- lib/mix/tasks/pleroma/instance.ex | 22 +---------- lib/mix/tasks/pleroma/release_env.ex | 76 ------------------------------------ 2 files changed, 1 insertion(+), 97 deletions(-) delete mode 100644 lib/mix/tasks/pleroma/release_env.ex (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/instance.ex b/lib/mix/tasks/pleroma/instance.ex index 1915aacd9..fc21ae062 100644 --- a/lib/mix/tasks/pleroma/instance.ex +++ b/lib/mix/tasks/pleroma/instance.ex @@ -36,9 +36,7 @@ defmodule Mix.Tasks.Pleroma.Instance do listen_port: :string, strip_uploads: :string, anonymize_uploads: :string, - dedupe_uploads: :string, - skip_release_env: :boolean, - release_env_file: :string + dedupe_uploads: :string ], aliases: [ o: :output, @@ -243,24 +241,6 @@ defmodule Mix.Tasks.Pleroma.Instance do write_robots_txt(static_dir, indexable, template_dir) - if Keyword.get(options, :skip_release_env, false) do - shell_info(""" - Release environment file is skip. Please generate the release env file before start. - `MIX_ENV=#{Mix.env()} mix pleroma.release_env gen` - """) - else - shell_info("Generation the environment file:") - - release_env_args = - with path when not is_nil(path) <- Keyword.get(options, :release_env_file) do - ["gen", "--path", path] - else - _ -> ["gen"] - end - - Mix.Tasks.Pleroma.ReleaseEnv.run(release_env_args) - end - shell_info( "\n All files successfully written! Refer to the installation instructions for your platform for next steps." ) diff --git a/lib/mix/tasks/pleroma/release_env.ex b/lib/mix/tasks/pleroma/release_env.ex deleted file mode 100644 index 9da74ffcf..000000000 --- a/lib/mix/tasks/pleroma/release_env.ex +++ /dev/null @@ -1,76 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Mix.Tasks.Pleroma.ReleaseEnv do - use Mix.Task - import Mix.Pleroma - - @shortdoc "Generate Pleroma environment file." - @moduledoc File.read!("docs/administration/CLI_tasks/release_environments.md") - - def run(["gen" | rest]) do - {options, [], []} = - OptionParser.parse( - rest, - strict: [ - force: :boolean, - path: :string - ], - aliases: [ - p: :path, - f: :force - ] - ) - - file_path = - get_option( - options, - :path, - "Environment file path", - "./config/pleroma.env" - ) - - env_path = Path.expand(file_path) - - proceed? = - if File.exists?(env_path) do - get_option( - options, - :force, - "Environment file already exists. Do you want to overwrite the #{env_path} file? (y/n)", - "n" - ) === "y" - else - true - end - - if proceed? do - case do_generate(env_path) do - {:error, reason} -> - shell_error( - File.Error.message(%{action: "write to file", reason: reason, path: env_path}) - ) - - _ -> - shell_info("\nThe file generated: #{env_path}.\n") - - shell_info(""" - WARNING: before start pleroma app please make sure to make the file read-only and non-modifiable. - Example: - chmod 0444 #{file_path} - chattr +i #{file_path} - """) - end - else - shell_info("\nThe file is exist. #{env_path}.\n") - end - end - - def do_generate(path) do - content = "RELEASE_COOKIE=#{Base.encode32(:crypto.strong_rand_bytes(32))}" - - File.mkdir_p!(Path.dirname(path)) - File.write(path, content) - end -end -- cgit v1.2.3 From e4a21084f0017c873a307ec85d0ef5ea341ce026 Mon Sep 17 00:00:00 2001 From: Sean King Date: Sun, 8 Nov 2020 16:16:20 -0700 Subject: Fix title on load of Pleroma HTML --- lib/pleroma/web/fallback/redirect_controller.ex | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'lib') diff --git a/lib/pleroma/web/fallback/redirect_controller.ex b/lib/pleroma/web/fallback/redirect_controller.ex index 6f759d559..712991c18 100644 --- a/lib/pleroma/web/fallback/redirect_controller.ex +++ b/lib/pleroma/web/fallback/redirect_controller.ex @@ -37,9 +37,11 @@ defmodule Pleroma.Web.Fallback.RedirectController do tags = build_tags(conn, params) preloads = preload_data(conn, params) + title = "#{Pleroma.Config.get([:instance, :name])}" response = index_content + |> String.replace(~r/.+?<\/title>/, title) |> String.replace("<!--server-generated-meta-->", tags <> preloads) conn @@ -54,9 +56,11 @@ defmodule Pleroma.Web.Fallback.RedirectController do def redirector_with_preload(conn, params) do {:ok, index_content} = File.read(index_file_path()) preloads = preload_data(conn, params) + title = "<title>#{Pleroma.Config.get([:instance, :name])}" response = index_content + |> String.replace(~r/.+?<\/title>/, title) |> String.replace("<!--server-generated-meta-->", preloads) conn -- cgit v1.2.3 From 0c68b9ac137e98867cf8aacfef1f264412cc7b3e Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Tue, 10 Nov 2020 10:44:22 +0300 Subject: escaping summary and other fields in xml templates --- lib/pleroma/web/feed/feed_view.ex | 2 +- lib/pleroma/web/templates/feed/feed/_activity.atom.eex | 2 +- lib/pleroma/web/templates/feed/feed/_activity.rss.eex | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/feed/feed_view.ex b/lib/pleroma/web/feed/feed_view.ex index 1ae03e7e2..56c024617 100644 --- a/lib/pleroma/web/feed/feed_view.ex +++ b/lib/pleroma/web/feed/feed_view.ex @@ -83,7 +83,7 @@ defmodule Pleroma.Web.Feed.FeedView do def activity_content(_), do: "" - def activity_context(activity), do: activity.data["context"] + def activity_context(activity), do: escape(activity.data["context"]) def attachment_href(attachment) do attachment["url"] diff --git a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex index 78350f2aa..3fd150c4e 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex @@ -12,7 +12,7 @@ <link href="<%= activity_context(@activity) %>" rel="ostatus:conversation"/> <%= if @data["summary"] do %> - <summary><%= @data["summary"] %></summary> + <summary><%= escape(@data["summary"]) %></summary> <% end %> <%= if @activity.local do %> diff --git a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex index a304a16af..42960de7d 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex @@ -12,7 +12,7 @@ <link rel="ostatus:conversation"><%= activity_context(@activity) %></link> <%= if @data["summary"] do %> - <description><%= @data["summary"] %></description> + <description><%= escape(@data["summary"]) %></description> <% end %> <%= if @activity.local do %> -- cgit v1.2.3 From 485697d96c6a45127af22b9a5f357c3802dba73c Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Tue, 10 Nov 2020 19:18:53 +0300 Subject: config descriptions for custom MRF policies --- lib/pleroma/docs/json.ex | 6 +- lib/pleroma/web/activity_pub/mrf.ex | 58 ++++++++++++++++- .../activity_pub/mrf/activity_expiration_policy.ex | 18 ++++++ .../web/activity_pub/mrf/hellthread_policy.ex | 27 ++++++++ lib/pleroma/web/activity_pub/mrf/keyword_policy.ex | 42 ++++++++++++ lib/pleroma/web/activity_pub/mrf/mention_policy.ex | 18 ++++++ .../web/activity_pub/mrf/normalize_markup.ex | 19 ++++++ .../web/activity_pub/mrf/object_age_policy.ex | 28 ++++++++ .../web/activity_pub/mrf/reject_non_public.ex | 23 +++++++ lib/pleroma/web/activity_pub/mrf/simple_policy.ex | 74 ++++++++++++++++++++++ .../web/activity_pub/mrf/subchain_policy.ex | 24 +++++++ .../web/activity_pub/mrf/user_allow_list_policy.ex | 14 ++++ .../web/activity_pub/mrf/vocabulary_policy.ex | 28 ++++++++ 13 files changed, 376 insertions(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/docs/json.ex b/lib/pleroma/docs/json.ex index 13618b509..a583e2a5b 100644 --- a/lib/pleroma/docs/json.ex +++ b/lib/pleroma/docs/json.ex @@ -11,7 +11,11 @@ defmodule Pleroma.Docs.JSON do @spec compile :: :ok def compile do - :persistent_term.put(@term, Pleroma.Docs.Generator.convert_to_strings(@raw_descriptions)) + descriptions = + Pleroma.Web.ActivityPub.MRF.config_descriptions() + |> Enum.reduce(@raw_descriptions, fn description, acc -> [description | acc] end) + + :persistent_term.put(@term, Pleroma.Docs.Generator.convert_to_strings(descriptions)) end @spec compiled_descriptions :: Map.t() diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index 5e5361082..656e4c7ca 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -3,7 +3,26 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF do + require Logger + + @default_description %{ + label: "", + description: "", + children: [] + } + + @required_description_keys [:key, :related_policy] + @callback filter(Map.t()) :: {:ok | :reject, Map.t()} + @callback describe() :: {:ok | :error, Map.t()} + @callback config_description() :: %{ + optional(:children) => [map()], + key: atom(), + related_policy: String.t(), + label: String.t(), + description: String.t() + } + @optional_callbacks config_description: 0 def filter(policies, %{} = message) do policies @@ -51,8 +70,6 @@ defmodule Pleroma.Web.ActivityPub.MRF do Enum.any?(domains, fn domain -> Regex.match?(domain, host) end) end - @callback describe() :: {:ok | :error, Map.t()} - def describe(policies) do {:ok, policy_configs} = policies @@ -82,4 +99,41 @@ defmodule Pleroma.Web.ActivityPub.MRF do end def describe, do: get_policies() |> describe() + + def config_descriptions do + Pleroma.Web.ActivityPub.MRF + |> Pleroma.Docs.Generator.list_behaviour_implementations() + |> config_descriptions() + end + + def config_descriptions(policies) do + Enum.reduce(policies, [], fn policy, acc -> + if function_exported?(policy, :config_description, 0) do + description = + @default_description + |> Map.merge(policy.config_description) + |> Map.put(:group, :pleroma) + |> Map.put(:tab, :mrf) + |> Map.put(:type, :group) + + if Enum.all?(@required_description_keys, &Map.has_key?(description, &1)) do + [description | acc] + else + Logger.warn( + "#{policy} config description doesn't have one or all required keys #{ + inspect(@required_description_keys) + }" + ) + + acc + end + else + Logger.info( + "#{policy} is excluded from config descriptions, because does not implement `config_description/0` method." + ) + + acc + end + end) + end end diff --git a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex index bee47b4ed..655a2ced0 100644 --- a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex @@ -40,4 +40,22 @@ defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy do _ -> Map.put(activity, "expires_at", expires_at) end end + + @impl true + def config_description do + %{ + key: :mrf_activity_expiration, + related_policy: "Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy", + label: "MRF Activity Expiration Policy", + description: "Adds automatic expiration to all local activities", + children: [ + %{ + key: :days, + type: :integer, + description: "Default global expiration time for all local activities (in days)", + suggestions: [90, 365] + } + ] + } + end end diff --git a/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex index 9ba07b4e3..3fd5c1e0a 100644 --- a/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex @@ -97,4 +97,31 @@ defmodule Pleroma.Web.ActivityPub.MRF.HellthreadPolicy do @impl true def describe, do: {:ok, %{mrf_hellthread: Pleroma.Config.get(:mrf_hellthread) |> Enum.into(%{})}} + + @impl true + def config_description do + %{ + key: :mrf_hellthread, + related_policy: "Pleroma.Web.ActivityPub.MRF.HellthreadPolicy", + label: "MRF Hellthread", + description: "Block messages with excessive user mentions", + children: [ + %{ + key: :delist_threshold, + type: :integer, + description: + "Number of mentioned users after which the message gets removed from timelines and" <> + "disables notifications. Set to 0 to disable.", + suggestions: [10] + }, + %{ + key: :reject_threshold, + type: :integer, + description: + "Number of mentioned users after which the messaged gets rejected. Set to 0 to disable.", + suggestions: [20] + } + ] + } + end end diff --git a/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex index db66cfa3e..ded0fe7f2 100644 --- a/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex @@ -126,4 +126,46 @@ defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicy do {:ok, %{mrf_keyword: mrf_keyword}} end + + @impl true + def config_description do + %{ + key: :mrf_keyword, + related_policy: "Pleroma.Web.ActivityPub.MRF.KeywordPolicy", + label: "MRF Keyword", + description: + "Reject or Word-Replace messages matching a keyword or [Regex](https://hexdocs.pm/elixir/Regex.html).", + children: [ + %{ + key: :reject, + type: {:list, :string}, + description: """ + A list of patterns which result in message being rejected. + + Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`. + """, + suggestions: ["foo", ~r/foo/iu] + }, + %{ + key: :federated_timeline_removal, + type: {:list, :string}, + description: """ + A list of patterns which result in message being removed from federated timelines (a.k.a unlisted). + + Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`. + """, + suggestions: ["foo", ~r/foo/iu] + }, + %{ + key: :replace, + type: {:list, :tuple}, + description: """ + **Pattern**: a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`. + + **Replacement**: a string. Leaving the field empty is permitted. + """ + } + ] + } + end end diff --git a/lib/pleroma/web/activity_pub/mrf/mention_policy.ex b/lib/pleroma/web/activity_pub/mrf/mention_policy.ex index 7910ca131..9c096712a 100644 --- a/lib/pleroma/web/activity_pub/mrf/mention_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/mention_policy.ex @@ -25,4 +25,22 @@ defmodule Pleroma.Web.ActivityPub.MRF.MentionPolicy do @impl true def describe, do: {:ok, %{}} + + @impl true + def config_description do + %{ + key: :mrf_mention, + related_policy: "Pleroma.Web.ActivityPub.MRF.MentionPolicy", + label: "MRF Mention", + description: "Block messages which mention a specific user", + children: [ + %{ + key: :actors, + type: {:list, :string}, + description: "A list of actors for which any post mentioning them will be dropped", + suggestions: ["actor1", "actor2"] + } + ] + } + end end diff --git a/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex b/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex index 7abae37ae..e00575c2a 100644 --- a/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex +++ b/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkup do @behaviour Pleroma.Web.ActivityPub.MRF + @impl true def filter(%{"type" => "Create", "object" => child_object} = object) do scrub_policy = Pleroma.Config.get([:mrf_normalize_markup, :scrub_policy]) @@ -22,5 +23,23 @@ defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkup do def filter(object), do: {:ok, object} + @impl true def describe, do: {:ok, %{}} + + @impl true + def config_description do + %{ + key: :mrf_normalize_markup, + related_policy: "Pleroma.Web.ActivityPub.MRF.NormalizeMarkup", + label: "MRF Normalize Markup", + description: "MRF NormalizeMarkup settings. Scrub configured hypertext markup.", + children: [ + %{ + key: :scrub_policy, + type: :module, + suggestions: [Pleroma.HTML.Scrubber.Default] + } + ] + } + end end diff --git a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex index d45d2d7e3..eb0481f20 100644 --- a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex @@ -106,4 +106,32 @@ defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy do {:ok, %{mrf_object_age: mrf_object_age}} end + + @impl true + def config_description do + %{ + key: :mrf_object_age, + related_policy: "Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy", + label: "MRF Object Age", + description: + "Rejects or delists posts based on their timestamp deviance from your server's clock.", + children: [ + %{ + key: :threshold, + type: :integer, + description: "Required age (in seconds) of a post before actions are taken.", + suggestions: [172_800] + }, + %{ + key: :actions, + type: {:list, :atom}, + description: + "A list of actions to apply to the post. `:delist` removes the post from public timelines; " <> + "`:strip_followers` removes followers from the ActivityPub recipient list ensuring they won't be delivered to home timelines; " <> + "`:reject` rejects the message entirely", + suggestions: [:delist, :strip_followers, :reject] + } + ] + } + end end diff --git a/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex b/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex index 0b9ed2224..cd7665e31 100644 --- a/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex +++ b/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex @@ -48,4 +48,27 @@ defmodule Pleroma.Web.ActivityPub.MRF.RejectNonPublic do @impl true def describe, do: {:ok, %{mrf_rejectnonpublic: Config.get(:mrf_rejectnonpublic) |> Enum.into(%{})}} + + @impl true + def config_description do + %{ + key: :mrf_rejectnonpublic, + related_policy: "Pleroma.Web.ActivityPub.MRF.RejectNonPublic", + description: "RejectNonPublic drops posts with non-public visibility settings.", + label: "MRF Reject Non Public", + children: [ + %{ + key: :allow_followersonly, + label: "Allow followers-only", + type: :boolean, + description: "Whether to allow followers-only posts" + }, + %{ + key: :allow_direct, + type: :boolean, + description: "Whether to allow direct messages" + } + ] + } + end end diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index 161177727..6cd91826d 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -244,4 +244,78 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do {:ok, %{mrf_simple: mrf_simple}} end + + @impl true + def config_description do + %{ + key: :mrf_simple, + related_policy: "Pleroma.Web.ActivityPub.MRF.SimplePolicy", + label: "MRF Simple", + description: "Simple ingress policies", + children: [ + %{ + key: :media_removal, + type: {:list, :string}, + description: "List of instances to strip media attachments from", + suggestions: ["example.com", "*.example.com"] + }, + %{ + key: :media_nsfw, + label: "Media NSFW", + type: {:list, :string}, + description: "List of instances to tag all media as NSFW (sensitive) from", + suggestions: ["example.com", "*.example.com"] + }, + %{ + key: :federated_timeline_removal, + type: {:list, :string}, + description: + "List of instances to remove from the Federated (aka The Whole Known Network) Timeline", + suggestions: ["example.com", "*.example.com"] + }, + %{ + key: :reject, + type: {:list, :string}, + description: "List of instances to reject activities from (except deletes)", + suggestions: ["example.com", "*.example.com"] + }, + %{ + key: :accept, + type: {:list, :string}, + description: "List of instances to only accept activities from (except deletes)", + suggestions: ["example.com", "*.example.com"] + }, + %{ + key: :followers_only, + type: {:list, :string}, + description: "Force posts from the given instances to be visible by followers only", + suggestions: ["example.com", "*.example.com"] + }, + %{ + key: :report_removal, + type: {:list, :string}, + description: "List of instances to reject reports from", + suggestions: ["example.com", "*.example.com"] + }, + %{ + key: :avatar_removal, + type: {:list, :string}, + description: "List of instances to strip avatars from", + suggestions: ["example.com", "*.example.com"] + }, + %{ + key: :banner_removal, + type: {:list, :string}, + description: "List of instances to strip banners from", + suggestions: ["example.com", "*.example.com"] + }, + %{ + key: :reject_deletes, + type: {:list, :string}, + description: "List of instances to reject deletions from", + suggestions: ["example.com", "*.example.com"] + } + ] + } + end end diff --git a/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex b/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex index 048052da6..2ec45260a 100644 --- a/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex @@ -39,4 +39,28 @@ defmodule Pleroma.Web.ActivityPub.MRF.SubchainPolicy do @impl true def describe, do: {:ok, %{}} + + @impl true + def config_description do + %{ + key: :mrf_subchain, + related_policy: "Pleroma.Web.ActivityPub.MRF.SubchainPolicy", + label: "MRF Subchain", + description: + "This policy processes messages through an alternate pipeline when a given message matches certain criteria." <> + " All criteria are configured as a map of regular expressions to lists of policy modules.", + children: [ + %{ + key: :match_actor, + type: {:map, {:list, :string}}, + description: "Matches a series of regular expressions against the actor field", + suggestions: [ + %{ + ~r/https:\/\/example.com/s => [Pleroma.Web.ActivityPub.MRF.DropPolicy] + } + ] + } + ] + } + end end diff --git a/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex index 1a28f2ba2..885bcca6f 100644 --- a/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex @@ -41,4 +41,18 @@ defmodule Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy do {:ok, %{mrf_user_allowlist: mrf_user_allowlist}} end + + @impl true + def config_description do + %{ + key: :mrf_user_allowlist, + related_policy: "Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy", + description: + "The keys in this section are the domain names that the policy should apply to." <> + " Each key should be assigned a list of users that should be allowed through by their ActivityPub ID", + suggestions: [ + %{"example.org" => ["https://example.org/users/admin"]} + ] + } + end end diff --git a/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex b/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex index a6c545570..f325cb680 100644 --- a/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.VocabularyPolicy do @behaviour Pleroma.Web.ActivityPub.MRF + @impl true def filter(%{"type" => "Undo", "object" => child_message} = message) do with {:ok, _} <- filter(child_message) do {:ok, message} @@ -36,6 +37,33 @@ defmodule Pleroma.Web.ActivityPub.MRF.VocabularyPolicy do def filter(message), do: {:ok, message} + @impl true def describe, do: {:ok, %{mrf_vocabulary: Pleroma.Config.get(:mrf_vocabulary) |> Enum.into(%{})}} + + @impl true + def config_description do + %{ + key: :mrf_vocabulary, + related_policy: "Pleroma.Web.ActivityPub.MRF.VocabularyPolicy", + label: "MRF Vocabulary", + description: "Filter messages which belong to certain activity vocabularies", + children: [ + %{ + key: :accept, + type: {:list, :string}, + description: + "A list of ActivityStreams terms to accept. If empty, all supported messages are accepted.", + suggestions: ["Create", "Follow", "Mention", "Announce", "Like"] + }, + %{ + key: :reject, + type: {:list, :string}, + description: + "A list of ActivityStreams terms to reject. If empty, no messages are rejected.", + suggestions: ["Create", "Follow", "Mention", "Announce", "Like"] + } + ] + } + end end -- cgit v1.2.3 From efc27f64643f24e97c604efa02c15bfe3210cf3b Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Wed, 11 Nov 2020 10:10:57 +0300 Subject: fix for adminFE - revert UserAllowPolicy description - MRF descriptions order --- lib/pleroma/web/activity_pub/mrf.ex | 42 ++++++++++++++++++++-- .../web/activity_pub/mrf/user_allow_list_policy.ex | 32 ++++++++++------- 2 files changed, 58 insertions(+), 16 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index 656e4c7ca..ce125a696 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -5,10 +5,46 @@ defmodule Pleroma.Web.ActivityPub.MRF do require Logger + @mrf_config_descriptions [ + %{ + group: :pleroma, + key: :mrf, + tab: :mrf, + label: "MRF", + type: :group, + description: "General MRF settings", + children: [ + %{ + key: :policies, + type: [:module, {:list, :module}], + description: + "A list of MRF policies enabled. Module names are shortened (removed leading `Pleroma.Web.ActivityPub.MRF.` part), but on adding custom module you need to use full name.", + suggestions: {:list_behaviour_implementations, Pleroma.Web.ActivityPub.MRF} + }, + %{ + key: :transparency, + label: "MRF transparency", + type: :boolean, + description: + "Make the content of your Message Rewrite Facility settings public (via nodeinfo)" + }, + %{ + key: :transparency_exclusions, + label: "MRF transparency exclusions", + type: {:list, :string}, + description: + "Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value.", + suggestions: [ + "exclusion.com" + ] + } + ] + } + ] + @default_description %{ label: "", - description: "", - children: [] + description: "" } @required_description_keys [:key, :related_policy] @@ -107,7 +143,7 @@ defmodule Pleroma.Web.ActivityPub.MRF do end def config_descriptions(policies) do - Enum.reduce(policies, [], fn policy, acc -> + Enum.reduce(policies, @mrf_config_descriptions, fn policy, acc -> if function_exported?(policy, :config_description, 0) do description = @default_description diff --git a/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex index 885bcca6f..f2859abde 100644 --- a/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex @@ -42,17 +42,23 @@ defmodule Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy do {:ok, %{mrf_user_allowlist: mrf_user_allowlist}} end - @impl true - def config_description do - %{ - key: :mrf_user_allowlist, - related_policy: "Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy", - description: - "The keys in this section are the domain names that the policy should apply to." <> - " Each key should be assigned a list of users that should be allowed through by their ActivityPub ID", - suggestions: [ - %{"example.org" => ["https://example.org/users/admin"]} - ] - } - end + # TODO: change way of getting settings on `lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex:18` to use `hosts` subkey + # @impl true + # def config_description do + # %{ + # key: :mrf_user_allowlist, + # related_policy: "Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy", + # description: "Accept-list of users from specified instances", + # children: [ + # %{ + # key: :hosts, + # type: :map, + # description: + # "The keys in this section are the domain names that the policy should apply to." <> + # " Each key should be assigned a list of users that should be allowed through by their ActivityPub ID", + # suggestions: [%{"example.org" => ["https://example.org/users/admin"]}] + # } + # ] + # } + # end end -- cgit v1.2.3 From f97f24b067b6d0205f093b04aeb08c3d56faa7b4 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Wed, 11 Nov 2020 10:48:03 +0300 Subject: making credo happy and test fix --- lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex index f2859abde..e9d0d0503 100644 --- a/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex @@ -55,7 +55,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy do # type: :map, # description: # "The keys in this section are the domain names that the policy should apply to." <> - # " Each key should be assigned a list of users that should be allowed through by their ActivityPub ID", + # " Each key should be assigned a list of users that should be allowed " <> + # "through by their ActivityPub ID", # suggestions: [%{"example.org" => ["https://example.org/users/admin"]}] # } # ] -- cgit v1.2.3 From 8d218ebaf5ab0b72e419068340c40a5ef9744924 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Thu, 10 Sep 2020 10:54:57 +0300 Subject: Moving some background jobs into simple tasks - fetching activity data - attachment prefetching - using limiter to prevent overload --- lib/pleroma/application.ex | 6 ++++++ lib/pleroma/web/activity_pub/activity_pub.ex | 4 +++- .../activity_pub/mrf/media_proxy_warming_policy.ex | 19 +++++++++++++------ lib/pleroma/web/activity_pub/side_effects.ex | 5 +++-- lib/pleroma/web/rich_media/helpers.ex | 5 ----- lib/pleroma/workers/background_worker.ex | 15 --------------- 6 files changed, 25 insertions(+), 29 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 7c4cd9626..769af1806 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -57,6 +57,7 @@ defmodule Pleroma.Application do setup_instrumenters() load_custom_modules() Pleroma.Docs.JSON.compile() + limiters_setup() adapter = Application.get_env(:tesla, :adapter) @@ -273,4 +274,9 @@ defmodule Pleroma.Application do end defp http_children(_, _), do: [] + + def limiters_setup do + [Pleroma.Web.RichMedia.Helpers, Pleroma.Web.MediaProxy] + |> Enum.each(&ConcurrentLimiter.new(&1, 1, 0)) + end end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index d8f685d38..6008f2f4a 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -123,7 +123,9 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do # Splice in the child object if we have one. activity = Maps.put_if_present(activity, :object, object) - BackgroundWorker.enqueue("fetch_data_for_activity", %{"activity_id" => activity.id}) + ConcurrentLimiter.limit(Pleroma.Web.RichMedia.Helpers, fn -> + Task.start(fn -> Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) end) + end) {:ok, activity} else diff --git a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex index 0fb05d3c4..816cc89bf 100644 --- a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex @@ -8,7 +8,6 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do alias Pleroma.HTTP alias Pleroma.Web.MediaProxy - alias Pleroma.Workers.BackgroundWorker require Logger @@ -17,7 +16,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do recv_timeout: 10_000 ] - def perform(:prefetch, url) do + defp prefetch(url) do # Fetching only proxiable resources if MediaProxy.enabled?() and MediaProxy.url_proxiable?(url) do # If preview proxy is enabled, it'll also hit media proxy (so we're caching both requests) @@ -25,17 +24,25 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do Logger.debug("Prefetching #{inspect(url)} as #{inspect(prefetch_url)}") - HTTP.get(prefetch_url, [], @adapter_options) + if Pleroma.Config.get(:env) == :test do + fetch(prefetch_url) + else + ConcurrentLimiter.limit(MediaProxy, fn -> + Task.start(fn -> fetch(prefetch_url) end) + end) + end end end - def perform(:preload, %{"object" => %{"attachment" => attachments}} = _message) do + defp fetch(url), do: HTTP.get(url, [], @adapter_options) + + defp preload(%{"object" => %{"attachment" => attachments}} = _message) do Enum.each(attachments, fn %{"url" => url} when is_list(url) -> url |> Enum.each(fn %{"href" => href} -> - BackgroundWorker.enqueue("media_proxy_prefetch", %{"url" => href}) + prefetch(href) x -> Logger.debug("Unhandled attachment URL object #{inspect(x)}") @@ -51,7 +58,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do %{"type" => "Create", "object" => %{"attachment" => attachments} = _object} = message ) when is_list(attachments) and length(attachments) > 0 do - BackgroundWorker.enqueue("media_proxy_preload", %{"message" => message}) + preload(message) {:ok, message} end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index bbff35c36..4d8fb721e 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -24,7 +24,6 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.Push alias Pleroma.Web.Streamer - alias Pleroma.Workers.BackgroundWorker require Logger @@ -191,7 +190,9 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do Object.increase_replies_count(in_reply_to) end - BackgroundWorker.enqueue("fetch_data_for_activity", %{"activity_id" => activity.id}) + ConcurrentLimiter.limit(Pleroma.Web.RichMedia.Helpers, fn -> + Task.start(fn -> Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) end) + end) meta = meta diff --git a/lib/pleroma/web/rich_media/helpers.ex b/lib/pleroma/web/rich_media/helpers.ex index d67b594b5..442bf9995 100644 --- a/lib/pleroma/web/rich_media/helpers.ex +++ b/lib/pleroma/web/rich_media/helpers.ex @@ -78,11 +78,6 @@ defmodule Pleroma.Web.RichMedia.Helpers do def fetch_data_for_activity(_), do: %{} - def perform(:fetch, %Activity{} = activity) do - fetch_data_for_activity(activity) - :ok - end - def rich_media_get(url) do headers = [{"user-agent", Pleroma.Application.user_agent() <> "; Bot"}] diff --git a/lib/pleroma/workers/background_worker.ex b/lib/pleroma/workers/background_worker.ex index 55b5a13d9..0647c65ae 100644 --- a/lib/pleroma/workers/background_worker.ex +++ b/lib/pleroma/workers/background_worker.ex @@ -3,9 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Workers.BackgroundWorker do - alias Pleroma.Activity alias Pleroma.User - alias Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy use Pleroma.Workers.WorkerHelper, queue: "background" @@ -32,19 +30,6 @@ defmodule Pleroma.Workers.BackgroundWorker do {:ok, User.Import.perform(String.to_atom(op), user, identifiers)} end - def perform(%Job{args: %{"op" => "media_proxy_preload", "message" => message}}) do - MediaProxyWarmingPolicy.perform(:preload, message) - end - - def perform(%Job{args: %{"op" => "media_proxy_prefetch", "url" => url}}) do - MediaProxyWarmingPolicy.perform(:prefetch, url) - end - - def perform(%Job{args: %{"op" => "fetch_data_for_activity", "activity_id" => activity_id}}) do - activity = Activity.get_by_id(activity_id) - Pleroma.Web.RichMedia.Helpers.perform(:fetch, activity) - end - def perform(%Job{ args: %{"op" => "move_following", "origin_id" => origin_id, "target_id" => target_id} }) do -- cgit v1.2.3 From 0118ccb53cd1f33cb91b28fc7f5b6378f2424ffc Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn <egor@kislitsyn.com> Date: Wed, 11 Nov 2020 18:47:57 +0400 Subject: Add `local` visibility --- lib/pleroma/activity.ex | 10 ---------- lib/pleroma/web/activity_pub/builder.ex | 2 +- lib/pleroma/web/activity_pub/pipeline.ex | 3 ++- lib/pleroma/web/activity_pub/utils.ex | 2 +- lib/pleroma/web/activity_pub/visibility.ex | 11 +++++++++++ lib/pleroma/web/api_spec/operations/status_operation.ex | 4 ---- lib/pleroma/web/api_spec/schemas/visibility_scope.ex | 2 +- lib/pleroma/web/common_api.ex | 2 +- lib/pleroma/web/common_api/utils.ex | 15 +++++++++------ lib/pleroma/web/mastodon_api/views/status_view.ex | 3 +-- 10 files changed, 27 insertions(+), 27 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 648cfb623..553834da0 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -19,8 +19,6 @@ defmodule Pleroma.Activity do import Ecto.Changeset import Ecto.Query - require Pleroma.Constants - @type t :: %__MODULE__{} @type actor :: String.t() @@ -358,12 +356,4 @@ defmodule Pleroma.Activity do actor = user_actor(activity) activity.id in actor.pinned_activities end - - def local_only?(activity) do - recipients = Enum.concat(activity.data["to"], Map.get(activity.data, "cc", [])) - public = Pleroma.Constants.as_public() - local = Pleroma.Constants.as_local_public() - - Enum.member?(recipients, local) and not Enum.member?(recipients, public) - end end diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index c9200a3f0..e99f6fd83 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -222,7 +222,7 @@ defmodule Pleroma.Web.ActivityPub.Builder do actor.ap_id == Relay.ap_id() -> [actor.follower_address] - public? and Pleroma.Activity.local_only?(object) -> + public? and Visibility.is_local_public?(object) -> [actor.follower_address, object.data["actor"], Pleroma.Constants.as_local_public()] public? -> diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 559c8387e..98c32a42b 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.SideEffects + alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.Federator @spec common_pipeline(map(), keyword()) :: @@ -55,7 +56,7 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do with {:ok, local} <- Keyword.fetch(meta, :local) do do_not_federate = meta[:do_not_federate] || !Config.get([:instance, :federating]) - if !do_not_federate and local and not Activity.local_only?(activity) do + if !do_not_federate and local and not Visibility.is_local_public?(activity) do activity = if object = Keyword.get(meta, :object_data) do %{activity | data: Map.put(activity.data, "object", object)} diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index faf3bea00..46002bec2 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -176,7 +176,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do with true <- Config.get!([:instance, :federating]), true <- type != "Block" || outgoing_blocks, - false <- Activity.local_only?(activity) do + false <- Visibility.is_local_public?(activity) do Pleroma.Web.Federator.publish(activity) end diff --git a/lib/pleroma/web/activity_pub/visibility.ex b/lib/pleroma/web/activity_pub/visibility.ex index b3b23a38b..2cb5a2bd0 100644 --- a/lib/pleroma/web/activity_pub/visibility.ex +++ b/lib/pleroma/web/activity_pub/visibility.ex @@ -23,6 +23,14 @@ defmodule Pleroma.Web.ActivityPub.Visibility do Utils.label_in_message?(Pleroma.Constants.as_local_public(), data) end + def is_local_public?(%Object{data: data}), do: is_local_public?(data) + def is_local_public?(%Activity{data: data}), do: is_local_public?(data) + + def is_local_public?(data) do + Utils.label_in_message?(Pleroma.Constants.as_local_public(), data) and + not Utils.label_in_message?(Pleroma.Constants.as_public(), data) + end + def is_private?(activity) do with false <- is_public?(activity), %User{follower_address: follower_address} <- @@ -118,6 +126,9 @@ defmodule Pleroma.Web.ActivityPub.Visibility do Pleroma.Constants.as_public() in cc -> "unlisted" + Pleroma.Constants.as_local_public() in to -> + "local" + # this should use the sql for the object's activity Enum.any?(to, &String.contains?(&1, "/followers")) -> "private" diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index e989e4f5f..d7ebde6f6 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -475,10 +475,6 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do type: :string, description: "Will reply to a given conversation, addressing only the people who are part of the recipient set of that conversation. Sets the visibility to `direct`." - }, - local_only: %Schema{ - type: :boolean, - description: "Post the status as local only" } }, example: %{ diff --git a/lib/pleroma/web/api_spec/schemas/visibility_scope.ex b/lib/pleroma/web/api_spec/schemas/visibility_scope.ex index 831734e27..633269a92 100644 --- a/lib/pleroma/web/api_spec/schemas/visibility_scope.ex +++ b/lib/pleroma/web/api_spec/schemas/visibility_scope.ex @@ -9,6 +9,6 @@ defmodule Pleroma.Web.ApiSpec.Schemas.VisibilityScope do title: "VisibilityScope", description: "Status visibility", type: :string, - enum: ["public", "unlisted", "private", "direct", "list"] + enum: ["public", "unlisted", "local", "private", "direct", "list"] }) end diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex index 4df37b695..31d9ea677 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -359,7 +359,7 @@ defmodule Pleroma.Web.CommonAPI do def get_visibility(_, _, %Participation{}), do: {"direct", "direct"} def get_visibility(%{visibility: visibility}, in_reply_to, _) - when visibility in ~w{public unlisted private direct}, + when visibility in ~w{public local unlisted private direct}, do: {visibility, get_replied_to_visibility(in_reply_to)} def get_visibility(%{visibility: "list:" <> list_id}, in_reply_to, _) do diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index abf6c40d5..ae133b54f 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -65,8 +65,14 @@ defmodule Pleroma.Web.CommonAPI.Utils do {Enum.map(participation.recipients, & &1.ap_id), []} end - def get_to_and_cc(%{visibility: "public"} = draft) do - to = [public_uri(draft) | draft.mentions] + def get_to_and_cc(%{visibility: visibility} = draft) when visibility in ["public", "local"] do + + to = + case visibility do + "public" -> [Pleroma.Constants.as_public() | draft.mentions] + "local" -> [Pleroma.Constants.as_local_public() | draft.mentions] + end + cc = [draft.user.follower_address] if draft.in_reply_to do @@ -78,7 +84,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do def get_to_and_cc(%{visibility: "unlisted"} = draft) do to = [draft.user.follower_address | draft.mentions] - cc = [public_uri(draft)] + cc = [Pleroma.Constants.as_public()] if draft.in_reply_to do {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc} @@ -103,9 +109,6 @@ defmodule Pleroma.Web.CommonAPI.Utils do def get_to_and_cc(%{visibility: {:list, _}, mentions: mentions}), do: {mentions, []} - defp public_uri(%{params: %{local_only: true}}), do: Pleroma.Constants.as_local_public() - defp public_uri(_), do: Pleroma.Constants.as_public() - def get_addressed_users(_, to) when is_list(to) do User.get_ap_ids_by_nicknames(to) end diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 0fc78972e..435bcde15 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -368,8 +368,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do direct_conversation_id: direct_conversation_id, thread_muted: thread_muted?, emoji_reactions: emoji_reactions, - parent_visible: visible_for_user?(reply_to, opts[:for]), - local_only: Activity.local_only?(activity) + parent_visible: visible_for_user?(reply_to, opts[:for]) } } end -- cgit v1.2.3 From af3f00292c6cb37580a6bf93d7e779316bc44c6a Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn <egor@kislitsyn.com> Date: Wed, 11 Nov 2020 19:12:46 +0400 Subject: Fix formatting --- lib/pleroma/web/common_api/utils.ex | 1 - 1 file changed, 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index ae133b54f..1c74ea787 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -66,7 +66,6 @@ defmodule Pleroma.Web.CommonAPI.Utils do end def get_to_and_cc(%{visibility: visibility} = draft) when visibility in ["public", "local"] do - to = case visibility do "public" -> [Pleroma.Constants.as_public() | draft.mentions] -- cgit v1.2.3 From 8da9f919f82ac45c4519910a7e24cac2b797061f Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Wed, 11 Nov 2020 18:49:15 +0300 Subject: little changes for MRF config descriptions - log level reduction, if policy doesn't implement config_description method - docs in dev.md --- lib/pleroma/web/activity_pub/mrf.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index ce125a696..6e73b2f22 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -164,7 +164,7 @@ defmodule Pleroma.Web.ActivityPub.MRF do acc end else - Logger.info( + Logger.debug( "#{policy} is excluded from config descriptions, because does not implement `config_description/0` method." ) -- cgit v1.2.3 From 631def2df228ceb0ec8921c63b90867758e0c308 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Wed, 11 Nov 2020 17:10:59 +0100 Subject: RedirectController: Don't replace title, but inject into the meta --- lib/pleroma/web/fallback/redirect_controller.ex | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/fallback/redirect_controller.ex b/lib/pleroma/web/fallback/redirect_controller.ex index 712991c18..1ac1319f8 100644 --- a/lib/pleroma/web/fallback/redirect_controller.ex +++ b/lib/pleroma/web/fallback/redirect_controller.ex @@ -41,8 +41,7 @@ defmodule Pleroma.Web.Fallback.RedirectController do response = index_content - |> String.replace(~r/<title>.+?<\/title>/, title) - |> String.replace("<!--server-generated-meta-->", tags <> preloads) + |> String.replace("<!--server-generated-meta-->", tags <> preloads <> title) conn |> put_resp_content_type("text/html") @@ -60,8 +59,7 @@ defmodule Pleroma.Web.Fallback.RedirectController do response = index_content - |> String.replace(~r/<title>.+?<\/title>/, title) - |> String.replace("<!--server-generated-meta-->", preloads) + |> String.replace("<!--server-generated-meta-->", preloads <> title) conn |> put_resp_content_type("text/html") -- cgit v1.2.3 From 435bf1f9450954eab5f753a983dee202aa11bac1 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn <egor@kislitsyn.com> Date: Wed, 11 Nov 2020 20:12:35 +0400 Subject: Remove FrontendInstallerWorker --- .../admin_api/controllers/frontend_controller.ex | 3 +-- lib/pleroma/workers/frontend_installer_worker.ex | 21 --------------------- 2 files changed, 1 insertion(+), 23 deletions(-) delete mode 100644 lib/pleroma/workers/frontend_installer_worker.ex (limited to 'lib') diff --git a/lib/pleroma/web/admin_api/controllers/frontend_controller.ex b/lib/pleroma/web/admin_api/controllers/frontend_controller.ex index 59c69aba1..4518bed5a 100644 --- a/lib/pleroma/web/admin_api/controllers/frontend_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/frontend_controller.ex @@ -7,7 +7,6 @@ defmodule Pleroma.Web.AdminAPI.FrontendController do alias Pleroma.Config alias Pleroma.Web.Plugs.OAuthScopesPlug - alias Pleroma.Workers.FrontendInstallerWorker plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(OAuthScopesPlug, %{scopes: ["write"], admin: true} when action == :install) @@ -30,7 +29,7 @@ defmodule Pleroma.Web.AdminAPI.FrontendController do end def install(%{body_params: params} = conn, _params) do - FrontendInstallerWorker.install(params.name, Map.delete(params, :name)) + Pleroma.Frontend.install(params.name, Map.delete(params, :name)) index(conn, %{}) end diff --git a/lib/pleroma/workers/frontend_installer_worker.ex b/lib/pleroma/workers/frontend_installer_worker.ex deleted file mode 100644 index 38688c63b..000000000 --- a/lib/pleroma/workers/frontend_installer_worker.ex +++ /dev/null @@ -1,21 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Workers.FrontendInstallerWorker do - use Oban.Worker, queue: :frontend_installer, max_attempts: 1 - - alias Oban.Job - alias Pleroma.Frontend - - def install(name, opts \\ []) do - %{"name" => name, "opts" => Map.new(opts)} - |> new() - |> Oban.insert() - end - - def perform(%Job{args: %{"name" => name, "opts" => opts}}) do - opts = Keyword.new(opts, fn {key, value} -> {String.to_existing_atom(key), value} end) - Frontend.install(name, opts) - end -end -- cgit v1.2.3 From d26a4493960cc9d99183dfcd18464040213ac91e Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn <egor@kislitsyn.com> Date: Wed, 11 Nov 2020 20:39:57 +0400 Subject: Change endpoint path --- lib/pleroma/web/router.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index f497a96b7..75a885377 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -245,7 +245,7 @@ defmodule Pleroma.Web.Router do delete("/chats/:id/messages/:message_id", ChatController, :delete_message) get("/frontends", FrontendController, :index) - post("/frontends", FrontendController, :install) + post("/frontends/install", FrontendController, :install) post("/backups", AdminAPIController, :create_backup) end -- cgit v1.2.3 From 6fd72e9e8526680836e1bf34c58e10b66dcfee8c Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Wed, 11 Nov 2020 12:27:51 -0600 Subject: Ingest blurhash for attachments if they were federated --- lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex index df102a134..f96fd54bf 100644 --- a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex @@ -15,6 +15,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do field(:type, :string) field(:mediaType, :string, default: "application/octet-stream") field(:name, :string) + field(:blurhash, :string) embeds_many :url, UrlObjectValidator, primary_key: false do field(:type, :string) @@ -41,7 +42,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do |> fix_url() struct - |> cast(data, [:type, :mediaType, :name]) + |> cast(data, [:type, :mediaType, :name, :blurhash]) |> cast_embed(:url, with: &url_changeset/2) |> validate_inclusion(:type, ~w[Link Document Audio Image Video]) |> validate_required([:type, :mediaType, :url]) -- cgit v1.2.3 From 2254e5e5958803beef94e2d01bdb04647a1f82c9 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Wed, 11 Nov 2020 12:51:13 -0600 Subject: Render blurhashes in Mastodon API --- lib/pleroma/web/mastodon_api/views/status_view.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 435bcde15..7cbbd3750 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -435,7 +435,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do text_url: href, type: type, description: attachment["name"], - pleroma: %{mime_type: media_type} + pleroma: %{mime_type: media_type}, + blurhash: attachment["blurhash"] } end -- cgit v1.2.3 From 2156de2feef373eecdf8e16f21ac7c4b1ee99995 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Wed, 11 Nov 2020 13:39:02 -0600 Subject: Ingest blurhash field during transmogrification --- lib/pleroma/web/activity_pub/transmogrifier.ex | 1 + 1 file changed, 1 insertion(+) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 39c8f7e39..0bcd1db22 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -252,6 +252,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do } |> Maps.put_if_present("mediaType", media_type) |> Maps.put_if_present("name", data["name"]) + |> Maps.put_if_present("blurhash", data["blurhash"]) else nil end -- cgit v1.2.3 From 6ca709816f74f1171423c7bc040619fca57a2087 Mon Sep 17 00:00:00 2001 From: rinpatch <rinpatch@sdf.org> Date: Wed, 28 Oct 2020 18:08:23 +0300 Subject: Fix object spoofing vulnerability in attachments Validate the content-type of the response when fetching an object, according to https://www.w3.org/TR/activitypub/#x3-2-retrieving-objects. content-type headers had to be added to many mocks in order to support this, some of this was done with a regex. While I did go over the resulting files to check I didn't modify anything unrelated, there is a possibility I missed something. Closes pleroma#1948 --- lib/pleroma/object/fetcher.ex | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 169298b34..ae4301738 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -232,8 +232,24 @@ defmodule Pleroma.Object.Fetcher do |> sign_fetch(id, date) case HTTP.get(id, headers) do - {:ok, %{body: body, status: code}} when code in 200..299 -> - {:ok, body} + {:ok, %{body: body, status: code, headers: headers}} when code in 200..299 -> + case List.keyfind(headers, "content-type", 0) do + {_, content_type} -> + case Plug.Conn.Utils.media_type(content_type) do + {:ok, "application", "activity+json", _} -> + {:ok, body} + + {:ok, "application", "ld+json", + %{"profile" => "https://www.w3.org/ns/activitystreams"}} -> + {:ok, body} + + _ -> + {:error, {:content_type, content_type}} + end + + _ -> + {:error, {:content_type, nil}} + end {:ok, %{status: code}} when code in [404, 410] -> {:error, "Object has been deleted"} -- cgit v1.2.3 From 1830b6aae5e3fa0dfebcadd6f4b78871f702dd2d Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Fri, 13 Nov 2020 15:13:14 +0300 Subject: added error messages for posix error code --- lib/pleroma/emoji/pack.ex | 55 +++++++++++-------- lib/pleroma/utils.ex | 16 ++++++ .../controllers/emoji_file_controller.ex | 37 +++++++++---- .../controllers/emoji_pack_controller.ex | 63 +++++++++++++++------- 4 files changed, 122 insertions(+), 49 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index ca58e5432..4f4e84bfe 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -22,14 +22,14 @@ defmodule Pleroma.Emoji.Pack do alias Pleroma.Emoji alias Pleroma.Emoji.Pack + alias Pleroma.Utils @spec create(String.t()) :: {:ok, t()} | {:error, File.posix()} | {:error, :empty_values} def create(name) do with :ok <- validate_not_empty([name]), dir <- Path.join(emoji_path(), name), :ok <- File.mkdir(dir) do - %__MODULE__{pack_file: Path.join(dir, "pack.json")} - |> save_pack() + save_pack(%__MODULE__{pack_file: Path.join(dir, "pack.json")}) end end @@ -94,7 +94,7 @@ defmodule Pleroma.Emoji.Pack do def add_file(%Pack{} = pack, _, _, %Plug.Upload{content_type: "application/zip"} = file) do with {:ok, zip_files} <- :zip.table(to_charlist(file.path)), [_ | _] = emojies <- unpack_zip_emojies(zip_files), - {:ok, tmp_dir} <- Pleroma.Utils.tmp_dir("emoji") do + {:ok, tmp_dir} <- Utils.tmp_dir("emoji") do try do {:ok, _emoji_files} = :zip.unzip( @@ -282,18 +282,21 @@ defmodule Pleroma.Emoji.Pack do end end - @spec load_pack(String.t()) :: {:ok, t()} | {:error, :not_found} + @spec load_pack(String.t()) :: {:ok, t()} | {:error, :file.posix()} def load_pack(name) do pack_file = Path.join([emoji_path(), name, "pack.json"]) - if File.exists?(pack_file) do + with {:ok, _} <- File.stat(pack_file), + {:ok, pack_data} <- File.read(pack_file) do pack = - pack_file - |> File.read!() - |> from_json() - |> Map.put(:pack_file, pack_file) - |> Map.put(:path, Path.dirname(pack_file)) - |> Map.put(:name, name) + from_json( + pack_data, + %{ + pack_file: pack_file, + path: Path.dirname(pack_file), + name: name + } + ) files_count = pack.files @@ -301,8 +304,6 @@ defmodule Pleroma.Emoji.Pack do |> length() {:ok, Map.put(pack, :files_count, files_count)} - else - {:error, :not_found} end end @@ -434,10 +435,17 @@ defmodule Pleroma.Emoji.Pack do end end - defp from_json(json) do + defp from_json(json, attrs) do map = Jason.decode!(json) - struct(__MODULE__, %{files: map["files"], pack: map["pack"]}) + pack_attrs = + attrs + |> Map.merge(%{ + files: map["files"], + pack: map["pack"] + }) + + struct(__MODULE__, pack_attrs) end defp validate_shareable_packs_available(uri) do @@ -491,10 +499,10 @@ defmodule Pleroma.Emoji.Pack do end defp create_subdirs(file_path) do - if String.contains?(file_path, "/") do - file_path - |> Path.dirname() - |> File.mkdir_p!() + with true <- String.contains?(file_path, "/"), + path <- Path.dirname(file_path), + false <- File.exists?(path) do + File.mkdir_p!(path) end end @@ -518,10 +526,15 @@ defmodule Pleroma.Emoji.Pack do defp get_filename(pack, shortcode) do with %{^shortcode => filename} when is_binary(filename) <- pack.files, - true <- pack.path |> Path.join(filename) |> File.exists?() do + file_path <- Path.join(pack.path, filename), + {:ok, _} <- File.stat(file_path) do {:ok, filename} else - _ -> {:error, :doesnt_exist} + {:error, _} = error -> + error + + _ -> + {:error, :doesnt_exist} end end diff --git a/lib/pleroma/utils.ex b/lib/pleroma/utils.ex index e95766223..fa75a8c99 100644 --- a/lib/pleroma/utils.ex +++ b/lib/pleroma/utils.ex @@ -3,6 +3,14 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Utils do + @posix_error_codes ~w( + eacces eagain ebadf ebadmsg ebusy edeadlk edeadlock edquot eexist efault + efbig eftype eintr einval eio eisdir eloop emfile emlink emultihop + enametoolong enfile enobufs enodev enolck enolink enoent enomem enospc + enosr enostr enosys enotblk enotdir enotsup enxio eopnotsupp eoverflow + eperm epipe erange erofs espipe esrch estale etxtbsy exdev + )a + def compile_dir(dir) when is_binary(dir) do dir |> File.ls!() @@ -44,4 +52,12 @@ defmodule Pleroma.Utils do error -> error end end + + @spec posix_error_message(atom()) :: binary() + def posix_error_message(code) when code in @posix_error_codes do + error_message = Gettext.dgettext(Pleroma.Web.Gettext, "posix_errors", "#{code}") + "(POSIX error: #{error_message})" + end + + def posix_error_message(_), do: "" end diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_file_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_file_controller.ex index 428c97de6..c15980ff0 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_file_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_file_controller.ex @@ -42,7 +42,10 @@ defmodule Pleroma.Web.PleromaAPI.EmojiFileController do |> json(%{error: "pack name, shortcode or filename cannot be empty"}) {:error, _} = error -> - handle_error(conn, error, %{pack_name: pack_name}) + handle_error(conn, error, %{ + pack_name: pack_name, + message: "Unexpected error occurred while adding file to pack." + }) end end @@ -69,7 +72,11 @@ defmodule Pleroma.Web.PleromaAPI.EmojiFileController do |> json(%{error: "new_shortcode or new_filename cannot be empty"}) {:error, _} = error -> - handle_error(conn, error, %{pack_name: pack_name, code: shortcode}) + handle_error(conn, error, %{ + pack_name: pack_name, + code: shortcode, + message: "Unexpected error occurred while updating." + }) end end @@ -84,7 +91,11 @@ defmodule Pleroma.Web.PleromaAPI.EmojiFileController do |> json(%{error: "pack name or shortcode cannot be empty"}) {:error, _} = error -> - handle_error(conn, error, %{pack_name: pack_name, code: shortcode}) + handle_error(conn, error, %{ + pack_name: pack_name, + code: shortcode, + message: "Unexpected error occurred while deleting emoji file." + }) end end @@ -94,18 +105,24 @@ defmodule Pleroma.Web.PleromaAPI.EmojiFileController do |> json(%{error: "Emoji \"#{emoji_code}\" does not exist"}) end - defp handle_error(conn, {:error, :not_found}, %{pack_name: pack_name}) do + defp handle_error(conn, {:error, :enoent}, %{pack_name: pack_name}) do conn |> put_status(:not_found) |> json(%{error: "pack \"#{pack_name}\" is not found"}) end - defp handle_error(conn, {:error, _}, _) do - render_error( - conn, - :internal_server_error, - "Unexpected error occurred while adding file to pack." - ) + defp handle_error(conn, {:error, error}, opts) do + message = + [ + Map.get(opts, :message, "Unexpected error occurred."), + Pleroma.Utils.posix_error_message(error) + ] + |> Enum.join(" ") + |> String.trim() + + conn + |> put_status(:internal_server_error) + |> json(%{error: message}) end defp get_filename(%Plug.Upload{filename: filename}), do: filename diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex index a9accc5af..2fb29d34e 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex @@ -71,7 +71,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackController do with {:ok, pack} <- Pack.show(name: name, page: page, page_size: page_size) do json(conn, pack) else - {:error, :not_found} -> + {:error, :enoent} -> conn |> put_status(:not_found) |> json(%{error: "Pack #{name} does not exist"}) @@ -80,6 +80,17 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackController do conn |> put_status(:bad_request) |> json(%{error: "pack name cannot be empty"}) + + {:error, error} -> + error_message = + add_posix_error( + "Failed to get the contents of the `#{name}` pack.", + error + ) + + conn + |> put_status(:internal_server_error) + |> json(%{error: error_message}) end end @@ -95,7 +106,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackController do "Pack #{name} cannot be downloaded from this instance, either pack sharing was disabled for this pack or some files are missing" }) - {:error, :not_found} -> + {:error, :enoent} -> conn |> put_status(:not_found) |> json(%{error: "Pack #{name} does not exist"}) @@ -116,10 +127,10 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackController do |> put_status(:internal_server_error) |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"}) - {:error, e} -> + {:error, error} -> conn |> put_status(:internal_server_error) - |> json(%{error: e}) + |> json(%{error: error}) end end @@ -139,12 +150,16 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackController do |> put_status(:bad_request) |> json(%{error: "pack name cannot be empty"}) - {:error, _} -> - render_error( - conn, - :internal_server_error, - "Unexpected error occurred while creating pack." - ) + {:error, error} -> + error_message = + add_posix_error( + "Unexpected error occurred while creating pack.", + error + ) + + conn + |> put_status(:internal_server_error) + |> json(%{error: error_message}) end end @@ -164,10 +179,12 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackController do |> put_status(:bad_request) |> json(%{error: "pack name cannot be empty"}) - {:error, _, _} -> + {:error, error, _} -> + error_message = add_posix_error("Couldn't delete the pack #{name}", error) + conn |> put_status(:internal_server_error) - |> json(%{error: "Couldn't delete the pack #{name}"}) + |> json(%{error: error_message}) end end @@ -180,12 +197,16 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackController do |> put_status(:bad_request) |> json(%{error: "The fallback archive does not have all files specified in pack.json"}) - {:error, _} -> - render_error( - conn, - :internal_server_error, - "Unexpected error occurred while updating pack metadata." - ) + {:error, error} -> + error_message = + add_posix_error( + "Unexpected error occurred while updating pack metadata.", + error + ) + + conn + |> put_status(:internal_server_error) + |> json(%{error: error_message}) end end @@ -204,4 +225,10 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackController do |> json(%{error: "Error accessing emoji pack directory"}) end end + + defp add_posix_error(msg, error) do + [msg, Pleroma.Utils.posix_error_message(error)] + |> Enum.join(" ") + |> String.trim() + end end -- cgit v1.2.3 From 70e4b86250da9ef97a836f497510c36bf22fa905 Mon Sep 17 00:00:00 2001 From: Ilja <pleroma@spectraltheorem.be> Date: Fri, 13 Nov 2020 13:35:46 +0000 Subject: Make notifs view work for reports * These are the first small steps for issue 2034 "Reports should send a notification to admins". * I added a new type of notification "pleroma:report" to the the database manually (a migration will need to be written later) * I added the new type to the notification_controller * I made the view return the notification. It doesn't include the report itself (yet) --- lib/pleroma/notification.ex | 12 ++++++++++-- .../web/api_spec/operations/notification_operation.ex | 3 +++ lib/pleroma/web/mastodon_api/views/notification_view.ex | 11 +++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 8868a910e..dd7a1c824 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -70,6 +70,7 @@ defmodule Pleroma.Notification do move pleroma:chat_mention pleroma:emoji_reaction + pleroma:report reblog } @@ -367,7 +368,7 @@ defmodule Pleroma.Notification do end def create_notifications(%Activity{data: %{"type" => type}} = activity, options) - when type in ["Follow", "Like", "Announce", "Move", "EmojiReact"] do + when type in ["Follow", "Like", "Announce", "Move", "EmojiReact", "Flag"] do do_create_notifications(activity, options) end @@ -410,6 +411,9 @@ defmodule Pleroma.Notification do "EmojiReact" -> "pleroma:emoji_reaction" + "Flag" -> + "pleroma:report" + # Compatibility with old reactions "EmojiReaction" -> "pleroma:emoji_reaction" @@ -467,7 +471,7 @@ defmodule Pleroma.Notification do def get_notified_from_activity(activity, local_only \\ true) def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, local_only) - when type in ["Create", "Like", "Announce", "Follow", "Move", "EmojiReact"] do + when type in ["Create", "Like", "Announce", "Follow", "Move", "EmojiReact", "Flag"] do potential_receiver_ap_ids = get_potential_receiver_ap_ids(activity) potential_receivers = @@ -503,6 +507,10 @@ defmodule Pleroma.Notification do [object_id] end + def get_potential_receiver_ap_ids(%{data: %{"type" => "Flag"}}) do + User.all_superusers() |> Enum.map(fn user -> user.ap_id end) + end + def get_potential_receiver_ap_ids(activity) do [] |> Utils.maybe_notify_to_recipients(activity) diff --git a/lib/pleroma/web/api_spec/operations/notification_operation.ex b/lib/pleroma/web/api_spec/operations/notification_operation.ex index f09be64cb..264a530d2 100644 --- a/lib/pleroma/web/api_spec/operations/notification_operation.ex +++ b/lib/pleroma/web/api_spec/operations/notification_operation.ex @@ -193,6 +193,7 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do "mention", "pleroma:emoji_reaction", "pleroma:chat_mention", + "pleroma:report", "move", "follow_request" ], @@ -206,6 +207,8 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do - `poll` - A poll you have voted in or created has ended - `move` - Someone moved their account - `pleroma:emoji_reaction` - Someone reacted with emoji to your status + - `pleroma:chat_mention` - Someone mentioned you in a chat message + - `pleroma:report` - Someone was reported """ } end diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index c97e6d32f..5b06a6b51 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -11,6 +11,8 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do alias Pleroma.Object alias Pleroma.User alias Pleroma.UserRelationship + alias Pleroma.Web.AdminAPI.Report + alias Pleroma.Web.AdminAPI.ReportView alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.NotificationView @@ -118,11 +120,20 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do "pleroma:chat_mention" -> put_chat_message(response, activity, reading_user, status_render_opts) + "pleroma:report" -> + put_report(response, activity) + type when type in ["follow", "follow_request"] -> response end end + defp put_report(response, activity) do + report_render = ReportView.render("show.json", Report.extract_report_info(activity)) + + Map.put(response, :report, report_render) + end + defp put_emoji(response, activity) do Map.put(response, :emoji, activity.data["content"]) end -- cgit v1.2.3 From 36ec6045214a69cd958c00eb6d37852fff1c7d08 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Sat, 14 Nov 2020 08:30:22 +0300 Subject: added test --- lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex index 79f52dcb3..e576ccbad 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex @@ -169,7 +169,8 @@ defmodule Pleroma.Web.ApiSpec.PleromaEmojiPackOperation do responses: %{ 200 => ok_response(), 400 => Operation.response("Bad Request", "application/json", ApiError), - 404 => Operation.response("Not Found", "application/json", ApiError) + 404 => Operation.response("Not Found", "application/json", ApiError), + 500 => Operation.response("Error", "application/json", ApiError) } } end @@ -184,7 +185,8 @@ defmodule Pleroma.Web.ApiSpec.PleromaEmojiPackOperation do parameters: [name_param()], responses: %{ 200 => Operation.response("Metadata", "application/json", metadata()), - 400 => Operation.response("Bad Request", "application/json", ApiError) + 400 => Operation.response("Bad Request", "application/json", ApiError), + 500 => Operation.response("Error", "application/json", ApiError) } } end -- cgit v1.2.3 From e2f573d68baa8b161b92459ffacf534054082422 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Sat, 14 Nov 2020 22:27:13 +0100 Subject: pleroma.instance: Fix Exiftool module name --- lib/mix/tasks/pleroma/instance.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/instance.ex b/lib/mix/tasks/pleroma/instance.ex index fc21ae062..ac8688424 100644 --- a/lib/mix/tasks/pleroma/instance.ex +++ b/lib/mix/tasks/pleroma/instance.ex @@ -284,7 +284,7 @@ defmodule Mix.Tasks.Pleroma.Instance do defp upload_filters(filters) when is_map(filters) do enabled_filters = if filters.strip do - [Pleroma.Upload.Filter.ExifTool] + [Pleroma.Upload.Filter.Exiftool] else [] end -- cgit v1.2.3 From e1d25bad0c91f903ef6d8c7a2c5d7f2d63213d85 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Mon, 16 Nov 2020 21:45:37 +0300 Subject: fix tests --- lib/pleroma/emoji/pack.ex | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index 4f4e84bfe..f768af19f 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -62,10 +62,9 @@ defmodule Pleroma.Emoji.Pack do @spec delete(String.t()) :: {:ok, [binary()]} | {:error, File.posix(), binary()} | {:error, :empty_values} def delete(name) do - with :ok <- validate_not_empty([name]) do - emoji_path() - |> Path.join(name) - |> File.rm_rf() + with :ok <- validate_not_empty([name]), + pack_path <- Path.join(emoji_path(), name) do + File.rm_rf(pack_path) end end -- cgit v1.2.3 From fb41bd1a85b0bcf41a306a0f89307ed80029bc04 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn <egor@kislitsyn.com> Date: Mon, 16 Nov 2020 22:23:25 +0400 Subject: Hide reactions from muted and blocked users --- lib/pleroma/user.ex | 27 ++++++++++++++++-- .../web/api_spec/operations/account_operation.ex | 6 ++++ .../operations/emoji_reaction_operation.ex | 6 ++++ .../web/api_spec/operations/status_operation.ex | 16 ++++++++++- .../mastodon_api/controllers/account_controller.ex | 3 +- .../mastodon_api/controllers/status_controller.ex | 10 ++++--- .../controllers/timeline_controller.ex | 12 +++++--- lib/pleroma/web/mastodon_api/views/status_view.ex | 26 ++++++++---------- .../web/pleroma_api/controllers/chat_controller.ex | 4 +-- .../controllers/emoji_reaction_controller.ex | 32 +++++++++++++++++++++- .../web/pleroma_api/views/emoji_reaction_view.ex | 2 +- 11 files changed, 113 insertions(+), 31 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 8e4ec8064..66f5efca7 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -245,6 +245,18 @@ defmodule Pleroma.User do end end + def cached_blocked_users_ap_ids(user) do + Cachex.fetch!(:user_cache, "blocked_users_ap_ids:#{user.ap_id}", fn _ -> + blocked_users_ap_ids(user) + end) + end + + def cached_muted_users_ap_ids(user) do + Cachex.fetch!(:user_cache, "muted_users_ap_ids:#{user.ap_id}", fn _ -> + muted_users_ap_ids(user) + end) + end + defdelegate following_count(user), to: FollowingRelationship defdelegate following(user), to: FollowingRelationship defdelegate following?(follower, followed), to: FollowingRelationship @@ -1036,6 +1048,8 @@ defmodule Pleroma.User do Cachex.del(:user_cache, "ap_id:#{user.ap_id}") Cachex.del(:user_cache, "nickname:#{user.nickname}") Cachex.del(:user_cache, "friends_ap_ids:#{user.ap_id}") + Cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}") + Cachex.del(:user_cache, "muted_users_ap_ids:#{user.ap_id}") end @spec get_cached_by_ap_id(String.t()) :: User.t() | nil @@ -1342,6 +1356,8 @@ defmodule Pleroma.User do ) end + Cachex.del(:user_cache, "muted_users_ap_ids:#{muter.ap_id}") + {:ok, Enum.filter([user_mute, user_notification_mute], & &1)} end end @@ -1350,6 +1366,7 @@ defmodule Pleroma.User do with {:ok, user_mute} <- UserRelationship.delete_mute(muter, mutee), {:ok, user_notification_mute} <- UserRelationship.delete_notification_mute(muter, mutee) do + Cachex.del(:user_cache, "muted_users_ap_ids:#{muter.ap_id}") {:ok, [user_mute, user_notification_mute]} end end @@ -2345,13 +2362,19 @@ defmodule Pleroma.User do @spec add_to_block(User.t(), User.t()) :: {:ok, UserRelationship.t()} | {:error, Ecto.Changeset.t()} defp add_to_block(%User{} = user, %User{} = blocked) do - UserRelationship.create_block(user, blocked) + with {:ok, relationship} <- UserRelationship.create_block(user, blocked) do + Cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}") + {:ok, relationship} + end end @spec add_to_block(User.t(), User.t()) :: {:ok, UserRelationship.t()} | {:ok, nil} | {:error, Ecto.Changeset.t()} defp remove_from_block(%User{} = user, %User{} = blocked) do - UserRelationship.delete_block(user, blocked) + with {:ok, relationship} <- UserRelationship.delete_block(user, blocked) do + Cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}") + {:ok, relationship} + end end def set_invisible(user, invisible) do diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 451aa2477..05595bc2a 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -139,6 +139,12 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do :query, %Schema{type: :array, items: VisibilityScope}, "Exclude visibilities" + ), + Operation.parameter( + :with_muted, + :query, + BooleanLike, + "Include reactions from muted acccounts." ) ] ++ pagination_params(), responses: %{ diff --git a/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex b/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex index 745d41f88..9d0e39fc7 100644 --- a/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex +++ b/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex @@ -24,6 +24,12 @@ defmodule Pleroma.Web.ApiSpec.EmojiReactionOperation do Operation.parameter(:id, :path, FlakeID, "Status ID", required: true), Operation.parameter(:emoji, :path, :string, "Filter by a single unicode emoji", required: nil + ), + Operation.parameter( + :with_muted, + :query, + :boolean, + "Include reactions from muted acccounts." ) ], security: [%{"oAuth" => ["read:statuses"]}], diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index b3b6ceb68..4ab918d83 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -31,6 +31,12 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do :query, %Schema{type: :array, items: FlakeID}, "Array of status IDs" + ), + Operation.parameter( + :with_muted, + :query, + BooleanLike, + "Include reactions from muted acccounts." ) ], operationId: "StatusController.index", @@ -67,7 +73,15 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do description: "View information about a status", operationId: "StatusController.show", security: [%{"oAuth" => ["read:statuses"]}], - parameters: [id_param()], + parameters: [ + id_param(), + Operation.parameter( + :with_muted, + :query, + BooleanLike, + "Include reactions from muted acccounts." + ) + ], responses: %{ 200 => status_response(), 404 => Operation.response("Not Found", "application/json", ApiError) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 784fdc975..7ed4603a4 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -292,7 +292,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do |> render("index.json", activities: activities, for: reading_user, - as: :activity + as: :activity, + with_muted: Map.get(params, :with_muted, false) ) else error -> user_visibility_error(conn, error) diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 4d9be5240..9e3a584f0 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -109,7 +109,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do `ids` query param is required """ - def index(%{assigns: %{user: user}} = conn, %{ids: ids} = _params) do + def index(%{assigns: %{user: user}} = conn, %{ids: ids} = params) do limit = 100 activities = @@ -121,7 +121,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do render(conn, "index.json", activities: activities, for: user, - as: :activity + as: :activity, + with_muted: Map.get(params, :with_muted, false) ) end @@ -189,13 +190,14 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do end @doc "GET /api/v1/statuses/:id" - def show(%{assigns: %{user: user}} = conn, %{id: id}) do + def show(%{assigns: %{user: user}} = conn, %{id: id} = params) do with %Activity{} = activity <- Activity.get_by_id_with_object(id), true <- Visibility.visible_for_user?(activity, user) do try_render(conn, "show.json", activity: activity, for: user, - with_direct_conversation_id: true + with_direct_conversation_id: true, + with_muted: Map.get(params, :with_muted, false) ) else _ -> {:error, :not_found} diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index ac96520a3..852bd0695 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -62,7 +62,8 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do |> render("index.json", activities: activities, for: user, - as: :activity + as: :activity, + with_muted: Map.get(params, :with_muted, false) ) end @@ -119,7 +120,8 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do |> render("index.json", activities: activities, for: user, - as: :activity + as: :activity, + with_muted: Map.get(params, :with_muted, false) ) end end @@ -173,7 +175,8 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do |> render("index.json", activities: activities, for: user, - as: :activity + as: :activity, + with_muted: Map.get(params, :with_muted, false) ) end end @@ -202,7 +205,8 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do render(conn, "index.json", activities: activities, for: user, - as: :activity + as: :activity, + with_muted: Map.get(params, :with_muted, false) ) else _e -> render_error(conn, :forbidden, "Error.") diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 7cbbd3750..2301e21cf 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -19,6 +19,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do alias Pleroma.Web.MastodonAPI.PollView alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MediaProxy + alias Pleroma.Web.PleromaAPI.EmojiReactionController import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1, visible_for_user?: 2] @@ -294,21 +295,16 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do end emoji_reactions = - with %{data: %{"reactions" => emoji_reactions}} <- object do - Enum.map(emoji_reactions, fn - [emoji, users] when is_list(users) -> - build_emoji_map(emoji, users, opts[:for]) - - {emoji, users} when is_list(users) -> - build_emoji_map(emoji, users, opts[:for]) - - _ -> - nil - end) - |> Enum.reject(&is_nil/1) - else - _ -> [] - end + object.data + |> Map.get("reactions", []) + |> EmojiReactionController.filter_allowed_users( + opts[:for], + Map.get(opts, :with_muted, false) + ) + |> Stream.map(fn {emoji, users} -> + build_emoji_map(emoji, users, opts[:for]) + end) + |> Enum.to_list() # Status muted state (would do 1 request per status unless user mutes are preloaded) muted = diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 77564b342..bfc0a1f19 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -140,8 +140,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do def index(%{assigns: %{user: %{id: user_id} = user}} = conn, params) do exclude_users = - User.blocked_users_ap_ids(user) ++ - if params[:with_muted], do: [], else: User.muted_users_ap_ids(user) + User.cached_blocked_users_ap_ids(user) ++ + if params[:with_muted], do: [], else: User.cached_muted_users_ap_ids(user) chats = user_id diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex index ae199a50f..dd9c746dc 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do alias Pleroma.Activity alias Pleroma.Object + alias Pleroma.User alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.Plugs.OAuthScopesPlug @@ -29,13 +30,42 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do %Activity{} = activity <- Activity.get_by_id_with_object(activity_id), %Object{data: %{"reactions" => reactions}} when is_list(reactions) <- Object.normalize(activity) do - reactions = filter(reactions, params) + reactions = + reactions + |> filter(params) + |> filter_allowed_users(user, Map.get(params, :with_muted, false)) + render(conn, "index.json", emoji_reactions: reactions, user: user) else _e -> json(conn, []) end end + def filter_allowed_users(reactions, user, with_muted) do + exclude_ap_ids = + if is_nil(user) do + [] + else + User.cached_blocked_users_ap_ids(user) ++ + if not with_muted, do: User.cached_muted_users_ap_ids(user), else: [] + end + + filter_emoji = fn emoji, users -> + case Enum.reject(users, &(&1 in exclude_ap_ids)) do + [] -> nil + users -> {emoji, users} + end + end + + reactions + |> Stream.map(fn + [emoji, users] when is_list(users) -> filter_emoji.(emoji, users) + {emoji, users} when is_list(users) -> filter_emoji.(emoji, users) + _ -> nil + end) + |> Stream.reject(&is_nil/1) + end + defp filter(reactions, %{emoji: emoji}) when is_binary(emoji) do Enum.filter(reactions, fn [e, _] -> e == emoji end) end diff --git a/lib/pleroma/web/pleroma_api/views/emoji_reaction_view.ex b/lib/pleroma/web/pleroma_api/views/emoji_reaction_view.ex index e0f98b50a..110e8a041 100644 --- a/lib/pleroma/web/pleroma_api/views/emoji_reaction_view.ex +++ b/lib/pleroma/web/pleroma_api/views/emoji_reaction_view.ex @@ -11,7 +11,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionView do render_many(emoji_reactions, __MODULE__, "show.json", opts) end - def render("show.json", %{emoji_reaction: [emoji, user_ap_ids], user: user}) do + def render("show.json", %{emoji_reaction: {emoji, user_ap_ids}, user: user}) do users = fetch_users(user_ap_ids) %{ -- cgit v1.2.3 From e4b202d905f4d2ec433862884f34729257990edf Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Mon, 16 Nov 2020 22:23:28 +0300 Subject: added test --- lib/pleroma/web/api_spec/operations/pleroma_emoji_file_operation.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/api_spec/operations/pleroma_emoji_file_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_emoji_file_operation.ex index a56641426..747f17e7f 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_emoji_file_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_emoji_file_operation.ex @@ -27,7 +27,8 @@ defmodule Pleroma.Web.ApiSpec.PleromaEmojiFileOperation do 422 => Operation.response("Unprocessable Entity", "application/json", ApiError), 404 => Operation.response("Not Found", "application/json", ApiError), 400 => Operation.response("Bad Request", "application/json", ApiError), - 409 => Operation.response("Conflict", "application/json", ApiError) + 409 => Operation.response("Conflict", "application/json", ApiError), + 500 => Operation.response("Error", "application/json", ApiError) } } end -- cgit v1.2.3 From b1466661ebd7ec1714642cab4bb8f986e97b93ec Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Mon, 16 Nov 2020 21:29:15 +0000 Subject: Use absolute URLs to thumbnail and background in /api/v1/instance --- lib/pleroma/web/mastodon_api/views/instance_view.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index ea2d3aa9c..c5aca5506 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -23,7 +23,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do streaming_api: Pleroma.Web.Endpoint.websocket_url() }, stats: Pleroma.Stats.get_stats(), - thumbnail: Keyword.get(instance, :instance_thumbnail), + thumbnail: Pleroma.Web.base_url() <> Keyword.get(instance, :instance_thumbnail), languages: ["en"], registrations: Keyword.get(instance, :registrations_open), approval_required: Keyword.get(instance, :account_approval_required), @@ -34,7 +34,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do avatar_upload_limit: Keyword.get(instance, :avatar_upload_limit), background_upload_limit: Keyword.get(instance, :background_upload_limit), banner_upload_limit: Keyword.get(instance, :banner_upload_limit), - background_image: Keyword.get(instance, :background_image), + background_image: Pleroma.Web.base_url() <> Keyword.get(instance, :background_image), chat_limit: Keyword.get(instance, :chat_limit), description_limit: Keyword.get(instance, :description_limit), pleroma: %{ -- cgit v1.2.3 From 81293e5aadd5f1dfe7f90f6a71f625ef86cf3359 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Tue, 17 Nov 2020 13:11:39 +0100 Subject: ActivityPubController: Don't return local only objects --- lib/pleroma/web/activity_pub/activity_pub_controller.ex | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 31df80adb..7e5647f8f 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -82,7 +82,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do def object(conn, _) do with ap_id <- Endpoint.url() <> conn.request_path, %Object{} = object <- Object.get_cached_by_ap_id(ap_id), - {_, true} <- {:public?, Visibility.is_public?(object)} do + {_, true} <- {:public?, Visibility.is_public?(object)}, + {_, false} <- {:local?, Visibility.is_local_public?(object)} do conn |> assign(:tracking_fun_data, object.id) |> set_cache_ttl_for(object) @@ -92,6 +93,9 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do else {:public?, false} -> {:error, :not_found} + + {:local?, true} -> + {:error, :not_found} end end @@ -108,7 +112,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do def activity(conn, _params) do with ap_id <- Endpoint.url() <> conn.request_path, %Activity{} = activity <- Activity.normalize(ap_id), - {_, true} <- {:public?, Visibility.is_public?(activity)} do + {_, true} <- {:public?, Visibility.is_public?(activity)}, + {_, false} <- {:local?, Visibility.is_local_public?(activity)} do conn |> maybe_set_tracking_data(activity) |> set_cache_ttl_for(activity) @@ -117,6 +122,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do |> render("object.json", object: activity) else {:public?, false} -> {:error, :not_found} + {:local?, true} -> {:error, :not_found} nil -> {:error, :not_found} end end -- cgit v1.2.3 From 2c55f7d7cb25b857265df67c21bc59f7778653ee Mon Sep 17 00:00:00 2001 From: rinpatch <rinpatch@sdf.org> Date: Tue, 17 Nov 2020 17:28:30 +0300 Subject: Remove FedSockets Current FedSocket implementation has a bunch of problems. It doesn't have proper error handling (in case of an error the server just doesn't respond until the connection is closed, while the client doesn't match any error messages and just assumes there has been an error after 15s) and the code is full of bad descisions (see: fetch registry which uses uuids for no reason and waits for a response by recursively querying a ets table until the value changes, or double JSON encoding). Sometime ago I almost completed rewriting fedsockets from scrach to adress these issues. However, while doing so, I realized that fedsockets are just too overkill for what they were trying to accomplish, which is reduce the overhead of federation by not signing every message. This could be done without reimplementing failure states and endpoint logic we already have with HTTP by, for example, using TLS cert auth, or switching to a more performant signature algorithm. I opened https://git.pleroma.social/pleroma/pleroma/-/issues/2262 for further discussion on alternatives to fedsockets. From discussions I had with other Pleroma developers it seems like they would approve the descision to remove them as well, therefore I am submitting this patch. --- lib/pleroma/application.ex | 3 +- lib/pleroma/object/fetcher.ex | 27 +--- lib/pleroma/signature.ex | 6 +- lib/pleroma/user.ex | 10 +- lib/pleroma/web/activity_pub/activity_pub.ex | 14 +- lib/pleroma/web/activity_pub/publisher.ex | 32 ++-- lib/pleroma/web/activity_pub/transmogrifier.ex | 2 +- lib/pleroma/web/fed_sockets.ex | 185 ------------------------ lib/pleroma/web/fed_sockets/fed_registry.ex | 185 ------------------------ lib/pleroma/web/fed_sockets/fed_socket.ex | 137 ------------------ lib/pleroma/web/fed_sockets/fetch_registry.ex | 151 ------------------- lib/pleroma/web/fed_sockets/incoming_handler.ex | 88 ----------- lib/pleroma/web/fed_sockets/ingester_worker.ex | 33 ----- lib/pleroma/web/fed_sockets/outgoing_handler.ex | 151 ------------------- lib/pleroma/web/fed_sockets/socket_info.ex | 52 ------- lib/pleroma/web/fed_sockets/supervisor.ex | 59 -------- 16 files changed, 32 insertions(+), 1103 deletions(-) delete mode 100644 lib/pleroma/web/fed_sockets.ex delete mode 100644 lib/pleroma/web/fed_sockets/fed_registry.ex delete mode 100644 lib/pleroma/web/fed_sockets/fed_socket.ex delete mode 100644 lib/pleroma/web/fed_sockets/fetch_registry.ex delete mode 100644 lib/pleroma/web/fed_sockets/incoming_handler.ex delete mode 100644 lib/pleroma/web/fed_sockets/ingester_worker.ex delete mode 100644 lib/pleroma/web/fed_sockets/outgoing_handler.ex delete mode 100644 lib/pleroma/web/fed_sockets/socket_info.ex delete mode 100644 lib/pleroma/web/fed_sockets/supervisor.ex (limited to 'lib') diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 7c4cd9626..8f08a6222 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -207,8 +207,7 @@ defmodule Pleroma.Application do name: Pleroma.Web.Streamer.registry(), keys: :duplicate, partitions: System.schedulers_online() - ]}, - Pleroma.Web.FedSockets.Supervisor + ]} ] end diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index ae4301738..20d8f687d 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -12,7 +12,6 @@ defmodule Pleroma.Object.Fetcher do alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.Federator - alias Pleroma.Web.FedSockets require Logger require Pleroma.Constants @@ -183,16 +182,16 @@ defmodule Pleroma.Object.Fetcher do end end - def fetch_and_contain_remote_object_from_id(prm, opts \\ []) + def fetch_and_contain_remote_object_from_id(id) - def fetch_and_contain_remote_object_from_id(%{"id" => id}, opts), - do: fetch_and_contain_remote_object_from_id(id, opts) + def fetch_and_contain_remote_object_from_id(%{"id" => id}), + do: fetch_and_contain_remote_object_from_id(id) - def fetch_and_contain_remote_object_from_id(id, opts) when is_binary(id) do + def fetch_and_contain_remote_object_from_id(id) when is_binary(id) do Logger.debug("Fetching object #{id} via AP") with {:scheme, true} <- {:scheme, String.starts_with?(id, "http")}, - {:ok, body} <- get_object(id, opts), + {:ok, body} <- get_object(id), {:ok, data} <- safe_json_decode(body), :ok <- Containment.contain_origin_from_id(id, data) do {:ok, data} @@ -208,22 +207,10 @@ defmodule Pleroma.Object.Fetcher do end end - def fetch_and_contain_remote_object_from_id(_id, _opts), + def fetch_and_contain_remote_object_from_id(_id), do: {:error, "id must be a string"} - defp get_object(id, opts) do - with false <- Keyword.get(opts, :force_http, false), - {:ok, fedsocket} <- FedSockets.get_or_create_fed_socket(id) do - Logger.debug("fetching via fedsocket - #{inspect(id)}") - FedSockets.fetch(fedsocket, id) - else - _other -> - Logger.debug("fetching via http - #{inspect(id)}") - get_object_http(id) - end - end - - defp get_object_http(id) do + defp get_object(id) do date = Pleroma.Signature.signed_date() headers = diff --git a/lib/pleroma/signature.ex b/lib/pleroma/signature.ex index e388993b7..3aa6909d2 100644 --- a/lib/pleroma/signature.ex +++ b/lib/pleroma/signature.ex @@ -39,7 +39,7 @@ defmodule Pleroma.Signature do def fetch_public_key(conn) do with %{"keyId" => kid} <- HTTPSignatures.signature_for_conn(conn), {:ok, actor_id} <- key_id_to_actor_id(kid), - {:ok, public_key} <- User.get_public_key_for_ap_id(actor_id, force_http: true) do + {:ok, public_key} <- User.get_public_key_for_ap_id(actor_id) do {:ok, public_key} else e -> @@ -50,8 +50,8 @@ defmodule Pleroma.Signature do def refetch_public_key(conn) do with %{"keyId" => kid} <- HTTPSignatures.signature_for_conn(conn), {:ok, actor_id} <- key_id_to_actor_id(kid), - {:ok, _user} <- ActivityPub.make_user_from_ap_id(actor_id, force_http: true), - {:ok, public_key} <- User.get_public_key_for_ap_id(actor_id, force_http: true) do + {:ok, _user} <- ActivityPub.make_user_from_ap_id(actor_id), + {:ok, public_key} <- User.get_public_key_for_ap_id(actor_id) do {:ok, public_key} else e -> diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 8e4ec8064..a240579f3 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1772,12 +1772,12 @@ defmodule Pleroma.User do def html_filter_policy(_), do: Config.get([:markup, :scrub_policy]) - def fetch_by_ap_id(ap_id, opts \\ []), do: ActivityPub.make_user_from_ap_id(ap_id, opts) + def fetch_by_ap_id(ap_id), do: ActivityPub.make_user_from_ap_id(ap_id) - def get_or_fetch_by_ap_id(ap_id, opts \\ []) do + def get_or_fetch_by_ap_id(ap_id) do cached_user = get_cached_by_ap_id(ap_id) - maybe_fetched_user = needs_update?(cached_user) && fetch_by_ap_id(ap_id, opts) + maybe_fetched_user = needs_update?(cached_user) && fetch_by_ap_id(ap_id) case {cached_user, maybe_fetched_user} do {_, {:ok, %User{} = user}} -> @@ -1850,8 +1850,8 @@ defmodule Pleroma.User do def public_key(_), do: {:error, "key not found"} - def get_public_key_for_ap_id(ap_id, opts \\ []) do - with {:ok, %User{} = user} <- get_or_fetch_by_ap_id(ap_id, opts), + def get_public_key_for_ap_id(ap_id) do + with {:ok, %User{} = user} <- get_or_fetch_by_ap_id(ap_id), {:ok, public_key} <- public_key(user) do {:ok, public_key} else diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index d8f685d38..35f71b7ae 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1289,12 +1289,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do def fetch_follow_information_for_user(user) do with {:ok, following_data} <- - Fetcher.fetch_and_contain_remote_object_from_id(user.following_address, - force_http: true - ), + Fetcher.fetch_and_contain_remote_object_from_id(user.following_address), {:ok, hide_follows} <- collection_private(following_data), {:ok, followers_data} <- - Fetcher.fetch_and_contain_remote_object_from_id(user.follower_address, force_http: true), + Fetcher.fetch_and_contain_remote_object_from_id(user.follower_address), {:ok, hide_followers} <- collection_private(followers_data) do {:ok, %{ @@ -1368,8 +1366,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end end - def fetch_and_prepare_user_from_ap_id(ap_id, opts \\ []) do - with {:ok, data} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id, opts), + def fetch_and_prepare_user_from_ap_id(ap_id) do + with {:ok, data} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id), {:ok, data} <- user_data_from_user_object(data) do {:ok, maybe_update_follow_information(data)} else @@ -1412,13 +1410,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end end - def make_user_from_ap_id(ap_id, opts \\ []) do + def make_user_from_ap_id(ap_id) do user = User.get_cached_by_ap_id(ap_id) if user && !User.ap_enabled?(user) do Transmogrifier.upgrade_user_from_ap_id(ap_id) else - with {:ok, data} <- fetch_and_prepare_user_from_ap_id(ap_id, opts) do + with {:ok, data} <- fetch_and_prepare_user_from_ap_id(ap_id) do if user do user |> User.remote_user_changeset(data) diff --git a/lib/pleroma/web/activity_pub/publisher.ex b/lib/pleroma/web/activity_pub/publisher.ex index a2930c1cd..5ab3562bf 100644 --- a/lib/pleroma/web/activity_pub/publisher.ex +++ b/lib/pleroma/web/activity_pub/publisher.ex @@ -13,7 +13,6 @@ defmodule Pleroma.Web.ActivityPub.Publisher do alias Pleroma.User alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.ActivityPub.Transmogrifier - alias Pleroma.Web.FedSockets require Pleroma.Constants @@ -50,28 +49,6 @@ defmodule Pleroma.Web.ActivityPub.Publisher do """ def publish_one(%{inbox: inbox, json: json, actor: %User{} = actor, id: id} = params) do Logger.debug("Federating #{id} to #{inbox}") - - case FedSockets.get_or_create_fed_socket(inbox) do - {:ok, fedsocket} -> - Logger.debug("publishing via fedsockets - #{inspect(inbox)}") - FedSockets.publish(fedsocket, json) - - _ -> - Logger.debug("publishing via http - #{inspect(inbox)}") - http_publish(inbox, actor, json, params) - end - end - - def publish_one(%{actor_id: actor_id} = params) do - actor = User.get_cached_by_id(actor_id) - - params - |> Map.delete(:actor_id) - |> Map.put(:actor, actor) - |> publish_one() - end - - defp http_publish(inbox, actor, json, params) do uri = %{path: path} = URI.parse(inbox) digest = "SHA-256=" <> (:crypto.hash(:sha256, json) |> Base.encode64()) @@ -110,6 +87,15 @@ defmodule Pleroma.Web.ActivityPub.Publisher do end end + def publish_one(%{actor_id: actor_id} = params) do + actor = User.get_cached_by_id(actor_id) + + params + |> Map.delete(:actor_id) + |> Map.put(:actor, actor) + |> publish_one() + end + defp signature_host(%URI{port: port, scheme: scheme, host: host}) do if port == URI.default_port(scheme) do host diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 0bcd1db22..565d32433 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -1008,7 +1008,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def upgrade_user_from_ap_id(ap_id) do with %User{local: false} = user <- User.get_cached_by_ap_id(ap_id), - {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id, force_http: true), + {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id), {:ok, user} <- update_user(user, data) do TransmogrifierWorker.enqueue("user_upgrade", %{"user_id" => user.id}) {:ok, user} diff --git a/lib/pleroma/web/fed_sockets.ex b/lib/pleroma/web/fed_sockets.ex deleted file mode 100644 index 1fd5899c8..000000000 --- a/lib/pleroma/web/fed_sockets.ex +++ /dev/null @@ -1,185 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.FedSockets do - @moduledoc """ - This documents the FedSockets framework. A framework for federating - ActivityPub objects between servers via persistant WebSocket connections. - - FedSockets allow servers to authenticate on first contact and maintain that - connection, eliminating the need to authenticate every time data needs to be shared. - - ## Protocol - FedSockets currently support 2 types of data transfer: - * `publish` method which doesn't require a response - * `fetch` method requires a response be sent - - ### Publish - The publish operation sends a json encoded map of the shape: - %{action: :publish, data: json} - and accepts (but does not require) a reply of form: - %{"action" => "publish_reply"} - - The outgoing params represent - * data: ActivityPub object encoded into json - - - ### Fetch - The fetch operation sends a json encoded map of the shape: - %{action: :fetch, data: id, uuid: fetch_uuid} - and requires a reply of form: - %{"action" => "fetch_reply", "uuid" => uuid, "data" => data} - - The outgoing params represent - * id: an ActivityPub object URI - * uuid: a unique uuid generated by the sender - - The reply params represent - * data: an ActivityPub object encoded into json - * uuid: the uuid sent along with the fetch request - - ## Examples - Clients of FedSocket transfers shouldn't need to use any of the functions outside of this module. - - A typical publish operation can be performed through the following code, and a fetch operation in a similar manner. - - case FedSockets.get_or_create_fed_socket(inbox) do - {:ok, fedsocket} -> - FedSockets.publish(fedsocket, json) - - _ -> - alternative_publish(inbox, actor, json, params) - end - - ## Configuration - FedSockets have the following config settings - - config :pleroma, :fed_sockets, - enabled: true, - ping_interval: :timer.seconds(15), - connection_duration: :timer.hours(1), - rejection_duration: :timer.hours(1), - fed_socket_fetches: [ - default: 12_000, - interval: 3_000, - lazy: false - ] - * enabled - turn FedSockets on or off with this flag. Can be toggled at runtime. - * connection_duration - How long a FedSocket can sit idle before it's culled. - * rejection_duration - After failing to make a FedSocket connection a host will be excluded - from further connections for this amount of time - * fed_socket_fetches - Use these parameters to pass options to the Cachex queue backing the FetchRegistry - * fed_socket_rejections - Use these parameters to pass options to the Cachex queue backing the FedRegistry - - Cachex options are - * default: the minimum amount of time a fetch can wait before it times out. - * interval: the interval between checks for timed out entries. This plus the default represent the maximum time allowed - * lazy: leave at false for consistant and fast lookups, set to true for stricter timeout enforcement - - """ - require Logger - - alias Pleroma.Web.FedSockets.FedRegistry - alias Pleroma.Web.FedSockets.FedSocket - alias Pleroma.Web.FedSockets.SocketInfo - - @doc """ - returns a FedSocket for the given origin. Will reuse an existing one or create a new one. - - address is expected to be a fully formed URL such as: - "http://www.example.com" or "http://www.example.com:8080" - - It can and usually does include additional path parameters, - but these are ignored as the FedSockets are organized by host and port info alone. - """ - def get_or_create_fed_socket(address) do - with {:cache, {:error, :missing}} <- {:cache, get_fed_socket(address)}, - {:connect, {:ok, _pid}} <- {:connect, FedSocket.connect_to_host(address)}, - {:cache, {:ok, fed_socket}} <- {:cache, get_fed_socket(address)} do - Logger.debug("fedsocket created for - #{inspect(address)}") - {:ok, fed_socket} - else - {:cache, {:ok, socket}} -> - Logger.debug("fedsocket found in cache - #{inspect(address)}") - {:ok, socket} - - {:cache, {:error, :rejected} = e} -> - e - - {:connect, {:error, _host}} -> - Logger.debug("set host rejected for - #{inspect(address)}") - FedRegistry.set_host_rejected(address) - {:error, :rejected} - - {_, {:error, :disabled}} -> - {:error, :disabled} - - {_, {:error, reason}} -> - Logger.warn("get_or_create_fed_socket error - #{inspect(reason)}") - {:error, reason} - end - end - - @doc """ - returns a FedSocket for the given origin. Will not create a new FedSocket if one does not exist. - - address is expected to be a fully formed URL such as: - "http://www.example.com" or "http://www.example.com:8080" - """ - def get_fed_socket(address) do - origin = SocketInfo.origin(address) - - with {:config, true} <- {:config, Pleroma.Config.get([:fed_sockets, :enabled], false)}, - {:ok, socket} <- FedRegistry.get_fed_socket(origin) do - {:ok, socket} - else - {:config, _} -> - {:error, :disabled} - - {:error, :rejected} -> - Logger.debug("FedSocket previously rejected - #{inspect(origin)}") - {:error, :rejected} - - {:error, reason} -> - {:error, reason} - end - end - - @doc """ - Sends the supplied data via the publish protocol. - It will not block waiting for a reply. - Returns :ok but this is not an indication of a successful transfer. - - the data is expected to be JSON encoded binary data. - """ - def publish(%SocketInfo{} = fed_socket, json) do - FedSocket.publish(fed_socket, json) - end - - @doc """ - Sends the supplied data via the fetch protocol. - It will block waiting for a reply or timeout. - - Returns {:ok, object} where object is the requested object (or nil) - {:error, :timeout} in the event the message was not responded to - - the id is expected to be the URI of an ActivityPub object. - """ - def fetch(%SocketInfo{} = fed_socket, id) do - FedSocket.fetch(fed_socket, id) - end - - @doc """ - Disconnect all and restart FedSockets. - This is mainly used in development and testing but could be useful in production. - """ - def reset do - FedRegistry - |> Process.whereis() - |> Process.exit(:testing) - end - - def uri_for_origin(origin), - do: "ws://#{origin}/api/fedsocket/v1" -end diff --git a/lib/pleroma/web/fed_sockets/fed_registry.ex b/lib/pleroma/web/fed_sockets/fed_registry.ex deleted file mode 100644 index e00ea69c0..000000000 --- a/lib/pleroma/web/fed_sockets/fed_registry.ex +++ /dev/null @@ -1,185 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.FedSockets.FedRegistry do - @moduledoc """ - The FedRegistry stores the active FedSockets for quick retrieval. - - The storage and retrieval portion of the FedRegistry is done in process through - elixir's `Registry` module for speed and its ability to monitor for terminated processes. - - Dropped connections will be caught by `Registry` and deleted. Since the next - message will initiate a new connection there is no reason to try and reconnect at that point. - - Normally outside modules should have no need to call or use the FedRegistry themselves. - """ - - alias Pleroma.Web.FedSockets.FedSocket - alias Pleroma.Web.FedSockets.SocketInfo - - require Logger - - @default_rejection_duration 15 * 60 * 1000 - @rejections :fed_socket_rejections - - @doc """ - Retrieves a FedSocket from the Registry given it's origin. - - The origin is expected to be a string identifying the endpoint "example.com" or "example2.com:8080" - - Will return: - * {:ok, fed_socket} for working FedSockets - * {:error, :rejected} for origins that have been tried and refused within the rejection duration interval - * {:error, some_reason} usually :missing for unknown origins - """ - def get_fed_socket(origin) do - case get_registry_data(origin) do - {:error, reason} -> - {:error, reason} - - {:ok, %{state: :connected} = socket_info} -> - {:ok, socket_info} - end - end - - @doc """ - Adds a connected FedSocket to the Registry. - - Always returns {:ok, fed_socket} - """ - def add_fed_socket(origin, pid \\ nil) do - origin - |> SocketInfo.build(pid) - |> SocketInfo.connect() - |> add_socket_info - end - - defp add_socket_info(%{origin: origin, state: :connected} = socket_info) do - case Registry.register(FedSockets.Registry, origin, socket_info) do - {:ok, _owner} -> - clear_prior_rejection(origin) - Logger.debug("fedsocket added: #{inspect(origin)}") - - {:ok, socket_info} - - {:error, {:already_registered, _pid}} -> - FedSocket.close(socket_info) - existing_socket_info = Registry.lookup(FedSockets.Registry, origin) - - {:ok, existing_socket_info} - - _ -> - {:error, :error_adding_socket} - end - end - - @doc """ - Mark this origin as having rejected a connection attempt. - This will keep it from getting additional connection attempts - for a period of time specified in the config. - - Always returns {:ok, new_reg_data} - """ - def set_host_rejected(uri) do - new_reg_data = - uri - |> SocketInfo.origin() - |> get_or_create_registry_data() - |> set_to_rejected() - |> save_registry_data() - - {:ok, new_reg_data} - end - - @doc """ - Retrieves the FedRegistryData from the Registry given it's origin. - - The origin is expected to be a string identifying the endpoint "example.com" or "example2.com:8080" - - Will return: - * {:ok, fed_registry_data} for known origins - * {:error, :missing} for uniknown origins - * {:error, :cache_error} indicating some low level runtime issues - """ - def get_registry_data(origin) do - case Registry.lookup(FedSockets.Registry, origin) do - [] -> - if is_rejected?(origin) do - Logger.debug("previously rejected fedsocket requested") - {:error, :rejected} - else - {:error, :missing} - end - - [{_pid, %{state: :connected} = socket_info}] -> - {:ok, socket_info} - - _ -> - {:error, :cache_error} - end - end - - @doc """ - Retrieves a map of all sockets from the Registry. The keys are the origins and the values are the corresponding SocketInfo - """ - def list_all do - (list_all_connected() ++ list_all_rejected()) - |> Enum.into(%{}) - end - - defp list_all_connected do - FedSockets.Registry - |> Registry.select([{{:"$1", :_, :"$3"}, [], [{{:"$1", :"$3"}}]}]) - end - - defp list_all_rejected do - {:ok, keys} = Cachex.keys(@rejections) - - {:ok, registry_data} = - Cachex.execute(@rejections, fn worker -> - Enum.map(keys, fn k -> {k, Cachex.get!(worker, k)} end) - end) - - registry_data - end - - defp clear_prior_rejection(origin), - do: Cachex.del(@rejections, origin) - - defp is_rejected?(origin) do - case Cachex.get(@rejections, origin) do - {:ok, nil} -> - false - - {:ok, _} -> - true - end - end - - defp get_or_create_registry_data(origin) do - case get_registry_data(origin) do - {:error, :missing} -> - %SocketInfo{origin: origin} - - {:ok, socket_info} -> - socket_info - end - end - - defp save_registry_data(%SocketInfo{origin: origin, state: :connected} = socket_info) do - {:ok, true} = Registry.update_value(FedSockets.Registry, origin, fn _ -> socket_info end) - socket_info - end - - defp save_registry_data(%SocketInfo{origin: origin, state: :rejected} = socket_info) do - rejection_expiration = - Pleroma.Config.get([:fed_sockets, :rejection_duration], @default_rejection_duration) - - {:ok, true} = Cachex.put(@rejections, origin, socket_info, ttl: rejection_expiration) - socket_info - end - - defp set_to_rejected(%SocketInfo{} = socket_info), - do: %SocketInfo{socket_info | state: :rejected} -end diff --git a/lib/pleroma/web/fed_sockets/fed_socket.ex b/lib/pleroma/web/fed_sockets/fed_socket.ex deleted file mode 100644 index 98d64e65a..000000000 --- a/lib/pleroma/web/fed_sockets/fed_socket.ex +++ /dev/null @@ -1,137 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.FedSockets.FedSocket do - @moduledoc """ - The FedSocket module abstracts the actions to be taken taken on connections regardless of - whether the connection started as inbound or outbound. - - - Normally outside modules will have no need to call the FedSocket module directly. - """ - - alias Pleroma.Object - alias Pleroma.Object.Containment - alias Pleroma.User - alias Pleroma.Web.ActivityPub.ObjectView - alias Pleroma.Web.ActivityPub.UserView - alias Pleroma.Web.ActivityPub.Visibility - alias Pleroma.Web.FedSockets.FetchRegistry - alias Pleroma.Web.FedSockets.IngesterWorker - alias Pleroma.Web.FedSockets.OutgoingHandler - alias Pleroma.Web.FedSockets.SocketInfo - - require Logger - - @shake "61dd18f7-f1e6-49a4-939a-a749fcdc1103" - - def connect_to_host(uri) do - case OutgoingHandler.start_link(uri) do - {:ok, pid} -> - {:ok, pid} - - error -> - {:error, error} - end - end - - def close(%SocketInfo{pid: socket_pid}), - do: Process.send(socket_pid, :close, []) - - def publish(%SocketInfo{pid: socket_pid}, json) do - %{action: :publish, data: json} - |> Jason.encode!() - |> send_packet(socket_pid) - end - - def fetch(%SocketInfo{pid: socket_pid}, id) do - fetch_uuid = FetchRegistry.register_fetch(id) - - %{action: :fetch, data: id, uuid: fetch_uuid} - |> Jason.encode!() - |> send_packet(socket_pid) - - wait_for_fetch_to_return(fetch_uuid, 0) - end - - def receive_package(%SocketInfo{} = fed_socket, json) do - json - |> Jason.decode!() - |> process_package(fed_socket) - end - - defp wait_for_fetch_to_return(uuid, cntr) do - case FetchRegistry.check_fetch(uuid) do - {:error, :waiting} -> - Process.sleep(:math.pow(cntr, 3) |> Kernel.trunc()) - wait_for_fetch_to_return(uuid, cntr + 1) - - {:error, :missing} -> - Logger.error("FedSocket fetch timed out - #{inspect(uuid)}") - {:error, :timeout} - - {:ok, _fr} -> - FetchRegistry.pop_fetch(uuid) - end - end - - defp process_package(%{"action" => "publish", "data" => data}, %{origin: origin} = _fed_socket) do - if Containment.contain_origin(origin, data) do - IngesterWorker.enqueue("ingest", %{"object" => data}) - end - - {:reply, %{"action" => "publish_reply", "status" => "processed"}} - end - - defp process_package(%{"action" => "fetch_reply", "uuid" => uuid, "data" => data}, _fed_socket) do - FetchRegistry.register_fetch_received(uuid, data) - {:noreply, nil} - end - - defp process_package(%{"action" => "fetch", "uuid" => uuid, "data" => ap_id}, _fed_socket) do - {:ok, data} = render_fetched_data(ap_id, uuid) - {:reply, data} - end - - defp process_package(%{"action" => "publish_reply"}, _fed_socket) do - {:noreply, nil} - end - - defp process_package(other, _fed_socket) do - Logger.warn("unknown json packages received #{inspect(other)}") - {:noreply, nil} - end - - defp render_fetched_data(ap_id, uuid) do - {:ok, - %{ - "action" => "fetch_reply", - "status" => "processed", - "uuid" => uuid, - "data" => represent_item(ap_id) - }} - end - - defp represent_item(ap_id) do - case User.get_by_ap_id(ap_id) do - nil -> - object = Object.get_cached_by_ap_id(ap_id) - - if Visibility.is_public?(object) do - Phoenix.View.render_to_string(ObjectView, "object.json", object: object) - else - nil - end - - user -> - Phoenix.View.render_to_string(UserView, "user.json", user: user) - end - end - - defp send_packet(data, socket_pid) do - Process.send(socket_pid, {:send, data}, []) - end - - def shake, do: @shake -end diff --git a/lib/pleroma/web/fed_sockets/fetch_registry.ex b/lib/pleroma/web/fed_sockets/fetch_registry.ex deleted file mode 100644 index 7897f0fc6..000000000 --- a/lib/pleroma/web/fed_sockets/fetch_registry.ex +++ /dev/null @@ -1,151 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.FedSockets.FetchRegistry do - @moduledoc """ - The FetchRegistry acts as a broker for fetch requests and return values. - This allows calling processes to block while waiting for a reply. - It doesn't impose it's own process instead using `Cachex` to handle fetches in process, allowing - multi threaded processes to avoid bottlenecking. - - Normally outside modules will have no need to call or use the FetchRegistry themselves. - - The `Cachex` parameters can be controlled from the config. Since exact timeout intervals - aren't necessary the following settings are used by default: - - config :pleroma, :fed_sockets, - fed_socket_fetches: [ - default: 12_000, - interval: 3_000, - lazy: false - ] - - """ - - defmodule FetchRegistryData do - defstruct uuid: nil, - sent_json: nil, - received_json: nil, - sent_at: nil, - received_at: nil - end - - alias Ecto.UUID - - require Logger - - @fetches :fed_socket_fetches - - @doc """ - Registers a json request wth the FetchRegistry and returns the identifying UUID. - """ - def register_fetch(json) do - %FetchRegistryData{uuid: uuid} = - json - |> new_registry_data - |> save_registry_data - - uuid - end - - @doc """ - Reports on the status of a Fetch given the identifying UUID. - - Will return - * {:ok, fetched_object} if a fetch has completed - * {:error, :waiting} if a fetch is still pending - * {:error, other_error} usually :missing to indicate a fetch that has timed out - """ - def check_fetch(uuid) do - case get_registry_data(uuid) do - {:ok, %FetchRegistryData{received_at: nil}} -> - {:error, :waiting} - - {:ok, %FetchRegistryData{} = reg_data} -> - {:ok, reg_data} - - e -> - e - end - end - - @doc """ - Retrieves the response to a fetch given the identifying UUID. - The completed fetch will be deleted from the FetchRegistry - - Will return - * {:ok, fetched_object} if a fetch has completed - * {:error, :waiting} if a fetch is still pending - * {:error, other_error} usually :missing to indicate a fetch that has timed out - """ - def pop_fetch(uuid) do - case check_fetch(uuid) do - {:ok, %FetchRegistryData{received_json: received_json}} -> - delete_registry_data(uuid) - {:ok, received_json} - - e -> - e - end - end - - @doc """ - This is called to register a fetch has returned. - It expects the result data along with the UUID that was sent in the request - - Will return the fetched object or :error - """ - def register_fetch_received(uuid, data) do - case get_registry_data(uuid) do - {:ok, %FetchRegistryData{received_at: nil} = reg_data} -> - reg_data - |> set_fetch_received(data) - |> save_registry_data() - - {:ok, %FetchRegistryData{} = reg_data} -> - Logger.warn("tried to add fetched data twice - #{uuid}") - reg_data - - {:error, _} -> - Logger.warn("Error adding fetch to registry - #{uuid}") - :error - end - end - - defp new_registry_data(json) do - %FetchRegistryData{ - uuid: UUID.generate(), - sent_json: json, - sent_at: :erlang.monotonic_time(:millisecond) - } - end - - defp get_registry_data(origin) do - case Cachex.get(@fetches, origin) do - {:ok, nil} -> - {:error, :missing} - - {:ok, reg_data} -> - {:ok, reg_data} - - _ -> - {:error, :cache_error} - end - end - - defp set_fetch_received(%FetchRegistryData{} = reg_data, data), - do: %FetchRegistryData{ - reg_data - | received_at: :erlang.monotonic_time(:millisecond), - received_json: data - } - - defp save_registry_data(%FetchRegistryData{uuid: uuid} = reg_data) do - {:ok, true} = Cachex.put(@fetches, uuid, reg_data) - reg_data - end - - defp delete_registry_data(origin), - do: {:ok, true} = Cachex.del(@fetches, origin) -end diff --git a/lib/pleroma/web/fed_sockets/incoming_handler.ex b/lib/pleroma/web/fed_sockets/incoming_handler.ex deleted file mode 100644 index 49d0d9d84..000000000 --- a/lib/pleroma/web/fed_sockets/incoming_handler.ex +++ /dev/null @@ -1,88 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.FedSockets.IncomingHandler do - require Logger - - alias Pleroma.Web.FedSockets.FedRegistry - alias Pleroma.Web.FedSockets.FedSocket - alias Pleroma.Web.FedSockets.SocketInfo - - import HTTPSignatures, only: [validate_conn: 1, split_signature: 1] - - @behaviour :cowboy_websocket - - def init(req, state) do - shake = FedSocket.shake() - - with true <- Pleroma.Config.get([:fed_sockets, :enabled]), - sec_protocol <- :cowboy_req.header("sec-websocket-protocol", req, nil), - headers = %{"(request-target)" => ^shake} <- :cowboy_req.headers(req), - true <- validate_conn(%{req_headers: headers}), - %{"keyId" => origin} <- split_signature(headers["signature"]) do - req = - if is_nil(sec_protocol) do - req - else - :cowboy_req.set_resp_header("sec-websocket-protocol", sec_protocol, req) - end - - {:cowboy_websocket, req, %{origin: origin}, %{}} - else - _ -> - {:ok, req, state} - end - end - - def websocket_init(%{origin: origin}) do - case FedRegistry.add_fed_socket(origin) do - {:ok, socket_info} -> - {:ok, socket_info} - - e -> - Logger.error("FedSocket websocket_init failed - #{inspect(e)}") - {:error, inspect(e)} - end - end - - # Use the ping to check if the connection should be expired - def websocket_handle(:ping, socket_info) do - if SocketInfo.expired?(socket_info) do - {:stop, socket_info} - else - {:ok, socket_info, :hibernate} - end - end - - def websocket_handle({:text, data}, socket_info) do - socket_info = SocketInfo.touch(socket_info) - - case FedSocket.receive_package(socket_info, data) do - {:noreply, _} -> - {:ok, socket_info} - - {:reply, reply} -> - {:reply, {:text, Jason.encode!(reply)}, socket_info} - - {:error, reason} -> - Logger.error("incoming error - receive_package: #{inspect(reason)}") - {:ok, socket_info} - end - end - - def websocket_info({:send, message}, socket_info) do - socket_info = SocketInfo.touch(socket_info) - - {:reply, {:text, message}, socket_info} - end - - def websocket_info(:close, state) do - {:stop, state} - end - - def websocket_info(message, state) do - Logger.debug("#{__MODULE__} unknown message #{inspect(message)}") - {:ok, state} - end -end diff --git a/lib/pleroma/web/fed_sockets/ingester_worker.ex b/lib/pleroma/web/fed_sockets/ingester_worker.ex deleted file mode 100644 index 325f2a4ab..000000000 --- a/lib/pleroma/web/fed_sockets/ingester_worker.ex +++ /dev/null @@ -1,33 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.FedSockets.IngesterWorker do - use Pleroma.Workers.WorkerHelper, queue: "ingestion_queue" - require Logger - - alias Pleroma.Web.Federator - - @impl Oban.Worker - def perform(%Job{args: %{"op" => "ingest", "object" => ingestee}}) do - try do - ingestee - |> Jason.decode!() - |> do_ingestion() - rescue - e -> - Logger.error("IngesterWorker error - #{inspect(e)}") - e - end - end - - defp do_ingestion(params) do - case Federator.incoming_ap_doc(params) do - {:error, reason} -> - {:error, reason} - - {:ok, object} -> - {:ok, object} - end - end -end diff --git a/lib/pleroma/web/fed_sockets/outgoing_handler.ex b/lib/pleroma/web/fed_sockets/outgoing_handler.ex deleted file mode 100644 index e235a7c43..000000000 --- a/lib/pleroma/web/fed_sockets/outgoing_handler.ex +++ /dev/null @@ -1,151 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.FedSockets.OutgoingHandler do - use GenServer - - require Logger - - alias Pleroma.Application - alias Pleroma.Web.ActivityPub.InternalFetchActor - alias Pleroma.Web.FedSockets - alias Pleroma.Web.FedSockets.FedRegistry - alias Pleroma.Web.FedSockets.FedSocket - alias Pleroma.Web.FedSockets.SocketInfo - - def start_link(uri) do - GenServer.start_link(__MODULE__, %{uri: uri}) - end - - def init(%{uri: uri}) do - case initiate_connection(uri) do - {:ok, ws_origin, conn_pid} -> - FedRegistry.add_fed_socket(ws_origin, conn_pid) - - {:error, reason} -> - Logger.debug("Outgoing connection failed - #{inspect(reason)}") - :ignore - end - end - - def handle_info({:gun_ws, conn_pid, _ref, {:text, data}}, socket_info) do - socket_info = SocketInfo.touch(socket_info) - - case FedSocket.receive_package(socket_info, data) do - {:noreply, _} -> - {:noreply, socket_info} - - {:reply, reply} -> - :gun.ws_send(conn_pid, {:text, Jason.encode!(reply)}) - {:noreply, socket_info} - - {:error, reason} -> - Logger.error("incoming error - receive_package: #{inspect(reason)}") - {:noreply, socket_info} - end - end - - def handle_info(:close, state) do - Logger.debug("Sending close frame !!!!!!!") - {:close, state} - end - - def handle_info({:gun_down, _pid, _prot, :closed, _}, state) do - {:stop, :normal, state} - end - - def handle_info({:send, data}, %{conn_pid: conn_pid} = socket_info) do - socket_info = SocketInfo.touch(socket_info) - :gun.ws_send(conn_pid, {:text, data}) - {:noreply, socket_info} - end - - def handle_info({:gun_ws, _, _, :pong}, state) do - {:noreply, state, :hibernate} - end - - def handle_info(msg, state) do - Logger.debug("#{__MODULE__} unhandled event #{inspect(msg)}") - {:noreply, state} - end - - def terminate(reason, state) do - Logger.debug( - "#{__MODULE__} terminating outgoing connection for #{inspect(state)} for #{inspect(reason)}" - ) - - {:ok, state} - end - - def initiate_connection(uri) do - ws_uri = - uri - |> SocketInfo.origin() - |> FedSockets.uri_for_origin() - - %{host: host, port: port, path: path} = URI.parse(ws_uri) - - with {:ok, conn_pid} <- :gun.open(to_charlist(host), port, %{protocols: [:http]}), - {:ok, _} <- :gun.await_up(conn_pid), - reference <- - :gun.get(conn_pid, to_charlist(path), [ - {'user-agent', to_charlist(Application.user_agent())} - ]), - {:response, :fin, 204, _} <- :gun.await(conn_pid, reference), - headers <- build_headers(uri), - ref <- :gun.ws_upgrade(conn_pid, to_charlist(path), headers, %{silence_pings: false}) do - receive do - {:gun_upgrade, ^conn_pid, ^ref, [<<"websocket">>], _} -> - {:ok, ws_uri, conn_pid} - after - 15_000 -> - Logger.debug("Fedsocket timeout connecting to #{inspect(uri)}") - {:error, :timeout} - end - else - {:response, :nofin, 404, _} -> - {:error, :fedsockets_not_supported} - - e -> - Logger.debug("Fedsocket error connecting to #{inspect(uri)}") - {:error, e} - end - end - - defp build_headers(uri) do - host_for_sig = uri |> URI.parse() |> host_signature() - - shake = FedSocket.shake() - digest = "SHA-256=" <> (:crypto.hash(:sha256, shake) |> Base.encode64()) - date = Pleroma.Signature.signed_date() - shake_size = byte_size(shake) - - signature_opts = %{ - "(request-target)": shake, - "content-length": to_charlist("#{shake_size}"), - date: date, - digest: digest, - host: host_for_sig - } - - signature = Pleroma.Signature.sign(InternalFetchActor.get_actor(), signature_opts) - - [ - {'signature', to_charlist(signature)}, - {'date', date}, - {'digest', to_charlist(digest)}, - {'content-length', to_charlist("#{shake_size}")}, - {to_charlist("(request-target)"), to_charlist(shake)}, - {'user-agent', to_charlist(Application.user_agent())} - ] - end - - defp host_signature(%{host: host, scheme: scheme, port: port}) do - if port == URI.default_port(scheme) do - host - else - "#{host}:#{port}" - end - end -end diff --git a/lib/pleroma/web/fed_sockets/socket_info.ex b/lib/pleroma/web/fed_sockets/socket_info.ex deleted file mode 100644 index d6fdffe1a..000000000 --- a/lib/pleroma/web/fed_sockets/socket_info.ex +++ /dev/null @@ -1,52 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.FedSockets.SocketInfo do - defstruct origin: nil, - pid: nil, - conn_pid: nil, - state: :default, - connected_until: nil - - alias Pleroma.Web.FedSockets.SocketInfo - @default_connection_duration 15 * 60 * 1000 - - def build(uri, conn_pid \\ nil) do - uri - |> build_origin() - |> build_pids(conn_pid) - |> touch() - end - - def touch(%SocketInfo{} = socket_info), - do: %{socket_info | connected_until: new_ttl()} - - def connect(%SocketInfo{} = socket_info), - do: %{socket_info | state: :connected} - - def expired?(%{connected_until: connected_until}), - do: connected_until < :erlang.monotonic_time(:millisecond) - - def origin(uri), - do: build_origin(uri).origin - - defp build_pids(socket_info, conn_pid), - do: struct(socket_info, pid: self(), conn_pid: conn_pid) - - defp build_origin(uri) when is_binary(uri), - do: uri |> URI.parse() |> build_origin - - defp build_origin(%{host: host, port: nil, scheme: scheme}), - do: build_origin(%{host: host, port: URI.default_port(scheme)}) - - defp build_origin(%{host: host, port: port}), - do: %SocketInfo{origin: "#{host}:#{port}"} - - defp new_ttl do - connection_duration = - Pleroma.Config.get([:fed_sockets, :connection_duration], @default_connection_duration) - - :erlang.monotonic_time(:millisecond) + connection_duration - end -end diff --git a/lib/pleroma/web/fed_sockets/supervisor.ex b/lib/pleroma/web/fed_sockets/supervisor.ex deleted file mode 100644 index a5f4bebfb..000000000 --- a/lib/pleroma/web/fed_sockets/supervisor.ex +++ /dev/null @@ -1,59 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.FedSockets.Supervisor do - use Supervisor - import Cachex.Spec - - def start_link(opts) do - Supervisor.start_link(__MODULE__, opts, name: __MODULE__) - end - - def init(args) do - children = [ - build_cache(:fed_socket_fetches, args), - build_cache(:fed_socket_rejections, args), - {Registry, keys: :unique, name: FedSockets.Registry, meta: [rejected: %{}]} - ] - - opts = [strategy: :one_for_all, name: Pleroma.Web.Streamer.Supervisor] - Supervisor.init(children, opts) - end - - defp build_cache(name, args) do - opts = get_opts(name, args) - - %{ - id: String.to_atom("#{name}_cache"), - start: {Cachex, :start_link, [name, opts]}, - type: :worker - } - end - - defp get_opts(cache_name, args) - when cache_name in [:fed_socket_fetches, :fed_socket_rejections] do - default = get_opts_or_config(args, cache_name, :default, 15_000) - interval = get_opts_or_config(args, cache_name, :interval, 3_000) - lazy = get_opts_or_config(args, cache_name, :lazy, false) - - [expiration: expiration(default: default, interval: interval, lazy: lazy)] - end - - defp get_opts(name, args) do - Keyword.get(args, name, []) - end - - defp get_opts_or_config(args, name, key, default) do - args - |> Keyword.get(name, []) - |> Keyword.get(key) - |> case do - nil -> - Pleroma.Config.get([:fed_sockets, name, key], default) - - value -> - value - end - end -end -- cgit v1.2.3 From 97201f7e3795fb356a7fa355f23be49b83d733dd Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Tue, 17 Nov 2020 15:15:53 +0000 Subject: Need to start web_resp cache or mix task fails --- lib/mix/pleroma.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/mix/pleroma.ex b/lib/mix/pleroma.ex index 49ba2aae4..3de11efce 100644 --- a/lib/mix/pleroma.ex +++ b/lib/mix/pleroma.ex @@ -14,7 +14,7 @@ defmodule Mix.Pleroma do :swoosh, :timex ] - @cachex_children ["object", "user", "scrubber"] + @cachex_children ["object", "user", "scrubber", "web_resp"] @doc "Common functions to be reused in mix tasks" def start_pleroma do Pleroma.Config.Holder.save_default() -- cgit v1.2.3 From bb9650f3c26c0a6155cfb15f5e4da8257316661a Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Tue, 17 Nov 2020 16:43:07 +0100 Subject: FrontendController: Return error on installation error. --- lib/pleroma/frontend.ex | 2 ++ lib/pleroma/web/admin_api/controllers/frontend_controller.ex | 6 +++--- lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/frontend.ex b/lib/pleroma/frontend.ex index b3d4c3325..bf935a728 100644 --- a/lib/pleroma/frontend.ex +++ b/lib/pleroma/frontend.ex @@ -42,9 +42,11 @@ defmodule Pleroma.Frontend do else {:download_or_unzip, _} -> Logger.info("Could not download or unzip the frontend") + {:error, "Could not download or unzip the frontend"} _e -> Logger.info("Could not install the frontend") + {:error, "Could not install the frontend"} end end diff --git a/lib/pleroma/web/admin_api/controllers/frontend_controller.ex b/lib/pleroma/web/admin_api/controllers/frontend_controller.ex index 4518bed5a..fac3522b8 100644 --- a/lib/pleroma/web/admin_api/controllers/frontend_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/frontend_controller.ex @@ -29,9 +29,9 @@ defmodule Pleroma.Web.AdminAPI.FrontendController do end def install(%{body_params: params} = conn, _params) do - Pleroma.Frontend.install(params.name, Map.delete(params, :name)) - - index(conn, %{}) + with :ok <- Pleroma.Frontend.install(params.name, Map.delete(params, :name)) do + index(conn, %{}) + end end defp installed do diff --git a/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex b/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex index 9d7d017a2..96d4cdee7 100644 --- a/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex @@ -36,7 +36,8 @@ defmodule Pleroma.Web.ApiSpec.Admin.FrontendOperation do requestBody: request_body("Parameters", install_request(), required: true), responses: %{ 200 => Operation.response("Response", "application/json", list_of_frontends()), - 403 => Operation.response("Forbidden", "application/json", ApiError) + 403 => Operation.response("Forbidden", "application/json", ApiError), + 400 => Operation.response("Error", "application/json", ApiError) } } end -- cgit v1.2.3 From 3ce11b830ee69d8557146fce6d3507337298259d Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Fri, 13 Nov 2020 17:01:53 -0600 Subject: Add capability for emoji reaction push notifications --- lib/pleroma/web/push/impl.ex | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index da535aa68..f91cb1d40 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -16,7 +16,7 @@ defmodule Pleroma.Web.Push.Impl do require Logger import Ecto.Query - @types ["Create", "Follow", "Announce", "Like", "Move"] + @types ["Create", "Follow", "Announce", "Like", "Move", "EmojiReact"] @doc "Performs sending notifications for user subscriptions" @spec perform(Notification.t()) :: list(any) | :error | {:error, :unknown_type} @@ -149,6 +149,15 @@ defmodule Pleroma.Web.Push.Impl do "@#{actor.nickname} repeated: #{Utils.scrub_html_and_truncate(content, 80)}" end + def format_body( + %{activity: %{data: %{"type" => "EmojiReact", "content" => content}}}, + actor, + _object, + _mastodon_type + ) do + "@#{actor.nickname} has reacted with #{content}" + end + def format_body( %{activity: %{data: %{"type" => type}}} = notification, actor, @@ -179,6 +188,7 @@ defmodule Pleroma.Web.Push.Impl do "reblog" -> "New Repeat" "favourite" -> "New Favorite" "pleroma:chat_mention" -> "New Chat Message" + "pleroma:emoji_reaction" -> "New Reaction" type -> "New #{String.capitalize(type || "event")}" end end -- cgit v1.2.3 From 83ec2f1384611c385e79ddfd23566ddc173a8a4a Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Tue, 17 Nov 2020 18:33:40 +0000 Subject: Allow subscribing for pleroma:emoji_reaction push notifications --- lib/pleroma/web/push/subscription.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/push/subscription.ex b/lib/pleroma/web/push/subscription.ex index 5b5aa0d59..95425b986 100644 --- a/lib/pleroma/web/push/subscription.ex +++ b/lib/pleroma/web/push/subscription.ex @@ -25,7 +25,7 @@ defmodule Pleroma.Web.Push.Subscription do timestamps() end - @supported_alert_types ~w[follow favourite mention reblog pleroma:chat_mention]a + @supported_alert_types ~w[follow favourite mention reblog pleroma:chat_mention pleroma:emoji_reaction]a defp alerts(%{data: %{alerts: alerts}}) do alerts = Map.take(alerts, @supported_alert_types) -- cgit v1.2.3 From 67a6abd071fd4e9f62c032fe952b65b957140bc5 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Tue, 17 Nov 2020 19:15:11 +0000 Subject: Update OpenAPI spec/schema and test to verify support for pleroma:emoji_reaction subscriptions --- lib/pleroma/web/api_spec/operations/subscription_operation.ex | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'lib') diff --git a/lib/pleroma/web/api_spec/operations/subscription_operation.ex b/lib/pleroma/web/api_spec/operations/subscription_operation.ex index 775dd795d..77e70005b 100644 --- a/lib/pleroma/web/api_spec/operations/subscription_operation.ex +++ b/lib/pleroma/web/api_spec/operations/subscription_operation.ex @@ -146,6 +146,11 @@ defmodule Pleroma.Web.ApiSpec.SubscriptionOperation do allOf: [BooleanLike], nullable: true, description: "Receive chat notifications?" + }, + "pleroma:emoji_reaction": %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive emoji reaction notifications?" } } } -- cgit v1.2.3 From 5d0bc5e028c94ab1c7ebf746b5d01af1fbef396f Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Tue, 17 Nov 2020 20:21:48 +0000 Subject: Support both pleroma:chat_mention and pleroma:emoji_reaction for /api/v1/push/subscription --- lib/pleroma/web/api_spec/operations/subscription_operation.ex | 10 ++++++++++ 1 file changed, 10 insertions(+) (limited to 'lib') diff --git a/lib/pleroma/web/api_spec/operations/subscription_operation.ex b/lib/pleroma/web/api_spec/operations/subscription_operation.ex index 77e70005b..67c7ea8f3 100644 --- a/lib/pleroma/web/api_spec/operations/subscription_operation.ex +++ b/lib/pleroma/web/api_spec/operations/subscription_operation.ex @@ -215,6 +215,16 @@ defmodule Pleroma.Web.ApiSpec.SubscriptionOperation do allOf: [BooleanLike], nullable: true, description: "Receive poll notifications?" + }, + "pleroma:chat_mention": %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive chat notifications?" + }, + "pleroma:emoji_reaction": %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive emoji reaction notifications?" } } } -- cgit v1.2.3 From 499faa82f6e9bc400b059a7fd3e5a910eebe1a58 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Tue, 17 Nov 2020 19:51:37 +0000 Subject: Synchronize reaction notification text with PleromaFE's style --- lib/pleroma/web/push/impl.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index f91cb1d40..82152dffa 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -155,7 +155,7 @@ defmodule Pleroma.Web.Push.Impl do _object, _mastodon_type ) do - "@#{actor.nickname} has reacted with #{content}" + "@#{actor.nickname} reacted with #{content}" end def format_body( -- cgit v1.2.3 From ccddedb504e5f03655c128edb074671b1f815f24 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Tue, 17 Nov 2020 20:33:30 +0000 Subject: Credo --- lib/pleroma/web/push/subscription.ex | 1 + 1 file changed, 1 insertion(+) (limited to 'lib') diff --git a/lib/pleroma/web/push/subscription.ex b/lib/pleroma/web/push/subscription.ex index 95425b986..749a573ba 100644 --- a/lib/pleroma/web/push/subscription.ex +++ b/lib/pleroma/web/push/subscription.ex @@ -25,6 +25,7 @@ defmodule Pleroma.Web.Push.Subscription do timestamps() end + # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength @supported_alert_types ~w[follow favourite mention reblog pleroma:chat_mention pleroma:emoji_reaction]a defp alerts(%{data: %{alerts: alerts}}) do -- cgit v1.2.3 From 25eb222bed8c55a81e1ee4c17e05834d0b894030 Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 18 Nov 2020 05:19:01 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex index 2fb29d34e..d2e869e6e 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex @@ -180,7 +180,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackController do |> json(%{error: "pack name cannot be empty"}) {:error, error, _} -> - error_message = add_posix_error("Couldn't delete the pack #{name}", error) + error_message = add_posix_error("Couldn't delete the #{name} pack", error) conn |> put_status(:internal_server_error) -- cgit v1.2.3 From 9c5d1cb9ed41dafea5db5637151a4568a9372d03 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Wed, 18 Nov 2020 09:58:51 +0300 Subject: fix tests --- lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex index d2e869e6e..bc4c8d840 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex @@ -180,7 +180,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackController do |> json(%{error: "pack name cannot be empty"}) {:error, error, _} -> - error_message = add_posix_error("Couldn't delete the #{name} pack", error) + error_message = add_posix_error("Couldn't delete the `#{name}` pack", error) conn |> put_status(:internal_server_error) -- cgit v1.2.3 From e2bf6b1f7ee6115a7eafa78272bdbafe7f4789c5 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Mon, 16 Nov 2020 19:22:32 +0300 Subject: fix for forwarded reports --- lib/pleroma/emails/admin_email.ex | 3 +++ lib/pleroma/web/activity_pub/activity_pub.ex | 35 +++++++++++++++++----------- 2 files changed, 25 insertions(+), 13 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/emails/admin_email.ex b/lib/pleroma/emails/admin_email.ex index 02274554f..d5757c12a 100644 --- a/lib/pleroma/emails/admin_email.ex +++ b/lib/pleroma/emails/admin_email.ex @@ -48,6 +48,9 @@ defmodule Pleroma.Emails.AdminEmail do status_url = Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, id) "<li><a href=\"#{status_url}\">#{status_url}</li>" + %{"id" => id} when is_binary(id) -> + "<li><a href=\"#{id}\">#{id}</li>" + id when is_binary(id) -> "<li><a href=\"#{id}\">#{id}</li>" end) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 35f71b7ae..8f3ce1343 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -332,15 +332,21 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end @spec flag(map()) :: {:ok, Activity.t()} | {:error, any()} - def flag( - %{ - actor: actor, - context: _context, - account: account, - statuses: statuses, - content: content - } = params - ) do + def flag(params) do + with {:ok, result} <- Repo.transaction(fn -> do_flag(params) end) do + result + end + end + + defp do_flag( + %{ + actor: actor, + context: _context, + account: account, + statuses: statuses, + content: content + } = params + ) do # only accept false as false value local = !(params[:local] == false) forward = !(params[:forward] == false) @@ -358,7 +364,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do {:ok, activity} <- insert(flag_data, local), {:ok, stripped_activity} <- strip_report_status_data(activity), _ <- notify_and_stream(activity), - :ok <- maybe_federate(stripped_activity) do + :ok <- + maybe_federate(stripped_activity) do User.all_superusers() |> Enum.filter(fn user -> not is_nil(user.email) end) |> Enum.each(fn superuser -> @@ -368,6 +375,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end) {:ok, activity} + else + {:error, error} -> Repo.rollback(error) end end @@ -791,10 +800,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do where: fragment( """ - ?->>'type' != 'Create' -- This isn't a Create + ?->>'type' != 'Create' -- This isn't a Create OR ?->>'inReplyTo' is null -- this isn't a reply - OR ? && array_remove(?, ?) -- The recipient is us or one of our friends, - -- unless they are the author (because authors + OR ? && array_remove(?, ?) -- The recipient is us or one of our friends, + -- unless they are the author (because authors -- are also part of the recipients). This leads -- to a bug that self-replies by friends won't -- show up. -- cgit v1.2.3 From be0b874e1da0178115e27778a55f52d7d28a727a Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Tue, 17 Nov 2020 19:57:57 +0300 Subject: fix for mastodon forwarded reports --- lib/pleroma/activity.ex | 10 +++++++++ lib/pleroma/web/activity_pub/utils.ex | 40 +++++++++++++++++++++-------------- 2 files changed, 34 insertions(+), 16 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 553834da0..bda5aa616 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -356,4 +356,14 @@ defmodule Pleroma.Activity do actor = user_actor(activity) activity.id in actor.pinned_activities end + + @spec get_by_object_ap_id_with_object(String.t()) :: t() | nil + def get_by_object_ap_id_with_object(ap_id) when is_binary(ap_id) do + ap_id + |> Queries.by_object_id() + |> with_preloaded_object() + |> Repo.one() + end + + def get_by_object_ap_id_with_object(_), do: nil end diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index 46002bec2..f93909a50 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -710,6 +710,22 @@ defmodule Pleroma.Web.ActivityPub.Utils do Enum.map(statuses || [], &build_flag_object/1) end + defp build_flag_object(%Activity{} = activity) do + activity_actor = User.get_by_ap_id(activity.object.data["actor"]) + + %{ + "type" => "Note", + "id" => activity.data["id"], + "content" => activity.object.data["content"], + "published" => activity.object.data["published"], + "actor" => + AccountView.render( + "show.json", + %{user: activity_actor, skip_visibility_check: true} + ) + } + end + defp build_flag_object(act) when is_map(act) or is_binary(act) do id = case act do @@ -720,22 +736,14 @@ defmodule Pleroma.Web.ActivityPub.Utils do case Activity.get_by_ap_id_with_object(id) do %Activity{} = activity -> - activity_actor = User.get_by_ap_id(activity.object.data["actor"]) - - %{ - "type" => "Note", - "id" => activity.data["id"], - "content" => activity.object.data["content"], - "published" => activity.object.data["published"], - "actor" => - AccountView.render( - "show.json", - %{user: activity_actor, skip_visibility_check: true} - ) - } - - _ -> - %{"id" => id, "deleted" => true} + build_flag_object(activity) + + nil -> + if activity = Activity.get_by_object_ap_id_with_object(id) do + build_flag_object(activity) + else + %{"id" => id, "deleted" => true} + end end end -- cgit v1.2.3 From 8a8c154b4eb5a271c9904a9bb21f4c5f2d985fe4 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Wed, 18 Nov 2020 10:03:48 +0300 Subject: test fixes --- lib/pleroma/web/activity_pub/utils.ex | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index f93909a50..ea1c3a04a 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -702,22 +702,22 @@ defmodule Pleroma.Web.ActivityPub.Utils do def make_flag_data(_, _), do: %{} - defp build_flag_object(%{account: account, statuses: statuses} = _) do - [account.ap_id] ++ build_flag_object(%{statuses: statuses}) + defp build_flag_object(%{account: account, statuses: statuses}) do + [account.ap_id | build_flag_object(%{statuses: statuses})] end defp build_flag_object(%{statuses: statuses}) do Enum.map(statuses || [], &build_flag_object/1) end - defp build_flag_object(%Activity{} = activity) do - activity_actor = User.get_by_ap_id(activity.object.data["actor"]) + defp build_flag_object(%Activity{data: %{"id" => id}, object: %{data: data}}) do + activity_actor = User.get_by_ap_id(data["actor"]) %{ "type" => "Note", - "id" => activity.data["id"], - "content" => activity.object.data["content"], - "published" => activity.object.data["published"], + "id" => id, + "content" => data["content"], + "published" => data["published"], "actor" => AccountView.render( "show.json", -- cgit v1.2.3 From 11e0d5f9acc85fe1a09e11da91f2abd35bc83f89 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Thu, 19 Nov 2020 12:27:06 +0100 Subject: Password Resets: Don't accept tokens above a certain age. By default, one day --- lib/pleroma/password_reset_token.ex | 11 +++++++++++ .../web/twitter_api/controllers/password_controller.ex | 1 + 2 files changed, 12 insertions(+) (limited to 'lib') diff --git a/lib/pleroma/password_reset_token.ex b/lib/pleroma/password_reset_token.ex index 787bd4781..fea5b1c22 100644 --- a/lib/pleroma/password_reset_token.ex +++ b/lib/pleroma/password_reset_token.ex @@ -40,6 +40,7 @@ defmodule Pleroma.PasswordResetToken do @spec reset_password(binary(), map()) :: {:ok, User.t()} | {:error, binary()} def reset_password(token, data) do with %{used: false} = token <- Repo.get_by(PasswordResetToken, %{token: token}), + false <- expired?(token), %User{} = user <- User.get_cached_by_id(token.user_id), {:ok, _user} <- User.reset_password(user, data), {:ok, token} <- Repo.update(used_changeset(token)) do @@ -48,4 +49,14 @@ defmodule Pleroma.PasswordResetToken do _e -> {:error, token} end end + + def expired?(%__MODULE__{inserted_at: inserted_at}) do + validity = Pleroma.Config.get([:instance, :password_reset_token_validity], 0) + + now = NaiveDateTime.utc_now() + + difference = NaiveDateTime.diff(now, inserted_at) + + difference > validity + end end diff --git a/lib/pleroma/web/twitter_api/controllers/password_controller.ex b/lib/pleroma/web/twitter_api/controllers/password_controller.ex index 800ab8954..b1a9d810e 100644 --- a/lib/pleroma/web/twitter_api/controllers/password_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/password_controller.ex @@ -17,6 +17,7 @@ defmodule Pleroma.Web.TwitterAPI.PasswordController do def reset(conn, %{"token" => token}) do with %{used: false} = token <- Repo.get_by(PasswordResetToken, %{token: token}), + false <- PasswordResetToken.expired?(token), %User{} = user <- User.get_cached_by_id(token.user_id) do render(conn, "reset.html", %{ token: token, -- cgit v1.2.3 From 5e2ba57327b88f7304ebbd9df73a15d892ee536c Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Thu, 19 Nov 2020 13:20:58 +0100 Subject: Activity search: Fix order of results Greatly speeds up the search for RUM. --- lib/pleroma/activity/search.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/activity/search.ex b/lib/pleroma/activity/search.ex index ceb365bb3..95ac90acb 100644 --- a/lib/pleroma/activity/search.ex +++ b/lib/pleroma/activity/search.ex @@ -27,7 +27,10 @@ defmodule Pleroma.Activity.Search do |> maybe_restrict_local(user) |> maybe_restrict_author(author) |> maybe_restrict_blocked(user) - |> Pagination.fetch_paginated(%{"offset" => offset, "limit" => limit}, :offset) + |> Pagination.fetch_paginated( + %{"offset" => offset, "limit" => limit, "skip_order" => true}, + :offset + ) |> maybe_fetch(user, search_query) end -- cgit v1.2.3 From 46dab37351994567ddb3a8a6fe654355175fe654 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Thu, 19 Nov 2020 15:29:26 +0300 Subject: little fix --- lib/pleroma/activity.ex | 1 + 1 file changed, 1 insertion(+) (limited to 'lib') diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index bda5aa616..8559ae6a9 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -362,6 +362,7 @@ defmodule Pleroma.Activity do ap_id |> Queries.by_object_id() |> with_preloaded_object() + |> first() |> Repo.one() end -- cgit v1.2.3 From fcad3e716ad8dc60bd3d94e5b2e0aa18af4c9376 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Thu, 19 Nov 2020 18:08:22 +0300 Subject: [#2301] Quick fix: users with is_discoverable == false (default!) are included in search results. --- lib/pleroma/user/search.ex | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/user/search.ex b/lib/pleroma/user/search.ex index 2dab67211..b54111090 100644 --- a/lib/pleroma/user/search.ex +++ b/lib/pleroma/user/search.ex @@ -85,7 +85,7 @@ defmodule Pleroma.User.Search do |> base_query(following) |> filter_blocked_user(for_user) |> filter_invisible_users() - |> filter_discoverable_users() + |> filter_non_discoverable_users() |> filter_internal_users() |> filter_blocked_domains(for_user) |> fts_search(query_string) @@ -163,8 +163,10 @@ defmodule Pleroma.User.Search do from(q in query, where: q.invisible == false) end - defp filter_discoverable_users(query) do - from(q in query, where: q.is_discoverable == true) + defp filter_non_discoverable_users(query) do + # Note: commented out — can't do it with users being non-discoverable by default + # from(q in query, where: q.is_discoverable == true) + query end defp filter_internal_users(query) do -- cgit v1.2.3 From a60242464e6a92bf6de46a1cf7877799de27a3ce Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Thu, 19 Nov 2020 16:12:01 +0100 Subject: Search: Add option to search with the websearch function --- lib/pleroma/activity/search.ex | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/activity/search.ex b/lib/pleroma/activity/search.ex index ceb365bb3..8449b9b00 100644 --- a/lib/pleroma/activity/search.ex +++ b/lib/pleroma/activity/search.ex @@ -19,11 +19,13 @@ defmodule Pleroma.Activity.Search do offset = Keyword.get(options, :offset, 0) author = Keyword.get(options, :author) + search_function = Pleroma.Config.get([:instance, :search_function], :plain) + Activity |> Activity.with_preloaded_object() |> Activity.restrict_deactivated_users() |> restrict_public() - |> query_with(index_type, search_query) + |> query_with(index_type, search_query, search_function) |> maybe_restrict_local(user) |> maybe_restrict_author(author) |> maybe_restrict_blocked(user) @@ -50,7 +52,7 @@ defmodule Pleroma.Activity.Search do ) end - defp query_with(q, :gin, search_query) do + defp query_with(q, :gin, search_query, :plain) do from([a, o] in q, where: fragment( @@ -61,7 +63,18 @@ defmodule Pleroma.Activity.Search do ) end - defp query_with(q, :rum, search_query) do + defp query_with(q, :gin, search_query, :websearch) do + from([a, o] in q, + where: + fragment( + "to_tsvector('english', ?->>'content') @@ websearch_to_tsquery('english', ?)", + o.data, + ^search_query + ) + ) + end + + defp query_with(q, :rum, search_query, :plain) do from([a, o] in q, where: fragment( @@ -73,6 +86,18 @@ defmodule Pleroma.Activity.Search do ) end + defp query_with(q, :rum, search_query, :websearch) do + from([a, o] in q, + where: + fragment( + "? @@ websearch_to_tsquery('english', ?)", + o.fts_content, + ^search_query + ), + order_by: [fragment("? <=> now()::date", o.inserted_at)] + ) + end + defp maybe_restrict_local(q, user) do limit = Pleroma.Config.get([:instance, :limit_to_local_content], :unauthenticated) -- cgit v1.2.3 From 8b90d625060ddaa2f04fbc276ee39b532c7082b6 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Thu, 19 Nov 2020 16:29:31 +0100 Subject: Search: Only skip ordering the rum index. --- lib/pleroma/activity/search.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/activity/search.ex b/lib/pleroma/activity/search.ex index 95ac90acb..382c81118 100644 --- a/lib/pleroma/activity/search.ex +++ b/lib/pleroma/activity/search.ex @@ -28,7 +28,7 @@ defmodule Pleroma.Activity.Search do |> maybe_restrict_author(author) |> maybe_restrict_blocked(user) |> Pagination.fetch_paginated( - %{"offset" => offset, "limit" => limit, "skip_order" => true}, + %{"offset" => offset, "limit" => limit, "skip_order" => index_type == :rum}, :offset ) |> maybe_fetch(user, search_query) -- cgit v1.2.3 From e164c37139c4365d7d46a2a990b364ad26dfdbf7 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Thu, 19 Nov 2020 19:30:02 +0300 Subject: [#2301] Proper handling of `User.is_discoverable`: users appear in in-service search but are hidden from external services like search bots. --- lib/pleroma/user/search.ex | 7 ------- lib/pleroma/web/activity_pub/views/user_view.ex | 1 + lib/pleroma/web/api_spec/operations/account_operation.ex | 2 +- lib/pleroma/web/api_spec/schemas/account.ex | 2 +- lib/pleroma/web/mastodon_api/controllers/account_controller.ex | 2 ++ lib/pleroma/web/metadata/providers/restrict_indexing.ex | 2 +- 6 files changed, 6 insertions(+), 10 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/user/search.ex b/lib/pleroma/user/search.ex index b54111090..f1761ef03 100644 --- a/lib/pleroma/user/search.ex +++ b/lib/pleroma/user/search.ex @@ -85,7 +85,6 @@ defmodule Pleroma.User.Search do |> base_query(following) |> filter_blocked_user(for_user) |> filter_invisible_users() - |> filter_non_discoverable_users() |> filter_internal_users() |> filter_blocked_domains(for_user) |> fts_search(query_string) @@ -163,12 +162,6 @@ defmodule Pleroma.User.Search do from(q in query, where: q.invisible == false) end - defp filter_non_discoverable_users(query) do - # Note: commented out — can't do it with users being non-discoverable by default - # from(q in query, where: q.is_discoverable == true) - query - end - defp filter_internal_users(query) do from(q in query, where: q.actor_type != "Application") end diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index 4dc45cde3..93c9f436c 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -110,6 +110,7 @@ defmodule Pleroma.Web.ActivityPub.UserView do "endpoints" => endpoints, "attachment" => fields, "tag" => emoji_tags, + # Note: key name is indeed "discoverable" (not an error) "discoverable" => user.is_discoverable, "capabilities" => capabilities } diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 05595bc2a..280100c3d 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -624,7 +624,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do allOf: [BooleanLike], nullable: true, description: - "Discovery of this account in search results and other services is allowed." + "Discovery (listing, indexing) of this account by external services (search bots etc.) is allowed." }, actor_type: ActorType }, diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index ca79f0747..684f6fc92 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -127,7 +127,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do discoverable: %Schema{ type: :boolean, description: - "whether the user allows discovery of the account in search results and other services." + "whether the user allows indexing / listing of the account by external services (search engines etc.)." }, no_rich_text: %Schema{ type: :boolean, diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 7ed4603a4..7011b7eb1 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -208,7 +208,9 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do if bot, do: {:ok, "Service"}, else: {:ok, "Person"} end) |> Maps.put_if_present(:actor_type, params[:actor_type]) + # Note: param name is indeed :locked (not an error) |> Maps.put_if_present(:is_locked, params[:locked]) + # Note: param name is indeed :discoverable (not an error) |> Maps.put_if_present(:is_discoverable, params[:discoverable]) # What happens here: diff --git a/lib/pleroma/web/metadata/providers/restrict_indexing.ex b/lib/pleroma/web/metadata/providers/restrict_indexing.ex index 900c2434d..a08a04b4a 100644 --- a/lib/pleroma/web/metadata/providers/restrict_indexing.ex +++ b/lib/pleroma/web/metadata/providers/restrict_indexing.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.Metadata.Providers.RestrictIndexing do @behaviour Pleroma.Web.Metadata.Providers.Provider @moduledoc """ - Restricts indexing of remote users. + Restricts indexing of remote and/or non-discoverable users. """ @impl true -- cgit v1.2.3 From 66f411fba0ecb350a2cd80293aabdecf402abaf9 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Thu, 19 Nov 2020 22:13:45 +0300 Subject: added subject actor to moderation log --- lib/pleroma/activity.ex | 13 +++ lib/pleroma/moderation_log.ex | 122 +++++++++++++-------- .../web/admin_api/controllers/report_controller.ex | 17 ++- 3 files changed, 102 insertions(+), 50 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 553834da0..d2066f7a0 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -194,6 +194,19 @@ defmodule Pleroma.Activity do end end + def get_by_id_with_user_actor(id) do + case FlakeId.flake_id?(id) do + true -> + Activity + |> where([a], a.id == ^id) + |> with_preloaded_user_actor() + |> Repo.one() + + _ -> + nil + end + end + def get_by_id_with_object(id) do Activity |> where(id: ^id) diff --git a/lib/pleroma/moderation_log.ex b/lib/pleroma/moderation_log.ex index 142dd8e0a..0a701127f 100644 --- a/lib/pleroma/moderation_log.ex +++ b/lib/pleroma/moderation_log.ex @@ -112,16 +112,19 @@ defmodule Pleroma.ModerationLog do @spec insert_log(%{actor: User, subject: User, action: String.t()}) :: {:ok, ModerationLog} | {:error, any} - def insert_log(%{ - actor: %User{} = actor, - action: "report_update", - subject: %Activity{data: %{"type" => "Flag"}} = subject - }) do + def insert_log( + %{ + actor: %User{} = actor, + action: "report_update", + subject: %Activity{data: %{"type" => "Flag"}} = subject + } = attrs + ) do %ModerationLog{ data: %{ "actor" => user_to_map(actor), "action" => "report_update", "subject" => report_to_map(subject), + "subject_actor" => user_to_map(attrs[:subject_actor]), "message" => "" } } @@ -130,17 +133,20 @@ defmodule Pleroma.ModerationLog do @spec insert_log(%{actor: User, subject: Activity, action: String.t(), text: String.t()}) :: {:ok, ModerationLog} | {:error, any} - def insert_log(%{ - actor: %User{} = actor, - action: "report_note", - subject: %Activity{} = subject, - text: text - }) do + def insert_log( + %{ + actor: %User{} = actor, + action: "report_note", + subject: %Activity{} = subject, + text: text + } = attrs + ) do %ModerationLog{ data: %{ "actor" => user_to_map(actor), "action" => "report_note", "subject" => report_to_map(subject), + "subject_actor" => user_to_map(attrs[:subject_actor]), "text" => text } } @@ -149,17 +155,20 @@ defmodule Pleroma.ModerationLog do @spec insert_log(%{actor: User, subject: Activity, action: String.t(), text: String.t()}) :: {:ok, ModerationLog} | {:error, any} - def insert_log(%{ - actor: %User{} = actor, - action: "report_note_delete", - subject: %Activity{} = subject, - text: text - }) do + def insert_log( + %{ + actor: %User{} = actor, + action: "report_note_delete", + subject: %Activity{} = subject, + text: text + } = attrs + ) do %ModerationLog{ data: %{ "actor" => user_to_map(actor), "action" => "report_note_delete", "subject" => report_to_map(subject), + "subject_actor" => user_to_map(attrs[:subject_actor]), "text" => text } } @@ -345,17 +354,18 @@ defmodule Pleroma.ModerationLog do end defp user_to_map(users) when is_list(users) do - users |> Enum.map(&user_to_map/1) + Enum.map(users, &user_to_map/1) end defp user_to_map(%User{} = user) do user - |> Map.from_struct() |> Map.take([:id, :nickname]) |> Map.new(fn {k, v} -> {Atom.to_string(k), v} end) |> Map.put("type", "user") end + defp user_to_map(_), do: nil + defp report_to_map(%Activity{} = report) do %{ "type" => "report", @@ -512,38 +522,48 @@ defmodule Pleroma.ModerationLog do end @spec get_log_entry_message(ModerationLog) :: String.t() - def get_log_entry_message(%ModerationLog{ - data: %{ - "actor" => %{"nickname" => actor_nickname}, - "action" => "report_update", - "subject" => %{"id" => subject_id, "state" => state, "type" => "report"} - } - }) do - "@#{actor_nickname} updated report ##{subject_id} with '#{state}' state" + def get_log_entry_message( + %ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "report_update", + "subject" => %{"id" => subject_id, "state" => state, "type" => "report"} + } + } = log + ) do + "@#{actor_nickname} updated report ##{subject_id}" <> + subject_actor_nickname(log, " (on user ", ")") <> + " with '#{state}' state" end @spec get_log_entry_message(ModerationLog) :: String.t() - def get_log_entry_message(%ModerationLog{ - data: %{ - "actor" => %{"nickname" => actor_nickname}, - "action" => "report_note", - "subject" => %{"id" => subject_id, "type" => "report"}, - "text" => text - } - }) do - "@#{actor_nickname} added note '#{text}' to report ##{subject_id}" + def get_log_entry_message( + %ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "report_note", + "subject" => %{"id" => subject_id, "type" => "report"}, + "text" => text + } + } = log + ) do + "@#{actor_nickname} added note '#{text}' to report ##{subject_id}" <> + subject_actor_nickname(log, " on user ") end @spec get_log_entry_message(ModerationLog) :: String.t() - def get_log_entry_message(%ModerationLog{ - data: %{ - "actor" => %{"nickname" => actor_nickname}, - "action" => "report_note_delete", - "subject" => %{"id" => subject_id, "type" => "report"}, - "text" => text - } - }) do - "@#{actor_nickname} deleted note '#{text}' from report ##{subject_id}" + def get_log_entry_message( + %ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "report_note_delete", + "subject" => %{"id" => subject_id, "type" => "report"}, + "text" => text + } + } = log + ) do + "@#{actor_nickname} deleted note '#{text}' from report ##{subject_id}" <> + subject_actor_nickname(log, " on user ") end @spec get_log_entry_message(ModerationLog) :: String.t() @@ -676,4 +696,16 @@ defmodule Pleroma.ModerationLog do |> Enum.map(&"@#{&1["nickname"]}") |> Enum.join(", ") end + + defp subject_actor_nickname(%ModerationLog{data: data}, prefix_msg, postfix_msg \\ "") do + case data do + %{"subject_actor" => %{"nickname" => subject_actor}} -> + [prefix_msg, "@#{subject_actor}", postfix_msg] + |> Enum.reject(&(&1 == "")) + |> Enum.join() + + _ -> + "" + end + end end diff --git a/lib/pleroma/web/admin_api/controllers/report_controller.ex b/lib/pleroma/web/admin_api/controllers/report_controller.ex index 6a0e56f5f..cc77cbfdf 100644 --- a/lib/pleroma/web/admin_api/controllers/report_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/report_controller.ex @@ -50,10 +50,13 @@ defmodule Pleroma.Web.AdminAPI.ReportController do Enum.map(reports, fn report -> case CommonAPI.update_report_state(report.id, report.state) do {:ok, activity} -> + report = Activity.get_by_id_with_user_actor(activity.id) + ModerationLog.insert_log(%{ action: "report_update", actor: admin, - subject: activity + subject: activity, + subject_actor: report.user_actor }) activity @@ -73,11 +76,13 @@ defmodule Pleroma.Web.AdminAPI.ReportController do def notes_create(%{assigns: %{user: user}, body_params: %{content: content}} = conn, %{ id: report_id }) do - with {:ok, _} <- ReportNote.create(user.id, report_id, content) do + with {:ok, _} <- ReportNote.create(user.id, report_id, content), + report <- Activity.get_by_id_with_user_actor(report_id) do ModerationLog.insert_log(%{ action: "report_note", actor: user, - subject: Activity.get_by_id(report_id), + subject: report, + subject_actor: report.user_actor, text: content }) @@ -91,11 +96,13 @@ defmodule Pleroma.Web.AdminAPI.ReportController do id: note_id, report_id: report_id }) do - with {:ok, note} <- ReportNote.destroy(note_id) do + with {:ok, note} <- ReportNote.destroy(note_id), + report <- Activity.get_by_id_with_user_actor(report_id) do ModerationLog.insert_log(%{ action: "report_note_delete", actor: user, - subject: Activity.get_by_id(report_id), + subject: report, + subject_actor: report.user_actor, text: note.content }) -- cgit v1.2.3 From 0a5b22bc3b1a0011b83e1a77f4f58700266c260a Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Fri, 20 Nov 2020 11:37:01 +0300 Subject: start limiters in mix tasks --- lib/mix/pleroma.ex | 1 + lib/pleroma/application.ex | 1 + 2 files changed, 2 insertions(+) (limited to 'lib') diff --git a/lib/mix/pleroma.ex b/lib/mix/pleroma.ex index 3de11efce..6df1cf538 100644 --- a/lib/mix/pleroma.ex +++ b/lib/mix/pleroma.ex @@ -19,6 +19,7 @@ defmodule Mix.Pleroma do def start_pleroma do Pleroma.Config.Holder.save_default() Pleroma.Config.Oban.warn() + Pleroma.Application.limiters_setup() Application.put_env(:phoenix, :serve_endpoints, false, persistent: true) if Pleroma.Config.get(:env) != :test do diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index d3c32942c..ced14f87f 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -274,6 +274,7 @@ defmodule Pleroma.Application do defp http_children(_, _), do: [] + @spec limiters_setup() :: :ok def limiters_setup do [Pleroma.Web.RichMedia.Helpers, Pleroma.Web.MediaProxy] |> Enum.each(&ConcurrentLimiter.new(&1, 1, 0)) -- cgit v1.2.3 From a407e33c78121abf880f257d291f45ed28b55eeb Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Fri, 20 Nov 2020 16:26:22 +0100 Subject: Application: Save postgres version in the environment --- lib/pleroma/application.ex | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 8f08a6222..f2a8c7825 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -109,7 +109,28 @@ defmodule Pleroma.Application do # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: Pleroma.Supervisor] - Supervisor.start_link(children, opts) + result = Supervisor.start_link(children, opts) + + set_postgres_server_version() + + result + end + + defp set_postgres_server_version() do + version = + with %{rows: [[version]]} <- Ecto.Adapters.SQL.query!(Pleroma.Repo, "show server_version"), + {num, _} <- Float.parse(version) do + num + else + e -> + Logger.warn( + "Could not get the postgres version: #{inspect(e)}.\nSetting the default value of 9.6" + ) + + 9.6 + end + + Application.put_env(:postgres, :version, version) end def load_custom_modules do -- cgit v1.2.3 From 9a1e5f5d48ef9f3b5a817c02dc8820aa99a6f693 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Fri, 20 Nov 2020 16:26:43 +0100 Subject: Search: Change search method based on detected pg version --- lib/pleroma/activity/search.ex | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/activity/search.ex b/lib/pleroma/activity/search.ex index cc98e2d06..ea9783225 100644 --- a/lib/pleroma/activity/search.ex +++ b/lib/pleroma/activity/search.ex @@ -19,7 +19,12 @@ defmodule Pleroma.Activity.Search do offset = Keyword.get(options, :offset, 0) author = Keyword.get(options, :author) - search_function = Pleroma.Config.get([:instance, :search_function], :plain) + search_function = + if Application.get_env(:postgres, :version) >= 11 do + :websearch + else + :plain + end Activity |> Activity.with_preloaded_object() -- cgit v1.2.3 From e4289792d28cb38c520e03df2ed82f6f30eb4c51 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Fri, 20 Nov 2020 16:38:05 +0100 Subject: Linting. --- lib/pleroma/application.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index f2a8c7825..17a241cdf 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -116,7 +116,7 @@ defmodule Pleroma.Application do result end - defp set_postgres_server_version() do + defp set_postgres_server_version do version = with %{rows: [[version]]} <- Ecto.Adapters.SQL.query!(Pleroma.Repo, "show server_version"), {num, _} <- Float.parse(version) do -- cgit v1.2.3 From ccc2cf0e87f47618163da588ead76846c64cba7a Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Sat, 21 Nov 2020 19:47:25 +0300 Subject: Session-based OAuth auth fixes (token expiration check), refactoring, tweaks. --- lib/pleroma/helpers/auth_helper.ex | 10 ++- lib/pleroma/web/o_auth/o_auth_controller.ex | 10 ++- lib/pleroma/web/o_auth/token.ex | 8 ++ lib/pleroma/web/plugs/o_auth_plug.ex | 92 +++++++++------------- .../web/plugs/session_authentication_plug.ex | 31 -------- lib/pleroma/web/plugs/set_user_session_id_plug.ex | 7 +- lib/pleroma/web/plugs/user_enabled_plug.ex | 9 ++- lib/pleroma/web/router.ex | 12 ++- 8 files changed, 84 insertions(+), 95 deletions(-) delete mode 100644 lib/pleroma/web/plugs/session_authentication_plug.ex (limited to 'lib') diff --git a/lib/pleroma/helpers/auth_helper.ex b/lib/pleroma/helpers/auth_helper.ex index 6e29c006a..878fec346 100644 --- a/lib/pleroma/helpers/auth_helper.ex +++ b/lib/pleroma/helpers/auth_helper.ex @@ -5,13 +5,21 @@ defmodule Pleroma.Helpers.AuthHelper do alias Pleroma.Web.Plugs.OAuthScopesPlug + import Plug.Conn + @doc """ Skips OAuth permissions (scopes) checks, assigns nil `:token`. Intended to be used with explicit authentication and only when OAuth token cannot be determined. """ def skip_oauth(conn) do conn - |> Plug.Conn.assign(:token, nil) + |> assign(:token, nil) |> OAuthScopesPlug.skip_plug() end + + def drop_auth_info(conn) do + conn + |> assign(:user, nil) + |> assign(:token, nil) + end end diff --git a/lib/pleroma/web/o_auth/o_auth_controller.ex b/lib/pleroma/web/o_auth/o_auth_controller.ex index d2f9d1ceb..83a25907d 100644 --- a/lib/pleroma/web/o_auth/o_auth_controller.ex +++ b/lib/pleroma/web/o_auth/o_auth_controller.ex @@ -363,7 +363,15 @@ defmodule Pleroma.Web.OAuth.OAuthController do def token_revoke(%Plug.Conn{} = conn, %{"token" => _token} = params) do with {:ok, app} <- Token.Utils.fetch_app(conn), - {:ok, _token} <- RevokeToken.revoke(app, params) do + {:ok, %Token{} = oauth_token} <- RevokeToken.revoke(app, params) do + conn = + with session_token = get_session(conn, :oauth_token), + %Token{token: ^session_token} <- oauth_token do + delete_session(conn, :oauth_token) + else + _ -> conn + end + json(conn, %{}) else _error -> diff --git a/lib/pleroma/web/o_auth/token.ex b/lib/pleroma/web/o_auth/token.ex index de37998f2..9170a7ec7 100644 --- a/lib/pleroma/web/o_auth/token.ex +++ b/lib/pleroma/web/o_auth/token.ex @@ -27,6 +27,14 @@ defmodule Pleroma.Web.OAuth.Token do timestamps() end + @doc "Gets token by unique access token" + @spec get_by_token(String.t()) :: {:ok, t()} | {:error, :not_found} + def get_by_token(token) do + token + |> Query.get_by_token() + |> Repo.find_resource() + end + @doc "Gets token for app by access token" @spec get_by_token(App.t(), String.t()) :: {:ok, t()} | {:error, :not_found} def get_by_token(%App{id: app_id} = _app, token) do diff --git a/lib/pleroma/web/plugs/o_auth_plug.ex b/lib/pleroma/web/plugs/o_auth_plug.ex index c7b58d90f..a3b7d42f7 100644 --- a/lib/pleroma/web/plugs/o_auth_plug.ex +++ b/lib/pleroma/web/plugs/o_auth_plug.ex @@ -3,6 +3,8 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Plugs.OAuthPlug do + @moduledoc "Performs OAuth authentication by token from params / headers / cookies." + import Plug.Conn import Ecto.Query @@ -17,45 +19,26 @@ defmodule Pleroma.Web.Plugs.OAuthPlug do def call(%{assigns: %{user: %User{}}} = conn, _), do: conn - def call(%{params: %{"access_token" => access_token}} = conn, _) do - with {:ok, user, token_record} <- fetch_user_and_token(access_token) do - conn - |> assign(:token, token_record) - |> assign(:user, user) - else - _ -> - # token found, but maybe only with app - with {:ok, app, token_record} <- fetch_app_and_token(access_token) do - conn - |> assign(:token, token_record) - |> assign(:app, app) - else - _ -> conn - end - end - end - def call(conn, _) do - case fetch_token_str(conn) do - {:ok, token} -> - with {:ok, user, token_record} <- fetch_user_and_token(token) do - conn - |> assign(:token, token_record) - |> assign(:user, user) - else - _ -> - # token found, but maybe only with app - with {:ok, app, token_record} <- fetch_app_and_token(token) do - conn - |> assign(:token, token_record) - |> assign(:app, app) - else - _ -> conn - end - end - - _ -> + with {:ok, token_str} <- fetch_token_str(conn) do + with {:ok, user, user_token} <- fetch_user_and_token(token_str), + false <- Token.is_expired?(user_token) do conn + |> assign(:token, user_token) + |> assign(:user, user) + else + _ -> + with {:ok, app, app_token} <- fetch_app_and_token(token_str), + false <- Token.is_expired?(app_token) do + conn + |> assign(:token, app_token) + |> assign(:app, app) + else + _ -> conn + end + end + else + _ -> conn end end @@ -70,7 +53,6 @@ defmodule Pleroma.Web.Plugs.OAuthPlug do preload: [user: user] ) - # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength with %Token{user: user} = token_record <- Repo.one(query) do {:ok, user, token_record} end @@ -86,29 +68,23 @@ defmodule Pleroma.Web.Plugs.OAuthPlug do end end - # Gets token from session by :oauth_token key + # Gets token string from conn (in params / headers / session) # - @spec fetch_token_from_session(Plug.Conn.t()) :: :no_token_found | {:ok, String.t()} - defp fetch_token_from_session(conn) do - case get_session(conn, :oauth_token) do - nil -> :no_token_found - token -> {:ok, token} - end + @spec fetch_token_str(Plug.Conn.t() | list(String.t())) :: :no_token_found | {:ok, String.t()} + defp fetch_token_str(%Plug.Conn{params: %{"access_token" => access_token}} = _conn) do + {:ok, access_token} end - # Gets token from headers - # - @spec fetch_token_str(Plug.Conn.t()) :: :no_token_found | {:ok, String.t()} defp fetch_token_str(%Plug.Conn{} = conn) do headers = get_req_header(conn, "authorization") - with :no_token_found <- fetch_token_str(headers), - do: fetch_token_from_session(conn) + with {:ok, token} <- fetch_token_str(headers) do + {:ok, token} + else + _ -> fetch_token_from_session(conn) + end end - @spec fetch_token_str(Keyword.t()) :: :no_token_found | {:ok, String.t()} - defp fetch_token_str([]), do: :no_token_found - defp fetch_token_str([token | tail]) do trimmed_token = String.trim(token) @@ -117,4 +93,14 @@ defmodule Pleroma.Web.Plugs.OAuthPlug do _ -> fetch_token_str(tail) end end + + defp fetch_token_str([]), do: :no_token_found + + @spec fetch_token_from_session(Plug.Conn.t()) :: :no_token_found | {:ok, String.t()} + defp fetch_token_from_session(conn) do + case get_session(conn, :oauth_token) do + nil -> :no_token_found + token -> {:ok, token} + end + end end diff --git a/lib/pleroma/web/plugs/session_authentication_plug.ex b/lib/pleroma/web/plugs/session_authentication_plug.ex deleted file mode 100644 index 51704e273..000000000 --- a/lib/pleroma/web/plugs/session_authentication_plug.ex +++ /dev/null @@ -1,31 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.Plugs.SessionAuthenticationPlug do - @moduledoc """ - Authenticates user by session-stored `:user_id` and request-contained username. - Username can be provided via HTTP Basic Auth (the password is not checked and can be anything). - """ - - import Plug.Conn - - alias Pleroma.Helpers.AuthHelper - - def init(options) do - options - end - - def call(%{assigns: %{user: %Pleroma.User{}}} = conn, _), do: conn - - def call(conn, _) do - with saved_user_id <- get_session(conn, :user_id), - %{auth_user: %{id: ^saved_user_id}} <- conn.assigns do - conn - |> assign(:user, conn.assigns.auth_user) - |> AuthHelper.skip_oauth() - else - _ -> conn - end - end -end diff --git a/lib/pleroma/web/plugs/set_user_session_id_plug.ex b/lib/pleroma/web/plugs/set_user_session_id_plug.ex index 6ddb6b5e5..d2338c03f 100644 --- a/lib/pleroma/web/plugs/set_user_session_id_plug.ex +++ b/lib/pleroma/web/plugs/set_user_session_id_plug.ex @@ -4,14 +4,15 @@ defmodule Pleroma.Web.Plugs.SetUserSessionIdPlug do import Plug.Conn - alias Pleroma.User + + alias Pleroma.Web.OAuth.Token def init(opts) do opts end - def call(%{assigns: %{user: %User{id: id}}} = conn, _) do - put_session(conn, :user_id, id) + def call(%{assigns: %{token: %Token{} = oauth_token}} = conn, _) do + put_session(conn, :oauth_token, oauth_token.token) end def call(conn, _), do: conn diff --git a/lib/pleroma/web/plugs/user_enabled_plug.ex b/lib/pleroma/web/plugs/user_enabled_plug.ex index fa28ee48b..291d1f568 100644 --- a/lib/pleroma/web/plugs/user_enabled_plug.ex +++ b/lib/pleroma/web/plugs/user_enabled_plug.ex @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Plugs.UserEnabledPlug do - import Plug.Conn + alias Pleroma.Helpers.AuthHelper alias Pleroma.User def init(options) do @@ -12,8 +12,11 @@ defmodule Pleroma.Web.Plugs.UserEnabledPlug do def call(%{assigns: %{user: %User{} = user}} = conn, _) do case User.account_status(user) do - :active -> conn - _ -> assign(conn, :user, nil) + :active -> + conn + + _ -> + AuthHelper.drop_auth_info(conn) end end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index c075fc7d3..2b8b3e95c 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -34,6 +34,7 @@ defmodule Pleroma.Web.Router do plug(:fetch_session) plug(Pleroma.Web.Plugs.OAuthPlug) plug(Pleroma.Web.Plugs.UserEnabledPlug) + plug(Pleroma.Web.Plugs.EnsureUserKeyPlug) end pipeline :expect_authentication do @@ -48,7 +49,6 @@ defmodule Pleroma.Web.Router do plug(Pleroma.Web.Plugs.OAuthPlug) plug(Pleroma.Web.Plugs.BasicAuthDecoderPlug) plug(Pleroma.Web.Plugs.UserFetcherPlug) - plug(Pleroma.Web.Plugs.SessionAuthenticationPlug) plug(Pleroma.Web.Plugs.AuthenticationPlug) end @@ -319,18 +319,24 @@ defmodule Pleroma.Web.Router do scope "/oauth", Pleroma.Web.OAuth do scope [] do pipe_through(:oauth) + get("/authorize", OAuthController, :authorize) + post("/authorize", OAuthController, :create_authorization) end - post("/authorize", OAuthController, :create_authorization) post("/token", OAuthController, :token_exchange) - post("/revoke", OAuthController, :token_revoke) get("/registration_details", OAuthController, :registration_details) post("/mfa/challenge", MFAController, :challenge) post("/mfa/verify", MFAController, :verify, as: :mfa_verify) get("/mfa", MFAController, :show) + scope [] do + pipe_through(:fetch_session) + + post("/revoke", OAuthController, :token_revoke) + end + scope [] do pipe_through(:browser) -- cgit v1.2.3 From 67b15cc033fd1154d1e6a96a5c5f141921c2e688 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Mon, 23 Nov 2020 15:29:55 +0100 Subject: Search: Save detected pg version in a persistent term. --- lib/pleroma/activity/search.ex | 2 +- lib/pleroma/application.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/activity/search.ex b/lib/pleroma/activity/search.ex index ea9783225..babf9520b 100644 --- a/lib/pleroma/activity/search.ex +++ b/lib/pleroma/activity/search.ex @@ -20,7 +20,7 @@ defmodule Pleroma.Activity.Search do author = Keyword.get(options, :author) search_function = - if Application.get_env(:postgres, :version) >= 11 do + if :persistent_term.get({Pleroma.Repo, :postgres_version}) >= 11 do :websearch else :plain diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 22936bd7f..bd568d858 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -131,7 +131,7 @@ defmodule Pleroma.Application do 9.6 end - Application.put_env(:postgres, :version, version) + :persistent_term.put({Pleroma.Repo, :postgres_version}, version) end def load_custom_modules do -- cgit v1.2.3 From 5eef4988bf968e12329e6e4ee89beccee19a66ce Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Tue, 24 Nov 2020 18:44:48 +0300 Subject: fix for elixir 1.11 load runtime configs in releases with config provider --- lib/pleroma/config/holder.ex | 19 ++++++---- lib/pleroma/config/release_runtime_provider.ex | 50 ++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 lib/pleroma/config/release_runtime_provider.ex (limited to 'lib') diff --git a/lib/pleroma/config/holder.ex b/lib/pleroma/config/holder.ex index f037d5d48..a99fc0471 100644 --- a/lib/pleroma/config/holder.ex +++ b/lib/pleroma/config/holder.ex @@ -9,12 +9,7 @@ defmodule Pleroma.Config.Holder do def save_default do default_config = if System.get_env("RELEASE_NAME") do - release_config = - [:code.root_dir(), "releases", System.get_env("RELEASE_VSN"), "releases.exs"] - |> Path.join() - |> Pleroma.Config.Loader.read() - - Pleroma.Config.Loader.merge(@config, release_config) + Pleroma.Config.Loader.merge(@config, release_defaults()) else @config end @@ -32,4 +27,16 @@ defmodule Pleroma.Config.Holder do def default_config(group, key), do: get_in(get_default(), [group, key]) defp get_default, do: Pleroma.Config.get(:default_config) + + @spec release_defaults() :: keyword() + def release_defaults do + [ + pleroma: [ + {:instance, [static_dir: "/var/lib/pleroma/static"]}, + {Pleroma.Uploaders.Local, [uploads: "/var/lib/pleroma/uploads"]}, + {:modules, [runtime_dir: "/var/lib/pleroma/modules"]}, + {:release, true} + ] + ] + end end diff --git a/lib/pleroma/config/release_runtime_provider.ex b/lib/pleroma/config/release_runtime_provider.ex new file mode 100644 index 000000000..8227195dc --- /dev/null +++ b/lib/pleroma/config/release_runtime_provider.ex @@ -0,0 +1,50 @@ +defmodule Pleroma.Config.ReleaseRuntimeProvider do + @moduledoc """ + Imports `runtime.exs` and `{env}.exported_from_db.secret.exs` for elixir releases. + """ + @behaviour Config.Provider + + @impl true + def init(opts), do: opts + + @impl true + def load(config, _opts) do + with_defaults = Config.Reader.merge(config, Pleroma.Config.Holder.release_defaults()) + + config_path = System.get_env("PLEROMA_CONFIG_PATH") || "/etc/pleroma/config.exs" + + with_runtime_config = + if File.exists?(config_path) do + runtime_config = Config.Reader.read!(config_path) + + with_defaults + |> Config.Reader.merge(pleroma: [config_path: config_path]) + |> Config.Reader.merge(runtime_config) + else + warning = [ + IO.ANSI.red(), + IO.ANSI.bright(), + "!!! #{config_path} not found! Please ensure it exists and that PLEROMA_CONFIG_PATH is unset or points to an existing file", + IO.ANSI.reset() + ] + + IO.puts(warning) + with_defaults + end + + exported_config_path = + config_path + |> Path.dirname() + |> Path.join("prod.exported_from_db.secret.exs") + + with_exported = + if File.exists?(exported_config_path) do + exported_config = Config.Reader.read!(with_runtime_config) + Config.Reader.merge(with_runtime_config, exported_config) + else + with_runtime_config + end + + with_exported + end +end -- cgit v1.2.3 From 12a5981cc3da65b7f2763d0ec05871b0986234f5 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Wed, 25 Nov 2020 21:47:23 +0300 Subject: Session token setting on token exchange. Auth-related refactoring. --- lib/pleroma/helpers/auth_helper.ex | 15 +++++++++++++++ .../mastodon_api/controllers/account_controller.ex | 3 +-- .../web/mastodon_api/controllers/auth_controller.ex | 5 +++-- lib/pleroma/web/o_auth/mfa_controller.ex | 3 +-- lib/pleroma/web/o_auth/o_auth_controller.ex | 19 +++++++++++++------ lib/pleroma/web/plugs/o_auth_plug.ex | 3 ++- lib/pleroma/web/plugs/set_user_session_id_plug.ex | 5 ++--- lib/pleroma/web/router.ex | 14 +++++++------- 8 files changed, 44 insertions(+), 23 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/helpers/auth_helper.ex b/lib/pleroma/helpers/auth_helper.ex index 878fec346..392fa7d5d 100644 --- a/lib/pleroma/helpers/auth_helper.ex +++ b/lib/pleroma/helpers/auth_helper.ex @@ -4,9 +4,12 @@ defmodule Pleroma.Helpers.AuthHelper do alias Pleroma.Web.Plugs.OAuthScopesPlug + alias Plug.Conn import Plug.Conn + @oauth_token_session_key :oauth_token + @doc """ Skips OAuth permissions (scopes) checks, assigns nil `:token`. Intended to be used with explicit authentication and only when OAuth token cannot be determined. @@ -22,4 +25,16 @@ defmodule Pleroma.Helpers.AuthHelper do |> assign(:user, nil) |> assign(:token, nil) end + + def get_session_token(%Conn{} = conn) do + get_session(conn, @oauth_token_session_key) + end + + def put_session_token(%Conn{} = conn, token) when is_binary(token) do + put_session(conn, @oauth_token_session_key, token) + end + + def delete_session_token(%Conn{} = conn) do + delete_session(conn, @oauth_token_session_key) + end end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 7011b7eb1..b4375872b 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -25,7 +25,6 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do alias Pleroma.Web.MastodonAPI.MastodonAPIController alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.OAuth.OAuthController - alias Pleroma.Web.OAuth.OAuthView alias Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.Plugs.RateLimiter @@ -103,7 +102,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do {:ok, user} <- TwitterAPI.register_user(params), {_, {:ok, token}} <- {:login, OAuthController.login(user, app, app.scopes)} do - json(conn, OAuthView.render("token.json", %{user: user, token: token})) + OAuthController.after_token_exchange(conn, %{user: user, token: token}) else {:login, {:account_status, :confirmation_pending}} -> json_response(conn, :ok, %{ diff --git a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex index 9cc3984d0..fa582dcfc 100644 --- a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.MastodonAPI.AuthController do import Pleroma.Web.ControllerHelper, only: [json_response: 3] + alias Pleroma.Helpers.AuthHelper alias Pleroma.User alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.Authorization @@ -30,7 +31,7 @@ defmodule Pleroma.Web.MastodonAPI.AuthController do {:ok, auth} <- Authorization.get_by_token(app, auth_token), {:ok, token} <- Token.exchange_token(app, auth) do conn - |> put_session(:oauth_token, token.token) + |> AuthHelper.put_session_token(token.token) |> redirect(to: local_mastodon_root_path(conn)) end end @@ -53,7 +54,7 @@ defmodule Pleroma.Web.MastodonAPI.AuthController do @doc "DELETE /auth/sign_out" def logout(conn, _) do conn - |> clear_session + |> clear_session() |> redirect(to: "/") end diff --git a/lib/pleroma/web/o_auth/mfa_controller.ex b/lib/pleroma/web/o_auth/mfa_controller.ex index f102c93e7..5d5ec286a 100644 --- a/lib/pleroma/web/o_auth/mfa_controller.ex +++ b/lib/pleroma/web/o_auth/mfa_controller.ex @@ -13,7 +13,6 @@ defmodule Pleroma.Web.OAuth.MFAController do alias Pleroma.Web.Auth.TOTPAuthenticator alias Pleroma.Web.OAuth.MFAView, as: View alias Pleroma.Web.OAuth.OAuthController - alias Pleroma.Web.OAuth.OAuthView alias Pleroma.Web.OAuth.Token plug(:fetch_session when action in [:show, :verify]) @@ -75,7 +74,7 @@ defmodule Pleroma.Web.OAuth.MFAController do {:ok, %{user: user, authorization: auth}} <- MFA.Token.validate(mfa_token), {:ok, _} <- validates_challenge(user, params), {:ok, token} <- Token.exchange_token(app, auth) do - json(conn, OAuthView.render("token.json", %{user: user, token: token})) + OAuthController.after_token_exchange(conn, %{user: user, token: token}) else _error -> conn diff --git a/lib/pleroma/web/o_auth/o_auth_controller.ex b/lib/pleroma/web/o_auth/o_auth_controller.ex index 83a25907d..8103395b3 100644 --- a/lib/pleroma/web/o_auth/o_auth_controller.ex +++ b/lib/pleroma/web/o_auth/o_auth_controller.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do use Pleroma.Web, :controller + alias Pleroma.Helpers.AuthHelper alias Pleroma.Helpers.UriHelper alias Pleroma.Maps alias Pleroma.MFA @@ -248,7 +249,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do with {:ok, app} <- Token.Utils.fetch_app(conn), {:ok, %{user: user} = token} <- Token.get_by_refresh_token(app, token), {:ok, token} <- RefreshToken.grant(token) do - json(conn, OAuthView.render("token.json", %{user: user, token: token})) + after_token_exchange(conn, %{user: user, token: token}) else _error -> render_invalid_credentials_error(conn) end @@ -260,7 +261,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do {:ok, auth} <- Authorization.get_by_token(app, fixed_token), %User{} = user <- User.get_cached_by_id(auth.user_id), {:ok, token} <- Token.exchange_token(app, auth) do - json(conn, OAuthView.render("token.json", %{user: user, token: token})) + after_token_exchange(conn, %{user: user, token: token}) else error -> handle_token_exchange_error(conn, error) @@ -275,7 +276,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do {:ok, app} <- Token.Utils.fetch_app(conn), requested_scopes <- Scopes.fetch_scopes(params, app.scopes), {:ok, token} <- login(user, app, requested_scopes) do - json(conn, OAuthView.render("token.json", %{user: user, token: token})) + after_token_exchange(conn, %{user: user, token: token}) else error -> handle_token_exchange_error(conn, error) @@ -298,7 +299,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do with {:ok, app} <- Token.Utils.fetch_app(conn), {:ok, auth} <- Authorization.create_authorization(app, %User{}), {:ok, token} <- Token.exchange_token(app, auth) do - json(conn, OAuthView.render("token.json", %{token: token})) + after_token_exchange(conn, %{token: token}) else _error -> handle_token_exchange_error(conn, :invalid_credentails) @@ -308,6 +309,12 @@ defmodule Pleroma.Web.OAuth.OAuthController do # Bad request def token_exchange(%Plug.Conn{} = conn, params), do: bad_request(conn, params) + def after_token_exchange(%Plug.Conn{} = conn, %{token: token} = view_params) do + conn + |> AuthHelper.put_session_token(token.token) + |> json(OAuthView.render("token.json", view_params)) + end + defp handle_token_exchange_error(%Plug.Conn{} = conn, {:mfa_required, user, auth, _}) do conn |> put_status(:forbidden) @@ -365,9 +372,9 @@ defmodule Pleroma.Web.OAuth.OAuthController do with {:ok, app} <- Token.Utils.fetch_app(conn), {:ok, %Token{} = oauth_token} <- RevokeToken.revoke(app, params) do conn = - with session_token = get_session(conn, :oauth_token), + with session_token = AuthHelper.get_session_token(conn), %Token{token: ^session_token} <- oauth_token do - delete_session(conn, :oauth_token) + AuthHelper.delete_session_token(conn) else _ -> conn end diff --git a/lib/pleroma/web/plugs/o_auth_plug.ex b/lib/pleroma/web/plugs/o_auth_plug.ex index a3b7d42f7..eb287318b 100644 --- a/lib/pleroma/web/plugs/o_auth_plug.ex +++ b/lib/pleroma/web/plugs/o_auth_plug.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.Plugs.OAuthPlug do import Plug.Conn import Ecto.Query + alias Pleroma.Helpers.AuthHelper alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.OAuth.App @@ -98,7 +99,7 @@ defmodule Pleroma.Web.Plugs.OAuthPlug do @spec fetch_token_from_session(Plug.Conn.t()) :: :no_token_found | {:ok, String.t()} defp fetch_token_from_session(conn) do - case get_session(conn, :oauth_token) do + case AuthHelper.get_session_token(conn) do nil -> :no_token_found token -> {:ok, token} end diff --git a/lib/pleroma/web/plugs/set_user_session_id_plug.ex b/lib/pleroma/web/plugs/set_user_session_id_plug.ex index d2338c03f..9f4a6b6ac 100644 --- a/lib/pleroma/web/plugs/set_user_session_id_plug.ex +++ b/lib/pleroma/web/plugs/set_user_session_id_plug.ex @@ -3,8 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Plugs.SetUserSessionIdPlug do - import Plug.Conn - + alias Pleroma.Helpers.AuthHelper alias Pleroma.Web.OAuth.Token def init(opts) do @@ -12,7 +11,7 @@ defmodule Pleroma.Web.Plugs.SetUserSessionIdPlug do end def call(%{assigns: %{token: %Token{} = oauth_token}} = conn, _) do - put_session(conn, :oauth_token, oauth_token.token) + AuthHelper.put_session_token(conn, oauth_token.token) end def call(conn, _), do: conn diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 3a3e63db6..b3462ba00 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -320,6 +320,11 @@ defmodule Pleroma.Web.Router do end scope "/oauth", Pleroma.Web.OAuth do + get("/registration_details", OAuthController, :registration_details) + + post("/mfa/verify", MFAController, :verify, as: :mfa_verify) + get("/mfa", MFAController, :show) + scope [] do pipe_through(:oauth) @@ -327,17 +332,12 @@ defmodule Pleroma.Web.Router do post("/authorize", OAuthController, :create_authorization) end - post("/token", OAuthController, :token_exchange) - get("/registration_details", OAuthController, :registration_details) - - post("/mfa/challenge", MFAController, :challenge) - post("/mfa/verify", MFAController, :verify, as: :mfa_verify) - get("/mfa", MFAController, :show) - scope [] do pipe_through(:fetch_session) + post("/token", OAuthController, :token_exchange) post("/revoke", OAuthController, :token_revoke) + post("/mfa/challenge", MFAController, :challenge) end scope [] do -- cgit v1.2.3 From 751712d97022fa99a190cda228a9bcc10b42ede9 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Wed, 25 Nov 2020 12:52:39 -0600 Subject: Prevent mix tasks from spewing any internal logging unless DEBUG is in the env e.g., DEBUG=1 mix pleroma.config migrate_from_db --- lib/mix/pleroma.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/mix/pleroma.ex b/lib/mix/pleroma.ex index 6df1cf538..cd3f44074 100644 --- a/lib/mix/pleroma.ex +++ b/lib/mix/pleroma.ex @@ -22,8 +22,8 @@ defmodule Mix.Pleroma do Pleroma.Application.limiters_setup() Application.put_env(:phoenix, :serve_endpoints, false, persistent: true) - if Pleroma.Config.get(:env) != :test do - Application.put_env(:logger, :console, level: :debug) + unless System.get_env("DEBUG") do + Logger.remove_backend(:console) end adapter = Application.get_env(:tesla, :adapter) -- cgit v1.2.3 From fb72f2034a5d6d434b7fcdc428d559bf9312b163 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Wed, 25 Nov 2020 16:44:11 +0300 Subject: fix spec --- lib/pleroma/moderation_log.ex | 335 +++++++++++++----------------------------- 1 file changed, 104 insertions(+), 231 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/moderation_log.ex b/lib/pleroma/moderation_log.ex index 0a701127f..a7f26793d 100644 --- a/lib/pleroma/moderation_log.ex +++ b/lib/pleroma/moderation_log.ex @@ -12,6 +12,26 @@ defmodule Pleroma.ModerationLog do import Ecto.Query + @type t :: %__MODULE__{} + @type log_subject :: Activity.t() | User.t() | list(User.t()) + @type log_params :: %{ + required(:actor) => User.t(), + required(:action) => String.t(), + optional(:subject) => log_subject(), + optional(:subject_actor) => User.t(), + optional(:subject_id) => String.t(), + optional(:subjects) => list(User.t()), + optional(:permission) => String.t(), + optional(:text) => String.t(), + optional(:sensitive) => String.t(), + optional(:visibility) => String.t(), + optional(:followed) => User.t(), + optional(:follower) => User.t(), + optional(:nicknames) => list(String.t()), + optional(:tags) => list(String.t()), + optional(:target) => String.t() + } + schema "moderation_log" do field(:data, :map) @@ -90,212 +110,105 @@ defmodule Pleroma.ModerationLog do parsed_datetime end - @spec insert_log(%{actor: User, subject: [User], action: String.t(), permission: String.t()}) :: - {:ok, ModerationLog} | {:error, any} - def insert_log(%{ - actor: %User{} = actor, - subject: subjects, - action: action, - permission: permission - }) do - %ModerationLog{ - data: %{ - "actor" => user_to_map(actor), - "subject" => user_to_map(subjects), - "action" => action, - "permission" => permission, - "message" => "" - } + defp prepare_log_data(%{actor: actor, action: action} = attrs) do + %{ + "actor" => user_to_map(actor), + "action" => action, + "message" => "" } - |> insert_log_entry_with_message() + |> Pleroma.Maps.put_if_present("subject_actor", user_to_map(attrs[:subject_actor])) end - @spec insert_log(%{actor: User, subject: User, action: String.t()}) :: - {:ok, ModerationLog} | {:error, any} - def insert_log( - %{ - actor: %User{} = actor, - action: "report_update", - subject: %Activity{data: %{"type" => "Flag"}} = subject - } = attrs - ) do - %ModerationLog{ - data: %{ - "actor" => user_to_map(actor), - "action" => "report_update", - "subject" => report_to_map(subject), - "subject_actor" => user_to_map(attrs[:subject_actor]), - "message" => "" - } - } - |> insert_log_entry_with_message() + defp prepare_log_data(attrs), do: attrs + + @spec insert_log(log_params()) :: {:ok, ModerationLog} | {:error, any} + def insert_log(%{actor: %User{}, subject: subjects, permission: permission} = attrs) do + data = + attrs + |> prepare_log_data + |> Map.merge(%{"subject" => user_to_map(subjects), "permission" => permission}) + + insert_log_entry_with_message(%ModerationLog{data: data}) end - @spec insert_log(%{actor: User, subject: Activity, action: String.t(), text: String.t()}) :: - {:ok, ModerationLog} | {:error, any} - def insert_log( - %{ - actor: %User{} = actor, - action: "report_note", - subject: %Activity{} = subject, - text: text - } = attrs - ) do - %ModerationLog{ - data: %{ - "actor" => user_to_map(actor), - "action" => "report_note", - "subject" => report_to_map(subject), - "subject_actor" => user_to_map(attrs[:subject_actor]), - "text" => text - } - } - |> insert_log_entry_with_message() + def insert_log(%{actor: %User{}, action: action, subject: %Activity{} = subject} = attrs) + when action in ["report_note_delete", "report_update", "report_note"] do + data = + attrs + |> prepare_log_data + |> Pleroma.Maps.put_if_present("text", attrs[:text]) + |> Map.merge(%{"subject" => report_to_map(subject)}) + + insert_log_entry_with_message(%ModerationLog{data: data}) end - @spec insert_log(%{actor: User, subject: Activity, action: String.t(), text: String.t()}) :: - {:ok, ModerationLog} | {:error, any} def insert_log( %{ - actor: %User{} = actor, - action: "report_note_delete", + actor: %User{}, + action: action, subject: %Activity{} = subject, - text: text + sensitive: sensitive, + visibility: visibility } = attrs - ) do - %ModerationLog{ - data: %{ - "actor" => user_to_map(actor), - "action" => "report_note_delete", - "subject" => report_to_map(subject), - "subject_actor" => user_to_map(attrs[:subject_actor]), - "text" => text - } - } - |> insert_log_entry_with_message() - end - - @spec insert_log(%{ - actor: User, - subject: Activity, - action: String.t(), - sensitive: String.t(), - visibility: String.t() - }) :: {:ok, ModerationLog} | {:error, any} - def insert_log(%{ - actor: %User{} = actor, - action: "status_update", - subject: %Activity{} = subject, - sensitive: sensitive, - visibility: visibility - }) do - %ModerationLog{ - data: %{ - "actor" => user_to_map(actor), - "action" => "status_update", + ) + when action == "status_update" do + data = + attrs + |> prepare_log_data + |> Map.merge(%{ "subject" => status_to_map(subject), "sensitive" => sensitive, - "visibility" => visibility, - "message" => "" - } - } - |> insert_log_entry_with_message() - end + "visibility" => visibility + }) - @spec insert_log(%{actor: User, action: String.t(), subject_id: String.t()}) :: - {:ok, ModerationLog} | {:error, any} - def insert_log(%{ - actor: %User{} = actor, - action: "status_delete", - subject_id: subject_id - }) do - %ModerationLog{ - data: %{ - "actor" => user_to_map(actor), - "action" => "status_delete", - "subject_id" => subject_id, - "message" => "" - } - } - |> insert_log_entry_with_message() + insert_log_entry_with_message(%ModerationLog{data: data}) end - @spec insert_log(%{actor: User, subject: User, action: String.t()}) :: - {:ok, ModerationLog} | {:error, any} - def insert_log(%{actor: %User{} = actor, subject: subject, action: action}) do - %ModerationLog{ - data: %{ - "actor" => user_to_map(actor), - "action" => action, - "subject" => user_to_map(subject), - "message" => "" - } - } - |> insert_log_entry_with_message() + def insert_log(%{actor: %User{}, action: action, subject_id: subject_id} = attrs) + when action == "status_delete" do + data = + attrs + |> prepare_log_data + |> Map.merge(%{"subject_id" => subject_id}) + + insert_log_entry_with_message(%ModerationLog{data: data}) end - @spec insert_log(%{actor: User, subjects: [User], action: String.t()}) :: - {:ok, ModerationLog} | {:error, any} - def insert_log(%{actor: %User{} = actor, subjects: subjects, action: action}) do - subjects = Enum.map(subjects, &user_to_map/1) + def insert_log(%{actor: %User{}, subject: subject, action: _action} = attrs) do + data = + attrs + |> prepare_log_data + |> Map.merge(%{"subject" => user_to_map(subject)}) - %ModerationLog{ - data: %{ - "actor" => user_to_map(actor), - "action" => action, - "subjects" => subjects, - "message" => "" - } - } - |> insert_log_entry_with_message() + insert_log_entry_with_message(%ModerationLog{data: data}) end - @spec insert_log(%{actor: User, action: String.t(), followed: User, follower: User}) :: - {:ok, ModerationLog} | {:error, any} - def insert_log(%{ - actor: %User{} = actor, - followed: %User{} = followed, - follower: %User{} = follower, - action: "follow" - }) do - %ModerationLog{ - data: %{ - "actor" => user_to_map(actor), - "action" => "follow", - "followed" => user_to_map(followed), - "follower" => user_to_map(follower), - "message" => "" - } - } - |> insert_log_entry_with_message() + def insert_log(%{actor: %User{}, subjects: subjects, action: _action} = attrs) do + data = + attrs + |> prepare_log_data + |> Map.merge(%{"subjects" => user_to_map(subjects)}) + + insert_log_entry_with_message(%ModerationLog{data: data}) end - @spec insert_log(%{actor: User, action: String.t(), followed: User, follower: User}) :: - {:ok, ModerationLog} | {:error, any} - def insert_log(%{ - actor: %User{} = actor, - followed: %User{} = followed, - follower: %User{} = follower, - action: "unfollow" - }) do - %ModerationLog{ - data: %{ - "actor" => user_to_map(actor), - "action" => "unfollow", - "followed" => user_to_map(followed), - "follower" => user_to_map(follower), - "message" => "" - } - } - |> insert_log_entry_with_message() + def insert_log( + %{ + actor: %User{}, + followed: %User{} = followed, + follower: %User{} = follower, + action: action + } = attrs + ) + when action in ["unfollow", "follow"] do + data = + attrs + |> prepare_log_data + |> Map.merge(%{"followed" => user_to_map(followed), "follower" => user_to_map(follower)}) + + insert_log_entry_with_message(%ModerationLog{data: data}) end - @spec insert_log(%{ - actor: User, - action: String.t(), - nicknames: [String.t()], - tags: [String.t()] - }) :: {:ok, ModerationLog} | {:error, any} def insert_log(%{ actor: %User{} = actor, nicknames: nicknames, @@ -314,27 +227,16 @@ defmodule Pleroma.ModerationLog do |> insert_log_entry_with_message() end - @spec insert_log(%{actor: User, action: String.t(), target: String.t()}) :: - {:ok, ModerationLog} | {:error, any} - def insert_log(%{ - actor: %User{} = actor, - action: action, - target: target - }) + def insert_log(%{actor: %User{}, action: action, target: target} = attrs) when action in ["relay_follow", "relay_unfollow"] do - %ModerationLog{ - data: %{ - "actor" => user_to_map(actor), - "action" => action, - "target" => target, - "message" => "" - } - } - |> insert_log_entry_with_message() + data = + attrs + |> prepare_log_data + |> Map.merge(%{"target" => target}) + + insert_log_entry_with_message(%ModerationLog{data: data}) end - @spec insert_log(%{actor: User, action: String.t(), subject_id: String.t()}) :: - {:ok, ModerationLog} | {:error, any} def insert_log(%{actor: %User{} = actor, action: "chat_message_delete", subject_id: subject_id}) do %ModerationLog{ data: %{ @@ -367,20 +269,14 @@ defmodule Pleroma.ModerationLog do defp user_to_map(_), do: nil defp report_to_map(%Activity{} = report) do - %{ - "type" => "report", - "id" => report.id, - "state" => report.data["state"] - } + %{"type" => "report", "id" => report.id, "state" => report.data["state"]} end defp status_to_map(%Activity{} = status) do - %{ - "type" => "status", - "id" => status.id - } + %{"type" => "status", "id" => status.id} end + @spec get_log_entry_message(ModerationLog.t()) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -392,7 +288,6 @@ defmodule Pleroma.ModerationLog do "@#{actor_nickname} made @#{follower_nickname} #{action} @#{followed_nickname}" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -403,7 +298,6 @@ defmodule Pleroma.ModerationLog do "@#{actor_nickname} deleted users: #{users_to_nicknames_string(subjects)}" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -414,7 +308,6 @@ defmodule Pleroma.ModerationLog do "@#{actor_nickname} created users: #{users_to_nicknames_string(subjects)}" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -425,7 +318,6 @@ defmodule Pleroma.ModerationLog do "@#{actor_nickname} activated users: #{users_to_nicknames_string(users)}" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -436,7 +328,6 @@ defmodule Pleroma.ModerationLog do "@#{actor_nickname} deactivated users: #{users_to_nicknames_string(users)}" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -447,7 +338,6 @@ defmodule Pleroma.ModerationLog do "@#{actor_nickname} approved users: #{users_to_nicknames_string(users)}" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -461,7 +351,6 @@ defmodule Pleroma.ModerationLog do "@#{actor_nickname} added tags: #{tags_string} to users: #{nicknames_to_string(nicknames)}" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -475,7 +364,6 @@ defmodule Pleroma.ModerationLog do "@#{actor_nickname} removed tags: #{tags_string} from users: #{nicknames_to_string(nicknames)}" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -487,7 +375,6 @@ defmodule Pleroma.ModerationLog do "@#{actor_nickname} made #{users_to_nicknames_string(users)} #{permission}" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -499,7 +386,6 @@ defmodule Pleroma.ModerationLog do "@#{actor_nickname} revoked #{permission} role from #{users_to_nicknames_string(users)}" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -510,7 +396,6 @@ defmodule Pleroma.ModerationLog do "@#{actor_nickname} followed relay: #{target}" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -521,7 +406,6 @@ defmodule Pleroma.ModerationLog do "@#{actor_nickname} unfollowed relay: #{target}" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message( %ModerationLog{ data: %{ @@ -536,7 +420,6 @@ defmodule Pleroma.ModerationLog do " with '#{state}' state" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message( %ModerationLog{ data: %{ @@ -551,7 +434,6 @@ defmodule Pleroma.ModerationLog do subject_actor_nickname(log, " on user ") end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message( %ModerationLog{ data: %{ @@ -566,7 +448,6 @@ defmodule Pleroma.ModerationLog do subject_actor_nickname(log, " on user ") end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -579,7 +460,6 @@ defmodule Pleroma.ModerationLog do "@#{actor_nickname} updated status ##{subject_id}, set visibility: '#{visibility}'" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -592,7 +472,6 @@ defmodule Pleroma.ModerationLog do "@#{actor_nickname} updated status ##{subject_id}, set sensitive: '#{sensitive}'" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -607,7 +486,6 @@ defmodule Pleroma.ModerationLog do }'" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -618,7 +496,6 @@ defmodule Pleroma.ModerationLog do "@#{actor_nickname} deleted status ##{subject_id}" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -629,7 +506,6 @@ defmodule Pleroma.ModerationLog do "@#{actor_nickname} forced password reset for users: #{users_to_nicknames_string(subjects)}" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -640,7 +516,6 @@ defmodule Pleroma.ModerationLog do "@#{actor_nickname} confirmed email for users: #{users_to_nicknames_string(subjects)}" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -653,7 +528,6 @@ defmodule Pleroma.ModerationLog do }" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -664,7 +538,6 @@ defmodule Pleroma.ModerationLog do "@#{actor_nickname} updated users: #{users_to_nicknames_string(subjects)}" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, -- cgit v1.2.3 From 6aadb1cb409a80632d17bba487cfabfdb0b13d34 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Thu, 26 Nov 2020 11:12:44 +0300 Subject: digest algorithm is taken from header --- lib/pleroma/web/plugs/digest_plug.ex | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/plugs/digest_plug.ex b/lib/pleroma/web/plugs/digest_plug.ex index b521b3073..fb2723b97 100644 --- a/lib/pleroma/web/plugs/digest_plug.ex +++ b/lib/pleroma/web/plugs/digest_plug.ex @@ -7,8 +7,22 @@ defmodule Pleroma.Web.Plugs.DigestPlug do require Logger def read_body(conn, opts) do + digest_algorithm = + with [digest_header] <- Conn.get_req_header(conn, "digest") do + digest_header + |> String.split("=", parts: 2) + |> List.first() + else + _ -> "SHA-256" + end + + unless String.downcase(digest_algorithm) == "sha-256" do + raise ArgumentError, + message: "invalid value for digest algorithm, got: #{digest_algorithm}" + end + {:ok, body, conn} = Conn.read_body(conn, opts) - digest = "SHA-256=" <> (:crypto.hash(:sha256, body) |> Base.encode64()) - {:ok, body, Conn.assign(conn, :digest, digest)} + encoded_digest = :crypto.hash(:sha256, body) |> Base.encode64() + {:ok, body, Conn.assign(conn, :digest, "#{digest_algorithm}=#{encoded_digest}")} end end -- cgit v1.2.3 From f1b07a2b2b6439579f0a35694f693712fb5ec5f4 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Sat, 28 Nov 2020 21:51:06 +0300 Subject: OAuth form user remembering feature. Local MastoFE login / logout fixes. --- lib/pleroma/user.ex | 4 + lib/pleroma/web/masto_fe_controller.ex | 34 +-- .../mastodon_api/controllers/auth_controller.ex | 64 ++++-- lib/pleroma/web/o_auth/o_auth_controller.ex | 21 +- lib/pleroma/web/templates/layout/app.html.eex | 236 +-------------------- .../web/templates/o_auth/o_auth/show.html.eex | 66 ++++-- 6 files changed, 136 insertions(+), 289 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index bcd5256c8..6a5a43a25 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -2406,4 +2406,8 @@ defmodule Pleroma.User do |> Map.put(:bio, HTML.filter_tags(user.bio, filter)) |> Map.put(:fields, fields) end + + def get_host(%User{ap_id: ap_id} = _user) do + URI.parse(ap_id).host + end end diff --git a/lib/pleroma/web/masto_fe_controller.ex b/lib/pleroma/web/masto_fe_controller.ex index 08f92d55f..7011ae214 100644 --- a/lib/pleroma/web/masto_fe_controller.ex +++ b/lib/pleroma/web/masto_fe_controller.ex @@ -6,6 +6,8 @@ defmodule Pleroma.Web.MastoFEController do use Pleroma.Web, :controller alias Pleroma.User + alias Pleroma.Web.OAuth.Token + alias Pleroma.Web.MastodonAPI.AuthController alias Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Web.Plugs.OAuthScopesPlug @@ -26,27 +28,27 @@ defmodule Pleroma.Web.MastoFEController do ) @doc "GET /web/*path" - def index(%{assigns: %{user: user, token: token}} = conn, _params) - when not is_nil(user) and not is_nil(token) do - conn - |> put_layout(false) - |> render("index.html", - token: token.token, - user: user, - custom_emojis: Pleroma.Emoji.get_all() - ) - end - def index(conn, _params) do - conn - |> put_session(:return_to, conn.request_path) - |> redirect(to: "/web/login") + with %{assigns: %{user: %User{} = user, token: %Token{app_id: token_app_id} = token}} <- conn, + {:ok, %{id: ^token_app_id}} <- AuthController.local_mastofe_app() do + conn + |> put_layout(false) + |> render("index.html", + token: token.token, + user: user, + custom_emojis: Pleroma.Emoji.get_all() + ) + else + _ -> + conn + |> put_session(:return_to, conn.request_path) + |> redirect(to: "/web/login") + end end @doc "GET /web/manifest.json" def manifest(conn, _params) do - conn - |> render("manifest.json") + render(conn, "manifest.json") end @doc "PUT /api/web/settings: Backend-obscure settings blob for MastoFE, don't parse/reuse elsewhere" diff --git a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex index fa582dcfc..93d057a79 100644 --- a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex @@ -8,10 +8,12 @@ defmodule Pleroma.Web.MastodonAPI.AuthController do import Pleroma.Web.ControllerHelper, only: [json_response: 3] alias Pleroma.Helpers.AuthHelper + alias Pleroma.Helpers.UriHelper alias Pleroma.User alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.Authorization alias Pleroma.Web.OAuth.Token + alias Pleroma.Web.OAuth.Token.Strategy.Revoke, as: RevokeToken alias Pleroma.Web.TwitterAPI.TwitterAPI action_fallback(Pleroma.Web.MastodonAPI.FallbackController) @@ -21,24 +23,35 @@ defmodule Pleroma.Web.MastodonAPI.AuthController do @local_mastodon_name "Mastodon-Local" @doc "GET /web/login" - def login(%{assigns: %{user: %User{}}} = conn, _params) do - redirect(conn, to: local_mastodon_root_path(conn)) - end - - # Local Mastodon FE login init action - def login(conn, %{"code" => auth_token}) do - with {:ok, app} <- get_or_make_app(), + # Local Mastodon FE login callback action + def login(conn, %{"code" => auth_token} = params) do + with {:ok, app} <- local_mastofe_app(), {:ok, auth} <- Authorization.get_by_token(app, auth_token), - {:ok, token} <- Token.exchange_token(app, auth) do + {:ok, oauth_token} <- Token.exchange_token(app, auth) do + redirect_to = + conn + |> local_mastodon_post_login_path() + |> UriHelper.modify_uri_params(%{"access_token" => oauth_token.token}) + conn - |> AuthHelper.put_session_token(token.token) - |> redirect(to: local_mastodon_root_path(conn)) + |> AuthHelper.put_session_token(oauth_token.token) + |> redirect(to: redirect_to) + else + _ -> redirect_to_oauth_form(conn, params) + end + end + + def login(conn, params) do + with %{assigns: %{user: %User{}, token: %Token{app_id: app_id}}} <- conn, + {:ok, %{id: ^app_id}} <- local_mastofe_app() do + redirect(conn, to: local_mastodon_post_login_path(conn)) + else + _ -> redirect_to_oauth_form(conn, params) end end - # Local Mastodon FE callback action - def login(conn, _) do - with {:ok, app} <- get_or_make_app() do + defp redirect_to_oauth_form(conn, _params) do + with {:ok, app} <- local_mastofe_app() do path = o_auth_path(conn, :authorize, response_type: "code", @@ -53,9 +66,16 @@ defmodule Pleroma.Web.MastodonAPI.AuthController do @doc "DELETE /auth/sign_out" def logout(conn, _) do - conn - |> clear_session() - |> redirect(to: "/") + conn = + with %{assigns: %{token: %Token{} = oauth_token}} <- conn, + session_token = AuthHelper.get_session_token(conn), + {:ok, %Token{token: ^session_token}} <- RevokeToken.revoke(oauth_token) do + AuthHelper.delete_session_token(conn) + else + _ -> conn + end + + redirect(conn, to: "/") end @doc "POST /auth/password" @@ -67,7 +87,7 @@ defmodule Pleroma.Web.MastodonAPI.AuthController do json_response(conn, :no_content, "") end - defp local_mastodon_root_path(conn) do + defp local_mastodon_post_login_path(conn) do case get_session(conn, :return_to) do nil -> masto_fe_path(conn, :index, ["getting-started"]) @@ -78,9 +98,11 @@ defmodule Pleroma.Web.MastodonAPI.AuthController do end end - @spec get_or_make_app() :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} - defp get_or_make_app do - %{client_name: @local_mastodon_name, redirect_uris: "."} - |> App.get_or_make(["read", "write", "follow", "push", "admin"]) + @spec local_mastofe_app() :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} + def local_mastofe_app do + App.get_or_make( + %{client_name: @local_mastodon_name, redirect_uris: "."}, + ["read", "write", "follow", "push", "admin"] + ) end end diff --git a/lib/pleroma/web/o_auth/o_auth_controller.ex b/lib/pleroma/web/o_auth/o_auth_controller.ex index 8103395b3..965c0f879 100644 --- a/lib/pleroma/web/o_auth/o_auth_controller.ex +++ b/lib/pleroma/web/o_auth/o_auth_controller.ex @@ -80,6 +80,13 @@ defmodule Pleroma.Web.OAuth.OAuthController do available_scopes = (app && app.scopes) || [] scopes = Scopes.fetch_scopes(params, available_scopes) + user = + with %{assigns: %{user: %User{} = user}} <- conn do + user + else + _ -> nil + end + scopes = if scopes == [] do available_scopes @@ -89,6 +96,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do # Note: `params` might differ from `conn.params`; use `@params` not `@conn.params` in template render(conn, Authenticator.auth_template(), %{ + user: user, + app: app && Map.delete(app, :client_secret), response_type: params["response_type"], client_id: params["client_id"], available_scopes: available_scopes, @@ -132,11 +141,13 @@ defmodule Pleroma.Web.OAuth.OAuthController do end end - def create_authorization( - %Plug.Conn{} = conn, - %{"authorization" => _} = params, - opts \\ [] - ) do + def create_authorization(_, _, opts \\ []) + + def create_authorization(%Plug.Conn{assigns: %{user: %User{} = user}} = conn, params, []) do + create_authorization(conn, params, user: user) + end + + def create_authorization(%Plug.Conn{} = conn, %{"authorization" => _} = params, opts) do with {:ok, auth, user} <- do_create_authorization(conn, params, opts[:user]), {:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)} do after_create_authorization(conn, auth, params) diff --git a/lib/pleroma/web/templates/layout/app.html.eex b/lib/pleroma/web/templates/layout/app.html.eex index 3f28f1920..1ede59fd8 100644 --- a/lib/pleroma/web/templates/layout/app.html.eex +++ b/lib/pleroma/web/templates/layout/app.html.eex @@ -1,233 +1,19 @@ <!DOCTYPE html> -<html> +<html lang="en"> <head> - <meta charset="utf-8" /> - <meta name="viewport" content="width=device-width,initial-scale=1,minimal-ui" /> - <title> - <%= Pleroma.Config.get([:instance, :name]) %> - - + + + <%= Pleroma.Config.get([:instance, :name]) %> + +
-

<%= Pleroma.Config.get([:instance, :name]) %>

<%= @inner_content %>
diff --git a/lib/pleroma/web/templates/o_auth/o_auth/show.html.eex b/lib/pleroma/web/templates/o_auth/o_auth/show.html.eex index b17142ff8..1a85818ec 100644 --- a/lib/pleroma/web/templates/o_auth/o_auth/show.html.eex +++ b/lib/pleroma/web/templates/o_auth/o_auth/show.html.eex @@ -5,32 +5,55 @@ <% end %> -

OAuth Authorization

<%= form_for @conn, o_auth_path(@conn, :authorize), [as: "authorization"], fn f -> %> -<%= if @params["registration"] in ["true", true] do %> -

This is the first time you visit! Please enter your Pleroma handle.

-

Choose carefully! You won't be able to change this later. You will be able to change your display name, though.

-
- <%= label f, :nickname, "Pleroma Handle" %> - <%= text_input f, :nickname, placeholder: "lain" %> +<%= if @user do %> + - <%= hidden_input f, :name, value: @params["name"] %> - <%= hidden_input f, :password, value: @params["password"] %> -
-<% else %> -
- <%= label f, :name, "Username" %> - <%= text_input f, :name %> -
-
- <%= label f, :password, "Password" %> - <%= password_input f, :password %> -
- <%= submit "Log In" %> - <%= render @view_module, "_scopes.html", Map.merge(assigns, %{form: f}) %> <% end %> +
+ <%= if @app do %> +

Application <%= @app.client_name %> is requesting access to your account.

+ <%= render @view_module, "_scopes.html", Map.merge(assigns, %{form: f}) %> + <% end %> + + <%= if @user do %> +
+ Cancel + <%= submit "Approve", class: "button--approve" %> +
+ <% else %> + <%= if @params["registration"] in ["true", true] do %> +

This is the first time you visit! Please enter your Pleroma handle.

+

Choose carefully! You won't be able to change this later. You will be able to change your display name, though.

+
+ <%= label f, :nickname, "Pleroma Handle" %> + <%= text_input f, :nickname, placeholder: "lain" %> +
+ <%= hidden_input f, :name, value: @params["name"] %> + <%= hidden_input f, :password, value: @params["password"] %> +
+ <% else %> +
+ <%= label f, :name, "Username" %> + <%= text_input f, :name %> +
+
+ <%= label f, :password, "Password" %> + <%= password_input f, :password %> +
+ <%= submit "Log In" %> + <% end %> + <% end %> +
+ <%= hidden_input f, :client_id, value: @client_id %> <%= hidden_input f, :response_type, value: @response_type %> <%= hidden_input f, :redirect_uri, value: @redirect_uri %> @@ -40,4 +63,3 @@ <%= if Pleroma.Config.oauth_consumer_enabled?() do %> <%= render @view_module, Pleroma.Web.Auth.Authenticator.oauth_consumer_template(), assigns %> <% end %> - -- cgit v1.2.3 From d50a3345ae7873f8a8744eba8a3eb755e2b8dfdc Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Mon, 30 Nov 2020 21:55:48 +0300 Subject: [#3112] Allowed revoking same-user token from any apps. Added tests. --- lib/pleroma/web/masto_fe_controller.ex | 2 +- lib/pleroma/web/o_auth/o_auth_controller.ex | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/masto_fe_controller.ex b/lib/pleroma/web/masto_fe_controller.ex index 7011ae214..20279ff45 100644 --- a/lib/pleroma/web/masto_fe_controller.ex +++ b/lib/pleroma/web/masto_fe_controller.ex @@ -6,8 +6,8 @@ defmodule Pleroma.Web.MastoFEController do use Pleroma.Web, :controller alias Pleroma.User - alias Pleroma.Web.OAuth.Token alias Pleroma.Web.MastodonAPI.AuthController + alias Pleroma.Web.OAuth.Token alias Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Web.Plugs.OAuthScopesPlug diff --git a/lib/pleroma/web/o_auth/o_auth_controller.ex b/lib/pleroma/web/o_auth/o_auth_controller.ex index 965c0f879..6e3c7e1a1 100644 --- a/lib/pleroma/web/o_auth/o_auth_controller.ex +++ b/lib/pleroma/web/o_auth/o_auth_controller.ex @@ -379,9 +379,9 @@ defmodule Pleroma.Web.OAuth.OAuthController do render_invalid_credentials_error(conn) end - def token_revoke(%Plug.Conn{} = conn, %{"token" => _token} = params) do - with {:ok, app} <- Token.Utils.fetch_app(conn), - {:ok, %Token{} = oauth_token} <- RevokeToken.revoke(app, params) do + def token_revoke(%Plug.Conn{} = conn, %{"token" => token}) do + with {:ok, %Token{} = oauth_token} <- Token.get_by_token(token), + {:ok, oauth_token} <- RevokeToken.revoke(oauth_token) do conn = with session_token = AuthHelper.get_session_token(conn), %Token{token: ^session_token} <- oauth_token do -- cgit v1.2.3 From 35ba48494f5129d3a0010b045ff36d98e7e7984f Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 2 Dec 2020 00:17:52 +0400 Subject: Stream follow updates --- lib/pleroma/following_relationship.ex | 42 ++++++++++++++++++++------ lib/pleroma/user.ex | 12 +------- lib/pleroma/user/import.ex | 2 +- lib/pleroma/web/activity_pub/side_effects.ex | 7 ++--- lib/pleroma/web/streamer.ex | 45 ++++++++++++++++------------ lib/pleroma/web/views/streamer_view.ex | 22 ++++++++++++++ 6 files changed, 86 insertions(+), 44 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index 2039a259d..bc6a7eaf9 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -62,23 +62,47 @@ defmodule Pleroma.FollowingRelationship do follow(follower, following, state) following_relationship -> - following_relationship - |> cast(%{state: state}, [:state]) - |> validate_required([:state]) - |> Repo.update() + with {:ok, _following_relationship} <- + following_relationship + |> cast(%{state: state}, [:state]) + |> validate_required([:state]) + |> Repo.update() do + after_update(state, follower, following) + end end end def follow(%User{} = follower, %User{} = following, state \\ :follow_accept) do - %__MODULE__{} - |> changeset(%{follower: follower, following: following, state: state}) - |> Repo.insert(on_conflict: :nothing) + with {:ok, _following_relationship} <- + %__MODULE__{} + |> changeset(%{follower: follower, following: following, state: state}) + |> Repo.insert(on_conflict: :nothing) do + after_update(state, follower, following) + end end def unfollow(%User{} = follower, %User{} = following) do case get(follower, following) do - %__MODULE__{} = following_relationship -> Repo.delete(following_relationship) - _ -> {:ok, nil} + %__MODULE__{} = following_relationship -> + with {:ok, _following_relationship} <- Repo.delete(following_relationship) do + after_update(:unfollow, follower, following) + end + + _ -> + {:ok, nil} + end + end + + defp after_update(state, %User{} = follower, %User{} = following) do + with {:ok, following} <- User.update_follower_count(following), + {:ok, follower} <- User.update_following_count(follower) do + Pleroma.Web.Streamer.stream("relationships:update", %{ + state: state, + following: following, + follower: follower + }) + + {:ok, follower, following} end end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index bcd5256c8..676483540 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -882,7 +882,7 @@ defmodule Pleroma.User do if not ap_enabled?(followed) do follow(follower, followed) else - {:ok, follower} + {:ok, follower, followed} end end @@ -908,11 +908,6 @@ defmodule Pleroma.User do true -> FollowingRelationship.follow(follower, followed, state) - - {:ok, _} = update_follower_count(followed) - - follower - |> update_following_count() end end @@ -936,11 +931,6 @@ defmodule Pleroma.User do case get_follow_state(follower, followed) do state when state in [:follow_pending, :follow_accept] -> FollowingRelationship.unfollow(follower, followed) - {:ok, followed} = update_follower_count(followed) - - {:ok, follower} = update_following_count(follower) - - {:ok, follower, followed} nil -> {:error, "Not subscribed!"} diff --git a/lib/pleroma/user/import.ex b/lib/pleroma/user/import.ex index e458021c8..86b49d8ae 100644 --- a/lib/pleroma/user/import.ex +++ b/lib/pleroma/user/import.ex @@ -45,7 +45,7 @@ defmodule Pleroma.User.Import do identifiers, fn identifier -> with {:ok, %User{} = followed} <- User.get_or_fetch(identifier), - {:ok, follower} <- User.maybe_direct_follow(follower, followed), + {:ok, follower, followed} <- User.maybe_direct_follow(follower, followed), {:ok, _, _, _} <- CommonAPI.follow(follower, followed) do followed else diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 4d8fb721e..8556fca1d 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -47,10 +47,9 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do %User{} = followed <- User.get_cached_by_ap_id(actor), %User{} = follower <- User.get_cached_by_ap_id(follower_id), {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "accept"), - {:ok, _relationship} <- FollowingRelationship.update(follower, followed, :follow_accept) do + {:ok, _follower, followed} <- + FollowingRelationship.update(follower, followed, :follow_accept) do Notification.update_notification_type(followed, follow_activity) - User.update_follower_count(followed) - User.update_following_count(follower) end {:ok, object, meta} @@ -99,7 +98,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do ) do with %User{} = follower <- User.get_cached_by_ap_id(following_user), %User{} = followed <- User.get_cached_by_ap_id(followed_user), - {_, {:ok, _}, _, _} <- + {_, {:ok, _, _}, _, _} <- {:following, User.follow(follower, followed, :follow_pending), follower, followed} do if followed.local && !followed.is_locked do {:ok, accept_data, _} = Builder.accept(followed, object) diff --git a/lib/pleroma/web/streamer.ex b/lib/pleroma/web/streamer.ex index 71fe27c89..0b6cc89e9 100644 --- a/lib/pleroma/web/streamer.ex +++ b/lib/pleroma/web/streamer.ex @@ -36,9 +36,8 @@ defmodule Pleroma.Web.Streamer do ) :: {:ok, topic :: String.t()} | {:error, :bad_topic} | {:error, :unauthorized} def get_topic_and_add_socket(stream, user, oauth_token, params \\ %{}) do - case get_topic(stream, user, oauth_token, params) do - {:ok, topic} -> add_socket(topic, user) - error -> error + with {:ok, topic} <- get_topic(stream, user, oauth_token, params) do + add_socket(topic, user) end end @@ -70,10 +69,10 @@ defmodule Pleroma.Web.Streamer do def get_topic( stream, %User{id: user_id} = user, - %Token{user_id: token_user_id} = oauth_token, + %Token{user_id: user_id} = oauth_token, _params ) - when stream in @user_streams and user_id == token_user_id do + when stream in @user_streams do # Note: "read" works for all user streams (not mentioning it since it's an ancestor scope) required_scopes = if stream == "user:notification" do @@ -97,10 +96,9 @@ defmodule Pleroma.Web.Streamer do def get_topic( "list", %User{id: user_id} = user, - %Token{user_id: token_user_id} = oauth_token, + %Token{user_id: user_id} = oauth_token, %{"list" => id} - ) - when user_id == token_user_id do + ) do cond do OAuthScopesPlug.filter_descendants(["read", "read:lists"], oauth_token.scopes) == [] -> {:error, :unauthorized} @@ -137,16 +135,10 @@ defmodule Pleroma.Web.Streamer do def stream(topics, items) do if should_env_send?() do - List.wrap(topics) - |> Enum.each(fn topic -> - List.wrap(items) - |> Enum.each(fn item -> - spawn(fn -> do_stream(topic, item) end) - end) - end) + for topic <- List.wrap(topics), item <- List.wrap(items) do + spawn(fn -> do_stream(topic, item) end) + end end - - :ok end def filtered_by_user?(user, item, streamed_type \\ :activity) @@ -160,8 +152,7 @@ defmodule Pleroma.Web.Streamer do domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks) with parent <- Object.normalize(item) || item, - true <- - Enum.all?([blocked_ap_ids, muted_ap_ids], &(item.actor not in &1)), + true <- Enum.all?([blocked_ap_ids, muted_ap_ids], &(item.actor not in &1)), true <- item.data["type"] != "Announce" || item.actor not in reblog_muted_ap_ids, true <- !(streamed_type == :activity && item.data["type"] == "Announce" && @@ -195,6 +186,22 @@ defmodule Pleroma.Web.Streamer do end) end + defp do_stream("relationships:update", item) do + text = StreamerView.render("relationships_update.json", item) + + [item.follower, item.following] + |> Enum.map(fn %{id: id} -> "user:#{id}" end) + |> Enum.each(fn user_topic -> + Logger.debug("Trying to push relationships:update to #{user_topic}\n\n") + + Registry.dispatch(@registry, user_topic, fn list -> + Enum.each(list, fn {pid, _auth} -> + send(pid, {:text, text}) + end) + end) + end) + end + defp do_stream("participation", participation) do user_topic = "direct:#{participation.user_id}" Logger.debug("Trying to push a conversation participation to #{user_topic}\n\n") diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index 476a33245..92239a411 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -74,6 +74,28 @@ defmodule Pleroma.Web.StreamerView do |> Jason.encode!() end + def render("relationships_update.json", item) do + %{ + event: "pleroma:relationships_update", + payload: + %{ + state: item.state, + follower: %{ + id: item.follower.id, + follower_count: item.follower.follower_count, + following_count: item.follower.following_count + }, + following: %{ + id: item.following.id, + follower_count: item.following.follower_count, + following_count: item.following.following_count + } + } + |> Jason.encode!() + } + |> Jason.encode!() + end + def render("conversation.json", %Participation{} = participation) do %{ event: "conversation", -- cgit v1.2.3 From 222312900e6d847e0d4823fb62b6eb3675a0180f Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 2 Dec 2020 12:18:43 +0100 Subject: User: Don't allow local users in remote changesets --- lib/pleroma/user.ex | 13 +++++++++++++ 1 file changed, 13 insertions(+) (limited to 'lib') diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index bcd5256c8..9222b5b2a 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -472,7 +472,20 @@ defmodule Pleroma.User do |> validate_format(:nickname, @email_regex) |> validate_length(:bio, max: bio_limit) |> validate_length(:name, max: name_limit) + |> validate_inclusion(:local, [true]) |> validate_fields(true) + |> validate_non_local() + end + + defp validate_non_local(cng) do + local? = get_field(cng, :local) + + if local? do + cng + |> add_error(:local, "User is local, can't update with this changeset.") + else + cng + end end def update_changeset(struct, params \\ %{}) do -- cgit v1.2.3 From 04af0bbe44ab4ebd83ee2f3b797768d6e255e365 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 2 Dec 2020 13:39:29 +0100 Subject: User: Remove left-over (wrong) fix. --- lib/pleroma/user.ex | 1 - 1 file changed, 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 9222b5b2a..4b3a9d690 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -472,7 +472,6 @@ defmodule Pleroma.User do |> validate_format(:nickname, @email_regex) |> validate_length(:bio, max: bio_limit) |> validate_length(:name, max: name_limit) - |> validate_inclusion(:local, [true]) |> validate_fields(true) |> validate_non_local() end -- cgit v1.2.3 From 1adee0832148265828b38d9b68a72dec1098bcaf Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 2 Dec 2020 16:15:03 +0100 Subject: Emoji: Update to Unicode 13.1, switch base file, allow multichar. --- lib/pleroma/emoji-test.txt | 4879 ++++++++++++++++++++++++++++++++++++++++++++ lib/pleroma/emoji.ex | 17 +- 2 files changed, 4885 insertions(+), 11 deletions(-) create mode 100644 lib/pleroma/emoji-test.txt (limited to 'lib') diff --git a/lib/pleroma/emoji-test.txt b/lib/pleroma/emoji-test.txt new file mode 100644 index 000000000..d3c6d12bd --- /dev/null +++ b/lib/pleroma/emoji-test.txt @@ -0,0 +1,4879 @@ +# emoji-test.txt +# Date: 2020-09-12, 22:19:50 GMT +# © 2020 Unicode®, Inc. +# Unicode and the Unicode Logo are registered trademarks of Unicode, Inc. in the U.S. and other countries. +# For terms of use, see http://www.unicode.org/terms_of_use.html +# +# Emoji Keyboard/Display Test Data for UTS #51 +# Version: 13.1 +# +# For documentation and usage, see http://www.unicode.org/reports/tr51 +# +# This file provides data for testing which emoji forms should be in keyboards and which should also be displayed/processed. +# Format: code points; status # emoji name +# Code points — list of one or more hex code points, separated by spaces +# Status +# component — an Emoji_Component, +# excluding Regional_Indicators, ASCII, and non-Emoji. +# fully-qualified — a fully-qualified emoji (see ED-18 in UTS #51), +# excluding Emoji_Component +# minimally-qualified — a minimally-qualified emoji (see ED-18a in UTS #51) +# unqualified — a unqualified emoji (See ED-19 in UTS #51) +# Notes: +# • This includes the emoji components that need emoji presentation (skin tone and hair) +# when isolated, but omits the components that need not have an emoji +# presentation when isolated. +# • The RGI set is covered by the listed fully-qualified emoji. +# • The listed minimally-qualified and unqualified cover all cases where an +# element of the RGI set is missing one or more emoji presentation selectors. +# • The file is in CLDR order, not codepoint order. This is recommended (but not required!) for keyboard palettes. +# • The groups and subgroups are illustrative. See the Emoji Order chart for more information. + + +# group: Smileys & Emotion + +# subgroup: face-smiling +1F600 ; fully-qualified # 😀 E1.0 grinning face +1F603 ; fully-qualified # 😃 E0.6 grinning face with big eyes +1F604 ; fully-qualified # 😄 E0.6 grinning face with smiling eyes +1F601 ; fully-qualified # 😁 E0.6 beaming face with smiling eyes +1F606 ; fully-qualified # 😆 E0.6 grinning squinting face +1F605 ; fully-qualified # 😅 E0.6 grinning face with sweat +1F923 ; fully-qualified # 🤣 E3.0 rolling on the floor laughing +1F602 ; fully-qualified # 😂 E0.6 face with tears of joy +1F642 ; fully-qualified # 🙂 E1.0 slightly smiling face +1F643 ; fully-qualified # 🙃 E1.0 upside-down face +1F609 ; fully-qualified # 😉 E0.6 winking face +1F60A ; fully-qualified # 😊 E0.6 smiling face with smiling eyes +1F607 ; fully-qualified # 😇 E1.0 smiling face with halo + +# subgroup: face-affection +1F970 ; fully-qualified # 🥰 E11.0 smiling face with hearts +1F60D ; fully-qualified # 😍 E0.6 smiling face with heart-eyes +1F929 ; fully-qualified # 🤩 E5.0 star-struck +1F618 ; fully-qualified # 😘 E0.6 face blowing a kiss +1F617 ; fully-qualified # 😗 E1.0 kissing face +263A FE0F ; fully-qualified # ☺️ E0.6 smiling face +263A ; unqualified # ☺ E0.6 smiling face +1F61A ; fully-qualified # 😚 E0.6 kissing face with closed eyes +1F619 ; fully-qualified # 😙 E1.0 kissing face with smiling eyes +1F972 ; fully-qualified # 🥲 E13.0 smiling face with tear + +# subgroup: face-tongue +1F60B ; fully-qualified # 😋 E0.6 face savoring food +1F61B ; fully-qualified # 😛 E1.0 face with tongue +1F61C ; fully-qualified # 😜 E0.6 winking face with tongue +1F92A ; fully-qualified # 🤪 E5.0 zany face +1F61D ; fully-qualified # 😝 E0.6 squinting face with tongue +1F911 ; fully-qualified # 🤑 E1.0 money-mouth face + +# subgroup: face-hand +1F917 ; fully-qualified # 🤗 E1.0 hugging face +1F92D ; fully-qualified # 🤭 E5.0 face with hand over mouth +1F92B ; fully-qualified # 🤫 E5.0 shushing face +1F914 ; fully-qualified # 🤔 E1.0 thinking face + +# subgroup: face-neutral-skeptical +1F910 ; fully-qualified # 🤐 E1.0 zipper-mouth face +1F928 ; fully-qualified # 🤨 E5.0 face with raised eyebrow +1F610 ; fully-qualified # 😐 E0.7 neutral face +1F611 ; fully-qualified # 😑 E1.0 expressionless face +1F636 ; fully-qualified # 😶 E1.0 face without mouth +1F636 200D 1F32B FE0F ; fully-qualified # 😶‍🌫️ E13.1 face in clouds +1F636 200D 1F32B ; minimally-qualified # 😶‍🌫 E13.1 face in clouds +1F60F ; fully-qualified # 😏 E0.6 smirking face +1F612 ; fully-qualified # 😒 E0.6 unamused face +1F644 ; fully-qualified # 🙄 E1.0 face with rolling eyes +1F62C ; fully-qualified # 😬 E1.0 grimacing face +1F62E 200D 1F4A8 ; fully-qualified # 😮‍💨 E13.1 face exhaling +1F925 ; fully-qualified # 🤥 E3.0 lying face + +# subgroup: face-sleepy +1F60C ; fully-qualified # 😌 E0.6 relieved face +1F614 ; fully-qualified # 😔 E0.6 pensive face +1F62A ; fully-qualified # 😪 E0.6 sleepy face +1F924 ; fully-qualified # 🤤 E3.0 drooling face +1F634 ; fully-qualified # 😴 E1.0 sleeping face + +# subgroup: face-unwell +1F637 ; fully-qualified # 😷 E0.6 face with medical mask +1F912 ; fully-qualified # 🤒 E1.0 face with thermometer +1F915 ; fully-qualified # 🤕 E1.0 face with head-bandage +1F922 ; fully-qualified # 🤢 E3.0 nauseated face +1F92E ; fully-qualified # 🤮 E5.0 face vomiting +1F927 ; fully-qualified # 🤧 E3.0 sneezing face +1F975 ; fully-qualified # 🥵 E11.0 hot face +1F976 ; fully-qualified # 🥶 E11.0 cold face +1F974 ; fully-qualified # 🥴 E11.0 woozy face +1F635 ; fully-qualified # 😵 E0.6 knocked-out face +1F635 200D 1F4AB ; fully-qualified # 😵‍💫 E13.1 face with spiral eyes +1F92F ; fully-qualified # 🤯 E5.0 exploding head + +# subgroup: face-hat +1F920 ; fully-qualified # 🤠 E3.0 cowboy hat face +1F973 ; fully-qualified # 🥳 E11.0 partying face +1F978 ; fully-qualified # 🥸 E13.0 disguised face + +# subgroup: face-glasses +1F60E ; fully-qualified # 😎 E1.0 smiling face with sunglasses +1F913 ; fully-qualified # 🤓 E1.0 nerd face +1F9D0 ; fully-qualified # 🧐 E5.0 face with monocle + +# subgroup: face-concerned +1F615 ; fully-qualified # 😕 E1.0 confused face +1F61F ; fully-qualified # 😟 E1.0 worried face +1F641 ; fully-qualified # 🙁 E1.0 slightly frowning face +2639 FE0F ; fully-qualified # ☹️ E0.7 frowning face +2639 ; unqualified # ☹ E0.7 frowning face +1F62E ; fully-qualified # 😮 E1.0 face with open mouth +1F62F ; fully-qualified # 😯 E1.0 hushed face +1F632 ; fully-qualified # 😲 E0.6 astonished face +1F633 ; fully-qualified # 😳 E0.6 flushed face +1F97A ; fully-qualified # 🥺 E11.0 pleading face +1F626 ; fully-qualified # 😦 E1.0 frowning face with open mouth +1F627 ; fully-qualified # 😧 E1.0 anguished face +1F628 ; fully-qualified # 😨 E0.6 fearful face +1F630 ; fully-qualified # 😰 E0.6 anxious face with sweat +1F625 ; fully-qualified # 😥 E0.6 sad but relieved face +1F622 ; fully-qualified # 😢 E0.6 crying face +1F62D ; fully-qualified # 😭 E0.6 loudly crying face +1F631 ; fully-qualified # 😱 E0.6 face screaming in fear +1F616 ; fully-qualified # 😖 E0.6 confounded face +1F623 ; fully-qualified # 😣 E0.6 persevering face +1F61E ; fully-qualified # 😞 E0.6 disappointed face +1F613 ; fully-qualified # 😓 E0.6 downcast face with sweat +1F629 ; fully-qualified # 😩 E0.6 weary face +1F62B ; fully-qualified # 😫 E0.6 tired face +1F971 ; fully-qualified # 🥱 E12.0 yawning face + +# subgroup: face-negative +1F624 ; fully-qualified # 😤 E0.6 face with steam from nose +1F621 ; fully-qualified # 😡 E0.6 pouting face +1F620 ; fully-qualified # 😠 E0.6 angry face +1F92C ; fully-qualified # 🤬 E5.0 face with symbols on mouth +1F608 ; fully-qualified # 😈 E1.0 smiling face with horns +1F47F ; fully-qualified # 👿 E0.6 angry face with horns +1F480 ; fully-qualified # 💀 E0.6 skull +2620 FE0F ; fully-qualified # ☠️ E1.0 skull and crossbones +2620 ; unqualified # ☠ E1.0 skull and crossbones + +# subgroup: face-costume +1F4A9 ; fully-qualified # 💩 E0.6 pile of poo +1F921 ; fully-qualified # 🤡 E3.0 clown face +1F479 ; fully-qualified # 👹 E0.6 ogre +1F47A ; fully-qualified # 👺 E0.6 goblin +1F47B ; fully-qualified # 👻 E0.6 ghost +1F47D ; fully-qualified # 👽 E0.6 alien +1F47E ; fully-qualified # 👾 E0.6 alien monster +1F916 ; fully-qualified # 🤖 E1.0 robot + +# subgroup: cat-face +1F63A ; fully-qualified # 😺 E0.6 grinning cat +1F638 ; fully-qualified # 😸 E0.6 grinning cat with smiling eyes +1F639 ; fully-qualified # 😹 E0.6 cat with tears of joy +1F63B ; fully-qualified # 😻 E0.6 smiling cat with heart-eyes +1F63C ; fully-qualified # 😼 E0.6 cat with wry smile +1F63D ; fully-qualified # 😽 E0.6 kissing cat +1F640 ; fully-qualified # 🙀 E0.6 weary cat +1F63F ; fully-qualified # 😿 E0.6 crying cat +1F63E ; fully-qualified # 😾 E0.6 pouting cat + +# subgroup: monkey-face +1F648 ; fully-qualified # 🙈 E0.6 see-no-evil monkey +1F649 ; fully-qualified # 🙉 E0.6 hear-no-evil monkey +1F64A ; fully-qualified # 🙊 E0.6 speak-no-evil monkey + +# subgroup: emotion +1F48B ; fully-qualified # 💋 E0.6 kiss mark +1F48C ; fully-qualified # 💌 E0.6 love letter +1F498 ; fully-qualified # 💘 E0.6 heart with arrow +1F49D ; fully-qualified # 💝 E0.6 heart with ribbon +1F496 ; fully-qualified # 💖 E0.6 sparkling heart +1F497 ; fully-qualified # 💗 E0.6 growing heart +1F493 ; fully-qualified # 💓 E0.6 beating heart +1F49E ; fully-qualified # 💞 E0.6 revolving hearts +1F495 ; fully-qualified # 💕 E0.6 two hearts +1F49F ; fully-qualified # 💟 E0.6 heart decoration +2763 FE0F ; fully-qualified # ❣️ E1.0 heart exclamation +2763 ; unqualified # ❣ E1.0 heart exclamation +1F494 ; fully-qualified # 💔 E0.6 broken heart +2764 FE0F 200D 1F525 ; fully-qualified # ❤️‍🔥 E13.1 heart on fire +2764 200D 1F525 ; unqualified # ❤‍🔥 E13.1 heart on fire +2764 FE0F 200D 1FA79 ; fully-qualified # ❤️‍🩹 E13.1 mending heart +2764 200D 1FA79 ; unqualified # ❤‍🩹 E13.1 mending heart +2764 FE0F ; fully-qualified # ❤️ E0.6 red heart +2764 ; unqualified # ❤ E0.6 red heart +1F9E1 ; fully-qualified # 🧡 E5.0 orange heart +1F49B ; fully-qualified # 💛 E0.6 yellow heart +1F49A ; fully-qualified # 💚 E0.6 green heart +1F499 ; fully-qualified # 💙 E0.6 blue heart +1F49C ; fully-qualified # 💜 E0.6 purple heart +1F90E ; fully-qualified # 🤎 E12.0 brown heart +1F5A4 ; fully-qualified # 🖤 E3.0 black heart +1F90D ; fully-qualified # 🤍 E12.0 white heart +1F4AF ; fully-qualified # 💯 E0.6 hundred points +1F4A2 ; fully-qualified # 💢 E0.6 anger symbol +1F4A5 ; fully-qualified # 💥 E0.6 collision +1F4AB ; fully-qualified # 💫 E0.6 dizzy +1F4A6 ; fully-qualified # 💦 E0.6 sweat droplets +1F4A8 ; fully-qualified # 💨 E0.6 dashing away +1F573 FE0F ; fully-qualified # 🕳️ E0.7 hole +1F573 ; unqualified # 🕳 E0.7 hole +1F4A3 ; fully-qualified # 💣 E0.6 bomb +1F4AC ; fully-qualified # 💬 E0.6 speech balloon +1F441 FE0F 200D 1F5E8 FE0F ; fully-qualified # 👁️‍🗨️ E2.0 eye in speech bubble +1F441 200D 1F5E8 FE0F ; unqualified # 👁‍🗨️ E2.0 eye in speech bubble +1F441 FE0F 200D 1F5E8 ; unqualified # 👁️‍🗨 E2.0 eye in speech bubble +1F441 200D 1F5E8 ; unqualified # 👁‍🗨 E2.0 eye in speech bubble +1F5E8 FE0F ; fully-qualified # 🗨️ E2.0 left speech bubble +1F5E8 ; unqualified # 🗨 E2.0 left speech bubble +1F5EF FE0F ; fully-qualified # 🗯️ E0.7 right anger bubble +1F5EF ; unqualified # 🗯 E0.7 right anger bubble +1F4AD ; fully-qualified # 💭 E1.0 thought balloon +1F4A4 ; fully-qualified # 💤 E0.6 zzz + +# Smileys & Emotion subtotal: 170 +# Smileys & Emotion subtotal: 170 w/o modifiers + +# group: People & Body + +# subgroup: hand-fingers-open +1F44B ; fully-qualified # 👋 E0.6 waving hand +1F44B 1F3FB ; fully-qualified # 👋🏻 E1.0 waving hand: light skin tone +1F44B 1F3FC ; fully-qualified # 👋🏼 E1.0 waving hand: medium-light skin tone +1F44B 1F3FD ; fully-qualified # 👋🏽 E1.0 waving hand: medium skin tone +1F44B 1F3FE ; fully-qualified # 👋🏾 E1.0 waving hand: medium-dark skin tone +1F44B 1F3FF ; fully-qualified # 👋🏿 E1.0 waving hand: dark skin tone +1F91A ; fully-qualified # 🤚 E3.0 raised back of hand +1F91A 1F3FB ; fully-qualified # 🤚🏻 E3.0 raised back of hand: light skin tone +1F91A 1F3FC ; fully-qualified # 🤚🏼 E3.0 raised back of hand: medium-light skin tone +1F91A 1F3FD ; fully-qualified # 🤚🏽 E3.0 raised back of hand: medium skin tone +1F91A 1F3FE ; fully-qualified # 🤚🏾 E3.0 raised back of hand: medium-dark skin tone +1F91A 1F3FF ; fully-qualified # 🤚🏿 E3.0 raised back of hand: dark skin tone +1F590 FE0F ; fully-qualified # 🖐️ E0.7 hand with fingers splayed +1F590 ; unqualified # 🖐 E0.7 hand with fingers splayed +1F590 1F3FB ; fully-qualified # 🖐🏻 E1.0 hand with fingers splayed: light skin tone +1F590 1F3FC ; fully-qualified # 🖐🏼 E1.0 hand with fingers splayed: medium-light skin tone +1F590 1F3FD ; fully-qualified # 🖐🏽 E1.0 hand with fingers splayed: medium skin tone +1F590 1F3FE ; fully-qualified # 🖐🏾 E1.0 hand with fingers splayed: medium-dark skin tone +1F590 1F3FF ; fully-qualified # 🖐🏿 E1.0 hand with fingers splayed: dark skin tone +270B ; fully-qualified # ✋ E0.6 raised hand +270B 1F3FB ; fully-qualified # ✋🏻 E1.0 raised hand: light skin tone +270B 1F3FC ; fully-qualified # ✋🏼 E1.0 raised hand: medium-light skin tone +270B 1F3FD ; fully-qualified # ✋🏽 E1.0 raised hand: medium skin tone +270B 1F3FE ; fully-qualified # ✋🏾 E1.0 raised hand: medium-dark skin tone +270B 1F3FF ; fully-qualified # ✋🏿 E1.0 raised hand: dark skin tone +1F596 ; fully-qualified # 🖖 E1.0 vulcan salute +1F596 1F3FB ; fully-qualified # 🖖🏻 E1.0 vulcan salute: light skin tone +1F596 1F3FC ; fully-qualified # 🖖🏼 E1.0 vulcan salute: medium-light skin tone +1F596 1F3FD ; fully-qualified # 🖖🏽 E1.0 vulcan salute: medium skin tone +1F596 1F3FE ; fully-qualified # 🖖🏾 E1.0 vulcan salute: medium-dark skin tone +1F596 1F3FF ; fully-qualified # 🖖🏿 E1.0 vulcan salute: dark skin tone + +# subgroup: hand-fingers-partial +1F44C ; fully-qualified # 👌 E0.6 OK hand +1F44C 1F3FB ; fully-qualified # 👌🏻 E1.0 OK hand: light skin tone +1F44C 1F3FC ; fully-qualified # 👌🏼 E1.0 OK hand: medium-light skin tone +1F44C 1F3FD ; fully-qualified # 👌🏽 E1.0 OK hand: medium skin tone +1F44C 1F3FE ; fully-qualified # 👌🏾 E1.0 OK hand: medium-dark skin tone +1F44C 1F3FF ; fully-qualified # 👌🏿 E1.0 OK hand: dark skin tone +1F90C ; fully-qualified # 🤌 E13.0 pinched fingers +1F90C 1F3FB ; fully-qualified # 🤌🏻 E13.0 pinched fingers: light skin tone +1F90C 1F3FC ; fully-qualified # 🤌🏼 E13.0 pinched fingers: medium-light skin tone +1F90C 1F3FD ; fully-qualified # 🤌🏽 E13.0 pinched fingers: medium skin tone +1F90C 1F3FE ; fully-qualified # 🤌🏾 E13.0 pinched fingers: medium-dark skin tone +1F90C 1F3FF ; fully-qualified # 🤌🏿 E13.0 pinched fingers: dark skin tone +1F90F ; fully-qualified # 🤏 E12.0 pinching hand +1F90F 1F3FB ; fully-qualified # 🤏🏻 E12.0 pinching hand: light skin tone +1F90F 1F3FC ; fully-qualified # 🤏🏼 E12.0 pinching hand: medium-light skin tone +1F90F 1F3FD ; fully-qualified # 🤏🏽 E12.0 pinching hand: medium skin tone +1F90F 1F3FE ; fully-qualified # 🤏🏾 E12.0 pinching hand: medium-dark skin tone +1F90F 1F3FF ; fully-qualified # 🤏🏿 E12.0 pinching hand: dark skin tone +270C FE0F ; fully-qualified # ✌️ E0.6 victory hand +270C ; unqualified # ✌ E0.6 victory hand +270C 1F3FB ; fully-qualified # ✌🏻 E1.0 victory hand: light skin tone +270C 1F3FC ; fully-qualified # ✌🏼 E1.0 victory hand: medium-light skin tone +270C 1F3FD ; fully-qualified # ✌🏽 E1.0 victory hand: medium skin tone +270C 1F3FE ; fully-qualified # ✌🏾 E1.0 victory hand: medium-dark skin tone +270C 1F3FF ; fully-qualified # ✌🏿 E1.0 victory hand: dark skin tone +1F91E ; fully-qualified # 🤞 E3.0 crossed fingers +1F91E 1F3FB ; fully-qualified # 🤞🏻 E3.0 crossed fingers: light skin tone +1F91E 1F3FC ; fully-qualified # 🤞🏼 E3.0 crossed fingers: medium-light skin tone +1F91E 1F3FD ; fully-qualified # 🤞🏽 E3.0 crossed fingers: medium skin tone +1F91E 1F3FE ; fully-qualified # 🤞🏾 E3.0 crossed fingers: medium-dark skin tone +1F91E 1F3FF ; fully-qualified # 🤞🏿 E3.0 crossed fingers: dark skin tone +1F91F ; fully-qualified # 🤟 E5.0 love-you gesture +1F91F 1F3FB ; fully-qualified # 🤟🏻 E5.0 love-you gesture: light skin tone +1F91F 1F3FC ; fully-qualified # 🤟🏼 E5.0 love-you gesture: medium-light skin tone +1F91F 1F3FD ; fully-qualified # 🤟🏽 E5.0 love-you gesture: medium skin tone +1F91F 1F3FE ; fully-qualified # 🤟🏾 E5.0 love-you gesture: medium-dark skin tone +1F91F 1F3FF ; fully-qualified # 🤟🏿 E5.0 love-you gesture: dark skin tone +1F918 ; fully-qualified # 🤘 E1.0 sign of the horns +1F918 1F3FB ; fully-qualified # 🤘🏻 E1.0 sign of the horns: light skin tone +1F918 1F3FC ; fully-qualified # 🤘🏼 E1.0 sign of the horns: medium-light skin tone +1F918 1F3FD ; fully-qualified # 🤘🏽 E1.0 sign of the horns: medium skin tone +1F918 1F3FE ; fully-qualified # 🤘🏾 E1.0 sign of the horns: medium-dark skin tone +1F918 1F3FF ; fully-qualified # 🤘🏿 E1.0 sign of the horns: dark skin tone +1F919 ; fully-qualified # 🤙 E3.0 call me hand +1F919 1F3FB ; fully-qualified # 🤙🏻 E3.0 call me hand: light skin tone +1F919 1F3FC ; fully-qualified # 🤙🏼 E3.0 call me hand: medium-light skin tone +1F919 1F3FD ; fully-qualified # 🤙🏽 E3.0 call me hand: medium skin tone +1F919 1F3FE ; fully-qualified # 🤙🏾 E3.0 call me hand: medium-dark skin tone +1F919 1F3FF ; fully-qualified # 🤙🏿 E3.0 call me hand: dark skin tone + +# subgroup: hand-single-finger +1F448 ; fully-qualified # 👈 E0.6 backhand index pointing left +1F448 1F3FB ; fully-qualified # 👈🏻 E1.0 backhand index pointing left: light skin tone +1F448 1F3FC ; fully-qualified # 👈🏼 E1.0 backhand index pointing left: medium-light skin tone +1F448 1F3FD ; fully-qualified # 👈🏽 E1.0 backhand index pointing left: medium skin tone +1F448 1F3FE ; fully-qualified # 👈🏾 E1.0 backhand index pointing left: medium-dark skin tone +1F448 1F3FF ; fully-qualified # 👈🏿 E1.0 backhand index pointing left: dark skin tone +1F449 ; fully-qualified # 👉 E0.6 backhand index pointing right +1F449 1F3FB ; fully-qualified # 👉🏻 E1.0 backhand index pointing right: light skin tone +1F449 1F3FC ; fully-qualified # 👉🏼 E1.0 backhand index pointing right: medium-light skin tone +1F449 1F3FD ; fully-qualified # 👉🏽 E1.0 backhand index pointing right: medium skin tone +1F449 1F3FE ; fully-qualified # 👉🏾 E1.0 backhand index pointing right: medium-dark skin tone +1F449 1F3FF ; fully-qualified # 👉🏿 E1.0 backhand index pointing right: dark skin tone +1F446 ; fully-qualified # 👆 E0.6 backhand index pointing up +1F446 1F3FB ; fully-qualified # 👆🏻 E1.0 backhand index pointing up: light skin tone +1F446 1F3FC ; fully-qualified # 👆🏼 E1.0 backhand index pointing up: medium-light skin tone +1F446 1F3FD ; fully-qualified # 👆🏽 E1.0 backhand index pointing up: medium skin tone +1F446 1F3FE ; fully-qualified # 👆🏾 E1.0 backhand index pointing up: medium-dark skin tone +1F446 1F3FF ; fully-qualified # 👆🏿 E1.0 backhand index pointing up: dark skin tone +1F595 ; fully-qualified # 🖕 E1.0 middle finger +1F595 1F3FB ; fully-qualified # 🖕🏻 E1.0 middle finger: light skin tone +1F595 1F3FC ; fully-qualified # 🖕🏼 E1.0 middle finger: medium-light skin tone +1F595 1F3FD ; fully-qualified # 🖕🏽 E1.0 middle finger: medium skin tone +1F595 1F3FE ; fully-qualified # 🖕🏾 E1.0 middle finger: medium-dark skin tone +1F595 1F3FF ; fully-qualified # 🖕🏿 E1.0 middle finger: dark skin tone +1F447 ; fully-qualified # 👇 E0.6 backhand index pointing down +1F447 1F3FB ; fully-qualified # 👇🏻 E1.0 backhand index pointing down: light skin tone +1F447 1F3FC ; fully-qualified # 👇🏼 E1.0 backhand index pointing down: medium-light skin tone +1F447 1F3FD ; fully-qualified # 👇🏽 E1.0 backhand index pointing down: medium skin tone +1F447 1F3FE ; fully-qualified # 👇🏾 E1.0 backhand index pointing down: medium-dark skin tone +1F447 1F3FF ; fully-qualified # 👇🏿 E1.0 backhand index pointing down: dark skin tone +261D FE0F ; fully-qualified # ☝️ E0.6 index pointing up +261D ; unqualified # ☝ E0.6 index pointing up +261D 1F3FB ; fully-qualified # ☝🏻 E1.0 index pointing up: light skin tone +261D 1F3FC ; fully-qualified # ☝🏼 E1.0 index pointing up: medium-light skin tone +261D 1F3FD ; fully-qualified # ☝🏽 E1.0 index pointing up: medium skin tone +261D 1F3FE ; fully-qualified # ☝🏾 E1.0 index pointing up: medium-dark skin tone +261D 1F3FF ; fully-qualified # ☝🏿 E1.0 index pointing up: dark skin tone + +# subgroup: hand-fingers-closed +1F44D ; fully-qualified # 👍 E0.6 thumbs up +1F44D 1F3FB ; fully-qualified # 👍🏻 E1.0 thumbs up: light skin tone +1F44D 1F3FC ; fully-qualified # 👍🏼 E1.0 thumbs up: medium-light skin tone +1F44D 1F3FD ; fully-qualified # 👍🏽 E1.0 thumbs up: medium skin tone +1F44D 1F3FE ; fully-qualified # 👍🏾 E1.0 thumbs up: medium-dark skin tone +1F44D 1F3FF ; fully-qualified # 👍🏿 E1.0 thumbs up: dark skin tone +1F44E ; fully-qualified # 👎 E0.6 thumbs down +1F44E 1F3FB ; fully-qualified # 👎🏻 E1.0 thumbs down: light skin tone +1F44E 1F3FC ; fully-qualified # 👎🏼 E1.0 thumbs down: medium-light skin tone +1F44E 1F3FD ; fully-qualified # 👎🏽 E1.0 thumbs down: medium skin tone +1F44E 1F3FE ; fully-qualified # 👎🏾 E1.0 thumbs down: medium-dark skin tone +1F44E 1F3FF ; fully-qualified # 👎🏿 E1.0 thumbs down: dark skin tone +270A ; fully-qualified # ✊ E0.6 raised fist +270A 1F3FB ; fully-qualified # ✊🏻 E1.0 raised fist: light skin tone +270A 1F3FC ; fully-qualified # ✊🏼 E1.0 raised fist: medium-light skin tone +270A 1F3FD ; fully-qualified # ✊🏽 E1.0 raised fist: medium skin tone +270A 1F3FE ; fully-qualified # ✊🏾 E1.0 raised fist: medium-dark skin tone +270A 1F3FF ; fully-qualified # ✊🏿 E1.0 raised fist: dark skin tone +1F44A ; fully-qualified # 👊 E0.6 oncoming fist +1F44A 1F3FB ; fully-qualified # 👊🏻 E1.0 oncoming fist: light skin tone +1F44A 1F3FC ; fully-qualified # 👊🏼 E1.0 oncoming fist: medium-light skin tone +1F44A 1F3FD ; fully-qualified # 👊🏽 E1.0 oncoming fist: medium skin tone +1F44A 1F3FE ; fully-qualified # 👊🏾 E1.0 oncoming fist: medium-dark skin tone +1F44A 1F3FF ; fully-qualified # 👊🏿 E1.0 oncoming fist: dark skin tone +1F91B ; fully-qualified # 🤛 E3.0 left-facing fist +1F91B 1F3FB ; fully-qualified # 🤛🏻 E3.0 left-facing fist: light skin tone +1F91B 1F3FC ; fully-qualified # 🤛🏼 E3.0 left-facing fist: medium-light skin tone +1F91B 1F3FD ; fully-qualified # 🤛🏽 E3.0 left-facing fist: medium skin tone +1F91B 1F3FE ; fully-qualified # 🤛🏾 E3.0 left-facing fist: medium-dark skin tone +1F91B 1F3FF ; fully-qualified # 🤛🏿 E3.0 left-facing fist: dark skin tone +1F91C ; fully-qualified # 🤜 E3.0 right-facing fist +1F91C 1F3FB ; fully-qualified # 🤜🏻 E3.0 right-facing fist: light skin tone +1F91C 1F3FC ; fully-qualified # 🤜🏼 E3.0 right-facing fist: medium-light skin tone +1F91C 1F3FD ; fully-qualified # 🤜🏽 E3.0 right-facing fist: medium skin tone +1F91C 1F3FE ; fully-qualified # 🤜🏾 E3.0 right-facing fist: medium-dark skin tone +1F91C 1F3FF ; fully-qualified # 🤜🏿 E3.0 right-facing fist: dark skin tone + +# subgroup: hands +1F44F ; fully-qualified # 👏 E0.6 clapping hands +1F44F 1F3FB ; fully-qualified # 👏🏻 E1.0 clapping hands: light skin tone +1F44F 1F3FC ; fully-qualified # 👏🏼 E1.0 clapping hands: medium-light skin tone +1F44F 1F3FD ; fully-qualified # 👏🏽 E1.0 clapping hands: medium skin tone +1F44F 1F3FE ; fully-qualified # 👏🏾 E1.0 clapping hands: medium-dark skin tone +1F44F 1F3FF ; fully-qualified # 👏🏿 E1.0 clapping hands: dark skin tone +1F64C ; fully-qualified # 🙌 E0.6 raising hands +1F64C 1F3FB ; fully-qualified # 🙌🏻 E1.0 raising hands: light skin tone +1F64C 1F3FC ; fully-qualified # 🙌🏼 E1.0 raising hands: medium-light skin tone +1F64C 1F3FD ; fully-qualified # 🙌🏽 E1.0 raising hands: medium skin tone +1F64C 1F3FE ; fully-qualified # 🙌🏾 E1.0 raising hands: medium-dark skin tone +1F64C 1F3FF ; fully-qualified # 🙌🏿 E1.0 raising hands: dark skin tone +1F450 ; fully-qualified # 👐 E0.6 open hands +1F450 1F3FB ; fully-qualified # 👐🏻 E1.0 open hands: light skin tone +1F450 1F3FC ; fully-qualified # 👐🏼 E1.0 open hands: medium-light skin tone +1F450 1F3FD ; fully-qualified # 👐🏽 E1.0 open hands: medium skin tone +1F450 1F3FE ; fully-qualified # 👐🏾 E1.0 open hands: medium-dark skin tone +1F450 1F3FF ; fully-qualified # 👐🏿 E1.0 open hands: dark skin tone +1F932 ; fully-qualified # 🤲 E5.0 palms up together +1F932 1F3FB ; fully-qualified # 🤲🏻 E5.0 palms up together: light skin tone +1F932 1F3FC ; fully-qualified # 🤲🏼 E5.0 palms up together: medium-light skin tone +1F932 1F3FD ; fully-qualified # 🤲🏽 E5.0 palms up together: medium skin tone +1F932 1F3FE ; fully-qualified # 🤲🏾 E5.0 palms up together: medium-dark skin tone +1F932 1F3FF ; fully-qualified # 🤲🏿 E5.0 palms up together: dark skin tone +1F91D ; fully-qualified # 🤝 E3.0 handshake +1F64F ; fully-qualified # 🙏 E0.6 folded hands +1F64F 1F3FB ; fully-qualified # 🙏🏻 E1.0 folded hands: light skin tone +1F64F 1F3FC ; fully-qualified # 🙏🏼 E1.0 folded hands: medium-light skin tone +1F64F 1F3FD ; fully-qualified # 🙏🏽 E1.0 folded hands: medium skin tone +1F64F 1F3FE ; fully-qualified # 🙏🏾 E1.0 folded hands: medium-dark skin tone +1F64F 1F3FF ; fully-qualified # 🙏🏿 E1.0 folded hands: dark skin tone + +# subgroup: hand-prop +270D FE0F ; fully-qualified # ✍️ E0.7 writing hand +270D ; unqualified # ✍ E0.7 writing hand +270D 1F3FB ; fully-qualified # ✍🏻 E1.0 writing hand: light skin tone +270D 1F3FC ; fully-qualified # ✍🏼 E1.0 writing hand: medium-light skin tone +270D 1F3FD ; fully-qualified # ✍🏽 E1.0 writing hand: medium skin tone +270D 1F3FE ; fully-qualified # ✍🏾 E1.0 writing hand: medium-dark skin tone +270D 1F3FF ; fully-qualified # ✍🏿 E1.0 writing hand: dark skin tone +1F485 ; fully-qualified # 💅 E0.6 nail polish +1F485 1F3FB ; fully-qualified # 💅🏻 E1.0 nail polish: light skin tone +1F485 1F3FC ; fully-qualified # 💅🏼 E1.0 nail polish: medium-light skin tone +1F485 1F3FD ; fully-qualified # 💅🏽 E1.0 nail polish: medium skin tone +1F485 1F3FE ; fully-qualified # 💅🏾 E1.0 nail polish: medium-dark skin tone +1F485 1F3FF ; fully-qualified # 💅🏿 E1.0 nail polish: dark skin tone +1F933 ; fully-qualified # 🤳 E3.0 selfie +1F933 1F3FB ; fully-qualified # 🤳🏻 E3.0 selfie: light skin tone +1F933 1F3FC ; fully-qualified # 🤳🏼 E3.0 selfie: medium-light skin tone +1F933 1F3FD ; fully-qualified # 🤳🏽 E3.0 selfie: medium skin tone +1F933 1F3FE ; fully-qualified # 🤳🏾 E3.0 selfie: medium-dark skin tone +1F933 1F3FF ; fully-qualified # 🤳🏿 E3.0 selfie: dark skin tone + +# subgroup: body-parts +1F4AA ; fully-qualified # 💪 E0.6 flexed biceps +1F4AA 1F3FB ; fully-qualified # 💪🏻 E1.0 flexed biceps: light skin tone +1F4AA 1F3FC ; fully-qualified # 💪🏼 E1.0 flexed biceps: medium-light skin tone +1F4AA 1F3FD ; fully-qualified # 💪🏽 E1.0 flexed biceps: medium skin tone +1F4AA 1F3FE ; fully-qualified # 💪🏾 E1.0 flexed biceps: medium-dark skin tone +1F4AA 1F3FF ; fully-qualified # 💪🏿 E1.0 flexed biceps: dark skin tone +1F9BE ; fully-qualified # 🦾 E12.0 mechanical arm +1F9BF ; fully-qualified # 🦿 E12.0 mechanical leg +1F9B5 ; fully-qualified # 🦵 E11.0 leg +1F9B5 1F3FB ; fully-qualified # 🦵🏻 E11.0 leg: light skin tone +1F9B5 1F3FC ; fully-qualified # 🦵🏼 E11.0 leg: medium-light skin tone +1F9B5 1F3FD ; fully-qualified # 🦵🏽 E11.0 leg: medium skin tone +1F9B5 1F3FE ; fully-qualified # 🦵🏾 E11.0 leg: medium-dark skin tone +1F9B5 1F3FF ; fully-qualified # 🦵🏿 E11.0 leg: dark skin tone +1F9B6 ; fully-qualified # 🦶 E11.0 foot +1F9B6 1F3FB ; fully-qualified # 🦶🏻 E11.0 foot: light skin tone +1F9B6 1F3FC ; fully-qualified # 🦶🏼 E11.0 foot: medium-light skin tone +1F9B6 1F3FD ; fully-qualified # 🦶🏽 E11.0 foot: medium skin tone +1F9B6 1F3FE ; fully-qualified # 🦶🏾 E11.0 foot: medium-dark skin tone +1F9B6 1F3FF ; fully-qualified # 🦶🏿 E11.0 foot: dark skin tone +1F442 ; fully-qualified # 👂 E0.6 ear +1F442 1F3FB ; fully-qualified # 👂🏻 E1.0 ear: light skin tone +1F442 1F3FC ; fully-qualified # 👂🏼 E1.0 ear: medium-light skin tone +1F442 1F3FD ; fully-qualified # 👂🏽 E1.0 ear: medium skin tone +1F442 1F3FE ; fully-qualified # 👂🏾 E1.0 ear: medium-dark skin tone +1F442 1F3FF ; fully-qualified # 👂🏿 E1.0 ear: dark skin tone +1F9BB ; fully-qualified # 🦻 E12.0 ear with hearing aid +1F9BB 1F3FB ; fully-qualified # 🦻🏻 E12.0 ear with hearing aid: light skin tone +1F9BB 1F3FC ; fully-qualified # 🦻🏼 E12.0 ear with hearing aid: medium-light skin tone +1F9BB 1F3FD ; fully-qualified # 🦻🏽 E12.0 ear with hearing aid: medium skin tone +1F9BB 1F3FE ; fully-qualified # 🦻🏾 E12.0 ear with hearing aid: medium-dark skin tone +1F9BB 1F3FF ; fully-qualified # 🦻🏿 E12.0 ear with hearing aid: dark skin tone +1F443 ; fully-qualified # 👃 E0.6 nose +1F443 1F3FB ; fully-qualified # 👃🏻 E1.0 nose: light skin tone +1F443 1F3FC ; fully-qualified # 👃🏼 E1.0 nose: medium-light skin tone +1F443 1F3FD ; fully-qualified # 👃🏽 E1.0 nose: medium skin tone +1F443 1F3FE ; fully-qualified # 👃🏾 E1.0 nose: medium-dark skin tone +1F443 1F3FF ; fully-qualified # 👃🏿 E1.0 nose: dark skin tone +1F9E0 ; fully-qualified # 🧠 E5.0 brain +1FAC0 ; fully-qualified # 🫀 E13.0 anatomical heart +1FAC1 ; fully-qualified # 🫁 E13.0 lungs +1F9B7 ; fully-qualified # 🦷 E11.0 tooth +1F9B4 ; fully-qualified # 🦴 E11.0 bone +1F440 ; fully-qualified # 👀 E0.6 eyes +1F441 FE0F ; fully-qualified # 👁️ E0.7 eye +1F441 ; unqualified # 👁 E0.7 eye +1F445 ; fully-qualified # 👅 E0.6 tongue +1F444 ; fully-qualified # 👄 E0.6 mouth + +# subgroup: person +1F476 ; fully-qualified # 👶 E0.6 baby +1F476 1F3FB ; fully-qualified # 👶🏻 E1.0 baby: light skin tone +1F476 1F3FC ; fully-qualified # 👶🏼 E1.0 baby: medium-light skin tone +1F476 1F3FD ; fully-qualified # 👶🏽 E1.0 baby: medium skin tone +1F476 1F3FE ; fully-qualified # 👶🏾 E1.0 baby: medium-dark skin tone +1F476 1F3FF ; fully-qualified # 👶🏿 E1.0 baby: dark skin tone +1F9D2 ; fully-qualified # 🧒 E5.0 child +1F9D2 1F3FB ; fully-qualified # 🧒🏻 E5.0 child: light skin tone +1F9D2 1F3FC ; fully-qualified # 🧒🏼 E5.0 child: medium-light skin tone +1F9D2 1F3FD ; fully-qualified # 🧒🏽 E5.0 child: medium skin tone +1F9D2 1F3FE ; fully-qualified # 🧒🏾 E5.0 child: medium-dark skin tone +1F9D2 1F3FF ; fully-qualified # 🧒🏿 E5.0 child: dark skin tone +1F466 ; fully-qualified # 👦 E0.6 boy +1F466 1F3FB ; fully-qualified # 👦🏻 E1.0 boy: light skin tone +1F466 1F3FC ; fully-qualified # 👦🏼 E1.0 boy: medium-light skin tone +1F466 1F3FD ; fully-qualified # 👦🏽 E1.0 boy: medium skin tone +1F466 1F3FE ; fully-qualified # 👦🏾 E1.0 boy: medium-dark skin tone +1F466 1F3FF ; fully-qualified # 👦🏿 E1.0 boy: dark skin tone +1F467 ; fully-qualified # 👧 E0.6 girl +1F467 1F3FB ; fully-qualified # 👧🏻 E1.0 girl: light skin tone +1F467 1F3FC ; fully-qualified # 👧🏼 E1.0 girl: medium-light skin tone +1F467 1F3FD ; fully-qualified # 👧🏽 E1.0 girl: medium skin tone +1F467 1F3FE ; fully-qualified # 👧🏾 E1.0 girl: medium-dark skin tone +1F467 1F3FF ; fully-qualified # 👧🏿 E1.0 girl: dark skin tone +1F9D1 ; fully-qualified # 🧑 E5.0 person +1F9D1 1F3FB ; fully-qualified # 🧑🏻 E5.0 person: light skin tone +1F9D1 1F3FC ; fully-qualified # 🧑🏼 E5.0 person: medium-light skin tone +1F9D1 1F3FD ; fully-qualified # 🧑🏽 E5.0 person: medium skin tone +1F9D1 1F3FE ; fully-qualified # 🧑🏾 E5.0 person: medium-dark skin tone +1F9D1 1F3FF ; fully-qualified # 🧑🏿 E5.0 person: dark skin tone +1F471 ; fully-qualified # 👱 E0.6 person: blond hair +1F471 1F3FB ; fully-qualified # 👱🏻 E1.0 person: light skin tone, blond hair +1F471 1F3FC ; fully-qualified # 👱🏼 E1.0 person: medium-light skin tone, blond hair +1F471 1F3FD ; fully-qualified # 👱🏽 E1.0 person: medium skin tone, blond hair +1F471 1F3FE ; fully-qualified # 👱🏾 E1.0 person: medium-dark skin tone, blond hair +1F471 1F3FF ; fully-qualified # 👱🏿 E1.0 person: dark skin tone, blond hair +1F468 ; fully-qualified # 👨 E0.6 man +1F468 1F3FB ; fully-qualified # 👨🏻 E1.0 man: light skin tone +1F468 1F3FC ; fully-qualified # 👨🏼 E1.0 man: medium-light skin tone +1F468 1F3FD ; fully-qualified # 👨🏽 E1.0 man: medium skin tone +1F468 1F3FE ; fully-qualified # 👨🏾 E1.0 man: medium-dark skin tone +1F468 1F3FF ; fully-qualified # 👨🏿 E1.0 man: dark skin tone +1F9D4 ; fully-qualified # 🧔 E5.0 person: beard +1F9D4 1F3FB ; fully-qualified # 🧔🏻 E5.0 person: light skin tone, beard +1F9D4 1F3FC ; fully-qualified # 🧔🏼 E5.0 person: medium-light skin tone, beard +1F9D4 1F3FD ; fully-qualified # 🧔🏽 E5.0 person: medium skin tone, beard +1F9D4 1F3FE ; fully-qualified # 🧔🏾 E5.0 person: medium-dark skin tone, beard +1F9D4 1F3FF ; fully-qualified # 🧔🏿 E5.0 person: dark skin tone, beard +1F9D4 200D 2642 FE0F ; fully-qualified # 🧔‍♂️ E13.1 man: beard +1F9D4 200D 2642 ; minimally-qualified # 🧔‍♂ E13.1 man: beard +1F9D4 1F3FB 200D 2642 FE0F ; fully-qualified # 🧔🏻‍♂️ E13.1 man: light skin tone, beard +1F9D4 1F3FB 200D 2642 ; minimally-qualified # 🧔🏻‍♂ E13.1 man: light skin tone, beard +1F9D4 1F3FC 200D 2642 FE0F ; fully-qualified # 🧔🏼‍♂️ E13.1 man: medium-light skin tone, beard +1F9D4 1F3FC 200D 2642 ; minimally-qualified # 🧔🏼‍♂ E13.1 man: medium-light skin tone, beard +1F9D4 1F3FD 200D 2642 FE0F ; fully-qualified # 🧔🏽‍♂️ E13.1 man: medium skin tone, beard +1F9D4 1F3FD 200D 2642 ; minimally-qualified # 🧔🏽‍♂ E13.1 man: medium skin tone, beard +1F9D4 1F3FE 200D 2642 FE0F ; fully-qualified # 🧔🏾‍♂️ E13.1 man: medium-dark skin tone, beard +1F9D4 1F3FE 200D 2642 ; minimally-qualified # 🧔🏾‍♂ E13.1 man: medium-dark skin tone, beard +1F9D4 1F3FF 200D 2642 FE0F ; fully-qualified # 🧔🏿‍♂️ E13.1 man: dark skin tone, beard +1F9D4 1F3FF 200D 2642 ; minimally-qualified # 🧔🏿‍♂ E13.1 man: dark skin tone, beard +1F9D4 200D 2640 FE0F ; fully-qualified # 🧔‍♀️ E13.1 woman: beard +1F9D4 200D 2640 ; minimally-qualified # 🧔‍♀ E13.1 woman: beard +1F9D4 1F3FB 200D 2640 FE0F ; fully-qualified # 🧔🏻‍♀️ E13.1 woman: light skin tone, beard +1F9D4 1F3FB 200D 2640 ; minimally-qualified # 🧔🏻‍♀ E13.1 woman: light skin tone, beard +1F9D4 1F3FC 200D 2640 FE0F ; fully-qualified # 🧔🏼‍♀️ E13.1 woman: medium-light skin tone, beard +1F9D4 1F3FC 200D 2640 ; minimally-qualified # 🧔🏼‍♀ E13.1 woman: medium-light skin tone, beard +1F9D4 1F3FD 200D 2640 FE0F ; fully-qualified # 🧔🏽‍♀️ E13.1 woman: medium skin tone, beard +1F9D4 1F3FD 200D 2640 ; minimally-qualified # 🧔🏽‍♀ E13.1 woman: medium skin tone, beard +1F9D4 1F3FE 200D 2640 FE0F ; fully-qualified # 🧔🏾‍♀️ E13.1 woman: medium-dark skin tone, beard +1F9D4 1F3FE 200D 2640 ; minimally-qualified # 🧔🏾‍♀ E13.1 woman: medium-dark skin tone, beard +1F9D4 1F3FF 200D 2640 FE0F ; fully-qualified # 🧔🏿‍♀️ E13.1 woman: dark skin tone, beard +1F9D4 1F3FF 200D 2640 ; minimally-qualified # 🧔🏿‍♀ E13.1 woman: dark skin tone, beard +1F468 200D 1F9B0 ; fully-qualified # 👨‍🦰 E11.0 man: red hair +1F468 1F3FB 200D 1F9B0 ; fully-qualified # 👨🏻‍🦰 E11.0 man: light skin tone, red hair +1F468 1F3FC 200D 1F9B0 ; fully-qualified # 👨🏼‍🦰 E11.0 man: medium-light skin tone, red hair +1F468 1F3FD 200D 1F9B0 ; fully-qualified # 👨🏽‍🦰 E11.0 man: medium skin tone, red hair +1F468 1F3FE 200D 1F9B0 ; fully-qualified # 👨🏾‍🦰 E11.0 man: medium-dark skin tone, red hair +1F468 1F3FF 200D 1F9B0 ; fully-qualified # 👨🏿‍🦰 E11.0 man: dark skin tone, red hair +1F468 200D 1F9B1 ; fully-qualified # 👨‍🦱 E11.0 man: curly hair +1F468 1F3FB 200D 1F9B1 ; fully-qualified # 👨🏻‍🦱 E11.0 man: light skin tone, curly hair +1F468 1F3FC 200D 1F9B1 ; fully-qualified # 👨🏼‍🦱 E11.0 man: medium-light skin tone, curly hair +1F468 1F3FD 200D 1F9B1 ; fully-qualified # 👨🏽‍🦱 E11.0 man: medium skin tone, curly hair +1F468 1F3FE 200D 1F9B1 ; fully-qualified # 👨🏾‍🦱 E11.0 man: medium-dark skin tone, curly hair +1F468 1F3FF 200D 1F9B1 ; fully-qualified # 👨🏿‍🦱 E11.0 man: dark skin tone, curly hair +1F468 200D 1F9B3 ; fully-qualified # 👨‍🦳 E11.0 man: white hair +1F468 1F3FB 200D 1F9B3 ; fully-qualified # 👨🏻‍🦳 E11.0 man: light skin tone, white hair +1F468 1F3FC 200D 1F9B3 ; fully-qualified # 👨🏼‍🦳 E11.0 man: medium-light skin tone, white hair +1F468 1F3FD 200D 1F9B3 ; fully-qualified # 👨🏽‍🦳 E11.0 man: medium skin tone, white hair +1F468 1F3FE 200D 1F9B3 ; fully-qualified # 👨🏾‍🦳 E11.0 man: medium-dark skin tone, white hair +1F468 1F3FF 200D 1F9B3 ; fully-qualified # 👨🏿‍🦳 E11.0 man: dark skin tone, white hair +1F468 200D 1F9B2 ; fully-qualified # 👨‍🦲 E11.0 man: bald +1F468 1F3FB 200D 1F9B2 ; fully-qualified # 👨🏻‍🦲 E11.0 man: light skin tone, bald +1F468 1F3FC 200D 1F9B2 ; fully-qualified # 👨🏼‍🦲 E11.0 man: medium-light skin tone, bald +1F468 1F3FD 200D 1F9B2 ; fully-qualified # 👨🏽‍🦲 E11.0 man: medium skin tone, bald +1F468 1F3FE 200D 1F9B2 ; fully-qualified # 👨🏾‍🦲 E11.0 man: medium-dark skin tone, bald +1F468 1F3FF 200D 1F9B2 ; fully-qualified # 👨🏿‍🦲 E11.0 man: dark skin tone, bald +1F469 ; fully-qualified # 👩 E0.6 woman +1F469 1F3FB ; fully-qualified # 👩🏻 E1.0 woman: light skin tone +1F469 1F3FC ; fully-qualified # 👩🏼 E1.0 woman: medium-light skin tone +1F469 1F3FD ; fully-qualified # 👩🏽 E1.0 woman: medium skin tone +1F469 1F3FE ; fully-qualified # 👩🏾 E1.0 woman: medium-dark skin tone +1F469 1F3FF ; fully-qualified # 👩🏿 E1.0 woman: dark skin tone +1F469 200D 1F9B0 ; fully-qualified # 👩‍🦰 E11.0 woman: red hair +1F469 1F3FB 200D 1F9B0 ; fully-qualified # 👩🏻‍🦰 E11.0 woman: light skin tone, red hair +1F469 1F3FC 200D 1F9B0 ; fully-qualified # 👩🏼‍🦰 E11.0 woman: medium-light skin tone, red hair +1F469 1F3FD 200D 1F9B0 ; fully-qualified # 👩🏽‍🦰 E11.0 woman: medium skin tone, red hair +1F469 1F3FE 200D 1F9B0 ; fully-qualified # 👩🏾‍🦰 E11.0 woman: medium-dark skin tone, red hair +1F469 1F3FF 200D 1F9B0 ; fully-qualified # 👩🏿‍🦰 E11.0 woman: dark skin tone, red hair +1F9D1 200D 1F9B0 ; fully-qualified # 🧑‍🦰 E12.1 person: red hair +1F9D1 1F3FB 200D 1F9B0 ; fully-qualified # 🧑🏻‍🦰 E12.1 person: light skin tone, red hair +1F9D1 1F3FC 200D 1F9B0 ; fully-qualified # 🧑🏼‍🦰 E12.1 person: medium-light skin tone, red hair +1F9D1 1F3FD 200D 1F9B0 ; fully-qualified # 🧑🏽‍🦰 E12.1 person: medium skin tone, red hair +1F9D1 1F3FE 200D 1F9B0 ; fully-qualified # 🧑🏾‍🦰 E12.1 person: medium-dark skin tone, red hair +1F9D1 1F3FF 200D 1F9B0 ; fully-qualified # 🧑🏿‍🦰 E12.1 person: dark skin tone, red hair +1F469 200D 1F9B1 ; fully-qualified # 👩‍🦱 E11.0 woman: curly hair +1F469 1F3FB 200D 1F9B1 ; fully-qualified # 👩🏻‍🦱 E11.0 woman: light skin tone, curly hair +1F469 1F3FC 200D 1F9B1 ; fully-qualified # 👩🏼‍🦱 E11.0 woman: medium-light skin tone, curly hair +1F469 1F3FD 200D 1F9B1 ; fully-qualified # 👩🏽‍🦱 E11.0 woman: medium skin tone, curly hair +1F469 1F3FE 200D 1F9B1 ; fully-qualified # 👩🏾‍🦱 E11.0 woman: medium-dark skin tone, curly hair +1F469 1F3FF 200D 1F9B1 ; fully-qualified # 👩🏿‍🦱 E11.0 woman: dark skin tone, curly hair +1F9D1 200D 1F9B1 ; fully-qualified # 🧑‍🦱 E12.1 person: curly hair +1F9D1 1F3FB 200D 1F9B1 ; fully-qualified # 🧑🏻‍🦱 E12.1 person: light skin tone, curly hair +1F9D1 1F3FC 200D 1F9B1 ; fully-qualified # 🧑🏼‍🦱 E12.1 person: medium-light skin tone, curly hair +1F9D1 1F3FD 200D 1F9B1 ; fully-qualified # 🧑🏽‍🦱 E12.1 person: medium skin tone, curly hair +1F9D1 1F3FE 200D 1F9B1 ; fully-qualified # 🧑🏾‍🦱 E12.1 person: medium-dark skin tone, curly hair +1F9D1 1F3FF 200D 1F9B1 ; fully-qualified # 🧑🏿‍🦱 E12.1 person: dark skin tone, curly hair +1F469 200D 1F9B3 ; fully-qualified # 👩‍🦳 E11.0 woman: white hair +1F469 1F3FB 200D 1F9B3 ; fully-qualified # 👩🏻‍🦳 E11.0 woman: light skin tone, white hair +1F469 1F3FC 200D 1F9B3 ; fully-qualified # 👩🏼‍🦳 E11.0 woman: medium-light skin tone, white hair +1F469 1F3FD 200D 1F9B3 ; fully-qualified # 👩🏽‍🦳 E11.0 woman: medium skin tone, white hair +1F469 1F3FE 200D 1F9B3 ; fully-qualified # 👩🏾‍🦳 E11.0 woman: medium-dark skin tone, white hair +1F469 1F3FF 200D 1F9B3 ; fully-qualified # 👩🏿‍🦳 E11.0 woman: dark skin tone, white hair +1F9D1 200D 1F9B3 ; fully-qualified # 🧑‍🦳 E12.1 person: white hair +1F9D1 1F3FB 200D 1F9B3 ; fully-qualified # 🧑🏻‍🦳 E12.1 person: light skin tone, white hair +1F9D1 1F3FC 200D 1F9B3 ; fully-qualified # 🧑🏼‍🦳 E12.1 person: medium-light skin tone, white hair +1F9D1 1F3FD 200D 1F9B3 ; fully-qualified # 🧑🏽‍🦳 E12.1 person: medium skin tone, white hair +1F9D1 1F3FE 200D 1F9B3 ; fully-qualified # 🧑🏾‍🦳 E12.1 person: medium-dark skin tone, white hair +1F9D1 1F3FF 200D 1F9B3 ; fully-qualified # 🧑🏿‍🦳 E12.1 person: dark skin tone, white hair +1F469 200D 1F9B2 ; fully-qualified # 👩‍🦲 E11.0 woman: bald +1F469 1F3FB 200D 1F9B2 ; fully-qualified # 👩🏻‍🦲 E11.0 woman: light skin tone, bald +1F469 1F3FC 200D 1F9B2 ; fully-qualified # 👩🏼‍🦲 E11.0 woman: medium-light skin tone, bald +1F469 1F3FD 200D 1F9B2 ; fully-qualified # 👩🏽‍🦲 E11.0 woman: medium skin tone, bald +1F469 1F3FE 200D 1F9B2 ; fully-qualified # 👩🏾‍🦲 E11.0 woman: medium-dark skin tone, bald +1F469 1F3FF 200D 1F9B2 ; fully-qualified # 👩🏿‍🦲 E11.0 woman: dark skin tone, bald +1F9D1 200D 1F9B2 ; fully-qualified # 🧑‍🦲 E12.1 person: bald +1F9D1 1F3FB 200D 1F9B2 ; fully-qualified # 🧑🏻‍🦲 E12.1 person: light skin tone, bald +1F9D1 1F3FC 200D 1F9B2 ; fully-qualified # 🧑🏼‍🦲 E12.1 person: medium-light skin tone, bald +1F9D1 1F3FD 200D 1F9B2 ; fully-qualified # 🧑🏽‍🦲 E12.1 person: medium skin tone, bald +1F9D1 1F3FE 200D 1F9B2 ; fully-qualified # 🧑🏾‍🦲 E12.1 person: medium-dark skin tone, bald +1F9D1 1F3FF 200D 1F9B2 ; fully-qualified # 🧑🏿‍🦲 E12.1 person: dark skin tone, bald +1F471 200D 2640 FE0F ; fully-qualified # 👱‍♀️ E4.0 woman: blond hair +1F471 200D 2640 ; minimally-qualified # 👱‍♀ E4.0 woman: blond hair +1F471 1F3FB 200D 2640 FE0F ; fully-qualified # 👱🏻‍♀️ E4.0 woman: light skin tone, blond hair +1F471 1F3FB 200D 2640 ; minimally-qualified # 👱🏻‍♀ E4.0 woman: light skin tone, blond hair +1F471 1F3FC 200D 2640 FE0F ; fully-qualified # 👱🏼‍♀️ E4.0 woman: medium-light skin tone, blond hair +1F471 1F3FC 200D 2640 ; minimally-qualified # 👱🏼‍♀ E4.0 woman: medium-light skin tone, blond hair +1F471 1F3FD 200D 2640 FE0F ; fully-qualified # 👱🏽‍♀️ E4.0 woman: medium skin tone, blond hair +1F471 1F3FD 200D 2640 ; minimally-qualified # 👱🏽‍♀ E4.0 woman: medium skin tone, blond hair +1F471 1F3FE 200D 2640 FE0F ; fully-qualified # 👱🏾‍♀️ E4.0 woman: medium-dark skin tone, blond hair +1F471 1F3FE 200D 2640 ; minimally-qualified # 👱🏾‍♀ E4.0 woman: medium-dark skin tone, blond hair +1F471 1F3FF 200D 2640 FE0F ; fully-qualified # 👱🏿‍♀️ E4.0 woman: dark skin tone, blond hair +1F471 1F3FF 200D 2640 ; minimally-qualified # 👱🏿‍♀ E4.0 woman: dark skin tone, blond hair +1F471 200D 2642 FE0F ; fully-qualified # 👱‍♂️ E4.0 man: blond hair +1F471 200D 2642 ; minimally-qualified # 👱‍♂ E4.0 man: blond hair +1F471 1F3FB 200D 2642 FE0F ; fully-qualified # 👱🏻‍♂️ E4.0 man: light skin tone, blond hair +1F471 1F3FB 200D 2642 ; minimally-qualified # 👱🏻‍♂ E4.0 man: light skin tone, blond hair +1F471 1F3FC 200D 2642 FE0F ; fully-qualified # 👱🏼‍♂️ E4.0 man: medium-light skin tone, blond hair +1F471 1F3FC 200D 2642 ; minimally-qualified # 👱🏼‍♂ E4.0 man: medium-light skin tone, blond hair +1F471 1F3FD 200D 2642 FE0F ; fully-qualified # 👱🏽‍♂️ E4.0 man: medium skin tone, blond hair +1F471 1F3FD 200D 2642 ; minimally-qualified # 👱🏽‍♂ E4.0 man: medium skin tone, blond hair +1F471 1F3FE 200D 2642 FE0F ; fully-qualified # 👱🏾‍♂️ E4.0 man: medium-dark skin tone, blond hair +1F471 1F3FE 200D 2642 ; minimally-qualified # 👱🏾‍♂ E4.0 man: medium-dark skin tone, blond hair +1F471 1F3FF 200D 2642 FE0F ; fully-qualified # 👱🏿‍♂️ E4.0 man: dark skin tone, blond hair +1F471 1F3FF 200D 2642 ; minimally-qualified # 👱🏿‍♂ E4.0 man: dark skin tone, blond hair +1F9D3 ; fully-qualified # 🧓 E5.0 older person +1F9D3 1F3FB ; fully-qualified # 🧓🏻 E5.0 older person: light skin tone +1F9D3 1F3FC ; fully-qualified # 🧓🏼 E5.0 older person: medium-light skin tone +1F9D3 1F3FD ; fully-qualified # 🧓🏽 E5.0 older person: medium skin tone +1F9D3 1F3FE ; fully-qualified # 🧓🏾 E5.0 older person: medium-dark skin tone +1F9D3 1F3FF ; fully-qualified # 🧓🏿 E5.0 older person: dark skin tone +1F474 ; fully-qualified # 👴 E0.6 old man +1F474 1F3FB ; fully-qualified # 👴🏻 E1.0 old man: light skin tone +1F474 1F3FC ; fully-qualified # 👴🏼 E1.0 old man: medium-light skin tone +1F474 1F3FD ; fully-qualified # 👴🏽 E1.0 old man: medium skin tone +1F474 1F3FE ; fully-qualified # 👴🏾 E1.0 old man: medium-dark skin tone +1F474 1F3FF ; fully-qualified # 👴🏿 E1.0 old man: dark skin tone +1F475 ; fully-qualified # 👵 E0.6 old woman +1F475 1F3FB ; fully-qualified # 👵🏻 E1.0 old woman: light skin tone +1F475 1F3FC ; fully-qualified # 👵🏼 E1.0 old woman: medium-light skin tone +1F475 1F3FD ; fully-qualified # 👵🏽 E1.0 old woman: medium skin tone +1F475 1F3FE ; fully-qualified # 👵🏾 E1.0 old woman: medium-dark skin tone +1F475 1F3FF ; fully-qualified # 👵🏿 E1.0 old woman: dark skin tone + +# subgroup: person-gesture +1F64D ; fully-qualified # 🙍 E0.6 person frowning +1F64D 1F3FB ; fully-qualified # 🙍🏻 E1.0 person frowning: light skin tone +1F64D 1F3FC ; fully-qualified # 🙍🏼 E1.0 person frowning: medium-light skin tone +1F64D 1F3FD ; fully-qualified # 🙍🏽 E1.0 person frowning: medium skin tone +1F64D 1F3FE ; fully-qualified # 🙍🏾 E1.0 person frowning: medium-dark skin tone +1F64D 1F3FF ; fully-qualified # 🙍🏿 E1.0 person frowning: dark skin tone +1F64D 200D 2642 FE0F ; fully-qualified # 🙍‍♂️ E4.0 man frowning +1F64D 200D 2642 ; minimally-qualified # 🙍‍♂ E4.0 man frowning +1F64D 1F3FB 200D 2642 FE0F ; fully-qualified # 🙍🏻‍♂️ E4.0 man frowning: light skin tone +1F64D 1F3FB 200D 2642 ; minimally-qualified # 🙍🏻‍♂ E4.0 man frowning: light skin tone +1F64D 1F3FC 200D 2642 FE0F ; fully-qualified # 🙍🏼‍♂️ E4.0 man frowning: medium-light skin tone +1F64D 1F3FC 200D 2642 ; minimally-qualified # 🙍🏼‍♂ E4.0 man frowning: medium-light skin tone +1F64D 1F3FD 200D 2642 FE0F ; fully-qualified # 🙍🏽‍♂️ E4.0 man frowning: medium skin tone +1F64D 1F3FD 200D 2642 ; minimally-qualified # 🙍🏽‍♂ E4.0 man frowning: medium skin tone +1F64D 1F3FE 200D 2642 FE0F ; fully-qualified # 🙍🏾‍♂️ E4.0 man frowning: medium-dark skin tone +1F64D 1F3FE 200D 2642 ; minimally-qualified # 🙍🏾‍♂ E4.0 man frowning: medium-dark skin tone +1F64D 1F3FF 200D 2642 FE0F ; fully-qualified # 🙍🏿‍♂️ E4.0 man frowning: dark skin tone +1F64D 1F3FF 200D 2642 ; minimally-qualified # 🙍🏿‍♂ E4.0 man frowning: dark skin tone +1F64D 200D 2640 FE0F ; fully-qualified # 🙍‍♀️ E4.0 woman frowning +1F64D 200D 2640 ; minimally-qualified # 🙍‍♀ E4.0 woman frowning +1F64D 1F3FB 200D 2640 FE0F ; fully-qualified # 🙍🏻‍♀️ E4.0 woman frowning: light skin tone +1F64D 1F3FB 200D 2640 ; minimally-qualified # 🙍🏻‍♀ E4.0 woman frowning: light skin tone +1F64D 1F3FC 200D 2640 FE0F ; fully-qualified # 🙍🏼‍♀️ E4.0 woman frowning: medium-light skin tone +1F64D 1F3FC 200D 2640 ; minimally-qualified # 🙍🏼‍♀ E4.0 woman frowning: medium-light skin tone +1F64D 1F3FD 200D 2640 FE0F ; fully-qualified # 🙍🏽‍♀️ E4.0 woman frowning: medium skin tone +1F64D 1F3FD 200D 2640 ; minimally-qualified # 🙍🏽‍♀ E4.0 woman frowning: medium skin tone +1F64D 1F3FE 200D 2640 FE0F ; fully-qualified # 🙍🏾‍♀️ E4.0 woman frowning: medium-dark skin tone +1F64D 1F3FE 200D 2640 ; minimally-qualified # 🙍🏾‍♀ E4.0 woman frowning: medium-dark skin tone +1F64D 1F3FF 200D 2640 FE0F ; fully-qualified # 🙍🏿‍♀️ E4.0 woman frowning: dark skin tone +1F64D 1F3FF 200D 2640 ; minimally-qualified # 🙍🏿‍♀ E4.0 woman frowning: dark skin tone +1F64E ; fully-qualified # 🙎 E0.6 person pouting +1F64E 1F3FB ; fully-qualified # 🙎🏻 E1.0 person pouting: light skin tone +1F64E 1F3FC ; fully-qualified # 🙎🏼 E1.0 person pouting: medium-light skin tone +1F64E 1F3FD ; fully-qualified # 🙎🏽 E1.0 person pouting: medium skin tone +1F64E 1F3FE ; fully-qualified # 🙎🏾 E1.0 person pouting: medium-dark skin tone +1F64E 1F3FF ; fully-qualified # 🙎🏿 E1.0 person pouting: dark skin tone +1F64E 200D 2642 FE0F ; fully-qualified # 🙎‍♂️ E4.0 man pouting +1F64E 200D 2642 ; minimally-qualified # 🙎‍♂ E4.0 man pouting +1F64E 1F3FB 200D 2642 FE0F ; fully-qualified # 🙎🏻‍♂️ E4.0 man pouting: light skin tone +1F64E 1F3FB 200D 2642 ; minimally-qualified # 🙎🏻‍♂ E4.0 man pouting: light skin tone +1F64E 1F3FC 200D 2642 FE0F ; fully-qualified # 🙎🏼‍♂️ E4.0 man pouting: medium-light skin tone +1F64E 1F3FC 200D 2642 ; minimally-qualified # 🙎🏼‍♂ E4.0 man pouting: medium-light skin tone +1F64E 1F3FD 200D 2642 FE0F ; fully-qualified # 🙎🏽‍♂️ E4.0 man pouting: medium skin tone +1F64E 1F3FD 200D 2642 ; minimally-qualified # 🙎🏽‍♂ E4.0 man pouting: medium skin tone +1F64E 1F3FE 200D 2642 FE0F ; fully-qualified # 🙎🏾‍♂️ E4.0 man pouting: medium-dark skin tone +1F64E 1F3FE 200D 2642 ; minimally-qualified # 🙎🏾‍♂ E4.0 man pouting: medium-dark skin tone +1F64E 1F3FF 200D 2642 FE0F ; fully-qualified # 🙎🏿‍♂️ E4.0 man pouting: dark skin tone +1F64E 1F3FF 200D 2642 ; minimally-qualified # 🙎🏿‍♂ E4.0 man pouting: dark skin tone +1F64E 200D 2640 FE0F ; fully-qualified # 🙎‍♀️ E4.0 woman pouting +1F64E 200D 2640 ; minimally-qualified # 🙎‍♀ E4.0 woman pouting +1F64E 1F3FB 200D 2640 FE0F ; fully-qualified # 🙎🏻‍♀️ E4.0 woman pouting: light skin tone +1F64E 1F3FB 200D 2640 ; minimally-qualified # 🙎🏻‍♀ E4.0 woman pouting: light skin tone +1F64E 1F3FC 200D 2640 FE0F ; fully-qualified # 🙎🏼‍♀️ E4.0 woman pouting: medium-light skin tone +1F64E 1F3FC 200D 2640 ; minimally-qualified # 🙎🏼‍♀ E4.0 woman pouting: medium-light skin tone +1F64E 1F3FD 200D 2640 FE0F ; fully-qualified # 🙎🏽‍♀️ E4.0 woman pouting: medium skin tone +1F64E 1F3FD 200D 2640 ; minimally-qualified # 🙎🏽‍♀ E4.0 woman pouting: medium skin tone +1F64E 1F3FE 200D 2640 FE0F ; fully-qualified # 🙎🏾‍♀️ E4.0 woman pouting: medium-dark skin tone +1F64E 1F3FE 200D 2640 ; minimally-qualified # 🙎🏾‍♀ E4.0 woman pouting: medium-dark skin tone +1F64E 1F3FF 200D 2640 FE0F ; fully-qualified # 🙎🏿‍♀️ E4.0 woman pouting: dark skin tone +1F64E 1F3FF 200D 2640 ; minimally-qualified # 🙎🏿‍♀ E4.0 woman pouting: dark skin tone +1F645 ; fully-qualified # 🙅 E0.6 person gesturing NO +1F645 1F3FB ; fully-qualified # 🙅🏻 E1.0 person gesturing NO: light skin tone +1F645 1F3FC ; fully-qualified # 🙅🏼 E1.0 person gesturing NO: medium-light skin tone +1F645 1F3FD ; fully-qualified # 🙅🏽 E1.0 person gesturing NO: medium skin tone +1F645 1F3FE ; fully-qualified # 🙅🏾 E1.0 person gesturing NO: medium-dark skin tone +1F645 1F3FF ; fully-qualified # 🙅🏿 E1.0 person gesturing NO: dark skin tone +1F645 200D 2642 FE0F ; fully-qualified # 🙅‍♂️ E4.0 man gesturing NO +1F645 200D 2642 ; minimally-qualified # 🙅‍♂ E4.0 man gesturing NO +1F645 1F3FB 200D 2642 FE0F ; fully-qualified # 🙅🏻‍♂️ E4.0 man gesturing NO: light skin tone +1F645 1F3FB 200D 2642 ; minimally-qualified # 🙅🏻‍♂ E4.0 man gesturing NO: light skin tone +1F645 1F3FC 200D 2642 FE0F ; fully-qualified # 🙅🏼‍♂️ E4.0 man gesturing NO: medium-light skin tone +1F645 1F3FC 200D 2642 ; minimally-qualified # 🙅🏼‍♂ E4.0 man gesturing NO: medium-light skin tone +1F645 1F3FD 200D 2642 FE0F ; fully-qualified # 🙅🏽‍♂️ E4.0 man gesturing NO: medium skin tone +1F645 1F3FD 200D 2642 ; minimally-qualified # 🙅🏽‍♂ E4.0 man gesturing NO: medium skin tone +1F645 1F3FE 200D 2642 FE0F ; fully-qualified # 🙅🏾‍♂️ E4.0 man gesturing NO: medium-dark skin tone +1F645 1F3FE 200D 2642 ; minimally-qualified # 🙅🏾‍♂ E4.0 man gesturing NO: medium-dark skin tone +1F645 1F3FF 200D 2642 FE0F ; fully-qualified # 🙅🏿‍♂️ E4.0 man gesturing NO: dark skin tone +1F645 1F3FF 200D 2642 ; minimally-qualified # 🙅🏿‍♂ E4.0 man gesturing NO: dark skin tone +1F645 200D 2640 FE0F ; fully-qualified # 🙅‍♀️ E4.0 woman gesturing NO +1F645 200D 2640 ; minimally-qualified # 🙅‍♀ E4.0 woman gesturing NO +1F645 1F3FB 200D 2640 FE0F ; fully-qualified # 🙅🏻‍♀️ E4.0 woman gesturing NO: light skin tone +1F645 1F3FB 200D 2640 ; minimally-qualified # 🙅🏻‍♀ E4.0 woman gesturing NO: light skin tone +1F645 1F3FC 200D 2640 FE0F ; fully-qualified # 🙅🏼‍♀️ E4.0 woman gesturing NO: medium-light skin tone +1F645 1F3FC 200D 2640 ; minimally-qualified # 🙅🏼‍♀ E4.0 woman gesturing NO: medium-light skin tone +1F645 1F3FD 200D 2640 FE0F ; fully-qualified # 🙅🏽‍♀️ E4.0 woman gesturing NO: medium skin tone +1F645 1F3FD 200D 2640 ; minimally-qualified # 🙅🏽‍♀ E4.0 woman gesturing NO: medium skin tone +1F645 1F3FE 200D 2640 FE0F ; fully-qualified # 🙅🏾‍♀️ E4.0 woman gesturing NO: medium-dark skin tone +1F645 1F3FE 200D 2640 ; minimally-qualified # 🙅🏾‍♀ E4.0 woman gesturing NO: medium-dark skin tone +1F645 1F3FF 200D 2640 FE0F ; fully-qualified # 🙅🏿‍♀️ E4.0 woman gesturing NO: dark skin tone +1F645 1F3FF 200D 2640 ; minimally-qualified # 🙅🏿‍♀ E4.0 woman gesturing NO: dark skin tone +1F646 ; fully-qualified # 🙆 E0.6 person gesturing OK +1F646 1F3FB ; fully-qualified # 🙆🏻 E1.0 person gesturing OK: light skin tone +1F646 1F3FC ; fully-qualified # 🙆🏼 E1.0 person gesturing OK: medium-light skin tone +1F646 1F3FD ; fully-qualified # 🙆🏽 E1.0 person gesturing OK: medium skin tone +1F646 1F3FE ; fully-qualified # 🙆🏾 E1.0 person gesturing OK: medium-dark skin tone +1F646 1F3FF ; fully-qualified # 🙆🏿 E1.0 person gesturing OK: dark skin tone +1F646 200D 2642 FE0F ; fully-qualified # 🙆‍♂️ E4.0 man gesturing OK +1F646 200D 2642 ; minimally-qualified # 🙆‍♂ E4.0 man gesturing OK +1F646 1F3FB 200D 2642 FE0F ; fully-qualified # 🙆🏻‍♂️ E4.0 man gesturing OK: light skin tone +1F646 1F3FB 200D 2642 ; minimally-qualified # 🙆🏻‍♂ E4.0 man gesturing OK: light skin tone +1F646 1F3FC 200D 2642 FE0F ; fully-qualified # 🙆🏼‍♂️ E4.0 man gesturing OK: medium-light skin tone +1F646 1F3FC 200D 2642 ; minimally-qualified # 🙆🏼‍♂ E4.0 man gesturing OK: medium-light skin tone +1F646 1F3FD 200D 2642 FE0F ; fully-qualified # 🙆🏽‍♂️ E4.0 man gesturing OK: medium skin tone +1F646 1F3FD 200D 2642 ; minimally-qualified # 🙆🏽‍♂ E4.0 man gesturing OK: medium skin tone +1F646 1F3FE 200D 2642 FE0F ; fully-qualified # 🙆🏾‍♂️ E4.0 man gesturing OK: medium-dark skin tone +1F646 1F3FE 200D 2642 ; minimally-qualified # 🙆🏾‍♂ E4.0 man gesturing OK: medium-dark skin tone +1F646 1F3FF 200D 2642 FE0F ; fully-qualified # 🙆🏿‍♂️ E4.0 man gesturing OK: dark skin tone +1F646 1F3FF 200D 2642 ; minimally-qualified # 🙆🏿‍♂ E4.0 man gesturing OK: dark skin tone +1F646 200D 2640 FE0F ; fully-qualified # 🙆‍♀️ E4.0 woman gesturing OK +1F646 200D 2640 ; minimally-qualified # 🙆‍♀ E4.0 woman gesturing OK +1F646 1F3FB 200D 2640 FE0F ; fully-qualified # 🙆🏻‍♀️ E4.0 woman gesturing OK: light skin tone +1F646 1F3FB 200D 2640 ; minimally-qualified # 🙆🏻‍♀ E4.0 woman gesturing OK: light skin tone +1F646 1F3FC 200D 2640 FE0F ; fully-qualified # 🙆🏼‍♀️ E4.0 woman gesturing OK: medium-light skin tone +1F646 1F3FC 200D 2640 ; minimally-qualified # 🙆🏼‍♀ E4.0 woman gesturing OK: medium-light skin tone +1F646 1F3FD 200D 2640 FE0F ; fully-qualified # 🙆🏽‍♀️ E4.0 woman gesturing OK: medium skin tone +1F646 1F3FD 200D 2640 ; minimally-qualified # 🙆🏽‍♀ E4.0 woman gesturing OK: medium skin tone +1F646 1F3FE 200D 2640 FE0F ; fully-qualified # 🙆🏾‍♀️ E4.0 woman gesturing OK: medium-dark skin tone +1F646 1F3FE 200D 2640 ; minimally-qualified # 🙆🏾‍♀ E4.0 woman gesturing OK: medium-dark skin tone +1F646 1F3FF 200D 2640 FE0F ; fully-qualified # 🙆🏿‍♀️ E4.0 woman gesturing OK: dark skin tone +1F646 1F3FF 200D 2640 ; minimally-qualified # 🙆🏿‍♀ E4.0 woman gesturing OK: dark skin tone +1F481 ; fully-qualified # 💁 E0.6 person tipping hand +1F481 1F3FB ; fully-qualified # 💁🏻 E1.0 person tipping hand: light skin tone +1F481 1F3FC ; fully-qualified # 💁🏼 E1.0 person tipping hand: medium-light skin tone +1F481 1F3FD ; fully-qualified # 💁🏽 E1.0 person tipping hand: medium skin tone +1F481 1F3FE ; fully-qualified # 💁🏾 E1.0 person tipping hand: medium-dark skin tone +1F481 1F3FF ; fully-qualified # 💁🏿 E1.0 person tipping hand: dark skin tone +1F481 200D 2642 FE0F ; fully-qualified # 💁‍♂️ E4.0 man tipping hand +1F481 200D 2642 ; minimally-qualified # 💁‍♂ E4.0 man tipping hand +1F481 1F3FB 200D 2642 FE0F ; fully-qualified # 💁🏻‍♂️ E4.0 man tipping hand: light skin tone +1F481 1F3FB 200D 2642 ; minimally-qualified # 💁🏻‍♂ E4.0 man tipping hand: light skin tone +1F481 1F3FC 200D 2642 FE0F ; fully-qualified # 💁🏼‍♂️ E4.0 man tipping hand: medium-light skin tone +1F481 1F3FC 200D 2642 ; minimally-qualified # 💁🏼‍♂ E4.0 man tipping hand: medium-light skin tone +1F481 1F3FD 200D 2642 FE0F ; fully-qualified # 💁🏽‍♂️ E4.0 man tipping hand: medium skin tone +1F481 1F3FD 200D 2642 ; minimally-qualified # 💁🏽‍♂ E4.0 man tipping hand: medium skin tone +1F481 1F3FE 200D 2642 FE0F ; fully-qualified # 💁🏾‍♂️ E4.0 man tipping hand: medium-dark skin tone +1F481 1F3FE 200D 2642 ; minimally-qualified # 💁🏾‍♂ E4.0 man tipping hand: medium-dark skin tone +1F481 1F3FF 200D 2642 FE0F ; fully-qualified # 💁🏿‍♂️ E4.0 man tipping hand: dark skin tone +1F481 1F3FF 200D 2642 ; minimally-qualified # 💁🏿‍♂ E4.0 man tipping hand: dark skin tone +1F481 200D 2640 FE0F ; fully-qualified # 💁‍♀️ E4.0 woman tipping hand +1F481 200D 2640 ; minimally-qualified # 💁‍♀ E4.0 woman tipping hand +1F481 1F3FB 200D 2640 FE0F ; fully-qualified # 💁🏻‍♀️ E4.0 woman tipping hand: light skin tone +1F481 1F3FB 200D 2640 ; minimally-qualified # 💁🏻‍♀ E4.0 woman tipping hand: light skin tone +1F481 1F3FC 200D 2640 FE0F ; fully-qualified # 💁🏼‍♀️ E4.0 woman tipping hand: medium-light skin tone +1F481 1F3FC 200D 2640 ; minimally-qualified # 💁🏼‍♀ E4.0 woman tipping hand: medium-light skin tone +1F481 1F3FD 200D 2640 FE0F ; fully-qualified # 💁🏽‍♀️ E4.0 woman tipping hand: medium skin tone +1F481 1F3FD 200D 2640 ; minimally-qualified # 💁🏽‍♀ E4.0 woman tipping hand: medium skin tone +1F481 1F3FE 200D 2640 FE0F ; fully-qualified # 💁🏾‍♀️ E4.0 woman tipping hand: medium-dark skin tone +1F481 1F3FE 200D 2640 ; minimally-qualified # 💁🏾‍♀ E4.0 woman tipping hand: medium-dark skin tone +1F481 1F3FF 200D 2640 FE0F ; fully-qualified # 💁🏿‍♀️ E4.0 woman tipping hand: dark skin tone +1F481 1F3FF 200D 2640 ; minimally-qualified # 💁🏿‍♀ E4.0 woman tipping hand: dark skin tone +1F64B ; fully-qualified # 🙋 E0.6 person raising hand +1F64B 1F3FB ; fully-qualified # 🙋🏻 E1.0 person raising hand: light skin tone +1F64B 1F3FC ; fully-qualified # 🙋🏼 E1.0 person raising hand: medium-light skin tone +1F64B 1F3FD ; fully-qualified # 🙋🏽 E1.0 person raising hand: medium skin tone +1F64B 1F3FE ; fully-qualified # 🙋🏾 E1.0 person raising hand: medium-dark skin tone +1F64B 1F3FF ; fully-qualified # 🙋🏿 E1.0 person raising hand: dark skin tone +1F64B 200D 2642 FE0F ; fully-qualified # 🙋‍♂️ E4.0 man raising hand +1F64B 200D 2642 ; minimally-qualified # 🙋‍♂ E4.0 man raising hand +1F64B 1F3FB 200D 2642 FE0F ; fully-qualified # 🙋🏻‍♂️ E4.0 man raising hand: light skin tone +1F64B 1F3FB 200D 2642 ; minimally-qualified # 🙋🏻‍♂ E4.0 man raising hand: light skin tone +1F64B 1F3FC 200D 2642 FE0F ; fully-qualified # 🙋🏼‍♂️ E4.0 man raising hand: medium-light skin tone +1F64B 1F3FC 200D 2642 ; minimally-qualified # 🙋🏼‍♂ E4.0 man raising hand: medium-light skin tone +1F64B 1F3FD 200D 2642 FE0F ; fully-qualified # 🙋🏽‍♂️ E4.0 man raising hand: medium skin tone +1F64B 1F3FD 200D 2642 ; minimally-qualified # 🙋🏽‍♂ E4.0 man raising hand: medium skin tone +1F64B 1F3FE 200D 2642 FE0F ; fully-qualified # 🙋🏾‍♂️ E4.0 man raising hand: medium-dark skin tone +1F64B 1F3FE 200D 2642 ; minimally-qualified # 🙋🏾‍♂ E4.0 man raising hand: medium-dark skin tone +1F64B 1F3FF 200D 2642 FE0F ; fully-qualified # 🙋🏿‍♂️ E4.0 man raising hand: dark skin tone +1F64B 1F3FF 200D 2642 ; minimally-qualified # 🙋🏿‍♂ E4.0 man raising hand: dark skin tone +1F64B 200D 2640 FE0F ; fully-qualified # 🙋‍♀️ E4.0 woman raising hand +1F64B 200D 2640 ; minimally-qualified # 🙋‍♀ E4.0 woman raising hand +1F64B 1F3FB 200D 2640 FE0F ; fully-qualified # 🙋🏻‍♀️ E4.0 woman raising hand: light skin tone +1F64B 1F3FB 200D 2640 ; minimally-qualified # 🙋🏻‍♀ E4.0 woman raising hand: light skin tone +1F64B 1F3FC 200D 2640 FE0F ; fully-qualified # 🙋🏼‍♀️ E4.0 woman raising hand: medium-light skin tone +1F64B 1F3FC 200D 2640 ; minimally-qualified # 🙋🏼‍♀ E4.0 woman raising hand: medium-light skin tone +1F64B 1F3FD 200D 2640 FE0F ; fully-qualified # 🙋🏽‍♀️ E4.0 woman raising hand: medium skin tone +1F64B 1F3FD 200D 2640 ; minimally-qualified # 🙋🏽‍♀ E4.0 woman raising hand: medium skin tone +1F64B 1F3FE 200D 2640 FE0F ; fully-qualified # 🙋🏾‍♀️ E4.0 woman raising hand: medium-dark skin tone +1F64B 1F3FE 200D 2640 ; minimally-qualified # 🙋🏾‍♀ E4.0 woman raising hand: medium-dark skin tone +1F64B 1F3FF 200D 2640 FE0F ; fully-qualified # 🙋🏿‍♀️ E4.0 woman raising hand: dark skin tone +1F64B 1F3FF 200D 2640 ; minimally-qualified # 🙋🏿‍♀ E4.0 woman raising hand: dark skin tone +1F9CF ; fully-qualified # 🧏 E12.0 deaf person +1F9CF 1F3FB ; fully-qualified # 🧏🏻 E12.0 deaf person: light skin tone +1F9CF 1F3FC ; fully-qualified # 🧏🏼 E12.0 deaf person: medium-light skin tone +1F9CF 1F3FD ; fully-qualified # 🧏🏽 E12.0 deaf person: medium skin tone +1F9CF 1F3FE ; fully-qualified # 🧏🏾 E12.0 deaf person: medium-dark skin tone +1F9CF 1F3FF ; fully-qualified # 🧏🏿 E12.0 deaf person: dark skin tone +1F9CF 200D 2642 FE0F ; fully-qualified # 🧏‍♂️ E12.0 deaf man +1F9CF 200D 2642 ; minimally-qualified # 🧏‍♂ E12.0 deaf man +1F9CF 1F3FB 200D 2642 FE0F ; fully-qualified # 🧏🏻‍♂️ E12.0 deaf man: light skin tone +1F9CF 1F3FB 200D 2642 ; minimally-qualified # 🧏🏻‍♂ E12.0 deaf man: light skin tone +1F9CF 1F3FC 200D 2642 FE0F ; fully-qualified # 🧏🏼‍♂️ E12.0 deaf man: medium-light skin tone +1F9CF 1F3FC 200D 2642 ; minimally-qualified # 🧏🏼‍♂ E12.0 deaf man: medium-light skin tone +1F9CF 1F3FD 200D 2642 FE0F ; fully-qualified # 🧏🏽‍♂️ E12.0 deaf man: medium skin tone +1F9CF 1F3FD 200D 2642 ; minimally-qualified # 🧏🏽‍♂ E12.0 deaf man: medium skin tone +1F9CF 1F3FE 200D 2642 FE0F ; fully-qualified # 🧏🏾‍♂️ E12.0 deaf man: medium-dark skin tone +1F9CF 1F3FE 200D 2642 ; minimally-qualified # 🧏🏾‍♂ E12.0 deaf man: medium-dark skin tone +1F9CF 1F3FF 200D 2642 FE0F ; fully-qualified # 🧏🏿‍♂️ E12.0 deaf man: dark skin tone +1F9CF 1F3FF 200D 2642 ; minimally-qualified # 🧏🏿‍♂ E12.0 deaf man: dark skin tone +1F9CF 200D 2640 FE0F ; fully-qualified # 🧏‍♀️ E12.0 deaf woman +1F9CF 200D 2640 ; minimally-qualified # 🧏‍♀ E12.0 deaf woman +1F9CF 1F3FB 200D 2640 FE0F ; fully-qualified # 🧏🏻‍♀️ E12.0 deaf woman: light skin tone +1F9CF 1F3FB 200D 2640 ; minimally-qualified # 🧏🏻‍♀ E12.0 deaf woman: light skin tone +1F9CF 1F3FC 200D 2640 FE0F ; fully-qualified # 🧏🏼‍♀️ E12.0 deaf woman: medium-light skin tone +1F9CF 1F3FC 200D 2640 ; minimally-qualified # 🧏🏼‍♀ E12.0 deaf woman: medium-light skin tone +1F9CF 1F3FD 200D 2640 FE0F ; fully-qualified # 🧏🏽‍♀️ E12.0 deaf woman: medium skin tone +1F9CF 1F3FD 200D 2640 ; minimally-qualified # 🧏🏽‍♀ E12.0 deaf woman: medium skin tone +1F9CF 1F3FE 200D 2640 FE0F ; fully-qualified # 🧏🏾‍♀️ E12.0 deaf woman: medium-dark skin tone +1F9CF 1F3FE 200D 2640 ; minimally-qualified # 🧏🏾‍♀ E12.0 deaf woman: medium-dark skin tone +1F9CF 1F3FF 200D 2640 FE0F ; fully-qualified # 🧏🏿‍♀️ E12.0 deaf woman: dark skin tone +1F9CF 1F3FF 200D 2640 ; minimally-qualified # 🧏🏿‍♀ E12.0 deaf woman: dark skin tone +1F647 ; fully-qualified # 🙇 E0.6 person bowing +1F647 1F3FB ; fully-qualified # 🙇🏻 E1.0 person bowing: light skin tone +1F647 1F3FC ; fully-qualified # 🙇🏼 E1.0 person bowing: medium-light skin tone +1F647 1F3FD ; fully-qualified # 🙇🏽 E1.0 person bowing: medium skin tone +1F647 1F3FE ; fully-qualified # 🙇🏾 E1.0 person bowing: medium-dark skin tone +1F647 1F3FF ; fully-qualified # 🙇🏿 E1.0 person bowing: dark skin tone +1F647 200D 2642 FE0F ; fully-qualified # 🙇‍♂️ E4.0 man bowing +1F647 200D 2642 ; minimally-qualified # 🙇‍♂ E4.0 man bowing +1F647 1F3FB 200D 2642 FE0F ; fully-qualified # 🙇🏻‍♂️ E4.0 man bowing: light skin tone +1F647 1F3FB 200D 2642 ; minimally-qualified # 🙇🏻‍♂ E4.0 man bowing: light skin tone +1F647 1F3FC 200D 2642 FE0F ; fully-qualified # 🙇🏼‍♂️ E4.0 man bowing: medium-light skin tone +1F647 1F3FC 200D 2642 ; minimally-qualified # 🙇🏼‍♂ E4.0 man bowing: medium-light skin tone +1F647 1F3FD 200D 2642 FE0F ; fully-qualified # 🙇🏽‍♂️ E4.0 man bowing: medium skin tone +1F647 1F3FD 200D 2642 ; minimally-qualified # 🙇🏽‍♂ E4.0 man bowing: medium skin tone +1F647 1F3FE 200D 2642 FE0F ; fully-qualified # 🙇🏾‍♂️ E4.0 man bowing: medium-dark skin tone +1F647 1F3FE 200D 2642 ; minimally-qualified # 🙇🏾‍♂ E4.0 man bowing: medium-dark skin tone +1F647 1F3FF 200D 2642 FE0F ; fully-qualified # 🙇🏿‍♂️ E4.0 man bowing: dark skin tone +1F647 1F3FF 200D 2642 ; minimally-qualified # 🙇🏿‍♂ E4.0 man bowing: dark skin tone +1F647 200D 2640 FE0F ; fully-qualified # 🙇‍♀️ E4.0 woman bowing +1F647 200D 2640 ; minimally-qualified # 🙇‍♀ E4.0 woman bowing +1F647 1F3FB 200D 2640 FE0F ; fully-qualified # 🙇🏻‍♀️ E4.0 woman bowing: light skin tone +1F647 1F3FB 200D 2640 ; minimally-qualified # 🙇🏻‍♀ E4.0 woman bowing: light skin tone +1F647 1F3FC 200D 2640 FE0F ; fully-qualified # 🙇🏼‍♀️ E4.0 woman bowing: medium-light skin tone +1F647 1F3FC 200D 2640 ; minimally-qualified # 🙇🏼‍♀ E4.0 woman bowing: medium-light skin tone +1F647 1F3FD 200D 2640 FE0F ; fully-qualified # 🙇🏽‍♀️ E4.0 woman bowing: medium skin tone +1F647 1F3FD 200D 2640 ; minimally-qualified # 🙇🏽‍♀ E4.0 woman bowing: medium skin tone +1F647 1F3FE 200D 2640 FE0F ; fully-qualified # 🙇🏾‍♀️ E4.0 woman bowing: medium-dark skin tone +1F647 1F3FE 200D 2640 ; minimally-qualified # 🙇🏾‍♀ E4.0 woman bowing: medium-dark skin tone +1F647 1F3FF 200D 2640 FE0F ; fully-qualified # 🙇🏿‍♀️ E4.0 woman bowing: dark skin tone +1F647 1F3FF 200D 2640 ; minimally-qualified # 🙇🏿‍♀ E4.0 woman bowing: dark skin tone +1F926 ; fully-qualified # 🤦 E3.0 person facepalming +1F926 1F3FB ; fully-qualified # 🤦🏻 E3.0 person facepalming: light skin tone +1F926 1F3FC ; fully-qualified # 🤦🏼 E3.0 person facepalming: medium-light skin tone +1F926 1F3FD ; fully-qualified # 🤦🏽 E3.0 person facepalming: medium skin tone +1F926 1F3FE ; fully-qualified # 🤦🏾 E3.0 person facepalming: medium-dark skin tone +1F926 1F3FF ; fully-qualified # 🤦🏿 E3.0 person facepalming: dark skin tone +1F926 200D 2642 FE0F ; fully-qualified # 🤦‍♂️ E4.0 man facepalming +1F926 200D 2642 ; minimally-qualified # 🤦‍♂ E4.0 man facepalming +1F926 1F3FB 200D 2642 FE0F ; fully-qualified # 🤦🏻‍♂️ E4.0 man facepalming: light skin tone +1F926 1F3FB 200D 2642 ; minimally-qualified # 🤦🏻‍♂ E4.0 man facepalming: light skin tone +1F926 1F3FC 200D 2642 FE0F ; fully-qualified # 🤦🏼‍♂️ E4.0 man facepalming: medium-light skin tone +1F926 1F3FC 200D 2642 ; minimally-qualified # 🤦🏼‍♂ E4.0 man facepalming: medium-light skin tone +1F926 1F3FD 200D 2642 FE0F ; fully-qualified # 🤦🏽‍♂️ E4.0 man facepalming: medium skin tone +1F926 1F3FD 200D 2642 ; minimally-qualified # 🤦🏽‍♂ E4.0 man facepalming: medium skin tone +1F926 1F3FE 200D 2642 FE0F ; fully-qualified # 🤦🏾‍♂️ E4.0 man facepalming: medium-dark skin tone +1F926 1F3FE 200D 2642 ; minimally-qualified # 🤦🏾‍♂ E4.0 man facepalming: medium-dark skin tone +1F926 1F3FF 200D 2642 FE0F ; fully-qualified # 🤦🏿‍♂️ E4.0 man facepalming: dark skin tone +1F926 1F3FF 200D 2642 ; minimally-qualified # 🤦🏿‍♂ E4.0 man facepalming: dark skin tone +1F926 200D 2640 FE0F ; fully-qualified # 🤦‍♀️ E4.0 woman facepalming +1F926 200D 2640 ; minimally-qualified # 🤦‍♀ E4.0 woman facepalming +1F926 1F3FB 200D 2640 FE0F ; fully-qualified # 🤦🏻‍♀️ E4.0 woman facepalming: light skin tone +1F926 1F3FB 200D 2640 ; minimally-qualified # 🤦🏻‍♀ E4.0 woman facepalming: light skin tone +1F926 1F3FC 200D 2640 FE0F ; fully-qualified # 🤦🏼‍♀️ E4.0 woman facepalming: medium-light skin tone +1F926 1F3FC 200D 2640 ; minimally-qualified # 🤦🏼‍♀ E4.0 woman facepalming: medium-light skin tone +1F926 1F3FD 200D 2640 FE0F ; fully-qualified # 🤦🏽‍♀️ E4.0 woman facepalming: medium skin tone +1F926 1F3FD 200D 2640 ; minimally-qualified # 🤦🏽‍♀ E4.0 woman facepalming: medium skin tone +1F926 1F3FE 200D 2640 FE0F ; fully-qualified # 🤦🏾‍♀️ E4.0 woman facepalming: medium-dark skin tone +1F926 1F3FE 200D 2640 ; minimally-qualified # 🤦🏾‍♀ E4.0 woman facepalming: medium-dark skin tone +1F926 1F3FF 200D 2640 FE0F ; fully-qualified # 🤦🏿‍♀️ E4.0 woman facepalming: dark skin tone +1F926 1F3FF 200D 2640 ; minimally-qualified # 🤦🏿‍♀ E4.0 woman facepalming: dark skin tone +1F937 ; fully-qualified # 🤷 E3.0 person shrugging +1F937 1F3FB ; fully-qualified # 🤷🏻 E3.0 person shrugging: light skin tone +1F937 1F3FC ; fully-qualified # 🤷🏼 E3.0 person shrugging: medium-light skin tone +1F937 1F3FD ; fully-qualified # 🤷🏽 E3.0 person shrugging: medium skin tone +1F937 1F3FE ; fully-qualified # 🤷🏾 E3.0 person shrugging: medium-dark skin tone +1F937 1F3FF ; fully-qualified # 🤷🏿 E3.0 person shrugging: dark skin tone +1F937 200D 2642 FE0F ; fully-qualified # 🤷‍♂️ E4.0 man shrugging +1F937 200D 2642 ; minimally-qualified # 🤷‍♂ E4.0 man shrugging +1F937 1F3FB 200D 2642 FE0F ; fully-qualified # 🤷🏻‍♂️ E4.0 man shrugging: light skin tone +1F937 1F3FB 200D 2642 ; minimally-qualified # 🤷🏻‍♂ E4.0 man shrugging: light skin tone +1F937 1F3FC 200D 2642 FE0F ; fully-qualified # 🤷🏼‍♂️ E4.0 man shrugging: medium-light skin tone +1F937 1F3FC 200D 2642 ; minimally-qualified # 🤷🏼‍♂ E4.0 man shrugging: medium-light skin tone +1F937 1F3FD 200D 2642 FE0F ; fully-qualified # 🤷🏽‍♂️ E4.0 man shrugging: medium skin tone +1F937 1F3FD 200D 2642 ; minimally-qualified # 🤷🏽‍♂ E4.0 man shrugging: medium skin tone +1F937 1F3FE 200D 2642 FE0F ; fully-qualified # 🤷🏾‍♂️ E4.0 man shrugging: medium-dark skin tone +1F937 1F3FE 200D 2642 ; minimally-qualified # 🤷🏾‍♂ E4.0 man shrugging: medium-dark skin tone +1F937 1F3FF 200D 2642 FE0F ; fully-qualified # 🤷🏿‍♂️ E4.0 man shrugging: dark skin tone +1F937 1F3FF 200D 2642 ; minimally-qualified # 🤷🏿‍♂ E4.0 man shrugging: dark skin tone +1F937 200D 2640 FE0F ; fully-qualified # 🤷‍♀️ E4.0 woman shrugging +1F937 200D 2640 ; minimally-qualified # 🤷‍♀ E4.0 woman shrugging +1F937 1F3FB 200D 2640 FE0F ; fully-qualified # 🤷🏻‍♀️ E4.0 woman shrugging: light skin tone +1F937 1F3FB 200D 2640 ; minimally-qualified # 🤷🏻‍♀ E4.0 woman shrugging: light skin tone +1F937 1F3FC 200D 2640 FE0F ; fully-qualified # 🤷🏼‍♀️ E4.0 woman shrugging: medium-light skin tone +1F937 1F3FC 200D 2640 ; minimally-qualified # 🤷🏼‍♀ E4.0 woman shrugging: medium-light skin tone +1F937 1F3FD 200D 2640 FE0F ; fully-qualified # 🤷🏽‍♀️ E4.0 woman shrugging: medium skin tone +1F937 1F3FD 200D 2640 ; minimally-qualified # 🤷🏽‍♀ E4.0 woman shrugging: medium skin tone +1F937 1F3FE 200D 2640 FE0F ; fully-qualified # 🤷🏾‍♀️ E4.0 woman shrugging: medium-dark skin tone +1F937 1F3FE 200D 2640 ; minimally-qualified # 🤷🏾‍♀ E4.0 woman shrugging: medium-dark skin tone +1F937 1F3FF 200D 2640 FE0F ; fully-qualified # 🤷🏿‍♀️ E4.0 woman shrugging: dark skin tone +1F937 1F3FF 200D 2640 ; minimally-qualified # 🤷🏿‍♀ E4.0 woman shrugging: dark skin tone + +# subgroup: person-role +1F9D1 200D 2695 FE0F ; fully-qualified # 🧑‍⚕️ E12.1 health worker +1F9D1 200D 2695 ; minimally-qualified # 🧑‍⚕ E12.1 health worker +1F9D1 1F3FB 200D 2695 FE0F ; fully-qualified # 🧑🏻‍⚕️ E12.1 health worker: light skin tone +1F9D1 1F3FB 200D 2695 ; minimally-qualified # 🧑🏻‍⚕ E12.1 health worker: light skin tone +1F9D1 1F3FC 200D 2695 FE0F ; fully-qualified # 🧑🏼‍⚕️ E12.1 health worker: medium-light skin tone +1F9D1 1F3FC 200D 2695 ; minimally-qualified # 🧑🏼‍⚕ E12.1 health worker: medium-light skin tone +1F9D1 1F3FD 200D 2695 FE0F ; fully-qualified # 🧑🏽‍⚕️ E12.1 health worker: medium skin tone +1F9D1 1F3FD 200D 2695 ; minimally-qualified # 🧑🏽‍⚕ E12.1 health worker: medium skin tone +1F9D1 1F3FE 200D 2695 FE0F ; fully-qualified # 🧑🏾‍⚕️ E12.1 health worker: medium-dark skin tone +1F9D1 1F3FE 200D 2695 ; minimally-qualified # 🧑🏾‍⚕ E12.1 health worker: medium-dark skin tone +1F9D1 1F3FF 200D 2695 FE0F ; fully-qualified # 🧑🏿‍⚕️ E12.1 health worker: dark skin tone +1F9D1 1F3FF 200D 2695 ; minimally-qualified # 🧑🏿‍⚕ E12.1 health worker: dark skin tone +1F468 200D 2695 FE0F ; fully-qualified # 👨‍⚕️ E4.0 man health worker +1F468 200D 2695 ; minimally-qualified # 👨‍⚕ E4.0 man health worker +1F468 1F3FB 200D 2695 FE0F ; fully-qualified # 👨🏻‍⚕️ E4.0 man health worker: light skin tone +1F468 1F3FB 200D 2695 ; minimally-qualified # 👨🏻‍⚕ E4.0 man health worker: light skin tone +1F468 1F3FC 200D 2695 FE0F ; fully-qualified # 👨🏼‍⚕️ E4.0 man health worker: medium-light skin tone +1F468 1F3FC 200D 2695 ; minimally-qualified # 👨🏼‍⚕ E4.0 man health worker: medium-light skin tone +1F468 1F3FD 200D 2695 FE0F ; fully-qualified # 👨🏽‍⚕️ E4.0 man health worker: medium skin tone +1F468 1F3FD 200D 2695 ; minimally-qualified # 👨🏽‍⚕ E4.0 man health worker: medium skin tone +1F468 1F3FE 200D 2695 FE0F ; fully-qualified # 👨🏾‍⚕️ E4.0 man health worker: medium-dark skin tone +1F468 1F3FE 200D 2695 ; minimally-qualified # 👨🏾‍⚕ E4.0 man health worker: medium-dark skin tone +1F468 1F3FF 200D 2695 FE0F ; fully-qualified # 👨🏿‍⚕️ E4.0 man health worker: dark skin tone +1F468 1F3FF 200D 2695 ; minimally-qualified # 👨🏿‍⚕ E4.0 man health worker: dark skin tone +1F469 200D 2695 FE0F ; fully-qualified # 👩‍⚕️ E4.0 woman health worker +1F469 200D 2695 ; minimally-qualified # 👩‍⚕ E4.0 woman health worker +1F469 1F3FB 200D 2695 FE0F ; fully-qualified # 👩🏻‍⚕️ E4.0 woman health worker: light skin tone +1F469 1F3FB 200D 2695 ; minimally-qualified # 👩🏻‍⚕ E4.0 woman health worker: light skin tone +1F469 1F3FC 200D 2695 FE0F ; fully-qualified # 👩🏼‍⚕️ E4.0 woman health worker: medium-light skin tone +1F469 1F3FC 200D 2695 ; minimally-qualified # 👩🏼‍⚕ E4.0 woman health worker: medium-light skin tone +1F469 1F3FD 200D 2695 FE0F ; fully-qualified # 👩🏽‍⚕️ E4.0 woman health worker: medium skin tone +1F469 1F3FD 200D 2695 ; minimally-qualified # 👩🏽‍⚕ E4.0 woman health worker: medium skin tone +1F469 1F3FE 200D 2695 FE0F ; fully-qualified # 👩🏾‍⚕️ E4.0 woman health worker: medium-dark skin tone +1F469 1F3FE 200D 2695 ; minimally-qualified # 👩🏾‍⚕ E4.0 woman health worker: medium-dark skin tone +1F469 1F3FF 200D 2695 FE0F ; fully-qualified # 👩🏿‍⚕️ E4.0 woman health worker: dark skin tone +1F469 1F3FF 200D 2695 ; minimally-qualified # 👩🏿‍⚕ E4.0 woman health worker: dark skin tone +1F9D1 200D 1F393 ; fully-qualified # 🧑‍🎓 E12.1 student +1F9D1 1F3FB 200D 1F393 ; fully-qualified # 🧑🏻‍🎓 E12.1 student: light skin tone +1F9D1 1F3FC 200D 1F393 ; fully-qualified # 🧑🏼‍🎓 E12.1 student: medium-light skin tone +1F9D1 1F3FD 200D 1F393 ; fully-qualified # 🧑🏽‍🎓 E12.1 student: medium skin tone +1F9D1 1F3FE 200D 1F393 ; fully-qualified # 🧑🏾‍🎓 E12.1 student: medium-dark skin tone +1F9D1 1F3FF 200D 1F393 ; fully-qualified # 🧑🏿‍🎓 E12.1 student: dark skin tone +1F468 200D 1F393 ; fully-qualified # 👨‍🎓 E4.0 man student +1F468 1F3FB 200D 1F393 ; fully-qualified # 👨🏻‍🎓 E4.0 man student: light skin tone +1F468 1F3FC 200D 1F393 ; fully-qualified # 👨🏼‍🎓 E4.0 man student: medium-light skin tone +1F468 1F3FD 200D 1F393 ; fully-qualified # 👨🏽‍🎓 E4.0 man student: medium skin tone +1F468 1F3FE 200D 1F393 ; fully-qualified # 👨🏾‍🎓 E4.0 man student: medium-dark skin tone +1F468 1F3FF 200D 1F393 ; fully-qualified # 👨🏿‍🎓 E4.0 man student: dark skin tone +1F469 200D 1F393 ; fully-qualified # 👩‍🎓 E4.0 woman student +1F469 1F3FB 200D 1F393 ; fully-qualified # 👩🏻‍🎓 E4.0 woman student: light skin tone +1F469 1F3FC 200D 1F393 ; fully-qualified # 👩🏼‍🎓 E4.0 woman student: medium-light skin tone +1F469 1F3FD 200D 1F393 ; fully-qualified # 👩🏽‍🎓 E4.0 woman student: medium skin tone +1F469 1F3FE 200D 1F393 ; fully-qualified # 👩🏾‍🎓 E4.0 woman student: medium-dark skin tone +1F469 1F3FF 200D 1F393 ; fully-qualified # 👩🏿‍🎓 E4.0 woman student: dark skin tone +1F9D1 200D 1F3EB ; fully-qualified # 🧑‍🏫 E12.1 teacher +1F9D1 1F3FB 200D 1F3EB ; fully-qualified # 🧑🏻‍🏫 E12.1 teacher: light skin tone +1F9D1 1F3FC 200D 1F3EB ; fully-qualified # 🧑🏼‍🏫 E12.1 teacher: medium-light skin tone +1F9D1 1F3FD 200D 1F3EB ; fully-qualified # 🧑🏽‍🏫 E12.1 teacher: medium skin tone +1F9D1 1F3FE 200D 1F3EB ; fully-qualified # 🧑🏾‍🏫 E12.1 teacher: medium-dark skin tone +1F9D1 1F3FF 200D 1F3EB ; fully-qualified # 🧑🏿‍🏫 E12.1 teacher: dark skin tone +1F468 200D 1F3EB ; fully-qualified # 👨‍🏫 E4.0 man teacher +1F468 1F3FB 200D 1F3EB ; fully-qualified # 👨🏻‍🏫 E4.0 man teacher: light skin tone +1F468 1F3FC 200D 1F3EB ; fully-qualified # 👨🏼‍🏫 E4.0 man teacher: medium-light skin tone +1F468 1F3FD 200D 1F3EB ; fully-qualified # 👨🏽‍🏫 E4.0 man teacher: medium skin tone +1F468 1F3FE 200D 1F3EB ; fully-qualified # 👨🏾‍🏫 E4.0 man teacher: medium-dark skin tone +1F468 1F3FF 200D 1F3EB ; fully-qualified # 👨🏿‍🏫 E4.0 man teacher: dark skin tone +1F469 200D 1F3EB ; fully-qualified # 👩‍🏫 E4.0 woman teacher +1F469 1F3FB 200D 1F3EB ; fully-qualified # 👩🏻‍🏫 E4.0 woman teacher: light skin tone +1F469 1F3FC 200D 1F3EB ; fully-qualified # 👩🏼‍🏫 E4.0 woman teacher: medium-light skin tone +1F469 1F3FD 200D 1F3EB ; fully-qualified # 👩🏽‍🏫 E4.0 woman teacher: medium skin tone +1F469 1F3FE 200D 1F3EB ; fully-qualified # 👩🏾‍🏫 E4.0 woman teacher: medium-dark skin tone +1F469 1F3FF 200D 1F3EB ; fully-qualified # 👩🏿‍🏫 E4.0 woman teacher: dark skin tone +1F9D1 200D 2696 FE0F ; fully-qualified # 🧑‍⚖️ E12.1 judge +1F9D1 200D 2696 ; minimally-qualified # 🧑‍⚖ E12.1 judge +1F9D1 1F3FB 200D 2696 FE0F ; fully-qualified # 🧑🏻‍⚖️ E12.1 judge: light skin tone +1F9D1 1F3FB 200D 2696 ; minimally-qualified # 🧑🏻‍⚖ E12.1 judge: light skin tone +1F9D1 1F3FC 200D 2696 FE0F ; fully-qualified # 🧑🏼‍⚖️ E12.1 judge: medium-light skin tone +1F9D1 1F3FC 200D 2696 ; minimally-qualified # 🧑🏼‍⚖ E12.1 judge: medium-light skin tone +1F9D1 1F3FD 200D 2696 FE0F ; fully-qualified # 🧑🏽‍⚖️ E12.1 judge: medium skin tone +1F9D1 1F3FD 200D 2696 ; minimally-qualified # 🧑🏽‍⚖ E12.1 judge: medium skin tone +1F9D1 1F3FE 200D 2696 FE0F ; fully-qualified # 🧑🏾‍⚖️ E12.1 judge: medium-dark skin tone +1F9D1 1F3FE 200D 2696 ; minimally-qualified # 🧑🏾‍⚖ E12.1 judge: medium-dark skin tone +1F9D1 1F3FF 200D 2696 FE0F ; fully-qualified # 🧑🏿‍⚖️ E12.1 judge: dark skin tone +1F9D1 1F3FF 200D 2696 ; minimally-qualified # 🧑🏿‍⚖ E12.1 judge: dark skin tone +1F468 200D 2696 FE0F ; fully-qualified # 👨‍⚖️ E4.0 man judge +1F468 200D 2696 ; minimally-qualified # 👨‍⚖ E4.0 man judge +1F468 1F3FB 200D 2696 FE0F ; fully-qualified # 👨🏻‍⚖️ E4.0 man judge: light skin tone +1F468 1F3FB 200D 2696 ; minimally-qualified # 👨🏻‍⚖ E4.0 man judge: light skin tone +1F468 1F3FC 200D 2696 FE0F ; fully-qualified # 👨🏼‍⚖️ E4.0 man judge: medium-light skin tone +1F468 1F3FC 200D 2696 ; minimally-qualified # 👨🏼‍⚖ E4.0 man judge: medium-light skin tone +1F468 1F3FD 200D 2696 FE0F ; fully-qualified # 👨🏽‍⚖️ E4.0 man judge: medium skin tone +1F468 1F3FD 200D 2696 ; minimally-qualified # 👨🏽‍⚖ E4.0 man judge: medium skin tone +1F468 1F3FE 200D 2696 FE0F ; fully-qualified # 👨🏾‍⚖️ E4.0 man judge: medium-dark skin tone +1F468 1F3FE 200D 2696 ; minimally-qualified # 👨🏾‍⚖ E4.0 man judge: medium-dark skin tone +1F468 1F3FF 200D 2696 FE0F ; fully-qualified # 👨🏿‍⚖️ E4.0 man judge: dark skin tone +1F468 1F3FF 200D 2696 ; minimally-qualified # 👨🏿‍⚖ E4.0 man judge: dark skin tone +1F469 200D 2696 FE0F ; fully-qualified # 👩‍⚖️ E4.0 woman judge +1F469 200D 2696 ; minimally-qualified # 👩‍⚖ E4.0 woman judge +1F469 1F3FB 200D 2696 FE0F ; fully-qualified # 👩🏻‍⚖️ E4.0 woman judge: light skin tone +1F469 1F3FB 200D 2696 ; minimally-qualified # 👩🏻‍⚖ E4.0 woman judge: light skin tone +1F469 1F3FC 200D 2696 FE0F ; fully-qualified # 👩🏼‍⚖️ E4.0 woman judge: medium-light skin tone +1F469 1F3FC 200D 2696 ; minimally-qualified # 👩🏼‍⚖ E4.0 woman judge: medium-light skin tone +1F469 1F3FD 200D 2696 FE0F ; fully-qualified # 👩🏽‍⚖️ E4.0 woman judge: medium skin tone +1F469 1F3FD 200D 2696 ; minimally-qualified # 👩🏽‍⚖ E4.0 woman judge: medium skin tone +1F469 1F3FE 200D 2696 FE0F ; fully-qualified # 👩🏾‍⚖️ E4.0 woman judge: medium-dark skin tone +1F469 1F3FE 200D 2696 ; minimally-qualified # 👩🏾‍⚖ E4.0 woman judge: medium-dark skin tone +1F469 1F3FF 200D 2696 FE0F ; fully-qualified # 👩🏿‍⚖️ E4.0 woman judge: dark skin tone +1F469 1F3FF 200D 2696 ; minimally-qualified # 👩🏿‍⚖ E4.0 woman judge: dark skin tone +1F9D1 200D 1F33E ; fully-qualified # 🧑‍🌾 E12.1 farmer +1F9D1 1F3FB 200D 1F33E ; fully-qualified # 🧑🏻‍🌾 E12.1 farmer: light skin tone +1F9D1 1F3FC 200D 1F33E ; fully-qualified # 🧑🏼‍🌾 E12.1 farmer: medium-light skin tone +1F9D1 1F3FD 200D 1F33E ; fully-qualified # 🧑🏽‍🌾 E12.1 farmer: medium skin tone +1F9D1 1F3FE 200D 1F33E ; fully-qualified # 🧑🏾‍🌾 E12.1 farmer: medium-dark skin tone +1F9D1 1F3FF 200D 1F33E ; fully-qualified # 🧑🏿‍🌾 E12.1 farmer: dark skin tone +1F468 200D 1F33E ; fully-qualified # 👨‍🌾 E4.0 man farmer +1F468 1F3FB 200D 1F33E ; fully-qualified # 👨🏻‍🌾 E4.0 man farmer: light skin tone +1F468 1F3FC 200D 1F33E ; fully-qualified # 👨🏼‍🌾 E4.0 man farmer: medium-light skin tone +1F468 1F3FD 200D 1F33E ; fully-qualified # 👨🏽‍🌾 E4.0 man farmer: medium skin tone +1F468 1F3FE 200D 1F33E ; fully-qualified # 👨🏾‍🌾 E4.0 man farmer: medium-dark skin tone +1F468 1F3FF 200D 1F33E ; fully-qualified # 👨🏿‍🌾 E4.0 man farmer: dark skin tone +1F469 200D 1F33E ; fully-qualified # 👩‍🌾 E4.0 woman farmer +1F469 1F3FB 200D 1F33E ; fully-qualified # 👩🏻‍🌾 E4.0 woman farmer: light skin tone +1F469 1F3FC 200D 1F33E ; fully-qualified # 👩🏼‍🌾 E4.0 woman farmer: medium-light skin tone +1F469 1F3FD 200D 1F33E ; fully-qualified # 👩🏽‍🌾 E4.0 woman farmer: medium skin tone +1F469 1F3FE 200D 1F33E ; fully-qualified # 👩🏾‍🌾 E4.0 woman farmer: medium-dark skin tone +1F469 1F3FF 200D 1F33E ; fully-qualified # 👩🏿‍🌾 E4.0 woman farmer: dark skin tone +1F9D1 200D 1F373 ; fully-qualified # 🧑‍🍳 E12.1 cook +1F9D1 1F3FB 200D 1F373 ; fully-qualified # 🧑🏻‍🍳 E12.1 cook: light skin tone +1F9D1 1F3FC 200D 1F373 ; fully-qualified # 🧑🏼‍🍳 E12.1 cook: medium-light skin tone +1F9D1 1F3FD 200D 1F373 ; fully-qualified # 🧑🏽‍🍳 E12.1 cook: medium skin tone +1F9D1 1F3FE 200D 1F373 ; fully-qualified # 🧑🏾‍🍳 E12.1 cook: medium-dark skin tone +1F9D1 1F3FF 200D 1F373 ; fully-qualified # 🧑🏿‍🍳 E12.1 cook: dark skin tone +1F468 200D 1F373 ; fully-qualified # 👨‍🍳 E4.0 man cook +1F468 1F3FB 200D 1F373 ; fully-qualified # 👨🏻‍🍳 E4.0 man cook: light skin tone +1F468 1F3FC 200D 1F373 ; fully-qualified # 👨🏼‍🍳 E4.0 man cook: medium-light skin tone +1F468 1F3FD 200D 1F373 ; fully-qualified # 👨🏽‍🍳 E4.0 man cook: medium skin tone +1F468 1F3FE 200D 1F373 ; fully-qualified # 👨🏾‍🍳 E4.0 man cook: medium-dark skin tone +1F468 1F3FF 200D 1F373 ; fully-qualified # 👨🏿‍🍳 E4.0 man cook: dark skin tone +1F469 200D 1F373 ; fully-qualified # 👩‍🍳 E4.0 woman cook +1F469 1F3FB 200D 1F373 ; fully-qualified # 👩🏻‍🍳 E4.0 woman cook: light skin tone +1F469 1F3FC 200D 1F373 ; fully-qualified # 👩🏼‍🍳 E4.0 woman cook: medium-light skin tone +1F469 1F3FD 200D 1F373 ; fully-qualified # 👩🏽‍🍳 E4.0 woman cook: medium skin tone +1F469 1F3FE 200D 1F373 ; fully-qualified # 👩🏾‍🍳 E4.0 woman cook: medium-dark skin tone +1F469 1F3FF 200D 1F373 ; fully-qualified # 👩🏿‍🍳 E4.0 woman cook: dark skin tone +1F9D1 200D 1F527 ; fully-qualified # 🧑‍🔧 E12.1 mechanic +1F9D1 1F3FB 200D 1F527 ; fully-qualified # 🧑🏻‍🔧 E12.1 mechanic: light skin tone +1F9D1 1F3FC 200D 1F527 ; fully-qualified # 🧑🏼‍🔧 E12.1 mechanic: medium-light skin tone +1F9D1 1F3FD 200D 1F527 ; fully-qualified # 🧑🏽‍🔧 E12.1 mechanic: medium skin tone +1F9D1 1F3FE 200D 1F527 ; fully-qualified # 🧑🏾‍🔧 E12.1 mechanic: medium-dark skin tone +1F9D1 1F3FF 200D 1F527 ; fully-qualified # 🧑🏿‍🔧 E12.1 mechanic: dark skin tone +1F468 200D 1F527 ; fully-qualified # 👨‍🔧 E4.0 man mechanic +1F468 1F3FB 200D 1F527 ; fully-qualified # 👨🏻‍🔧 E4.0 man mechanic: light skin tone +1F468 1F3FC 200D 1F527 ; fully-qualified # 👨🏼‍🔧 E4.0 man mechanic: medium-light skin tone +1F468 1F3FD 200D 1F527 ; fully-qualified # 👨🏽‍🔧 E4.0 man mechanic: medium skin tone +1F468 1F3FE 200D 1F527 ; fully-qualified # 👨🏾‍🔧 E4.0 man mechanic: medium-dark skin tone +1F468 1F3FF 200D 1F527 ; fully-qualified # 👨🏿‍🔧 E4.0 man mechanic: dark skin tone +1F469 200D 1F527 ; fully-qualified # 👩‍🔧 E4.0 woman mechanic +1F469 1F3FB 200D 1F527 ; fully-qualified # 👩🏻‍🔧 E4.0 woman mechanic: light skin tone +1F469 1F3FC 200D 1F527 ; fully-qualified # 👩🏼‍🔧 E4.0 woman mechanic: medium-light skin tone +1F469 1F3FD 200D 1F527 ; fully-qualified # 👩🏽‍🔧 E4.0 woman mechanic: medium skin tone +1F469 1F3FE 200D 1F527 ; fully-qualified # 👩🏾‍🔧 E4.0 woman mechanic: medium-dark skin tone +1F469 1F3FF 200D 1F527 ; fully-qualified # 👩🏿‍🔧 E4.0 woman mechanic: dark skin tone +1F9D1 200D 1F3ED ; fully-qualified # 🧑‍🏭 E12.1 factory worker +1F9D1 1F3FB 200D 1F3ED ; fully-qualified # 🧑🏻‍🏭 E12.1 factory worker: light skin tone +1F9D1 1F3FC 200D 1F3ED ; fully-qualified # 🧑🏼‍🏭 E12.1 factory worker: medium-light skin tone +1F9D1 1F3FD 200D 1F3ED ; fully-qualified # 🧑🏽‍🏭 E12.1 factory worker: medium skin tone +1F9D1 1F3FE 200D 1F3ED ; fully-qualified # 🧑🏾‍🏭 E12.1 factory worker: medium-dark skin tone +1F9D1 1F3FF 200D 1F3ED ; fully-qualified # 🧑🏿‍🏭 E12.1 factory worker: dark skin tone +1F468 200D 1F3ED ; fully-qualified # 👨‍🏭 E4.0 man factory worker +1F468 1F3FB 200D 1F3ED ; fully-qualified # 👨🏻‍🏭 E4.0 man factory worker: light skin tone +1F468 1F3FC 200D 1F3ED ; fully-qualified # 👨🏼‍🏭 E4.0 man factory worker: medium-light skin tone +1F468 1F3FD 200D 1F3ED ; fully-qualified # 👨🏽‍🏭 E4.0 man factory worker: medium skin tone +1F468 1F3FE 200D 1F3ED ; fully-qualified # 👨🏾‍🏭 E4.0 man factory worker: medium-dark skin tone +1F468 1F3FF 200D 1F3ED ; fully-qualified # 👨🏿‍🏭 E4.0 man factory worker: dark skin tone +1F469 200D 1F3ED ; fully-qualified # 👩‍🏭 E4.0 woman factory worker +1F469 1F3FB 200D 1F3ED ; fully-qualified # 👩🏻‍🏭 E4.0 woman factory worker: light skin tone +1F469 1F3FC 200D 1F3ED ; fully-qualified # 👩🏼‍🏭 E4.0 woman factory worker: medium-light skin tone +1F469 1F3FD 200D 1F3ED ; fully-qualified # 👩🏽‍🏭 E4.0 woman factory worker: medium skin tone +1F469 1F3FE 200D 1F3ED ; fully-qualified # 👩🏾‍🏭 E4.0 woman factory worker: medium-dark skin tone +1F469 1F3FF 200D 1F3ED ; fully-qualified # 👩🏿‍🏭 E4.0 woman factory worker: dark skin tone +1F9D1 200D 1F4BC ; fully-qualified # 🧑‍💼 E12.1 office worker +1F9D1 1F3FB 200D 1F4BC ; fully-qualified # 🧑🏻‍💼 E12.1 office worker: light skin tone +1F9D1 1F3FC 200D 1F4BC ; fully-qualified # 🧑🏼‍💼 E12.1 office worker: medium-light skin tone +1F9D1 1F3FD 200D 1F4BC ; fully-qualified # 🧑🏽‍💼 E12.1 office worker: medium skin tone +1F9D1 1F3FE 200D 1F4BC ; fully-qualified # 🧑🏾‍💼 E12.1 office worker: medium-dark skin tone +1F9D1 1F3FF 200D 1F4BC ; fully-qualified # 🧑🏿‍💼 E12.1 office worker: dark skin tone +1F468 200D 1F4BC ; fully-qualified # 👨‍💼 E4.0 man office worker +1F468 1F3FB 200D 1F4BC ; fully-qualified # 👨🏻‍💼 E4.0 man office worker: light skin tone +1F468 1F3FC 200D 1F4BC ; fully-qualified # 👨🏼‍💼 E4.0 man office worker: medium-light skin tone +1F468 1F3FD 200D 1F4BC ; fully-qualified # 👨🏽‍💼 E4.0 man office worker: medium skin tone +1F468 1F3FE 200D 1F4BC ; fully-qualified # 👨🏾‍💼 E4.0 man office worker: medium-dark skin tone +1F468 1F3FF 200D 1F4BC ; fully-qualified # 👨🏿‍💼 E4.0 man office worker: dark skin tone +1F469 200D 1F4BC ; fully-qualified # 👩‍💼 E4.0 woman office worker +1F469 1F3FB 200D 1F4BC ; fully-qualified # 👩🏻‍💼 E4.0 woman office worker: light skin tone +1F469 1F3FC 200D 1F4BC ; fully-qualified # 👩🏼‍💼 E4.0 woman office worker: medium-light skin tone +1F469 1F3FD 200D 1F4BC ; fully-qualified # 👩🏽‍💼 E4.0 woman office worker: medium skin tone +1F469 1F3FE 200D 1F4BC ; fully-qualified # 👩🏾‍💼 E4.0 woman office worker: medium-dark skin tone +1F469 1F3FF 200D 1F4BC ; fully-qualified # 👩🏿‍💼 E4.0 woman office worker: dark skin tone +1F9D1 200D 1F52C ; fully-qualified # 🧑‍🔬 E12.1 scientist +1F9D1 1F3FB 200D 1F52C ; fully-qualified # 🧑🏻‍🔬 E12.1 scientist: light skin tone +1F9D1 1F3FC 200D 1F52C ; fully-qualified # 🧑🏼‍🔬 E12.1 scientist: medium-light skin tone +1F9D1 1F3FD 200D 1F52C ; fully-qualified # 🧑🏽‍🔬 E12.1 scientist: medium skin tone +1F9D1 1F3FE 200D 1F52C ; fully-qualified # 🧑🏾‍🔬 E12.1 scientist: medium-dark skin tone +1F9D1 1F3FF 200D 1F52C ; fully-qualified # 🧑🏿‍🔬 E12.1 scientist: dark skin tone +1F468 200D 1F52C ; fully-qualified # 👨‍🔬 E4.0 man scientist +1F468 1F3FB 200D 1F52C ; fully-qualified # 👨🏻‍🔬 E4.0 man scientist: light skin tone +1F468 1F3FC 200D 1F52C ; fully-qualified # 👨🏼‍🔬 E4.0 man scientist: medium-light skin tone +1F468 1F3FD 200D 1F52C ; fully-qualified # 👨🏽‍🔬 E4.0 man scientist: medium skin tone +1F468 1F3FE 200D 1F52C ; fully-qualified # 👨🏾‍🔬 E4.0 man scientist: medium-dark skin tone +1F468 1F3FF 200D 1F52C ; fully-qualified # 👨🏿‍🔬 E4.0 man scientist: dark skin tone +1F469 200D 1F52C ; fully-qualified # 👩‍🔬 E4.0 woman scientist +1F469 1F3FB 200D 1F52C ; fully-qualified # 👩🏻‍🔬 E4.0 woman scientist: light skin tone +1F469 1F3FC 200D 1F52C ; fully-qualified # 👩🏼‍🔬 E4.0 woman scientist: medium-light skin tone +1F469 1F3FD 200D 1F52C ; fully-qualified # 👩🏽‍🔬 E4.0 woman scientist: medium skin tone +1F469 1F3FE 200D 1F52C ; fully-qualified # 👩🏾‍🔬 E4.0 woman scientist: medium-dark skin tone +1F469 1F3FF 200D 1F52C ; fully-qualified # 👩🏿‍🔬 E4.0 woman scientist: dark skin tone +1F9D1 200D 1F4BB ; fully-qualified # 🧑‍💻 E12.1 technologist +1F9D1 1F3FB 200D 1F4BB ; fully-qualified # 🧑🏻‍💻 E12.1 technologist: light skin tone +1F9D1 1F3FC 200D 1F4BB ; fully-qualified # 🧑🏼‍💻 E12.1 technologist: medium-light skin tone +1F9D1 1F3FD 200D 1F4BB ; fully-qualified # 🧑🏽‍💻 E12.1 technologist: medium skin tone +1F9D1 1F3FE 200D 1F4BB ; fully-qualified # 🧑🏾‍💻 E12.1 technologist: medium-dark skin tone +1F9D1 1F3FF 200D 1F4BB ; fully-qualified # 🧑🏿‍💻 E12.1 technologist: dark skin tone +1F468 200D 1F4BB ; fully-qualified # 👨‍💻 E4.0 man technologist +1F468 1F3FB 200D 1F4BB ; fully-qualified # 👨🏻‍💻 E4.0 man technologist: light skin tone +1F468 1F3FC 200D 1F4BB ; fully-qualified # 👨🏼‍💻 E4.0 man technologist: medium-light skin tone +1F468 1F3FD 200D 1F4BB ; fully-qualified # 👨🏽‍💻 E4.0 man technologist: medium skin tone +1F468 1F3FE 200D 1F4BB ; fully-qualified # 👨🏾‍💻 E4.0 man technologist: medium-dark skin tone +1F468 1F3FF 200D 1F4BB ; fully-qualified # 👨🏿‍💻 E4.0 man technologist: dark skin tone +1F469 200D 1F4BB ; fully-qualified # 👩‍💻 E4.0 woman technologist +1F469 1F3FB 200D 1F4BB ; fully-qualified # 👩🏻‍💻 E4.0 woman technologist: light skin tone +1F469 1F3FC 200D 1F4BB ; fully-qualified # 👩🏼‍💻 E4.0 woman technologist: medium-light skin tone +1F469 1F3FD 200D 1F4BB ; fully-qualified # 👩🏽‍💻 E4.0 woman technologist: medium skin tone +1F469 1F3FE 200D 1F4BB ; fully-qualified # 👩🏾‍💻 E4.0 woman technologist: medium-dark skin tone +1F469 1F3FF 200D 1F4BB ; fully-qualified # 👩🏿‍💻 E4.0 woman technologist: dark skin tone +1F9D1 200D 1F3A4 ; fully-qualified # 🧑‍🎤 E12.1 singer +1F9D1 1F3FB 200D 1F3A4 ; fully-qualified # 🧑🏻‍🎤 E12.1 singer: light skin tone +1F9D1 1F3FC 200D 1F3A4 ; fully-qualified # 🧑🏼‍🎤 E12.1 singer: medium-light skin tone +1F9D1 1F3FD 200D 1F3A4 ; fully-qualified # 🧑🏽‍🎤 E12.1 singer: medium skin tone +1F9D1 1F3FE 200D 1F3A4 ; fully-qualified # 🧑🏾‍🎤 E12.1 singer: medium-dark skin tone +1F9D1 1F3FF 200D 1F3A4 ; fully-qualified # 🧑🏿‍🎤 E12.1 singer: dark skin tone +1F468 200D 1F3A4 ; fully-qualified # 👨‍🎤 E4.0 man singer +1F468 1F3FB 200D 1F3A4 ; fully-qualified # 👨🏻‍🎤 E4.0 man singer: light skin tone +1F468 1F3FC 200D 1F3A4 ; fully-qualified # 👨🏼‍🎤 E4.0 man singer: medium-light skin tone +1F468 1F3FD 200D 1F3A4 ; fully-qualified # 👨🏽‍🎤 E4.0 man singer: medium skin tone +1F468 1F3FE 200D 1F3A4 ; fully-qualified # 👨🏾‍🎤 E4.0 man singer: medium-dark skin tone +1F468 1F3FF 200D 1F3A4 ; fully-qualified # 👨🏿‍🎤 E4.0 man singer: dark skin tone +1F469 200D 1F3A4 ; fully-qualified # 👩‍🎤 E4.0 woman singer +1F469 1F3FB 200D 1F3A4 ; fully-qualified # 👩🏻‍🎤 E4.0 woman singer: light skin tone +1F469 1F3FC 200D 1F3A4 ; fully-qualified # 👩🏼‍🎤 E4.0 woman singer: medium-light skin tone +1F469 1F3FD 200D 1F3A4 ; fully-qualified # 👩🏽‍🎤 E4.0 woman singer: medium skin tone +1F469 1F3FE 200D 1F3A4 ; fully-qualified # 👩🏾‍🎤 E4.0 woman singer: medium-dark skin tone +1F469 1F3FF 200D 1F3A4 ; fully-qualified # 👩🏿‍🎤 E4.0 woman singer: dark skin tone +1F9D1 200D 1F3A8 ; fully-qualified # 🧑‍🎨 E12.1 artist +1F9D1 1F3FB 200D 1F3A8 ; fully-qualified # 🧑🏻‍🎨 E12.1 artist: light skin tone +1F9D1 1F3FC 200D 1F3A8 ; fully-qualified # 🧑🏼‍🎨 E12.1 artist: medium-light skin tone +1F9D1 1F3FD 200D 1F3A8 ; fully-qualified # 🧑🏽‍🎨 E12.1 artist: medium skin tone +1F9D1 1F3FE 200D 1F3A8 ; fully-qualified # 🧑🏾‍🎨 E12.1 artist: medium-dark skin tone +1F9D1 1F3FF 200D 1F3A8 ; fully-qualified # 🧑🏿‍🎨 E12.1 artist: dark skin tone +1F468 200D 1F3A8 ; fully-qualified # 👨‍🎨 E4.0 man artist +1F468 1F3FB 200D 1F3A8 ; fully-qualified # 👨🏻‍🎨 E4.0 man artist: light skin tone +1F468 1F3FC 200D 1F3A8 ; fully-qualified # 👨🏼‍🎨 E4.0 man artist: medium-light skin tone +1F468 1F3FD 200D 1F3A8 ; fully-qualified # 👨🏽‍🎨 E4.0 man artist: medium skin tone +1F468 1F3FE 200D 1F3A8 ; fully-qualified # 👨🏾‍🎨 E4.0 man artist: medium-dark skin tone +1F468 1F3FF 200D 1F3A8 ; fully-qualified # 👨🏿‍🎨 E4.0 man artist: dark skin tone +1F469 200D 1F3A8 ; fully-qualified # 👩‍🎨 E4.0 woman artist +1F469 1F3FB 200D 1F3A8 ; fully-qualified # 👩🏻‍🎨 E4.0 woman artist: light skin tone +1F469 1F3FC 200D 1F3A8 ; fully-qualified # 👩🏼‍🎨 E4.0 woman artist: medium-light skin tone +1F469 1F3FD 200D 1F3A8 ; fully-qualified # 👩🏽‍🎨 E4.0 woman artist: medium skin tone +1F469 1F3FE 200D 1F3A8 ; fully-qualified # 👩🏾‍🎨 E4.0 woman artist: medium-dark skin tone +1F469 1F3FF 200D 1F3A8 ; fully-qualified # 👩🏿‍🎨 E4.0 woman artist: dark skin tone +1F9D1 200D 2708 FE0F ; fully-qualified # 🧑‍✈️ E12.1 pilot +1F9D1 200D 2708 ; minimally-qualified # 🧑‍✈ E12.1 pilot +1F9D1 1F3FB 200D 2708 FE0F ; fully-qualified # 🧑🏻‍✈️ E12.1 pilot: light skin tone +1F9D1 1F3FB 200D 2708 ; minimally-qualified # 🧑🏻‍✈ E12.1 pilot: light skin tone +1F9D1 1F3FC 200D 2708 FE0F ; fully-qualified # 🧑🏼‍✈️ E12.1 pilot: medium-light skin tone +1F9D1 1F3FC 200D 2708 ; minimally-qualified # 🧑🏼‍✈ E12.1 pilot: medium-light skin tone +1F9D1 1F3FD 200D 2708 FE0F ; fully-qualified # 🧑🏽‍✈️ E12.1 pilot: medium skin tone +1F9D1 1F3FD 200D 2708 ; minimally-qualified # 🧑🏽‍✈ E12.1 pilot: medium skin tone +1F9D1 1F3FE 200D 2708 FE0F ; fully-qualified # 🧑🏾‍✈️ E12.1 pilot: medium-dark skin tone +1F9D1 1F3FE 200D 2708 ; minimally-qualified # 🧑🏾‍✈ E12.1 pilot: medium-dark skin tone +1F9D1 1F3FF 200D 2708 FE0F ; fully-qualified # 🧑🏿‍✈️ E12.1 pilot: dark skin tone +1F9D1 1F3FF 200D 2708 ; minimally-qualified # 🧑🏿‍✈ E12.1 pilot: dark skin tone +1F468 200D 2708 FE0F ; fully-qualified # 👨‍✈️ E4.0 man pilot +1F468 200D 2708 ; minimally-qualified # 👨‍✈ E4.0 man pilot +1F468 1F3FB 200D 2708 FE0F ; fully-qualified # 👨🏻‍✈️ E4.0 man pilot: light skin tone +1F468 1F3FB 200D 2708 ; minimally-qualified # 👨🏻‍✈ E4.0 man pilot: light skin tone +1F468 1F3FC 200D 2708 FE0F ; fully-qualified # 👨🏼‍✈️ E4.0 man pilot: medium-light skin tone +1F468 1F3FC 200D 2708 ; minimally-qualified # 👨🏼‍✈ E4.0 man pilot: medium-light skin tone +1F468 1F3FD 200D 2708 FE0F ; fully-qualified # 👨🏽‍✈️ E4.0 man pilot: medium skin tone +1F468 1F3FD 200D 2708 ; minimally-qualified # 👨🏽‍✈ E4.0 man pilot: medium skin tone +1F468 1F3FE 200D 2708 FE0F ; fully-qualified # 👨🏾‍✈️ E4.0 man pilot: medium-dark skin tone +1F468 1F3FE 200D 2708 ; minimally-qualified # 👨🏾‍✈ E4.0 man pilot: medium-dark skin tone +1F468 1F3FF 200D 2708 FE0F ; fully-qualified # 👨🏿‍✈️ E4.0 man pilot: dark skin tone +1F468 1F3FF 200D 2708 ; minimally-qualified # 👨🏿‍✈ E4.0 man pilot: dark skin tone +1F469 200D 2708 FE0F ; fully-qualified # 👩‍✈️ E4.0 woman pilot +1F469 200D 2708 ; minimally-qualified # 👩‍✈ E4.0 woman pilot +1F469 1F3FB 200D 2708 FE0F ; fully-qualified # 👩🏻‍✈️ E4.0 woman pilot: light skin tone +1F469 1F3FB 200D 2708 ; minimally-qualified # 👩🏻‍✈ E4.0 woman pilot: light skin tone +1F469 1F3FC 200D 2708 FE0F ; fully-qualified # 👩🏼‍✈️ E4.0 woman pilot: medium-light skin tone +1F469 1F3FC 200D 2708 ; minimally-qualified # 👩🏼‍✈ E4.0 woman pilot: medium-light skin tone +1F469 1F3FD 200D 2708 FE0F ; fully-qualified # 👩🏽‍✈️ E4.0 woman pilot: medium skin tone +1F469 1F3FD 200D 2708 ; minimally-qualified # 👩🏽‍✈ E4.0 woman pilot: medium skin tone +1F469 1F3FE 200D 2708 FE0F ; fully-qualified # 👩🏾‍✈️ E4.0 woman pilot: medium-dark skin tone +1F469 1F3FE 200D 2708 ; minimally-qualified # 👩🏾‍✈ E4.0 woman pilot: medium-dark skin tone +1F469 1F3FF 200D 2708 FE0F ; fully-qualified # 👩🏿‍✈️ E4.0 woman pilot: dark skin tone +1F469 1F3FF 200D 2708 ; minimally-qualified # 👩🏿‍✈ E4.0 woman pilot: dark skin tone +1F9D1 200D 1F680 ; fully-qualified # 🧑‍🚀 E12.1 astronaut +1F9D1 1F3FB 200D 1F680 ; fully-qualified # 🧑🏻‍🚀 E12.1 astronaut: light skin tone +1F9D1 1F3FC 200D 1F680 ; fully-qualified # 🧑🏼‍🚀 E12.1 astronaut: medium-light skin tone +1F9D1 1F3FD 200D 1F680 ; fully-qualified # 🧑🏽‍🚀 E12.1 astronaut: medium skin tone +1F9D1 1F3FE 200D 1F680 ; fully-qualified # 🧑🏾‍🚀 E12.1 astronaut: medium-dark skin tone +1F9D1 1F3FF 200D 1F680 ; fully-qualified # 🧑🏿‍🚀 E12.1 astronaut: dark skin tone +1F468 200D 1F680 ; fully-qualified # 👨‍🚀 E4.0 man astronaut +1F468 1F3FB 200D 1F680 ; fully-qualified # 👨🏻‍🚀 E4.0 man astronaut: light skin tone +1F468 1F3FC 200D 1F680 ; fully-qualified # 👨🏼‍🚀 E4.0 man astronaut: medium-light skin tone +1F468 1F3FD 200D 1F680 ; fully-qualified # 👨🏽‍🚀 E4.0 man astronaut: medium skin tone +1F468 1F3FE 200D 1F680 ; fully-qualified # 👨🏾‍🚀 E4.0 man astronaut: medium-dark skin tone +1F468 1F3FF 200D 1F680 ; fully-qualified # 👨🏿‍🚀 E4.0 man astronaut: dark skin tone +1F469 200D 1F680 ; fully-qualified # 👩‍🚀 E4.0 woman astronaut +1F469 1F3FB 200D 1F680 ; fully-qualified # 👩🏻‍🚀 E4.0 woman astronaut: light skin tone +1F469 1F3FC 200D 1F680 ; fully-qualified # 👩🏼‍🚀 E4.0 woman astronaut: medium-light skin tone +1F469 1F3FD 200D 1F680 ; fully-qualified # 👩🏽‍🚀 E4.0 woman astronaut: medium skin tone +1F469 1F3FE 200D 1F680 ; fully-qualified # 👩🏾‍🚀 E4.0 woman astronaut: medium-dark skin tone +1F469 1F3FF 200D 1F680 ; fully-qualified # 👩🏿‍🚀 E4.0 woman astronaut: dark skin tone +1F9D1 200D 1F692 ; fully-qualified # 🧑‍🚒 E12.1 firefighter +1F9D1 1F3FB 200D 1F692 ; fully-qualified # 🧑🏻‍🚒 E12.1 firefighter: light skin tone +1F9D1 1F3FC 200D 1F692 ; fully-qualified # 🧑🏼‍🚒 E12.1 firefighter: medium-light skin tone +1F9D1 1F3FD 200D 1F692 ; fully-qualified # 🧑🏽‍🚒 E12.1 firefighter: medium skin tone +1F9D1 1F3FE 200D 1F692 ; fully-qualified # 🧑🏾‍🚒 E12.1 firefighter: medium-dark skin tone +1F9D1 1F3FF 200D 1F692 ; fully-qualified # 🧑🏿‍🚒 E12.1 firefighter: dark skin tone +1F468 200D 1F692 ; fully-qualified # 👨‍🚒 E4.0 man firefighter +1F468 1F3FB 200D 1F692 ; fully-qualified # 👨🏻‍🚒 E4.0 man firefighter: light skin tone +1F468 1F3FC 200D 1F692 ; fully-qualified # 👨🏼‍🚒 E4.0 man firefighter: medium-light skin tone +1F468 1F3FD 200D 1F692 ; fully-qualified # 👨🏽‍🚒 E4.0 man firefighter: medium skin tone +1F468 1F3FE 200D 1F692 ; fully-qualified # 👨🏾‍🚒 E4.0 man firefighter: medium-dark skin tone +1F468 1F3FF 200D 1F692 ; fully-qualified # 👨🏿‍🚒 E4.0 man firefighter: dark skin tone +1F469 200D 1F692 ; fully-qualified # 👩‍🚒 E4.0 woman firefighter +1F469 1F3FB 200D 1F692 ; fully-qualified # 👩🏻‍🚒 E4.0 woman firefighter: light skin tone +1F469 1F3FC 200D 1F692 ; fully-qualified # 👩🏼‍🚒 E4.0 woman firefighter: medium-light skin tone +1F469 1F3FD 200D 1F692 ; fully-qualified # 👩🏽‍🚒 E4.0 woman firefighter: medium skin tone +1F469 1F3FE 200D 1F692 ; fully-qualified # 👩🏾‍🚒 E4.0 woman firefighter: medium-dark skin tone +1F469 1F3FF 200D 1F692 ; fully-qualified # 👩🏿‍🚒 E4.0 woman firefighter: dark skin tone +1F46E ; fully-qualified # 👮 E0.6 police officer +1F46E 1F3FB ; fully-qualified # 👮🏻 E1.0 police officer: light skin tone +1F46E 1F3FC ; fully-qualified # 👮🏼 E1.0 police officer: medium-light skin tone +1F46E 1F3FD ; fully-qualified # 👮🏽 E1.0 police officer: medium skin tone +1F46E 1F3FE ; fully-qualified # 👮🏾 E1.0 police officer: medium-dark skin tone +1F46E 1F3FF ; fully-qualified # 👮🏿 E1.0 police officer: dark skin tone +1F46E 200D 2642 FE0F ; fully-qualified # 👮‍♂️ E4.0 man police officer +1F46E 200D 2642 ; minimally-qualified # 👮‍♂ E4.0 man police officer +1F46E 1F3FB 200D 2642 FE0F ; fully-qualified # 👮🏻‍♂️ E4.0 man police officer: light skin tone +1F46E 1F3FB 200D 2642 ; minimally-qualified # 👮🏻‍♂ E4.0 man police officer: light skin tone +1F46E 1F3FC 200D 2642 FE0F ; fully-qualified # 👮🏼‍♂️ E4.0 man police officer: medium-light skin tone +1F46E 1F3FC 200D 2642 ; minimally-qualified # 👮🏼‍♂ E4.0 man police officer: medium-light skin tone +1F46E 1F3FD 200D 2642 FE0F ; fully-qualified # 👮🏽‍♂️ E4.0 man police officer: medium skin tone +1F46E 1F3FD 200D 2642 ; minimally-qualified # 👮🏽‍♂ E4.0 man police officer: medium skin tone +1F46E 1F3FE 200D 2642 FE0F ; fully-qualified # 👮🏾‍♂️ E4.0 man police officer: medium-dark skin tone +1F46E 1F3FE 200D 2642 ; minimally-qualified # 👮🏾‍♂ E4.0 man police officer: medium-dark skin tone +1F46E 1F3FF 200D 2642 FE0F ; fully-qualified # 👮🏿‍♂️ E4.0 man police officer: dark skin tone +1F46E 1F3FF 200D 2642 ; minimally-qualified # 👮🏿‍♂ E4.0 man police officer: dark skin tone +1F46E 200D 2640 FE0F ; fully-qualified # 👮‍♀️ E4.0 woman police officer +1F46E 200D 2640 ; minimally-qualified # 👮‍♀ E4.0 woman police officer +1F46E 1F3FB 200D 2640 FE0F ; fully-qualified # 👮🏻‍♀️ E4.0 woman police officer: light skin tone +1F46E 1F3FB 200D 2640 ; minimally-qualified # 👮🏻‍♀ E4.0 woman police officer: light skin tone +1F46E 1F3FC 200D 2640 FE0F ; fully-qualified # 👮🏼‍♀️ E4.0 woman police officer: medium-light skin tone +1F46E 1F3FC 200D 2640 ; minimally-qualified # 👮🏼‍♀ E4.0 woman police officer: medium-light skin tone +1F46E 1F3FD 200D 2640 FE0F ; fully-qualified # 👮🏽‍♀️ E4.0 woman police officer: medium skin tone +1F46E 1F3FD 200D 2640 ; minimally-qualified # 👮🏽‍♀ E4.0 woman police officer: medium skin tone +1F46E 1F3FE 200D 2640 FE0F ; fully-qualified # 👮🏾‍♀️ E4.0 woman police officer: medium-dark skin tone +1F46E 1F3FE 200D 2640 ; minimally-qualified # 👮🏾‍♀ E4.0 woman police officer: medium-dark skin tone +1F46E 1F3FF 200D 2640 FE0F ; fully-qualified # 👮🏿‍♀️ E4.0 woman police officer: dark skin tone +1F46E 1F3FF 200D 2640 ; minimally-qualified # 👮🏿‍♀ E4.0 woman police officer: dark skin tone +1F575 FE0F ; fully-qualified # 🕵️ E0.7 detective +1F575 ; unqualified # 🕵 E0.7 detective +1F575 1F3FB ; fully-qualified # 🕵🏻 E2.0 detective: light skin tone +1F575 1F3FC ; fully-qualified # 🕵🏼 E2.0 detective: medium-light skin tone +1F575 1F3FD ; fully-qualified # 🕵🏽 E2.0 detective: medium skin tone +1F575 1F3FE ; fully-qualified # 🕵🏾 E2.0 detective: medium-dark skin tone +1F575 1F3FF ; fully-qualified # 🕵🏿 E2.0 detective: dark skin tone +1F575 FE0F 200D 2642 FE0F ; fully-qualified # 🕵️‍♂️ E4.0 man detective +1F575 200D 2642 FE0F ; unqualified # 🕵‍♂️ E4.0 man detective +1F575 FE0F 200D 2642 ; unqualified # 🕵️‍♂ E4.0 man detective +1F575 200D 2642 ; unqualified # 🕵‍♂ E4.0 man detective +1F575 1F3FB 200D 2642 FE0F ; fully-qualified # 🕵🏻‍♂️ E4.0 man detective: light skin tone +1F575 1F3FB 200D 2642 ; minimally-qualified # 🕵🏻‍♂ E4.0 man detective: light skin tone +1F575 1F3FC 200D 2642 FE0F ; fully-qualified # 🕵🏼‍♂️ E4.0 man detective: medium-light skin tone +1F575 1F3FC 200D 2642 ; minimally-qualified # 🕵🏼‍♂ E4.0 man detective: medium-light skin tone +1F575 1F3FD 200D 2642 FE0F ; fully-qualified # 🕵🏽‍♂️ E4.0 man detective: medium skin tone +1F575 1F3FD 200D 2642 ; minimally-qualified # 🕵🏽‍♂ E4.0 man detective: medium skin tone +1F575 1F3FE 200D 2642 FE0F ; fully-qualified # 🕵🏾‍♂️ E4.0 man detective: medium-dark skin tone +1F575 1F3FE 200D 2642 ; minimally-qualified # 🕵🏾‍♂ E4.0 man detective: medium-dark skin tone +1F575 1F3FF 200D 2642 FE0F ; fully-qualified # 🕵🏿‍♂️ E4.0 man detective: dark skin tone +1F575 1F3FF 200D 2642 ; minimally-qualified # 🕵🏿‍♂ E4.0 man detective: dark skin tone +1F575 FE0F 200D 2640 FE0F ; fully-qualified # 🕵️‍♀️ E4.0 woman detective +1F575 200D 2640 FE0F ; unqualified # 🕵‍♀️ E4.0 woman detective +1F575 FE0F 200D 2640 ; unqualified # 🕵️‍♀ E4.0 woman detective +1F575 200D 2640 ; unqualified # 🕵‍♀ E4.0 woman detective +1F575 1F3FB 200D 2640 FE0F ; fully-qualified # 🕵🏻‍♀️ E4.0 woman detective: light skin tone +1F575 1F3FB 200D 2640 ; minimally-qualified # 🕵🏻‍♀ E4.0 woman detective: light skin tone +1F575 1F3FC 200D 2640 FE0F ; fully-qualified # 🕵🏼‍♀️ E4.0 woman detective: medium-light skin tone +1F575 1F3FC 200D 2640 ; minimally-qualified # 🕵🏼‍♀ E4.0 woman detective: medium-light skin tone +1F575 1F3FD 200D 2640 FE0F ; fully-qualified # 🕵🏽‍♀️ E4.0 woman detective: medium skin tone +1F575 1F3FD 200D 2640 ; minimally-qualified # 🕵🏽‍♀ E4.0 woman detective: medium skin tone +1F575 1F3FE 200D 2640 FE0F ; fully-qualified # 🕵🏾‍♀️ E4.0 woman detective: medium-dark skin tone +1F575 1F3FE 200D 2640 ; minimally-qualified # 🕵🏾‍♀ E4.0 woman detective: medium-dark skin tone +1F575 1F3FF 200D 2640 FE0F ; fully-qualified # 🕵🏿‍♀️ E4.0 woman detective: dark skin tone +1F575 1F3FF 200D 2640 ; minimally-qualified # 🕵🏿‍♀ E4.0 woman detective: dark skin tone +1F482 ; fully-qualified # 💂 E0.6 guard +1F482 1F3FB ; fully-qualified # 💂🏻 E1.0 guard: light skin tone +1F482 1F3FC ; fully-qualified # 💂🏼 E1.0 guard: medium-light skin tone +1F482 1F3FD ; fully-qualified # 💂🏽 E1.0 guard: medium skin tone +1F482 1F3FE ; fully-qualified # 💂🏾 E1.0 guard: medium-dark skin tone +1F482 1F3FF ; fully-qualified # 💂🏿 E1.0 guard: dark skin tone +1F482 200D 2642 FE0F ; fully-qualified # 💂‍♂️ E4.0 man guard +1F482 200D 2642 ; minimally-qualified # 💂‍♂ E4.0 man guard +1F482 1F3FB 200D 2642 FE0F ; fully-qualified # 💂🏻‍♂️ E4.0 man guard: light skin tone +1F482 1F3FB 200D 2642 ; minimally-qualified # 💂🏻‍♂ E4.0 man guard: light skin tone +1F482 1F3FC 200D 2642 FE0F ; fully-qualified # 💂🏼‍♂️ E4.0 man guard: medium-light skin tone +1F482 1F3FC 200D 2642 ; minimally-qualified # 💂🏼‍♂ E4.0 man guard: medium-light skin tone +1F482 1F3FD 200D 2642 FE0F ; fully-qualified # 💂🏽‍♂️ E4.0 man guard: medium skin tone +1F482 1F3FD 200D 2642 ; minimally-qualified # 💂🏽‍♂ E4.0 man guard: medium skin tone +1F482 1F3FE 200D 2642 FE0F ; fully-qualified # 💂🏾‍♂️ E4.0 man guard: medium-dark skin tone +1F482 1F3FE 200D 2642 ; minimally-qualified # 💂🏾‍♂ E4.0 man guard: medium-dark skin tone +1F482 1F3FF 200D 2642 FE0F ; fully-qualified # 💂🏿‍♂️ E4.0 man guard: dark skin tone +1F482 1F3FF 200D 2642 ; minimally-qualified # 💂🏿‍♂ E4.0 man guard: dark skin tone +1F482 200D 2640 FE0F ; fully-qualified # 💂‍♀️ E4.0 woman guard +1F482 200D 2640 ; minimally-qualified # 💂‍♀ E4.0 woman guard +1F482 1F3FB 200D 2640 FE0F ; fully-qualified # 💂🏻‍♀️ E4.0 woman guard: light skin tone +1F482 1F3FB 200D 2640 ; minimally-qualified # 💂🏻‍♀ E4.0 woman guard: light skin tone +1F482 1F3FC 200D 2640 FE0F ; fully-qualified # 💂🏼‍♀️ E4.0 woman guard: medium-light skin tone +1F482 1F3FC 200D 2640 ; minimally-qualified # 💂🏼‍♀ E4.0 woman guard: medium-light skin tone +1F482 1F3FD 200D 2640 FE0F ; fully-qualified # 💂🏽‍♀️ E4.0 woman guard: medium skin tone +1F482 1F3FD 200D 2640 ; minimally-qualified # 💂🏽‍♀ E4.0 woman guard: medium skin tone +1F482 1F3FE 200D 2640 FE0F ; fully-qualified # 💂🏾‍♀️ E4.0 woman guard: medium-dark skin tone +1F482 1F3FE 200D 2640 ; minimally-qualified # 💂🏾‍♀ E4.0 woman guard: medium-dark skin tone +1F482 1F3FF 200D 2640 FE0F ; fully-qualified # 💂🏿‍♀️ E4.0 woman guard: dark skin tone +1F482 1F3FF 200D 2640 ; minimally-qualified # 💂🏿‍♀ E4.0 woman guard: dark skin tone +1F977 ; fully-qualified # 🥷 E13.0 ninja +1F977 1F3FB ; fully-qualified # 🥷🏻 E13.0 ninja: light skin tone +1F977 1F3FC ; fully-qualified # 🥷🏼 E13.0 ninja: medium-light skin tone +1F977 1F3FD ; fully-qualified # 🥷🏽 E13.0 ninja: medium skin tone +1F977 1F3FE ; fully-qualified # 🥷🏾 E13.0 ninja: medium-dark skin tone +1F977 1F3FF ; fully-qualified # 🥷🏿 E13.0 ninja: dark skin tone +1F477 ; fully-qualified # 👷 E0.6 construction worker +1F477 1F3FB ; fully-qualified # 👷🏻 E1.0 construction worker: light skin tone +1F477 1F3FC ; fully-qualified # 👷🏼 E1.0 construction worker: medium-light skin tone +1F477 1F3FD ; fully-qualified # 👷🏽 E1.0 construction worker: medium skin tone +1F477 1F3FE ; fully-qualified # 👷🏾 E1.0 construction worker: medium-dark skin tone +1F477 1F3FF ; fully-qualified # 👷🏿 E1.0 construction worker: dark skin tone +1F477 200D 2642 FE0F ; fully-qualified # 👷‍♂️ E4.0 man construction worker +1F477 200D 2642 ; minimally-qualified # 👷‍♂ E4.0 man construction worker +1F477 1F3FB 200D 2642 FE0F ; fully-qualified # 👷🏻‍♂️ E4.0 man construction worker: light skin tone +1F477 1F3FB 200D 2642 ; minimally-qualified # 👷🏻‍♂ E4.0 man construction worker: light skin tone +1F477 1F3FC 200D 2642 FE0F ; fully-qualified # 👷🏼‍♂️ E4.0 man construction worker: medium-light skin tone +1F477 1F3FC 200D 2642 ; minimally-qualified # 👷🏼‍♂ E4.0 man construction worker: medium-light skin tone +1F477 1F3FD 200D 2642 FE0F ; fully-qualified # 👷🏽‍♂️ E4.0 man construction worker: medium skin tone +1F477 1F3FD 200D 2642 ; minimally-qualified # 👷🏽‍♂ E4.0 man construction worker: medium skin tone +1F477 1F3FE 200D 2642 FE0F ; fully-qualified # 👷🏾‍♂️ E4.0 man construction worker: medium-dark skin tone +1F477 1F3FE 200D 2642 ; minimally-qualified # 👷🏾‍♂ E4.0 man construction worker: medium-dark skin tone +1F477 1F3FF 200D 2642 FE0F ; fully-qualified # 👷🏿‍♂️ E4.0 man construction worker: dark skin tone +1F477 1F3FF 200D 2642 ; minimally-qualified # 👷🏿‍♂ E4.0 man construction worker: dark skin tone +1F477 200D 2640 FE0F ; fully-qualified # 👷‍♀️ E4.0 woman construction worker +1F477 200D 2640 ; minimally-qualified # 👷‍♀ E4.0 woman construction worker +1F477 1F3FB 200D 2640 FE0F ; fully-qualified # 👷🏻‍♀️ E4.0 woman construction worker: light skin tone +1F477 1F3FB 200D 2640 ; minimally-qualified # 👷🏻‍♀ E4.0 woman construction worker: light skin tone +1F477 1F3FC 200D 2640 FE0F ; fully-qualified # 👷🏼‍♀️ E4.0 woman construction worker: medium-light skin tone +1F477 1F3FC 200D 2640 ; minimally-qualified # 👷🏼‍♀ E4.0 woman construction worker: medium-light skin tone +1F477 1F3FD 200D 2640 FE0F ; fully-qualified # 👷🏽‍♀️ E4.0 woman construction worker: medium skin tone +1F477 1F3FD 200D 2640 ; minimally-qualified # 👷🏽‍♀ E4.0 woman construction worker: medium skin tone +1F477 1F3FE 200D 2640 FE0F ; fully-qualified # 👷🏾‍♀️ E4.0 woman construction worker: medium-dark skin tone +1F477 1F3FE 200D 2640 ; minimally-qualified # 👷🏾‍♀ E4.0 woman construction worker: medium-dark skin tone +1F477 1F3FF 200D 2640 FE0F ; fully-qualified # 👷🏿‍♀️ E4.0 woman construction worker: dark skin tone +1F477 1F3FF 200D 2640 ; minimally-qualified # 👷🏿‍♀ E4.0 woman construction worker: dark skin tone +1F934 ; fully-qualified # 🤴 E3.0 prince +1F934 1F3FB ; fully-qualified # 🤴🏻 E3.0 prince: light skin tone +1F934 1F3FC ; fully-qualified # 🤴🏼 E3.0 prince: medium-light skin tone +1F934 1F3FD ; fully-qualified # 🤴🏽 E3.0 prince: medium skin tone +1F934 1F3FE ; fully-qualified # 🤴🏾 E3.0 prince: medium-dark skin tone +1F934 1F3FF ; fully-qualified # 🤴🏿 E3.0 prince: dark skin tone +1F478 ; fully-qualified # 👸 E0.6 princess +1F478 1F3FB ; fully-qualified # 👸🏻 E1.0 princess: light skin tone +1F478 1F3FC ; fully-qualified # 👸🏼 E1.0 princess: medium-light skin tone +1F478 1F3FD ; fully-qualified # 👸🏽 E1.0 princess: medium skin tone +1F478 1F3FE ; fully-qualified # 👸🏾 E1.0 princess: medium-dark skin tone +1F478 1F3FF ; fully-qualified # 👸🏿 E1.0 princess: dark skin tone +1F473 ; fully-qualified # 👳 E0.6 person wearing turban +1F473 1F3FB ; fully-qualified # 👳🏻 E1.0 person wearing turban: light skin tone +1F473 1F3FC ; fully-qualified # 👳🏼 E1.0 person wearing turban: medium-light skin tone +1F473 1F3FD ; fully-qualified # 👳🏽 E1.0 person wearing turban: medium skin tone +1F473 1F3FE ; fully-qualified # 👳🏾 E1.0 person wearing turban: medium-dark skin tone +1F473 1F3FF ; fully-qualified # 👳🏿 E1.0 person wearing turban: dark skin tone +1F473 200D 2642 FE0F ; fully-qualified # 👳‍♂️ E4.0 man wearing turban +1F473 200D 2642 ; minimally-qualified # 👳‍♂ E4.0 man wearing turban +1F473 1F3FB 200D 2642 FE0F ; fully-qualified # 👳🏻‍♂️ E4.0 man wearing turban: light skin tone +1F473 1F3FB 200D 2642 ; minimally-qualified # 👳🏻‍♂ E4.0 man wearing turban: light skin tone +1F473 1F3FC 200D 2642 FE0F ; fully-qualified # 👳🏼‍♂️ E4.0 man wearing turban: medium-light skin tone +1F473 1F3FC 200D 2642 ; minimally-qualified # 👳🏼‍♂ E4.0 man wearing turban: medium-light skin tone +1F473 1F3FD 200D 2642 FE0F ; fully-qualified # 👳🏽‍♂️ E4.0 man wearing turban: medium skin tone +1F473 1F3FD 200D 2642 ; minimally-qualified # 👳🏽‍♂ E4.0 man wearing turban: medium skin tone +1F473 1F3FE 200D 2642 FE0F ; fully-qualified # 👳🏾‍♂️ E4.0 man wearing turban: medium-dark skin tone +1F473 1F3FE 200D 2642 ; minimally-qualified # 👳🏾‍♂ E4.0 man wearing turban: medium-dark skin tone +1F473 1F3FF 200D 2642 FE0F ; fully-qualified # 👳🏿‍♂️ E4.0 man wearing turban: dark skin tone +1F473 1F3FF 200D 2642 ; minimally-qualified # 👳🏿‍♂ E4.0 man wearing turban: dark skin tone +1F473 200D 2640 FE0F ; fully-qualified # 👳‍♀️ E4.0 woman wearing turban +1F473 200D 2640 ; minimally-qualified # 👳‍♀ E4.0 woman wearing turban +1F473 1F3FB 200D 2640 FE0F ; fully-qualified # 👳🏻‍♀️ E4.0 woman wearing turban: light skin tone +1F473 1F3FB 200D 2640 ; minimally-qualified # 👳🏻‍♀ E4.0 woman wearing turban: light skin tone +1F473 1F3FC 200D 2640 FE0F ; fully-qualified # 👳🏼‍♀️ E4.0 woman wearing turban: medium-light skin tone +1F473 1F3FC 200D 2640 ; minimally-qualified # 👳🏼‍♀ E4.0 woman wearing turban: medium-light skin tone +1F473 1F3FD 200D 2640 FE0F ; fully-qualified # 👳🏽‍♀️ E4.0 woman wearing turban: medium skin tone +1F473 1F3FD 200D 2640 ; minimally-qualified # 👳🏽‍♀ E4.0 woman wearing turban: medium skin tone +1F473 1F3FE 200D 2640 FE0F ; fully-qualified # 👳🏾‍♀️ E4.0 woman wearing turban: medium-dark skin tone +1F473 1F3FE 200D 2640 ; minimally-qualified # 👳🏾‍♀ E4.0 woman wearing turban: medium-dark skin tone +1F473 1F3FF 200D 2640 FE0F ; fully-qualified # 👳🏿‍♀️ E4.0 woman wearing turban: dark skin tone +1F473 1F3FF 200D 2640 ; minimally-qualified # 👳🏿‍♀ E4.0 woman wearing turban: dark skin tone +1F472 ; fully-qualified # 👲 E0.6 person with skullcap +1F472 1F3FB ; fully-qualified # 👲🏻 E1.0 person with skullcap: light skin tone +1F472 1F3FC ; fully-qualified # 👲🏼 E1.0 person with skullcap: medium-light skin tone +1F472 1F3FD ; fully-qualified # 👲🏽 E1.0 person with skullcap: medium skin tone +1F472 1F3FE ; fully-qualified # 👲🏾 E1.0 person with skullcap: medium-dark skin tone +1F472 1F3FF ; fully-qualified # 👲🏿 E1.0 person with skullcap: dark skin tone +1F9D5 ; fully-qualified # 🧕 E5.0 woman with headscarf +1F9D5 1F3FB ; fully-qualified # 🧕🏻 E5.0 woman with headscarf: light skin tone +1F9D5 1F3FC ; fully-qualified # 🧕🏼 E5.0 woman with headscarf: medium-light skin tone +1F9D5 1F3FD ; fully-qualified # 🧕🏽 E5.0 woman with headscarf: medium skin tone +1F9D5 1F3FE ; fully-qualified # 🧕🏾 E5.0 woman with headscarf: medium-dark skin tone +1F9D5 1F3FF ; fully-qualified # 🧕🏿 E5.0 woman with headscarf: dark skin tone +1F935 ; fully-qualified # 🤵 E3.0 person in tuxedo +1F935 1F3FB ; fully-qualified # 🤵🏻 E3.0 person in tuxedo: light skin tone +1F935 1F3FC ; fully-qualified # 🤵🏼 E3.0 person in tuxedo: medium-light skin tone +1F935 1F3FD ; fully-qualified # 🤵🏽 E3.0 person in tuxedo: medium skin tone +1F935 1F3FE ; fully-qualified # 🤵🏾 E3.0 person in tuxedo: medium-dark skin tone +1F935 1F3FF ; fully-qualified # 🤵🏿 E3.0 person in tuxedo: dark skin tone +1F935 200D 2642 FE0F ; fully-qualified # 🤵‍♂️ E13.0 man in tuxedo +1F935 200D 2642 ; minimally-qualified # 🤵‍♂ E13.0 man in tuxedo +1F935 1F3FB 200D 2642 FE0F ; fully-qualified # 🤵🏻‍♂️ E13.0 man in tuxedo: light skin tone +1F935 1F3FB 200D 2642 ; minimally-qualified # 🤵🏻‍♂ E13.0 man in tuxedo: light skin tone +1F935 1F3FC 200D 2642 FE0F ; fully-qualified # 🤵🏼‍♂️ E13.0 man in tuxedo: medium-light skin tone +1F935 1F3FC 200D 2642 ; minimally-qualified # 🤵🏼‍♂ E13.0 man in tuxedo: medium-light skin tone +1F935 1F3FD 200D 2642 FE0F ; fully-qualified # 🤵🏽‍♂️ E13.0 man in tuxedo: medium skin tone +1F935 1F3FD 200D 2642 ; minimally-qualified # 🤵🏽‍♂ E13.0 man in tuxedo: medium skin tone +1F935 1F3FE 200D 2642 FE0F ; fully-qualified # 🤵🏾‍♂️ E13.0 man in tuxedo: medium-dark skin tone +1F935 1F3FE 200D 2642 ; minimally-qualified # 🤵🏾‍♂ E13.0 man in tuxedo: medium-dark skin tone +1F935 1F3FF 200D 2642 FE0F ; fully-qualified # 🤵🏿‍♂️ E13.0 man in tuxedo: dark skin tone +1F935 1F3FF 200D 2642 ; minimally-qualified # 🤵🏿‍♂ E13.0 man in tuxedo: dark skin tone +1F935 200D 2640 FE0F ; fully-qualified # 🤵‍♀️ E13.0 woman in tuxedo +1F935 200D 2640 ; minimally-qualified # 🤵‍♀ E13.0 woman in tuxedo +1F935 1F3FB 200D 2640 FE0F ; fully-qualified # 🤵🏻‍♀️ E13.0 woman in tuxedo: light skin tone +1F935 1F3FB 200D 2640 ; minimally-qualified # 🤵🏻‍♀ E13.0 woman in tuxedo: light skin tone +1F935 1F3FC 200D 2640 FE0F ; fully-qualified # 🤵🏼‍♀️ E13.0 woman in tuxedo: medium-light skin tone +1F935 1F3FC 200D 2640 ; minimally-qualified # 🤵🏼‍♀ E13.0 woman in tuxedo: medium-light skin tone +1F935 1F3FD 200D 2640 FE0F ; fully-qualified # 🤵🏽‍♀️ E13.0 woman in tuxedo: medium skin tone +1F935 1F3FD 200D 2640 ; minimally-qualified # 🤵🏽‍♀ E13.0 woman in tuxedo: medium skin tone +1F935 1F3FE 200D 2640 FE0F ; fully-qualified # 🤵🏾‍♀️ E13.0 woman in tuxedo: medium-dark skin tone +1F935 1F3FE 200D 2640 ; minimally-qualified # 🤵🏾‍♀ E13.0 woman in tuxedo: medium-dark skin tone +1F935 1F3FF 200D 2640 FE0F ; fully-qualified # 🤵🏿‍♀️ E13.0 woman in tuxedo: dark skin tone +1F935 1F3FF 200D 2640 ; minimally-qualified # 🤵🏿‍♀ E13.0 woman in tuxedo: dark skin tone +1F470 ; fully-qualified # 👰 E0.6 person with veil +1F470 1F3FB ; fully-qualified # 👰🏻 E1.0 person with veil: light skin tone +1F470 1F3FC ; fully-qualified # 👰🏼 E1.0 person with veil: medium-light skin tone +1F470 1F3FD ; fully-qualified # 👰🏽 E1.0 person with veil: medium skin tone +1F470 1F3FE ; fully-qualified # 👰🏾 E1.0 person with veil: medium-dark skin tone +1F470 1F3FF ; fully-qualified # 👰🏿 E1.0 person with veil: dark skin tone +1F470 200D 2642 FE0F ; fully-qualified # 👰‍♂️ E13.0 man with veil +1F470 200D 2642 ; minimally-qualified # 👰‍♂ E13.0 man with veil +1F470 1F3FB 200D 2642 FE0F ; fully-qualified # 👰🏻‍♂️ E13.0 man with veil: light skin tone +1F470 1F3FB 200D 2642 ; minimally-qualified # 👰🏻‍♂ E13.0 man with veil: light skin tone +1F470 1F3FC 200D 2642 FE0F ; fully-qualified # 👰🏼‍♂️ E13.0 man with veil: medium-light skin tone +1F470 1F3FC 200D 2642 ; minimally-qualified # 👰🏼‍♂ E13.0 man with veil: medium-light skin tone +1F470 1F3FD 200D 2642 FE0F ; fully-qualified # 👰🏽‍♂️ E13.0 man with veil: medium skin tone +1F470 1F3FD 200D 2642 ; minimally-qualified # 👰🏽‍♂ E13.0 man with veil: medium skin tone +1F470 1F3FE 200D 2642 FE0F ; fully-qualified # 👰🏾‍♂️ E13.0 man with veil: medium-dark skin tone +1F470 1F3FE 200D 2642 ; minimally-qualified # 👰🏾‍♂ E13.0 man with veil: medium-dark skin tone +1F470 1F3FF 200D 2642 FE0F ; fully-qualified # 👰🏿‍♂️ E13.0 man with veil: dark skin tone +1F470 1F3FF 200D 2642 ; minimally-qualified # 👰🏿‍♂ E13.0 man with veil: dark skin tone +1F470 200D 2640 FE0F ; fully-qualified # 👰‍♀️ E13.0 woman with veil +1F470 200D 2640 ; minimally-qualified # 👰‍♀ E13.0 woman with veil +1F470 1F3FB 200D 2640 FE0F ; fully-qualified # 👰🏻‍♀️ E13.0 woman with veil: light skin tone +1F470 1F3FB 200D 2640 ; minimally-qualified # 👰🏻‍♀ E13.0 woman with veil: light skin tone +1F470 1F3FC 200D 2640 FE0F ; fully-qualified # 👰🏼‍♀️ E13.0 woman with veil: medium-light skin tone +1F470 1F3FC 200D 2640 ; minimally-qualified # 👰🏼‍♀ E13.0 woman with veil: medium-light skin tone +1F470 1F3FD 200D 2640 FE0F ; fully-qualified # 👰🏽‍♀️ E13.0 woman with veil: medium skin tone +1F470 1F3FD 200D 2640 ; minimally-qualified # 👰🏽‍♀ E13.0 woman with veil: medium skin tone +1F470 1F3FE 200D 2640 FE0F ; fully-qualified # 👰🏾‍♀️ E13.0 woman with veil: medium-dark skin tone +1F470 1F3FE 200D 2640 ; minimally-qualified # 👰🏾‍♀ E13.0 woman with veil: medium-dark skin tone +1F470 1F3FF 200D 2640 FE0F ; fully-qualified # 👰🏿‍♀️ E13.0 woman with veil: dark skin tone +1F470 1F3FF 200D 2640 ; minimally-qualified # 👰🏿‍♀ E13.0 woman with veil: dark skin tone +1F930 ; fully-qualified # 🤰 E3.0 pregnant woman +1F930 1F3FB ; fully-qualified # 🤰🏻 E3.0 pregnant woman: light skin tone +1F930 1F3FC ; fully-qualified # 🤰🏼 E3.0 pregnant woman: medium-light skin tone +1F930 1F3FD ; fully-qualified # 🤰🏽 E3.0 pregnant woman: medium skin tone +1F930 1F3FE ; fully-qualified # 🤰🏾 E3.0 pregnant woman: medium-dark skin tone +1F930 1F3FF ; fully-qualified # 🤰🏿 E3.0 pregnant woman: dark skin tone +1F931 ; fully-qualified # 🤱 E5.0 breast-feeding +1F931 1F3FB ; fully-qualified # 🤱🏻 E5.0 breast-feeding: light skin tone +1F931 1F3FC ; fully-qualified # 🤱🏼 E5.0 breast-feeding: medium-light skin tone +1F931 1F3FD ; fully-qualified # 🤱🏽 E5.0 breast-feeding: medium skin tone +1F931 1F3FE ; fully-qualified # 🤱🏾 E5.0 breast-feeding: medium-dark skin tone +1F931 1F3FF ; fully-qualified # 🤱🏿 E5.0 breast-feeding: dark skin tone +1F469 200D 1F37C ; fully-qualified # 👩‍🍼 E13.0 woman feeding baby +1F469 1F3FB 200D 1F37C ; fully-qualified # 👩🏻‍🍼 E13.0 woman feeding baby: light skin tone +1F469 1F3FC 200D 1F37C ; fully-qualified # 👩🏼‍🍼 E13.0 woman feeding baby: medium-light skin tone +1F469 1F3FD 200D 1F37C ; fully-qualified # 👩🏽‍🍼 E13.0 woman feeding baby: medium skin tone +1F469 1F3FE 200D 1F37C ; fully-qualified # 👩🏾‍🍼 E13.0 woman feeding baby: medium-dark skin tone +1F469 1F3FF 200D 1F37C ; fully-qualified # 👩🏿‍🍼 E13.0 woman feeding baby: dark skin tone +1F468 200D 1F37C ; fully-qualified # 👨‍🍼 E13.0 man feeding baby +1F468 1F3FB 200D 1F37C ; fully-qualified # 👨🏻‍🍼 E13.0 man feeding baby: light skin tone +1F468 1F3FC 200D 1F37C ; fully-qualified # 👨🏼‍🍼 E13.0 man feeding baby: medium-light skin tone +1F468 1F3FD 200D 1F37C ; fully-qualified # 👨🏽‍🍼 E13.0 man feeding baby: medium skin tone +1F468 1F3FE 200D 1F37C ; fully-qualified # 👨🏾‍🍼 E13.0 man feeding baby: medium-dark skin tone +1F468 1F3FF 200D 1F37C ; fully-qualified # 👨🏿‍🍼 E13.0 man feeding baby: dark skin tone +1F9D1 200D 1F37C ; fully-qualified # 🧑‍🍼 E13.0 person feeding baby +1F9D1 1F3FB 200D 1F37C ; fully-qualified # 🧑🏻‍🍼 E13.0 person feeding baby: light skin tone +1F9D1 1F3FC 200D 1F37C ; fully-qualified # 🧑🏼‍🍼 E13.0 person feeding baby: medium-light skin tone +1F9D1 1F3FD 200D 1F37C ; fully-qualified # 🧑🏽‍🍼 E13.0 person feeding baby: medium skin tone +1F9D1 1F3FE 200D 1F37C ; fully-qualified # 🧑🏾‍🍼 E13.0 person feeding baby: medium-dark skin tone +1F9D1 1F3FF 200D 1F37C ; fully-qualified # 🧑🏿‍🍼 E13.0 person feeding baby: dark skin tone + +# subgroup: person-fantasy +1F47C ; fully-qualified # 👼 E0.6 baby angel +1F47C 1F3FB ; fully-qualified # 👼🏻 E1.0 baby angel: light skin tone +1F47C 1F3FC ; fully-qualified # 👼🏼 E1.0 baby angel: medium-light skin tone +1F47C 1F3FD ; fully-qualified # 👼🏽 E1.0 baby angel: medium skin tone +1F47C 1F3FE ; fully-qualified # 👼🏾 E1.0 baby angel: medium-dark skin tone +1F47C 1F3FF ; fully-qualified # 👼🏿 E1.0 baby angel: dark skin tone +1F385 ; fully-qualified # 🎅 E0.6 Santa Claus +1F385 1F3FB ; fully-qualified # 🎅🏻 E1.0 Santa Claus: light skin tone +1F385 1F3FC ; fully-qualified # 🎅🏼 E1.0 Santa Claus: medium-light skin tone +1F385 1F3FD ; fully-qualified # 🎅🏽 E1.0 Santa Claus: medium skin tone +1F385 1F3FE ; fully-qualified # 🎅🏾 E1.0 Santa Claus: medium-dark skin tone +1F385 1F3FF ; fully-qualified # 🎅🏿 E1.0 Santa Claus: dark skin tone +1F936 ; fully-qualified # 🤶 E3.0 Mrs. Claus +1F936 1F3FB ; fully-qualified # 🤶🏻 E3.0 Mrs. Claus: light skin tone +1F936 1F3FC ; fully-qualified # 🤶🏼 E3.0 Mrs. Claus: medium-light skin tone +1F936 1F3FD ; fully-qualified # 🤶🏽 E3.0 Mrs. Claus: medium skin tone +1F936 1F3FE ; fully-qualified # 🤶🏾 E3.0 Mrs. Claus: medium-dark skin tone +1F936 1F3FF ; fully-qualified # 🤶🏿 E3.0 Mrs. Claus: dark skin tone +1F9D1 200D 1F384 ; fully-qualified # 🧑‍🎄 E13.0 mx claus +1F9D1 1F3FB 200D 1F384 ; fully-qualified # 🧑🏻‍🎄 E13.0 mx claus: light skin tone +1F9D1 1F3FC 200D 1F384 ; fully-qualified # 🧑🏼‍🎄 E13.0 mx claus: medium-light skin tone +1F9D1 1F3FD 200D 1F384 ; fully-qualified # 🧑🏽‍🎄 E13.0 mx claus: medium skin tone +1F9D1 1F3FE 200D 1F384 ; fully-qualified # 🧑🏾‍🎄 E13.0 mx claus: medium-dark skin tone +1F9D1 1F3FF 200D 1F384 ; fully-qualified # 🧑🏿‍🎄 E13.0 mx claus: dark skin tone +1F9B8 ; fully-qualified # 🦸 E11.0 superhero +1F9B8 1F3FB ; fully-qualified # 🦸🏻 E11.0 superhero: light skin tone +1F9B8 1F3FC ; fully-qualified # 🦸🏼 E11.0 superhero: medium-light skin tone +1F9B8 1F3FD ; fully-qualified # 🦸🏽 E11.0 superhero: medium skin tone +1F9B8 1F3FE ; fully-qualified # 🦸🏾 E11.0 superhero: medium-dark skin tone +1F9B8 1F3FF ; fully-qualified # 🦸🏿 E11.0 superhero: dark skin tone +1F9B8 200D 2642 FE0F ; fully-qualified # 🦸‍♂️ E11.0 man superhero +1F9B8 200D 2642 ; minimally-qualified # 🦸‍♂ E11.0 man superhero +1F9B8 1F3FB 200D 2642 FE0F ; fully-qualified # 🦸🏻‍♂️ E11.0 man superhero: light skin tone +1F9B8 1F3FB 200D 2642 ; minimally-qualified # 🦸🏻‍♂ E11.0 man superhero: light skin tone +1F9B8 1F3FC 200D 2642 FE0F ; fully-qualified # 🦸🏼‍♂️ E11.0 man superhero: medium-light skin tone +1F9B8 1F3FC 200D 2642 ; minimally-qualified # 🦸🏼‍♂ E11.0 man superhero: medium-light skin tone +1F9B8 1F3FD 200D 2642 FE0F ; fully-qualified # 🦸🏽‍♂️ E11.0 man superhero: medium skin tone +1F9B8 1F3FD 200D 2642 ; minimally-qualified # 🦸🏽‍♂ E11.0 man superhero: medium skin tone +1F9B8 1F3FE 200D 2642 FE0F ; fully-qualified # 🦸🏾‍♂️ E11.0 man superhero: medium-dark skin tone +1F9B8 1F3FE 200D 2642 ; minimally-qualified # 🦸🏾‍♂ E11.0 man superhero: medium-dark skin tone +1F9B8 1F3FF 200D 2642 FE0F ; fully-qualified # 🦸🏿‍♂️ E11.0 man superhero: dark skin tone +1F9B8 1F3FF 200D 2642 ; minimally-qualified # 🦸🏿‍♂ E11.0 man superhero: dark skin tone +1F9B8 200D 2640 FE0F ; fully-qualified # 🦸‍♀️ E11.0 woman superhero +1F9B8 200D 2640 ; minimally-qualified # 🦸‍♀ E11.0 woman superhero +1F9B8 1F3FB 200D 2640 FE0F ; fully-qualified # 🦸🏻‍♀️ E11.0 woman superhero: light skin tone +1F9B8 1F3FB 200D 2640 ; minimally-qualified # 🦸🏻‍♀ E11.0 woman superhero: light skin tone +1F9B8 1F3FC 200D 2640 FE0F ; fully-qualified # 🦸🏼‍♀️ E11.0 woman superhero: medium-light skin tone +1F9B8 1F3FC 200D 2640 ; minimally-qualified # 🦸🏼‍♀ E11.0 woman superhero: medium-light skin tone +1F9B8 1F3FD 200D 2640 FE0F ; fully-qualified # 🦸🏽‍♀️ E11.0 woman superhero: medium skin tone +1F9B8 1F3FD 200D 2640 ; minimally-qualified # 🦸🏽‍♀ E11.0 woman superhero: medium skin tone +1F9B8 1F3FE 200D 2640 FE0F ; fully-qualified # 🦸🏾‍♀️ E11.0 woman superhero: medium-dark skin tone +1F9B8 1F3FE 200D 2640 ; minimally-qualified # 🦸🏾‍♀ E11.0 woman superhero: medium-dark skin tone +1F9B8 1F3FF 200D 2640 FE0F ; fully-qualified # 🦸🏿‍♀️ E11.0 woman superhero: dark skin tone +1F9B8 1F3FF 200D 2640 ; minimally-qualified # 🦸🏿‍♀ E11.0 woman superhero: dark skin tone +1F9B9 ; fully-qualified # 🦹 E11.0 supervillain +1F9B9 1F3FB ; fully-qualified # 🦹🏻 E11.0 supervillain: light skin tone +1F9B9 1F3FC ; fully-qualified # 🦹🏼 E11.0 supervillain: medium-light skin tone +1F9B9 1F3FD ; fully-qualified # 🦹🏽 E11.0 supervillain: medium skin tone +1F9B9 1F3FE ; fully-qualified # 🦹🏾 E11.0 supervillain: medium-dark skin tone +1F9B9 1F3FF ; fully-qualified # 🦹🏿 E11.0 supervillain: dark skin tone +1F9B9 200D 2642 FE0F ; fully-qualified # 🦹‍♂️ E11.0 man supervillain +1F9B9 200D 2642 ; minimally-qualified # 🦹‍♂ E11.0 man supervillain +1F9B9 1F3FB 200D 2642 FE0F ; fully-qualified # 🦹🏻‍♂️ E11.0 man supervillain: light skin tone +1F9B9 1F3FB 200D 2642 ; minimally-qualified # 🦹🏻‍♂ E11.0 man supervillain: light skin tone +1F9B9 1F3FC 200D 2642 FE0F ; fully-qualified # 🦹🏼‍♂️ E11.0 man supervillain: medium-light skin tone +1F9B9 1F3FC 200D 2642 ; minimally-qualified # 🦹🏼‍♂ E11.0 man supervillain: medium-light skin tone +1F9B9 1F3FD 200D 2642 FE0F ; fully-qualified # 🦹🏽‍♂️ E11.0 man supervillain: medium skin tone +1F9B9 1F3FD 200D 2642 ; minimally-qualified # 🦹🏽‍♂ E11.0 man supervillain: medium skin tone +1F9B9 1F3FE 200D 2642 FE0F ; fully-qualified # 🦹🏾‍♂️ E11.0 man supervillain: medium-dark skin tone +1F9B9 1F3FE 200D 2642 ; minimally-qualified # 🦹🏾‍♂ E11.0 man supervillain: medium-dark skin tone +1F9B9 1F3FF 200D 2642 FE0F ; fully-qualified # 🦹🏿‍♂️ E11.0 man supervillain: dark skin tone +1F9B9 1F3FF 200D 2642 ; minimally-qualified # 🦹🏿‍♂ E11.0 man supervillain: dark skin tone +1F9B9 200D 2640 FE0F ; fully-qualified # 🦹‍♀️ E11.0 woman supervillain +1F9B9 200D 2640 ; minimally-qualified # 🦹‍♀ E11.0 woman supervillain +1F9B9 1F3FB 200D 2640 FE0F ; fully-qualified # 🦹🏻‍♀️ E11.0 woman supervillain: light skin tone +1F9B9 1F3FB 200D 2640 ; minimally-qualified # 🦹🏻‍♀ E11.0 woman supervillain: light skin tone +1F9B9 1F3FC 200D 2640 FE0F ; fully-qualified # 🦹🏼‍♀️ E11.0 woman supervillain: medium-light skin tone +1F9B9 1F3FC 200D 2640 ; minimally-qualified # 🦹🏼‍♀ E11.0 woman supervillain: medium-light skin tone +1F9B9 1F3FD 200D 2640 FE0F ; fully-qualified # 🦹🏽‍♀️ E11.0 woman supervillain: medium skin tone +1F9B9 1F3FD 200D 2640 ; minimally-qualified # 🦹🏽‍♀ E11.0 woman supervillain: medium skin tone +1F9B9 1F3FE 200D 2640 FE0F ; fully-qualified # 🦹🏾‍♀️ E11.0 woman supervillain: medium-dark skin tone +1F9B9 1F3FE 200D 2640 ; minimally-qualified # 🦹🏾‍♀ E11.0 woman supervillain: medium-dark skin tone +1F9B9 1F3FF 200D 2640 FE0F ; fully-qualified # 🦹🏿‍♀️ E11.0 woman supervillain: dark skin tone +1F9B9 1F3FF 200D 2640 ; minimally-qualified # 🦹🏿‍♀ E11.0 woman supervillain: dark skin tone +1F9D9 ; fully-qualified # 🧙 E5.0 mage +1F9D9 1F3FB ; fully-qualified # 🧙🏻 E5.0 mage: light skin tone +1F9D9 1F3FC ; fully-qualified # 🧙🏼 E5.0 mage: medium-light skin tone +1F9D9 1F3FD ; fully-qualified # 🧙🏽 E5.0 mage: medium skin tone +1F9D9 1F3FE ; fully-qualified # 🧙🏾 E5.0 mage: medium-dark skin tone +1F9D9 1F3FF ; fully-qualified # 🧙🏿 E5.0 mage: dark skin tone +1F9D9 200D 2642 FE0F ; fully-qualified # 🧙‍♂️ E5.0 man mage +1F9D9 200D 2642 ; minimally-qualified # 🧙‍♂ E5.0 man mage +1F9D9 1F3FB 200D 2642 FE0F ; fully-qualified # 🧙🏻‍♂️ E5.0 man mage: light skin tone +1F9D9 1F3FB 200D 2642 ; minimally-qualified # 🧙🏻‍♂ E5.0 man mage: light skin tone +1F9D9 1F3FC 200D 2642 FE0F ; fully-qualified # 🧙🏼‍♂️ E5.0 man mage: medium-light skin tone +1F9D9 1F3FC 200D 2642 ; minimally-qualified # 🧙🏼‍♂ E5.0 man mage: medium-light skin tone +1F9D9 1F3FD 200D 2642 FE0F ; fully-qualified # 🧙🏽‍♂️ E5.0 man mage: medium skin tone +1F9D9 1F3FD 200D 2642 ; minimally-qualified # 🧙🏽‍♂ E5.0 man mage: medium skin tone +1F9D9 1F3FE 200D 2642 FE0F ; fully-qualified # 🧙🏾‍♂️ E5.0 man mage: medium-dark skin tone +1F9D9 1F3FE 200D 2642 ; minimally-qualified # 🧙🏾‍♂ E5.0 man mage: medium-dark skin tone +1F9D9 1F3FF 200D 2642 FE0F ; fully-qualified # 🧙🏿‍♂️ E5.0 man mage: dark skin tone +1F9D9 1F3FF 200D 2642 ; minimally-qualified # 🧙🏿‍♂ E5.0 man mage: dark skin tone +1F9D9 200D 2640 FE0F ; fully-qualified # 🧙‍♀️ E5.0 woman mage +1F9D9 200D 2640 ; minimally-qualified # 🧙‍♀ E5.0 woman mage +1F9D9 1F3FB 200D 2640 FE0F ; fully-qualified # 🧙🏻‍♀️ E5.0 woman mage: light skin tone +1F9D9 1F3FB 200D 2640 ; minimally-qualified # 🧙🏻‍♀ E5.0 woman mage: light skin tone +1F9D9 1F3FC 200D 2640 FE0F ; fully-qualified # 🧙🏼‍♀️ E5.0 woman mage: medium-light skin tone +1F9D9 1F3FC 200D 2640 ; minimally-qualified # 🧙🏼‍♀ E5.0 woman mage: medium-light skin tone +1F9D9 1F3FD 200D 2640 FE0F ; fully-qualified # 🧙🏽‍♀️ E5.0 woman mage: medium skin tone +1F9D9 1F3FD 200D 2640 ; minimally-qualified # 🧙🏽‍♀ E5.0 woman mage: medium skin tone +1F9D9 1F3FE 200D 2640 FE0F ; fully-qualified # 🧙🏾‍♀️ E5.0 woman mage: medium-dark skin tone +1F9D9 1F3FE 200D 2640 ; minimally-qualified # 🧙🏾‍♀ E5.0 woman mage: medium-dark skin tone +1F9D9 1F3FF 200D 2640 FE0F ; fully-qualified # 🧙🏿‍♀️ E5.0 woman mage: dark skin tone +1F9D9 1F3FF 200D 2640 ; minimally-qualified # 🧙🏿‍♀ E5.0 woman mage: dark skin tone +1F9DA ; fully-qualified # 🧚 E5.0 fairy +1F9DA 1F3FB ; fully-qualified # 🧚🏻 E5.0 fairy: light skin tone +1F9DA 1F3FC ; fully-qualified # 🧚🏼 E5.0 fairy: medium-light skin tone +1F9DA 1F3FD ; fully-qualified # 🧚🏽 E5.0 fairy: medium skin tone +1F9DA 1F3FE ; fully-qualified # 🧚🏾 E5.0 fairy: medium-dark skin tone +1F9DA 1F3FF ; fully-qualified # 🧚🏿 E5.0 fairy: dark skin tone +1F9DA 200D 2642 FE0F ; fully-qualified # 🧚‍♂️ E5.0 man fairy +1F9DA 200D 2642 ; minimally-qualified # 🧚‍♂ E5.0 man fairy +1F9DA 1F3FB 200D 2642 FE0F ; fully-qualified # 🧚🏻‍♂️ E5.0 man fairy: light skin tone +1F9DA 1F3FB 200D 2642 ; minimally-qualified # 🧚🏻‍♂ E5.0 man fairy: light skin tone +1F9DA 1F3FC 200D 2642 FE0F ; fully-qualified # 🧚🏼‍♂️ E5.0 man fairy: medium-light skin tone +1F9DA 1F3FC 200D 2642 ; minimally-qualified # 🧚🏼‍♂ E5.0 man fairy: medium-light skin tone +1F9DA 1F3FD 200D 2642 FE0F ; fully-qualified # 🧚🏽‍♂️ E5.0 man fairy: medium skin tone +1F9DA 1F3FD 200D 2642 ; minimally-qualified # 🧚🏽‍♂ E5.0 man fairy: medium skin tone +1F9DA 1F3FE 200D 2642 FE0F ; fully-qualified # 🧚🏾‍♂️ E5.0 man fairy: medium-dark skin tone +1F9DA 1F3FE 200D 2642 ; minimally-qualified # 🧚🏾‍♂ E5.0 man fairy: medium-dark skin tone +1F9DA 1F3FF 200D 2642 FE0F ; fully-qualified # 🧚🏿‍♂️ E5.0 man fairy: dark skin tone +1F9DA 1F3FF 200D 2642 ; minimally-qualified # 🧚🏿‍♂ E5.0 man fairy: dark skin tone +1F9DA 200D 2640 FE0F ; fully-qualified # 🧚‍♀️ E5.0 woman fairy +1F9DA 200D 2640 ; minimally-qualified # 🧚‍♀ E5.0 woman fairy +1F9DA 1F3FB 200D 2640 FE0F ; fully-qualified # 🧚🏻‍♀️ E5.0 woman fairy: light skin tone +1F9DA 1F3FB 200D 2640 ; minimally-qualified # 🧚🏻‍♀ E5.0 woman fairy: light skin tone +1F9DA 1F3FC 200D 2640 FE0F ; fully-qualified # 🧚🏼‍♀️ E5.0 woman fairy: medium-light skin tone +1F9DA 1F3FC 200D 2640 ; minimally-qualified # 🧚🏼‍♀ E5.0 woman fairy: medium-light skin tone +1F9DA 1F3FD 200D 2640 FE0F ; fully-qualified # 🧚🏽‍♀️ E5.0 woman fairy: medium skin tone +1F9DA 1F3FD 200D 2640 ; minimally-qualified # 🧚🏽‍♀ E5.0 woman fairy: medium skin tone +1F9DA 1F3FE 200D 2640 FE0F ; fully-qualified # 🧚🏾‍♀️ E5.0 woman fairy: medium-dark skin tone +1F9DA 1F3FE 200D 2640 ; minimally-qualified # 🧚🏾‍♀ E5.0 woman fairy: medium-dark skin tone +1F9DA 1F3FF 200D 2640 FE0F ; fully-qualified # 🧚🏿‍♀️ E5.0 woman fairy: dark skin tone +1F9DA 1F3FF 200D 2640 ; minimally-qualified # 🧚🏿‍♀ E5.0 woman fairy: dark skin tone +1F9DB ; fully-qualified # 🧛 E5.0 vampire +1F9DB 1F3FB ; fully-qualified # 🧛🏻 E5.0 vampire: light skin tone +1F9DB 1F3FC ; fully-qualified # 🧛🏼 E5.0 vampire: medium-light skin tone +1F9DB 1F3FD ; fully-qualified # 🧛🏽 E5.0 vampire: medium skin tone +1F9DB 1F3FE ; fully-qualified # 🧛🏾 E5.0 vampire: medium-dark skin tone +1F9DB 1F3FF ; fully-qualified # 🧛🏿 E5.0 vampire: dark skin tone +1F9DB 200D 2642 FE0F ; fully-qualified # 🧛‍♂️ E5.0 man vampire +1F9DB 200D 2642 ; minimally-qualified # 🧛‍♂ E5.0 man vampire +1F9DB 1F3FB 200D 2642 FE0F ; fully-qualified # 🧛🏻‍♂️ E5.0 man vampire: light skin tone +1F9DB 1F3FB 200D 2642 ; minimally-qualified # 🧛🏻‍♂ E5.0 man vampire: light skin tone +1F9DB 1F3FC 200D 2642 FE0F ; fully-qualified # 🧛🏼‍♂️ E5.0 man vampire: medium-light skin tone +1F9DB 1F3FC 200D 2642 ; minimally-qualified # 🧛🏼‍♂ E5.0 man vampire: medium-light skin tone +1F9DB 1F3FD 200D 2642 FE0F ; fully-qualified # 🧛🏽‍♂️ E5.0 man vampire: medium skin tone +1F9DB 1F3FD 200D 2642 ; minimally-qualified # 🧛🏽‍♂ E5.0 man vampire: medium skin tone +1F9DB 1F3FE 200D 2642 FE0F ; fully-qualified # 🧛🏾‍♂️ E5.0 man vampire: medium-dark skin tone +1F9DB 1F3FE 200D 2642 ; minimally-qualified # 🧛🏾‍♂ E5.0 man vampire: medium-dark skin tone +1F9DB 1F3FF 200D 2642 FE0F ; fully-qualified # 🧛🏿‍♂️ E5.0 man vampire: dark skin tone +1F9DB 1F3FF 200D 2642 ; minimally-qualified # 🧛🏿‍♂ E5.0 man vampire: dark skin tone +1F9DB 200D 2640 FE0F ; fully-qualified # 🧛‍♀️ E5.0 woman vampire +1F9DB 200D 2640 ; minimally-qualified # 🧛‍♀ E5.0 woman vampire +1F9DB 1F3FB 200D 2640 FE0F ; fully-qualified # 🧛🏻‍♀️ E5.0 woman vampire: light skin tone +1F9DB 1F3FB 200D 2640 ; minimally-qualified # 🧛🏻‍♀ E5.0 woman vampire: light skin tone +1F9DB 1F3FC 200D 2640 FE0F ; fully-qualified # 🧛🏼‍♀️ E5.0 woman vampire: medium-light skin tone +1F9DB 1F3FC 200D 2640 ; minimally-qualified # 🧛🏼‍♀ E5.0 woman vampire: medium-light skin tone +1F9DB 1F3FD 200D 2640 FE0F ; fully-qualified # 🧛🏽‍♀️ E5.0 woman vampire: medium skin tone +1F9DB 1F3FD 200D 2640 ; minimally-qualified # 🧛🏽‍♀ E5.0 woman vampire: medium skin tone +1F9DB 1F3FE 200D 2640 FE0F ; fully-qualified # 🧛🏾‍♀️ E5.0 woman vampire: medium-dark skin tone +1F9DB 1F3FE 200D 2640 ; minimally-qualified # 🧛🏾‍♀ E5.0 woman vampire: medium-dark skin tone +1F9DB 1F3FF 200D 2640 FE0F ; fully-qualified # 🧛🏿‍♀️ E5.0 woman vampire: dark skin tone +1F9DB 1F3FF 200D 2640 ; minimally-qualified # 🧛🏿‍♀ E5.0 woman vampire: dark skin tone +1F9DC ; fully-qualified # 🧜 E5.0 merperson +1F9DC 1F3FB ; fully-qualified # 🧜🏻 E5.0 merperson: light skin tone +1F9DC 1F3FC ; fully-qualified # 🧜🏼 E5.0 merperson: medium-light skin tone +1F9DC 1F3FD ; fully-qualified # 🧜🏽 E5.0 merperson: medium skin tone +1F9DC 1F3FE ; fully-qualified # 🧜🏾 E5.0 merperson: medium-dark skin tone +1F9DC 1F3FF ; fully-qualified # 🧜🏿 E5.0 merperson: dark skin tone +1F9DC 200D 2642 FE0F ; fully-qualified # 🧜‍♂️ E5.0 merman +1F9DC 200D 2642 ; minimally-qualified # 🧜‍♂ E5.0 merman +1F9DC 1F3FB 200D 2642 FE0F ; fully-qualified # 🧜🏻‍♂️ E5.0 merman: light skin tone +1F9DC 1F3FB 200D 2642 ; minimally-qualified # 🧜🏻‍♂ E5.0 merman: light skin tone +1F9DC 1F3FC 200D 2642 FE0F ; fully-qualified # 🧜🏼‍♂️ E5.0 merman: medium-light skin tone +1F9DC 1F3FC 200D 2642 ; minimally-qualified # 🧜🏼‍♂ E5.0 merman: medium-light skin tone +1F9DC 1F3FD 200D 2642 FE0F ; fully-qualified # 🧜🏽‍♂️ E5.0 merman: medium skin tone +1F9DC 1F3FD 200D 2642 ; minimally-qualified # 🧜🏽‍♂ E5.0 merman: medium skin tone +1F9DC 1F3FE 200D 2642 FE0F ; fully-qualified # 🧜🏾‍♂️ E5.0 merman: medium-dark skin tone +1F9DC 1F3FE 200D 2642 ; minimally-qualified # 🧜🏾‍♂ E5.0 merman: medium-dark skin tone +1F9DC 1F3FF 200D 2642 FE0F ; fully-qualified # 🧜🏿‍♂️ E5.0 merman: dark skin tone +1F9DC 1F3FF 200D 2642 ; minimally-qualified # 🧜🏿‍♂ E5.0 merman: dark skin tone +1F9DC 200D 2640 FE0F ; fully-qualified # 🧜‍♀️ E5.0 mermaid +1F9DC 200D 2640 ; minimally-qualified # 🧜‍♀ E5.0 mermaid +1F9DC 1F3FB 200D 2640 FE0F ; fully-qualified # 🧜🏻‍♀️ E5.0 mermaid: light skin tone +1F9DC 1F3FB 200D 2640 ; minimally-qualified # 🧜🏻‍♀ E5.0 mermaid: light skin tone +1F9DC 1F3FC 200D 2640 FE0F ; fully-qualified # 🧜🏼‍♀️ E5.0 mermaid: medium-light skin tone +1F9DC 1F3FC 200D 2640 ; minimally-qualified # 🧜🏼‍♀ E5.0 mermaid: medium-light skin tone +1F9DC 1F3FD 200D 2640 FE0F ; fully-qualified # 🧜🏽‍♀️ E5.0 mermaid: medium skin tone +1F9DC 1F3FD 200D 2640 ; minimally-qualified # 🧜🏽‍♀ E5.0 mermaid: medium skin tone +1F9DC 1F3FE 200D 2640 FE0F ; fully-qualified # 🧜🏾‍♀️ E5.0 mermaid: medium-dark skin tone +1F9DC 1F3FE 200D 2640 ; minimally-qualified # 🧜🏾‍♀ E5.0 mermaid: medium-dark skin tone +1F9DC 1F3FF 200D 2640 FE0F ; fully-qualified # 🧜🏿‍♀️ E5.0 mermaid: dark skin tone +1F9DC 1F3FF 200D 2640 ; minimally-qualified # 🧜🏿‍♀ E5.0 mermaid: dark skin tone +1F9DD ; fully-qualified # 🧝 E5.0 elf +1F9DD 1F3FB ; fully-qualified # 🧝🏻 E5.0 elf: light skin tone +1F9DD 1F3FC ; fully-qualified # 🧝🏼 E5.0 elf: medium-light skin tone +1F9DD 1F3FD ; fully-qualified # 🧝🏽 E5.0 elf: medium skin tone +1F9DD 1F3FE ; fully-qualified # 🧝🏾 E5.0 elf: medium-dark skin tone +1F9DD 1F3FF ; fully-qualified # 🧝🏿 E5.0 elf: dark skin tone +1F9DD 200D 2642 FE0F ; fully-qualified # 🧝‍♂️ E5.0 man elf +1F9DD 200D 2642 ; minimally-qualified # 🧝‍♂ E5.0 man elf +1F9DD 1F3FB 200D 2642 FE0F ; fully-qualified # 🧝🏻‍♂️ E5.0 man elf: light skin tone +1F9DD 1F3FB 200D 2642 ; minimally-qualified # 🧝🏻‍♂ E5.0 man elf: light skin tone +1F9DD 1F3FC 200D 2642 FE0F ; fully-qualified # 🧝🏼‍♂️ E5.0 man elf: medium-light skin tone +1F9DD 1F3FC 200D 2642 ; minimally-qualified # 🧝🏼‍♂ E5.0 man elf: medium-light skin tone +1F9DD 1F3FD 200D 2642 FE0F ; fully-qualified # 🧝🏽‍♂️ E5.0 man elf: medium skin tone +1F9DD 1F3FD 200D 2642 ; minimally-qualified # 🧝🏽‍♂ E5.0 man elf: medium skin tone +1F9DD 1F3FE 200D 2642 FE0F ; fully-qualified # 🧝🏾‍♂️ E5.0 man elf: medium-dark skin tone +1F9DD 1F3FE 200D 2642 ; minimally-qualified # 🧝🏾‍♂ E5.0 man elf: medium-dark skin tone +1F9DD 1F3FF 200D 2642 FE0F ; fully-qualified # 🧝🏿‍♂️ E5.0 man elf: dark skin tone +1F9DD 1F3FF 200D 2642 ; minimally-qualified # 🧝🏿‍♂ E5.0 man elf: dark skin tone +1F9DD 200D 2640 FE0F ; fully-qualified # 🧝‍♀️ E5.0 woman elf +1F9DD 200D 2640 ; minimally-qualified # 🧝‍♀ E5.0 woman elf +1F9DD 1F3FB 200D 2640 FE0F ; fully-qualified # 🧝🏻‍♀️ E5.0 woman elf: light skin tone +1F9DD 1F3FB 200D 2640 ; minimally-qualified # 🧝🏻‍♀ E5.0 woman elf: light skin tone +1F9DD 1F3FC 200D 2640 FE0F ; fully-qualified # 🧝🏼‍♀️ E5.0 woman elf: medium-light skin tone +1F9DD 1F3FC 200D 2640 ; minimally-qualified # 🧝🏼‍♀ E5.0 woman elf: medium-light skin tone +1F9DD 1F3FD 200D 2640 FE0F ; fully-qualified # 🧝🏽‍♀️ E5.0 woman elf: medium skin tone +1F9DD 1F3FD 200D 2640 ; minimally-qualified # 🧝🏽‍♀ E5.0 woman elf: medium skin tone +1F9DD 1F3FE 200D 2640 FE0F ; fully-qualified # 🧝🏾‍♀️ E5.0 woman elf: medium-dark skin tone +1F9DD 1F3FE 200D 2640 ; minimally-qualified # 🧝🏾‍♀ E5.0 woman elf: medium-dark skin tone +1F9DD 1F3FF 200D 2640 FE0F ; fully-qualified # 🧝🏿‍♀️ E5.0 woman elf: dark skin tone +1F9DD 1F3FF 200D 2640 ; minimally-qualified # 🧝🏿‍♀ E5.0 woman elf: dark skin tone +1F9DE ; fully-qualified # 🧞 E5.0 genie +1F9DE 200D 2642 FE0F ; fully-qualified # 🧞‍♂️ E5.0 man genie +1F9DE 200D 2642 ; minimally-qualified # 🧞‍♂ E5.0 man genie +1F9DE 200D 2640 FE0F ; fully-qualified # 🧞‍♀️ E5.0 woman genie +1F9DE 200D 2640 ; minimally-qualified # 🧞‍♀ E5.0 woman genie +1F9DF ; fully-qualified # 🧟 E5.0 zombie +1F9DF 200D 2642 FE0F ; fully-qualified # 🧟‍♂️ E5.0 man zombie +1F9DF 200D 2642 ; minimally-qualified # 🧟‍♂ E5.0 man zombie +1F9DF 200D 2640 FE0F ; fully-qualified # 🧟‍♀️ E5.0 woman zombie +1F9DF 200D 2640 ; minimally-qualified # 🧟‍♀ E5.0 woman zombie + +# subgroup: person-activity +1F486 ; fully-qualified # 💆 E0.6 person getting massage +1F486 1F3FB ; fully-qualified # 💆🏻 E1.0 person getting massage: light skin tone +1F486 1F3FC ; fully-qualified # 💆🏼 E1.0 person getting massage: medium-light skin tone +1F486 1F3FD ; fully-qualified # 💆🏽 E1.0 person getting massage: medium skin tone +1F486 1F3FE ; fully-qualified # 💆🏾 E1.0 person getting massage: medium-dark skin tone +1F486 1F3FF ; fully-qualified # 💆🏿 E1.0 person getting massage: dark skin tone +1F486 200D 2642 FE0F ; fully-qualified # 💆‍♂️ E4.0 man getting massage +1F486 200D 2642 ; minimally-qualified # 💆‍♂ E4.0 man getting massage +1F486 1F3FB 200D 2642 FE0F ; fully-qualified # 💆🏻‍♂️ E4.0 man getting massage: light skin tone +1F486 1F3FB 200D 2642 ; minimally-qualified # 💆🏻‍♂ E4.0 man getting massage: light skin tone +1F486 1F3FC 200D 2642 FE0F ; fully-qualified # 💆🏼‍♂️ E4.0 man getting massage: medium-light skin tone +1F486 1F3FC 200D 2642 ; minimally-qualified # 💆🏼‍♂ E4.0 man getting massage: medium-light skin tone +1F486 1F3FD 200D 2642 FE0F ; fully-qualified # 💆🏽‍♂️ E4.0 man getting massage: medium skin tone +1F486 1F3FD 200D 2642 ; minimally-qualified # 💆🏽‍♂ E4.0 man getting massage: medium skin tone +1F486 1F3FE 200D 2642 FE0F ; fully-qualified # 💆🏾‍♂️ E4.0 man getting massage: medium-dark skin tone +1F486 1F3FE 200D 2642 ; minimally-qualified # 💆🏾‍♂ E4.0 man getting massage: medium-dark skin tone +1F486 1F3FF 200D 2642 FE0F ; fully-qualified # 💆🏿‍♂️ E4.0 man getting massage: dark skin tone +1F486 1F3FF 200D 2642 ; minimally-qualified # 💆🏿‍♂ E4.0 man getting massage: dark skin tone +1F486 200D 2640 FE0F ; fully-qualified # 💆‍♀️ E4.0 woman getting massage +1F486 200D 2640 ; minimally-qualified # 💆‍♀ E4.0 woman getting massage +1F486 1F3FB 200D 2640 FE0F ; fully-qualified # 💆🏻‍♀️ E4.0 woman getting massage: light skin tone +1F486 1F3FB 200D 2640 ; minimally-qualified # 💆🏻‍♀ E4.0 woman getting massage: light skin tone +1F486 1F3FC 200D 2640 FE0F ; fully-qualified # 💆🏼‍♀️ E4.0 woman getting massage: medium-light skin tone +1F486 1F3FC 200D 2640 ; minimally-qualified # 💆🏼‍♀ E4.0 woman getting massage: medium-light skin tone +1F486 1F3FD 200D 2640 FE0F ; fully-qualified # 💆🏽‍♀️ E4.0 woman getting massage: medium skin tone +1F486 1F3FD 200D 2640 ; minimally-qualified # 💆🏽‍♀ E4.0 woman getting massage: medium skin tone +1F486 1F3FE 200D 2640 FE0F ; fully-qualified # 💆🏾‍♀️ E4.0 woman getting massage: medium-dark skin tone +1F486 1F3FE 200D 2640 ; minimally-qualified # 💆🏾‍♀ E4.0 woman getting massage: medium-dark skin tone +1F486 1F3FF 200D 2640 FE0F ; fully-qualified # 💆🏿‍♀️ E4.0 woman getting massage: dark skin tone +1F486 1F3FF 200D 2640 ; minimally-qualified # 💆🏿‍♀ E4.0 woman getting massage: dark skin tone +1F487 ; fully-qualified # 💇 E0.6 person getting haircut +1F487 1F3FB ; fully-qualified # 💇🏻 E1.0 person getting haircut: light skin tone +1F487 1F3FC ; fully-qualified # 💇🏼 E1.0 person getting haircut: medium-light skin tone +1F487 1F3FD ; fully-qualified # 💇🏽 E1.0 person getting haircut: medium skin tone +1F487 1F3FE ; fully-qualified # 💇🏾 E1.0 person getting haircut: medium-dark skin tone +1F487 1F3FF ; fully-qualified # 💇🏿 E1.0 person getting haircut: dark skin tone +1F487 200D 2642 FE0F ; fully-qualified # 💇‍♂️ E4.0 man getting haircut +1F487 200D 2642 ; minimally-qualified # 💇‍♂ E4.0 man getting haircut +1F487 1F3FB 200D 2642 FE0F ; fully-qualified # 💇🏻‍♂️ E4.0 man getting haircut: light skin tone +1F487 1F3FB 200D 2642 ; minimally-qualified # 💇🏻‍♂ E4.0 man getting haircut: light skin tone +1F487 1F3FC 200D 2642 FE0F ; fully-qualified # 💇🏼‍♂️ E4.0 man getting haircut: medium-light skin tone +1F487 1F3FC 200D 2642 ; minimally-qualified # 💇🏼‍♂ E4.0 man getting haircut: medium-light skin tone +1F487 1F3FD 200D 2642 FE0F ; fully-qualified # 💇🏽‍♂️ E4.0 man getting haircut: medium skin tone +1F487 1F3FD 200D 2642 ; minimally-qualified # 💇🏽‍♂ E4.0 man getting haircut: medium skin tone +1F487 1F3FE 200D 2642 FE0F ; fully-qualified # 💇🏾‍♂️ E4.0 man getting haircut: medium-dark skin tone +1F487 1F3FE 200D 2642 ; minimally-qualified # 💇🏾‍♂ E4.0 man getting haircut: medium-dark skin tone +1F487 1F3FF 200D 2642 FE0F ; fully-qualified # 💇🏿‍♂️ E4.0 man getting haircut: dark skin tone +1F487 1F3FF 200D 2642 ; minimally-qualified # 💇🏿‍♂ E4.0 man getting haircut: dark skin tone +1F487 200D 2640 FE0F ; fully-qualified # 💇‍♀️ E4.0 woman getting haircut +1F487 200D 2640 ; minimally-qualified # 💇‍♀ E4.0 woman getting haircut +1F487 1F3FB 200D 2640 FE0F ; fully-qualified # 💇🏻‍♀️ E4.0 woman getting haircut: light skin tone +1F487 1F3FB 200D 2640 ; minimally-qualified # 💇🏻‍♀ E4.0 woman getting haircut: light skin tone +1F487 1F3FC 200D 2640 FE0F ; fully-qualified # 💇🏼‍♀️ E4.0 woman getting haircut: medium-light skin tone +1F487 1F3FC 200D 2640 ; minimally-qualified # 💇🏼‍♀ E4.0 woman getting haircut: medium-light skin tone +1F487 1F3FD 200D 2640 FE0F ; fully-qualified # 💇🏽‍♀️ E4.0 woman getting haircut: medium skin tone +1F487 1F3FD 200D 2640 ; minimally-qualified # 💇🏽‍♀ E4.0 woman getting haircut: medium skin tone +1F487 1F3FE 200D 2640 FE0F ; fully-qualified # 💇🏾‍♀️ E4.0 woman getting haircut: medium-dark skin tone +1F487 1F3FE 200D 2640 ; minimally-qualified # 💇🏾‍♀ E4.0 woman getting haircut: medium-dark skin tone +1F487 1F3FF 200D 2640 FE0F ; fully-qualified # 💇🏿‍♀️ E4.0 woman getting haircut: dark skin tone +1F487 1F3FF 200D 2640 ; minimally-qualified # 💇🏿‍♀ E4.0 woman getting haircut: dark skin tone +1F6B6 ; fully-qualified # 🚶 E0.6 person walking +1F6B6 1F3FB ; fully-qualified # 🚶🏻 E1.0 person walking: light skin tone +1F6B6 1F3FC ; fully-qualified # 🚶🏼 E1.0 person walking: medium-light skin tone +1F6B6 1F3FD ; fully-qualified # 🚶🏽 E1.0 person walking: medium skin tone +1F6B6 1F3FE ; fully-qualified # 🚶🏾 E1.0 person walking: medium-dark skin tone +1F6B6 1F3FF ; fully-qualified # 🚶🏿 E1.0 person walking: dark skin tone +1F6B6 200D 2642 FE0F ; fully-qualified # 🚶‍♂️ E4.0 man walking +1F6B6 200D 2642 ; minimally-qualified # 🚶‍♂ E4.0 man walking +1F6B6 1F3FB 200D 2642 FE0F ; fully-qualified # 🚶🏻‍♂️ E4.0 man walking: light skin tone +1F6B6 1F3FB 200D 2642 ; minimally-qualified # 🚶🏻‍♂ E4.0 man walking: light skin tone +1F6B6 1F3FC 200D 2642 FE0F ; fully-qualified # 🚶🏼‍♂️ E4.0 man walking: medium-light skin tone +1F6B6 1F3FC 200D 2642 ; minimally-qualified # 🚶🏼‍♂ E4.0 man walking: medium-light skin tone +1F6B6 1F3FD 200D 2642 FE0F ; fully-qualified # 🚶🏽‍♂️ E4.0 man walking: medium skin tone +1F6B6 1F3FD 200D 2642 ; minimally-qualified # 🚶🏽‍♂ E4.0 man walking: medium skin tone +1F6B6 1F3FE 200D 2642 FE0F ; fully-qualified # 🚶🏾‍♂️ E4.0 man walking: medium-dark skin tone +1F6B6 1F3FE 200D 2642 ; minimally-qualified # 🚶🏾‍♂ E4.0 man walking: medium-dark skin tone +1F6B6 1F3FF 200D 2642 FE0F ; fully-qualified # 🚶🏿‍♂️ E4.0 man walking: dark skin tone +1F6B6 1F3FF 200D 2642 ; minimally-qualified # 🚶🏿‍♂ E4.0 man walking: dark skin tone +1F6B6 200D 2640 FE0F ; fully-qualified # 🚶‍♀️ E4.0 woman walking +1F6B6 200D 2640 ; minimally-qualified # 🚶‍♀ E4.0 woman walking +1F6B6 1F3FB 200D 2640 FE0F ; fully-qualified # 🚶🏻‍♀️ E4.0 woman walking: light skin tone +1F6B6 1F3FB 200D 2640 ; minimally-qualified # 🚶🏻‍♀ E4.0 woman walking: light skin tone +1F6B6 1F3FC 200D 2640 FE0F ; fully-qualified # 🚶🏼‍♀️ E4.0 woman walking: medium-light skin tone +1F6B6 1F3FC 200D 2640 ; minimally-qualified # 🚶🏼‍♀ E4.0 woman walking: medium-light skin tone +1F6B6 1F3FD 200D 2640 FE0F ; fully-qualified # 🚶🏽‍♀️ E4.0 woman walking: medium skin tone +1F6B6 1F3FD 200D 2640 ; minimally-qualified # 🚶🏽‍♀ E4.0 woman walking: medium skin tone +1F6B6 1F3FE 200D 2640 FE0F ; fully-qualified # 🚶🏾‍♀️ E4.0 woman walking: medium-dark skin tone +1F6B6 1F3FE 200D 2640 ; minimally-qualified # 🚶🏾‍♀ E4.0 woman walking: medium-dark skin tone +1F6B6 1F3FF 200D 2640 FE0F ; fully-qualified # 🚶🏿‍♀️ E4.0 woman walking: dark skin tone +1F6B6 1F3FF 200D 2640 ; minimally-qualified # 🚶🏿‍♀ E4.0 woman walking: dark skin tone +1F9CD ; fully-qualified # 🧍 E12.0 person standing +1F9CD 1F3FB ; fully-qualified # 🧍🏻 E12.0 person standing: light skin tone +1F9CD 1F3FC ; fully-qualified # 🧍🏼 E12.0 person standing: medium-light skin tone +1F9CD 1F3FD ; fully-qualified # 🧍🏽 E12.0 person standing: medium skin tone +1F9CD 1F3FE ; fully-qualified # 🧍🏾 E12.0 person standing: medium-dark skin tone +1F9CD 1F3FF ; fully-qualified # 🧍🏿 E12.0 person standing: dark skin tone +1F9CD 200D 2642 FE0F ; fully-qualified # 🧍‍♂️ E12.0 man standing +1F9CD 200D 2642 ; minimally-qualified # 🧍‍♂ E12.0 man standing +1F9CD 1F3FB 200D 2642 FE0F ; fully-qualified # 🧍🏻‍♂️ E12.0 man standing: light skin tone +1F9CD 1F3FB 200D 2642 ; minimally-qualified # 🧍🏻‍♂ E12.0 man standing: light skin tone +1F9CD 1F3FC 200D 2642 FE0F ; fully-qualified # 🧍🏼‍♂️ E12.0 man standing: medium-light skin tone +1F9CD 1F3FC 200D 2642 ; minimally-qualified # 🧍🏼‍♂ E12.0 man standing: medium-light skin tone +1F9CD 1F3FD 200D 2642 FE0F ; fully-qualified # 🧍🏽‍♂️ E12.0 man standing: medium skin tone +1F9CD 1F3FD 200D 2642 ; minimally-qualified # 🧍🏽‍♂ E12.0 man standing: medium skin tone +1F9CD 1F3FE 200D 2642 FE0F ; fully-qualified # 🧍🏾‍♂️ E12.0 man standing: medium-dark skin tone +1F9CD 1F3FE 200D 2642 ; minimally-qualified # 🧍🏾‍♂ E12.0 man standing: medium-dark skin tone +1F9CD 1F3FF 200D 2642 FE0F ; fully-qualified # 🧍🏿‍♂️ E12.0 man standing: dark skin tone +1F9CD 1F3FF 200D 2642 ; minimally-qualified # 🧍🏿‍♂ E12.0 man standing: dark skin tone +1F9CD 200D 2640 FE0F ; fully-qualified # 🧍‍♀️ E12.0 woman standing +1F9CD 200D 2640 ; minimally-qualified # 🧍‍♀ E12.0 woman standing +1F9CD 1F3FB 200D 2640 FE0F ; fully-qualified # 🧍🏻‍♀️ E12.0 woman standing: light skin tone +1F9CD 1F3FB 200D 2640 ; minimally-qualified # 🧍🏻‍♀ E12.0 woman standing: light skin tone +1F9CD 1F3FC 200D 2640 FE0F ; fully-qualified # 🧍🏼‍♀️ E12.0 woman standing: medium-light skin tone +1F9CD 1F3FC 200D 2640 ; minimally-qualified # 🧍🏼‍♀ E12.0 woman standing: medium-light skin tone +1F9CD 1F3FD 200D 2640 FE0F ; fully-qualified # 🧍🏽‍♀️ E12.0 woman standing: medium skin tone +1F9CD 1F3FD 200D 2640 ; minimally-qualified # 🧍🏽‍♀ E12.0 woman standing: medium skin tone +1F9CD 1F3FE 200D 2640 FE0F ; fully-qualified # 🧍🏾‍♀️ E12.0 woman standing: medium-dark skin tone +1F9CD 1F3FE 200D 2640 ; minimally-qualified # 🧍🏾‍♀ E12.0 woman standing: medium-dark skin tone +1F9CD 1F3FF 200D 2640 FE0F ; fully-qualified # 🧍🏿‍♀️ E12.0 woman standing: dark skin tone +1F9CD 1F3FF 200D 2640 ; minimally-qualified # 🧍🏿‍♀ E12.0 woman standing: dark skin tone +1F9CE ; fully-qualified # 🧎 E12.0 person kneeling +1F9CE 1F3FB ; fully-qualified # 🧎🏻 E12.0 person kneeling: light skin tone +1F9CE 1F3FC ; fully-qualified # 🧎🏼 E12.0 person kneeling: medium-light skin tone +1F9CE 1F3FD ; fully-qualified # 🧎🏽 E12.0 person kneeling: medium skin tone +1F9CE 1F3FE ; fully-qualified # 🧎🏾 E12.0 person kneeling: medium-dark skin tone +1F9CE 1F3FF ; fully-qualified # 🧎🏿 E12.0 person kneeling: dark skin tone +1F9CE 200D 2642 FE0F ; fully-qualified # 🧎‍♂️ E12.0 man kneeling +1F9CE 200D 2642 ; minimally-qualified # 🧎‍♂ E12.0 man kneeling +1F9CE 1F3FB 200D 2642 FE0F ; fully-qualified # 🧎🏻‍♂️ E12.0 man kneeling: light skin tone +1F9CE 1F3FB 200D 2642 ; minimally-qualified # 🧎🏻‍♂ E12.0 man kneeling: light skin tone +1F9CE 1F3FC 200D 2642 FE0F ; fully-qualified # 🧎🏼‍♂️ E12.0 man kneeling: medium-light skin tone +1F9CE 1F3FC 200D 2642 ; minimally-qualified # 🧎🏼‍♂ E12.0 man kneeling: medium-light skin tone +1F9CE 1F3FD 200D 2642 FE0F ; fully-qualified # 🧎🏽‍♂️ E12.0 man kneeling: medium skin tone +1F9CE 1F3FD 200D 2642 ; minimally-qualified # 🧎🏽‍♂ E12.0 man kneeling: medium skin tone +1F9CE 1F3FE 200D 2642 FE0F ; fully-qualified # 🧎🏾‍♂️ E12.0 man kneeling: medium-dark skin tone +1F9CE 1F3FE 200D 2642 ; minimally-qualified # 🧎🏾‍♂ E12.0 man kneeling: medium-dark skin tone +1F9CE 1F3FF 200D 2642 FE0F ; fully-qualified # 🧎🏿‍♂️ E12.0 man kneeling: dark skin tone +1F9CE 1F3FF 200D 2642 ; minimally-qualified # 🧎🏿‍♂ E12.0 man kneeling: dark skin tone +1F9CE 200D 2640 FE0F ; fully-qualified # 🧎‍♀️ E12.0 woman kneeling +1F9CE 200D 2640 ; minimally-qualified # 🧎‍♀ E12.0 woman kneeling +1F9CE 1F3FB 200D 2640 FE0F ; fully-qualified # 🧎🏻‍♀️ E12.0 woman kneeling: light skin tone +1F9CE 1F3FB 200D 2640 ; minimally-qualified # 🧎🏻‍♀ E12.0 woman kneeling: light skin tone +1F9CE 1F3FC 200D 2640 FE0F ; fully-qualified # 🧎🏼‍♀️ E12.0 woman kneeling: medium-light skin tone +1F9CE 1F3FC 200D 2640 ; minimally-qualified # 🧎🏼‍♀ E12.0 woman kneeling: medium-light skin tone +1F9CE 1F3FD 200D 2640 FE0F ; fully-qualified # 🧎🏽‍♀️ E12.0 woman kneeling: medium skin tone +1F9CE 1F3FD 200D 2640 ; minimally-qualified # 🧎🏽‍♀ E12.0 woman kneeling: medium skin tone +1F9CE 1F3FE 200D 2640 FE0F ; fully-qualified # 🧎🏾‍♀️ E12.0 woman kneeling: medium-dark skin tone +1F9CE 1F3FE 200D 2640 ; minimally-qualified # 🧎🏾‍♀ E12.0 woman kneeling: medium-dark skin tone +1F9CE 1F3FF 200D 2640 FE0F ; fully-qualified # 🧎🏿‍♀️ E12.0 woman kneeling: dark skin tone +1F9CE 1F3FF 200D 2640 ; minimally-qualified # 🧎🏿‍♀ E12.0 woman kneeling: dark skin tone +1F9D1 200D 1F9AF ; fully-qualified # 🧑‍🦯 E12.1 person with white cane +1F9D1 1F3FB 200D 1F9AF ; fully-qualified # 🧑🏻‍🦯 E12.1 person with white cane: light skin tone +1F9D1 1F3FC 200D 1F9AF ; fully-qualified # 🧑🏼‍🦯 E12.1 person with white cane: medium-light skin tone +1F9D1 1F3FD 200D 1F9AF ; fully-qualified # 🧑🏽‍🦯 E12.1 person with white cane: medium skin tone +1F9D1 1F3FE 200D 1F9AF ; fully-qualified # 🧑🏾‍🦯 E12.1 person with white cane: medium-dark skin tone +1F9D1 1F3FF 200D 1F9AF ; fully-qualified # 🧑🏿‍🦯 E12.1 person with white cane: dark skin tone +1F468 200D 1F9AF ; fully-qualified # 👨‍🦯 E12.0 man with white cane +1F468 1F3FB 200D 1F9AF ; fully-qualified # 👨🏻‍🦯 E12.0 man with white cane: light skin tone +1F468 1F3FC 200D 1F9AF ; fully-qualified # 👨🏼‍🦯 E12.0 man with white cane: medium-light skin tone +1F468 1F3FD 200D 1F9AF ; fully-qualified # 👨🏽‍🦯 E12.0 man with white cane: medium skin tone +1F468 1F3FE 200D 1F9AF ; fully-qualified # 👨🏾‍🦯 E12.0 man with white cane: medium-dark skin tone +1F468 1F3FF 200D 1F9AF ; fully-qualified # 👨🏿‍🦯 E12.0 man with white cane: dark skin tone +1F469 200D 1F9AF ; fully-qualified # 👩‍🦯 E12.0 woman with white cane +1F469 1F3FB 200D 1F9AF ; fully-qualified # 👩🏻‍🦯 E12.0 woman with white cane: light skin tone +1F469 1F3FC 200D 1F9AF ; fully-qualified # 👩🏼‍🦯 E12.0 woman with white cane: medium-light skin tone +1F469 1F3FD 200D 1F9AF ; fully-qualified # 👩🏽‍🦯 E12.0 woman with white cane: medium skin tone +1F469 1F3FE 200D 1F9AF ; fully-qualified # 👩🏾‍🦯 E12.0 woman with white cane: medium-dark skin tone +1F469 1F3FF 200D 1F9AF ; fully-qualified # 👩🏿‍🦯 E12.0 woman with white cane: dark skin tone +1F9D1 200D 1F9BC ; fully-qualified # 🧑‍🦼 E12.1 person in motorized wheelchair +1F9D1 1F3FB 200D 1F9BC ; fully-qualified # 🧑🏻‍🦼 E12.1 person in motorized wheelchair: light skin tone +1F9D1 1F3FC 200D 1F9BC ; fully-qualified # 🧑🏼‍🦼 E12.1 person in motorized wheelchair: medium-light skin tone +1F9D1 1F3FD 200D 1F9BC ; fully-qualified # 🧑🏽‍🦼 E12.1 person in motorized wheelchair: medium skin tone +1F9D1 1F3FE 200D 1F9BC ; fully-qualified # 🧑🏾‍🦼 E12.1 person in motorized wheelchair: medium-dark skin tone +1F9D1 1F3FF 200D 1F9BC ; fully-qualified # 🧑🏿‍🦼 E12.1 person in motorized wheelchair: dark skin tone +1F468 200D 1F9BC ; fully-qualified # 👨‍🦼 E12.0 man in motorized wheelchair +1F468 1F3FB 200D 1F9BC ; fully-qualified # 👨🏻‍🦼 E12.0 man in motorized wheelchair: light skin tone +1F468 1F3FC 200D 1F9BC ; fully-qualified # 👨🏼‍🦼 E12.0 man in motorized wheelchair: medium-light skin tone +1F468 1F3FD 200D 1F9BC ; fully-qualified # 👨🏽‍🦼 E12.0 man in motorized wheelchair: medium skin tone +1F468 1F3FE 200D 1F9BC ; fully-qualified # 👨🏾‍🦼 E12.0 man in motorized wheelchair: medium-dark skin tone +1F468 1F3FF 200D 1F9BC ; fully-qualified # 👨🏿‍🦼 E12.0 man in motorized wheelchair: dark skin tone +1F469 200D 1F9BC ; fully-qualified # 👩‍🦼 E12.0 woman in motorized wheelchair +1F469 1F3FB 200D 1F9BC ; fully-qualified # 👩🏻‍🦼 E12.0 woman in motorized wheelchair: light skin tone +1F469 1F3FC 200D 1F9BC ; fully-qualified # 👩🏼‍🦼 E12.0 woman in motorized wheelchair: medium-light skin tone +1F469 1F3FD 200D 1F9BC ; fully-qualified # 👩🏽‍🦼 E12.0 woman in motorized wheelchair: medium skin tone +1F469 1F3FE 200D 1F9BC ; fully-qualified # 👩🏾‍🦼 E12.0 woman in motorized wheelchair: medium-dark skin tone +1F469 1F3FF 200D 1F9BC ; fully-qualified # 👩🏿‍🦼 E12.0 woman in motorized wheelchair: dark skin tone +1F9D1 200D 1F9BD ; fully-qualified # 🧑‍🦽 E12.1 person in manual wheelchair +1F9D1 1F3FB 200D 1F9BD ; fully-qualified # 🧑🏻‍🦽 E12.1 person in manual wheelchair: light skin tone +1F9D1 1F3FC 200D 1F9BD ; fully-qualified # 🧑🏼‍🦽 E12.1 person in manual wheelchair: medium-light skin tone +1F9D1 1F3FD 200D 1F9BD ; fully-qualified # 🧑🏽‍🦽 E12.1 person in manual wheelchair: medium skin tone +1F9D1 1F3FE 200D 1F9BD ; fully-qualified # 🧑🏾‍🦽 E12.1 person in manual wheelchair: medium-dark skin tone +1F9D1 1F3FF 200D 1F9BD ; fully-qualified # 🧑🏿‍🦽 E12.1 person in manual wheelchair: dark skin tone +1F468 200D 1F9BD ; fully-qualified # 👨‍🦽 E12.0 man in manual wheelchair +1F468 1F3FB 200D 1F9BD ; fully-qualified # 👨🏻‍🦽 E12.0 man in manual wheelchair: light skin tone +1F468 1F3FC 200D 1F9BD ; fully-qualified # 👨🏼‍🦽 E12.0 man in manual wheelchair: medium-light skin tone +1F468 1F3FD 200D 1F9BD ; fully-qualified # 👨🏽‍🦽 E12.0 man in manual wheelchair: medium skin tone +1F468 1F3FE 200D 1F9BD ; fully-qualified # 👨🏾‍🦽 E12.0 man in manual wheelchair: medium-dark skin tone +1F468 1F3FF 200D 1F9BD ; fully-qualified # 👨🏿‍🦽 E12.0 man in manual wheelchair: dark skin tone +1F469 200D 1F9BD ; fully-qualified # 👩‍🦽 E12.0 woman in manual wheelchair +1F469 1F3FB 200D 1F9BD ; fully-qualified # 👩🏻‍🦽 E12.0 woman in manual wheelchair: light skin tone +1F469 1F3FC 200D 1F9BD ; fully-qualified # 👩🏼‍🦽 E12.0 woman in manual wheelchair: medium-light skin tone +1F469 1F3FD 200D 1F9BD ; fully-qualified # 👩🏽‍🦽 E12.0 woman in manual wheelchair: medium skin tone +1F469 1F3FE 200D 1F9BD ; fully-qualified # 👩🏾‍🦽 E12.0 woman in manual wheelchair: medium-dark skin tone +1F469 1F3FF 200D 1F9BD ; fully-qualified # 👩🏿‍🦽 E12.0 woman in manual wheelchair: dark skin tone +1F3C3 ; fully-qualified # 🏃 E0.6 person running +1F3C3 1F3FB ; fully-qualified # 🏃🏻 E1.0 person running: light skin tone +1F3C3 1F3FC ; fully-qualified # 🏃🏼 E1.0 person running: medium-light skin tone +1F3C3 1F3FD ; fully-qualified # 🏃🏽 E1.0 person running: medium skin tone +1F3C3 1F3FE ; fully-qualified # 🏃🏾 E1.0 person running: medium-dark skin tone +1F3C3 1F3FF ; fully-qualified # 🏃🏿 E1.0 person running: dark skin tone +1F3C3 200D 2642 FE0F ; fully-qualified # 🏃‍♂️ E4.0 man running +1F3C3 200D 2642 ; minimally-qualified # 🏃‍♂ E4.0 man running +1F3C3 1F3FB 200D 2642 FE0F ; fully-qualified # 🏃🏻‍♂️ E4.0 man running: light skin tone +1F3C3 1F3FB 200D 2642 ; minimally-qualified # 🏃🏻‍♂ E4.0 man running: light skin tone +1F3C3 1F3FC 200D 2642 FE0F ; fully-qualified # 🏃🏼‍♂️ E4.0 man running: medium-light skin tone +1F3C3 1F3FC 200D 2642 ; minimally-qualified # 🏃🏼‍♂ E4.0 man running: medium-light skin tone +1F3C3 1F3FD 200D 2642 FE0F ; fully-qualified # 🏃🏽‍♂️ E4.0 man running: medium skin tone +1F3C3 1F3FD 200D 2642 ; minimally-qualified # 🏃🏽‍♂ E4.0 man running: medium skin tone +1F3C3 1F3FE 200D 2642 FE0F ; fully-qualified # 🏃🏾‍♂️ E4.0 man running: medium-dark skin tone +1F3C3 1F3FE 200D 2642 ; minimally-qualified # 🏃🏾‍♂ E4.0 man running: medium-dark skin tone +1F3C3 1F3FF 200D 2642 FE0F ; fully-qualified # 🏃🏿‍♂️ E4.0 man running: dark skin tone +1F3C3 1F3FF 200D 2642 ; minimally-qualified # 🏃🏿‍♂ E4.0 man running: dark skin tone +1F3C3 200D 2640 FE0F ; fully-qualified # 🏃‍♀️ E4.0 woman running +1F3C3 200D 2640 ; minimally-qualified # 🏃‍♀ E4.0 woman running +1F3C3 1F3FB 200D 2640 FE0F ; fully-qualified # 🏃🏻‍♀️ E4.0 woman running: light skin tone +1F3C3 1F3FB 200D 2640 ; minimally-qualified # 🏃🏻‍♀ E4.0 woman running: light skin tone +1F3C3 1F3FC 200D 2640 FE0F ; fully-qualified # 🏃🏼‍♀️ E4.0 woman running: medium-light skin tone +1F3C3 1F3FC 200D 2640 ; minimally-qualified # 🏃🏼‍♀ E4.0 woman running: medium-light skin tone +1F3C3 1F3FD 200D 2640 FE0F ; fully-qualified # 🏃🏽‍♀️ E4.0 woman running: medium skin tone +1F3C3 1F3FD 200D 2640 ; minimally-qualified # 🏃🏽‍♀ E4.0 woman running: medium skin tone +1F3C3 1F3FE 200D 2640 FE0F ; fully-qualified # 🏃🏾‍♀️ E4.0 woman running: medium-dark skin tone +1F3C3 1F3FE 200D 2640 ; minimally-qualified # 🏃🏾‍♀ E4.0 woman running: medium-dark skin tone +1F3C3 1F3FF 200D 2640 FE0F ; fully-qualified # 🏃🏿‍♀️ E4.0 woman running: dark skin tone +1F3C3 1F3FF 200D 2640 ; minimally-qualified # 🏃🏿‍♀ E4.0 woman running: dark skin tone +1F483 ; fully-qualified # 💃 E0.6 woman dancing +1F483 1F3FB ; fully-qualified # 💃🏻 E1.0 woman dancing: light skin tone +1F483 1F3FC ; fully-qualified # 💃🏼 E1.0 woman dancing: medium-light skin tone +1F483 1F3FD ; fully-qualified # 💃🏽 E1.0 woman dancing: medium skin tone +1F483 1F3FE ; fully-qualified # 💃🏾 E1.0 woman dancing: medium-dark skin tone +1F483 1F3FF ; fully-qualified # 💃🏿 E1.0 woman dancing: dark skin tone +1F57A ; fully-qualified # 🕺 E3.0 man dancing +1F57A 1F3FB ; fully-qualified # 🕺🏻 E3.0 man dancing: light skin tone +1F57A 1F3FC ; fully-qualified # 🕺🏼 E3.0 man dancing: medium-light skin tone +1F57A 1F3FD ; fully-qualified # 🕺🏽 E3.0 man dancing: medium skin tone +1F57A 1F3FE ; fully-qualified # 🕺🏾 E3.0 man dancing: medium-dark skin tone +1F57A 1F3FF ; fully-qualified # 🕺🏿 E3.0 man dancing: dark skin tone +1F574 FE0F ; fully-qualified # 🕴️ E0.7 person in suit levitating +1F574 ; unqualified # 🕴 E0.7 person in suit levitating +1F574 1F3FB ; fully-qualified # 🕴🏻 E4.0 person in suit levitating: light skin tone +1F574 1F3FC ; fully-qualified # 🕴🏼 E4.0 person in suit levitating: medium-light skin tone +1F574 1F3FD ; fully-qualified # 🕴🏽 E4.0 person in suit levitating: medium skin tone +1F574 1F3FE ; fully-qualified # 🕴🏾 E4.0 person in suit levitating: medium-dark skin tone +1F574 1F3FF ; fully-qualified # 🕴🏿 E4.0 person in suit levitating: dark skin tone +1F46F ; fully-qualified # 👯 E0.6 people with bunny ears +1F46F 200D 2642 FE0F ; fully-qualified # 👯‍♂️ E4.0 men with bunny ears +1F46F 200D 2642 ; minimally-qualified # 👯‍♂ E4.0 men with bunny ears +1F46F 200D 2640 FE0F ; fully-qualified # 👯‍♀️ E4.0 women with bunny ears +1F46F 200D 2640 ; minimally-qualified # 👯‍♀ E4.0 women with bunny ears +1F9D6 ; fully-qualified # 🧖 E5.0 person in steamy room +1F9D6 1F3FB ; fully-qualified # 🧖🏻 E5.0 person in steamy room: light skin tone +1F9D6 1F3FC ; fully-qualified # 🧖🏼 E5.0 person in steamy room: medium-light skin tone +1F9D6 1F3FD ; fully-qualified # 🧖🏽 E5.0 person in steamy room: medium skin tone +1F9D6 1F3FE ; fully-qualified # 🧖🏾 E5.0 person in steamy room: medium-dark skin tone +1F9D6 1F3FF ; fully-qualified # 🧖🏿 E5.0 person in steamy room: dark skin tone +1F9D6 200D 2642 FE0F ; fully-qualified # 🧖‍♂️ E5.0 man in steamy room +1F9D6 200D 2642 ; minimally-qualified # 🧖‍♂ E5.0 man in steamy room +1F9D6 1F3FB 200D 2642 FE0F ; fully-qualified # 🧖🏻‍♂️ E5.0 man in steamy room: light skin tone +1F9D6 1F3FB 200D 2642 ; minimally-qualified # 🧖🏻‍♂ E5.0 man in steamy room: light skin tone +1F9D6 1F3FC 200D 2642 FE0F ; fully-qualified # 🧖🏼‍♂️ E5.0 man in steamy room: medium-light skin tone +1F9D6 1F3FC 200D 2642 ; minimally-qualified # 🧖🏼‍♂ E5.0 man in steamy room: medium-light skin tone +1F9D6 1F3FD 200D 2642 FE0F ; fully-qualified # 🧖🏽‍♂️ E5.0 man in steamy room: medium skin tone +1F9D6 1F3FD 200D 2642 ; minimally-qualified # 🧖🏽‍♂ E5.0 man in steamy room: medium skin tone +1F9D6 1F3FE 200D 2642 FE0F ; fully-qualified # 🧖🏾‍♂️ E5.0 man in steamy room: medium-dark skin tone +1F9D6 1F3FE 200D 2642 ; minimally-qualified # 🧖🏾‍♂ E5.0 man in steamy room: medium-dark skin tone +1F9D6 1F3FF 200D 2642 FE0F ; fully-qualified # 🧖🏿‍♂️ E5.0 man in steamy room: dark skin tone +1F9D6 1F3FF 200D 2642 ; minimally-qualified # 🧖🏿‍♂ E5.0 man in steamy room: dark skin tone +1F9D6 200D 2640 FE0F ; fully-qualified # 🧖‍♀️ E5.0 woman in steamy room +1F9D6 200D 2640 ; minimally-qualified # 🧖‍♀ E5.0 woman in steamy room +1F9D6 1F3FB 200D 2640 FE0F ; fully-qualified # 🧖🏻‍♀️ E5.0 woman in steamy room: light skin tone +1F9D6 1F3FB 200D 2640 ; minimally-qualified # 🧖🏻‍♀ E5.0 woman in steamy room: light skin tone +1F9D6 1F3FC 200D 2640 FE0F ; fully-qualified # 🧖🏼‍♀️ E5.0 woman in steamy room: medium-light skin tone +1F9D6 1F3FC 200D 2640 ; minimally-qualified # 🧖🏼‍♀ E5.0 woman in steamy room: medium-light skin tone +1F9D6 1F3FD 200D 2640 FE0F ; fully-qualified # 🧖🏽‍♀️ E5.0 woman in steamy room: medium skin tone +1F9D6 1F3FD 200D 2640 ; minimally-qualified # 🧖🏽‍♀ E5.0 woman in steamy room: medium skin tone +1F9D6 1F3FE 200D 2640 FE0F ; fully-qualified # 🧖🏾‍♀️ E5.0 woman in steamy room: medium-dark skin tone +1F9D6 1F3FE 200D 2640 ; minimally-qualified # 🧖🏾‍♀ E5.0 woman in steamy room: medium-dark skin tone +1F9D6 1F3FF 200D 2640 FE0F ; fully-qualified # 🧖🏿‍♀️ E5.0 woman in steamy room: dark skin tone +1F9D6 1F3FF 200D 2640 ; minimally-qualified # 🧖🏿‍♀ E5.0 woman in steamy room: dark skin tone +1F9D7 ; fully-qualified # 🧗 E5.0 person climbing +1F9D7 1F3FB ; fully-qualified # 🧗🏻 E5.0 person climbing: light skin tone +1F9D7 1F3FC ; fully-qualified # 🧗🏼 E5.0 person climbing: medium-light skin tone +1F9D7 1F3FD ; fully-qualified # 🧗🏽 E5.0 person climbing: medium skin tone +1F9D7 1F3FE ; fully-qualified # 🧗🏾 E5.0 person climbing: medium-dark skin tone +1F9D7 1F3FF ; fully-qualified # 🧗🏿 E5.0 person climbing: dark skin tone +1F9D7 200D 2642 FE0F ; fully-qualified # 🧗‍♂️ E5.0 man climbing +1F9D7 200D 2642 ; minimally-qualified # 🧗‍♂ E5.0 man climbing +1F9D7 1F3FB 200D 2642 FE0F ; fully-qualified # 🧗🏻‍♂️ E5.0 man climbing: light skin tone +1F9D7 1F3FB 200D 2642 ; minimally-qualified # 🧗🏻‍♂ E5.0 man climbing: light skin tone +1F9D7 1F3FC 200D 2642 FE0F ; fully-qualified # 🧗🏼‍♂️ E5.0 man climbing: medium-light skin tone +1F9D7 1F3FC 200D 2642 ; minimally-qualified # 🧗🏼‍♂ E5.0 man climbing: medium-light skin tone +1F9D7 1F3FD 200D 2642 FE0F ; fully-qualified # 🧗🏽‍♂️ E5.0 man climbing: medium skin tone +1F9D7 1F3FD 200D 2642 ; minimally-qualified # 🧗🏽‍♂ E5.0 man climbing: medium skin tone +1F9D7 1F3FE 200D 2642 FE0F ; fully-qualified # 🧗🏾‍♂️ E5.0 man climbing: medium-dark skin tone +1F9D7 1F3FE 200D 2642 ; minimally-qualified # 🧗🏾‍♂ E5.0 man climbing: medium-dark skin tone +1F9D7 1F3FF 200D 2642 FE0F ; fully-qualified # 🧗🏿‍♂️ E5.0 man climbing: dark skin tone +1F9D7 1F3FF 200D 2642 ; minimally-qualified # 🧗🏿‍♂ E5.0 man climbing: dark skin tone +1F9D7 200D 2640 FE0F ; fully-qualified # 🧗‍♀️ E5.0 woman climbing +1F9D7 200D 2640 ; minimally-qualified # 🧗‍♀ E5.0 woman climbing +1F9D7 1F3FB 200D 2640 FE0F ; fully-qualified # 🧗🏻‍♀️ E5.0 woman climbing: light skin tone +1F9D7 1F3FB 200D 2640 ; minimally-qualified # 🧗🏻‍♀ E5.0 woman climbing: light skin tone +1F9D7 1F3FC 200D 2640 FE0F ; fully-qualified # 🧗🏼‍♀️ E5.0 woman climbing: medium-light skin tone +1F9D7 1F3FC 200D 2640 ; minimally-qualified # 🧗🏼‍♀ E5.0 woman climbing: medium-light skin tone +1F9D7 1F3FD 200D 2640 FE0F ; fully-qualified # 🧗🏽‍♀️ E5.0 woman climbing: medium skin tone +1F9D7 1F3FD 200D 2640 ; minimally-qualified # 🧗🏽‍♀ E5.0 woman climbing: medium skin tone +1F9D7 1F3FE 200D 2640 FE0F ; fully-qualified # 🧗🏾‍♀️ E5.0 woman climbing: medium-dark skin tone +1F9D7 1F3FE 200D 2640 ; minimally-qualified # 🧗🏾‍♀ E5.0 woman climbing: medium-dark skin tone +1F9D7 1F3FF 200D 2640 FE0F ; fully-qualified # 🧗🏿‍♀️ E5.0 woman climbing: dark skin tone +1F9D7 1F3FF 200D 2640 ; minimally-qualified # 🧗🏿‍♀ E5.0 woman climbing: dark skin tone + +# subgroup: person-sport +1F93A ; fully-qualified # 🤺 E3.0 person fencing +1F3C7 ; fully-qualified # 🏇 E1.0 horse racing +1F3C7 1F3FB ; fully-qualified # 🏇🏻 E1.0 horse racing: light skin tone +1F3C7 1F3FC ; fully-qualified # 🏇🏼 E1.0 horse racing: medium-light skin tone +1F3C7 1F3FD ; fully-qualified # 🏇🏽 E1.0 horse racing: medium skin tone +1F3C7 1F3FE ; fully-qualified # 🏇🏾 E1.0 horse racing: medium-dark skin tone +1F3C7 1F3FF ; fully-qualified # 🏇🏿 E1.0 horse racing: dark skin tone +26F7 FE0F ; fully-qualified # ⛷️ E0.7 skier +26F7 ; unqualified # ⛷ E0.7 skier +1F3C2 ; fully-qualified # 🏂 E0.6 snowboarder +1F3C2 1F3FB ; fully-qualified # 🏂🏻 E1.0 snowboarder: light skin tone +1F3C2 1F3FC ; fully-qualified # 🏂🏼 E1.0 snowboarder: medium-light skin tone +1F3C2 1F3FD ; fully-qualified # 🏂🏽 E1.0 snowboarder: medium skin tone +1F3C2 1F3FE ; fully-qualified # 🏂🏾 E1.0 snowboarder: medium-dark skin tone +1F3C2 1F3FF ; fully-qualified # 🏂🏿 E1.0 snowboarder: dark skin tone +1F3CC FE0F ; fully-qualified # 🏌️ E0.7 person golfing +1F3CC ; unqualified # 🏌 E0.7 person golfing +1F3CC 1F3FB ; fully-qualified # 🏌🏻 E4.0 person golfing: light skin tone +1F3CC 1F3FC ; fully-qualified # 🏌🏼 E4.0 person golfing: medium-light skin tone +1F3CC 1F3FD ; fully-qualified # 🏌🏽 E4.0 person golfing: medium skin tone +1F3CC 1F3FE ; fully-qualified # 🏌🏾 E4.0 person golfing: medium-dark skin tone +1F3CC 1F3FF ; fully-qualified # 🏌🏿 E4.0 person golfing: dark skin tone +1F3CC FE0F 200D 2642 FE0F ; fully-qualified # 🏌️‍♂️ E4.0 man golfing +1F3CC 200D 2642 FE0F ; unqualified # 🏌‍♂️ E4.0 man golfing +1F3CC FE0F 200D 2642 ; unqualified # 🏌️‍♂ E4.0 man golfing +1F3CC 200D 2642 ; unqualified # 🏌‍♂ E4.0 man golfing +1F3CC 1F3FB 200D 2642 FE0F ; fully-qualified # 🏌🏻‍♂️ E4.0 man golfing: light skin tone +1F3CC 1F3FB 200D 2642 ; minimally-qualified # 🏌🏻‍♂ E4.0 man golfing: light skin tone +1F3CC 1F3FC 200D 2642 FE0F ; fully-qualified # 🏌🏼‍♂️ E4.0 man golfing: medium-light skin tone +1F3CC 1F3FC 200D 2642 ; minimally-qualified # 🏌🏼‍♂ E4.0 man golfing: medium-light skin tone +1F3CC 1F3FD 200D 2642 FE0F ; fully-qualified # 🏌🏽‍♂️ E4.0 man golfing: medium skin tone +1F3CC 1F3FD 200D 2642 ; minimally-qualified # 🏌🏽‍♂ E4.0 man golfing: medium skin tone +1F3CC 1F3FE 200D 2642 FE0F ; fully-qualified # 🏌🏾‍♂️ E4.0 man golfing: medium-dark skin tone +1F3CC 1F3FE 200D 2642 ; minimally-qualified # 🏌🏾‍♂ E4.0 man golfing: medium-dark skin tone +1F3CC 1F3FF 200D 2642 FE0F ; fully-qualified # 🏌🏿‍♂️ E4.0 man golfing: dark skin tone +1F3CC 1F3FF 200D 2642 ; minimally-qualified # 🏌🏿‍♂ E4.0 man golfing: dark skin tone +1F3CC FE0F 200D 2640 FE0F ; fully-qualified # 🏌️‍♀️ E4.0 woman golfing +1F3CC 200D 2640 FE0F ; unqualified # 🏌‍♀️ E4.0 woman golfing +1F3CC FE0F 200D 2640 ; unqualified # 🏌️‍♀ E4.0 woman golfing +1F3CC 200D 2640 ; unqualified # 🏌‍♀ E4.0 woman golfing +1F3CC 1F3FB 200D 2640 FE0F ; fully-qualified # 🏌🏻‍♀️ E4.0 woman golfing: light skin tone +1F3CC 1F3FB 200D 2640 ; minimally-qualified # 🏌🏻‍♀ E4.0 woman golfing: light skin tone +1F3CC 1F3FC 200D 2640 FE0F ; fully-qualified # 🏌🏼‍♀️ E4.0 woman golfing: medium-light skin tone +1F3CC 1F3FC 200D 2640 ; minimally-qualified # 🏌🏼‍♀ E4.0 woman golfing: medium-light skin tone +1F3CC 1F3FD 200D 2640 FE0F ; fully-qualified # 🏌🏽‍♀️ E4.0 woman golfing: medium skin tone +1F3CC 1F3FD 200D 2640 ; minimally-qualified # 🏌🏽‍♀ E4.0 woman golfing: medium skin tone +1F3CC 1F3FE 200D 2640 FE0F ; fully-qualified # 🏌🏾‍♀️ E4.0 woman golfing: medium-dark skin tone +1F3CC 1F3FE 200D 2640 ; minimally-qualified # 🏌🏾‍♀ E4.0 woman golfing: medium-dark skin tone +1F3CC 1F3FF 200D 2640 FE0F ; fully-qualified # 🏌🏿‍♀️ E4.0 woman golfing: dark skin tone +1F3CC 1F3FF 200D 2640 ; minimally-qualified # 🏌🏿‍♀ E4.0 woman golfing: dark skin tone +1F3C4 ; fully-qualified # 🏄 E0.6 person surfing +1F3C4 1F3FB ; fully-qualified # 🏄🏻 E1.0 person surfing: light skin tone +1F3C4 1F3FC ; fully-qualified # 🏄🏼 E1.0 person surfing: medium-light skin tone +1F3C4 1F3FD ; fully-qualified # 🏄🏽 E1.0 person surfing: medium skin tone +1F3C4 1F3FE ; fully-qualified # 🏄🏾 E1.0 person surfing: medium-dark skin tone +1F3C4 1F3FF ; fully-qualified # 🏄🏿 E1.0 person surfing: dark skin tone +1F3C4 200D 2642 FE0F ; fully-qualified # 🏄‍♂️ E4.0 man surfing +1F3C4 200D 2642 ; minimally-qualified # 🏄‍♂ E4.0 man surfing +1F3C4 1F3FB 200D 2642 FE0F ; fully-qualified # 🏄🏻‍♂️ E4.0 man surfing: light skin tone +1F3C4 1F3FB 200D 2642 ; minimally-qualified # 🏄🏻‍♂ E4.0 man surfing: light skin tone +1F3C4 1F3FC 200D 2642 FE0F ; fully-qualified # 🏄🏼‍♂️ E4.0 man surfing: medium-light skin tone +1F3C4 1F3FC 200D 2642 ; minimally-qualified # 🏄🏼‍♂ E4.0 man surfing: medium-light skin tone +1F3C4 1F3FD 200D 2642 FE0F ; fully-qualified # 🏄🏽‍♂️ E4.0 man surfing: medium skin tone +1F3C4 1F3FD 200D 2642 ; minimally-qualified # 🏄🏽‍♂ E4.0 man surfing: medium skin tone +1F3C4 1F3FE 200D 2642 FE0F ; fully-qualified # 🏄🏾‍♂️ E4.0 man surfing: medium-dark skin tone +1F3C4 1F3FE 200D 2642 ; minimally-qualified # 🏄🏾‍♂ E4.0 man surfing: medium-dark skin tone +1F3C4 1F3FF 200D 2642 FE0F ; fully-qualified # 🏄🏿‍♂️ E4.0 man surfing: dark skin tone +1F3C4 1F3FF 200D 2642 ; minimally-qualified # 🏄🏿‍♂ E4.0 man surfing: dark skin tone +1F3C4 200D 2640 FE0F ; fully-qualified # 🏄‍♀️ E4.0 woman surfing +1F3C4 200D 2640 ; minimally-qualified # 🏄‍♀ E4.0 woman surfing +1F3C4 1F3FB 200D 2640 FE0F ; fully-qualified # 🏄🏻‍♀️ E4.0 woman surfing: light skin tone +1F3C4 1F3FB 200D 2640 ; minimally-qualified # 🏄🏻‍♀ E4.0 woman surfing: light skin tone +1F3C4 1F3FC 200D 2640 FE0F ; fully-qualified # 🏄🏼‍♀️ E4.0 woman surfing: medium-light skin tone +1F3C4 1F3FC 200D 2640 ; minimally-qualified # 🏄🏼‍♀ E4.0 woman surfing: medium-light skin tone +1F3C4 1F3FD 200D 2640 FE0F ; fully-qualified # 🏄🏽‍♀️ E4.0 woman surfing: medium skin tone +1F3C4 1F3FD 200D 2640 ; minimally-qualified # 🏄🏽‍♀ E4.0 woman surfing: medium skin tone +1F3C4 1F3FE 200D 2640 FE0F ; fully-qualified # 🏄🏾‍♀️ E4.0 woman surfing: medium-dark skin tone +1F3C4 1F3FE 200D 2640 ; minimally-qualified # 🏄🏾‍♀ E4.0 woman surfing: medium-dark skin tone +1F3C4 1F3FF 200D 2640 FE0F ; fully-qualified # 🏄🏿‍♀️ E4.0 woman surfing: dark skin tone +1F3C4 1F3FF 200D 2640 ; minimally-qualified # 🏄🏿‍♀ E4.0 woman surfing: dark skin tone +1F6A3 ; fully-qualified # 🚣 E1.0 person rowing boat +1F6A3 1F3FB ; fully-qualified # 🚣🏻 E1.0 person rowing boat: light skin tone +1F6A3 1F3FC ; fully-qualified # 🚣🏼 E1.0 person rowing boat: medium-light skin tone +1F6A3 1F3FD ; fully-qualified # 🚣🏽 E1.0 person rowing boat: medium skin tone +1F6A3 1F3FE ; fully-qualified # 🚣🏾 E1.0 person rowing boat: medium-dark skin tone +1F6A3 1F3FF ; fully-qualified # 🚣🏿 E1.0 person rowing boat: dark skin tone +1F6A3 200D 2642 FE0F ; fully-qualified # 🚣‍♂️ E4.0 man rowing boat +1F6A3 200D 2642 ; minimally-qualified # 🚣‍♂ E4.0 man rowing boat +1F6A3 1F3FB 200D 2642 FE0F ; fully-qualified # 🚣🏻‍♂️ E4.0 man rowing boat: light skin tone +1F6A3 1F3FB 200D 2642 ; minimally-qualified # 🚣🏻‍♂ E4.0 man rowing boat: light skin tone +1F6A3 1F3FC 200D 2642 FE0F ; fully-qualified # 🚣🏼‍♂️ E4.0 man rowing boat: medium-light skin tone +1F6A3 1F3FC 200D 2642 ; minimally-qualified # 🚣🏼‍♂ E4.0 man rowing boat: medium-light skin tone +1F6A3 1F3FD 200D 2642 FE0F ; fully-qualified # 🚣🏽‍♂️ E4.0 man rowing boat: medium skin tone +1F6A3 1F3FD 200D 2642 ; minimally-qualified # 🚣🏽‍♂ E4.0 man rowing boat: medium skin tone +1F6A3 1F3FE 200D 2642 FE0F ; fully-qualified # 🚣🏾‍♂️ E4.0 man rowing boat: medium-dark skin tone +1F6A3 1F3FE 200D 2642 ; minimally-qualified # 🚣🏾‍♂ E4.0 man rowing boat: medium-dark skin tone +1F6A3 1F3FF 200D 2642 FE0F ; fully-qualified # 🚣🏿‍♂️ E4.0 man rowing boat: dark skin tone +1F6A3 1F3FF 200D 2642 ; minimally-qualified # 🚣🏿‍♂ E4.0 man rowing boat: dark skin tone +1F6A3 200D 2640 FE0F ; fully-qualified # 🚣‍♀️ E4.0 woman rowing boat +1F6A3 200D 2640 ; minimally-qualified # 🚣‍♀ E4.0 woman rowing boat +1F6A3 1F3FB 200D 2640 FE0F ; fully-qualified # 🚣🏻‍♀️ E4.0 woman rowing boat: light skin tone +1F6A3 1F3FB 200D 2640 ; minimally-qualified # 🚣🏻‍♀ E4.0 woman rowing boat: light skin tone +1F6A3 1F3FC 200D 2640 FE0F ; fully-qualified # 🚣🏼‍♀️ E4.0 woman rowing boat: medium-light skin tone +1F6A3 1F3FC 200D 2640 ; minimally-qualified # 🚣🏼‍♀ E4.0 woman rowing boat: medium-light skin tone +1F6A3 1F3FD 200D 2640 FE0F ; fully-qualified # 🚣🏽‍♀️ E4.0 woman rowing boat: medium skin tone +1F6A3 1F3FD 200D 2640 ; minimally-qualified # 🚣🏽‍♀ E4.0 woman rowing boat: medium skin tone +1F6A3 1F3FE 200D 2640 FE0F ; fully-qualified # 🚣🏾‍♀️ E4.0 woman rowing boat: medium-dark skin tone +1F6A3 1F3FE 200D 2640 ; minimally-qualified # 🚣🏾‍♀ E4.0 woman rowing boat: medium-dark skin tone +1F6A3 1F3FF 200D 2640 FE0F ; fully-qualified # 🚣🏿‍♀️ E4.0 woman rowing boat: dark skin tone +1F6A3 1F3FF 200D 2640 ; minimally-qualified # 🚣🏿‍♀ E4.0 woman rowing boat: dark skin tone +1F3CA ; fully-qualified # 🏊 E0.6 person swimming +1F3CA 1F3FB ; fully-qualified # 🏊🏻 E1.0 person swimming: light skin tone +1F3CA 1F3FC ; fully-qualified # 🏊🏼 E1.0 person swimming: medium-light skin tone +1F3CA 1F3FD ; fully-qualified # 🏊🏽 E1.0 person swimming: medium skin tone +1F3CA 1F3FE ; fully-qualified # 🏊🏾 E1.0 person swimming: medium-dark skin tone +1F3CA 1F3FF ; fully-qualified # 🏊🏿 E1.0 person swimming: dark skin tone +1F3CA 200D 2642 FE0F ; fully-qualified # 🏊‍♂️ E4.0 man swimming +1F3CA 200D 2642 ; minimally-qualified # 🏊‍♂ E4.0 man swimming +1F3CA 1F3FB 200D 2642 FE0F ; fully-qualified # 🏊🏻‍♂️ E4.0 man swimming: light skin tone +1F3CA 1F3FB 200D 2642 ; minimally-qualified # 🏊🏻‍♂ E4.0 man swimming: light skin tone +1F3CA 1F3FC 200D 2642 FE0F ; fully-qualified # 🏊🏼‍♂️ E4.0 man swimming: medium-light skin tone +1F3CA 1F3FC 200D 2642 ; minimally-qualified # 🏊🏼‍♂ E4.0 man swimming: medium-light skin tone +1F3CA 1F3FD 200D 2642 FE0F ; fully-qualified # 🏊🏽‍♂️ E4.0 man swimming: medium skin tone +1F3CA 1F3FD 200D 2642 ; minimally-qualified # 🏊🏽‍♂ E4.0 man swimming: medium skin tone +1F3CA 1F3FE 200D 2642 FE0F ; fully-qualified # 🏊🏾‍♂️ E4.0 man swimming: medium-dark skin tone +1F3CA 1F3FE 200D 2642 ; minimally-qualified # 🏊🏾‍♂ E4.0 man swimming: medium-dark skin tone +1F3CA 1F3FF 200D 2642 FE0F ; fully-qualified # 🏊🏿‍♂️ E4.0 man swimming: dark skin tone +1F3CA 1F3FF 200D 2642 ; minimally-qualified # 🏊🏿‍♂ E4.0 man swimming: dark skin tone +1F3CA 200D 2640 FE0F ; fully-qualified # 🏊‍♀️ E4.0 woman swimming +1F3CA 200D 2640 ; minimally-qualified # 🏊‍♀ E4.0 woman swimming +1F3CA 1F3FB 200D 2640 FE0F ; fully-qualified # 🏊🏻‍♀️ E4.0 woman swimming: light skin tone +1F3CA 1F3FB 200D 2640 ; minimally-qualified # 🏊🏻‍♀ E4.0 woman swimming: light skin tone +1F3CA 1F3FC 200D 2640 FE0F ; fully-qualified # 🏊🏼‍♀️ E4.0 woman swimming: medium-light skin tone +1F3CA 1F3FC 200D 2640 ; minimally-qualified # 🏊🏼‍♀ E4.0 woman swimming: medium-light skin tone +1F3CA 1F3FD 200D 2640 FE0F ; fully-qualified # 🏊🏽‍♀️ E4.0 woman swimming: medium skin tone +1F3CA 1F3FD 200D 2640 ; minimally-qualified # 🏊🏽‍♀ E4.0 woman swimming: medium skin tone +1F3CA 1F3FE 200D 2640 FE0F ; fully-qualified # 🏊🏾‍♀️ E4.0 woman swimming: medium-dark skin tone +1F3CA 1F3FE 200D 2640 ; minimally-qualified # 🏊🏾‍♀ E4.0 woman swimming: medium-dark skin tone +1F3CA 1F3FF 200D 2640 FE0F ; fully-qualified # 🏊🏿‍♀️ E4.0 woman swimming: dark skin tone +1F3CA 1F3FF 200D 2640 ; minimally-qualified # 🏊🏿‍♀ E4.0 woman swimming: dark skin tone +26F9 FE0F ; fully-qualified # ⛹️ E0.7 person bouncing ball +26F9 ; unqualified # ⛹ E0.7 person bouncing ball +26F9 1F3FB ; fully-qualified # ⛹🏻 E2.0 person bouncing ball: light skin tone +26F9 1F3FC ; fully-qualified # ⛹🏼 E2.0 person bouncing ball: medium-light skin tone +26F9 1F3FD ; fully-qualified # ⛹🏽 E2.0 person bouncing ball: medium skin tone +26F9 1F3FE ; fully-qualified # ⛹🏾 E2.0 person bouncing ball: medium-dark skin tone +26F9 1F3FF ; fully-qualified # ⛹🏿 E2.0 person bouncing ball: dark skin tone +26F9 FE0F 200D 2642 FE0F ; fully-qualified # ⛹️‍♂️ E4.0 man bouncing ball +26F9 200D 2642 FE0F ; unqualified # ⛹‍♂️ E4.0 man bouncing ball +26F9 FE0F 200D 2642 ; unqualified # ⛹️‍♂ E4.0 man bouncing ball +26F9 200D 2642 ; unqualified # ⛹‍♂ E4.0 man bouncing ball +26F9 1F3FB 200D 2642 FE0F ; fully-qualified # ⛹🏻‍♂️ E4.0 man bouncing ball: light skin tone +26F9 1F3FB 200D 2642 ; minimally-qualified # ⛹🏻‍♂ E4.0 man bouncing ball: light skin tone +26F9 1F3FC 200D 2642 FE0F ; fully-qualified # ⛹🏼‍♂️ E4.0 man bouncing ball: medium-light skin tone +26F9 1F3FC 200D 2642 ; minimally-qualified # ⛹🏼‍♂ E4.0 man bouncing ball: medium-light skin tone +26F9 1F3FD 200D 2642 FE0F ; fully-qualified # ⛹🏽‍♂️ E4.0 man bouncing ball: medium skin tone +26F9 1F3FD 200D 2642 ; minimally-qualified # ⛹🏽‍♂ E4.0 man bouncing ball: medium skin tone +26F9 1F3FE 200D 2642 FE0F ; fully-qualified # ⛹🏾‍♂️ E4.0 man bouncing ball: medium-dark skin tone +26F9 1F3FE 200D 2642 ; minimally-qualified # ⛹🏾‍♂ E4.0 man bouncing ball: medium-dark skin tone +26F9 1F3FF 200D 2642 FE0F ; fully-qualified # ⛹🏿‍♂️ E4.0 man bouncing ball: dark skin tone +26F9 1F3FF 200D 2642 ; minimally-qualified # ⛹🏿‍♂ E4.0 man bouncing ball: dark skin tone +26F9 FE0F 200D 2640 FE0F ; fully-qualified # ⛹️‍♀️ E4.0 woman bouncing ball +26F9 200D 2640 FE0F ; unqualified # ⛹‍♀️ E4.0 woman bouncing ball +26F9 FE0F 200D 2640 ; unqualified # ⛹️‍♀ E4.0 woman bouncing ball +26F9 200D 2640 ; unqualified # ⛹‍♀ E4.0 woman bouncing ball +26F9 1F3FB 200D 2640 FE0F ; fully-qualified # ⛹🏻‍♀️ E4.0 woman bouncing ball: light skin tone +26F9 1F3FB 200D 2640 ; minimally-qualified # ⛹🏻‍♀ E4.0 woman bouncing ball: light skin tone +26F9 1F3FC 200D 2640 FE0F ; fully-qualified # ⛹🏼‍♀️ E4.0 woman bouncing ball: medium-light skin tone +26F9 1F3FC 200D 2640 ; minimally-qualified # ⛹🏼‍♀ E4.0 woman bouncing ball: medium-light skin tone +26F9 1F3FD 200D 2640 FE0F ; fully-qualified # ⛹🏽‍♀️ E4.0 woman bouncing ball: medium skin tone +26F9 1F3FD 200D 2640 ; minimally-qualified # ⛹🏽‍♀ E4.0 woman bouncing ball: medium skin tone +26F9 1F3FE 200D 2640 FE0F ; fully-qualified # ⛹🏾‍♀️ E4.0 woman bouncing ball: medium-dark skin tone +26F9 1F3FE 200D 2640 ; minimally-qualified # ⛹🏾‍♀ E4.0 woman bouncing ball: medium-dark skin tone +26F9 1F3FF 200D 2640 FE0F ; fully-qualified # ⛹🏿‍♀️ E4.0 woman bouncing ball: dark skin tone +26F9 1F3FF 200D 2640 ; minimally-qualified # ⛹🏿‍♀ E4.0 woman bouncing ball: dark skin tone +1F3CB FE0F ; fully-qualified # 🏋️ E0.7 person lifting weights +1F3CB ; unqualified # 🏋 E0.7 person lifting weights +1F3CB 1F3FB ; fully-qualified # 🏋🏻 E2.0 person lifting weights: light skin tone +1F3CB 1F3FC ; fully-qualified # 🏋🏼 E2.0 person lifting weights: medium-light skin tone +1F3CB 1F3FD ; fully-qualified # 🏋🏽 E2.0 person lifting weights: medium skin tone +1F3CB 1F3FE ; fully-qualified # 🏋🏾 E2.0 person lifting weights: medium-dark skin tone +1F3CB 1F3FF ; fully-qualified # 🏋🏿 E2.0 person lifting weights: dark skin tone +1F3CB FE0F 200D 2642 FE0F ; fully-qualified # 🏋️‍♂️ E4.0 man lifting weights +1F3CB 200D 2642 FE0F ; unqualified # 🏋‍♂️ E4.0 man lifting weights +1F3CB FE0F 200D 2642 ; unqualified # 🏋️‍♂ E4.0 man lifting weights +1F3CB 200D 2642 ; unqualified # 🏋‍♂ E4.0 man lifting weights +1F3CB 1F3FB 200D 2642 FE0F ; fully-qualified # 🏋🏻‍♂️ E4.0 man lifting weights: light skin tone +1F3CB 1F3FB 200D 2642 ; minimally-qualified # 🏋🏻‍♂ E4.0 man lifting weights: light skin tone +1F3CB 1F3FC 200D 2642 FE0F ; fully-qualified # 🏋🏼‍♂️ E4.0 man lifting weights: medium-light skin tone +1F3CB 1F3FC 200D 2642 ; minimally-qualified # 🏋🏼‍♂ E4.0 man lifting weights: medium-light skin tone +1F3CB 1F3FD 200D 2642 FE0F ; fully-qualified # 🏋🏽‍♂️ E4.0 man lifting weights: medium skin tone +1F3CB 1F3FD 200D 2642 ; minimally-qualified # 🏋🏽‍♂ E4.0 man lifting weights: medium skin tone +1F3CB 1F3FE 200D 2642 FE0F ; fully-qualified # 🏋🏾‍♂️ E4.0 man lifting weights: medium-dark skin tone +1F3CB 1F3FE 200D 2642 ; minimally-qualified # 🏋🏾‍♂ E4.0 man lifting weights: medium-dark skin tone +1F3CB 1F3FF 200D 2642 FE0F ; fully-qualified # 🏋🏿‍♂️ E4.0 man lifting weights: dark skin tone +1F3CB 1F3FF 200D 2642 ; minimally-qualified # 🏋🏿‍♂ E4.0 man lifting weights: dark skin tone +1F3CB FE0F 200D 2640 FE0F ; fully-qualified # 🏋️‍♀️ E4.0 woman lifting weights +1F3CB 200D 2640 FE0F ; unqualified # 🏋‍♀️ E4.0 woman lifting weights +1F3CB FE0F 200D 2640 ; unqualified # 🏋️‍♀ E4.0 woman lifting weights +1F3CB 200D 2640 ; unqualified # 🏋‍♀ E4.0 woman lifting weights +1F3CB 1F3FB 200D 2640 FE0F ; fully-qualified # 🏋🏻‍♀️ E4.0 woman lifting weights: light skin tone +1F3CB 1F3FB 200D 2640 ; minimally-qualified # 🏋🏻‍♀ E4.0 woman lifting weights: light skin tone +1F3CB 1F3FC 200D 2640 FE0F ; fully-qualified # 🏋🏼‍♀️ E4.0 woman lifting weights: medium-light skin tone +1F3CB 1F3FC 200D 2640 ; minimally-qualified # 🏋🏼‍♀ E4.0 woman lifting weights: medium-light skin tone +1F3CB 1F3FD 200D 2640 FE0F ; fully-qualified # 🏋🏽‍♀️ E4.0 woman lifting weights: medium skin tone +1F3CB 1F3FD 200D 2640 ; minimally-qualified # 🏋🏽‍♀ E4.0 woman lifting weights: medium skin tone +1F3CB 1F3FE 200D 2640 FE0F ; fully-qualified # 🏋🏾‍♀️ E4.0 woman lifting weights: medium-dark skin tone +1F3CB 1F3FE 200D 2640 ; minimally-qualified # 🏋🏾‍♀ E4.0 woman lifting weights: medium-dark skin tone +1F3CB 1F3FF 200D 2640 FE0F ; fully-qualified # 🏋🏿‍♀️ E4.0 woman lifting weights: dark skin tone +1F3CB 1F3FF 200D 2640 ; minimally-qualified # 🏋🏿‍♀ E4.0 woman lifting weights: dark skin tone +1F6B4 ; fully-qualified # 🚴 E1.0 person biking +1F6B4 1F3FB ; fully-qualified # 🚴🏻 E1.0 person biking: light skin tone +1F6B4 1F3FC ; fully-qualified # 🚴🏼 E1.0 person biking: medium-light skin tone +1F6B4 1F3FD ; fully-qualified # 🚴🏽 E1.0 person biking: medium skin tone +1F6B4 1F3FE ; fully-qualified # 🚴🏾 E1.0 person biking: medium-dark skin tone +1F6B4 1F3FF ; fully-qualified # 🚴🏿 E1.0 person biking: dark skin tone +1F6B4 200D 2642 FE0F ; fully-qualified # 🚴‍♂️ E4.0 man biking +1F6B4 200D 2642 ; minimally-qualified # 🚴‍♂ E4.0 man biking +1F6B4 1F3FB 200D 2642 FE0F ; fully-qualified # 🚴🏻‍♂️ E4.0 man biking: light skin tone +1F6B4 1F3FB 200D 2642 ; minimally-qualified # 🚴🏻‍♂ E4.0 man biking: light skin tone +1F6B4 1F3FC 200D 2642 FE0F ; fully-qualified # 🚴🏼‍♂️ E4.0 man biking: medium-light skin tone +1F6B4 1F3FC 200D 2642 ; minimally-qualified # 🚴🏼‍♂ E4.0 man biking: medium-light skin tone +1F6B4 1F3FD 200D 2642 FE0F ; fully-qualified # 🚴🏽‍♂️ E4.0 man biking: medium skin tone +1F6B4 1F3FD 200D 2642 ; minimally-qualified # 🚴🏽‍♂ E4.0 man biking: medium skin tone +1F6B4 1F3FE 200D 2642 FE0F ; fully-qualified # 🚴🏾‍♂️ E4.0 man biking: medium-dark skin tone +1F6B4 1F3FE 200D 2642 ; minimally-qualified # 🚴🏾‍♂ E4.0 man biking: medium-dark skin tone +1F6B4 1F3FF 200D 2642 FE0F ; fully-qualified # 🚴🏿‍♂️ E4.0 man biking: dark skin tone +1F6B4 1F3FF 200D 2642 ; minimally-qualified # 🚴🏿‍♂ E4.0 man biking: dark skin tone +1F6B4 200D 2640 FE0F ; fully-qualified # 🚴‍♀️ E4.0 woman biking +1F6B4 200D 2640 ; minimally-qualified # 🚴‍♀ E4.0 woman biking +1F6B4 1F3FB 200D 2640 FE0F ; fully-qualified # 🚴🏻‍♀️ E4.0 woman biking: light skin tone +1F6B4 1F3FB 200D 2640 ; minimally-qualified # 🚴🏻‍♀ E4.0 woman biking: light skin tone +1F6B4 1F3FC 200D 2640 FE0F ; fully-qualified # 🚴🏼‍♀️ E4.0 woman biking: medium-light skin tone +1F6B4 1F3FC 200D 2640 ; minimally-qualified # 🚴🏼‍♀ E4.0 woman biking: medium-light skin tone +1F6B4 1F3FD 200D 2640 FE0F ; fully-qualified # 🚴🏽‍♀️ E4.0 woman biking: medium skin tone +1F6B4 1F3FD 200D 2640 ; minimally-qualified # 🚴🏽‍♀ E4.0 woman biking: medium skin tone +1F6B4 1F3FE 200D 2640 FE0F ; fully-qualified # 🚴🏾‍♀️ E4.0 woman biking: medium-dark skin tone +1F6B4 1F3FE 200D 2640 ; minimally-qualified # 🚴🏾‍♀ E4.0 woman biking: medium-dark skin tone +1F6B4 1F3FF 200D 2640 FE0F ; fully-qualified # 🚴🏿‍♀️ E4.0 woman biking: dark skin tone +1F6B4 1F3FF 200D 2640 ; minimally-qualified # 🚴🏿‍♀ E4.0 woman biking: dark skin tone +1F6B5 ; fully-qualified # 🚵 E1.0 person mountain biking +1F6B5 1F3FB ; fully-qualified # 🚵🏻 E1.0 person mountain biking: light skin tone +1F6B5 1F3FC ; fully-qualified # 🚵🏼 E1.0 person mountain biking: medium-light skin tone +1F6B5 1F3FD ; fully-qualified # 🚵🏽 E1.0 person mountain biking: medium skin tone +1F6B5 1F3FE ; fully-qualified # 🚵🏾 E1.0 person mountain biking: medium-dark skin tone +1F6B5 1F3FF ; fully-qualified # 🚵🏿 E1.0 person mountain biking: dark skin tone +1F6B5 200D 2642 FE0F ; fully-qualified # 🚵‍♂️ E4.0 man mountain biking +1F6B5 200D 2642 ; minimally-qualified # 🚵‍♂ E4.0 man mountain biking +1F6B5 1F3FB 200D 2642 FE0F ; fully-qualified # 🚵🏻‍♂️ E4.0 man mountain biking: light skin tone +1F6B5 1F3FB 200D 2642 ; minimally-qualified # 🚵🏻‍♂ E4.0 man mountain biking: light skin tone +1F6B5 1F3FC 200D 2642 FE0F ; fully-qualified # 🚵🏼‍♂️ E4.0 man mountain biking: medium-light skin tone +1F6B5 1F3FC 200D 2642 ; minimally-qualified # 🚵🏼‍♂ E4.0 man mountain biking: medium-light skin tone +1F6B5 1F3FD 200D 2642 FE0F ; fully-qualified # 🚵🏽‍♂️ E4.0 man mountain biking: medium skin tone +1F6B5 1F3FD 200D 2642 ; minimally-qualified # 🚵🏽‍♂ E4.0 man mountain biking: medium skin tone +1F6B5 1F3FE 200D 2642 FE0F ; fully-qualified # 🚵🏾‍♂️ E4.0 man mountain biking: medium-dark skin tone +1F6B5 1F3FE 200D 2642 ; minimally-qualified # 🚵🏾‍♂ E4.0 man mountain biking: medium-dark skin tone +1F6B5 1F3FF 200D 2642 FE0F ; fully-qualified # 🚵🏿‍♂️ E4.0 man mountain biking: dark skin tone +1F6B5 1F3FF 200D 2642 ; minimally-qualified # 🚵🏿‍♂ E4.0 man mountain biking: dark skin tone +1F6B5 200D 2640 FE0F ; fully-qualified # 🚵‍♀️ E4.0 woman mountain biking +1F6B5 200D 2640 ; minimally-qualified # 🚵‍♀ E4.0 woman mountain biking +1F6B5 1F3FB 200D 2640 FE0F ; fully-qualified # 🚵🏻‍♀️ E4.0 woman mountain biking: light skin tone +1F6B5 1F3FB 200D 2640 ; minimally-qualified # 🚵🏻‍♀ E4.0 woman mountain biking: light skin tone +1F6B5 1F3FC 200D 2640 FE0F ; fully-qualified # 🚵🏼‍♀️ E4.0 woman mountain biking: medium-light skin tone +1F6B5 1F3FC 200D 2640 ; minimally-qualified # 🚵🏼‍♀ E4.0 woman mountain biking: medium-light skin tone +1F6B5 1F3FD 200D 2640 FE0F ; fully-qualified # 🚵🏽‍♀️ E4.0 woman mountain biking: medium skin tone +1F6B5 1F3FD 200D 2640 ; minimally-qualified # 🚵🏽‍♀ E4.0 woman mountain biking: medium skin tone +1F6B5 1F3FE 200D 2640 FE0F ; fully-qualified # 🚵🏾‍♀️ E4.0 woman mountain biking: medium-dark skin tone +1F6B5 1F3FE 200D 2640 ; minimally-qualified # 🚵🏾‍♀ E4.0 woman mountain biking: medium-dark skin tone +1F6B5 1F3FF 200D 2640 FE0F ; fully-qualified # 🚵🏿‍♀️ E4.0 woman mountain biking: dark skin tone +1F6B5 1F3FF 200D 2640 ; minimally-qualified # 🚵🏿‍♀ E4.0 woman mountain biking: dark skin tone +1F938 ; fully-qualified # 🤸 E3.0 person cartwheeling +1F938 1F3FB ; fully-qualified # 🤸🏻 E3.0 person cartwheeling: light skin tone +1F938 1F3FC ; fully-qualified # 🤸🏼 E3.0 person cartwheeling: medium-light skin tone +1F938 1F3FD ; fully-qualified # 🤸🏽 E3.0 person cartwheeling: medium skin tone +1F938 1F3FE ; fully-qualified # 🤸🏾 E3.0 person cartwheeling: medium-dark skin tone +1F938 1F3FF ; fully-qualified # 🤸🏿 E3.0 person cartwheeling: dark skin tone +1F938 200D 2642 FE0F ; fully-qualified # 🤸‍♂️ E4.0 man cartwheeling +1F938 200D 2642 ; minimally-qualified # 🤸‍♂ E4.0 man cartwheeling +1F938 1F3FB 200D 2642 FE0F ; fully-qualified # 🤸🏻‍♂️ E4.0 man cartwheeling: light skin tone +1F938 1F3FB 200D 2642 ; minimally-qualified # 🤸🏻‍♂ E4.0 man cartwheeling: light skin tone +1F938 1F3FC 200D 2642 FE0F ; fully-qualified # 🤸🏼‍♂️ E4.0 man cartwheeling: medium-light skin tone +1F938 1F3FC 200D 2642 ; minimally-qualified # 🤸🏼‍♂ E4.0 man cartwheeling: medium-light skin tone +1F938 1F3FD 200D 2642 FE0F ; fully-qualified # 🤸🏽‍♂️ E4.0 man cartwheeling: medium skin tone +1F938 1F3FD 200D 2642 ; minimally-qualified # 🤸🏽‍♂ E4.0 man cartwheeling: medium skin tone +1F938 1F3FE 200D 2642 FE0F ; fully-qualified # 🤸🏾‍♂️ E4.0 man cartwheeling: medium-dark skin tone +1F938 1F3FE 200D 2642 ; minimally-qualified # 🤸🏾‍♂ E4.0 man cartwheeling: medium-dark skin tone +1F938 1F3FF 200D 2642 FE0F ; fully-qualified # 🤸🏿‍♂️ E4.0 man cartwheeling: dark skin tone +1F938 1F3FF 200D 2642 ; minimally-qualified # 🤸🏿‍♂ E4.0 man cartwheeling: dark skin tone +1F938 200D 2640 FE0F ; fully-qualified # 🤸‍♀️ E4.0 woman cartwheeling +1F938 200D 2640 ; minimally-qualified # 🤸‍♀ E4.0 woman cartwheeling +1F938 1F3FB 200D 2640 FE0F ; fully-qualified # 🤸🏻‍♀️ E4.0 woman cartwheeling: light skin tone +1F938 1F3FB 200D 2640 ; minimally-qualified # 🤸🏻‍♀ E4.0 woman cartwheeling: light skin tone +1F938 1F3FC 200D 2640 FE0F ; fully-qualified # 🤸🏼‍♀️ E4.0 woman cartwheeling: medium-light skin tone +1F938 1F3FC 200D 2640 ; minimally-qualified # 🤸🏼‍♀ E4.0 woman cartwheeling: medium-light skin tone +1F938 1F3FD 200D 2640 FE0F ; fully-qualified # 🤸🏽‍♀️ E4.0 woman cartwheeling: medium skin tone +1F938 1F3FD 200D 2640 ; minimally-qualified # 🤸🏽‍♀ E4.0 woman cartwheeling: medium skin tone +1F938 1F3FE 200D 2640 FE0F ; fully-qualified # 🤸🏾‍♀️ E4.0 woman cartwheeling: medium-dark skin tone +1F938 1F3FE 200D 2640 ; minimally-qualified # 🤸🏾‍♀ E4.0 woman cartwheeling: medium-dark skin tone +1F938 1F3FF 200D 2640 FE0F ; fully-qualified # 🤸🏿‍♀️ E4.0 woman cartwheeling: dark skin tone +1F938 1F3FF 200D 2640 ; minimally-qualified # 🤸🏿‍♀ E4.0 woman cartwheeling: dark skin tone +1F93C ; fully-qualified # 🤼 E3.0 people wrestling +1F93C 200D 2642 FE0F ; fully-qualified # 🤼‍♂️ E4.0 men wrestling +1F93C 200D 2642 ; minimally-qualified # 🤼‍♂ E4.0 men wrestling +1F93C 200D 2640 FE0F ; fully-qualified # 🤼‍♀️ E4.0 women wrestling +1F93C 200D 2640 ; minimally-qualified # 🤼‍♀ E4.0 women wrestling +1F93D ; fully-qualified # 🤽 E3.0 person playing water polo +1F93D 1F3FB ; fully-qualified # 🤽🏻 E3.0 person playing water polo: light skin tone +1F93D 1F3FC ; fully-qualified # 🤽🏼 E3.0 person playing water polo: medium-light skin tone +1F93D 1F3FD ; fully-qualified # 🤽🏽 E3.0 person playing water polo: medium skin tone +1F93D 1F3FE ; fully-qualified # 🤽🏾 E3.0 person playing water polo: medium-dark skin tone +1F93D 1F3FF ; fully-qualified # 🤽🏿 E3.0 person playing water polo: dark skin tone +1F93D 200D 2642 FE0F ; fully-qualified # 🤽‍♂️ E4.0 man playing water polo +1F93D 200D 2642 ; minimally-qualified # 🤽‍♂ E4.0 man playing water polo +1F93D 1F3FB 200D 2642 FE0F ; fully-qualified # 🤽🏻‍♂️ E4.0 man playing water polo: light skin tone +1F93D 1F3FB 200D 2642 ; minimally-qualified # 🤽🏻‍♂ E4.0 man playing water polo: light skin tone +1F93D 1F3FC 200D 2642 FE0F ; fully-qualified # 🤽🏼‍♂️ E4.0 man playing water polo: medium-light skin tone +1F93D 1F3FC 200D 2642 ; minimally-qualified # 🤽🏼‍♂ E4.0 man playing water polo: medium-light skin tone +1F93D 1F3FD 200D 2642 FE0F ; fully-qualified # 🤽🏽‍♂️ E4.0 man playing water polo: medium skin tone +1F93D 1F3FD 200D 2642 ; minimally-qualified # 🤽🏽‍♂ E4.0 man playing water polo: medium skin tone +1F93D 1F3FE 200D 2642 FE0F ; fully-qualified # 🤽🏾‍♂️ E4.0 man playing water polo: medium-dark skin tone +1F93D 1F3FE 200D 2642 ; minimally-qualified # 🤽🏾‍♂ E4.0 man playing water polo: medium-dark skin tone +1F93D 1F3FF 200D 2642 FE0F ; fully-qualified # 🤽🏿‍♂️ E4.0 man playing water polo: dark skin tone +1F93D 1F3FF 200D 2642 ; minimally-qualified # 🤽🏿‍♂ E4.0 man playing water polo: dark skin tone +1F93D 200D 2640 FE0F ; fully-qualified # 🤽‍♀️ E4.0 woman playing water polo +1F93D 200D 2640 ; minimally-qualified # 🤽‍♀ E4.0 woman playing water polo +1F93D 1F3FB 200D 2640 FE0F ; fully-qualified # 🤽🏻‍♀️ E4.0 woman playing water polo: light skin tone +1F93D 1F3FB 200D 2640 ; minimally-qualified # 🤽🏻‍♀ E4.0 woman playing water polo: light skin tone +1F93D 1F3FC 200D 2640 FE0F ; fully-qualified # 🤽🏼‍♀️ E4.0 woman playing water polo: medium-light skin tone +1F93D 1F3FC 200D 2640 ; minimally-qualified # 🤽🏼‍♀ E4.0 woman playing water polo: medium-light skin tone +1F93D 1F3FD 200D 2640 FE0F ; fully-qualified # 🤽🏽‍♀️ E4.0 woman playing water polo: medium skin tone +1F93D 1F3FD 200D 2640 ; minimally-qualified # 🤽🏽‍♀ E4.0 woman playing water polo: medium skin tone +1F93D 1F3FE 200D 2640 FE0F ; fully-qualified # 🤽🏾‍♀️ E4.0 woman playing water polo: medium-dark skin tone +1F93D 1F3FE 200D 2640 ; minimally-qualified # 🤽🏾‍♀ E4.0 woman playing water polo: medium-dark skin tone +1F93D 1F3FF 200D 2640 FE0F ; fully-qualified # 🤽🏿‍♀️ E4.0 woman playing water polo: dark skin tone +1F93D 1F3FF 200D 2640 ; minimally-qualified # 🤽🏿‍♀ E4.0 woman playing water polo: dark skin tone +1F93E ; fully-qualified # 🤾 E3.0 person playing handball +1F93E 1F3FB ; fully-qualified # 🤾🏻 E3.0 person playing handball: light skin tone +1F93E 1F3FC ; fully-qualified # 🤾🏼 E3.0 person playing handball: medium-light skin tone +1F93E 1F3FD ; fully-qualified # 🤾🏽 E3.0 person playing handball: medium skin tone +1F93E 1F3FE ; fully-qualified # 🤾🏾 E3.0 person playing handball: medium-dark skin tone +1F93E 1F3FF ; fully-qualified # 🤾🏿 E3.0 person playing handball: dark skin tone +1F93E 200D 2642 FE0F ; fully-qualified # 🤾‍♂️ E4.0 man playing handball +1F93E 200D 2642 ; minimally-qualified # 🤾‍♂ E4.0 man playing handball +1F93E 1F3FB 200D 2642 FE0F ; fully-qualified # 🤾🏻‍♂️ E4.0 man playing handball: light skin tone +1F93E 1F3FB 200D 2642 ; minimally-qualified # 🤾🏻‍♂ E4.0 man playing handball: light skin tone +1F93E 1F3FC 200D 2642 FE0F ; fully-qualified # 🤾🏼‍♂️ E4.0 man playing handball: medium-light skin tone +1F93E 1F3FC 200D 2642 ; minimally-qualified # 🤾🏼‍♂ E4.0 man playing handball: medium-light skin tone +1F93E 1F3FD 200D 2642 FE0F ; fully-qualified # 🤾🏽‍♂️ E4.0 man playing handball: medium skin tone +1F93E 1F3FD 200D 2642 ; minimally-qualified # 🤾🏽‍♂ E4.0 man playing handball: medium skin tone +1F93E 1F3FE 200D 2642 FE0F ; fully-qualified # 🤾🏾‍♂️ E4.0 man playing handball: medium-dark skin tone +1F93E 1F3FE 200D 2642 ; minimally-qualified # 🤾🏾‍♂ E4.0 man playing handball: medium-dark skin tone +1F93E 1F3FF 200D 2642 FE0F ; fully-qualified # 🤾🏿‍♂️ E4.0 man playing handball: dark skin tone +1F93E 1F3FF 200D 2642 ; minimally-qualified # 🤾🏿‍♂ E4.0 man playing handball: dark skin tone +1F93E 200D 2640 FE0F ; fully-qualified # 🤾‍♀️ E4.0 woman playing handball +1F93E 200D 2640 ; minimally-qualified # 🤾‍♀ E4.0 woman playing handball +1F93E 1F3FB 200D 2640 FE0F ; fully-qualified # 🤾🏻‍♀️ E4.0 woman playing handball: light skin tone +1F93E 1F3FB 200D 2640 ; minimally-qualified # 🤾🏻‍♀ E4.0 woman playing handball: light skin tone +1F93E 1F3FC 200D 2640 FE0F ; fully-qualified # 🤾🏼‍♀️ E4.0 woman playing handball: medium-light skin tone +1F93E 1F3FC 200D 2640 ; minimally-qualified # 🤾🏼‍♀ E4.0 woman playing handball: medium-light skin tone +1F93E 1F3FD 200D 2640 FE0F ; fully-qualified # 🤾🏽‍♀️ E4.0 woman playing handball: medium skin tone +1F93E 1F3FD 200D 2640 ; minimally-qualified # 🤾🏽‍♀ E4.0 woman playing handball: medium skin tone +1F93E 1F3FE 200D 2640 FE0F ; fully-qualified # 🤾🏾‍♀️ E4.0 woman playing handball: medium-dark skin tone +1F93E 1F3FE 200D 2640 ; minimally-qualified # 🤾🏾‍♀ E4.0 woman playing handball: medium-dark skin tone +1F93E 1F3FF 200D 2640 FE0F ; fully-qualified # 🤾🏿‍♀️ E4.0 woman playing handball: dark skin tone +1F93E 1F3FF 200D 2640 ; minimally-qualified # 🤾🏿‍♀ E4.0 woman playing handball: dark skin tone +1F939 ; fully-qualified # 🤹 E3.0 person juggling +1F939 1F3FB ; fully-qualified # 🤹🏻 E3.0 person juggling: light skin tone +1F939 1F3FC ; fully-qualified # 🤹🏼 E3.0 person juggling: medium-light skin tone +1F939 1F3FD ; fully-qualified # 🤹🏽 E3.0 person juggling: medium skin tone +1F939 1F3FE ; fully-qualified # 🤹🏾 E3.0 person juggling: medium-dark skin tone +1F939 1F3FF ; fully-qualified # 🤹🏿 E3.0 person juggling: dark skin tone +1F939 200D 2642 FE0F ; fully-qualified # 🤹‍♂️ E4.0 man juggling +1F939 200D 2642 ; minimally-qualified # 🤹‍♂ E4.0 man juggling +1F939 1F3FB 200D 2642 FE0F ; fully-qualified # 🤹🏻‍♂️ E4.0 man juggling: light skin tone +1F939 1F3FB 200D 2642 ; minimally-qualified # 🤹🏻‍♂ E4.0 man juggling: light skin tone +1F939 1F3FC 200D 2642 FE0F ; fully-qualified # 🤹🏼‍♂️ E4.0 man juggling: medium-light skin tone +1F939 1F3FC 200D 2642 ; minimally-qualified # 🤹🏼‍♂ E4.0 man juggling: medium-light skin tone +1F939 1F3FD 200D 2642 FE0F ; fully-qualified # 🤹🏽‍♂️ E4.0 man juggling: medium skin tone +1F939 1F3FD 200D 2642 ; minimally-qualified # 🤹🏽‍♂ E4.0 man juggling: medium skin tone +1F939 1F3FE 200D 2642 FE0F ; fully-qualified # 🤹🏾‍♂️ E4.0 man juggling: medium-dark skin tone +1F939 1F3FE 200D 2642 ; minimally-qualified # 🤹🏾‍♂ E4.0 man juggling: medium-dark skin tone +1F939 1F3FF 200D 2642 FE0F ; fully-qualified # 🤹🏿‍♂️ E4.0 man juggling: dark skin tone +1F939 1F3FF 200D 2642 ; minimally-qualified # 🤹🏿‍♂ E4.0 man juggling: dark skin tone +1F939 200D 2640 FE0F ; fully-qualified # 🤹‍♀️ E4.0 woman juggling +1F939 200D 2640 ; minimally-qualified # 🤹‍♀ E4.0 woman juggling +1F939 1F3FB 200D 2640 FE0F ; fully-qualified # 🤹🏻‍♀️ E4.0 woman juggling: light skin tone +1F939 1F3FB 200D 2640 ; minimally-qualified # 🤹🏻‍♀ E4.0 woman juggling: light skin tone +1F939 1F3FC 200D 2640 FE0F ; fully-qualified # 🤹🏼‍♀️ E4.0 woman juggling: medium-light skin tone +1F939 1F3FC 200D 2640 ; minimally-qualified # 🤹🏼‍♀ E4.0 woman juggling: medium-light skin tone +1F939 1F3FD 200D 2640 FE0F ; fully-qualified # 🤹🏽‍♀️ E4.0 woman juggling: medium skin tone +1F939 1F3FD 200D 2640 ; minimally-qualified # 🤹🏽‍♀ E4.0 woman juggling: medium skin tone +1F939 1F3FE 200D 2640 FE0F ; fully-qualified # 🤹🏾‍♀️ E4.0 woman juggling: medium-dark skin tone +1F939 1F3FE 200D 2640 ; minimally-qualified # 🤹🏾‍♀ E4.0 woman juggling: medium-dark skin tone +1F939 1F3FF 200D 2640 FE0F ; fully-qualified # 🤹🏿‍♀️ E4.0 woman juggling: dark skin tone +1F939 1F3FF 200D 2640 ; minimally-qualified # 🤹🏿‍♀ E4.0 woman juggling: dark skin tone + +# subgroup: person-resting +1F9D8 ; fully-qualified # 🧘 E5.0 person in lotus position +1F9D8 1F3FB ; fully-qualified # 🧘🏻 E5.0 person in lotus position: light skin tone +1F9D8 1F3FC ; fully-qualified # 🧘🏼 E5.0 person in lotus position: medium-light skin tone +1F9D8 1F3FD ; fully-qualified # 🧘🏽 E5.0 person in lotus position: medium skin tone +1F9D8 1F3FE ; fully-qualified # 🧘🏾 E5.0 person in lotus position: medium-dark skin tone +1F9D8 1F3FF ; fully-qualified # 🧘🏿 E5.0 person in lotus position: dark skin tone +1F9D8 200D 2642 FE0F ; fully-qualified # 🧘‍♂️ E5.0 man in lotus position +1F9D8 200D 2642 ; minimally-qualified # 🧘‍♂ E5.0 man in lotus position +1F9D8 1F3FB 200D 2642 FE0F ; fully-qualified # 🧘🏻‍♂️ E5.0 man in lotus position: light skin tone +1F9D8 1F3FB 200D 2642 ; minimally-qualified # 🧘🏻‍♂ E5.0 man in lotus position: light skin tone +1F9D8 1F3FC 200D 2642 FE0F ; fully-qualified # 🧘🏼‍♂️ E5.0 man in lotus position: medium-light skin tone +1F9D8 1F3FC 200D 2642 ; minimally-qualified # 🧘🏼‍♂ E5.0 man in lotus position: medium-light skin tone +1F9D8 1F3FD 200D 2642 FE0F ; fully-qualified # 🧘🏽‍♂️ E5.0 man in lotus position: medium skin tone +1F9D8 1F3FD 200D 2642 ; minimally-qualified # 🧘🏽‍♂ E5.0 man in lotus position: medium skin tone +1F9D8 1F3FE 200D 2642 FE0F ; fully-qualified # 🧘🏾‍♂️ E5.0 man in lotus position: medium-dark skin tone +1F9D8 1F3FE 200D 2642 ; minimally-qualified # 🧘🏾‍♂ E5.0 man in lotus position: medium-dark skin tone +1F9D8 1F3FF 200D 2642 FE0F ; fully-qualified # 🧘🏿‍♂️ E5.0 man in lotus position: dark skin tone +1F9D8 1F3FF 200D 2642 ; minimally-qualified # 🧘🏿‍♂ E5.0 man in lotus position: dark skin tone +1F9D8 200D 2640 FE0F ; fully-qualified # 🧘‍♀️ E5.0 woman in lotus position +1F9D8 200D 2640 ; minimally-qualified # 🧘‍♀ E5.0 woman in lotus position +1F9D8 1F3FB 200D 2640 FE0F ; fully-qualified # 🧘🏻‍♀️ E5.0 woman in lotus position: light skin tone +1F9D8 1F3FB 200D 2640 ; minimally-qualified # 🧘🏻‍♀ E5.0 woman in lotus position: light skin tone +1F9D8 1F3FC 200D 2640 FE0F ; fully-qualified # 🧘🏼‍♀️ E5.0 woman in lotus position: medium-light skin tone +1F9D8 1F3FC 200D 2640 ; minimally-qualified # 🧘🏼‍♀ E5.0 woman in lotus position: medium-light skin tone +1F9D8 1F3FD 200D 2640 FE0F ; fully-qualified # 🧘🏽‍♀️ E5.0 woman in lotus position: medium skin tone +1F9D8 1F3FD 200D 2640 ; minimally-qualified # 🧘🏽‍♀ E5.0 woman in lotus position: medium skin tone +1F9D8 1F3FE 200D 2640 FE0F ; fully-qualified # 🧘🏾‍♀️ E5.0 woman in lotus position: medium-dark skin tone +1F9D8 1F3FE 200D 2640 ; minimally-qualified # 🧘🏾‍♀ E5.0 woman in lotus position: medium-dark skin tone +1F9D8 1F3FF 200D 2640 FE0F ; fully-qualified # 🧘🏿‍♀️ E5.0 woman in lotus position: dark skin tone +1F9D8 1F3FF 200D 2640 ; minimally-qualified # 🧘🏿‍♀ E5.0 woman in lotus position: dark skin tone +1F6C0 ; fully-qualified # 🛀 E0.6 person taking bath +1F6C0 1F3FB ; fully-qualified # 🛀🏻 E1.0 person taking bath: light skin tone +1F6C0 1F3FC ; fully-qualified # 🛀🏼 E1.0 person taking bath: medium-light skin tone +1F6C0 1F3FD ; fully-qualified # 🛀🏽 E1.0 person taking bath: medium skin tone +1F6C0 1F3FE ; fully-qualified # 🛀🏾 E1.0 person taking bath: medium-dark skin tone +1F6C0 1F3FF ; fully-qualified # 🛀🏿 E1.0 person taking bath: dark skin tone +1F6CC ; fully-qualified # 🛌 E1.0 person in bed +1F6CC 1F3FB ; fully-qualified # 🛌🏻 E4.0 person in bed: light skin tone +1F6CC 1F3FC ; fully-qualified # 🛌🏼 E4.0 person in bed: medium-light skin tone +1F6CC 1F3FD ; fully-qualified # 🛌🏽 E4.0 person in bed: medium skin tone +1F6CC 1F3FE ; fully-qualified # 🛌🏾 E4.0 person in bed: medium-dark skin tone +1F6CC 1F3FF ; fully-qualified # 🛌🏿 E4.0 person in bed: dark skin tone + +# subgroup: family +1F9D1 200D 1F91D 200D 1F9D1 ; fully-qualified # 🧑‍🤝‍🧑 E12.0 people holding hands +1F9D1 1F3FB 200D 1F91D 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏻‍🤝‍🧑🏻 E12.0 people holding hands: light skin tone +1F9D1 1F3FB 200D 1F91D 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏻‍🤝‍🧑🏼 E12.1 people holding hands: light skin tone, medium-light skin tone +1F9D1 1F3FB 200D 1F91D 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏻‍🤝‍🧑🏽 E12.1 people holding hands: light skin tone, medium skin tone +1F9D1 1F3FB 200D 1F91D 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏻‍🤝‍🧑🏾 E12.1 people holding hands: light skin tone, medium-dark skin tone +1F9D1 1F3FB 200D 1F91D 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏻‍🤝‍🧑🏿 E12.1 people holding hands: light skin tone, dark skin tone +1F9D1 1F3FC 200D 1F91D 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏼‍🤝‍🧑🏻 E12.0 people holding hands: medium-light skin tone, light skin tone +1F9D1 1F3FC 200D 1F91D 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏼‍🤝‍🧑🏼 E12.0 people holding hands: medium-light skin tone +1F9D1 1F3FC 200D 1F91D 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏼‍🤝‍🧑🏽 E12.1 people holding hands: medium-light skin tone, medium skin tone +1F9D1 1F3FC 200D 1F91D 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏼‍🤝‍🧑🏾 E12.1 people holding hands: medium-light skin tone, medium-dark skin tone +1F9D1 1F3FC 200D 1F91D 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏼‍🤝‍🧑🏿 E12.1 people holding hands: medium-light skin tone, dark skin tone +1F9D1 1F3FD 200D 1F91D 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏽‍🤝‍🧑🏻 E12.0 people holding hands: medium skin tone, light skin tone +1F9D1 1F3FD 200D 1F91D 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏽‍🤝‍🧑🏼 E12.0 people holding hands: medium skin tone, medium-light skin tone +1F9D1 1F3FD 200D 1F91D 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏽‍🤝‍🧑🏽 E12.0 people holding hands: medium skin tone +1F9D1 1F3FD 200D 1F91D 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏽‍🤝‍🧑🏾 E12.1 people holding hands: medium skin tone, medium-dark skin tone +1F9D1 1F3FD 200D 1F91D 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏽‍🤝‍🧑🏿 E12.1 people holding hands: medium skin tone, dark skin tone +1F9D1 1F3FE 200D 1F91D 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏾‍🤝‍🧑🏻 E12.0 people holding hands: medium-dark skin tone, light skin tone +1F9D1 1F3FE 200D 1F91D 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏾‍🤝‍🧑🏼 E12.0 people holding hands: medium-dark skin tone, medium-light skin tone +1F9D1 1F3FE 200D 1F91D 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏾‍🤝‍🧑🏽 E12.0 people holding hands: medium-dark skin tone, medium skin tone +1F9D1 1F3FE 200D 1F91D 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏾‍🤝‍🧑🏾 E12.0 people holding hands: medium-dark skin tone +1F9D1 1F3FE 200D 1F91D 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏾‍🤝‍🧑🏿 E12.1 people holding hands: medium-dark skin tone, dark skin tone +1F9D1 1F3FF 200D 1F91D 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏿‍🤝‍🧑🏻 E12.0 people holding hands: dark skin tone, light skin tone +1F9D1 1F3FF 200D 1F91D 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏿‍🤝‍🧑🏼 E12.0 people holding hands: dark skin tone, medium-light skin tone +1F9D1 1F3FF 200D 1F91D 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏿‍🤝‍🧑🏽 E12.0 people holding hands: dark skin tone, medium skin tone +1F9D1 1F3FF 200D 1F91D 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏿‍🤝‍🧑🏾 E12.0 people holding hands: dark skin tone, medium-dark skin tone +1F9D1 1F3FF 200D 1F91D 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏿‍🤝‍🧑🏿 E12.0 people holding hands: dark skin tone +1F46D ; fully-qualified # 👭 E1.0 women holding hands +1F46D 1F3FB ; fully-qualified # 👭🏻 E12.0 women holding hands: light skin tone +1F469 1F3FB 200D 1F91D 200D 1F469 1F3FC ; fully-qualified # 👩🏻‍🤝‍👩🏼 E12.1 women holding hands: light skin tone, medium-light skin tone +1F469 1F3FB 200D 1F91D 200D 1F469 1F3FD ; fully-qualified # 👩🏻‍🤝‍👩🏽 E12.1 women holding hands: light skin tone, medium skin tone +1F469 1F3FB 200D 1F91D 200D 1F469 1F3FE ; fully-qualified # 👩🏻‍🤝‍👩🏾 E12.1 women holding hands: light skin tone, medium-dark skin tone +1F469 1F3FB 200D 1F91D 200D 1F469 1F3FF ; fully-qualified # 👩🏻‍🤝‍👩🏿 E12.1 women holding hands: light skin tone, dark skin tone +1F469 1F3FC 200D 1F91D 200D 1F469 1F3FB ; fully-qualified # 👩🏼‍🤝‍👩🏻 E12.0 women holding hands: medium-light skin tone, light skin tone +1F46D 1F3FC ; fully-qualified # 👭🏼 E12.0 women holding hands: medium-light skin tone +1F469 1F3FC 200D 1F91D 200D 1F469 1F3FD ; fully-qualified # 👩🏼‍🤝‍👩🏽 E12.1 women holding hands: medium-light skin tone, medium skin tone +1F469 1F3FC 200D 1F91D 200D 1F469 1F3FE ; fully-qualified # 👩🏼‍🤝‍👩🏾 E12.1 women holding hands: medium-light skin tone, medium-dark skin tone +1F469 1F3FC 200D 1F91D 200D 1F469 1F3FF ; fully-qualified # 👩🏼‍🤝‍👩🏿 E12.1 women holding hands: medium-light skin tone, dark skin tone +1F469 1F3FD 200D 1F91D 200D 1F469 1F3FB ; fully-qualified # 👩🏽‍🤝‍👩🏻 E12.0 women holding hands: medium skin tone, light skin tone +1F469 1F3FD 200D 1F91D 200D 1F469 1F3FC ; fully-qualified # 👩🏽‍🤝‍👩🏼 E12.0 women holding hands: medium skin tone, medium-light skin tone +1F46D 1F3FD ; fully-qualified # 👭🏽 E12.0 women holding hands: medium skin tone +1F469 1F3FD 200D 1F91D 200D 1F469 1F3FE ; fully-qualified # 👩🏽‍🤝‍👩🏾 E12.1 women holding hands: medium skin tone, medium-dark skin tone +1F469 1F3FD 200D 1F91D 200D 1F469 1F3FF ; fully-qualified # 👩🏽‍🤝‍👩🏿 E12.1 women holding hands: medium skin tone, dark skin tone +1F469 1F3FE 200D 1F91D 200D 1F469 1F3FB ; fully-qualified # 👩🏾‍🤝‍👩🏻 E12.0 women holding hands: medium-dark skin tone, light skin tone +1F469 1F3FE 200D 1F91D 200D 1F469 1F3FC ; fully-qualified # 👩🏾‍🤝‍👩🏼 E12.0 women holding hands: medium-dark skin tone, medium-light skin tone +1F469 1F3FE 200D 1F91D 200D 1F469 1F3FD ; fully-qualified # 👩🏾‍🤝‍👩🏽 E12.0 women holding hands: medium-dark skin tone, medium skin tone +1F46D 1F3FE ; fully-qualified # 👭🏾 E12.0 women holding hands: medium-dark skin tone +1F469 1F3FE 200D 1F91D 200D 1F469 1F3FF ; fully-qualified # 👩🏾‍🤝‍👩🏿 E12.1 women holding hands: medium-dark skin tone, dark skin tone +1F469 1F3FF 200D 1F91D 200D 1F469 1F3FB ; fully-qualified # 👩🏿‍🤝‍👩🏻 E12.0 women holding hands: dark skin tone, light skin tone +1F469 1F3FF 200D 1F91D 200D 1F469 1F3FC ; fully-qualified # 👩🏿‍🤝‍👩🏼 E12.0 women holding hands: dark skin tone, medium-light skin tone +1F469 1F3FF 200D 1F91D 200D 1F469 1F3FD ; fully-qualified # 👩🏿‍🤝‍👩🏽 E12.0 women holding hands: dark skin tone, medium skin tone +1F469 1F3FF 200D 1F91D 200D 1F469 1F3FE ; fully-qualified # 👩🏿‍🤝‍👩🏾 E12.0 women holding hands: dark skin tone, medium-dark skin tone +1F46D 1F3FF ; fully-qualified # 👭🏿 E12.0 women holding hands: dark skin tone +1F46B ; fully-qualified # 👫 E0.6 woman and man holding hands +1F46B 1F3FB ; fully-qualified # 👫🏻 E12.0 woman and man holding hands: light skin tone +1F469 1F3FB 200D 1F91D 200D 1F468 1F3FC ; fully-qualified # 👩🏻‍🤝‍👨🏼 E12.0 woman and man holding hands: light skin tone, medium-light skin tone +1F469 1F3FB 200D 1F91D 200D 1F468 1F3FD ; fully-qualified # 👩🏻‍🤝‍👨🏽 E12.0 woman and man holding hands: light skin tone, medium skin tone +1F469 1F3FB 200D 1F91D 200D 1F468 1F3FE ; fully-qualified # 👩🏻‍🤝‍👨🏾 E12.0 woman and man holding hands: light skin tone, medium-dark skin tone +1F469 1F3FB 200D 1F91D 200D 1F468 1F3FF ; fully-qualified # 👩🏻‍🤝‍👨🏿 E12.0 woman and man holding hands: light skin tone, dark skin tone +1F469 1F3FC 200D 1F91D 200D 1F468 1F3FB ; fully-qualified # 👩🏼‍🤝‍👨🏻 E12.0 woman and man holding hands: medium-light skin tone, light skin tone +1F46B 1F3FC ; fully-qualified # 👫🏼 E12.0 woman and man holding hands: medium-light skin tone +1F469 1F3FC 200D 1F91D 200D 1F468 1F3FD ; fully-qualified # 👩🏼‍🤝‍👨🏽 E12.0 woman and man holding hands: medium-light skin tone, medium skin tone +1F469 1F3FC 200D 1F91D 200D 1F468 1F3FE ; fully-qualified # 👩🏼‍🤝‍👨🏾 E12.0 woman and man holding hands: medium-light skin tone, medium-dark skin tone +1F469 1F3FC 200D 1F91D 200D 1F468 1F3FF ; fully-qualified # 👩🏼‍🤝‍👨🏿 E12.0 woman and man holding hands: medium-light skin tone, dark skin tone +1F469 1F3FD 200D 1F91D 200D 1F468 1F3FB ; fully-qualified # 👩🏽‍🤝‍👨🏻 E12.0 woman and man holding hands: medium skin tone, light skin tone +1F469 1F3FD 200D 1F91D 200D 1F468 1F3FC ; fully-qualified # 👩🏽‍🤝‍👨🏼 E12.0 woman and man holding hands: medium skin tone, medium-light skin tone +1F46B 1F3FD ; fully-qualified # 👫🏽 E12.0 woman and man holding hands: medium skin tone +1F469 1F3FD 200D 1F91D 200D 1F468 1F3FE ; fully-qualified # 👩🏽‍🤝‍👨🏾 E12.0 woman and man holding hands: medium skin tone, medium-dark skin tone +1F469 1F3FD 200D 1F91D 200D 1F468 1F3FF ; fully-qualified # 👩🏽‍🤝‍👨🏿 E12.0 woman and man holding hands: medium skin tone, dark skin tone +1F469 1F3FE 200D 1F91D 200D 1F468 1F3FB ; fully-qualified # 👩🏾‍🤝‍👨🏻 E12.0 woman and man holding hands: medium-dark skin tone, light skin tone +1F469 1F3FE 200D 1F91D 200D 1F468 1F3FC ; fully-qualified # 👩🏾‍🤝‍👨🏼 E12.0 woman and man holding hands: medium-dark skin tone, medium-light skin tone +1F469 1F3FE 200D 1F91D 200D 1F468 1F3FD ; fully-qualified # 👩🏾‍🤝‍👨🏽 E12.0 woman and man holding hands: medium-dark skin tone, medium skin tone +1F46B 1F3FE ; fully-qualified # 👫🏾 E12.0 woman and man holding hands: medium-dark skin tone +1F469 1F3FE 200D 1F91D 200D 1F468 1F3FF ; fully-qualified # 👩🏾‍🤝‍👨🏿 E12.0 woman and man holding hands: medium-dark skin tone, dark skin tone +1F469 1F3FF 200D 1F91D 200D 1F468 1F3FB ; fully-qualified # 👩🏿‍🤝‍👨🏻 E12.0 woman and man holding hands: dark skin tone, light skin tone +1F469 1F3FF 200D 1F91D 200D 1F468 1F3FC ; fully-qualified # 👩🏿‍🤝‍👨🏼 E12.0 woman and man holding hands: dark skin tone, medium-light skin tone +1F469 1F3FF 200D 1F91D 200D 1F468 1F3FD ; fully-qualified # 👩🏿‍🤝‍👨🏽 E12.0 woman and man holding hands: dark skin tone, medium skin tone +1F469 1F3FF 200D 1F91D 200D 1F468 1F3FE ; fully-qualified # 👩🏿‍🤝‍👨🏾 E12.0 woman and man holding hands: dark skin tone, medium-dark skin tone +1F46B 1F3FF ; fully-qualified # 👫🏿 E12.0 woman and man holding hands: dark skin tone +1F46C ; fully-qualified # 👬 E1.0 men holding hands +1F46C 1F3FB ; fully-qualified # 👬🏻 E12.0 men holding hands: light skin tone +1F468 1F3FB 200D 1F91D 200D 1F468 1F3FC ; fully-qualified # 👨🏻‍🤝‍👨🏼 E12.1 men holding hands: light skin tone, medium-light skin tone +1F468 1F3FB 200D 1F91D 200D 1F468 1F3FD ; fully-qualified # 👨🏻‍🤝‍👨🏽 E12.1 men holding hands: light skin tone, medium skin tone +1F468 1F3FB 200D 1F91D 200D 1F468 1F3FE ; fully-qualified # 👨🏻‍🤝‍👨🏾 E12.1 men holding hands: light skin tone, medium-dark skin tone +1F468 1F3FB 200D 1F91D 200D 1F468 1F3FF ; fully-qualified # 👨🏻‍🤝‍👨🏿 E12.1 men holding hands: light skin tone, dark skin tone +1F468 1F3FC 200D 1F91D 200D 1F468 1F3FB ; fully-qualified # 👨🏼‍🤝‍👨🏻 E12.0 men holding hands: medium-light skin tone, light skin tone +1F46C 1F3FC ; fully-qualified # 👬🏼 E12.0 men holding hands: medium-light skin tone +1F468 1F3FC 200D 1F91D 200D 1F468 1F3FD ; fully-qualified # 👨🏼‍🤝‍👨🏽 E12.1 men holding hands: medium-light skin tone, medium skin tone +1F468 1F3FC 200D 1F91D 200D 1F468 1F3FE ; fully-qualified # 👨🏼‍🤝‍👨🏾 E12.1 men holding hands: medium-light skin tone, medium-dark skin tone +1F468 1F3FC 200D 1F91D 200D 1F468 1F3FF ; fully-qualified # 👨🏼‍🤝‍👨🏿 E12.1 men holding hands: medium-light skin tone, dark skin tone +1F468 1F3FD 200D 1F91D 200D 1F468 1F3FB ; fully-qualified # 👨🏽‍🤝‍👨🏻 E12.0 men holding hands: medium skin tone, light skin tone +1F468 1F3FD 200D 1F91D 200D 1F468 1F3FC ; fully-qualified # 👨🏽‍🤝‍👨🏼 E12.0 men holding hands: medium skin tone, medium-light skin tone +1F46C 1F3FD ; fully-qualified # 👬🏽 E12.0 men holding hands: medium skin tone +1F468 1F3FD 200D 1F91D 200D 1F468 1F3FE ; fully-qualified # 👨🏽‍🤝‍👨🏾 E12.1 men holding hands: medium skin tone, medium-dark skin tone +1F468 1F3FD 200D 1F91D 200D 1F468 1F3FF ; fully-qualified # 👨🏽‍🤝‍👨🏿 E12.1 men holding hands: medium skin tone, dark skin tone +1F468 1F3FE 200D 1F91D 200D 1F468 1F3FB ; fully-qualified # 👨🏾‍🤝‍👨🏻 E12.0 men holding hands: medium-dark skin tone, light skin tone +1F468 1F3FE 200D 1F91D 200D 1F468 1F3FC ; fully-qualified # 👨🏾‍🤝‍👨🏼 E12.0 men holding hands: medium-dark skin tone, medium-light skin tone +1F468 1F3FE 200D 1F91D 200D 1F468 1F3FD ; fully-qualified # 👨🏾‍🤝‍👨🏽 E12.0 men holding hands: medium-dark skin tone, medium skin tone +1F46C 1F3FE ; fully-qualified # 👬🏾 E12.0 men holding hands: medium-dark skin tone +1F468 1F3FE 200D 1F91D 200D 1F468 1F3FF ; fully-qualified # 👨🏾‍🤝‍👨🏿 E12.1 men holding hands: medium-dark skin tone, dark skin tone +1F468 1F3FF 200D 1F91D 200D 1F468 1F3FB ; fully-qualified # 👨🏿‍🤝‍👨🏻 E12.0 men holding hands: dark skin tone, light skin tone +1F468 1F3FF 200D 1F91D 200D 1F468 1F3FC ; fully-qualified # 👨🏿‍🤝‍👨🏼 E12.0 men holding hands: dark skin tone, medium-light skin tone +1F468 1F3FF 200D 1F91D 200D 1F468 1F3FD ; fully-qualified # 👨🏿‍🤝‍👨🏽 E12.0 men holding hands: dark skin tone, medium skin tone +1F468 1F3FF 200D 1F91D 200D 1F468 1F3FE ; fully-qualified # 👨🏿‍🤝‍👨🏾 E12.0 men holding hands: dark skin tone, medium-dark skin tone +1F46C 1F3FF ; fully-qualified # 👬🏿 E12.0 men holding hands: dark skin tone +1F48F ; fully-qualified # 💏 E0.6 kiss +1F48F 1F3FB ; fully-qualified # 💏🏻 E13.1 kiss: light skin tone +1F48F 1F3FC ; fully-qualified # 💏🏼 E13.1 kiss: medium-light skin tone +1F48F 1F3FD ; fully-qualified # 💏🏽 E13.1 kiss: medium skin tone +1F48F 1F3FE ; fully-qualified # 💏🏾 E13.1 kiss: medium-dark skin tone +1F48F 1F3FF ; fully-qualified # 💏🏿 E13.1 kiss: dark skin tone +1F9D1 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏻‍❤️‍💋‍🧑🏼 E13.1 kiss: person, person, light skin tone, medium-light skin tone +1F9D1 1F3FB 200D 2764 200D 1F48B 200D 1F9D1 1F3FC ; minimally-qualified # 🧑🏻‍❤‍💋‍🧑🏼 E13.1 kiss: person, person, light skin tone, medium-light skin tone +1F9D1 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏻‍❤️‍💋‍🧑🏽 E13.1 kiss: person, person, light skin tone, medium skin tone +1F9D1 1F3FB 200D 2764 200D 1F48B 200D 1F9D1 1F3FD ; minimally-qualified # 🧑🏻‍❤‍💋‍🧑🏽 E13.1 kiss: person, person, light skin tone, medium skin tone +1F9D1 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏻‍❤️‍💋‍🧑🏾 E13.1 kiss: person, person, light skin tone, medium-dark skin tone +1F9D1 1F3FB 200D 2764 200D 1F48B 200D 1F9D1 1F3FE ; minimally-qualified # 🧑🏻‍❤‍💋‍🧑🏾 E13.1 kiss: person, person, light skin tone, medium-dark skin tone +1F9D1 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏻‍❤️‍💋‍🧑🏿 E13.1 kiss: person, person, light skin tone, dark skin tone +1F9D1 1F3FB 200D 2764 200D 1F48B 200D 1F9D1 1F3FF ; minimally-qualified # 🧑🏻‍❤‍💋‍🧑🏿 E13.1 kiss: person, person, light skin tone, dark skin tone +1F9D1 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏼‍❤️‍💋‍🧑🏻 E13.1 kiss: person, person, medium-light skin tone, light skin tone +1F9D1 1F3FC 200D 2764 200D 1F48B 200D 1F9D1 1F3FB ; minimally-qualified # 🧑🏼‍❤‍💋‍🧑🏻 E13.1 kiss: person, person, medium-light skin tone, light skin tone +1F9D1 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏼‍❤️‍💋‍🧑🏽 E13.1 kiss: person, person, medium-light skin tone, medium skin tone +1F9D1 1F3FC 200D 2764 200D 1F48B 200D 1F9D1 1F3FD ; minimally-qualified # 🧑🏼‍❤‍💋‍🧑🏽 E13.1 kiss: person, person, medium-light skin tone, medium skin tone +1F9D1 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏼‍❤️‍💋‍🧑🏾 E13.1 kiss: person, person, medium-light skin tone, medium-dark skin tone +1F9D1 1F3FC 200D 2764 200D 1F48B 200D 1F9D1 1F3FE ; minimally-qualified # 🧑🏼‍❤‍💋‍🧑🏾 E13.1 kiss: person, person, medium-light skin tone, medium-dark skin tone +1F9D1 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏼‍❤️‍💋‍🧑🏿 E13.1 kiss: person, person, medium-light skin tone, dark skin tone +1F9D1 1F3FC 200D 2764 200D 1F48B 200D 1F9D1 1F3FF ; minimally-qualified # 🧑🏼‍❤‍💋‍🧑🏿 E13.1 kiss: person, person, medium-light skin tone, dark skin tone +1F9D1 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏽‍❤️‍💋‍🧑🏻 E13.1 kiss: person, person, medium skin tone, light skin tone +1F9D1 1F3FD 200D 2764 200D 1F48B 200D 1F9D1 1F3FB ; minimally-qualified # 🧑🏽‍❤‍💋‍🧑🏻 E13.1 kiss: person, person, medium skin tone, light skin tone +1F9D1 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏽‍❤️‍💋‍🧑🏼 E13.1 kiss: person, person, medium skin tone, medium-light skin tone +1F9D1 1F3FD 200D 2764 200D 1F48B 200D 1F9D1 1F3FC ; minimally-qualified # 🧑🏽‍❤‍💋‍🧑🏼 E13.1 kiss: person, person, medium skin tone, medium-light skin tone +1F9D1 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏽‍❤️‍💋‍🧑🏾 E13.1 kiss: person, person, medium skin tone, medium-dark skin tone +1F9D1 1F3FD 200D 2764 200D 1F48B 200D 1F9D1 1F3FE ; minimally-qualified # 🧑🏽‍❤‍💋‍🧑🏾 E13.1 kiss: person, person, medium skin tone, medium-dark skin tone +1F9D1 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏽‍❤️‍💋‍🧑🏿 E13.1 kiss: person, person, medium skin tone, dark skin tone +1F9D1 1F3FD 200D 2764 200D 1F48B 200D 1F9D1 1F3FF ; minimally-qualified # 🧑🏽‍❤‍💋‍🧑🏿 E13.1 kiss: person, person, medium skin tone, dark skin tone +1F9D1 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏾‍❤️‍💋‍🧑🏻 E13.1 kiss: person, person, medium-dark skin tone, light skin tone +1F9D1 1F3FE 200D 2764 200D 1F48B 200D 1F9D1 1F3FB ; minimally-qualified # 🧑🏾‍❤‍💋‍🧑🏻 E13.1 kiss: person, person, medium-dark skin tone, light skin tone +1F9D1 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏾‍❤️‍💋‍🧑🏼 E13.1 kiss: person, person, medium-dark skin tone, medium-light skin tone +1F9D1 1F3FE 200D 2764 200D 1F48B 200D 1F9D1 1F3FC ; minimally-qualified # 🧑🏾‍❤‍💋‍🧑🏼 E13.1 kiss: person, person, medium-dark skin tone, medium-light skin tone +1F9D1 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏾‍❤️‍💋‍🧑🏽 E13.1 kiss: person, person, medium-dark skin tone, medium skin tone +1F9D1 1F3FE 200D 2764 200D 1F48B 200D 1F9D1 1F3FD ; minimally-qualified # 🧑🏾‍❤‍💋‍🧑🏽 E13.1 kiss: person, person, medium-dark skin tone, medium skin tone +1F9D1 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏾‍❤️‍💋‍🧑🏿 E13.1 kiss: person, person, medium-dark skin tone, dark skin tone +1F9D1 1F3FE 200D 2764 200D 1F48B 200D 1F9D1 1F3FF ; minimally-qualified # 🧑🏾‍❤‍💋‍🧑🏿 E13.1 kiss: person, person, medium-dark skin tone, dark skin tone +1F9D1 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏿‍❤️‍💋‍🧑🏻 E13.1 kiss: person, person, dark skin tone, light skin tone +1F9D1 1F3FF 200D 2764 200D 1F48B 200D 1F9D1 1F3FB ; minimally-qualified # 🧑🏿‍❤‍💋‍🧑🏻 E13.1 kiss: person, person, dark skin tone, light skin tone +1F9D1 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏿‍❤️‍💋‍🧑🏼 E13.1 kiss: person, person, dark skin tone, medium-light skin tone +1F9D1 1F3FF 200D 2764 200D 1F48B 200D 1F9D1 1F3FC ; minimally-qualified # 🧑🏿‍❤‍💋‍🧑🏼 E13.1 kiss: person, person, dark skin tone, medium-light skin tone +1F9D1 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏿‍❤️‍💋‍🧑🏽 E13.1 kiss: person, person, dark skin tone, medium skin tone +1F9D1 1F3FF 200D 2764 200D 1F48B 200D 1F9D1 1F3FD ; minimally-qualified # 🧑🏿‍❤‍💋‍🧑🏽 E13.1 kiss: person, person, dark skin tone, medium skin tone +1F9D1 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏿‍❤️‍💋‍🧑🏾 E13.1 kiss: person, person, dark skin tone, medium-dark skin tone +1F9D1 1F3FF 200D 2764 200D 1F48B 200D 1F9D1 1F3FE ; minimally-qualified # 🧑🏿‍❤‍💋‍🧑🏾 E13.1 kiss: person, person, dark skin tone, medium-dark skin tone +1F469 200D 2764 FE0F 200D 1F48B 200D 1F468 ; fully-qualified # 👩‍❤️‍💋‍👨 E2.0 kiss: woman, man +1F469 200D 2764 200D 1F48B 200D 1F468 ; minimally-qualified # 👩‍❤‍💋‍👨 E2.0 kiss: woman, man +1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB ; fully-qualified # 👩🏻‍❤️‍💋‍👨🏻 E13.1 kiss: woman, man, light skin tone +1F469 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FB ; minimally-qualified # 👩🏻‍❤‍💋‍👨🏻 E13.1 kiss: woman, man, light skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC ; fully-qualified # 👩🏻‍❤️‍💋‍👨🏼 E13.1 kiss: woman, man, light skin tone, medium-light skin tone +1F469 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FC ; minimally-qualified # 👩🏻‍❤‍💋‍👨🏼 E13.1 kiss: woman, man, light skin tone, medium-light skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD ; fully-qualified # 👩🏻‍❤️‍💋‍👨🏽 E13.1 kiss: woman, man, light skin tone, medium skin tone +1F469 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FD ; minimally-qualified # 👩🏻‍❤‍💋‍👨🏽 E13.1 kiss: woman, man, light skin tone, medium skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE ; fully-qualified # 👩🏻‍❤️‍💋‍👨🏾 E13.1 kiss: woman, man, light skin tone, medium-dark skin tone +1F469 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FE ; minimally-qualified # 👩🏻‍❤‍💋‍👨🏾 E13.1 kiss: woman, man, light skin tone, medium-dark skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF ; fully-qualified # 👩🏻‍❤️‍💋‍👨🏿 E13.1 kiss: woman, man, light skin tone, dark skin tone +1F469 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FF ; minimally-qualified # 👩🏻‍❤‍💋‍👨🏿 E13.1 kiss: woman, man, light skin tone, dark skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB ; fully-qualified # 👩🏼‍❤️‍💋‍👨🏻 E13.1 kiss: woman, man, medium-light skin tone, light skin tone +1F469 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FB ; minimally-qualified # 👩🏼‍❤‍💋‍👨🏻 E13.1 kiss: woman, man, medium-light skin tone, light skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC ; fully-qualified # 👩🏼‍❤️‍💋‍👨🏼 E13.1 kiss: woman, man, medium-light skin tone +1F469 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FC ; minimally-qualified # 👩🏼‍❤‍💋‍👨🏼 E13.1 kiss: woman, man, medium-light skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD ; fully-qualified # 👩🏼‍❤️‍💋‍👨🏽 E13.1 kiss: woman, man, medium-light skin tone, medium skin tone +1F469 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FD ; minimally-qualified # 👩🏼‍❤‍💋‍👨🏽 E13.1 kiss: woman, man, medium-light skin tone, medium skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE ; fully-qualified # 👩🏼‍❤️‍💋‍👨🏾 E13.1 kiss: woman, man, medium-light skin tone, medium-dark skin tone +1F469 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FE ; minimally-qualified # 👩🏼‍❤‍💋‍👨🏾 E13.1 kiss: woman, man, medium-light skin tone, medium-dark skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF ; fully-qualified # 👩🏼‍❤️‍💋‍👨🏿 E13.1 kiss: woman, man, medium-light skin tone, dark skin tone +1F469 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FF ; minimally-qualified # 👩🏼‍❤‍💋‍👨🏿 E13.1 kiss: woman, man, medium-light skin tone, dark skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB ; fully-qualified # 👩🏽‍❤️‍💋‍👨🏻 E13.1 kiss: woman, man, medium skin tone, light skin tone +1F469 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FB ; minimally-qualified # 👩🏽‍❤‍💋‍👨🏻 E13.1 kiss: woman, man, medium skin tone, light skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC ; fully-qualified # 👩🏽‍❤️‍💋‍👨🏼 E13.1 kiss: woman, man, medium skin tone, medium-light skin tone +1F469 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FC ; minimally-qualified # 👩🏽‍❤‍💋‍👨🏼 E13.1 kiss: woman, man, medium skin tone, medium-light skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD ; fully-qualified # 👩🏽‍❤️‍💋‍👨🏽 E13.1 kiss: woman, man, medium skin tone +1F469 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FD ; minimally-qualified # 👩🏽‍❤‍💋‍👨🏽 E13.1 kiss: woman, man, medium skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE ; fully-qualified # 👩🏽‍❤️‍💋‍👨🏾 E13.1 kiss: woman, man, medium skin tone, medium-dark skin tone +1F469 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FE ; minimally-qualified # 👩🏽‍❤‍💋‍👨🏾 E13.1 kiss: woman, man, medium skin tone, medium-dark skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF ; fully-qualified # 👩🏽‍❤️‍💋‍👨🏿 E13.1 kiss: woman, man, medium skin tone, dark skin tone +1F469 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FF ; minimally-qualified # 👩🏽‍❤‍💋‍👨🏿 E13.1 kiss: woman, man, medium skin tone, dark skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB ; fully-qualified # 👩🏾‍❤️‍💋‍👨🏻 E13.1 kiss: woman, man, medium-dark skin tone, light skin tone +1F469 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FB ; minimally-qualified # 👩🏾‍❤‍💋‍👨🏻 E13.1 kiss: woman, man, medium-dark skin tone, light skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC ; fully-qualified # 👩🏾‍❤️‍💋‍👨🏼 E13.1 kiss: woman, man, medium-dark skin tone, medium-light skin tone +1F469 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FC ; minimally-qualified # 👩🏾‍❤‍💋‍👨🏼 E13.1 kiss: woman, man, medium-dark skin tone, medium-light skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD ; fully-qualified # 👩🏾‍❤️‍💋‍👨🏽 E13.1 kiss: woman, man, medium-dark skin tone, medium skin tone +1F469 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FD ; minimally-qualified # 👩🏾‍❤‍💋‍👨🏽 E13.1 kiss: woman, man, medium-dark skin tone, medium skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE ; fully-qualified # 👩🏾‍❤️‍💋‍👨🏾 E13.1 kiss: woman, man, medium-dark skin tone +1F469 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FE ; minimally-qualified # 👩🏾‍❤‍💋‍👨🏾 E13.1 kiss: woman, man, medium-dark skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF ; fully-qualified # 👩🏾‍❤️‍💋‍👨🏿 E13.1 kiss: woman, man, medium-dark skin tone, dark skin tone +1F469 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FF ; minimally-qualified # 👩🏾‍❤‍💋‍👨🏿 E13.1 kiss: woman, man, medium-dark skin tone, dark skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB ; fully-qualified # 👩🏿‍❤️‍💋‍👨🏻 E13.1 kiss: woman, man, dark skin tone, light skin tone +1F469 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FB ; minimally-qualified # 👩🏿‍❤‍💋‍👨🏻 E13.1 kiss: woman, man, dark skin tone, light skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC ; fully-qualified # 👩🏿‍❤️‍💋‍👨🏼 E13.1 kiss: woman, man, dark skin tone, medium-light skin tone +1F469 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FC ; minimally-qualified # 👩🏿‍❤‍💋‍👨🏼 E13.1 kiss: woman, man, dark skin tone, medium-light skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD ; fully-qualified # 👩🏿‍❤️‍💋‍👨🏽 E13.1 kiss: woman, man, dark skin tone, medium skin tone +1F469 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FD ; minimally-qualified # 👩🏿‍❤‍💋‍👨🏽 E13.1 kiss: woman, man, dark skin tone, medium skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE ; fully-qualified # 👩🏿‍❤️‍💋‍👨🏾 E13.1 kiss: woman, man, dark skin tone, medium-dark skin tone +1F469 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FE ; minimally-qualified # 👩🏿‍❤‍💋‍👨🏾 E13.1 kiss: woman, man, dark skin tone, medium-dark skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF ; fully-qualified # 👩🏿‍❤️‍💋‍👨🏿 E13.1 kiss: woman, man, dark skin tone +1F469 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FF ; minimally-qualified # 👩🏿‍❤‍💋‍👨🏿 E13.1 kiss: woman, man, dark skin tone +1F468 200D 2764 FE0F 200D 1F48B 200D 1F468 ; fully-qualified # 👨‍❤️‍💋‍👨 E2.0 kiss: man, man +1F468 200D 2764 200D 1F48B 200D 1F468 ; minimally-qualified # 👨‍❤‍💋‍👨 E2.0 kiss: man, man +1F468 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB ; fully-qualified # 👨🏻‍❤️‍💋‍👨🏻 E13.1 kiss: man, man, light skin tone +1F468 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FB ; minimally-qualified # 👨🏻‍❤‍💋‍👨🏻 E13.1 kiss: man, man, light skin tone +1F468 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC ; fully-qualified # 👨🏻‍❤️‍💋‍👨🏼 E13.1 kiss: man, man, light skin tone, medium-light skin tone +1F468 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FC ; minimally-qualified # 👨🏻‍❤‍💋‍👨🏼 E13.1 kiss: man, man, light skin tone, medium-light skin tone +1F468 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD ; fully-qualified # 👨🏻‍❤️‍💋‍👨🏽 E13.1 kiss: man, man, light skin tone, medium skin tone +1F468 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FD ; minimally-qualified # 👨🏻‍❤‍💋‍👨🏽 E13.1 kiss: man, man, light skin tone, medium skin tone +1F468 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE ; fully-qualified # 👨🏻‍❤️‍💋‍👨🏾 E13.1 kiss: man, man, light skin tone, medium-dark skin tone +1F468 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FE ; minimally-qualified # 👨🏻‍❤‍💋‍👨🏾 E13.1 kiss: man, man, light skin tone, medium-dark skin tone +1F468 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF ; fully-qualified # 👨🏻‍❤️‍💋‍👨🏿 E13.1 kiss: man, man, light skin tone, dark skin tone +1F468 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FF ; minimally-qualified # 👨🏻‍❤‍💋‍👨🏿 E13.1 kiss: man, man, light skin tone, dark skin tone +1F468 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB ; fully-qualified # 👨🏼‍❤️‍💋‍👨🏻 E13.1 kiss: man, man, medium-light skin tone, light skin tone +1F468 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FB ; minimally-qualified # 👨🏼‍❤‍💋‍👨🏻 E13.1 kiss: man, man, medium-light skin tone, light skin tone +1F468 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC ; fully-qualified # 👨🏼‍❤️‍💋‍👨🏼 E13.1 kiss: man, man, medium-light skin tone +1F468 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FC ; minimally-qualified # 👨🏼‍❤‍💋‍👨🏼 E13.1 kiss: man, man, medium-light skin tone +1F468 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD ; fully-qualified # 👨🏼‍❤️‍💋‍👨🏽 E13.1 kiss: man, man, medium-light skin tone, medium skin tone +1F468 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FD ; minimally-qualified # 👨🏼‍❤‍💋‍👨🏽 E13.1 kiss: man, man, medium-light skin tone, medium skin tone +1F468 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE ; fully-qualified # 👨🏼‍❤️‍💋‍👨🏾 E13.1 kiss: man, man, medium-light skin tone, medium-dark skin tone +1F468 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FE ; minimally-qualified # 👨🏼‍❤‍💋‍👨🏾 E13.1 kiss: man, man, medium-light skin tone, medium-dark skin tone +1F468 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF ; fully-qualified # 👨🏼‍❤️‍💋‍👨🏿 E13.1 kiss: man, man, medium-light skin tone, dark skin tone +1F468 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FF ; minimally-qualified # 👨🏼‍❤‍💋‍👨🏿 E13.1 kiss: man, man, medium-light skin tone, dark skin tone +1F468 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB ; fully-qualified # 👨🏽‍❤️‍💋‍👨🏻 E13.1 kiss: man, man, medium skin tone, light skin tone +1F468 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FB ; minimally-qualified # 👨🏽‍❤‍💋‍👨🏻 E13.1 kiss: man, man, medium skin tone, light skin tone +1F468 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC ; fully-qualified # 👨🏽‍❤️‍💋‍👨🏼 E13.1 kiss: man, man, medium skin tone, medium-light skin tone +1F468 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FC ; minimally-qualified # 👨🏽‍❤‍💋‍👨🏼 E13.1 kiss: man, man, medium skin tone, medium-light skin tone +1F468 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD ; fully-qualified # 👨🏽‍❤️‍💋‍👨🏽 E13.1 kiss: man, man, medium skin tone +1F468 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FD ; minimally-qualified # 👨🏽‍❤‍💋‍👨🏽 E13.1 kiss: man, man, medium skin tone +1F468 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE ; fully-qualified # 👨🏽‍❤️‍💋‍👨🏾 E13.1 kiss: man, man, medium skin tone, medium-dark skin tone +1F468 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FE ; minimally-qualified # 👨🏽‍❤‍💋‍👨🏾 E13.1 kiss: man, man, medium skin tone, medium-dark skin tone +1F468 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF ; fully-qualified # 👨🏽‍❤️‍💋‍👨🏿 E13.1 kiss: man, man, medium skin tone, dark skin tone +1F468 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FF ; minimally-qualified # 👨🏽‍❤‍💋‍👨🏿 E13.1 kiss: man, man, medium skin tone, dark skin tone +1F468 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB ; fully-qualified # 👨🏾‍❤️‍💋‍👨🏻 E13.1 kiss: man, man, medium-dark skin tone, light skin tone +1F468 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FB ; minimally-qualified # 👨🏾‍❤‍💋‍👨🏻 E13.1 kiss: man, man, medium-dark skin tone, light skin tone +1F468 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC ; fully-qualified # 👨🏾‍❤️‍💋‍👨🏼 E13.1 kiss: man, man, medium-dark skin tone, medium-light skin tone +1F468 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FC ; minimally-qualified # 👨🏾‍❤‍💋‍👨🏼 E13.1 kiss: man, man, medium-dark skin tone, medium-light skin tone +1F468 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD ; fully-qualified # 👨🏾‍❤️‍💋‍👨🏽 E13.1 kiss: man, man, medium-dark skin tone, medium skin tone +1F468 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FD ; minimally-qualified # 👨🏾‍❤‍💋‍👨🏽 E13.1 kiss: man, man, medium-dark skin tone, medium skin tone +1F468 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE ; fully-qualified # 👨🏾‍❤️‍💋‍👨🏾 E13.1 kiss: man, man, medium-dark skin tone +1F468 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FE ; minimally-qualified # 👨🏾‍❤‍💋‍👨🏾 E13.1 kiss: man, man, medium-dark skin tone +1F468 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF ; fully-qualified # 👨🏾‍❤️‍💋‍👨🏿 E13.1 kiss: man, man, medium-dark skin tone, dark skin tone +1F468 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FF ; minimally-qualified # 👨🏾‍❤‍💋‍👨🏿 E13.1 kiss: man, man, medium-dark skin tone, dark skin tone +1F468 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB ; fully-qualified # 👨🏿‍❤️‍💋‍👨🏻 E13.1 kiss: man, man, dark skin tone, light skin tone +1F468 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FB ; minimally-qualified # 👨🏿‍❤‍💋‍👨🏻 E13.1 kiss: man, man, dark skin tone, light skin tone +1F468 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC ; fully-qualified # 👨🏿‍❤️‍💋‍👨🏼 E13.1 kiss: man, man, dark skin tone, medium-light skin tone +1F468 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FC ; minimally-qualified # 👨🏿‍❤‍💋‍👨🏼 E13.1 kiss: man, man, dark skin tone, medium-light skin tone +1F468 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD ; fully-qualified # 👨🏿‍❤️‍💋‍👨🏽 E13.1 kiss: man, man, dark skin tone, medium skin tone +1F468 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FD ; minimally-qualified # 👨🏿‍❤‍💋‍👨🏽 E13.1 kiss: man, man, dark skin tone, medium skin tone +1F468 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE ; fully-qualified # 👨🏿‍❤️‍💋‍👨🏾 E13.1 kiss: man, man, dark skin tone, medium-dark skin tone +1F468 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FE ; minimally-qualified # 👨🏿‍❤‍💋‍👨🏾 E13.1 kiss: man, man, dark skin tone, medium-dark skin tone +1F468 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF ; fully-qualified # 👨🏿‍❤️‍💋‍👨🏿 E13.1 kiss: man, man, dark skin tone +1F468 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FF ; minimally-qualified # 👨🏿‍❤‍💋‍👨🏿 E13.1 kiss: man, man, dark skin tone +1F469 200D 2764 FE0F 200D 1F48B 200D 1F469 ; fully-qualified # 👩‍❤️‍💋‍👩 E2.0 kiss: woman, woman +1F469 200D 2764 200D 1F48B 200D 1F469 ; minimally-qualified # 👩‍❤‍💋‍👩 E2.0 kiss: woman, woman +1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FB ; fully-qualified # 👩🏻‍❤️‍💋‍👩🏻 E13.1 kiss: woman, woman, light skin tone +1F469 1F3FB 200D 2764 200D 1F48B 200D 1F469 1F3FB ; minimally-qualified # 👩🏻‍❤‍💋‍👩🏻 E13.1 kiss: woman, woman, light skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FC ; fully-qualified # 👩🏻‍❤️‍💋‍👩🏼 E13.1 kiss: woman, woman, light skin tone, medium-light skin tone +1F469 1F3FB 200D 2764 200D 1F48B 200D 1F469 1F3FC ; minimally-qualified # 👩🏻‍❤‍💋‍👩🏼 E13.1 kiss: woman, woman, light skin tone, medium-light skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FD ; fully-qualified # 👩🏻‍❤️‍💋‍👩🏽 E13.1 kiss: woman, woman, light skin tone, medium skin tone +1F469 1F3FB 200D 2764 200D 1F48B 200D 1F469 1F3FD ; minimally-qualified # 👩🏻‍❤‍💋‍👩🏽 E13.1 kiss: woman, woman, light skin tone, medium skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FE ; fully-qualified # 👩🏻‍❤️‍💋‍👩🏾 E13.1 kiss: woman, woman, light skin tone, medium-dark skin tone +1F469 1F3FB 200D 2764 200D 1F48B 200D 1F469 1F3FE ; minimally-qualified # 👩🏻‍❤‍💋‍👩🏾 E13.1 kiss: woman, woman, light skin tone, medium-dark skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FF ; fully-qualified # 👩🏻‍❤️‍💋‍👩🏿 E13.1 kiss: woman, woman, light skin tone, dark skin tone +1F469 1F3FB 200D 2764 200D 1F48B 200D 1F469 1F3FF ; minimally-qualified # 👩🏻‍❤‍💋‍👩🏿 E13.1 kiss: woman, woman, light skin tone, dark skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FB ; fully-qualified # 👩🏼‍❤️‍💋‍👩🏻 E13.1 kiss: woman, woman, medium-light skin tone, light skin tone +1F469 1F3FC 200D 2764 200D 1F48B 200D 1F469 1F3FB ; minimally-qualified # 👩🏼‍❤‍💋‍👩🏻 E13.1 kiss: woman, woman, medium-light skin tone, light skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FC ; fully-qualified # 👩🏼‍❤️‍💋‍👩🏼 E13.1 kiss: woman, woman, medium-light skin tone +1F469 1F3FC 200D 2764 200D 1F48B 200D 1F469 1F3FC ; minimally-qualified # 👩🏼‍❤‍💋‍👩🏼 E13.1 kiss: woman, woman, medium-light skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FD ; fully-qualified # 👩🏼‍❤️‍💋‍👩🏽 E13.1 kiss: woman, woman, medium-light skin tone, medium skin tone +1F469 1F3FC 200D 2764 200D 1F48B 200D 1F469 1F3FD ; minimally-qualified # 👩🏼‍❤‍💋‍👩🏽 E13.1 kiss: woman, woman, medium-light skin tone, medium skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FE ; fully-qualified # 👩🏼‍❤️‍💋‍👩🏾 E13.1 kiss: woman, woman, medium-light skin tone, medium-dark skin tone +1F469 1F3FC 200D 2764 200D 1F48B 200D 1F469 1F3FE ; minimally-qualified # 👩🏼‍❤‍💋‍👩🏾 E13.1 kiss: woman, woman, medium-light skin tone, medium-dark skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FF ; fully-qualified # 👩🏼‍❤️‍💋‍👩🏿 E13.1 kiss: woman, woman, medium-light skin tone, dark skin tone +1F469 1F3FC 200D 2764 200D 1F48B 200D 1F469 1F3FF ; minimally-qualified # 👩🏼‍❤‍💋‍👩🏿 E13.1 kiss: woman, woman, medium-light skin tone, dark skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FB ; fully-qualified # 👩🏽‍❤️‍💋‍👩🏻 E13.1 kiss: woman, woman, medium skin tone, light skin tone +1F469 1F3FD 200D 2764 200D 1F48B 200D 1F469 1F3FB ; minimally-qualified # 👩🏽‍❤‍💋‍👩🏻 E13.1 kiss: woman, woman, medium skin tone, light skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FC ; fully-qualified # 👩🏽‍❤️‍💋‍👩🏼 E13.1 kiss: woman, woman, medium skin tone, medium-light skin tone +1F469 1F3FD 200D 2764 200D 1F48B 200D 1F469 1F3FC ; minimally-qualified # 👩🏽‍❤‍💋‍👩🏼 E13.1 kiss: woman, woman, medium skin tone, medium-light skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FD ; fully-qualified # 👩🏽‍❤️‍💋‍👩🏽 E13.1 kiss: woman, woman, medium skin tone +1F469 1F3FD 200D 2764 200D 1F48B 200D 1F469 1F3FD ; minimally-qualified # 👩🏽‍❤‍💋‍👩🏽 E13.1 kiss: woman, woman, medium skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FE ; fully-qualified # 👩🏽‍❤️‍💋‍👩🏾 E13.1 kiss: woman, woman, medium skin tone, medium-dark skin tone +1F469 1F3FD 200D 2764 200D 1F48B 200D 1F469 1F3FE ; minimally-qualified # 👩🏽‍❤‍💋‍👩🏾 E13.1 kiss: woman, woman, medium skin tone, medium-dark skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FF ; fully-qualified # 👩🏽‍❤️‍💋‍👩🏿 E13.1 kiss: woman, woman, medium skin tone, dark skin tone +1F469 1F3FD 200D 2764 200D 1F48B 200D 1F469 1F3FF ; minimally-qualified # 👩🏽‍❤‍💋‍👩🏿 E13.1 kiss: woman, woman, medium skin tone, dark skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FB ; fully-qualified # 👩🏾‍❤️‍💋‍👩🏻 E13.1 kiss: woman, woman, medium-dark skin tone, light skin tone +1F469 1F3FE 200D 2764 200D 1F48B 200D 1F469 1F3FB ; minimally-qualified # 👩🏾‍❤‍💋‍👩🏻 E13.1 kiss: woman, woman, medium-dark skin tone, light skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FC ; fully-qualified # 👩🏾‍❤️‍💋‍👩🏼 E13.1 kiss: woman, woman, medium-dark skin tone, medium-light skin tone +1F469 1F3FE 200D 2764 200D 1F48B 200D 1F469 1F3FC ; minimally-qualified # 👩🏾‍❤‍💋‍👩🏼 E13.1 kiss: woman, woman, medium-dark skin tone, medium-light skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FD ; fully-qualified # 👩🏾‍❤️‍💋‍👩🏽 E13.1 kiss: woman, woman, medium-dark skin tone, medium skin tone +1F469 1F3FE 200D 2764 200D 1F48B 200D 1F469 1F3FD ; minimally-qualified # 👩🏾‍❤‍💋‍👩🏽 E13.1 kiss: woman, woman, medium-dark skin tone, medium skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FE ; fully-qualified # 👩🏾‍❤️‍💋‍👩🏾 E13.1 kiss: woman, woman, medium-dark skin tone +1F469 1F3FE 200D 2764 200D 1F48B 200D 1F469 1F3FE ; minimally-qualified # 👩🏾‍❤‍💋‍👩🏾 E13.1 kiss: woman, woman, medium-dark skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FF ; fully-qualified # 👩🏾‍❤️‍💋‍👩🏿 E13.1 kiss: woman, woman, medium-dark skin tone, dark skin tone +1F469 1F3FE 200D 2764 200D 1F48B 200D 1F469 1F3FF ; minimally-qualified # 👩🏾‍❤‍💋‍👩🏿 E13.1 kiss: woman, woman, medium-dark skin tone, dark skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FB ; fully-qualified # 👩🏿‍❤️‍💋‍👩🏻 E13.1 kiss: woman, woman, dark skin tone, light skin tone +1F469 1F3FF 200D 2764 200D 1F48B 200D 1F469 1F3FB ; minimally-qualified # 👩🏿‍❤‍💋‍👩🏻 E13.1 kiss: woman, woman, dark skin tone, light skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FC ; fully-qualified # 👩🏿‍❤️‍💋‍👩🏼 E13.1 kiss: woman, woman, dark skin tone, medium-light skin tone +1F469 1F3FF 200D 2764 200D 1F48B 200D 1F469 1F3FC ; minimally-qualified # 👩🏿‍❤‍💋‍👩🏼 E13.1 kiss: woman, woman, dark skin tone, medium-light skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FD ; fully-qualified # 👩🏿‍❤️‍💋‍👩🏽 E13.1 kiss: woman, woman, dark skin tone, medium skin tone +1F469 1F3FF 200D 2764 200D 1F48B 200D 1F469 1F3FD ; minimally-qualified # 👩🏿‍❤‍💋‍👩🏽 E13.1 kiss: woman, woman, dark skin tone, medium skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FE ; fully-qualified # 👩🏿‍❤️‍💋‍👩🏾 E13.1 kiss: woman, woman, dark skin tone, medium-dark skin tone +1F469 1F3FF 200D 2764 200D 1F48B 200D 1F469 1F3FE ; minimally-qualified # 👩🏿‍❤‍💋‍👩🏾 E13.1 kiss: woman, woman, dark skin tone, medium-dark skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FF ; fully-qualified # 👩🏿‍❤️‍💋‍👩🏿 E13.1 kiss: woman, woman, dark skin tone +1F469 1F3FF 200D 2764 200D 1F48B 200D 1F469 1F3FF ; minimally-qualified # 👩🏿‍❤‍💋‍👩🏿 E13.1 kiss: woman, woman, dark skin tone +1F491 ; fully-qualified # 💑 E0.6 couple with heart +1F491 1F3FB ; fully-qualified # 💑🏻 E13.1 couple with heart: light skin tone +1F491 1F3FC ; fully-qualified # 💑🏼 E13.1 couple with heart: medium-light skin tone +1F491 1F3FD ; fully-qualified # 💑🏽 E13.1 couple with heart: medium skin tone +1F491 1F3FE ; fully-qualified # 💑🏾 E13.1 couple with heart: medium-dark skin tone +1F491 1F3FF ; fully-qualified # 💑🏿 E13.1 couple with heart: dark skin tone +1F9D1 1F3FB 200D 2764 FE0F 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏻‍❤️‍🧑🏼 E13.1 couple with heart: person, person, light skin tone, medium-light skin tone +1F9D1 1F3FB 200D 2764 200D 1F9D1 1F3FC ; minimally-qualified # 🧑🏻‍❤‍🧑🏼 E13.1 couple with heart: person, person, light skin tone, medium-light skin tone +1F9D1 1F3FB 200D 2764 FE0F 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏻‍❤️‍🧑🏽 E13.1 couple with heart: person, person, light skin tone, medium skin tone +1F9D1 1F3FB 200D 2764 200D 1F9D1 1F3FD ; minimally-qualified # 🧑🏻‍❤‍🧑🏽 E13.1 couple with heart: person, person, light skin tone, medium skin tone +1F9D1 1F3FB 200D 2764 FE0F 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏻‍❤️‍🧑🏾 E13.1 couple with heart: person, person, light skin tone, medium-dark skin tone +1F9D1 1F3FB 200D 2764 200D 1F9D1 1F3FE ; minimally-qualified # 🧑🏻‍❤‍🧑🏾 E13.1 couple with heart: person, person, light skin tone, medium-dark skin tone +1F9D1 1F3FB 200D 2764 FE0F 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏻‍❤️‍🧑🏿 E13.1 couple with heart: person, person, light skin tone, dark skin tone +1F9D1 1F3FB 200D 2764 200D 1F9D1 1F3FF ; minimally-qualified # 🧑🏻‍❤‍🧑🏿 E13.1 couple with heart: person, person, light skin tone, dark skin tone +1F9D1 1F3FC 200D 2764 FE0F 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏼‍❤️‍🧑🏻 E13.1 couple with heart: person, person, medium-light skin tone, light skin tone +1F9D1 1F3FC 200D 2764 200D 1F9D1 1F3FB ; minimally-qualified # 🧑🏼‍❤‍🧑🏻 E13.1 couple with heart: person, person, medium-light skin tone, light skin tone +1F9D1 1F3FC 200D 2764 FE0F 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏼‍❤️‍🧑🏽 E13.1 couple with heart: person, person, medium-light skin tone, medium skin tone +1F9D1 1F3FC 200D 2764 200D 1F9D1 1F3FD ; minimally-qualified # 🧑🏼‍❤‍🧑🏽 E13.1 couple with heart: person, person, medium-light skin tone, medium skin tone +1F9D1 1F3FC 200D 2764 FE0F 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏼‍❤️‍🧑🏾 E13.1 couple with heart: person, person, medium-light skin tone, medium-dark skin tone +1F9D1 1F3FC 200D 2764 200D 1F9D1 1F3FE ; minimally-qualified # 🧑🏼‍❤‍🧑🏾 E13.1 couple with heart: person, person, medium-light skin tone, medium-dark skin tone +1F9D1 1F3FC 200D 2764 FE0F 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏼‍❤️‍🧑🏿 E13.1 couple with heart: person, person, medium-light skin tone, dark skin tone +1F9D1 1F3FC 200D 2764 200D 1F9D1 1F3FF ; minimally-qualified # 🧑🏼‍❤‍🧑🏿 E13.1 couple with heart: person, person, medium-light skin tone, dark skin tone +1F9D1 1F3FD 200D 2764 FE0F 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏽‍❤️‍🧑🏻 E13.1 couple with heart: person, person, medium skin tone, light skin tone +1F9D1 1F3FD 200D 2764 200D 1F9D1 1F3FB ; minimally-qualified # 🧑🏽‍❤‍🧑🏻 E13.1 couple with heart: person, person, medium skin tone, light skin tone +1F9D1 1F3FD 200D 2764 FE0F 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏽‍❤️‍🧑🏼 E13.1 couple with heart: person, person, medium skin tone, medium-light skin tone +1F9D1 1F3FD 200D 2764 200D 1F9D1 1F3FC ; minimally-qualified # 🧑🏽‍❤‍🧑🏼 E13.1 couple with heart: person, person, medium skin tone, medium-light skin tone +1F9D1 1F3FD 200D 2764 FE0F 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏽‍❤️‍🧑🏾 E13.1 couple with heart: person, person, medium skin tone, medium-dark skin tone +1F9D1 1F3FD 200D 2764 200D 1F9D1 1F3FE ; minimally-qualified # 🧑🏽‍❤‍🧑🏾 E13.1 couple with heart: person, person, medium skin tone, medium-dark skin tone +1F9D1 1F3FD 200D 2764 FE0F 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏽‍❤️‍🧑🏿 E13.1 couple with heart: person, person, medium skin tone, dark skin tone +1F9D1 1F3FD 200D 2764 200D 1F9D1 1F3FF ; minimally-qualified # 🧑🏽‍❤‍🧑🏿 E13.1 couple with heart: person, person, medium skin tone, dark skin tone +1F9D1 1F3FE 200D 2764 FE0F 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏾‍❤️‍🧑🏻 E13.1 couple with heart: person, person, medium-dark skin tone, light skin tone +1F9D1 1F3FE 200D 2764 200D 1F9D1 1F3FB ; minimally-qualified # 🧑🏾‍❤‍🧑🏻 E13.1 couple with heart: person, person, medium-dark skin tone, light skin tone +1F9D1 1F3FE 200D 2764 FE0F 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏾‍❤️‍🧑🏼 E13.1 couple with heart: person, person, medium-dark skin tone, medium-light skin tone +1F9D1 1F3FE 200D 2764 200D 1F9D1 1F3FC ; minimally-qualified # 🧑🏾‍❤‍🧑🏼 E13.1 couple with heart: person, person, medium-dark skin tone, medium-light skin tone +1F9D1 1F3FE 200D 2764 FE0F 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏾‍❤️‍🧑🏽 E13.1 couple with heart: person, person, medium-dark skin tone, medium skin tone +1F9D1 1F3FE 200D 2764 200D 1F9D1 1F3FD ; minimally-qualified # 🧑🏾‍❤‍🧑🏽 E13.1 couple with heart: person, person, medium-dark skin tone, medium skin tone +1F9D1 1F3FE 200D 2764 FE0F 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏾‍❤️‍🧑🏿 E13.1 couple with heart: person, person, medium-dark skin tone, dark skin tone +1F9D1 1F3FE 200D 2764 200D 1F9D1 1F3FF ; minimally-qualified # 🧑🏾‍❤‍🧑🏿 E13.1 couple with heart: person, person, medium-dark skin tone, dark skin tone +1F9D1 1F3FF 200D 2764 FE0F 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏿‍❤️‍🧑🏻 E13.1 couple with heart: person, person, dark skin tone, light skin tone +1F9D1 1F3FF 200D 2764 200D 1F9D1 1F3FB ; minimally-qualified # 🧑🏿‍❤‍🧑🏻 E13.1 couple with heart: person, person, dark skin tone, light skin tone +1F9D1 1F3FF 200D 2764 FE0F 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏿‍❤️‍🧑🏼 E13.1 couple with heart: person, person, dark skin tone, medium-light skin tone +1F9D1 1F3FF 200D 2764 200D 1F9D1 1F3FC ; minimally-qualified # 🧑🏿‍❤‍🧑🏼 E13.1 couple with heart: person, person, dark skin tone, medium-light skin tone +1F9D1 1F3FF 200D 2764 FE0F 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏿‍❤️‍🧑🏽 E13.1 couple with heart: person, person, dark skin tone, medium skin tone +1F9D1 1F3FF 200D 2764 200D 1F9D1 1F3FD ; minimally-qualified # 🧑🏿‍❤‍🧑🏽 E13.1 couple with heart: person, person, dark skin tone, medium skin tone +1F9D1 1F3FF 200D 2764 FE0F 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏿‍❤️‍🧑🏾 E13.1 couple with heart: person, person, dark skin tone, medium-dark skin tone +1F9D1 1F3FF 200D 2764 200D 1F9D1 1F3FE ; minimally-qualified # 🧑🏿‍❤‍🧑🏾 E13.1 couple with heart: person, person, dark skin tone, medium-dark skin tone +1F469 200D 2764 FE0F 200D 1F468 ; fully-qualified # 👩‍❤️‍👨 E2.0 couple with heart: woman, man +1F469 200D 2764 200D 1F468 ; minimally-qualified # 👩‍❤‍👨 E2.0 couple with heart: woman, man +1F469 1F3FB 200D 2764 FE0F 200D 1F468 1F3FB ; fully-qualified # 👩🏻‍❤️‍👨🏻 E13.1 couple with heart: woman, man, light skin tone +1F469 1F3FB 200D 2764 200D 1F468 1F3FB ; minimally-qualified # 👩🏻‍❤‍👨🏻 E13.1 couple with heart: woman, man, light skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F468 1F3FC ; fully-qualified # 👩🏻‍❤️‍👨🏼 E13.1 couple with heart: woman, man, light skin tone, medium-light skin tone +1F469 1F3FB 200D 2764 200D 1F468 1F3FC ; minimally-qualified # 👩🏻‍❤‍👨🏼 E13.1 couple with heart: woman, man, light skin tone, medium-light skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F468 1F3FD ; fully-qualified # 👩🏻‍❤️‍👨🏽 E13.1 couple with heart: woman, man, light skin tone, medium skin tone +1F469 1F3FB 200D 2764 200D 1F468 1F3FD ; minimally-qualified # 👩🏻‍❤‍👨🏽 E13.1 couple with heart: woman, man, light skin tone, medium skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F468 1F3FE ; fully-qualified # 👩🏻‍❤️‍👨🏾 E13.1 couple with heart: woman, man, light skin tone, medium-dark skin tone +1F469 1F3FB 200D 2764 200D 1F468 1F3FE ; minimally-qualified # 👩🏻‍❤‍👨🏾 E13.1 couple with heart: woman, man, light skin tone, medium-dark skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F468 1F3FF ; fully-qualified # 👩🏻‍❤️‍👨🏿 E13.1 couple with heart: woman, man, light skin tone, dark skin tone +1F469 1F3FB 200D 2764 200D 1F468 1F3FF ; minimally-qualified # 👩🏻‍❤‍👨🏿 E13.1 couple with heart: woman, man, light skin tone, dark skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F468 1F3FB ; fully-qualified # 👩🏼‍❤️‍👨🏻 E13.1 couple with heart: woman, man, medium-light skin tone, light skin tone +1F469 1F3FC 200D 2764 200D 1F468 1F3FB ; minimally-qualified # 👩🏼‍❤‍👨🏻 E13.1 couple with heart: woman, man, medium-light skin tone, light skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F468 1F3FC ; fully-qualified # 👩🏼‍❤️‍👨🏼 E13.1 couple with heart: woman, man, medium-light skin tone +1F469 1F3FC 200D 2764 200D 1F468 1F3FC ; minimally-qualified # 👩🏼‍❤‍👨🏼 E13.1 couple with heart: woman, man, medium-light skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F468 1F3FD ; fully-qualified # 👩🏼‍❤️‍👨🏽 E13.1 couple with heart: woman, man, medium-light skin tone, medium skin tone +1F469 1F3FC 200D 2764 200D 1F468 1F3FD ; minimally-qualified # 👩🏼‍❤‍👨🏽 E13.1 couple with heart: woman, man, medium-light skin tone, medium skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F468 1F3FE ; fully-qualified # 👩🏼‍❤️‍👨🏾 E13.1 couple with heart: woman, man, medium-light skin tone, medium-dark skin tone +1F469 1F3FC 200D 2764 200D 1F468 1F3FE ; minimally-qualified # 👩🏼‍❤‍👨🏾 E13.1 couple with heart: woman, man, medium-light skin tone, medium-dark skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F468 1F3FF ; fully-qualified # 👩🏼‍❤️‍👨🏿 E13.1 couple with heart: woman, man, medium-light skin tone, dark skin tone +1F469 1F3FC 200D 2764 200D 1F468 1F3FF ; minimally-qualified # 👩🏼‍❤‍👨🏿 E13.1 couple with heart: woman, man, medium-light skin tone, dark skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F468 1F3FB ; fully-qualified # 👩🏽‍❤️‍👨🏻 E13.1 couple with heart: woman, man, medium skin tone, light skin tone +1F469 1F3FD 200D 2764 200D 1F468 1F3FB ; minimally-qualified # 👩🏽‍❤‍👨🏻 E13.1 couple with heart: woman, man, medium skin tone, light skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F468 1F3FC ; fully-qualified # 👩🏽‍❤️‍👨🏼 E13.1 couple with heart: woman, man, medium skin tone, medium-light skin tone +1F469 1F3FD 200D 2764 200D 1F468 1F3FC ; minimally-qualified # 👩🏽‍❤‍👨🏼 E13.1 couple with heart: woman, man, medium skin tone, medium-light skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F468 1F3FD ; fully-qualified # 👩🏽‍❤️‍👨🏽 E13.1 couple with heart: woman, man, medium skin tone +1F469 1F3FD 200D 2764 200D 1F468 1F3FD ; minimally-qualified # 👩🏽‍❤‍👨🏽 E13.1 couple with heart: woman, man, medium skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F468 1F3FE ; fully-qualified # 👩🏽‍❤️‍👨🏾 E13.1 couple with heart: woman, man, medium skin tone, medium-dark skin tone +1F469 1F3FD 200D 2764 200D 1F468 1F3FE ; minimally-qualified # 👩🏽‍❤‍👨🏾 E13.1 couple with heart: woman, man, medium skin tone, medium-dark skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F468 1F3FF ; fully-qualified # 👩🏽‍❤️‍👨🏿 E13.1 couple with heart: woman, man, medium skin tone, dark skin tone +1F469 1F3FD 200D 2764 200D 1F468 1F3FF ; minimally-qualified # 👩🏽‍❤‍👨🏿 E13.1 couple with heart: woman, man, medium skin tone, dark skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F468 1F3FB ; fully-qualified # 👩🏾‍❤️‍👨🏻 E13.1 couple with heart: woman, man, medium-dark skin tone, light skin tone +1F469 1F3FE 200D 2764 200D 1F468 1F3FB ; minimally-qualified # 👩🏾‍❤‍👨🏻 E13.1 couple with heart: woman, man, medium-dark skin tone, light skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F468 1F3FC ; fully-qualified # 👩🏾‍❤️‍👨🏼 E13.1 couple with heart: woman, man, medium-dark skin tone, medium-light skin tone +1F469 1F3FE 200D 2764 200D 1F468 1F3FC ; minimally-qualified # 👩🏾‍❤‍👨🏼 E13.1 couple with heart: woman, man, medium-dark skin tone, medium-light skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F468 1F3FD ; fully-qualified # 👩🏾‍❤️‍👨🏽 E13.1 couple with heart: woman, man, medium-dark skin tone, medium skin tone +1F469 1F3FE 200D 2764 200D 1F468 1F3FD ; minimally-qualified # 👩🏾‍❤‍👨🏽 E13.1 couple with heart: woman, man, medium-dark skin tone, medium skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F468 1F3FE ; fully-qualified # 👩🏾‍❤️‍👨🏾 E13.1 couple with heart: woman, man, medium-dark skin tone +1F469 1F3FE 200D 2764 200D 1F468 1F3FE ; minimally-qualified # 👩🏾‍❤‍👨🏾 E13.1 couple with heart: woman, man, medium-dark skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F468 1F3FF ; fully-qualified # 👩🏾‍❤️‍👨🏿 E13.1 couple with heart: woman, man, medium-dark skin tone, dark skin tone +1F469 1F3FE 200D 2764 200D 1F468 1F3FF ; minimally-qualified # 👩🏾‍❤‍👨🏿 E13.1 couple with heart: woman, man, medium-dark skin tone, dark skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F468 1F3FB ; fully-qualified # 👩🏿‍❤️‍👨🏻 E13.1 couple with heart: woman, man, dark skin tone, light skin tone +1F469 1F3FF 200D 2764 200D 1F468 1F3FB ; minimally-qualified # 👩🏿‍❤‍👨🏻 E13.1 couple with heart: woman, man, dark skin tone, light skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F468 1F3FC ; fully-qualified # 👩🏿‍❤️‍👨🏼 E13.1 couple with heart: woman, man, dark skin tone, medium-light skin tone +1F469 1F3FF 200D 2764 200D 1F468 1F3FC ; minimally-qualified # 👩🏿‍❤‍👨🏼 E13.1 couple with heart: woman, man, dark skin tone, medium-light skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F468 1F3FD ; fully-qualified # 👩🏿‍❤️‍👨🏽 E13.1 couple with heart: woman, man, dark skin tone, medium skin tone +1F469 1F3FF 200D 2764 200D 1F468 1F3FD ; minimally-qualified # 👩🏿‍❤‍👨🏽 E13.1 couple with heart: woman, man, dark skin tone, medium skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F468 1F3FE ; fully-qualified # 👩🏿‍❤️‍👨🏾 E13.1 couple with heart: woman, man, dark skin tone, medium-dark skin tone +1F469 1F3FF 200D 2764 200D 1F468 1F3FE ; minimally-qualified # 👩🏿‍❤‍👨🏾 E13.1 couple with heart: woman, man, dark skin tone, medium-dark skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F468 1F3FF ; fully-qualified # 👩🏿‍❤️‍👨🏿 E13.1 couple with heart: woman, man, dark skin tone +1F469 1F3FF 200D 2764 200D 1F468 1F3FF ; minimally-qualified # 👩🏿‍❤‍👨🏿 E13.1 couple with heart: woman, man, dark skin tone +1F468 200D 2764 FE0F 200D 1F468 ; fully-qualified # 👨‍❤️‍👨 E2.0 couple with heart: man, man +1F468 200D 2764 200D 1F468 ; minimally-qualified # 👨‍❤‍👨 E2.0 couple with heart: man, man +1F468 1F3FB 200D 2764 FE0F 200D 1F468 1F3FB ; fully-qualified # 👨🏻‍❤️‍👨🏻 E13.1 couple with heart: man, man, light skin tone +1F468 1F3FB 200D 2764 200D 1F468 1F3FB ; minimally-qualified # 👨🏻‍❤‍👨🏻 E13.1 couple with heart: man, man, light skin tone +1F468 1F3FB 200D 2764 FE0F 200D 1F468 1F3FC ; fully-qualified # 👨🏻‍❤️‍👨🏼 E13.1 couple with heart: man, man, light skin tone, medium-light skin tone +1F468 1F3FB 200D 2764 200D 1F468 1F3FC ; minimally-qualified # 👨🏻‍❤‍👨🏼 E13.1 couple with heart: man, man, light skin tone, medium-light skin tone +1F468 1F3FB 200D 2764 FE0F 200D 1F468 1F3FD ; fully-qualified # 👨🏻‍❤️‍👨🏽 E13.1 couple with heart: man, man, light skin tone, medium skin tone +1F468 1F3FB 200D 2764 200D 1F468 1F3FD ; minimally-qualified # 👨🏻‍❤‍👨🏽 E13.1 couple with heart: man, man, light skin tone, medium skin tone +1F468 1F3FB 200D 2764 FE0F 200D 1F468 1F3FE ; fully-qualified # 👨🏻‍❤️‍👨🏾 E13.1 couple with heart: man, man, light skin tone, medium-dark skin tone +1F468 1F3FB 200D 2764 200D 1F468 1F3FE ; minimally-qualified # 👨🏻‍❤‍👨🏾 E13.1 couple with heart: man, man, light skin tone, medium-dark skin tone +1F468 1F3FB 200D 2764 FE0F 200D 1F468 1F3FF ; fully-qualified # 👨🏻‍❤️‍👨🏿 E13.1 couple with heart: man, man, light skin tone, dark skin tone +1F468 1F3FB 200D 2764 200D 1F468 1F3FF ; minimally-qualified # 👨🏻‍❤‍👨🏿 E13.1 couple with heart: man, man, light skin tone, dark skin tone +1F468 1F3FC 200D 2764 FE0F 200D 1F468 1F3FB ; fully-qualified # 👨🏼‍❤️‍👨🏻 E13.1 couple with heart: man, man, medium-light skin tone, light skin tone +1F468 1F3FC 200D 2764 200D 1F468 1F3FB ; minimally-qualified # 👨🏼‍❤‍👨🏻 E13.1 couple with heart: man, man, medium-light skin tone, light skin tone +1F468 1F3FC 200D 2764 FE0F 200D 1F468 1F3FC ; fully-qualified # 👨🏼‍❤️‍👨🏼 E13.1 couple with heart: man, man, medium-light skin tone +1F468 1F3FC 200D 2764 200D 1F468 1F3FC ; minimally-qualified # 👨🏼‍❤‍👨🏼 E13.1 couple with heart: man, man, medium-light skin tone +1F468 1F3FC 200D 2764 FE0F 200D 1F468 1F3FD ; fully-qualified # 👨🏼‍❤️‍👨🏽 E13.1 couple with heart: man, man, medium-light skin tone, medium skin tone +1F468 1F3FC 200D 2764 200D 1F468 1F3FD ; minimally-qualified # 👨🏼‍❤‍👨🏽 E13.1 couple with heart: man, man, medium-light skin tone, medium skin tone +1F468 1F3FC 200D 2764 FE0F 200D 1F468 1F3FE ; fully-qualified # 👨🏼‍❤️‍👨🏾 E13.1 couple with heart: man, man, medium-light skin tone, medium-dark skin tone +1F468 1F3FC 200D 2764 200D 1F468 1F3FE ; minimally-qualified # 👨🏼‍❤‍👨🏾 E13.1 couple with heart: man, man, medium-light skin tone, medium-dark skin tone +1F468 1F3FC 200D 2764 FE0F 200D 1F468 1F3FF ; fully-qualified # 👨🏼‍❤️‍👨🏿 E13.1 couple with heart: man, man, medium-light skin tone, dark skin tone +1F468 1F3FC 200D 2764 200D 1F468 1F3FF ; minimally-qualified # 👨🏼‍❤‍👨🏿 E13.1 couple with heart: man, man, medium-light skin tone, dark skin tone +1F468 1F3FD 200D 2764 FE0F 200D 1F468 1F3FB ; fully-qualified # 👨🏽‍❤️‍👨🏻 E13.1 couple with heart: man, man, medium skin tone, light skin tone +1F468 1F3FD 200D 2764 200D 1F468 1F3FB ; minimally-qualified # 👨🏽‍❤‍👨🏻 E13.1 couple with heart: man, man, medium skin tone, light skin tone +1F468 1F3FD 200D 2764 FE0F 200D 1F468 1F3FC ; fully-qualified # 👨🏽‍❤️‍👨🏼 E13.1 couple with heart: man, man, medium skin tone, medium-light skin tone +1F468 1F3FD 200D 2764 200D 1F468 1F3FC ; minimally-qualified # 👨🏽‍❤‍👨🏼 E13.1 couple with heart: man, man, medium skin tone, medium-light skin tone +1F468 1F3FD 200D 2764 FE0F 200D 1F468 1F3FD ; fully-qualified # 👨🏽‍❤️‍👨🏽 E13.1 couple with heart: man, man, medium skin tone +1F468 1F3FD 200D 2764 200D 1F468 1F3FD ; minimally-qualified # 👨🏽‍❤‍👨🏽 E13.1 couple with heart: man, man, medium skin tone +1F468 1F3FD 200D 2764 FE0F 200D 1F468 1F3FE ; fully-qualified # 👨🏽‍❤️‍👨🏾 E13.1 couple with heart: man, man, medium skin tone, medium-dark skin tone +1F468 1F3FD 200D 2764 200D 1F468 1F3FE ; minimally-qualified # 👨🏽‍❤‍👨🏾 E13.1 couple with heart: man, man, medium skin tone, medium-dark skin tone +1F468 1F3FD 200D 2764 FE0F 200D 1F468 1F3FF ; fully-qualified # 👨🏽‍❤️‍👨🏿 E13.1 couple with heart: man, man, medium skin tone, dark skin tone +1F468 1F3FD 200D 2764 200D 1F468 1F3FF ; minimally-qualified # 👨🏽‍❤‍👨🏿 E13.1 couple with heart: man, man, medium skin tone, dark skin tone +1F468 1F3FE 200D 2764 FE0F 200D 1F468 1F3FB ; fully-qualified # 👨🏾‍❤️‍👨🏻 E13.1 couple with heart: man, man, medium-dark skin tone, light skin tone +1F468 1F3FE 200D 2764 200D 1F468 1F3FB ; minimally-qualified # 👨🏾‍❤‍👨🏻 E13.1 couple with heart: man, man, medium-dark skin tone, light skin tone +1F468 1F3FE 200D 2764 FE0F 200D 1F468 1F3FC ; fully-qualified # 👨🏾‍❤️‍👨🏼 E13.1 couple with heart: man, man, medium-dark skin tone, medium-light skin tone +1F468 1F3FE 200D 2764 200D 1F468 1F3FC ; minimally-qualified # 👨🏾‍❤‍👨🏼 E13.1 couple with heart: man, man, medium-dark skin tone, medium-light skin tone +1F468 1F3FE 200D 2764 FE0F 200D 1F468 1F3FD ; fully-qualified # 👨🏾‍❤️‍👨🏽 E13.1 couple with heart: man, man, medium-dark skin tone, medium skin tone +1F468 1F3FE 200D 2764 200D 1F468 1F3FD ; minimally-qualified # 👨🏾‍❤‍👨🏽 E13.1 couple with heart: man, man, medium-dark skin tone, medium skin tone +1F468 1F3FE 200D 2764 FE0F 200D 1F468 1F3FE ; fully-qualified # 👨🏾‍❤️‍👨🏾 E13.1 couple with heart: man, man, medium-dark skin tone +1F468 1F3FE 200D 2764 200D 1F468 1F3FE ; minimally-qualified # 👨🏾‍❤‍👨🏾 E13.1 couple with heart: man, man, medium-dark skin tone +1F468 1F3FE 200D 2764 FE0F 200D 1F468 1F3FF ; fully-qualified # 👨🏾‍❤️‍👨🏿 E13.1 couple with heart: man, man, medium-dark skin tone, dark skin tone +1F468 1F3FE 200D 2764 200D 1F468 1F3FF ; minimally-qualified # 👨🏾‍❤‍👨🏿 E13.1 couple with heart: man, man, medium-dark skin tone, dark skin tone +1F468 1F3FF 200D 2764 FE0F 200D 1F468 1F3FB ; fully-qualified # 👨🏿‍❤️‍👨🏻 E13.1 couple with heart: man, man, dark skin tone, light skin tone +1F468 1F3FF 200D 2764 200D 1F468 1F3FB ; minimally-qualified # 👨🏿‍❤‍👨🏻 E13.1 couple with heart: man, man, dark skin tone, light skin tone +1F468 1F3FF 200D 2764 FE0F 200D 1F468 1F3FC ; fully-qualified # 👨🏿‍❤️‍👨🏼 E13.1 couple with heart: man, man, dark skin tone, medium-light skin tone +1F468 1F3FF 200D 2764 200D 1F468 1F3FC ; minimally-qualified # 👨🏿‍❤‍👨🏼 E13.1 couple with heart: man, man, dark skin tone, medium-light skin tone +1F468 1F3FF 200D 2764 FE0F 200D 1F468 1F3FD ; fully-qualified # 👨🏿‍❤️‍👨🏽 E13.1 couple with heart: man, man, dark skin tone, medium skin tone +1F468 1F3FF 200D 2764 200D 1F468 1F3FD ; minimally-qualified # 👨🏿‍❤‍👨🏽 E13.1 couple with heart: man, man, dark skin tone, medium skin tone +1F468 1F3FF 200D 2764 FE0F 200D 1F468 1F3FE ; fully-qualified # 👨🏿‍❤️‍👨🏾 E13.1 couple with heart: man, man, dark skin tone, medium-dark skin tone +1F468 1F3FF 200D 2764 200D 1F468 1F3FE ; minimally-qualified # 👨🏿‍❤‍👨🏾 E13.1 couple with heart: man, man, dark skin tone, medium-dark skin tone +1F468 1F3FF 200D 2764 FE0F 200D 1F468 1F3FF ; fully-qualified # 👨🏿‍❤️‍👨🏿 E13.1 couple with heart: man, man, dark skin tone +1F468 1F3FF 200D 2764 200D 1F468 1F3FF ; minimally-qualified # 👨🏿‍❤‍👨🏿 E13.1 couple with heart: man, man, dark skin tone +1F469 200D 2764 FE0F 200D 1F469 ; fully-qualified # 👩‍❤️‍👩 E2.0 couple with heart: woman, woman +1F469 200D 2764 200D 1F469 ; minimally-qualified # 👩‍❤‍👩 E2.0 couple with heart: woman, woman +1F469 1F3FB 200D 2764 FE0F 200D 1F469 1F3FB ; fully-qualified # 👩🏻‍❤️‍👩🏻 E13.1 couple with heart: woman, woman, light skin tone +1F469 1F3FB 200D 2764 200D 1F469 1F3FB ; minimally-qualified # 👩🏻‍❤‍👩🏻 E13.1 couple with heart: woman, woman, light skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F469 1F3FC ; fully-qualified # 👩🏻‍❤️‍👩🏼 E13.1 couple with heart: woman, woman, light skin tone, medium-light skin tone +1F469 1F3FB 200D 2764 200D 1F469 1F3FC ; minimally-qualified # 👩🏻‍❤‍👩🏼 E13.1 couple with heart: woman, woman, light skin tone, medium-light skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F469 1F3FD ; fully-qualified # 👩🏻‍❤️‍👩🏽 E13.1 couple with heart: woman, woman, light skin tone, medium skin tone +1F469 1F3FB 200D 2764 200D 1F469 1F3FD ; minimally-qualified # 👩🏻‍❤‍👩🏽 E13.1 couple with heart: woman, woman, light skin tone, medium skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F469 1F3FE ; fully-qualified # 👩🏻‍❤️‍👩🏾 E13.1 couple with heart: woman, woman, light skin tone, medium-dark skin tone +1F469 1F3FB 200D 2764 200D 1F469 1F3FE ; minimally-qualified # 👩🏻‍❤‍👩🏾 E13.1 couple with heart: woman, woman, light skin tone, medium-dark skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F469 1F3FF ; fully-qualified # 👩🏻‍❤️‍👩🏿 E13.1 couple with heart: woman, woman, light skin tone, dark skin tone +1F469 1F3FB 200D 2764 200D 1F469 1F3FF ; minimally-qualified # 👩🏻‍❤‍👩🏿 E13.1 couple with heart: woman, woman, light skin tone, dark skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F469 1F3FB ; fully-qualified # 👩🏼‍❤️‍👩🏻 E13.1 couple with heart: woman, woman, medium-light skin tone, light skin tone +1F469 1F3FC 200D 2764 200D 1F469 1F3FB ; minimally-qualified # 👩🏼‍❤‍👩🏻 E13.1 couple with heart: woman, woman, medium-light skin tone, light skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F469 1F3FC ; fully-qualified # 👩🏼‍❤️‍👩🏼 E13.1 couple with heart: woman, woman, medium-light skin tone +1F469 1F3FC 200D 2764 200D 1F469 1F3FC ; minimally-qualified # 👩🏼‍❤‍👩🏼 E13.1 couple with heart: woman, woman, medium-light skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F469 1F3FD ; fully-qualified # 👩🏼‍❤️‍👩🏽 E13.1 couple with heart: woman, woman, medium-light skin tone, medium skin tone +1F469 1F3FC 200D 2764 200D 1F469 1F3FD ; minimally-qualified # 👩🏼‍❤‍👩🏽 E13.1 couple with heart: woman, woman, medium-light skin tone, medium skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F469 1F3FE ; fully-qualified # 👩🏼‍❤️‍👩🏾 E13.1 couple with heart: woman, woman, medium-light skin tone, medium-dark skin tone +1F469 1F3FC 200D 2764 200D 1F469 1F3FE ; minimally-qualified # 👩🏼‍❤‍👩🏾 E13.1 couple with heart: woman, woman, medium-light skin tone, medium-dark skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F469 1F3FF ; fully-qualified # 👩🏼‍❤️‍👩🏿 E13.1 couple with heart: woman, woman, medium-light skin tone, dark skin tone +1F469 1F3FC 200D 2764 200D 1F469 1F3FF ; minimally-qualified # 👩🏼‍❤‍👩🏿 E13.1 couple with heart: woman, woman, medium-light skin tone, dark skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F469 1F3FB ; fully-qualified # 👩🏽‍❤️‍👩🏻 E13.1 couple with heart: woman, woman, medium skin tone, light skin tone +1F469 1F3FD 200D 2764 200D 1F469 1F3FB ; minimally-qualified # 👩🏽‍❤‍👩🏻 E13.1 couple with heart: woman, woman, medium skin tone, light skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F469 1F3FC ; fully-qualified # 👩🏽‍❤️‍👩🏼 E13.1 couple with heart: woman, woman, medium skin tone, medium-light skin tone +1F469 1F3FD 200D 2764 200D 1F469 1F3FC ; minimally-qualified # 👩🏽‍❤‍👩🏼 E13.1 couple with heart: woman, woman, medium skin tone, medium-light skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F469 1F3FD ; fully-qualified # 👩🏽‍❤️‍👩🏽 E13.1 couple with heart: woman, woman, medium skin tone +1F469 1F3FD 200D 2764 200D 1F469 1F3FD ; minimally-qualified # 👩🏽‍❤‍👩🏽 E13.1 couple with heart: woman, woman, medium skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F469 1F3FE ; fully-qualified # 👩🏽‍❤️‍👩🏾 E13.1 couple with heart: woman, woman, medium skin tone, medium-dark skin tone +1F469 1F3FD 200D 2764 200D 1F469 1F3FE ; minimally-qualified # 👩🏽‍❤‍👩🏾 E13.1 couple with heart: woman, woman, medium skin tone, medium-dark skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F469 1F3FF ; fully-qualified # 👩🏽‍❤️‍👩🏿 E13.1 couple with heart: woman, woman, medium skin tone, dark skin tone +1F469 1F3FD 200D 2764 200D 1F469 1F3FF ; minimally-qualified # 👩🏽‍❤‍👩🏿 E13.1 couple with heart: woman, woman, medium skin tone, dark skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F469 1F3FB ; fully-qualified # 👩🏾‍❤️‍👩🏻 E13.1 couple with heart: woman, woman, medium-dark skin tone, light skin tone +1F469 1F3FE 200D 2764 200D 1F469 1F3FB ; minimally-qualified # 👩🏾‍❤‍👩🏻 E13.1 couple with heart: woman, woman, medium-dark skin tone, light skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F469 1F3FC ; fully-qualified # 👩🏾‍❤️‍👩🏼 E13.1 couple with heart: woman, woman, medium-dark skin tone, medium-light skin tone +1F469 1F3FE 200D 2764 200D 1F469 1F3FC ; minimally-qualified # 👩🏾‍❤‍👩🏼 E13.1 couple with heart: woman, woman, medium-dark skin tone, medium-light skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F469 1F3FD ; fully-qualified # 👩🏾‍❤️‍👩🏽 E13.1 couple with heart: woman, woman, medium-dark skin tone, medium skin tone +1F469 1F3FE 200D 2764 200D 1F469 1F3FD ; minimally-qualified # 👩🏾‍❤‍👩🏽 E13.1 couple with heart: woman, woman, medium-dark skin tone, medium skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F469 1F3FE ; fully-qualified # 👩🏾‍❤️‍👩🏾 E13.1 couple with heart: woman, woman, medium-dark skin tone +1F469 1F3FE 200D 2764 200D 1F469 1F3FE ; minimally-qualified # 👩🏾‍❤‍👩🏾 E13.1 couple with heart: woman, woman, medium-dark skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F469 1F3FF ; fully-qualified # 👩🏾‍❤️‍👩🏿 E13.1 couple with heart: woman, woman, medium-dark skin tone, dark skin tone +1F469 1F3FE 200D 2764 200D 1F469 1F3FF ; minimally-qualified # 👩🏾‍❤‍👩🏿 E13.1 couple with heart: woman, woman, medium-dark skin tone, dark skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F469 1F3FB ; fully-qualified # 👩🏿‍❤️‍👩🏻 E13.1 couple with heart: woman, woman, dark skin tone, light skin tone +1F469 1F3FF 200D 2764 200D 1F469 1F3FB ; minimally-qualified # 👩🏿‍❤‍👩🏻 E13.1 couple with heart: woman, woman, dark skin tone, light skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F469 1F3FC ; fully-qualified # 👩🏿‍❤️‍👩🏼 E13.1 couple with heart: woman, woman, dark skin tone, medium-light skin tone +1F469 1F3FF 200D 2764 200D 1F469 1F3FC ; minimally-qualified # 👩🏿‍❤‍👩🏼 E13.1 couple with heart: woman, woman, dark skin tone, medium-light skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F469 1F3FD ; fully-qualified # 👩🏿‍❤️‍👩🏽 E13.1 couple with heart: woman, woman, dark skin tone, medium skin tone +1F469 1F3FF 200D 2764 200D 1F469 1F3FD ; minimally-qualified # 👩🏿‍❤‍👩🏽 E13.1 couple with heart: woman, woman, dark skin tone, medium skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F469 1F3FE ; fully-qualified # 👩🏿‍❤️‍👩🏾 E13.1 couple with heart: woman, woman, dark skin tone, medium-dark skin tone +1F469 1F3FF 200D 2764 200D 1F469 1F3FE ; minimally-qualified # 👩🏿‍❤‍👩🏾 E13.1 couple with heart: woman, woman, dark skin tone, medium-dark skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F469 1F3FF ; fully-qualified # 👩🏿‍❤️‍👩🏿 E13.1 couple with heart: woman, woman, dark skin tone +1F469 1F3FF 200D 2764 200D 1F469 1F3FF ; minimally-qualified # 👩🏿‍❤‍👩🏿 E13.1 couple with heart: woman, woman, dark skin tone +1F46A ; fully-qualified # 👪 E0.6 family +1F468 200D 1F469 200D 1F466 ; fully-qualified # 👨‍👩‍👦 E2.0 family: man, woman, boy +1F468 200D 1F469 200D 1F467 ; fully-qualified # 👨‍👩‍👧 E2.0 family: man, woman, girl +1F468 200D 1F469 200D 1F467 200D 1F466 ; fully-qualified # 👨‍👩‍👧‍👦 E2.0 family: man, woman, girl, boy +1F468 200D 1F469 200D 1F466 200D 1F466 ; fully-qualified # 👨‍👩‍👦‍👦 E2.0 family: man, woman, boy, boy +1F468 200D 1F469 200D 1F467 200D 1F467 ; fully-qualified # 👨‍👩‍👧‍👧 E2.0 family: man, woman, girl, girl +1F468 200D 1F468 200D 1F466 ; fully-qualified # 👨‍👨‍👦 E2.0 family: man, man, boy +1F468 200D 1F468 200D 1F467 ; fully-qualified # 👨‍👨‍👧 E2.0 family: man, man, girl +1F468 200D 1F468 200D 1F467 200D 1F466 ; fully-qualified # 👨‍👨‍👧‍👦 E2.0 family: man, man, girl, boy +1F468 200D 1F468 200D 1F466 200D 1F466 ; fully-qualified # 👨‍👨‍👦‍👦 E2.0 family: man, man, boy, boy +1F468 200D 1F468 200D 1F467 200D 1F467 ; fully-qualified # 👨‍👨‍👧‍👧 E2.0 family: man, man, girl, girl +1F469 200D 1F469 200D 1F466 ; fully-qualified # 👩‍👩‍👦 E2.0 family: woman, woman, boy +1F469 200D 1F469 200D 1F467 ; fully-qualified # 👩‍👩‍👧 E2.0 family: woman, woman, girl +1F469 200D 1F469 200D 1F467 200D 1F466 ; fully-qualified # 👩‍👩‍👧‍👦 E2.0 family: woman, woman, girl, boy +1F469 200D 1F469 200D 1F466 200D 1F466 ; fully-qualified # 👩‍👩‍👦‍👦 E2.0 family: woman, woman, boy, boy +1F469 200D 1F469 200D 1F467 200D 1F467 ; fully-qualified # 👩‍👩‍👧‍👧 E2.0 family: woman, woman, girl, girl +1F468 200D 1F466 ; fully-qualified # 👨‍👦 E4.0 family: man, boy +1F468 200D 1F466 200D 1F466 ; fully-qualified # 👨‍👦‍👦 E4.0 family: man, boy, boy +1F468 200D 1F467 ; fully-qualified # 👨‍👧 E4.0 family: man, girl +1F468 200D 1F467 200D 1F466 ; fully-qualified # 👨‍👧‍👦 E4.0 family: man, girl, boy +1F468 200D 1F467 200D 1F467 ; fully-qualified # 👨‍👧‍👧 E4.0 family: man, girl, girl +1F469 200D 1F466 ; fully-qualified # 👩‍👦 E4.0 family: woman, boy +1F469 200D 1F466 200D 1F466 ; fully-qualified # 👩‍👦‍👦 E4.0 family: woman, boy, boy +1F469 200D 1F467 ; fully-qualified # 👩‍👧 E4.0 family: woman, girl +1F469 200D 1F467 200D 1F466 ; fully-qualified # 👩‍👧‍👦 E4.0 family: woman, girl, boy +1F469 200D 1F467 200D 1F467 ; fully-qualified # 👩‍👧‍👧 E4.0 family: woman, girl, girl + +# subgroup: person-symbol +1F5E3 FE0F ; fully-qualified # 🗣️ E0.7 speaking head +1F5E3 ; unqualified # 🗣 E0.7 speaking head +1F464 ; fully-qualified # 👤 E0.6 bust in silhouette +1F465 ; fully-qualified # 👥 E1.0 busts in silhouette +1FAC2 ; fully-qualified # 🫂 E13.0 people hugging +1F463 ; fully-qualified # 👣 E0.6 footprints + +# People & Body subtotal: 2899 +# People & Body subtotal: 494 w/o modifiers + +# group: Component + +# subgroup: skin-tone +1F3FB ; component # 🏻 E1.0 light skin tone +1F3FC ; component # 🏼 E1.0 medium-light skin tone +1F3FD ; component # 🏽 E1.0 medium skin tone +1F3FE ; component # 🏾 E1.0 medium-dark skin tone +1F3FF ; component # 🏿 E1.0 dark skin tone + +# subgroup: hair-style +1F9B0 ; component # 🦰 E11.0 red hair +1F9B1 ; component # 🦱 E11.0 curly hair +1F9B3 ; component # 🦳 E11.0 white hair +1F9B2 ; component # 🦲 E11.0 bald + +# Component subtotal: 9 +# Component subtotal: 4 w/o modifiers + +# group: Animals & Nature + +# subgroup: animal-mammal +1F435 ; fully-qualified # 🐵 E0.6 monkey face +1F412 ; fully-qualified # 🐒 E0.6 monkey +1F98D ; fully-qualified # 🦍 E3.0 gorilla +1F9A7 ; fully-qualified # 🦧 E12.0 orangutan +1F436 ; fully-qualified # 🐶 E0.6 dog face +1F415 ; fully-qualified # 🐕 E0.7 dog +1F9AE ; fully-qualified # 🦮 E12.0 guide dog +1F415 200D 1F9BA ; fully-qualified # 🐕‍🦺 E12.0 service dog +1F429 ; fully-qualified # 🐩 E0.6 poodle +1F43A ; fully-qualified # 🐺 E0.6 wolf +1F98A ; fully-qualified # 🦊 E3.0 fox +1F99D ; fully-qualified # 🦝 E11.0 raccoon +1F431 ; fully-qualified # 🐱 E0.6 cat face +1F408 ; fully-qualified # 🐈 E0.7 cat +1F408 200D 2B1B ; fully-qualified # 🐈‍⬛ E13.0 black cat +1F981 ; fully-qualified # 🦁 E1.0 lion +1F42F ; fully-qualified # 🐯 E0.6 tiger face +1F405 ; fully-qualified # 🐅 E1.0 tiger +1F406 ; fully-qualified # 🐆 E1.0 leopard +1F434 ; fully-qualified # 🐴 E0.6 horse face +1F40E ; fully-qualified # 🐎 E0.6 horse +1F984 ; fully-qualified # 🦄 E1.0 unicorn +1F993 ; fully-qualified # 🦓 E5.0 zebra +1F98C ; fully-qualified # 🦌 E3.0 deer +1F9AC ; fully-qualified # 🦬 E13.0 bison +1F42E ; fully-qualified # 🐮 E0.6 cow face +1F402 ; fully-qualified # 🐂 E1.0 ox +1F403 ; fully-qualified # 🐃 E1.0 water buffalo +1F404 ; fully-qualified # 🐄 E1.0 cow +1F437 ; fully-qualified # 🐷 E0.6 pig face +1F416 ; fully-qualified # 🐖 E1.0 pig +1F417 ; fully-qualified # 🐗 E0.6 boar +1F43D ; fully-qualified # 🐽 E0.6 pig nose +1F40F ; fully-qualified # 🐏 E1.0 ram +1F411 ; fully-qualified # 🐑 E0.6 ewe +1F410 ; fully-qualified # 🐐 E1.0 goat +1F42A ; fully-qualified # 🐪 E1.0 camel +1F42B ; fully-qualified # 🐫 E0.6 two-hump camel +1F999 ; fully-qualified # 🦙 E11.0 llama +1F992 ; fully-qualified # 🦒 E5.0 giraffe +1F418 ; fully-qualified # 🐘 E0.6 elephant +1F9A3 ; fully-qualified # 🦣 E13.0 mammoth +1F98F ; fully-qualified # 🦏 E3.0 rhinoceros +1F99B ; fully-qualified # 🦛 E11.0 hippopotamus +1F42D ; fully-qualified # 🐭 E0.6 mouse face +1F401 ; fully-qualified # 🐁 E1.0 mouse +1F400 ; fully-qualified # 🐀 E1.0 rat +1F439 ; fully-qualified # 🐹 E0.6 hamster +1F430 ; fully-qualified # 🐰 E0.6 rabbit face +1F407 ; fully-qualified # 🐇 E1.0 rabbit +1F43F FE0F ; fully-qualified # 🐿️ E0.7 chipmunk +1F43F ; unqualified # 🐿 E0.7 chipmunk +1F9AB ; fully-qualified # 🦫 E13.0 beaver +1F994 ; fully-qualified # 🦔 E5.0 hedgehog +1F987 ; fully-qualified # 🦇 E3.0 bat +1F43B ; fully-qualified # 🐻 E0.6 bear +1F43B 200D 2744 FE0F ; fully-qualified # 🐻‍❄️ E13.0 polar bear +1F43B 200D 2744 ; minimally-qualified # 🐻‍❄ E13.0 polar bear +1F428 ; fully-qualified # 🐨 E0.6 koala +1F43C ; fully-qualified # 🐼 E0.6 panda +1F9A5 ; fully-qualified # 🦥 E12.0 sloth +1F9A6 ; fully-qualified # 🦦 E12.0 otter +1F9A8 ; fully-qualified # 🦨 E12.0 skunk +1F998 ; fully-qualified # 🦘 E11.0 kangaroo +1F9A1 ; fully-qualified # 🦡 E11.0 badger +1F43E ; fully-qualified # 🐾 E0.6 paw prints + +# subgroup: animal-bird +1F983 ; fully-qualified # 🦃 E1.0 turkey +1F414 ; fully-qualified # 🐔 E0.6 chicken +1F413 ; fully-qualified # 🐓 E1.0 rooster +1F423 ; fully-qualified # 🐣 E0.6 hatching chick +1F424 ; fully-qualified # 🐤 E0.6 baby chick +1F425 ; fully-qualified # 🐥 E0.6 front-facing baby chick +1F426 ; fully-qualified # 🐦 E0.6 bird +1F427 ; fully-qualified # 🐧 E0.6 penguin +1F54A FE0F ; fully-qualified # 🕊️ E0.7 dove +1F54A ; unqualified # 🕊 E0.7 dove +1F985 ; fully-qualified # 🦅 E3.0 eagle +1F986 ; fully-qualified # 🦆 E3.0 duck +1F9A2 ; fully-qualified # 🦢 E11.0 swan +1F989 ; fully-qualified # 🦉 E3.0 owl +1F9A4 ; fully-qualified # 🦤 E13.0 dodo +1FAB6 ; fully-qualified # 🪶 E13.0 feather +1F9A9 ; fully-qualified # 🦩 E12.0 flamingo +1F99A ; fully-qualified # 🦚 E11.0 peacock +1F99C ; fully-qualified # 🦜 E11.0 parrot + +# subgroup: animal-amphibian +1F438 ; fully-qualified # 🐸 E0.6 frog + +# subgroup: animal-reptile +1F40A ; fully-qualified # 🐊 E1.0 crocodile +1F422 ; fully-qualified # 🐢 E0.6 turtle +1F98E ; fully-qualified # 🦎 E3.0 lizard +1F40D ; fully-qualified # 🐍 E0.6 snake +1F432 ; fully-qualified # 🐲 E0.6 dragon face +1F409 ; fully-qualified # 🐉 E1.0 dragon +1F995 ; fully-qualified # 🦕 E5.0 sauropod +1F996 ; fully-qualified # 🦖 E5.0 T-Rex + +# subgroup: animal-marine +1F433 ; fully-qualified # 🐳 E0.6 spouting whale +1F40B ; fully-qualified # 🐋 E1.0 whale +1F42C ; fully-qualified # 🐬 E0.6 dolphin +1F9AD ; fully-qualified # 🦭 E13.0 seal +1F41F ; fully-qualified # 🐟 E0.6 fish +1F420 ; fully-qualified # 🐠 E0.6 tropical fish +1F421 ; fully-qualified # 🐡 E0.6 blowfish +1F988 ; fully-qualified # 🦈 E3.0 shark +1F419 ; fully-qualified # 🐙 E0.6 octopus +1F41A ; fully-qualified # 🐚 E0.6 spiral shell + +# subgroup: animal-bug +1F40C ; fully-qualified # 🐌 E0.6 snail +1F98B ; fully-qualified # 🦋 E3.0 butterfly +1F41B ; fully-qualified # 🐛 E0.6 bug +1F41C ; fully-qualified # 🐜 E0.6 ant +1F41D ; fully-qualified # 🐝 E0.6 honeybee +1FAB2 ; fully-qualified # 🪲 E13.0 beetle +1F41E ; fully-qualified # 🐞 E0.6 lady beetle +1F997 ; fully-qualified # 🦗 E5.0 cricket +1FAB3 ; fully-qualified # 🪳 E13.0 cockroach +1F577 FE0F ; fully-qualified # 🕷️ E0.7 spider +1F577 ; unqualified # 🕷 E0.7 spider +1F578 FE0F ; fully-qualified # 🕸️ E0.7 spider web +1F578 ; unqualified # 🕸 E0.7 spider web +1F982 ; fully-qualified # 🦂 E1.0 scorpion +1F99F ; fully-qualified # 🦟 E11.0 mosquito +1FAB0 ; fully-qualified # 🪰 E13.0 fly +1FAB1 ; fully-qualified # 🪱 E13.0 worm +1F9A0 ; fully-qualified # 🦠 E11.0 microbe + +# subgroup: plant-flower +1F490 ; fully-qualified # 💐 E0.6 bouquet +1F338 ; fully-qualified # 🌸 E0.6 cherry blossom +1F4AE ; fully-qualified # 💮 E0.6 white flower +1F3F5 FE0F ; fully-qualified # 🏵️ E0.7 rosette +1F3F5 ; unqualified # 🏵 E0.7 rosette +1F339 ; fully-qualified # 🌹 E0.6 rose +1F940 ; fully-qualified # 🥀 E3.0 wilted flower +1F33A ; fully-qualified # 🌺 E0.6 hibiscus +1F33B ; fully-qualified # 🌻 E0.6 sunflower +1F33C ; fully-qualified # 🌼 E0.6 blossom +1F337 ; fully-qualified # 🌷 E0.6 tulip + +# subgroup: plant-other +1F331 ; fully-qualified # 🌱 E0.6 seedling +1FAB4 ; fully-qualified # 🪴 E13.0 potted plant +1F332 ; fully-qualified # 🌲 E1.0 evergreen tree +1F333 ; fully-qualified # 🌳 E1.0 deciduous tree +1F334 ; fully-qualified # 🌴 E0.6 palm tree +1F335 ; fully-qualified # 🌵 E0.6 cactus +1F33E ; fully-qualified # 🌾 E0.6 sheaf of rice +1F33F ; fully-qualified # 🌿 E0.6 herb +2618 FE0F ; fully-qualified # ☘️ E1.0 shamrock +2618 ; unqualified # ☘ E1.0 shamrock +1F340 ; fully-qualified # 🍀 E0.6 four leaf clover +1F341 ; fully-qualified # 🍁 E0.6 maple leaf +1F342 ; fully-qualified # 🍂 E0.6 fallen leaf +1F343 ; fully-qualified # 🍃 E0.6 leaf fluttering in wind + +# Animals & Nature subtotal: 147 +# Animals & Nature subtotal: 147 w/o modifiers + +# group: Food & Drink + +# subgroup: food-fruit +1F347 ; fully-qualified # 🍇 E0.6 grapes +1F348 ; fully-qualified # 🍈 E0.6 melon +1F349 ; fully-qualified # 🍉 E0.6 watermelon +1F34A ; fully-qualified # 🍊 E0.6 tangerine +1F34B ; fully-qualified # 🍋 E1.0 lemon +1F34C ; fully-qualified # 🍌 E0.6 banana +1F34D ; fully-qualified # 🍍 E0.6 pineapple +1F96D ; fully-qualified # 🥭 E11.0 mango +1F34E ; fully-qualified # 🍎 E0.6 red apple +1F34F ; fully-qualified # 🍏 E0.6 green apple +1F350 ; fully-qualified # 🍐 E1.0 pear +1F351 ; fully-qualified # 🍑 E0.6 peach +1F352 ; fully-qualified # 🍒 E0.6 cherries +1F353 ; fully-qualified # 🍓 E0.6 strawberry +1FAD0 ; fully-qualified # 🫐 E13.0 blueberries +1F95D ; fully-qualified # 🥝 E3.0 kiwi fruit +1F345 ; fully-qualified # 🍅 E0.6 tomato +1FAD2 ; fully-qualified # 🫒 E13.0 olive +1F965 ; fully-qualified # 🥥 E5.0 coconut + +# subgroup: food-vegetable +1F951 ; fully-qualified # 🥑 E3.0 avocado +1F346 ; fully-qualified # 🍆 E0.6 eggplant +1F954 ; fully-qualified # 🥔 E3.0 potato +1F955 ; fully-qualified # 🥕 E3.0 carrot +1F33D ; fully-qualified # 🌽 E0.6 ear of corn +1F336 FE0F ; fully-qualified # 🌶️ E0.7 hot pepper +1F336 ; unqualified # 🌶 E0.7 hot pepper +1FAD1 ; fully-qualified # 🫑 E13.0 bell pepper +1F952 ; fully-qualified # 🥒 E3.0 cucumber +1F96C ; fully-qualified # 🥬 E11.0 leafy green +1F966 ; fully-qualified # 🥦 E5.0 broccoli +1F9C4 ; fully-qualified # 🧄 E12.0 garlic +1F9C5 ; fully-qualified # 🧅 E12.0 onion +1F344 ; fully-qualified # 🍄 E0.6 mushroom +1F95C ; fully-qualified # 🥜 E3.0 peanuts +1F330 ; fully-qualified # 🌰 E0.6 chestnut + +# subgroup: food-prepared +1F35E ; fully-qualified # 🍞 E0.6 bread +1F950 ; fully-qualified # 🥐 E3.0 croissant +1F956 ; fully-qualified # 🥖 E3.0 baguette bread +1FAD3 ; fully-qualified # 🫓 E13.0 flatbread +1F968 ; fully-qualified # 🥨 E5.0 pretzel +1F96F ; fully-qualified # 🥯 E11.0 bagel +1F95E ; fully-qualified # 🥞 E3.0 pancakes +1F9C7 ; fully-qualified # 🧇 E12.0 waffle +1F9C0 ; fully-qualified # 🧀 E1.0 cheese wedge +1F356 ; fully-qualified # 🍖 E0.6 meat on bone +1F357 ; fully-qualified # 🍗 E0.6 poultry leg +1F969 ; fully-qualified # 🥩 E5.0 cut of meat +1F953 ; fully-qualified # 🥓 E3.0 bacon +1F354 ; fully-qualified # 🍔 E0.6 hamburger +1F35F ; fully-qualified # 🍟 E0.6 french fries +1F355 ; fully-qualified # 🍕 E0.6 pizza +1F32D ; fully-qualified # 🌭 E1.0 hot dog +1F96A ; fully-qualified # 🥪 E5.0 sandwich +1F32E ; fully-qualified # 🌮 E1.0 taco +1F32F ; fully-qualified # 🌯 E1.0 burrito +1FAD4 ; fully-qualified # 🫔 E13.0 tamale +1F959 ; fully-qualified # 🥙 E3.0 stuffed flatbread +1F9C6 ; fully-qualified # 🧆 E12.0 falafel +1F95A ; fully-qualified # 🥚 E3.0 egg +1F373 ; fully-qualified # 🍳 E0.6 cooking +1F958 ; fully-qualified # 🥘 E3.0 shallow pan of food +1F372 ; fully-qualified # 🍲 E0.6 pot of food +1FAD5 ; fully-qualified # 🫕 E13.0 fondue +1F963 ; fully-qualified # 🥣 E5.0 bowl with spoon +1F957 ; fully-qualified # 🥗 E3.0 green salad +1F37F ; fully-qualified # 🍿 E1.0 popcorn +1F9C8 ; fully-qualified # 🧈 E12.0 butter +1F9C2 ; fully-qualified # 🧂 E11.0 salt +1F96B ; fully-qualified # 🥫 E5.0 canned food + +# subgroup: food-asian +1F371 ; fully-qualified # 🍱 E0.6 bento box +1F358 ; fully-qualified # 🍘 E0.6 rice cracker +1F359 ; fully-qualified # 🍙 E0.6 rice ball +1F35A ; fully-qualified # 🍚 E0.6 cooked rice +1F35B ; fully-qualified # 🍛 E0.6 curry rice +1F35C ; fully-qualified # 🍜 E0.6 steaming bowl +1F35D ; fully-qualified # 🍝 E0.6 spaghetti +1F360 ; fully-qualified # 🍠 E0.6 roasted sweet potato +1F362 ; fully-qualified # 🍢 E0.6 oden +1F363 ; fully-qualified # 🍣 E0.6 sushi +1F364 ; fully-qualified # 🍤 E0.6 fried shrimp +1F365 ; fully-qualified # 🍥 E0.6 fish cake with swirl +1F96E ; fully-qualified # 🥮 E11.0 moon cake +1F361 ; fully-qualified # 🍡 E0.6 dango +1F95F ; fully-qualified # 🥟 E5.0 dumpling +1F960 ; fully-qualified # 🥠 E5.0 fortune cookie +1F961 ; fully-qualified # 🥡 E5.0 takeout box + +# subgroup: food-marine +1F980 ; fully-qualified # 🦀 E1.0 crab +1F99E ; fully-qualified # 🦞 E11.0 lobster +1F990 ; fully-qualified # 🦐 E3.0 shrimp +1F991 ; fully-qualified # 🦑 E3.0 squid +1F9AA ; fully-qualified # 🦪 E12.0 oyster + +# subgroup: food-sweet +1F366 ; fully-qualified # 🍦 E0.6 soft ice cream +1F367 ; fully-qualified # 🍧 E0.6 shaved ice +1F368 ; fully-qualified # 🍨 E0.6 ice cream +1F369 ; fully-qualified # 🍩 E0.6 doughnut +1F36A ; fully-qualified # 🍪 E0.6 cookie +1F382 ; fully-qualified # 🎂 E0.6 birthday cake +1F370 ; fully-qualified # 🍰 E0.6 shortcake +1F9C1 ; fully-qualified # 🧁 E11.0 cupcake +1F967 ; fully-qualified # 🥧 E5.0 pie +1F36B ; fully-qualified # 🍫 E0.6 chocolate bar +1F36C ; fully-qualified # 🍬 E0.6 candy +1F36D ; fully-qualified # 🍭 E0.6 lollipop +1F36E ; fully-qualified # 🍮 E0.6 custard +1F36F ; fully-qualified # 🍯 E0.6 honey pot + +# subgroup: drink +1F37C ; fully-qualified # 🍼 E1.0 baby bottle +1F95B ; fully-qualified # 🥛 E3.0 glass of milk +2615 ; fully-qualified # ☕ E0.6 hot beverage +1FAD6 ; fully-qualified # 🫖 E13.0 teapot +1F375 ; fully-qualified # 🍵 E0.6 teacup without handle +1F376 ; fully-qualified # 🍶 E0.6 sake +1F37E ; fully-qualified # 🍾 E1.0 bottle with popping cork +1F377 ; fully-qualified # 🍷 E0.6 wine glass +1F378 ; fully-qualified # 🍸 E0.6 cocktail glass +1F379 ; fully-qualified # 🍹 E0.6 tropical drink +1F37A ; fully-qualified # 🍺 E0.6 beer mug +1F37B ; fully-qualified # 🍻 E0.6 clinking beer mugs +1F942 ; fully-qualified # 🥂 E3.0 clinking glasses +1F943 ; fully-qualified # 🥃 E3.0 tumbler glass +1F964 ; fully-qualified # 🥤 E5.0 cup with straw +1F9CB ; fully-qualified # 🧋 E13.0 bubble tea +1F9C3 ; fully-qualified # 🧃 E12.0 beverage box +1F9C9 ; fully-qualified # 🧉 E12.0 mate +1F9CA ; fully-qualified # 🧊 E12.0 ice + +# subgroup: dishware +1F962 ; fully-qualified # 🥢 E5.0 chopsticks +1F37D FE0F ; fully-qualified # 🍽️ E0.7 fork and knife with plate +1F37D ; unqualified # 🍽 E0.7 fork and knife with plate +1F374 ; fully-qualified # 🍴 E0.6 fork and knife +1F944 ; fully-qualified # 🥄 E3.0 spoon +1F52A ; fully-qualified # 🔪 E0.6 kitchen knife +1F3FA ; fully-qualified # 🏺 E1.0 amphora + +# Food & Drink subtotal: 131 +# Food & Drink subtotal: 131 w/o modifiers + +# group: Travel & Places + +# subgroup: place-map +1F30D ; fully-qualified # 🌍 E0.7 globe showing Europe-Africa +1F30E ; fully-qualified # 🌎 E0.7 globe showing Americas +1F30F ; fully-qualified # 🌏 E0.6 globe showing Asia-Australia +1F310 ; fully-qualified # 🌐 E1.0 globe with meridians +1F5FA FE0F ; fully-qualified # 🗺️ E0.7 world map +1F5FA ; unqualified # 🗺 E0.7 world map +1F5FE ; fully-qualified # 🗾 E0.6 map of Japan +1F9ED ; fully-qualified # 🧭 E11.0 compass + +# subgroup: place-geographic +1F3D4 FE0F ; fully-qualified # 🏔️ E0.7 snow-capped mountain +1F3D4 ; unqualified # 🏔 E0.7 snow-capped mountain +26F0 FE0F ; fully-qualified # ⛰️ E0.7 mountain +26F0 ; unqualified # ⛰ E0.7 mountain +1F30B ; fully-qualified # 🌋 E0.6 volcano +1F5FB ; fully-qualified # 🗻 E0.6 mount fuji +1F3D5 FE0F ; fully-qualified # 🏕️ E0.7 camping +1F3D5 ; unqualified # 🏕 E0.7 camping +1F3D6 FE0F ; fully-qualified # 🏖️ E0.7 beach with umbrella +1F3D6 ; unqualified # 🏖 E0.7 beach with umbrella +1F3DC FE0F ; fully-qualified # 🏜️ E0.7 desert +1F3DC ; unqualified # 🏜 E0.7 desert +1F3DD FE0F ; fully-qualified # 🏝️ E0.7 desert island +1F3DD ; unqualified # 🏝 E0.7 desert island +1F3DE FE0F ; fully-qualified # 🏞️ E0.7 national park +1F3DE ; unqualified # 🏞 E0.7 national park + +# subgroup: place-building +1F3DF FE0F ; fully-qualified # 🏟️ E0.7 stadium +1F3DF ; unqualified # 🏟 E0.7 stadium +1F3DB FE0F ; fully-qualified # 🏛️ E0.7 classical building +1F3DB ; unqualified # 🏛 E0.7 classical building +1F3D7 FE0F ; fully-qualified # 🏗️ E0.7 building construction +1F3D7 ; unqualified # 🏗 E0.7 building construction +1F9F1 ; fully-qualified # 🧱 E11.0 brick +1FAA8 ; fully-qualified # 🪨 E13.0 rock +1FAB5 ; fully-qualified # 🪵 E13.0 wood +1F6D6 ; fully-qualified # 🛖 E13.0 hut +1F3D8 FE0F ; fully-qualified # 🏘️ E0.7 houses +1F3D8 ; unqualified # 🏘 E0.7 houses +1F3DA FE0F ; fully-qualified # 🏚️ E0.7 derelict house +1F3DA ; unqualified # 🏚 E0.7 derelict house +1F3E0 ; fully-qualified # 🏠 E0.6 house +1F3E1 ; fully-qualified # 🏡 E0.6 house with garden +1F3E2 ; fully-qualified # 🏢 E0.6 office building +1F3E3 ; fully-qualified # 🏣 E0.6 Japanese post office +1F3E4 ; fully-qualified # 🏤 E1.0 post office +1F3E5 ; fully-qualified # 🏥 E0.6 hospital +1F3E6 ; fully-qualified # 🏦 E0.6 bank +1F3E8 ; fully-qualified # 🏨 E0.6 hotel +1F3E9 ; fully-qualified # 🏩 E0.6 love hotel +1F3EA ; fully-qualified # 🏪 E0.6 convenience store +1F3EB ; fully-qualified # 🏫 E0.6 school +1F3EC ; fully-qualified # 🏬 E0.6 department store +1F3ED ; fully-qualified # 🏭 E0.6 factory +1F3EF ; fully-qualified # 🏯 E0.6 Japanese castle +1F3F0 ; fully-qualified # 🏰 E0.6 castle +1F492 ; fully-qualified # 💒 E0.6 wedding +1F5FC ; fully-qualified # 🗼 E0.6 Tokyo tower +1F5FD ; fully-qualified # 🗽 E0.6 Statue of Liberty + +# subgroup: place-religious +26EA ; fully-qualified # ⛪ E0.6 church +1F54C ; fully-qualified # 🕌 E1.0 mosque +1F6D5 ; fully-qualified # 🛕 E12.0 hindu temple +1F54D ; fully-qualified # 🕍 E1.0 synagogue +26E9 FE0F ; fully-qualified # ⛩️ E0.7 shinto shrine +26E9 ; unqualified # ⛩ E0.7 shinto shrine +1F54B ; fully-qualified # 🕋 E1.0 kaaba + +# subgroup: place-other +26F2 ; fully-qualified # ⛲ E0.6 fountain +26FA ; fully-qualified # ⛺ E0.6 tent +1F301 ; fully-qualified # 🌁 E0.6 foggy +1F303 ; fully-qualified # 🌃 E0.6 night with stars +1F3D9 FE0F ; fully-qualified # 🏙️ E0.7 cityscape +1F3D9 ; unqualified # 🏙 E0.7 cityscape +1F304 ; fully-qualified # 🌄 E0.6 sunrise over mountains +1F305 ; fully-qualified # 🌅 E0.6 sunrise +1F306 ; fully-qualified # 🌆 E0.6 cityscape at dusk +1F307 ; fully-qualified # 🌇 E0.6 sunset +1F309 ; fully-qualified # 🌉 E0.6 bridge at night +2668 FE0F ; fully-qualified # ♨️ E0.6 hot springs +2668 ; unqualified # ♨ E0.6 hot springs +1F3A0 ; fully-qualified # 🎠 E0.6 carousel horse +1F3A1 ; fully-qualified # 🎡 E0.6 ferris wheel +1F3A2 ; fully-qualified # 🎢 E0.6 roller coaster +1F488 ; fully-qualified # 💈 E0.6 barber pole +1F3AA ; fully-qualified # 🎪 E0.6 circus tent + +# subgroup: transport-ground +1F682 ; fully-qualified # 🚂 E1.0 locomotive +1F683 ; fully-qualified # 🚃 E0.6 railway car +1F684 ; fully-qualified # 🚄 E0.6 high-speed train +1F685 ; fully-qualified # 🚅 E0.6 bullet train +1F686 ; fully-qualified # 🚆 E1.0 train +1F687 ; fully-qualified # 🚇 E0.6 metro +1F688 ; fully-qualified # 🚈 E1.0 light rail +1F689 ; fully-qualified # 🚉 E0.6 station +1F68A ; fully-qualified # 🚊 E1.0 tram +1F69D ; fully-qualified # 🚝 E1.0 monorail +1F69E ; fully-qualified # 🚞 E1.0 mountain railway +1F68B ; fully-qualified # 🚋 E1.0 tram car +1F68C ; fully-qualified # 🚌 E0.6 bus +1F68D ; fully-qualified # 🚍 E0.7 oncoming bus +1F68E ; fully-qualified # 🚎 E1.0 trolleybus +1F690 ; fully-qualified # 🚐 E1.0 minibus +1F691 ; fully-qualified # 🚑 E0.6 ambulance +1F692 ; fully-qualified # 🚒 E0.6 fire engine +1F693 ; fully-qualified # 🚓 E0.6 police car +1F694 ; fully-qualified # 🚔 E0.7 oncoming police car +1F695 ; fully-qualified # 🚕 E0.6 taxi +1F696 ; fully-qualified # 🚖 E1.0 oncoming taxi +1F697 ; fully-qualified # 🚗 E0.6 automobile +1F698 ; fully-qualified # 🚘 E0.7 oncoming automobile +1F699 ; fully-qualified # 🚙 E0.6 sport utility vehicle +1F6FB ; fully-qualified # 🛻 E13.0 pickup truck +1F69A ; fully-qualified # 🚚 E0.6 delivery truck +1F69B ; fully-qualified # 🚛 E1.0 articulated lorry +1F69C ; fully-qualified # 🚜 E1.0 tractor +1F3CE FE0F ; fully-qualified # 🏎️ E0.7 racing car +1F3CE ; unqualified # 🏎 E0.7 racing car +1F3CD FE0F ; fully-qualified # 🏍️ E0.7 motorcycle +1F3CD ; unqualified # 🏍 E0.7 motorcycle +1F6F5 ; fully-qualified # 🛵 E3.0 motor scooter +1F9BD ; fully-qualified # 🦽 E12.0 manual wheelchair +1F9BC ; fully-qualified # 🦼 E12.0 motorized wheelchair +1F6FA ; fully-qualified # 🛺 E12.0 auto rickshaw +1F6B2 ; fully-qualified # 🚲 E0.6 bicycle +1F6F4 ; fully-qualified # 🛴 E3.0 kick scooter +1F6F9 ; fully-qualified # 🛹 E11.0 skateboard +1F6FC ; fully-qualified # 🛼 E13.0 roller skate +1F68F ; fully-qualified # 🚏 E0.6 bus stop +1F6E3 FE0F ; fully-qualified # 🛣️ E0.7 motorway +1F6E3 ; unqualified # 🛣 E0.7 motorway +1F6E4 FE0F ; fully-qualified # 🛤️ E0.7 railway track +1F6E4 ; unqualified # 🛤 E0.7 railway track +1F6E2 FE0F ; fully-qualified # 🛢️ E0.7 oil drum +1F6E2 ; unqualified # 🛢 E0.7 oil drum +26FD ; fully-qualified # ⛽ E0.6 fuel pump +1F6A8 ; fully-qualified # 🚨 E0.6 police car light +1F6A5 ; fully-qualified # 🚥 E0.6 horizontal traffic light +1F6A6 ; fully-qualified # 🚦 E1.0 vertical traffic light +1F6D1 ; fully-qualified # 🛑 E3.0 stop sign +1F6A7 ; fully-qualified # 🚧 E0.6 construction + +# subgroup: transport-water +2693 ; fully-qualified # ⚓ E0.6 anchor +26F5 ; fully-qualified # ⛵ E0.6 sailboat +1F6F6 ; fully-qualified # 🛶 E3.0 canoe +1F6A4 ; fully-qualified # 🚤 E0.6 speedboat +1F6F3 FE0F ; fully-qualified # 🛳️ E0.7 passenger ship +1F6F3 ; unqualified # 🛳 E0.7 passenger ship +26F4 FE0F ; fully-qualified # ⛴️ E0.7 ferry +26F4 ; unqualified # ⛴ E0.7 ferry +1F6E5 FE0F ; fully-qualified # 🛥️ E0.7 motor boat +1F6E5 ; unqualified # 🛥 E0.7 motor boat +1F6A2 ; fully-qualified # 🚢 E0.6 ship + +# subgroup: transport-air +2708 FE0F ; fully-qualified # ✈️ E0.6 airplane +2708 ; unqualified # ✈ E0.6 airplane +1F6E9 FE0F ; fully-qualified # 🛩️ E0.7 small airplane +1F6E9 ; unqualified # 🛩 E0.7 small airplane +1F6EB ; fully-qualified # 🛫 E1.0 airplane departure +1F6EC ; fully-qualified # 🛬 E1.0 airplane arrival +1FA82 ; fully-qualified # 🪂 E12.0 parachute +1F4BA ; fully-qualified # 💺 E0.6 seat +1F681 ; fully-qualified # 🚁 E1.0 helicopter +1F69F ; fully-qualified # 🚟 E1.0 suspension railway +1F6A0 ; fully-qualified # 🚠 E1.0 mountain cableway +1F6A1 ; fully-qualified # 🚡 E1.0 aerial tramway +1F6F0 FE0F ; fully-qualified # 🛰️ E0.7 satellite +1F6F0 ; unqualified # 🛰 E0.7 satellite +1F680 ; fully-qualified # 🚀 E0.6 rocket +1F6F8 ; fully-qualified # 🛸 E5.0 flying saucer + +# subgroup: hotel +1F6CE FE0F ; fully-qualified # 🛎️ E0.7 bellhop bell +1F6CE ; unqualified # 🛎 E0.7 bellhop bell +1F9F3 ; fully-qualified # 🧳 E11.0 luggage + +# subgroup: time +231B ; fully-qualified # ⌛ E0.6 hourglass done +23F3 ; fully-qualified # ⏳ E0.6 hourglass not done +231A ; fully-qualified # ⌚ E0.6 watch +23F0 ; fully-qualified # ⏰ E0.6 alarm clock +23F1 FE0F ; fully-qualified # ⏱️ E1.0 stopwatch +23F1 ; unqualified # ⏱ E1.0 stopwatch +23F2 FE0F ; fully-qualified # ⏲️ E1.0 timer clock +23F2 ; unqualified # ⏲ E1.0 timer clock +1F570 FE0F ; fully-qualified # 🕰️ E0.7 mantelpiece clock +1F570 ; unqualified # 🕰 E0.7 mantelpiece clock +1F55B ; fully-qualified # 🕛 E0.6 twelve o’clock +1F567 ; fully-qualified # 🕧 E0.7 twelve-thirty +1F550 ; fully-qualified # 🕐 E0.6 one o’clock +1F55C ; fully-qualified # 🕜 E0.7 one-thirty +1F551 ; fully-qualified # 🕑 E0.6 two o’clock +1F55D ; fully-qualified # 🕝 E0.7 two-thirty +1F552 ; fully-qualified # 🕒 E0.6 three o’clock +1F55E ; fully-qualified # 🕞 E0.7 three-thirty +1F553 ; fully-qualified # 🕓 E0.6 four o’clock +1F55F ; fully-qualified # 🕟 E0.7 four-thirty +1F554 ; fully-qualified # 🕔 E0.6 five o’clock +1F560 ; fully-qualified # 🕠 E0.7 five-thirty +1F555 ; fully-qualified # 🕕 E0.6 six o’clock +1F561 ; fully-qualified # 🕡 E0.7 six-thirty +1F556 ; fully-qualified # 🕖 E0.6 seven o’clock +1F562 ; fully-qualified # 🕢 E0.7 seven-thirty +1F557 ; fully-qualified # 🕗 E0.6 eight o’clock +1F563 ; fully-qualified # 🕣 E0.7 eight-thirty +1F558 ; fully-qualified # 🕘 E0.6 nine o’clock +1F564 ; fully-qualified # 🕤 E0.7 nine-thirty +1F559 ; fully-qualified # 🕙 E0.6 ten o’clock +1F565 ; fully-qualified # 🕥 E0.7 ten-thirty +1F55A ; fully-qualified # 🕚 E0.6 eleven o’clock +1F566 ; fully-qualified # 🕦 E0.7 eleven-thirty + +# subgroup: sky & weather +1F311 ; fully-qualified # 🌑 E0.6 new moon +1F312 ; fully-qualified # 🌒 E1.0 waxing crescent moon +1F313 ; fully-qualified # 🌓 E0.6 first quarter moon +1F314 ; fully-qualified # 🌔 E0.6 waxing gibbous moon +1F315 ; fully-qualified # 🌕 E0.6 full moon +1F316 ; fully-qualified # 🌖 E1.0 waning gibbous moon +1F317 ; fully-qualified # 🌗 E1.0 last quarter moon +1F318 ; fully-qualified # 🌘 E1.0 waning crescent moon +1F319 ; fully-qualified # 🌙 E0.6 crescent moon +1F31A ; fully-qualified # 🌚 E1.0 new moon face +1F31B ; fully-qualified # 🌛 E0.6 first quarter moon face +1F31C ; fully-qualified # 🌜 E0.7 last quarter moon face +1F321 FE0F ; fully-qualified # 🌡️ E0.7 thermometer +1F321 ; unqualified # 🌡 E0.7 thermometer +2600 FE0F ; fully-qualified # ☀️ E0.6 sun +2600 ; unqualified # ☀ E0.6 sun +1F31D ; fully-qualified # 🌝 E1.0 full moon face +1F31E ; fully-qualified # 🌞 E1.0 sun with face +1FA90 ; fully-qualified # 🪐 E12.0 ringed planet +2B50 ; fully-qualified # ⭐ E0.6 star +1F31F ; fully-qualified # 🌟 E0.6 glowing star +1F320 ; fully-qualified # 🌠 E0.6 shooting star +1F30C ; fully-qualified # 🌌 E0.6 milky way +2601 FE0F ; fully-qualified # ☁️ E0.6 cloud +2601 ; unqualified # ☁ E0.6 cloud +26C5 ; fully-qualified # ⛅ E0.6 sun behind cloud +26C8 FE0F ; fully-qualified # ⛈️ E0.7 cloud with lightning and rain +26C8 ; unqualified # ⛈ E0.7 cloud with lightning and rain +1F324 FE0F ; fully-qualified # 🌤️ E0.7 sun behind small cloud +1F324 ; unqualified # 🌤 E0.7 sun behind small cloud +1F325 FE0F ; fully-qualified # 🌥️ E0.7 sun behind large cloud +1F325 ; unqualified # 🌥 E0.7 sun behind large cloud +1F326 FE0F ; fully-qualified # 🌦️ E0.7 sun behind rain cloud +1F326 ; unqualified # 🌦 E0.7 sun behind rain cloud +1F327 FE0F ; fully-qualified # 🌧️ E0.7 cloud with rain +1F327 ; unqualified # 🌧 E0.7 cloud with rain +1F328 FE0F ; fully-qualified # 🌨️ E0.7 cloud with snow +1F328 ; unqualified # 🌨 E0.7 cloud with snow +1F329 FE0F ; fully-qualified # 🌩️ E0.7 cloud with lightning +1F329 ; unqualified # 🌩 E0.7 cloud with lightning +1F32A FE0F ; fully-qualified # 🌪️ E0.7 tornado +1F32A ; unqualified # 🌪 E0.7 tornado +1F32B FE0F ; fully-qualified # 🌫️ E0.7 fog +1F32B ; unqualified # 🌫 E0.7 fog +1F32C FE0F ; fully-qualified # 🌬️ E0.7 wind face +1F32C ; unqualified # 🌬 E0.7 wind face +1F300 ; fully-qualified # 🌀 E0.6 cyclone +1F308 ; fully-qualified # 🌈 E0.6 rainbow +1F302 ; fully-qualified # 🌂 E0.6 closed umbrella +2602 FE0F ; fully-qualified # ☂️ E0.7 umbrella +2602 ; unqualified # ☂ E0.7 umbrella +2614 ; fully-qualified # ☔ E0.6 umbrella with rain drops +26F1 FE0F ; fully-qualified # ⛱️ E0.7 umbrella on ground +26F1 ; unqualified # ⛱ E0.7 umbrella on ground +26A1 ; fully-qualified # ⚡ E0.6 high voltage +2744 FE0F ; fully-qualified # ❄️ E0.6 snowflake +2744 ; unqualified # ❄ E0.6 snowflake +2603 FE0F ; fully-qualified # ☃️ E0.7 snowman +2603 ; unqualified # ☃ E0.7 snowman +26C4 ; fully-qualified # ⛄ E0.6 snowman without snow +2604 FE0F ; fully-qualified # ☄️ E1.0 comet +2604 ; unqualified # ☄ E1.0 comet +1F525 ; fully-qualified # 🔥 E0.6 fire +1F4A7 ; fully-qualified # 💧 E0.6 droplet +1F30A ; fully-qualified # 🌊 E0.6 water wave + +# Travel & Places subtotal: 264 +# Travel & Places subtotal: 264 w/o modifiers + +# group: Activities + +# subgroup: event +1F383 ; fully-qualified # 🎃 E0.6 jack-o-lantern +1F384 ; fully-qualified # 🎄 E0.6 Christmas tree +1F386 ; fully-qualified # 🎆 E0.6 fireworks +1F387 ; fully-qualified # 🎇 E0.6 sparkler +1F9E8 ; fully-qualified # 🧨 E11.0 firecracker +2728 ; fully-qualified # ✨ E0.6 sparkles +1F388 ; fully-qualified # 🎈 E0.6 balloon +1F389 ; fully-qualified # 🎉 E0.6 party popper +1F38A ; fully-qualified # 🎊 E0.6 confetti ball +1F38B ; fully-qualified # 🎋 E0.6 tanabata tree +1F38D ; fully-qualified # 🎍 E0.6 pine decoration +1F38E ; fully-qualified # 🎎 E0.6 Japanese dolls +1F38F ; fully-qualified # 🎏 E0.6 carp streamer +1F390 ; fully-qualified # 🎐 E0.6 wind chime +1F391 ; fully-qualified # 🎑 E0.6 moon viewing ceremony +1F9E7 ; fully-qualified # 🧧 E11.0 red envelope +1F380 ; fully-qualified # 🎀 E0.6 ribbon +1F381 ; fully-qualified # 🎁 E0.6 wrapped gift +1F397 FE0F ; fully-qualified # 🎗️ E0.7 reminder ribbon +1F397 ; unqualified # 🎗 E0.7 reminder ribbon +1F39F FE0F ; fully-qualified # 🎟️ E0.7 admission tickets +1F39F ; unqualified # 🎟 E0.7 admission tickets +1F3AB ; fully-qualified # 🎫 E0.6 ticket + +# subgroup: award-medal +1F396 FE0F ; fully-qualified # 🎖️ E0.7 military medal +1F396 ; unqualified # 🎖 E0.7 military medal +1F3C6 ; fully-qualified # 🏆 E0.6 trophy +1F3C5 ; fully-qualified # 🏅 E1.0 sports medal +1F947 ; fully-qualified # 🥇 E3.0 1st place medal +1F948 ; fully-qualified # 🥈 E3.0 2nd place medal +1F949 ; fully-qualified # 🥉 E3.0 3rd place medal + +# subgroup: sport +26BD ; fully-qualified # ⚽ E0.6 soccer ball +26BE ; fully-qualified # ⚾ E0.6 baseball +1F94E ; fully-qualified # 🥎 E11.0 softball +1F3C0 ; fully-qualified # 🏀 E0.6 basketball +1F3D0 ; fully-qualified # 🏐 E1.0 volleyball +1F3C8 ; fully-qualified # 🏈 E0.6 american football +1F3C9 ; fully-qualified # 🏉 E1.0 rugby football +1F3BE ; fully-qualified # 🎾 E0.6 tennis +1F94F ; fully-qualified # 🥏 E11.0 flying disc +1F3B3 ; fully-qualified # 🎳 E0.6 bowling +1F3CF ; fully-qualified # 🏏 E1.0 cricket game +1F3D1 ; fully-qualified # 🏑 E1.0 field hockey +1F3D2 ; fully-qualified # 🏒 E1.0 ice hockey +1F94D ; fully-qualified # 🥍 E11.0 lacrosse +1F3D3 ; fully-qualified # 🏓 E1.0 ping pong +1F3F8 ; fully-qualified # 🏸 E1.0 badminton +1F94A ; fully-qualified # 🥊 E3.0 boxing glove +1F94B ; fully-qualified # 🥋 E3.0 martial arts uniform +1F945 ; fully-qualified # 🥅 E3.0 goal net +26F3 ; fully-qualified # ⛳ E0.6 flag in hole +26F8 FE0F ; fully-qualified # ⛸️ E0.7 ice skate +26F8 ; unqualified # ⛸ E0.7 ice skate +1F3A3 ; fully-qualified # 🎣 E0.6 fishing pole +1F93F ; fully-qualified # 🤿 E12.0 diving mask +1F3BD ; fully-qualified # 🎽 E0.6 running shirt +1F3BF ; fully-qualified # 🎿 E0.6 skis +1F6F7 ; fully-qualified # 🛷 E5.0 sled +1F94C ; fully-qualified # 🥌 E5.0 curling stone + +# subgroup: game +1F3AF ; fully-qualified # 🎯 E0.6 bullseye +1FA80 ; fully-qualified # 🪀 E12.0 yo-yo +1FA81 ; fully-qualified # 🪁 E12.0 kite +1F3B1 ; fully-qualified # 🎱 E0.6 pool 8 ball +1F52E ; fully-qualified # 🔮 E0.6 crystal ball +1FA84 ; fully-qualified # 🪄 E13.0 magic wand +1F9FF ; fully-qualified # 🧿 E11.0 nazar amulet +1F3AE ; fully-qualified # 🎮 E0.6 video game +1F579 FE0F ; fully-qualified # 🕹️ E0.7 joystick +1F579 ; unqualified # 🕹 E0.7 joystick +1F3B0 ; fully-qualified # 🎰 E0.6 slot machine +1F3B2 ; fully-qualified # 🎲 E0.6 game die +1F9E9 ; fully-qualified # 🧩 E11.0 puzzle piece +1F9F8 ; fully-qualified # 🧸 E11.0 teddy bear +1FA85 ; fully-qualified # 🪅 E13.0 piñata +1FA86 ; fully-qualified # 🪆 E13.0 nesting dolls +2660 FE0F ; fully-qualified # ♠️ E0.6 spade suit +2660 ; unqualified # ♠ E0.6 spade suit +2665 FE0F ; fully-qualified # ♥️ E0.6 heart suit +2665 ; unqualified # ♥ E0.6 heart suit +2666 FE0F ; fully-qualified # ♦️ E0.6 diamond suit +2666 ; unqualified # ♦ E0.6 diamond suit +2663 FE0F ; fully-qualified # ♣️ E0.6 club suit +2663 ; unqualified # ♣ E0.6 club suit +265F FE0F ; fully-qualified # ♟️ E11.0 chess pawn +265F ; unqualified # ♟ E11.0 chess pawn +1F0CF ; fully-qualified # 🃏 E0.6 joker +1F004 ; fully-qualified # 🀄 E0.6 mahjong red dragon +1F3B4 ; fully-qualified # 🎴 E0.6 flower playing cards + +# subgroup: arts & crafts +1F3AD ; fully-qualified # 🎭 E0.6 performing arts +1F5BC FE0F ; fully-qualified # 🖼️ E0.7 framed picture +1F5BC ; unqualified # 🖼 E0.7 framed picture +1F3A8 ; fully-qualified # 🎨 E0.6 artist palette +1F9F5 ; fully-qualified # 🧵 E11.0 thread +1FAA1 ; fully-qualified # 🪡 E13.0 sewing needle +1F9F6 ; fully-qualified # 🧶 E11.0 yarn +1FAA2 ; fully-qualified # 🪢 E13.0 knot + +# Activities subtotal: 95 +# Activities subtotal: 95 w/o modifiers + +# group: Objects + +# subgroup: clothing +1F453 ; fully-qualified # 👓 E0.6 glasses +1F576 FE0F ; fully-qualified # 🕶️ E0.7 sunglasses +1F576 ; unqualified # 🕶 E0.7 sunglasses +1F97D ; fully-qualified # 🥽 E11.0 goggles +1F97C ; fully-qualified # 🥼 E11.0 lab coat +1F9BA ; fully-qualified # 🦺 E12.0 safety vest +1F454 ; fully-qualified # 👔 E0.6 necktie +1F455 ; fully-qualified # 👕 E0.6 t-shirt +1F456 ; fully-qualified # 👖 E0.6 jeans +1F9E3 ; fully-qualified # 🧣 E5.0 scarf +1F9E4 ; fully-qualified # 🧤 E5.0 gloves +1F9E5 ; fully-qualified # 🧥 E5.0 coat +1F9E6 ; fully-qualified # 🧦 E5.0 socks +1F457 ; fully-qualified # 👗 E0.6 dress +1F458 ; fully-qualified # 👘 E0.6 kimono +1F97B ; fully-qualified # 🥻 E12.0 sari +1FA71 ; fully-qualified # 🩱 E12.0 one-piece swimsuit +1FA72 ; fully-qualified # 🩲 E12.0 briefs +1FA73 ; fully-qualified # 🩳 E12.0 shorts +1F459 ; fully-qualified # 👙 E0.6 bikini +1F45A ; fully-qualified # 👚 E0.6 woman’s clothes +1F45B ; fully-qualified # 👛 E0.6 purse +1F45C ; fully-qualified # 👜 E0.6 handbag +1F45D ; fully-qualified # 👝 E0.6 clutch bag +1F6CD FE0F ; fully-qualified # 🛍️ E0.7 shopping bags +1F6CD ; unqualified # 🛍 E0.7 shopping bags +1F392 ; fully-qualified # 🎒 E0.6 backpack +1FA74 ; fully-qualified # 🩴 E13.0 thong sandal +1F45E ; fully-qualified # 👞 E0.6 man’s shoe +1F45F ; fully-qualified # 👟 E0.6 running shoe +1F97E ; fully-qualified # 🥾 E11.0 hiking boot +1F97F ; fully-qualified # 🥿 E11.0 flat shoe +1F460 ; fully-qualified # 👠 E0.6 high-heeled shoe +1F461 ; fully-qualified # 👡 E0.6 woman’s sandal +1FA70 ; fully-qualified # 🩰 E12.0 ballet shoes +1F462 ; fully-qualified # 👢 E0.6 woman’s boot +1F451 ; fully-qualified # 👑 E0.6 crown +1F452 ; fully-qualified # 👒 E0.6 woman’s hat +1F3A9 ; fully-qualified # 🎩 E0.6 top hat +1F393 ; fully-qualified # 🎓 E0.6 graduation cap +1F9E2 ; fully-qualified # 🧢 E5.0 billed cap +1FA96 ; fully-qualified # 🪖 E13.0 military helmet +26D1 FE0F ; fully-qualified # ⛑️ E0.7 rescue worker’s helmet +26D1 ; unqualified # ⛑ E0.7 rescue worker’s helmet +1F4FF ; fully-qualified # 📿 E1.0 prayer beads +1F484 ; fully-qualified # 💄 E0.6 lipstick +1F48D ; fully-qualified # 💍 E0.6 ring +1F48E ; fully-qualified # 💎 E0.6 gem stone + +# subgroup: sound +1F507 ; fully-qualified # 🔇 E1.0 muted speaker +1F508 ; fully-qualified # 🔈 E0.7 speaker low volume +1F509 ; fully-qualified # 🔉 E1.0 speaker medium volume +1F50A ; fully-qualified # 🔊 E0.6 speaker high volume +1F4E2 ; fully-qualified # 📢 E0.6 loudspeaker +1F4E3 ; fully-qualified # 📣 E0.6 megaphone +1F4EF ; fully-qualified # 📯 E1.0 postal horn +1F514 ; fully-qualified # 🔔 E0.6 bell +1F515 ; fully-qualified # 🔕 E1.0 bell with slash + +# subgroup: music +1F3BC ; fully-qualified # 🎼 E0.6 musical score +1F3B5 ; fully-qualified # 🎵 E0.6 musical note +1F3B6 ; fully-qualified # 🎶 E0.6 musical notes +1F399 FE0F ; fully-qualified # 🎙️ E0.7 studio microphone +1F399 ; unqualified # 🎙 E0.7 studio microphone +1F39A FE0F ; fully-qualified # 🎚️ E0.7 level slider +1F39A ; unqualified # 🎚 E0.7 level slider +1F39B FE0F ; fully-qualified # 🎛️ E0.7 control knobs +1F39B ; unqualified # 🎛 E0.7 control knobs +1F3A4 ; fully-qualified # 🎤 E0.6 microphone +1F3A7 ; fully-qualified # 🎧 E0.6 headphone +1F4FB ; fully-qualified # 📻 E0.6 radio + +# subgroup: musical-instrument +1F3B7 ; fully-qualified # 🎷 E0.6 saxophone +1FA97 ; fully-qualified # 🪗 E13.0 accordion +1F3B8 ; fully-qualified # 🎸 E0.6 guitar +1F3B9 ; fully-qualified # 🎹 E0.6 musical keyboard +1F3BA ; fully-qualified # 🎺 E0.6 trumpet +1F3BB ; fully-qualified # 🎻 E0.6 violin +1FA95 ; fully-qualified # 🪕 E12.0 banjo +1F941 ; fully-qualified # 🥁 E3.0 drum +1FA98 ; fully-qualified # 🪘 E13.0 long drum + +# subgroup: phone +1F4F1 ; fully-qualified # 📱 E0.6 mobile phone +1F4F2 ; fully-qualified # 📲 E0.6 mobile phone with arrow +260E FE0F ; fully-qualified # ☎️ E0.6 telephone +260E ; unqualified # ☎ E0.6 telephone +1F4DE ; fully-qualified # 📞 E0.6 telephone receiver +1F4DF ; fully-qualified # 📟 E0.6 pager +1F4E0 ; fully-qualified # 📠 E0.6 fax machine + +# subgroup: computer +1F50B ; fully-qualified # 🔋 E0.6 battery +1F50C ; fully-qualified # 🔌 E0.6 electric plug +1F4BB ; fully-qualified # 💻 E0.6 laptop +1F5A5 FE0F ; fully-qualified # 🖥️ E0.7 desktop computer +1F5A5 ; unqualified # 🖥 E0.7 desktop computer +1F5A8 FE0F ; fully-qualified # 🖨️ E0.7 printer +1F5A8 ; unqualified # 🖨 E0.7 printer +2328 FE0F ; fully-qualified # ⌨️ E1.0 keyboard +2328 ; unqualified # ⌨ E1.0 keyboard +1F5B1 FE0F ; fully-qualified # 🖱️ E0.7 computer mouse +1F5B1 ; unqualified # 🖱 E0.7 computer mouse +1F5B2 FE0F ; fully-qualified # 🖲️ E0.7 trackball +1F5B2 ; unqualified # 🖲 E0.7 trackball +1F4BD ; fully-qualified # 💽 E0.6 computer disk +1F4BE ; fully-qualified # 💾 E0.6 floppy disk +1F4BF ; fully-qualified # 💿 E0.6 optical disk +1F4C0 ; fully-qualified # 📀 E0.6 dvd +1F9EE ; fully-qualified # 🧮 E11.0 abacus + +# subgroup: light & video +1F3A5 ; fully-qualified # 🎥 E0.6 movie camera +1F39E FE0F ; fully-qualified # 🎞️ E0.7 film frames +1F39E ; unqualified # 🎞 E0.7 film frames +1F4FD FE0F ; fully-qualified # 📽️ E0.7 film projector +1F4FD ; unqualified # 📽 E0.7 film projector +1F3AC ; fully-qualified # 🎬 E0.6 clapper board +1F4FA ; fully-qualified # 📺 E0.6 television +1F4F7 ; fully-qualified # 📷 E0.6 camera +1F4F8 ; fully-qualified # 📸 E1.0 camera with flash +1F4F9 ; fully-qualified # 📹 E0.6 video camera +1F4FC ; fully-qualified # 📼 E0.6 videocassette +1F50D ; fully-qualified # 🔍 E0.6 magnifying glass tilted left +1F50E ; fully-qualified # 🔎 E0.6 magnifying glass tilted right +1F56F FE0F ; fully-qualified # 🕯️ E0.7 candle +1F56F ; unqualified # 🕯 E0.7 candle +1F4A1 ; fully-qualified # 💡 E0.6 light bulb +1F526 ; fully-qualified # 🔦 E0.6 flashlight +1F3EE ; fully-qualified # 🏮 E0.6 red paper lantern +1FA94 ; fully-qualified # 🪔 E12.0 diya lamp + +# subgroup: book-paper +1F4D4 ; fully-qualified # 📔 E0.6 notebook with decorative cover +1F4D5 ; fully-qualified # 📕 E0.6 closed book +1F4D6 ; fully-qualified # 📖 E0.6 open book +1F4D7 ; fully-qualified # 📗 E0.6 green book +1F4D8 ; fully-qualified # 📘 E0.6 blue book +1F4D9 ; fully-qualified # 📙 E0.6 orange book +1F4DA ; fully-qualified # 📚 E0.6 books +1F4D3 ; fully-qualified # 📓 E0.6 notebook +1F4D2 ; fully-qualified # 📒 E0.6 ledger +1F4C3 ; fully-qualified # 📃 E0.6 page with curl +1F4DC ; fully-qualified # 📜 E0.6 scroll +1F4C4 ; fully-qualified # 📄 E0.6 page facing up +1F4F0 ; fully-qualified # 📰 E0.6 newspaper +1F5DE FE0F ; fully-qualified # 🗞️ E0.7 rolled-up newspaper +1F5DE ; unqualified # 🗞 E0.7 rolled-up newspaper +1F4D1 ; fully-qualified # 📑 E0.6 bookmark tabs +1F516 ; fully-qualified # 🔖 E0.6 bookmark +1F3F7 FE0F ; fully-qualified # 🏷️ E0.7 label +1F3F7 ; unqualified # 🏷 E0.7 label + +# subgroup: money +1F4B0 ; fully-qualified # 💰 E0.6 money bag +1FA99 ; fully-qualified # 🪙 E13.0 coin +1F4B4 ; fully-qualified # 💴 E0.6 yen banknote +1F4B5 ; fully-qualified # 💵 E0.6 dollar banknote +1F4B6 ; fully-qualified # 💶 E1.0 euro banknote +1F4B7 ; fully-qualified # 💷 E1.0 pound banknote +1F4B8 ; fully-qualified # 💸 E0.6 money with wings +1F4B3 ; fully-qualified # 💳 E0.6 credit card +1F9FE ; fully-qualified # 🧾 E11.0 receipt +1F4B9 ; fully-qualified # 💹 E0.6 chart increasing with yen + +# subgroup: mail +2709 FE0F ; fully-qualified # ✉️ E0.6 envelope +2709 ; unqualified # ✉ E0.6 envelope +1F4E7 ; fully-qualified # 📧 E0.6 e-mail +1F4E8 ; fully-qualified # 📨 E0.6 incoming envelope +1F4E9 ; fully-qualified # 📩 E0.6 envelope with arrow +1F4E4 ; fully-qualified # 📤 E0.6 outbox tray +1F4E5 ; fully-qualified # 📥 E0.6 inbox tray +1F4E6 ; fully-qualified # 📦 E0.6 package +1F4EB ; fully-qualified # 📫 E0.6 closed mailbox with raised flag +1F4EA ; fully-qualified # 📪 E0.6 closed mailbox with lowered flag +1F4EC ; fully-qualified # 📬 E0.7 open mailbox with raised flag +1F4ED ; fully-qualified # 📭 E0.7 open mailbox with lowered flag +1F4EE ; fully-qualified # 📮 E0.6 postbox +1F5F3 FE0F ; fully-qualified # 🗳️ E0.7 ballot box with ballot +1F5F3 ; unqualified # 🗳 E0.7 ballot box with ballot + +# subgroup: writing +270F FE0F ; fully-qualified # ✏️ E0.6 pencil +270F ; unqualified # ✏ E0.6 pencil +2712 FE0F ; fully-qualified # ✒️ E0.6 black nib +2712 ; unqualified # ✒ E0.6 black nib +1F58B FE0F ; fully-qualified # 🖋️ E0.7 fountain pen +1F58B ; unqualified # 🖋 E0.7 fountain pen +1F58A FE0F ; fully-qualified # 🖊️ E0.7 pen +1F58A ; unqualified # 🖊 E0.7 pen +1F58C FE0F ; fully-qualified # 🖌️ E0.7 paintbrush +1F58C ; unqualified # 🖌 E0.7 paintbrush +1F58D FE0F ; fully-qualified # 🖍️ E0.7 crayon +1F58D ; unqualified # 🖍 E0.7 crayon +1F4DD ; fully-qualified # 📝 E0.6 memo + +# subgroup: office +1F4BC ; fully-qualified # 💼 E0.6 briefcase +1F4C1 ; fully-qualified # 📁 E0.6 file folder +1F4C2 ; fully-qualified # 📂 E0.6 open file folder +1F5C2 FE0F ; fully-qualified # 🗂️ E0.7 card index dividers +1F5C2 ; unqualified # 🗂 E0.7 card index dividers +1F4C5 ; fully-qualified # 📅 E0.6 calendar +1F4C6 ; fully-qualified # 📆 E0.6 tear-off calendar +1F5D2 FE0F ; fully-qualified # 🗒️ E0.7 spiral notepad +1F5D2 ; unqualified # 🗒 E0.7 spiral notepad +1F5D3 FE0F ; fully-qualified # 🗓️ E0.7 spiral calendar +1F5D3 ; unqualified # 🗓 E0.7 spiral calendar +1F4C7 ; fully-qualified # 📇 E0.6 card index +1F4C8 ; fully-qualified # 📈 E0.6 chart increasing +1F4C9 ; fully-qualified # 📉 E0.6 chart decreasing +1F4CA ; fully-qualified # 📊 E0.6 bar chart +1F4CB ; fully-qualified # 📋 E0.6 clipboard +1F4CC ; fully-qualified # 📌 E0.6 pushpin +1F4CD ; fully-qualified # 📍 E0.6 round pushpin +1F4CE ; fully-qualified # 📎 E0.6 paperclip +1F587 FE0F ; fully-qualified # 🖇️ E0.7 linked paperclips +1F587 ; unqualified # 🖇 E0.7 linked paperclips +1F4CF ; fully-qualified # 📏 E0.6 straight ruler +1F4D0 ; fully-qualified # 📐 E0.6 triangular ruler +2702 FE0F ; fully-qualified # ✂️ E0.6 scissors +2702 ; unqualified # ✂ E0.6 scissors +1F5C3 FE0F ; fully-qualified # 🗃️ E0.7 card file box +1F5C3 ; unqualified # 🗃 E0.7 card file box +1F5C4 FE0F ; fully-qualified # 🗄️ E0.7 file cabinet +1F5C4 ; unqualified # 🗄 E0.7 file cabinet +1F5D1 FE0F ; fully-qualified # 🗑️ E0.7 wastebasket +1F5D1 ; unqualified # 🗑 E0.7 wastebasket + +# subgroup: lock +1F512 ; fully-qualified # 🔒 E0.6 locked +1F513 ; fully-qualified # 🔓 E0.6 unlocked +1F50F ; fully-qualified # 🔏 E0.6 locked with pen +1F510 ; fully-qualified # 🔐 E0.6 locked with key +1F511 ; fully-qualified # 🔑 E0.6 key +1F5DD FE0F ; fully-qualified # 🗝️ E0.7 old key +1F5DD ; unqualified # 🗝 E0.7 old key + +# subgroup: tool +1F528 ; fully-qualified # 🔨 E0.6 hammer +1FA93 ; fully-qualified # 🪓 E12.0 axe +26CF FE0F ; fully-qualified # ⛏️ E0.7 pick +26CF ; unqualified # ⛏ E0.7 pick +2692 FE0F ; fully-qualified # ⚒️ E1.0 hammer and pick +2692 ; unqualified # ⚒ E1.0 hammer and pick +1F6E0 FE0F ; fully-qualified # 🛠️ E0.7 hammer and wrench +1F6E0 ; unqualified # 🛠 E0.7 hammer and wrench +1F5E1 FE0F ; fully-qualified # 🗡️ E0.7 dagger +1F5E1 ; unqualified # 🗡 E0.7 dagger +2694 FE0F ; fully-qualified # ⚔️ E1.0 crossed swords +2694 ; unqualified # ⚔ E1.0 crossed swords +1F52B ; fully-qualified # 🔫 E0.6 water pistol +1FA83 ; fully-qualified # 🪃 E13.0 boomerang +1F3F9 ; fully-qualified # 🏹 E1.0 bow and arrow +1F6E1 FE0F ; fully-qualified # 🛡️ E0.7 shield +1F6E1 ; unqualified # 🛡 E0.7 shield +1FA9A ; fully-qualified # 🪚 E13.0 carpentry saw +1F527 ; fully-qualified # 🔧 E0.6 wrench +1FA9B ; fully-qualified # 🪛 E13.0 screwdriver +1F529 ; fully-qualified # 🔩 E0.6 nut and bolt +2699 FE0F ; fully-qualified # ⚙️ E1.0 gear +2699 ; unqualified # ⚙ E1.0 gear +1F5DC FE0F ; fully-qualified # 🗜️ E0.7 clamp +1F5DC ; unqualified # 🗜 E0.7 clamp +2696 FE0F ; fully-qualified # ⚖️ E1.0 balance scale +2696 ; unqualified # ⚖ E1.0 balance scale +1F9AF ; fully-qualified # 🦯 E12.0 white cane +1F517 ; fully-qualified # 🔗 E0.6 link +26D3 FE0F ; fully-qualified # ⛓️ E0.7 chains +26D3 ; unqualified # ⛓ E0.7 chains +1FA9D ; fully-qualified # 🪝 E13.0 hook +1F9F0 ; fully-qualified # 🧰 E11.0 toolbox +1F9F2 ; fully-qualified # 🧲 E11.0 magnet +1FA9C ; fully-qualified # 🪜 E13.0 ladder + +# subgroup: science +2697 FE0F ; fully-qualified # ⚗️ E1.0 alembic +2697 ; unqualified # ⚗ E1.0 alembic +1F9EA ; fully-qualified # 🧪 E11.0 test tube +1F9EB ; fully-qualified # 🧫 E11.0 petri dish +1F9EC ; fully-qualified # 🧬 E11.0 dna +1F52C ; fully-qualified # 🔬 E1.0 microscope +1F52D ; fully-qualified # 🔭 E1.0 telescope +1F4E1 ; fully-qualified # 📡 E0.6 satellite antenna + +# subgroup: medical +1F489 ; fully-qualified # 💉 E0.6 syringe +1FA78 ; fully-qualified # 🩸 E12.0 drop of blood +1F48A ; fully-qualified # 💊 E0.6 pill +1FA79 ; fully-qualified # 🩹 E12.0 adhesive bandage +1FA7A ; fully-qualified # 🩺 E12.0 stethoscope + +# subgroup: household +1F6AA ; fully-qualified # 🚪 E0.6 door +1F6D7 ; fully-qualified # 🛗 E13.0 elevator +1FA9E ; fully-qualified # 🪞 E13.0 mirror +1FA9F ; fully-qualified # 🪟 E13.0 window +1F6CF FE0F ; fully-qualified # 🛏️ E0.7 bed +1F6CF ; unqualified # 🛏 E0.7 bed +1F6CB FE0F ; fully-qualified # 🛋️ E0.7 couch and lamp +1F6CB ; unqualified # 🛋 E0.7 couch and lamp +1FA91 ; fully-qualified # 🪑 E12.0 chair +1F6BD ; fully-qualified # 🚽 E0.6 toilet +1FAA0 ; fully-qualified # 🪠 E13.0 plunger +1F6BF ; fully-qualified # 🚿 E1.0 shower +1F6C1 ; fully-qualified # 🛁 E1.0 bathtub +1FAA4 ; fully-qualified # 🪤 E13.0 mouse trap +1FA92 ; fully-qualified # 🪒 E12.0 razor +1F9F4 ; fully-qualified # 🧴 E11.0 lotion bottle +1F9F7 ; fully-qualified # 🧷 E11.0 safety pin +1F9F9 ; fully-qualified # 🧹 E11.0 broom +1F9FA ; fully-qualified # 🧺 E11.0 basket +1F9FB ; fully-qualified # 🧻 E11.0 roll of paper +1FAA3 ; fully-qualified # 🪣 E13.0 bucket +1F9FC ; fully-qualified # 🧼 E11.0 soap +1FAA5 ; fully-qualified # 🪥 E13.0 toothbrush +1F9FD ; fully-qualified # 🧽 E11.0 sponge +1F9EF ; fully-qualified # 🧯 E11.0 fire extinguisher +1F6D2 ; fully-qualified # 🛒 E3.0 shopping cart + +# subgroup: other-object +1F6AC ; fully-qualified # 🚬 E0.6 cigarette +26B0 FE0F ; fully-qualified # ⚰️ E1.0 coffin +26B0 ; unqualified # ⚰ E1.0 coffin +1FAA6 ; fully-qualified # 🪦 E13.0 headstone +26B1 FE0F ; fully-qualified # ⚱️ E1.0 funeral urn +26B1 ; unqualified # ⚱ E1.0 funeral urn +1F5FF ; fully-qualified # 🗿 E0.6 moai +1FAA7 ; fully-qualified # 🪧 E13.0 placard + +# Objects subtotal: 299 +# Objects subtotal: 299 w/o modifiers + +# group: Symbols + +# subgroup: transport-sign +1F3E7 ; fully-qualified # 🏧 E0.6 ATM sign +1F6AE ; fully-qualified # 🚮 E1.0 litter in bin sign +1F6B0 ; fully-qualified # 🚰 E1.0 potable water +267F ; fully-qualified # ♿ E0.6 wheelchair symbol +1F6B9 ; fully-qualified # 🚹 E0.6 men’s room +1F6BA ; fully-qualified # 🚺 E0.6 women’s room +1F6BB ; fully-qualified # 🚻 E0.6 restroom +1F6BC ; fully-qualified # 🚼 E0.6 baby symbol +1F6BE ; fully-qualified # 🚾 E0.6 water closet +1F6C2 ; fully-qualified # 🛂 E1.0 passport control +1F6C3 ; fully-qualified # 🛃 E1.0 customs +1F6C4 ; fully-qualified # 🛄 E1.0 baggage claim +1F6C5 ; fully-qualified # 🛅 E1.0 left luggage + +# subgroup: warning +26A0 FE0F ; fully-qualified # ⚠️ E0.6 warning +26A0 ; unqualified # ⚠ E0.6 warning +1F6B8 ; fully-qualified # 🚸 E1.0 children crossing +26D4 ; fully-qualified # ⛔ E0.6 no entry +1F6AB ; fully-qualified # 🚫 E0.6 prohibited +1F6B3 ; fully-qualified # 🚳 E1.0 no bicycles +1F6AD ; fully-qualified # 🚭 E0.6 no smoking +1F6AF ; fully-qualified # 🚯 E1.0 no littering +1F6B1 ; fully-qualified # 🚱 E1.0 non-potable water +1F6B7 ; fully-qualified # 🚷 E1.0 no pedestrians +1F4F5 ; fully-qualified # 📵 E1.0 no mobile phones +1F51E ; fully-qualified # 🔞 E0.6 no one under eighteen +2622 FE0F ; fully-qualified # ☢️ E1.0 radioactive +2622 ; unqualified # ☢ E1.0 radioactive +2623 FE0F ; fully-qualified # ☣️ E1.0 biohazard +2623 ; unqualified # ☣ E1.0 biohazard + +# subgroup: arrow +2B06 FE0F ; fully-qualified # ⬆️ E0.6 up arrow +2B06 ; unqualified # ⬆ E0.6 up arrow +2197 FE0F ; fully-qualified # ↗️ E0.6 up-right arrow +2197 ; unqualified # ↗ E0.6 up-right arrow +27A1 FE0F ; fully-qualified # ➡️ E0.6 right arrow +27A1 ; unqualified # ➡ E0.6 right arrow +2198 FE0F ; fully-qualified # ↘️ E0.6 down-right arrow +2198 ; unqualified # ↘ E0.6 down-right arrow +2B07 FE0F ; fully-qualified # ⬇️ E0.6 down arrow +2B07 ; unqualified # ⬇ E0.6 down arrow +2199 FE0F ; fully-qualified # ↙️ E0.6 down-left arrow +2199 ; unqualified # ↙ E0.6 down-left arrow +2B05 FE0F ; fully-qualified # ⬅️ E0.6 left arrow +2B05 ; unqualified # ⬅ E0.6 left arrow +2196 FE0F ; fully-qualified # ↖️ E0.6 up-left arrow +2196 ; unqualified # ↖ E0.6 up-left arrow +2195 FE0F ; fully-qualified # ↕️ E0.6 up-down arrow +2195 ; unqualified # ↕ E0.6 up-down arrow +2194 FE0F ; fully-qualified # ↔️ E0.6 left-right arrow +2194 ; unqualified # ↔ E0.6 left-right arrow +21A9 FE0F ; fully-qualified # ↩️ E0.6 right arrow curving left +21A9 ; unqualified # ↩ E0.6 right arrow curving left +21AA FE0F ; fully-qualified # ↪️ E0.6 left arrow curving right +21AA ; unqualified # ↪ E0.6 left arrow curving right +2934 FE0F ; fully-qualified # ⤴️ E0.6 right arrow curving up +2934 ; unqualified # ⤴ E0.6 right arrow curving up +2935 FE0F ; fully-qualified # ⤵️ E0.6 right arrow curving down +2935 ; unqualified # ⤵ E0.6 right arrow curving down +1F503 ; fully-qualified # 🔃 E0.6 clockwise vertical arrows +1F504 ; fully-qualified # 🔄 E1.0 counterclockwise arrows button +1F519 ; fully-qualified # 🔙 E0.6 BACK arrow +1F51A ; fully-qualified # 🔚 E0.6 END arrow +1F51B ; fully-qualified # 🔛 E0.6 ON! arrow +1F51C ; fully-qualified # 🔜 E0.6 SOON arrow +1F51D ; fully-qualified # 🔝 E0.6 TOP arrow + +# subgroup: religion +1F6D0 ; fully-qualified # 🛐 E1.0 place of worship +269B FE0F ; fully-qualified # ⚛️ E1.0 atom symbol +269B ; unqualified # ⚛ E1.0 atom symbol +1F549 FE0F ; fully-qualified # 🕉️ E0.7 om +1F549 ; unqualified # 🕉 E0.7 om +2721 FE0F ; fully-qualified # ✡️ E0.7 star of David +2721 ; unqualified # ✡ E0.7 star of David +2638 FE0F ; fully-qualified # ☸️ E0.7 wheel of dharma +2638 ; unqualified # ☸ E0.7 wheel of dharma +262F FE0F ; fully-qualified # ☯️ E0.7 yin yang +262F ; unqualified # ☯ E0.7 yin yang +271D FE0F ; fully-qualified # ✝️ E0.7 latin cross +271D ; unqualified # ✝ E0.7 latin cross +2626 FE0F ; fully-qualified # ☦️ E1.0 orthodox cross +2626 ; unqualified # ☦ E1.0 orthodox cross +262A FE0F ; fully-qualified # ☪️ E0.7 star and crescent +262A ; unqualified # ☪ E0.7 star and crescent +262E FE0F ; fully-qualified # ☮️ E1.0 peace symbol +262E ; unqualified # ☮ E1.0 peace symbol +1F54E ; fully-qualified # 🕎 E1.0 menorah +1F52F ; fully-qualified # 🔯 E0.6 dotted six-pointed star + +# subgroup: zodiac +2648 ; fully-qualified # ♈ E0.6 Aries +2649 ; fully-qualified # ♉ E0.6 Taurus +264A ; fully-qualified # ♊ E0.6 Gemini +264B ; fully-qualified # ♋ E0.6 Cancer +264C ; fully-qualified # ♌ E0.6 Leo +264D ; fully-qualified # ♍ E0.6 Virgo +264E ; fully-qualified # ♎ E0.6 Libra +264F ; fully-qualified # ♏ E0.6 Scorpio +2650 ; fully-qualified # ♐ E0.6 Sagittarius +2651 ; fully-qualified # ♑ E0.6 Capricorn +2652 ; fully-qualified # ♒ E0.6 Aquarius +2653 ; fully-qualified # ♓ E0.6 Pisces +26CE ; fully-qualified # ⛎ E0.6 Ophiuchus + +# subgroup: av-symbol +1F500 ; fully-qualified # 🔀 E1.0 shuffle tracks button +1F501 ; fully-qualified # 🔁 E1.0 repeat button +1F502 ; fully-qualified # 🔂 E1.0 repeat single button +25B6 FE0F ; fully-qualified # ▶️ E0.6 play button +25B6 ; unqualified # ▶ E0.6 play button +23E9 ; fully-qualified # ⏩ E0.6 fast-forward button +23ED FE0F ; fully-qualified # ⏭️ E0.7 next track button +23ED ; unqualified # ⏭ E0.7 next track button +23EF FE0F ; fully-qualified # ⏯️ E1.0 play or pause button +23EF ; unqualified # ⏯ E1.0 play or pause button +25C0 FE0F ; fully-qualified # ◀️ E0.6 reverse button +25C0 ; unqualified # ◀ E0.6 reverse button +23EA ; fully-qualified # ⏪ E0.6 fast reverse button +23EE FE0F ; fully-qualified # ⏮️ E0.7 last track button +23EE ; unqualified # ⏮ E0.7 last track button +1F53C ; fully-qualified # 🔼 E0.6 upwards button +23EB ; fully-qualified # ⏫ E0.6 fast up button +1F53D ; fully-qualified # 🔽 E0.6 downwards button +23EC ; fully-qualified # ⏬ E0.6 fast down button +23F8 FE0F ; fully-qualified # ⏸️ E0.7 pause button +23F8 ; unqualified # ⏸ E0.7 pause button +23F9 FE0F ; fully-qualified # ⏹️ E0.7 stop button +23F9 ; unqualified # ⏹ E0.7 stop button +23FA FE0F ; fully-qualified # ⏺️ E0.7 record button +23FA ; unqualified # ⏺ E0.7 record button +23CF FE0F ; fully-qualified # ⏏️ E1.0 eject button +23CF ; unqualified # ⏏ E1.0 eject button +1F3A6 ; fully-qualified # 🎦 E0.6 cinema +1F505 ; fully-qualified # 🔅 E1.0 dim button +1F506 ; fully-qualified # 🔆 E1.0 bright button +1F4F6 ; fully-qualified # 📶 E0.6 antenna bars +1F4F3 ; fully-qualified # 📳 E0.6 vibration mode +1F4F4 ; fully-qualified # 📴 E0.6 mobile phone off + +# subgroup: gender +2640 FE0F ; fully-qualified # ♀️ E4.0 female sign +2640 ; unqualified # ♀ E4.0 female sign +2642 FE0F ; fully-qualified # ♂️ E4.0 male sign +2642 ; unqualified # ♂ E4.0 male sign +26A7 FE0F ; fully-qualified # ⚧️ E13.0 transgender symbol +26A7 ; unqualified # ⚧ E13.0 transgender symbol + +# subgroup: math +2716 FE0F ; fully-qualified # ✖️ E0.6 multiply +2716 ; unqualified # ✖ E0.6 multiply +2795 ; fully-qualified # ➕ E0.6 plus +2796 ; fully-qualified # ➖ E0.6 minus +2797 ; fully-qualified # ➗ E0.6 divide +267E FE0F ; fully-qualified # ♾️ E11.0 infinity +267E ; unqualified # ♾ E11.0 infinity + +# subgroup: punctuation +203C FE0F ; fully-qualified # ‼️ E0.6 double exclamation mark +203C ; unqualified # ‼ E0.6 double exclamation mark +2049 FE0F ; fully-qualified # ⁉️ E0.6 exclamation question mark +2049 ; unqualified # ⁉ E0.6 exclamation question mark +2753 ; fully-qualified # ❓ E0.6 red question mark +2754 ; fully-qualified # ❔ E0.6 white question mark +2755 ; fully-qualified # ❕ E0.6 white exclamation mark +2757 ; fully-qualified # ❗ E0.6 red exclamation mark +3030 FE0F ; fully-qualified # 〰️ E0.6 wavy dash +3030 ; unqualified # 〰 E0.6 wavy dash + +# subgroup: currency +1F4B1 ; fully-qualified # 💱 E0.6 currency exchange +1F4B2 ; fully-qualified # 💲 E0.6 heavy dollar sign + +# subgroup: other-symbol +2695 FE0F ; fully-qualified # ⚕️ E4.0 medical symbol +2695 ; unqualified # ⚕ E4.0 medical symbol +267B FE0F ; fully-qualified # ♻️ E0.6 recycling symbol +267B ; unqualified # ♻ E0.6 recycling symbol +269C FE0F ; fully-qualified # ⚜️ E1.0 fleur-de-lis +269C ; unqualified # ⚜ E1.0 fleur-de-lis +1F531 ; fully-qualified # 🔱 E0.6 trident emblem +1F4DB ; fully-qualified # 📛 E0.6 name badge +1F530 ; fully-qualified # 🔰 E0.6 Japanese symbol for beginner +2B55 ; fully-qualified # ⭕ E0.6 hollow red circle +2705 ; fully-qualified # ✅ E0.6 check mark button +2611 FE0F ; fully-qualified # ☑️ E0.6 check box with check +2611 ; unqualified # ☑ E0.6 check box with check +2714 FE0F ; fully-qualified # ✔️ E0.6 check mark +2714 ; unqualified # ✔ E0.6 check mark +274C ; fully-qualified # ❌ E0.6 cross mark +274E ; fully-qualified # ❎ E0.6 cross mark button +27B0 ; fully-qualified # ➰ E0.6 curly loop +27BF ; fully-qualified # ➿ E1.0 double curly loop +303D FE0F ; fully-qualified # 〽️ E0.6 part alternation mark +303D ; unqualified # 〽 E0.6 part alternation mark +2733 FE0F ; fully-qualified # ✳️ E0.6 eight-spoked asterisk +2733 ; unqualified # ✳ E0.6 eight-spoked asterisk +2734 FE0F ; fully-qualified # ✴️ E0.6 eight-pointed star +2734 ; unqualified # ✴ E0.6 eight-pointed star +2747 FE0F ; fully-qualified # ❇️ E0.6 sparkle +2747 ; unqualified # ❇ E0.6 sparkle +00A9 FE0F ; fully-qualified # ©️ E0.6 copyright +00A9 ; unqualified # © E0.6 copyright +00AE FE0F ; fully-qualified # ®️ E0.6 registered +00AE ; unqualified # ® E0.6 registered +2122 FE0F ; fully-qualified # ™️ E0.6 trade mark +2122 ; unqualified # ™ E0.6 trade mark + +# subgroup: keycap +0023 FE0F 20E3 ; fully-qualified # #️⃣ E0.6 keycap: # +0023 20E3 ; unqualified # #⃣ E0.6 keycap: # +002A FE0F 20E3 ; fully-qualified # *️⃣ E2.0 keycap: * +002A 20E3 ; unqualified # *⃣ E2.0 keycap: * +0030 FE0F 20E3 ; fully-qualified # 0️⃣ E0.6 keycap: 0 +0030 20E3 ; unqualified # 0⃣ E0.6 keycap: 0 +0031 FE0F 20E3 ; fully-qualified # 1️⃣ E0.6 keycap: 1 +0031 20E3 ; unqualified # 1⃣ E0.6 keycap: 1 +0032 FE0F 20E3 ; fully-qualified # 2️⃣ E0.6 keycap: 2 +0032 20E3 ; unqualified # 2⃣ E0.6 keycap: 2 +0033 FE0F 20E3 ; fully-qualified # 3️⃣ E0.6 keycap: 3 +0033 20E3 ; unqualified # 3⃣ E0.6 keycap: 3 +0034 FE0F 20E3 ; fully-qualified # 4️⃣ E0.6 keycap: 4 +0034 20E3 ; unqualified # 4⃣ E0.6 keycap: 4 +0035 FE0F 20E3 ; fully-qualified # 5️⃣ E0.6 keycap: 5 +0035 20E3 ; unqualified # 5⃣ E0.6 keycap: 5 +0036 FE0F 20E3 ; fully-qualified # 6️⃣ E0.6 keycap: 6 +0036 20E3 ; unqualified # 6⃣ E0.6 keycap: 6 +0037 FE0F 20E3 ; fully-qualified # 7️⃣ E0.6 keycap: 7 +0037 20E3 ; unqualified # 7⃣ E0.6 keycap: 7 +0038 FE0F 20E3 ; fully-qualified # 8️⃣ E0.6 keycap: 8 +0038 20E3 ; unqualified # 8⃣ E0.6 keycap: 8 +0039 FE0F 20E3 ; fully-qualified # 9️⃣ E0.6 keycap: 9 +0039 20E3 ; unqualified # 9⃣ E0.6 keycap: 9 +1F51F ; fully-qualified # 🔟 E0.6 keycap: 10 + +# subgroup: alphanum +1F520 ; fully-qualified # 🔠 E0.6 input latin uppercase +1F521 ; fully-qualified # 🔡 E0.6 input latin lowercase +1F522 ; fully-qualified # 🔢 E0.6 input numbers +1F523 ; fully-qualified # 🔣 E0.6 input symbols +1F524 ; fully-qualified # 🔤 E0.6 input latin letters +1F170 FE0F ; fully-qualified # 🅰️ E0.6 A button (blood type) +1F170 ; unqualified # 🅰 E0.6 A button (blood type) +1F18E ; fully-qualified # 🆎 E0.6 AB button (blood type) +1F171 FE0F ; fully-qualified # 🅱️ E0.6 B button (blood type) +1F171 ; unqualified # 🅱 E0.6 B button (blood type) +1F191 ; fully-qualified # 🆑 E0.6 CL button +1F192 ; fully-qualified # 🆒 E0.6 COOL button +1F193 ; fully-qualified # 🆓 E0.6 FREE button +2139 FE0F ; fully-qualified # ℹ️ E0.6 information +2139 ; unqualified # ℹ E0.6 information +1F194 ; fully-qualified # 🆔 E0.6 ID button +24C2 FE0F ; fully-qualified # Ⓜ️ E0.6 circled M +24C2 ; unqualified # Ⓜ E0.6 circled M +1F195 ; fully-qualified # 🆕 E0.6 NEW button +1F196 ; fully-qualified # 🆖 E0.6 NG button +1F17E FE0F ; fully-qualified # 🅾️ E0.6 O button (blood type) +1F17E ; unqualified # 🅾 E0.6 O button (blood type) +1F197 ; fully-qualified # 🆗 E0.6 OK button +1F17F FE0F ; fully-qualified # 🅿️ E0.6 P button +1F17F ; unqualified # 🅿 E0.6 P button +1F198 ; fully-qualified # 🆘 E0.6 SOS button +1F199 ; fully-qualified # 🆙 E0.6 UP! button +1F19A ; fully-qualified # 🆚 E0.6 VS button +1F201 ; fully-qualified # 🈁 E0.6 Japanese “here” button +1F202 FE0F ; fully-qualified # 🈂️ E0.6 Japanese “service charge” button +1F202 ; unqualified # 🈂 E0.6 Japanese “service charge” button +1F237 FE0F ; fully-qualified # 🈷️ E0.6 Japanese “monthly amount” button +1F237 ; unqualified # 🈷 E0.6 Japanese “monthly amount” button +1F236 ; fully-qualified # 🈶 E0.6 Japanese “not free of charge” button +1F22F ; fully-qualified # 🈯 E0.6 Japanese “reserved” button +1F250 ; fully-qualified # 🉐 E0.6 Japanese “bargain” button +1F239 ; fully-qualified # 🈹 E0.6 Japanese “discount” button +1F21A ; fully-qualified # 🈚 E0.6 Japanese “free of charge” button +1F232 ; fully-qualified # 🈲 E0.6 Japanese “prohibited” button +1F251 ; fully-qualified # 🉑 E0.6 Japanese “acceptable” button +1F238 ; fully-qualified # 🈸 E0.6 Japanese “application” button +1F234 ; fully-qualified # 🈴 E0.6 Japanese “passing grade” button +1F233 ; fully-qualified # 🈳 E0.6 Japanese “vacancy” button +3297 FE0F ; fully-qualified # ㊗️ E0.6 Japanese “congratulations” button +3297 ; unqualified # ㊗ E0.6 Japanese “congratulations” button +3299 FE0F ; fully-qualified # ㊙️ E0.6 Japanese “secret” button +3299 ; unqualified # ㊙ E0.6 Japanese “secret” button +1F23A ; fully-qualified # 🈺 E0.6 Japanese “open for business” button +1F235 ; fully-qualified # 🈵 E0.6 Japanese “no vacancy” button + +# subgroup: geometric +1F534 ; fully-qualified # 🔴 E0.6 red circle +1F7E0 ; fully-qualified # 🟠 E12.0 orange circle +1F7E1 ; fully-qualified # 🟡 E12.0 yellow circle +1F7E2 ; fully-qualified # 🟢 E12.0 green circle +1F535 ; fully-qualified # 🔵 E0.6 blue circle +1F7E3 ; fully-qualified # 🟣 E12.0 purple circle +1F7E4 ; fully-qualified # 🟤 E12.0 brown circle +26AB ; fully-qualified # ⚫ E0.6 black circle +26AA ; fully-qualified # ⚪ E0.6 white circle +1F7E5 ; fully-qualified # 🟥 E12.0 red square +1F7E7 ; fully-qualified # 🟧 E12.0 orange square +1F7E8 ; fully-qualified # 🟨 E12.0 yellow square +1F7E9 ; fully-qualified # 🟩 E12.0 green square +1F7E6 ; fully-qualified # 🟦 E12.0 blue square +1F7EA ; fully-qualified # 🟪 E12.0 purple square +1F7EB ; fully-qualified # 🟫 E12.0 brown square +2B1B ; fully-qualified # ⬛ E0.6 black large square +2B1C ; fully-qualified # ⬜ E0.6 white large square +25FC FE0F ; fully-qualified # ◼️ E0.6 black medium square +25FC ; unqualified # ◼ E0.6 black medium square +25FB FE0F ; fully-qualified # ◻️ E0.6 white medium square +25FB ; unqualified # ◻ E0.6 white medium square +25FE ; fully-qualified # ◾ E0.6 black medium-small square +25FD ; fully-qualified # ◽ E0.6 white medium-small square +25AA FE0F ; fully-qualified # ▪️ E0.6 black small square +25AA ; unqualified # ▪ E0.6 black small square +25AB FE0F ; fully-qualified # ▫️ E0.6 white small square +25AB ; unqualified # ▫ E0.6 white small square +1F536 ; fully-qualified # 🔶 E0.6 large orange diamond +1F537 ; fully-qualified # 🔷 E0.6 large blue diamond +1F538 ; fully-qualified # 🔸 E0.6 small orange diamond +1F539 ; fully-qualified # 🔹 E0.6 small blue diamond +1F53A ; fully-qualified # 🔺 E0.6 red triangle pointed up +1F53B ; fully-qualified # 🔻 E0.6 red triangle pointed down +1F4A0 ; fully-qualified # 💠 E0.6 diamond with a dot +1F518 ; fully-qualified # 🔘 E0.6 radio button +1F533 ; fully-qualified # 🔳 E0.6 white square button +1F532 ; fully-qualified # 🔲 E0.6 black square button + +# Symbols subtotal: 301 +# Symbols subtotal: 301 w/o modifiers + +# group: Flags + +# subgroup: flag +1F3C1 ; fully-qualified # 🏁 E0.6 chequered flag +1F6A9 ; fully-qualified # 🚩 E0.6 triangular flag +1F38C ; fully-qualified # 🎌 E0.6 crossed flags +1F3F4 ; fully-qualified # 🏴 E1.0 black flag +1F3F3 FE0F ; fully-qualified # 🏳️ E0.7 white flag +1F3F3 ; unqualified # 🏳 E0.7 white flag +1F3F3 FE0F 200D 1F308 ; fully-qualified # 🏳️‍🌈 E4.0 rainbow flag +1F3F3 200D 1F308 ; unqualified # 🏳‍🌈 E4.0 rainbow flag +1F3F3 FE0F 200D 26A7 FE0F ; fully-qualified # 🏳️‍⚧️ E13.0 transgender flag +1F3F3 200D 26A7 FE0F ; unqualified # 🏳‍⚧️ E13.0 transgender flag +1F3F3 FE0F 200D 26A7 ; unqualified # 🏳️‍⚧ E13.0 transgender flag +1F3F3 200D 26A7 ; unqualified # 🏳‍⚧ E13.0 transgender flag +1F3F4 200D 2620 FE0F ; fully-qualified # 🏴‍☠️ E11.0 pirate flag +1F3F4 200D 2620 ; minimally-qualified # 🏴‍☠ E11.0 pirate flag + +# subgroup: country-flag +1F1E6 1F1E8 ; fully-qualified # 🇦🇨 E2.0 flag: Ascension Island +1F1E6 1F1E9 ; fully-qualified # 🇦🇩 E2.0 flag: Andorra +1F1E6 1F1EA ; fully-qualified # 🇦🇪 E2.0 flag: United Arab Emirates +1F1E6 1F1EB ; fully-qualified # 🇦🇫 E2.0 flag: Afghanistan +1F1E6 1F1EC ; fully-qualified # 🇦🇬 E2.0 flag: Antigua & Barbuda +1F1E6 1F1EE ; fully-qualified # 🇦🇮 E2.0 flag: Anguilla +1F1E6 1F1F1 ; fully-qualified # 🇦🇱 E2.0 flag: Albania +1F1E6 1F1F2 ; fully-qualified # 🇦🇲 E2.0 flag: Armenia +1F1E6 1F1F4 ; fully-qualified # 🇦🇴 E2.0 flag: Angola +1F1E6 1F1F6 ; fully-qualified # 🇦🇶 E2.0 flag: Antarctica +1F1E6 1F1F7 ; fully-qualified # 🇦🇷 E2.0 flag: Argentina +1F1E6 1F1F8 ; fully-qualified # 🇦🇸 E2.0 flag: American Samoa +1F1E6 1F1F9 ; fully-qualified # 🇦🇹 E2.0 flag: Austria +1F1E6 1F1FA ; fully-qualified # 🇦🇺 E2.0 flag: Australia +1F1E6 1F1FC ; fully-qualified # 🇦🇼 E2.0 flag: Aruba +1F1E6 1F1FD ; fully-qualified # 🇦🇽 E2.0 flag: Åland Islands +1F1E6 1F1FF ; fully-qualified # 🇦🇿 E2.0 flag: Azerbaijan +1F1E7 1F1E6 ; fully-qualified # 🇧🇦 E2.0 flag: Bosnia & Herzegovina +1F1E7 1F1E7 ; fully-qualified # 🇧🇧 E2.0 flag: Barbados +1F1E7 1F1E9 ; fully-qualified # 🇧🇩 E2.0 flag: Bangladesh +1F1E7 1F1EA ; fully-qualified # 🇧🇪 E2.0 flag: Belgium +1F1E7 1F1EB ; fully-qualified # 🇧🇫 E2.0 flag: Burkina Faso +1F1E7 1F1EC ; fully-qualified # 🇧🇬 E2.0 flag: Bulgaria +1F1E7 1F1ED ; fully-qualified # 🇧🇭 E2.0 flag: Bahrain +1F1E7 1F1EE ; fully-qualified # 🇧🇮 E2.0 flag: Burundi +1F1E7 1F1EF ; fully-qualified # 🇧🇯 E2.0 flag: Benin +1F1E7 1F1F1 ; fully-qualified # 🇧🇱 E2.0 flag: St. Barthélemy +1F1E7 1F1F2 ; fully-qualified # 🇧🇲 E2.0 flag: Bermuda +1F1E7 1F1F3 ; fully-qualified # 🇧🇳 E2.0 flag: Brunei +1F1E7 1F1F4 ; fully-qualified # 🇧🇴 E2.0 flag: Bolivia +1F1E7 1F1F6 ; fully-qualified # 🇧🇶 E2.0 flag: Caribbean Netherlands +1F1E7 1F1F7 ; fully-qualified # 🇧🇷 E2.0 flag: Brazil +1F1E7 1F1F8 ; fully-qualified # 🇧🇸 E2.0 flag: Bahamas +1F1E7 1F1F9 ; fully-qualified # 🇧🇹 E2.0 flag: Bhutan +1F1E7 1F1FB ; fully-qualified # 🇧🇻 E2.0 flag: Bouvet Island +1F1E7 1F1FC ; fully-qualified # 🇧🇼 E2.0 flag: Botswana +1F1E7 1F1FE ; fully-qualified # 🇧🇾 E2.0 flag: Belarus +1F1E7 1F1FF ; fully-qualified # 🇧🇿 E2.0 flag: Belize +1F1E8 1F1E6 ; fully-qualified # 🇨🇦 E2.0 flag: Canada +1F1E8 1F1E8 ; fully-qualified # 🇨🇨 E2.0 flag: Cocos (Keeling) Islands +1F1E8 1F1E9 ; fully-qualified # 🇨🇩 E2.0 flag: Congo - Kinshasa +1F1E8 1F1EB ; fully-qualified # 🇨🇫 E2.0 flag: Central African Republic +1F1E8 1F1EC ; fully-qualified # 🇨🇬 E2.0 flag: Congo - Brazzaville +1F1E8 1F1ED ; fully-qualified # 🇨🇭 E2.0 flag: Switzerland +1F1E8 1F1EE ; fully-qualified # 🇨🇮 E2.0 flag: Côte d’Ivoire +1F1E8 1F1F0 ; fully-qualified # 🇨🇰 E2.0 flag: Cook Islands +1F1E8 1F1F1 ; fully-qualified # 🇨🇱 E2.0 flag: Chile +1F1E8 1F1F2 ; fully-qualified # 🇨🇲 E2.0 flag: Cameroon +1F1E8 1F1F3 ; fully-qualified # 🇨🇳 E0.6 flag: China +1F1E8 1F1F4 ; fully-qualified # 🇨🇴 E2.0 flag: Colombia +1F1E8 1F1F5 ; fully-qualified # 🇨🇵 E2.0 flag: Clipperton Island +1F1E8 1F1F7 ; fully-qualified # 🇨🇷 E2.0 flag: Costa Rica +1F1E8 1F1FA ; fully-qualified # 🇨🇺 E2.0 flag: Cuba +1F1E8 1F1FB ; fully-qualified # 🇨🇻 E2.0 flag: Cape Verde +1F1E8 1F1FC ; fully-qualified # 🇨🇼 E2.0 flag: Curaçao +1F1E8 1F1FD ; fully-qualified # 🇨🇽 E2.0 flag: Christmas Island +1F1E8 1F1FE ; fully-qualified # 🇨🇾 E2.0 flag: Cyprus +1F1E8 1F1FF ; fully-qualified # 🇨🇿 E2.0 flag: Czechia +1F1E9 1F1EA ; fully-qualified # 🇩🇪 E0.6 flag: Germany +1F1E9 1F1EC ; fully-qualified # 🇩🇬 E2.0 flag: Diego Garcia +1F1E9 1F1EF ; fully-qualified # 🇩🇯 E2.0 flag: Djibouti +1F1E9 1F1F0 ; fully-qualified # 🇩🇰 E2.0 flag: Denmark +1F1E9 1F1F2 ; fully-qualified # 🇩🇲 E2.0 flag: Dominica +1F1E9 1F1F4 ; fully-qualified # 🇩🇴 E2.0 flag: Dominican Republic +1F1E9 1F1FF ; fully-qualified # 🇩🇿 E2.0 flag: Algeria +1F1EA 1F1E6 ; fully-qualified # 🇪🇦 E2.0 flag: Ceuta & Melilla +1F1EA 1F1E8 ; fully-qualified # 🇪🇨 E2.0 flag: Ecuador +1F1EA 1F1EA ; fully-qualified # 🇪🇪 E2.0 flag: Estonia +1F1EA 1F1EC ; fully-qualified # 🇪🇬 E2.0 flag: Egypt +1F1EA 1F1ED ; fully-qualified # 🇪🇭 E2.0 flag: Western Sahara +1F1EA 1F1F7 ; fully-qualified # 🇪🇷 E2.0 flag: Eritrea +1F1EA 1F1F8 ; fully-qualified # 🇪🇸 E0.6 flag: Spain +1F1EA 1F1F9 ; fully-qualified # 🇪🇹 E2.0 flag: Ethiopia +1F1EA 1F1FA ; fully-qualified # 🇪🇺 E2.0 flag: European Union +1F1EB 1F1EE ; fully-qualified # 🇫🇮 E2.0 flag: Finland +1F1EB 1F1EF ; fully-qualified # 🇫🇯 E2.0 flag: Fiji +1F1EB 1F1F0 ; fully-qualified # 🇫🇰 E2.0 flag: Falkland Islands +1F1EB 1F1F2 ; fully-qualified # 🇫🇲 E2.0 flag: Micronesia +1F1EB 1F1F4 ; fully-qualified # 🇫🇴 E2.0 flag: Faroe Islands +1F1EB 1F1F7 ; fully-qualified # 🇫🇷 E0.6 flag: France +1F1EC 1F1E6 ; fully-qualified # 🇬🇦 E2.0 flag: Gabon +1F1EC 1F1E7 ; fully-qualified # 🇬🇧 E0.6 flag: United Kingdom +1F1EC 1F1E9 ; fully-qualified # 🇬🇩 E2.0 flag: Grenada +1F1EC 1F1EA ; fully-qualified # 🇬🇪 E2.0 flag: Georgia +1F1EC 1F1EB ; fully-qualified # 🇬🇫 E2.0 flag: French Guiana +1F1EC 1F1EC ; fully-qualified # 🇬🇬 E2.0 flag: Guernsey +1F1EC 1F1ED ; fully-qualified # 🇬🇭 E2.0 flag: Ghana +1F1EC 1F1EE ; fully-qualified # 🇬🇮 E2.0 flag: Gibraltar +1F1EC 1F1F1 ; fully-qualified # 🇬🇱 E2.0 flag: Greenland +1F1EC 1F1F2 ; fully-qualified # 🇬🇲 E2.0 flag: Gambia +1F1EC 1F1F3 ; fully-qualified # 🇬🇳 E2.0 flag: Guinea +1F1EC 1F1F5 ; fully-qualified # 🇬🇵 E2.0 flag: Guadeloupe +1F1EC 1F1F6 ; fully-qualified # 🇬🇶 E2.0 flag: Equatorial Guinea +1F1EC 1F1F7 ; fully-qualified # 🇬🇷 E2.0 flag: Greece +1F1EC 1F1F8 ; fully-qualified # 🇬🇸 E2.0 flag: South Georgia & South Sandwich Islands +1F1EC 1F1F9 ; fully-qualified # 🇬🇹 E2.0 flag: Guatemala +1F1EC 1F1FA ; fully-qualified # 🇬🇺 E2.0 flag: Guam +1F1EC 1F1FC ; fully-qualified # 🇬🇼 E2.0 flag: Guinea-Bissau +1F1EC 1F1FE ; fully-qualified # 🇬🇾 E2.0 flag: Guyana +1F1ED 1F1F0 ; fully-qualified # 🇭🇰 E2.0 flag: Hong Kong SAR China +1F1ED 1F1F2 ; fully-qualified # 🇭🇲 E2.0 flag: Heard & McDonald Islands +1F1ED 1F1F3 ; fully-qualified # 🇭🇳 E2.0 flag: Honduras +1F1ED 1F1F7 ; fully-qualified # 🇭🇷 E2.0 flag: Croatia +1F1ED 1F1F9 ; fully-qualified # 🇭🇹 E2.0 flag: Haiti +1F1ED 1F1FA ; fully-qualified # 🇭🇺 E2.0 flag: Hungary +1F1EE 1F1E8 ; fully-qualified # 🇮🇨 E2.0 flag: Canary Islands +1F1EE 1F1E9 ; fully-qualified # 🇮🇩 E2.0 flag: Indonesia +1F1EE 1F1EA ; fully-qualified # 🇮🇪 E2.0 flag: Ireland +1F1EE 1F1F1 ; fully-qualified # 🇮🇱 E2.0 flag: Israel +1F1EE 1F1F2 ; fully-qualified # 🇮🇲 E2.0 flag: Isle of Man +1F1EE 1F1F3 ; fully-qualified # 🇮🇳 E2.0 flag: India +1F1EE 1F1F4 ; fully-qualified # 🇮🇴 E2.0 flag: British Indian Ocean Territory +1F1EE 1F1F6 ; fully-qualified # 🇮🇶 E2.0 flag: Iraq +1F1EE 1F1F7 ; fully-qualified # 🇮🇷 E2.0 flag: Iran +1F1EE 1F1F8 ; fully-qualified # 🇮🇸 E2.0 flag: Iceland +1F1EE 1F1F9 ; fully-qualified # 🇮🇹 E0.6 flag: Italy +1F1EF 1F1EA ; fully-qualified # 🇯🇪 E2.0 flag: Jersey +1F1EF 1F1F2 ; fully-qualified # 🇯🇲 E2.0 flag: Jamaica +1F1EF 1F1F4 ; fully-qualified # 🇯🇴 E2.0 flag: Jordan +1F1EF 1F1F5 ; fully-qualified # 🇯🇵 E0.6 flag: Japan +1F1F0 1F1EA ; fully-qualified # 🇰🇪 E2.0 flag: Kenya +1F1F0 1F1EC ; fully-qualified # 🇰🇬 E2.0 flag: Kyrgyzstan +1F1F0 1F1ED ; fully-qualified # 🇰🇭 E2.0 flag: Cambodia +1F1F0 1F1EE ; fully-qualified # 🇰🇮 E2.0 flag: Kiribati +1F1F0 1F1F2 ; fully-qualified # 🇰🇲 E2.0 flag: Comoros +1F1F0 1F1F3 ; fully-qualified # 🇰🇳 E2.0 flag: St. Kitts & Nevis +1F1F0 1F1F5 ; fully-qualified # 🇰🇵 E2.0 flag: North Korea +1F1F0 1F1F7 ; fully-qualified # 🇰🇷 E0.6 flag: South Korea +1F1F0 1F1FC ; fully-qualified # 🇰🇼 E2.0 flag: Kuwait +1F1F0 1F1FE ; fully-qualified # 🇰🇾 E2.0 flag: Cayman Islands +1F1F0 1F1FF ; fully-qualified # 🇰🇿 E2.0 flag: Kazakhstan +1F1F1 1F1E6 ; fully-qualified # 🇱🇦 E2.0 flag: Laos +1F1F1 1F1E7 ; fully-qualified # 🇱🇧 E2.0 flag: Lebanon +1F1F1 1F1E8 ; fully-qualified # 🇱🇨 E2.0 flag: St. Lucia +1F1F1 1F1EE ; fully-qualified # 🇱🇮 E2.0 flag: Liechtenstein +1F1F1 1F1F0 ; fully-qualified # 🇱🇰 E2.0 flag: Sri Lanka +1F1F1 1F1F7 ; fully-qualified # 🇱🇷 E2.0 flag: Liberia +1F1F1 1F1F8 ; fully-qualified # 🇱🇸 E2.0 flag: Lesotho +1F1F1 1F1F9 ; fully-qualified # 🇱🇹 E2.0 flag: Lithuania +1F1F1 1F1FA ; fully-qualified # 🇱🇺 E2.0 flag: Luxembourg +1F1F1 1F1FB ; fully-qualified # 🇱🇻 E2.0 flag: Latvia +1F1F1 1F1FE ; fully-qualified # 🇱🇾 E2.0 flag: Libya +1F1F2 1F1E6 ; fully-qualified # 🇲🇦 E2.0 flag: Morocco +1F1F2 1F1E8 ; fully-qualified # 🇲🇨 E2.0 flag: Monaco +1F1F2 1F1E9 ; fully-qualified # 🇲🇩 E2.0 flag: Moldova +1F1F2 1F1EA ; fully-qualified # 🇲🇪 E2.0 flag: Montenegro +1F1F2 1F1EB ; fully-qualified # 🇲🇫 E2.0 flag: St. Martin +1F1F2 1F1EC ; fully-qualified # 🇲🇬 E2.0 flag: Madagascar +1F1F2 1F1ED ; fully-qualified # 🇲🇭 E2.0 flag: Marshall Islands +1F1F2 1F1F0 ; fully-qualified # 🇲🇰 E2.0 flag: North Macedonia +1F1F2 1F1F1 ; fully-qualified # 🇲🇱 E2.0 flag: Mali +1F1F2 1F1F2 ; fully-qualified # 🇲🇲 E2.0 flag: Myanmar (Burma) +1F1F2 1F1F3 ; fully-qualified # 🇲🇳 E2.0 flag: Mongolia +1F1F2 1F1F4 ; fully-qualified # 🇲🇴 E2.0 flag: Macao SAR China +1F1F2 1F1F5 ; fully-qualified # 🇲🇵 E2.0 flag: Northern Mariana Islands +1F1F2 1F1F6 ; fully-qualified # 🇲🇶 E2.0 flag: Martinique +1F1F2 1F1F7 ; fully-qualified # 🇲🇷 E2.0 flag: Mauritania +1F1F2 1F1F8 ; fully-qualified # 🇲🇸 E2.0 flag: Montserrat +1F1F2 1F1F9 ; fully-qualified # 🇲🇹 E2.0 flag: Malta +1F1F2 1F1FA ; fully-qualified # 🇲🇺 E2.0 flag: Mauritius +1F1F2 1F1FB ; fully-qualified # 🇲🇻 E2.0 flag: Maldives +1F1F2 1F1FC ; fully-qualified # 🇲🇼 E2.0 flag: Malawi +1F1F2 1F1FD ; fully-qualified # 🇲🇽 E2.0 flag: Mexico +1F1F2 1F1FE ; fully-qualified # 🇲🇾 E2.0 flag: Malaysia +1F1F2 1F1FF ; fully-qualified # 🇲🇿 E2.0 flag: Mozambique +1F1F3 1F1E6 ; fully-qualified # 🇳🇦 E2.0 flag: Namibia +1F1F3 1F1E8 ; fully-qualified # 🇳🇨 E2.0 flag: New Caledonia +1F1F3 1F1EA ; fully-qualified # 🇳🇪 E2.0 flag: Niger +1F1F3 1F1EB ; fully-qualified # 🇳🇫 E2.0 flag: Norfolk Island +1F1F3 1F1EC ; fully-qualified # 🇳🇬 E2.0 flag: Nigeria +1F1F3 1F1EE ; fully-qualified # 🇳🇮 E2.0 flag: Nicaragua +1F1F3 1F1F1 ; fully-qualified # 🇳🇱 E2.0 flag: Netherlands +1F1F3 1F1F4 ; fully-qualified # 🇳🇴 E2.0 flag: Norway +1F1F3 1F1F5 ; fully-qualified # 🇳🇵 E2.0 flag: Nepal +1F1F3 1F1F7 ; fully-qualified # 🇳🇷 E2.0 flag: Nauru +1F1F3 1F1FA ; fully-qualified # 🇳🇺 E2.0 flag: Niue +1F1F3 1F1FF ; fully-qualified # 🇳🇿 E2.0 flag: New Zealand +1F1F4 1F1F2 ; fully-qualified # 🇴🇲 E2.0 flag: Oman +1F1F5 1F1E6 ; fully-qualified # 🇵🇦 E2.0 flag: Panama +1F1F5 1F1EA ; fully-qualified # 🇵🇪 E2.0 flag: Peru +1F1F5 1F1EB ; fully-qualified # 🇵🇫 E2.0 flag: French Polynesia +1F1F5 1F1EC ; fully-qualified # 🇵🇬 E2.0 flag: Papua New Guinea +1F1F5 1F1ED ; fully-qualified # 🇵🇭 E2.0 flag: Philippines +1F1F5 1F1F0 ; fully-qualified # 🇵🇰 E2.0 flag: Pakistan +1F1F5 1F1F1 ; fully-qualified # 🇵🇱 E2.0 flag: Poland +1F1F5 1F1F2 ; fully-qualified # 🇵🇲 E2.0 flag: St. Pierre & Miquelon +1F1F5 1F1F3 ; fully-qualified # 🇵🇳 E2.0 flag: Pitcairn Islands +1F1F5 1F1F7 ; fully-qualified # 🇵🇷 E2.0 flag: Puerto Rico +1F1F5 1F1F8 ; fully-qualified # 🇵🇸 E2.0 flag: Palestinian Territories +1F1F5 1F1F9 ; fully-qualified # 🇵🇹 E2.0 flag: Portugal +1F1F5 1F1FC ; fully-qualified # 🇵🇼 E2.0 flag: Palau +1F1F5 1F1FE ; fully-qualified # 🇵🇾 E2.0 flag: Paraguay +1F1F6 1F1E6 ; fully-qualified # 🇶🇦 E2.0 flag: Qatar +1F1F7 1F1EA ; fully-qualified # 🇷🇪 E2.0 flag: Réunion +1F1F7 1F1F4 ; fully-qualified # 🇷🇴 E2.0 flag: Romania +1F1F7 1F1F8 ; fully-qualified # 🇷🇸 E2.0 flag: Serbia +1F1F7 1F1FA ; fully-qualified # 🇷🇺 E0.6 flag: Russia +1F1F7 1F1FC ; fully-qualified # 🇷🇼 E2.0 flag: Rwanda +1F1F8 1F1E6 ; fully-qualified # 🇸🇦 E2.0 flag: Saudi Arabia +1F1F8 1F1E7 ; fully-qualified # 🇸🇧 E2.0 flag: Solomon Islands +1F1F8 1F1E8 ; fully-qualified # 🇸🇨 E2.0 flag: Seychelles +1F1F8 1F1E9 ; fully-qualified # 🇸🇩 E2.0 flag: Sudan +1F1F8 1F1EA ; fully-qualified # 🇸🇪 E2.0 flag: Sweden +1F1F8 1F1EC ; fully-qualified # 🇸🇬 E2.0 flag: Singapore +1F1F8 1F1ED ; fully-qualified # 🇸🇭 E2.0 flag: St. Helena +1F1F8 1F1EE ; fully-qualified # 🇸🇮 E2.0 flag: Slovenia +1F1F8 1F1EF ; fully-qualified # 🇸🇯 E2.0 flag: Svalbard & Jan Mayen +1F1F8 1F1F0 ; fully-qualified # 🇸🇰 E2.0 flag: Slovakia +1F1F8 1F1F1 ; fully-qualified # 🇸🇱 E2.0 flag: Sierra Leone +1F1F8 1F1F2 ; fully-qualified # 🇸🇲 E2.0 flag: San Marino +1F1F8 1F1F3 ; fully-qualified # 🇸🇳 E2.0 flag: Senegal +1F1F8 1F1F4 ; fully-qualified # 🇸🇴 E2.0 flag: Somalia +1F1F8 1F1F7 ; fully-qualified # 🇸🇷 E2.0 flag: Suriname +1F1F8 1F1F8 ; fully-qualified # 🇸🇸 E2.0 flag: South Sudan +1F1F8 1F1F9 ; fully-qualified # 🇸🇹 E2.0 flag: São Tomé & Príncipe +1F1F8 1F1FB ; fully-qualified # 🇸🇻 E2.0 flag: El Salvador +1F1F8 1F1FD ; fully-qualified # 🇸🇽 E2.0 flag: Sint Maarten +1F1F8 1F1FE ; fully-qualified # 🇸🇾 E2.0 flag: Syria +1F1F8 1F1FF ; fully-qualified # 🇸🇿 E2.0 flag: Eswatini +1F1F9 1F1E6 ; fully-qualified # 🇹🇦 E2.0 flag: Tristan da Cunha +1F1F9 1F1E8 ; fully-qualified # 🇹🇨 E2.0 flag: Turks & Caicos Islands +1F1F9 1F1E9 ; fully-qualified # 🇹🇩 E2.0 flag: Chad +1F1F9 1F1EB ; fully-qualified # 🇹🇫 E2.0 flag: French Southern Territories +1F1F9 1F1EC ; fully-qualified # 🇹🇬 E2.0 flag: Togo +1F1F9 1F1ED ; fully-qualified # 🇹🇭 E2.0 flag: Thailand +1F1F9 1F1EF ; fully-qualified # 🇹🇯 E2.0 flag: Tajikistan +1F1F9 1F1F0 ; fully-qualified # 🇹🇰 E2.0 flag: Tokelau +1F1F9 1F1F1 ; fully-qualified # 🇹🇱 E2.0 flag: Timor-Leste +1F1F9 1F1F2 ; fully-qualified # 🇹🇲 E2.0 flag: Turkmenistan +1F1F9 1F1F3 ; fully-qualified # 🇹🇳 E2.0 flag: Tunisia +1F1F9 1F1F4 ; fully-qualified # 🇹🇴 E2.0 flag: Tonga +1F1F9 1F1F7 ; fully-qualified # 🇹🇷 E2.0 flag: Turkey +1F1F9 1F1F9 ; fully-qualified # 🇹🇹 E2.0 flag: Trinidad & Tobago +1F1F9 1F1FB ; fully-qualified # 🇹🇻 E2.0 flag: Tuvalu +1F1F9 1F1FC ; fully-qualified # 🇹🇼 E2.0 flag: Taiwan +1F1F9 1F1FF ; fully-qualified # 🇹🇿 E2.0 flag: Tanzania +1F1FA 1F1E6 ; fully-qualified # 🇺🇦 E2.0 flag: Ukraine +1F1FA 1F1EC ; fully-qualified # 🇺🇬 E2.0 flag: Uganda +1F1FA 1F1F2 ; fully-qualified # 🇺🇲 E2.0 flag: U.S. Outlying Islands +1F1FA 1F1F3 ; fully-qualified # 🇺🇳 E4.0 flag: United Nations +1F1FA 1F1F8 ; fully-qualified # 🇺🇸 E0.6 flag: United States +1F1FA 1F1FE ; fully-qualified # 🇺🇾 E2.0 flag: Uruguay +1F1FA 1F1FF ; fully-qualified # 🇺🇿 E2.0 flag: Uzbekistan +1F1FB 1F1E6 ; fully-qualified # 🇻🇦 E2.0 flag: Vatican City +1F1FB 1F1E8 ; fully-qualified # 🇻🇨 E2.0 flag: St. Vincent & Grenadines +1F1FB 1F1EA ; fully-qualified # 🇻🇪 E2.0 flag: Venezuela +1F1FB 1F1EC ; fully-qualified # 🇻🇬 E2.0 flag: British Virgin Islands +1F1FB 1F1EE ; fully-qualified # 🇻🇮 E2.0 flag: U.S. Virgin Islands +1F1FB 1F1F3 ; fully-qualified # 🇻🇳 E2.0 flag: Vietnam +1F1FB 1F1FA ; fully-qualified # 🇻🇺 E2.0 flag: Vanuatu +1F1FC 1F1EB ; fully-qualified # 🇼🇫 E2.0 flag: Wallis & Futuna +1F1FC 1F1F8 ; fully-qualified # 🇼🇸 E2.0 flag: Samoa +1F1FD 1F1F0 ; fully-qualified # 🇽🇰 E2.0 flag: Kosovo +1F1FE 1F1EA ; fully-qualified # 🇾🇪 E2.0 flag: Yemen +1F1FE 1F1F9 ; fully-qualified # 🇾🇹 E2.0 flag: Mayotte +1F1FF 1F1E6 ; fully-qualified # 🇿🇦 E2.0 flag: South Africa +1F1FF 1F1F2 ; fully-qualified # 🇿🇲 E2.0 flag: Zambia +1F1FF 1F1FC ; fully-qualified # 🇿🇼 E2.0 flag: Zimbabwe + +# subgroup: subdivision-flag +1F3F4 E0067 E0062 E0065 E006E E0067 E007F ; fully-qualified # 🏴󠁧󠁢󠁥󠁮󠁧󠁿 E5.0 flag: England +1F3F4 E0067 E0062 E0073 E0063 E0074 E007F ; fully-qualified # 🏴󠁧󠁢󠁳󠁣󠁴󠁿 E5.0 flag: Scotland +1F3F4 E0067 E0062 E0077 E006C E0073 E007F ; fully-qualified # 🏴󠁧󠁢󠁷󠁬󠁳󠁿 E5.0 flag: Wales + +# Flags subtotal: 275 +# Flags subtotal: 275 w/o modifiers + +# Status Counts +# fully-qualified : 3512 +# minimally-qualified : 817 +# unqualified : 252 +# component : 9 + +#EOF diff --git a/lib/pleroma/emoji.ex b/lib/pleroma/emoji.ex index 04936155b..98644f84e 100644 --- a/lib/pleroma/emoji.ex +++ b/lib/pleroma/emoji.ex @@ -102,7 +102,7 @@ defmodule Pleroma.Emoji do :ets.insert(@ets, emojis) end - @external_resource "lib/pleroma/emoji-data.txt" + @external_resource "lib/pleroma/emoji-test.txt" emojis = @external_resource @@ -114,17 +114,12 @@ defmodule Pleroma.Emoji do |> String.split(";", parts: 2) |> hd() |> String.trim() - |> String.split("..") - |> case do - [number] -> - <> - - [first, last] -> - String.to_integer(first, 16)..String.to_integer(last, 16) - |> Enum.map(&<<&1::utf8>>) - end + |> String.split() + |> Enum.map(fn codepoint -> + <> + end) + |> Enum.join() end) - |> List.flatten() |> Enum.uniq() for emoji <- emojis do -- cgit v1.2.3 From b6f5e9ac9c801f4fc765629fce5846e447f0ec33 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 2 Dec 2020 16:15:31 +0100 Subject: Emoji: Remove unused emoji-data.txt --- lib/pleroma/emoji-data.txt | 769 --------------------------------------------- 1 file changed, 769 deletions(-) delete mode 100644 lib/pleroma/emoji-data.txt (limited to 'lib') diff --git a/lib/pleroma/emoji-data.txt b/lib/pleroma/emoji-data.txt deleted file mode 100644 index 2fb5c3ff6..000000000 --- a/lib/pleroma/emoji-data.txt +++ /dev/null @@ -1,769 +0,0 @@ -# emoji-data.txt -# Date: 2019-01-15, 12:10:05 GMT -# © 2019 Unicode®, Inc. -# Unicode and the Unicode Logo are registered trademarks of Unicode, Inc. in the U.S. and other countries. -# For terms of use, see http://www.unicode.org/terms_of_use.html -# -# Emoji Data for UTS #51 -# Version: 12.0 -# -# For documentation and usage, see http://www.unicode.org/reports/tr51 -# -# Format: -# ; # -# Note: there is no guarantee as to the structure of whitespace or comments -# -# Characters and sequences are listed in code point order. Users should be shown a more natural order. -# See the CLDR collation order for Emoji. - - -# ================================================ - -# All omitted code points have Emoji=No -# @missing: 0000..10FFFF ; Emoji ; No - -0023 ; Emoji # 1.1 [1] (#️) number sign -002A ; Emoji # 1.1 [1] (*️) asterisk -0030..0039 ; Emoji # 1.1 [10] (0️..9️) digit zero..digit nine -00A9 ; Emoji # 1.1 [1] (©️) copyright -00AE ; Emoji # 1.1 [1] (®️) registered -203C ; Emoji # 1.1 [1] (‼️) double exclamation mark -2049 ; Emoji # 3.0 [1] (⁉️) exclamation question mark -2122 ; Emoji # 1.1 [1] (™️) trade mark -2139 ; Emoji # 3.0 [1] (ℹ️) information -2194..2199 ; Emoji # 1.1 [6] (↔️..↙️) left-right arrow..down-left arrow -21A9..21AA ; Emoji # 1.1 [2] (↩️..↪️) right arrow curving left..left arrow curving right -231A..231B ; Emoji # 1.1 [2] (⌚..⌛) watch..hourglass done -2328 ; Emoji # 1.1 [1] (⌨️) keyboard -23CF ; Emoji # 4.0 [1] (⏏️) eject button -23E9..23F3 ; Emoji # 6.0 [11] (⏩..⏳) fast-forward button..hourglass not done -23F8..23FA ; Emoji # 7.0 [3] (⏸️..⏺️) pause button..record button -24C2 ; Emoji # 1.1 [1] (Ⓜ️) circled M -25AA..25AB ; Emoji # 1.1 [2] (▪️..▫️) black small square..white small square -25B6 ; Emoji # 1.1 [1] (▶️) play button -25C0 ; Emoji # 1.1 [1] (◀️) reverse button -25FB..25FE ; Emoji # 3.2 [4] (◻️..◾) white medium square..black medium-small square -2600..2604 ; Emoji # 1.1 [5] (☀️..☄️) sun..comet -260E ; Emoji # 1.1 [1] (☎️) telephone -2611 ; Emoji # 1.1 [1] (☑️) check box with check -2614..2615 ; Emoji # 4.0 [2] (☔..☕) umbrella with rain drops..hot beverage -2618 ; Emoji # 4.1 [1] (☘️) shamrock -261D ; Emoji # 1.1 [1] (☝️) index pointing up -2620 ; Emoji # 1.1 [1] (☠️) skull and crossbones -2622..2623 ; Emoji # 1.1 [2] (☢️..☣️) radioactive..biohazard -2626 ; Emoji # 1.1 [1] (☦️) orthodox cross -262A ; Emoji # 1.1 [1] (☪️) star and crescent -262E..262F ; Emoji # 1.1 [2] (☮️..☯️) peace symbol..yin yang -2638..263A ; Emoji # 1.1 [3] (☸️..☺️) wheel of dharma..smiling face -2640 ; Emoji # 1.1 [1] (♀️) female sign -2642 ; Emoji # 1.1 [1] (♂️) male sign -2648..2653 ; Emoji # 1.1 [12] (♈..♓) Aries..Pisces -265F..2660 ; Emoji # 1.1 [2] (♟️..♠️) chess pawn..spade suit -2663 ; Emoji # 1.1 [1] (♣️) club suit -2665..2666 ; Emoji # 1.1 [2] (♥️..♦️) heart suit..diamond suit -2668 ; Emoji # 1.1 [1] (♨️) hot springs -267B ; Emoji # 3.2 [1] (♻️) recycling symbol -267E..267F ; Emoji # 4.1 [2] (♾️..♿) infinity..wheelchair symbol -2692..2697 ; Emoji # 4.1 [6] (⚒️..⚗️) hammer and pick..alembic -2699 ; Emoji # 4.1 [1] (⚙️) gear -269B..269C ; Emoji # 4.1 [2] (⚛️..⚜️) atom symbol..fleur-de-lis -26A0..26A1 ; Emoji # 4.0 [2] (⚠️..⚡) warning..high voltage -26AA..26AB ; Emoji # 4.1 [2] (⚪..⚫) white circle..black circle -26B0..26B1 ; Emoji # 4.1 [2] (⚰️..⚱️) coffin..funeral urn -26BD..26BE ; Emoji # 5.2 [2] (⚽..⚾) soccer ball..baseball -26C4..26C5 ; Emoji # 5.2 [2] (⛄..⛅) snowman without snow..sun behind cloud -26C8 ; Emoji # 5.2 [1] (⛈️) cloud with lightning and rain -26CE ; Emoji # 6.0 [1] (⛎) Ophiuchus -26CF ; Emoji # 5.2 [1] (⛏️) pick -26D1 ; Emoji # 5.2 [1] (⛑️) rescue worker’s helmet -26D3..26D4 ; Emoji # 5.2 [2] (⛓️..⛔) chains..no entry -26E9..26EA ; Emoji # 5.2 [2] (⛩️..⛪) shinto shrine..church -26F0..26F5 ; Emoji # 5.2 [6] (⛰️..⛵) mountain..sailboat -26F7..26FA ; Emoji # 5.2 [4] (⛷️..⛺) skier..tent -26FD ; Emoji # 5.2 [1] (⛽) fuel pump -2702 ; Emoji # 1.1 [1] (✂️) scissors -2705 ; Emoji # 6.0 [1] (✅) check mark button -2708..2709 ; Emoji # 1.1 [2] (✈️..✉️) airplane..envelope -270A..270B ; Emoji # 6.0 [2] (✊..✋) raised fist..raised hand -270C..270D ; Emoji # 1.1 [2] (✌️..✍️) victory hand..writing hand -270F ; Emoji # 1.1 [1] (✏️) pencil -2712 ; Emoji # 1.1 [1] (✒️) black nib -2714 ; Emoji # 1.1 [1] (✔️) check mark -2716 ; Emoji # 1.1 [1] (✖️) multiplication sign -271D ; Emoji # 1.1 [1] (✝️) latin cross -2721 ; Emoji # 1.1 [1] (✡️) star of David -2728 ; Emoji # 6.0 [1] (✨) sparkles -2733..2734 ; Emoji # 1.1 [2] (✳️..✴️) eight-spoked asterisk..eight-pointed star -2744 ; Emoji # 1.1 [1] (❄️) snowflake -2747 ; Emoji # 1.1 [1] (❇️) sparkle -274C ; Emoji # 6.0 [1] (❌) cross mark -274E ; Emoji # 6.0 [1] (❎) cross mark button -2753..2755 ; Emoji # 6.0 [3] (❓..❕) question mark..white exclamation mark -2757 ; Emoji # 5.2 [1] (❗) exclamation mark -2763..2764 ; Emoji # 1.1 [2] (❣️..❤️) heart exclamation..red heart -2795..2797 ; Emoji # 6.0 [3] (➕..➗) plus sign..division sign -27A1 ; Emoji # 1.1 [1] (➡️) right arrow -27B0 ; Emoji # 6.0 [1] (➰) curly loop -27BF ; Emoji # 6.0 [1] (➿) double curly loop -2934..2935 ; Emoji # 3.2 [2] (⤴️..⤵️) right arrow curving up..right arrow curving down -2B05..2B07 ; Emoji # 4.0 [3] (⬅️..⬇️) left arrow..down arrow -2B1B..2B1C ; Emoji # 5.1 [2] (⬛..⬜) black large square..white large square -2B50 ; Emoji # 5.1 [1] (⭐) star -2B55 ; Emoji # 5.2 [1] (⭕) hollow red circle -3030 ; Emoji # 1.1 [1] (〰️) wavy dash -303D ; Emoji # 3.2 [1] (〽️) part alternation mark -3297 ; Emoji # 1.1 [1] (㊗️) Japanese “congratulations” button -3299 ; Emoji # 1.1 [1] (㊙️) Japanese “secret” button -1F004 ; Emoji # 5.1 [1] (🀄) mahjong red dragon -1F0CF ; Emoji # 6.0 [1] (🃏) joker -1F170..1F171 ; Emoji # 6.0 [2] (🅰️..🅱️) A button (blood type)..B button (blood type) -1F17E ; Emoji # 6.0 [1] (🅾️) O button (blood type) -1F17F ; Emoji # 5.2 [1] (🅿️) P button -1F18E ; Emoji # 6.0 [1] (🆎) AB button (blood type) -1F191..1F19A ; Emoji # 6.0 [10] (🆑..🆚) CL button..VS button -1F1E6..1F1FF ; Emoji # 6.0 [26] (🇦..🇿) regional indicator symbol letter a..regional indicator symbol letter z -1F201..1F202 ; Emoji # 6.0 [2] (🈁..🈂️) Japanese “here” button..Japanese “service charge” button -1F21A ; Emoji # 5.2 [1] (🈚) Japanese “free of charge” button -1F22F ; Emoji # 5.2 [1] (🈯) Japanese “reserved” button -1F232..1F23A ; Emoji # 6.0 [9] (🈲..🈺) Japanese “prohibited” button..Japanese “open for business” button -1F250..1F251 ; Emoji # 6.0 [2] (🉐..🉑) Japanese “bargain” button..Japanese “acceptable” button -1F300..1F320 ; Emoji # 6.0 [33] (🌀..🌠) cyclone..shooting star -1F321 ; Emoji # 7.0 [1] (🌡️) thermometer -1F324..1F32C ; Emoji # 7.0 [9] (🌤️..🌬️) sun behind small cloud..wind face -1F32D..1F32F ; Emoji # 8.0 [3] (🌭..🌯) hot dog..burrito -1F330..1F335 ; Emoji # 6.0 [6] (🌰..🌵) chestnut..cactus -1F336 ; Emoji # 7.0 [1] (🌶️) hot pepper -1F337..1F37C ; Emoji # 6.0 [70] (🌷..🍼) tulip..baby bottle -1F37D ; Emoji # 7.0 [1] (🍽️) fork and knife with plate -1F37E..1F37F ; Emoji # 8.0 [2] (🍾..🍿) bottle with popping cork..popcorn -1F380..1F393 ; Emoji # 6.0 [20] (🎀..🎓) ribbon..graduation cap -1F396..1F397 ; Emoji # 7.0 [2] (🎖️..🎗️) military medal..reminder ribbon -1F399..1F39B ; Emoji # 7.0 [3] (🎙️..🎛️) studio microphone..control knobs -1F39E..1F39F ; Emoji # 7.0 [2] (🎞️..🎟️) film frames..admission tickets -1F3A0..1F3C4 ; Emoji # 6.0 [37] (🎠..🏄) carousel horse..person surfing -1F3C5 ; Emoji # 7.0 [1] (🏅) sports medal -1F3C6..1F3CA ; Emoji # 6.0 [5] (🏆..🏊) trophy..person swimming -1F3CB..1F3CE ; Emoji # 7.0 [4] (🏋️..🏎️) person lifting weights..racing car -1F3CF..1F3D3 ; Emoji # 8.0 [5] (🏏..🏓) cricket game..ping pong -1F3D4..1F3DF ; Emoji # 7.0 [12] (🏔️..🏟️) snow-capped mountain..stadium -1F3E0..1F3F0 ; Emoji # 6.0 [17] (🏠..🏰) house..castle -1F3F3..1F3F5 ; Emoji # 7.0 [3] (🏳️..🏵️) white flag..rosette -1F3F7 ; Emoji # 7.0 [1] (🏷️) label -1F3F8..1F3FF ; Emoji # 8.0 [8] (🏸..🏿) badminton..dark skin tone -1F400..1F43E ; Emoji # 6.0 [63] (🐀..🐾) rat..paw prints -1F43F ; Emoji # 7.0 [1] (🐿️) chipmunk -1F440 ; Emoji # 6.0 [1] (👀) eyes -1F441 ; Emoji # 7.0 [1] (👁️) eye -1F442..1F4F7 ; Emoji # 6.0[182] (👂..📷) ear..camera -1F4F8 ; Emoji # 7.0 [1] (📸) camera with flash -1F4F9..1F4FC ; Emoji # 6.0 [4] (📹..📼) video camera..videocassette -1F4FD ; Emoji # 7.0 [1] (📽️) film projector -1F4FF ; Emoji # 8.0 [1] (📿) prayer beads -1F500..1F53D ; Emoji # 6.0 [62] (🔀..🔽) shuffle tracks button..downwards button -1F549..1F54A ; Emoji # 7.0 [2] (🕉️..🕊️) om..dove -1F54B..1F54E ; Emoji # 8.0 [4] (🕋..🕎) kaaba..menorah -1F550..1F567 ; Emoji # 6.0 [24] (🕐..🕧) one o’clock..twelve-thirty -1F56F..1F570 ; Emoji # 7.0 [2] (🕯️..🕰️) candle..mantelpiece clock -1F573..1F579 ; Emoji # 7.0 [7] (🕳️..🕹️) hole..joystick -1F57A ; Emoji # 9.0 [1] (🕺) man dancing -1F587 ; Emoji # 7.0 [1] (🖇️) linked paperclips -1F58A..1F58D ; Emoji # 7.0 [4] (🖊️..🖍️) pen..crayon -1F590 ; Emoji # 7.0 [1] (🖐️) hand with fingers splayed -1F595..1F596 ; Emoji # 7.0 [2] (🖕..🖖) middle finger..vulcan salute -1F5A4 ; Emoji # 9.0 [1] (🖤) black heart -1F5A5 ; Emoji # 7.0 [1] (🖥️) desktop computer -1F5A8 ; Emoji # 7.0 [1] (🖨️) printer -1F5B1..1F5B2 ; Emoji # 7.0 [2] (🖱️..🖲️) computer mouse..trackball -1F5BC ; Emoji # 7.0 [1] (🖼️) framed picture -1F5C2..1F5C4 ; Emoji # 7.0 [3] (🗂️..🗄️) card index dividers..file cabinet -1F5D1..1F5D3 ; Emoji # 7.0 [3] (🗑️..🗓️) wastebasket..spiral calendar -1F5DC..1F5DE ; Emoji # 7.0 [3] (🗜️..🗞️) clamp..rolled-up newspaper -1F5E1 ; Emoji # 7.0 [1] (🗡️) dagger -1F5E3 ; Emoji # 7.0 [1] (🗣️) speaking head -1F5E8 ; Emoji # 7.0 [1] (🗨️) left speech bubble -1F5EF ; Emoji # 7.0 [1] (🗯️) right anger bubble -1F5F3 ; Emoji # 7.0 [1] (🗳️) ballot box with ballot -1F5FA ; Emoji # 7.0 [1] (🗺️) world map -1F5FB..1F5FF ; Emoji # 6.0 [5] (🗻..🗿) mount fuji..moai -1F600 ; Emoji # 6.1 [1] (😀) grinning face -1F601..1F610 ; Emoji # 6.0 [16] (😁..😐) beaming face with smiling eyes..neutral face -1F611 ; Emoji # 6.1 [1] (😑) expressionless face -1F612..1F614 ; Emoji # 6.0 [3] (😒..😔) unamused face..pensive face -1F615 ; Emoji # 6.1 [1] (😕) confused face -1F616 ; Emoji # 6.0 [1] (😖) confounded face -1F617 ; Emoji # 6.1 [1] (😗) kissing face -1F618 ; Emoji # 6.0 [1] (😘) face blowing a kiss -1F619 ; Emoji # 6.1 [1] (😙) kissing face with smiling eyes -1F61A ; Emoji # 6.0 [1] (😚) kissing face with closed eyes -1F61B ; Emoji # 6.1 [1] (😛) face with tongue -1F61C..1F61E ; Emoji # 6.0 [3] (😜..😞) winking face with tongue..disappointed face -1F61F ; Emoji # 6.1 [1] (😟) worried face -1F620..1F625 ; Emoji # 6.0 [6] (😠..😥) angry face..sad but relieved face -1F626..1F627 ; Emoji # 6.1 [2] (😦..😧) frowning face with open mouth..anguished face -1F628..1F62B ; Emoji # 6.0 [4] (😨..😫) fearful face..tired face -1F62C ; Emoji # 6.1 [1] (😬) grimacing face -1F62D ; Emoji # 6.0 [1] (😭) loudly crying face -1F62E..1F62F ; Emoji # 6.1 [2] (😮..😯) face with open mouth..hushed face -1F630..1F633 ; Emoji # 6.0 [4] (😰..😳) anxious face with sweat..flushed face -1F634 ; Emoji # 6.1 [1] (😴) sleeping face -1F635..1F640 ; Emoji # 6.0 [12] (😵..🙀) dizzy face..weary cat -1F641..1F642 ; Emoji # 7.0 [2] (🙁..🙂) slightly frowning face..slightly smiling face -1F643..1F644 ; Emoji # 8.0 [2] (🙃..🙄) upside-down face..face with rolling eyes -1F645..1F64F ; Emoji # 6.0 [11] (🙅..🙏) person gesturing NO..folded hands -1F680..1F6C5 ; Emoji # 6.0 [70] (🚀..🛅) rocket..left luggage -1F6CB..1F6CF ; Emoji # 7.0 [5] (🛋️..🛏️) couch and lamp..bed -1F6D0 ; Emoji # 8.0 [1] (🛐) place of worship -1F6D1..1F6D2 ; Emoji # 9.0 [2] (🛑..🛒) stop sign..shopping cart -1F6D5 ; Emoji # 12.0 [1] (🛕) hindu temple -1F6E0..1F6E5 ; Emoji # 7.0 [6] (🛠️..🛥️) hammer and wrench..motor boat -1F6E9 ; Emoji # 7.0 [1] (🛩️) small airplane -1F6EB..1F6EC ; Emoji # 7.0 [2] (🛫..🛬) airplane departure..airplane arrival -1F6F0 ; Emoji # 7.0 [1] (🛰️) satellite -1F6F3 ; Emoji # 7.0 [1] (🛳️) passenger ship -1F6F4..1F6F6 ; Emoji # 9.0 [3] (🛴..🛶) kick scooter..canoe -1F6F7..1F6F8 ; Emoji # 10.0 [2] (🛷..🛸) sled..flying saucer -1F6F9 ; Emoji # 11.0 [1] (🛹) skateboard -1F6FA ; Emoji # 12.0 [1] (🛺) auto rickshaw -1F7E0..1F7EB ; Emoji # 12.0 [12] (🟠..🟫) orange circle..brown square -1F90D..1F90F ; Emoji # 12.0 [3] (🤍..🤏) white heart..pinching hand -1F910..1F918 ; Emoji # 8.0 [9] (🤐..🤘) zipper-mouth face..sign of the horns -1F919..1F91E ; Emoji # 9.0 [6] (🤙..🤞) call me hand..crossed fingers -1F91F ; Emoji # 10.0 [1] (🤟) love-you gesture -1F920..1F927 ; Emoji # 9.0 [8] (🤠..🤧) cowboy hat face..sneezing face -1F928..1F92F ; Emoji # 10.0 [8] (🤨..🤯) face with raised eyebrow..exploding head -1F930 ; Emoji # 9.0 [1] (🤰) pregnant woman -1F931..1F932 ; Emoji # 10.0 [2] (🤱..🤲) breast-feeding..palms up together -1F933..1F93A ; Emoji # 9.0 [8] (🤳..🤺) selfie..person fencing -1F93C..1F93E ; Emoji # 9.0 [3] (🤼..🤾) people wrestling..person playing handball -1F93F ; Emoji # 12.0 [1] (🤿) diving mask -1F940..1F945 ; Emoji # 9.0 [6] (🥀..🥅) wilted flower..goal net -1F947..1F94B ; Emoji # 9.0 [5] (🥇..🥋) 1st place medal..martial arts uniform -1F94C ; Emoji # 10.0 [1] (🥌) curling stone -1F94D..1F94F ; Emoji # 11.0 [3] (🥍..🥏) lacrosse..flying disc -1F950..1F95E ; Emoji # 9.0 [15] (🥐..🥞) croissant..pancakes -1F95F..1F96B ; Emoji # 10.0 [13] (🥟..🥫) dumpling..canned food -1F96C..1F970 ; Emoji # 11.0 [5] (🥬..🥰) leafy green..smiling face with hearts -1F971 ; Emoji # 12.0 [1] (🥱) yawning face -1F973..1F976 ; Emoji # 11.0 [4] (🥳..🥶) partying face..cold face -1F97A ; Emoji # 11.0 [1] (🥺) pleading face -1F97B ; Emoji # 12.0 [1] (🥻) sari -1F97C..1F97F ; Emoji # 11.0 [4] (🥼..🥿) lab coat..flat shoe -1F980..1F984 ; Emoji # 8.0 [5] (🦀..🦄) crab..unicorn -1F985..1F991 ; Emoji # 9.0 [13] (🦅..🦑) eagle..squid -1F992..1F997 ; Emoji # 10.0 [6] (🦒..🦗) giraffe..cricket -1F998..1F9A2 ; Emoji # 11.0 [11] (🦘..🦢) kangaroo..swan -1F9A5..1F9AA ; Emoji # 12.0 [6] (🦥..🦪) sloth..oyster -1F9AE..1F9AF ; Emoji # 12.0 [2] (🦮..🦯) guide dog..probing cane -1F9B0..1F9B9 ; Emoji # 11.0 [10] (🦰..🦹) red hair..supervillain -1F9BA..1F9BF ; Emoji # 12.0 [6] (🦺..🦿) safety vest..mechanical leg -1F9C0 ; Emoji # 8.0 [1] (🧀) cheese wedge -1F9C1..1F9C2 ; Emoji # 11.0 [2] (🧁..🧂) cupcake..salt -1F9C3..1F9CA ; Emoji # 12.0 [8] (🧃..🧊) beverage box..ice cube -1F9CD..1F9CF ; Emoji # 12.0 [3] (🧍..🧏) person standing..deaf person -1F9D0..1F9E6 ; Emoji # 10.0 [23] (🧐..🧦) face with monocle..socks -1F9E7..1F9FF ; Emoji # 11.0 [25] (🧧..🧿) red envelope..nazar amulet -1FA70..1FA73 ; Emoji # 12.0 [4] (🩰..🩳) ballet shoes..shorts -1FA78..1FA7A ; Emoji # 12.0 [3] (🩸..🩺) drop of blood..stethoscope -1FA80..1FA82 ; Emoji # 12.0 [3] (🪀..🪂) yo-yo..parachute -1FA90..1FA95 ; Emoji # 12.0 [6] (🪐..🪕) ringed planet..banjo - -# Total elements: 1311 - -# ================================================ - -# All omitted code points have Emoji_Presentation=No -# @missing: 0000..10FFFF ; Emoji_Presentation ; No - -231A..231B ; Emoji_Presentation # 1.1 [2] (⌚..⌛) watch..hourglass done -23E9..23EC ; Emoji_Presentation # 6.0 [4] (⏩..⏬) fast-forward button..fast down button -23F0 ; Emoji_Presentation # 6.0 [1] (⏰) alarm clock -23F3 ; Emoji_Presentation # 6.0 [1] (⏳) hourglass not done -25FD..25FE ; Emoji_Presentation # 3.2 [2] (◽..◾) white medium-small square..black medium-small square -2614..2615 ; Emoji_Presentation # 4.0 [2] (☔..☕) umbrella with rain drops..hot beverage -2648..2653 ; Emoji_Presentation # 1.1 [12] (♈..♓) Aries..Pisces -267F ; Emoji_Presentation # 4.1 [1] (♿) wheelchair symbol -2693 ; Emoji_Presentation # 4.1 [1] (⚓) anchor -26A1 ; Emoji_Presentation # 4.0 [1] (⚡) high voltage -26AA..26AB ; Emoji_Presentation # 4.1 [2] (⚪..⚫) white circle..black circle -26BD..26BE ; Emoji_Presentation # 5.2 [2] (⚽..⚾) soccer ball..baseball -26C4..26C5 ; Emoji_Presentation # 5.2 [2] (⛄..⛅) snowman without snow..sun behind cloud -26CE ; Emoji_Presentation # 6.0 [1] (⛎) Ophiuchus -26D4 ; Emoji_Presentation # 5.2 [1] (⛔) no entry -26EA ; Emoji_Presentation # 5.2 [1] (⛪) church -26F2..26F3 ; Emoji_Presentation # 5.2 [2] (⛲..⛳) fountain..flag in hole -26F5 ; Emoji_Presentation # 5.2 [1] (⛵) sailboat -26FA ; Emoji_Presentation # 5.2 [1] (⛺) tent -26FD ; Emoji_Presentation # 5.2 [1] (⛽) fuel pump -2705 ; Emoji_Presentation # 6.0 [1] (✅) check mark button -270A..270B ; Emoji_Presentation # 6.0 [2] (✊..✋) raised fist..raised hand -2728 ; Emoji_Presentation # 6.0 [1] (✨) sparkles -274C ; Emoji_Presentation # 6.0 [1] (❌) cross mark -274E ; Emoji_Presentation # 6.0 [1] (❎) cross mark button -2753..2755 ; Emoji_Presentation # 6.0 [3] (❓..❕) question mark..white exclamation mark -2757 ; Emoji_Presentation # 5.2 [1] (❗) exclamation mark -2795..2797 ; Emoji_Presentation # 6.0 [3] (➕..➗) plus sign..division sign -27B0 ; Emoji_Presentation # 6.0 [1] (➰) curly loop -27BF ; Emoji_Presentation # 6.0 [1] (➿) double curly loop -2B1B..2B1C ; Emoji_Presentation # 5.1 [2] (⬛..⬜) black large square..white large square -2B50 ; Emoji_Presentation # 5.1 [1] (⭐) star -2B55 ; Emoji_Presentation # 5.2 [1] (⭕) hollow red circle -1F004 ; Emoji_Presentation # 5.1 [1] (🀄) mahjong red dragon -1F0CF ; Emoji_Presentation # 6.0 [1] (🃏) joker -1F18E ; Emoji_Presentation # 6.0 [1] (🆎) AB button (blood type) -1F191..1F19A ; Emoji_Presentation # 6.0 [10] (🆑..🆚) CL button..VS button -1F1E6..1F1FF ; Emoji_Presentation # 6.0 [26] (🇦..🇿) regional indicator symbol letter a..regional indicator symbol letter z -1F201 ; Emoji_Presentation # 6.0 [1] (🈁) Japanese “here” button -1F21A ; Emoji_Presentation # 5.2 [1] (🈚) Japanese “free of charge” button -1F22F ; Emoji_Presentation # 5.2 [1] (🈯) Japanese “reserved” button -1F232..1F236 ; Emoji_Presentation # 6.0 [5] (🈲..🈶) Japanese “prohibited” button..Japanese “not free of charge” button -1F238..1F23A ; Emoji_Presentation # 6.0 [3] (🈸..🈺) Japanese “application” button..Japanese “open for business” button -1F250..1F251 ; Emoji_Presentation # 6.0 [2] (🉐..🉑) Japanese “bargain” button..Japanese “acceptable” button -1F300..1F320 ; Emoji_Presentation # 6.0 [33] (🌀..🌠) cyclone..shooting star -1F32D..1F32F ; Emoji_Presentation # 8.0 [3] (🌭..🌯) hot dog..burrito -1F330..1F335 ; Emoji_Presentation # 6.0 [6] (🌰..🌵) chestnut..cactus -1F337..1F37C ; Emoji_Presentation # 6.0 [70] (🌷..🍼) tulip..baby bottle -1F37E..1F37F ; Emoji_Presentation # 8.0 [2] (🍾..🍿) bottle with popping cork..popcorn -1F380..1F393 ; Emoji_Presentation # 6.0 [20] (🎀..🎓) ribbon..graduation cap -1F3A0..1F3C4 ; Emoji_Presentation # 6.0 [37] (🎠..🏄) carousel horse..person surfing -1F3C5 ; Emoji_Presentation # 7.0 [1] (🏅) sports medal -1F3C6..1F3CA ; Emoji_Presentation # 6.0 [5] (🏆..🏊) trophy..person swimming -1F3CF..1F3D3 ; Emoji_Presentation # 8.0 [5] (🏏..🏓) cricket game..ping pong -1F3E0..1F3F0 ; Emoji_Presentation # 6.0 [17] (🏠..🏰) house..castle -1F3F4 ; Emoji_Presentation # 7.0 [1] (🏴) black flag -1F3F8..1F3FF ; Emoji_Presentation # 8.0 [8] (🏸..🏿) badminton..dark skin tone -1F400..1F43E ; Emoji_Presentation # 6.0 [63] (🐀..🐾) rat..paw prints -1F440 ; Emoji_Presentation # 6.0 [1] (👀) eyes -1F442..1F4F7 ; Emoji_Presentation # 6.0[182] (👂..📷) ear..camera -1F4F8 ; Emoji_Presentation # 7.0 [1] (📸) camera with flash -1F4F9..1F4FC ; Emoji_Presentation # 6.0 [4] (📹..📼) video camera..videocassette -1F4FF ; Emoji_Presentation # 8.0 [1] (📿) prayer beads -1F500..1F53D ; Emoji_Presentation # 6.0 [62] (🔀..🔽) shuffle tracks button..downwards button -1F54B..1F54E ; Emoji_Presentation # 8.0 [4] (🕋..🕎) kaaba..menorah -1F550..1F567 ; Emoji_Presentation # 6.0 [24] (🕐..🕧) one o’clock..twelve-thirty -1F57A ; Emoji_Presentation # 9.0 [1] (🕺) man dancing -1F595..1F596 ; Emoji_Presentation # 7.0 [2] (🖕..🖖) middle finger..vulcan salute -1F5A4 ; Emoji_Presentation # 9.0 [1] (🖤) black heart -1F5FB..1F5FF ; Emoji_Presentation # 6.0 [5] (🗻..🗿) mount fuji..moai -1F600 ; Emoji_Presentation # 6.1 [1] (😀) grinning face -1F601..1F610 ; Emoji_Presentation # 6.0 [16] (😁..😐) beaming face with smiling eyes..neutral face -1F611 ; Emoji_Presentation # 6.1 [1] (😑) expressionless face -1F612..1F614 ; Emoji_Presentation # 6.0 [3] (😒..😔) unamused face..pensive face -1F615 ; Emoji_Presentation # 6.1 [1] (😕) confused face -1F616 ; Emoji_Presentation # 6.0 [1] (😖) confounded face -1F617 ; Emoji_Presentation # 6.1 [1] (😗) kissing face -1F618 ; Emoji_Presentation # 6.0 [1] (😘) face blowing a kiss -1F619 ; Emoji_Presentation # 6.1 [1] (😙) kissing face with smiling eyes -1F61A ; Emoji_Presentation # 6.0 [1] (😚) kissing face with closed eyes -1F61B ; Emoji_Presentation # 6.1 [1] (😛) face with tongue -1F61C..1F61E ; Emoji_Presentation # 6.0 [3] (😜..😞) winking face with tongue..disappointed face -1F61F ; Emoji_Presentation # 6.1 [1] (😟) worried face -1F620..1F625 ; Emoji_Presentation # 6.0 [6] (😠..😥) angry face..sad but relieved face -1F626..1F627 ; Emoji_Presentation # 6.1 [2] (😦..😧) frowning face with open mouth..anguished face -1F628..1F62B ; Emoji_Presentation # 6.0 [4] (😨..😫) fearful face..tired face -1F62C ; Emoji_Presentation # 6.1 [1] (😬) grimacing face -1F62D ; Emoji_Presentation # 6.0 [1] (😭) loudly crying face -1F62E..1F62F ; Emoji_Presentation # 6.1 [2] (😮..😯) face with open mouth..hushed face -1F630..1F633 ; Emoji_Presentation # 6.0 [4] (😰..😳) anxious face with sweat..flushed face -1F634 ; Emoji_Presentation # 6.1 [1] (😴) sleeping face -1F635..1F640 ; Emoji_Presentation # 6.0 [12] (😵..🙀) dizzy face..weary cat -1F641..1F642 ; Emoji_Presentation # 7.0 [2] (🙁..🙂) slightly frowning face..slightly smiling face -1F643..1F644 ; Emoji_Presentation # 8.0 [2] (🙃..🙄) upside-down face..face with rolling eyes -1F645..1F64F ; Emoji_Presentation # 6.0 [11] (🙅..🙏) person gesturing NO..folded hands -1F680..1F6C5 ; Emoji_Presentation # 6.0 [70] (🚀..🛅) rocket..left luggage -1F6CC ; Emoji_Presentation # 7.0 [1] (🛌) person in bed -1F6D0 ; Emoji_Presentation # 8.0 [1] (🛐) place of worship -1F6D1..1F6D2 ; Emoji_Presentation # 9.0 [2] (🛑..🛒) stop sign..shopping cart -1F6D5 ; Emoji_Presentation # 12.0 [1] (🛕) hindu temple -1F6EB..1F6EC ; Emoji_Presentation # 7.0 [2] (🛫..🛬) airplane departure..airplane arrival -1F6F4..1F6F6 ; Emoji_Presentation # 9.0 [3] (🛴..🛶) kick scooter..canoe -1F6F7..1F6F8 ; Emoji_Presentation # 10.0 [2] (🛷..🛸) sled..flying saucer -1F6F9 ; Emoji_Presentation # 11.0 [1] (🛹) skateboard -1F6FA ; Emoji_Presentation # 12.0 [1] (🛺) auto rickshaw -1F7E0..1F7EB ; Emoji_Presentation # 12.0 [12] (🟠..🟫) orange circle..brown square -1F90D..1F90F ; Emoji_Presentation # 12.0 [3] (🤍..🤏) white heart..pinching hand -1F910..1F918 ; Emoji_Presentation # 8.0 [9] (🤐..🤘) zipper-mouth face..sign of the horns -1F919..1F91E ; Emoji_Presentation # 9.0 [6] (🤙..🤞) call me hand..crossed fingers -1F91F ; Emoji_Presentation # 10.0 [1] (🤟) love-you gesture -1F920..1F927 ; Emoji_Presentation # 9.0 [8] (🤠..🤧) cowboy hat face..sneezing face -1F928..1F92F ; Emoji_Presentation # 10.0 [8] (🤨..🤯) face with raised eyebrow..exploding head -1F930 ; Emoji_Presentation # 9.0 [1] (🤰) pregnant woman -1F931..1F932 ; Emoji_Presentation # 10.0 [2] (🤱..🤲) breast-feeding..palms up together -1F933..1F93A ; Emoji_Presentation # 9.0 [8] (🤳..🤺) selfie..person fencing -1F93C..1F93E ; Emoji_Presentation # 9.0 [3] (🤼..🤾) people wrestling..person playing handball -1F93F ; Emoji_Presentation # 12.0 [1] (🤿) diving mask -1F940..1F945 ; Emoji_Presentation # 9.0 [6] (🥀..🥅) wilted flower..goal net -1F947..1F94B ; Emoji_Presentation # 9.0 [5] (🥇..🥋) 1st place medal..martial arts uniform -1F94C ; Emoji_Presentation # 10.0 [1] (🥌) curling stone -1F94D..1F94F ; Emoji_Presentation # 11.0 [3] (🥍..🥏) lacrosse..flying disc -1F950..1F95E ; Emoji_Presentation # 9.0 [15] (🥐..🥞) croissant..pancakes -1F95F..1F96B ; Emoji_Presentation # 10.0 [13] (🥟..🥫) dumpling..canned food -1F96C..1F970 ; Emoji_Presentation # 11.0 [5] (🥬..🥰) leafy green..smiling face with hearts -1F971 ; Emoji_Presentation # 12.0 [1] (🥱) yawning face -1F973..1F976 ; Emoji_Presentation # 11.0 [4] (🥳..🥶) partying face..cold face -1F97A ; Emoji_Presentation # 11.0 [1] (🥺) pleading face -1F97B ; Emoji_Presentation # 12.0 [1] (🥻) sari -1F97C..1F97F ; Emoji_Presentation # 11.0 [4] (🥼..🥿) lab coat..flat shoe -1F980..1F984 ; Emoji_Presentation # 8.0 [5] (🦀..🦄) crab..unicorn -1F985..1F991 ; Emoji_Presentation # 9.0 [13] (🦅..🦑) eagle..squid -1F992..1F997 ; Emoji_Presentation # 10.0 [6] (🦒..🦗) giraffe..cricket -1F998..1F9A2 ; Emoji_Presentation # 11.0 [11] (🦘..🦢) kangaroo..swan -1F9A5..1F9AA ; Emoji_Presentation # 12.0 [6] (🦥..🦪) sloth..oyster -1F9AE..1F9AF ; Emoji_Presentation # 12.0 [2] (🦮..🦯) guide dog..probing cane -1F9B0..1F9B9 ; Emoji_Presentation # 11.0 [10] (🦰..🦹) red hair..supervillain -1F9BA..1F9BF ; Emoji_Presentation # 12.0 [6] (🦺..🦿) safety vest..mechanical leg -1F9C0 ; Emoji_Presentation # 8.0 [1] (🧀) cheese wedge -1F9C1..1F9C2 ; Emoji_Presentation # 11.0 [2] (🧁..🧂) cupcake..salt -1F9C3..1F9CA ; Emoji_Presentation # 12.0 [8] (🧃..🧊) beverage box..ice cube -1F9CD..1F9CF ; Emoji_Presentation # 12.0 [3] (🧍..🧏) person standing..deaf person -1F9D0..1F9E6 ; Emoji_Presentation # 10.0 [23] (🧐..🧦) face with monocle..socks -1F9E7..1F9FF ; Emoji_Presentation # 11.0 [25] (🧧..🧿) red envelope..nazar amulet -1FA70..1FA73 ; Emoji_Presentation # 12.0 [4] (🩰..🩳) ballet shoes..shorts -1FA78..1FA7A ; Emoji_Presentation # 12.0 [3] (🩸..🩺) drop of blood..stethoscope -1FA80..1FA82 ; Emoji_Presentation # 12.0 [3] (🪀..🪂) yo-yo..parachute -1FA90..1FA95 ; Emoji_Presentation # 12.0 [6] (🪐..🪕) ringed planet..banjo - -# Total elements: 1093 - -# ================================================ - -# All omitted code points have Emoji_Modifier=No -# @missing: 0000..10FFFF ; Emoji_Modifier ; No - -1F3FB..1F3FF ; Emoji_Modifier # 8.0 [5] (🏻..🏿) light skin tone..dark skin tone - -# Total elements: 5 - -# ================================================ - -# All omitted code points have Emoji_Modifier_Base=No -# @missing: 0000..10FFFF ; Emoji_Modifier_Base ; No - -261D ; Emoji_Modifier_Base # 1.1 [1] (☝️) index pointing up -26F9 ; Emoji_Modifier_Base # 5.2 [1] (⛹️) person bouncing ball -270A..270B ; Emoji_Modifier_Base # 6.0 [2] (✊..✋) raised fist..raised hand -270C..270D ; Emoji_Modifier_Base # 1.1 [2] (✌️..✍️) victory hand..writing hand -1F385 ; Emoji_Modifier_Base # 6.0 [1] (🎅) Santa Claus -1F3C2..1F3C4 ; Emoji_Modifier_Base # 6.0 [3] (🏂..🏄) snowboarder..person surfing -1F3C7 ; Emoji_Modifier_Base # 6.0 [1] (🏇) horse racing -1F3CA ; Emoji_Modifier_Base # 6.0 [1] (🏊) person swimming -1F3CB..1F3CC ; Emoji_Modifier_Base # 7.0 [2] (🏋️..🏌️) person lifting weights..person golfing -1F442..1F443 ; Emoji_Modifier_Base # 6.0 [2] (👂..👃) ear..nose -1F446..1F450 ; Emoji_Modifier_Base # 6.0 [11] (👆..👐) backhand index pointing up..open hands -1F466..1F478 ; Emoji_Modifier_Base # 6.0 [19] (👦..👸) boy..princess -1F47C ; Emoji_Modifier_Base # 6.0 [1] (👼) baby angel -1F481..1F483 ; Emoji_Modifier_Base # 6.0 [3] (💁..💃) person tipping hand..woman dancing -1F485..1F487 ; Emoji_Modifier_Base # 6.0 [3] (💅..💇) nail polish..person getting haircut -1F48F ; Emoji_Modifier_Base # 6.0 [1] (💏) kiss -1F491 ; Emoji_Modifier_Base # 6.0 [1] (💑) couple with heart -1F4AA ; Emoji_Modifier_Base # 6.0 [1] (💪) flexed biceps -1F574..1F575 ; Emoji_Modifier_Base # 7.0 [2] (🕴️..🕵️) man in suit levitating..detective -1F57A ; Emoji_Modifier_Base # 9.0 [1] (🕺) man dancing -1F590 ; Emoji_Modifier_Base # 7.0 [1] (🖐️) hand with fingers splayed -1F595..1F596 ; Emoji_Modifier_Base # 7.0 [2] (🖕..🖖) middle finger..vulcan salute -1F645..1F647 ; Emoji_Modifier_Base # 6.0 [3] (🙅..🙇) person gesturing NO..person bowing -1F64B..1F64F ; Emoji_Modifier_Base # 6.0 [5] (🙋..🙏) person raising hand..folded hands -1F6A3 ; Emoji_Modifier_Base # 6.0 [1] (🚣) person rowing boat -1F6B4..1F6B6 ; Emoji_Modifier_Base # 6.0 [3] (🚴..🚶) person biking..person walking -1F6C0 ; Emoji_Modifier_Base # 6.0 [1] (🛀) person taking bath -1F6CC ; Emoji_Modifier_Base # 7.0 [1] (🛌) person in bed -1F90F ; Emoji_Modifier_Base # 12.0 [1] (🤏) pinching hand -1F918 ; Emoji_Modifier_Base # 8.0 [1] (🤘) sign of the horns -1F919..1F91E ; Emoji_Modifier_Base # 9.0 [6] (🤙..🤞) call me hand..crossed fingers -1F91F ; Emoji_Modifier_Base # 10.0 [1] (🤟) love-you gesture -1F926 ; Emoji_Modifier_Base # 9.0 [1] (🤦) person facepalming -1F930 ; Emoji_Modifier_Base # 9.0 [1] (🤰) pregnant woman -1F931..1F932 ; Emoji_Modifier_Base # 10.0 [2] (🤱..🤲) breast-feeding..palms up together -1F933..1F939 ; Emoji_Modifier_Base # 9.0 [7] (🤳..🤹) selfie..person juggling -1F93C..1F93E ; Emoji_Modifier_Base # 9.0 [3] (🤼..🤾) people wrestling..person playing handball -1F9B5..1F9B6 ; Emoji_Modifier_Base # 11.0 [2] (🦵..🦶) leg..foot -1F9B8..1F9B9 ; Emoji_Modifier_Base # 11.0 [2] (🦸..🦹) superhero..supervillain -1F9BB ; Emoji_Modifier_Base # 12.0 [1] (🦻) ear with hearing aid -1F9CD..1F9CF ; Emoji_Modifier_Base # 12.0 [3] (🧍..🧏) person standing..deaf person -1F9D1..1F9DD ; Emoji_Modifier_Base # 10.0 [13] (🧑..🧝) person..elf - -# Total elements: 120 - -# ================================================ - -# All omitted code points have Emoji_Component=No -# @missing: 0000..10FFFF ; Emoji_Component ; No - -0023 ; Emoji_Component # 1.1 [1] (#️) number sign -002A ; Emoji_Component # 1.1 [1] (*️) asterisk -0030..0039 ; Emoji_Component # 1.1 [10] (0️..9️) digit zero..digit nine -200D ; Emoji_Component # 1.1 [1] (‍) zero width joiner -20E3 ; Emoji_Component # 3.0 [1] (⃣) combining enclosing keycap -FE0F ; Emoji_Component # 3.2 [1] () VARIATION SELECTOR-16 -1F1E6..1F1FF ; Emoji_Component # 6.0 [26] (🇦..🇿) regional indicator symbol letter a..regional indicator symbol letter z -1F3FB..1F3FF ; Emoji_Component # 8.0 [5] (🏻..🏿) light skin tone..dark skin tone -1F9B0..1F9B3 ; Emoji_Component # 11.0 [4] (🦰..🦳) red hair..white hair -E0020..E007F ; Emoji_Component # 3.1 [96] (󠀠..󠁿) tag space..cancel tag - -# Total elements: 146 - -# ================================================ - -# All omitted code points have Extended_Pictographic=No -# @missing: 0000..10FFFF ; Extended_Pictographic ; No - -00A9 ; Extended_Pictographic# 1.1 [1] (©️) copyright -00AE ; Extended_Pictographic# 1.1 [1] (®️) registered -203C ; Extended_Pictographic# 1.1 [1] (‼️) double exclamation mark -2049 ; Extended_Pictographic# 3.0 [1] (⁉️) exclamation question mark -2122 ; Extended_Pictographic# 1.1 [1] (™️) trade mark -2139 ; Extended_Pictographic# 3.0 [1] (ℹ️) information -2194..2199 ; Extended_Pictographic# 1.1 [6] (↔️..↙️) left-right arrow..down-left arrow -21A9..21AA ; Extended_Pictographic# 1.1 [2] (↩️..↪️) right arrow curving left..left arrow curving right -231A..231B ; Extended_Pictographic# 1.1 [2] (⌚..⌛) watch..hourglass done -2328 ; Extended_Pictographic# 1.1 [1] (⌨️) keyboard -2388 ; Extended_Pictographic# 3.0 [1] (⎈) HELM SYMBOL -23CF ; Extended_Pictographic# 4.0 [1] (⏏️) eject button -23E9..23F3 ; Extended_Pictographic# 6.0 [11] (⏩..⏳) fast-forward button..hourglass not done -23F8..23FA ; Extended_Pictographic# 7.0 [3] (⏸️..⏺️) pause button..record button -24C2 ; Extended_Pictographic# 1.1 [1] (Ⓜ️) circled M -25AA..25AB ; Extended_Pictographic# 1.1 [2] (▪️..▫️) black small square..white small square -25B6 ; Extended_Pictographic# 1.1 [1] (▶️) play button -25C0 ; Extended_Pictographic# 1.1 [1] (◀️) reverse button -25FB..25FE ; Extended_Pictographic# 3.2 [4] (◻️..◾) white medium square..black medium-small square -2600..2605 ; Extended_Pictographic# 1.1 [6] (☀️..★) sun..BLACK STAR -2607..2612 ; Extended_Pictographic# 1.1 [12] (☇..☒) LIGHTNING..BALLOT BOX WITH X -2614..2615 ; Extended_Pictographic# 4.0 [2] (☔..☕) umbrella with rain drops..hot beverage -2616..2617 ; Extended_Pictographic# 3.2 [2] (☖..☗) WHITE SHOGI PIECE..BLACK SHOGI PIECE -2618 ; Extended_Pictographic# 4.1 [1] (☘️) shamrock -2619 ; Extended_Pictographic# 3.0 [1] (☙) REVERSED ROTATED FLORAL HEART BULLET -261A..266F ; Extended_Pictographic# 1.1 [86] (☚..♯) BLACK LEFT POINTING INDEX..MUSIC SHARP SIGN -2670..2671 ; Extended_Pictographic# 3.0 [2] (♰..♱) WEST SYRIAC CROSS..EAST SYRIAC CROSS -2672..267D ; Extended_Pictographic# 3.2 [12] (♲..♽) UNIVERSAL RECYCLING SYMBOL..PARTIALLY-RECYCLED PAPER SYMBOL -267E..267F ; Extended_Pictographic# 4.1 [2] (♾️..♿) infinity..wheelchair symbol -2680..2685 ; Extended_Pictographic# 3.2 [6] (⚀..⚅) DIE FACE-1..DIE FACE-6 -2690..2691 ; Extended_Pictographic# 4.0 [2] (⚐..⚑) WHITE FLAG..BLACK FLAG -2692..269C ; Extended_Pictographic# 4.1 [11] (⚒️..⚜️) hammer and pick..fleur-de-lis -269D ; Extended_Pictographic# 5.1 [1] (⚝) OUTLINED WHITE STAR -269E..269F ; Extended_Pictographic# 5.2 [2] (⚞..⚟) THREE LINES CONVERGING RIGHT..THREE LINES CONVERGING LEFT -26A0..26A1 ; Extended_Pictographic# 4.0 [2] (⚠️..⚡) warning..high voltage -26A2..26B1 ; Extended_Pictographic# 4.1 [16] (⚢..⚱️) DOUBLED FEMALE SIGN..funeral urn -26B2 ; Extended_Pictographic# 5.0 [1] (⚲) NEUTER -26B3..26BC ; Extended_Pictographic# 5.1 [10] (⚳..⚼) CERES..SESQUIQUADRATE -26BD..26BF ; Extended_Pictographic# 5.2 [3] (⚽..⚿) soccer ball..SQUARED KEY -26C0..26C3 ; Extended_Pictographic# 5.1 [4] (⛀..⛃) WHITE DRAUGHTS MAN..BLACK DRAUGHTS KING -26C4..26CD ; Extended_Pictographic# 5.2 [10] (⛄..⛍) snowman without snow..DISABLED CAR -26CE ; Extended_Pictographic# 6.0 [1] (⛎) Ophiuchus -26CF..26E1 ; Extended_Pictographic# 5.2 [19] (⛏️..⛡) pick..RESTRICTED LEFT ENTRY-2 -26E2 ; Extended_Pictographic# 6.0 [1] (⛢) ASTRONOMICAL SYMBOL FOR URANUS -26E3 ; Extended_Pictographic# 5.2 [1] (⛣) HEAVY CIRCLE WITH STROKE AND TWO DOTS ABOVE -26E4..26E7 ; Extended_Pictographic# 6.0 [4] (⛤..⛧) PENTAGRAM..INVERTED PENTAGRAM -26E8..26FF ; Extended_Pictographic# 5.2 [24] (⛨..⛿) BLACK CROSS ON SHIELD..WHITE FLAG WITH HORIZONTAL MIDDLE BLACK STRIPE -2700 ; Extended_Pictographic# 7.0 [1] (✀) BLACK SAFETY SCISSORS -2701..2704 ; Extended_Pictographic# 1.1 [4] (✁..✄) UPPER BLADE SCISSORS..WHITE SCISSORS -2705 ; Extended_Pictographic# 6.0 [1] (✅) check mark button -2708..2709 ; Extended_Pictographic# 1.1 [2] (✈️..✉️) airplane..envelope -270A..270B ; Extended_Pictographic# 6.0 [2] (✊..✋) raised fist..raised hand -270C..2712 ; Extended_Pictographic# 1.1 [7] (✌️..✒️) victory hand..black nib -2714 ; Extended_Pictographic# 1.1 [1] (✔️) check mark -2716 ; Extended_Pictographic# 1.1 [1] (✖️) multiplication sign -271D ; Extended_Pictographic# 1.1 [1] (✝️) latin cross -2721 ; Extended_Pictographic# 1.1 [1] (✡️) star of David -2728 ; Extended_Pictographic# 6.0 [1] (✨) sparkles -2733..2734 ; Extended_Pictographic# 1.1 [2] (✳️..✴️) eight-spoked asterisk..eight-pointed star -2744 ; Extended_Pictographic# 1.1 [1] (❄️) snowflake -2747 ; Extended_Pictographic# 1.1 [1] (❇️) sparkle -274C ; Extended_Pictographic# 6.0 [1] (❌) cross mark -274E ; Extended_Pictographic# 6.0 [1] (❎) cross mark button -2753..2755 ; Extended_Pictographic# 6.0 [3] (❓..❕) question mark..white exclamation mark -2757 ; Extended_Pictographic# 5.2 [1] (❗) exclamation mark -2763..2767 ; Extended_Pictographic# 1.1 [5] (❣️..❧) heart exclamation..ROTATED FLORAL HEART BULLET -2795..2797 ; Extended_Pictographic# 6.0 [3] (➕..➗) plus sign..division sign -27A1 ; Extended_Pictographic# 1.1 [1] (➡️) right arrow -27B0 ; Extended_Pictographic# 6.0 [1] (➰) curly loop -27BF ; Extended_Pictographic# 6.0 [1] (➿) double curly loop -2934..2935 ; Extended_Pictographic# 3.2 [2] (⤴️..⤵️) right arrow curving up..right arrow curving down -2B05..2B07 ; Extended_Pictographic# 4.0 [3] (⬅️..⬇️) left arrow..down arrow -2B1B..2B1C ; Extended_Pictographic# 5.1 [2] (⬛..⬜) black large square..white large square -2B50 ; Extended_Pictographic# 5.1 [1] (⭐) star -2B55 ; Extended_Pictographic# 5.2 [1] (⭕) hollow red circle -3030 ; Extended_Pictographic# 1.1 [1] (〰️) wavy dash -303D ; Extended_Pictographic# 3.2 [1] (〽️) part alternation mark -3297 ; Extended_Pictographic# 1.1 [1] (㊗️) Japanese “congratulations” button -3299 ; Extended_Pictographic# 1.1 [1] (㊙️) Japanese “secret” button -1F000..1F02B ; Extended_Pictographic# 5.1 [44] (🀀..🀫) MAHJONG TILE EAST WIND..MAHJONG TILE BACK -1F02C..1F02F ; Extended_Pictographic# NA [4] (🀬..🀯) .. -1F030..1F093 ; Extended_Pictographic# 5.1[100] (🀰..🂓) DOMINO TILE HORIZONTAL BACK..DOMINO TILE VERTICAL-06-06 -1F094..1F09F ; Extended_Pictographic# NA [12] (🂔..🂟) .. -1F0A0..1F0AE ; Extended_Pictographic# 6.0 [15] (🂠..🂮) PLAYING CARD BACK..PLAYING CARD KING OF SPADES -1F0AF..1F0B0 ; Extended_Pictographic# NA [2] (🂯..🂰) .. -1F0B1..1F0BE ; Extended_Pictographic# 6.0 [14] (🂱..🂾) PLAYING CARD ACE OF HEARTS..PLAYING CARD KING OF HEARTS -1F0BF ; Extended_Pictographic# 7.0 [1] (🂿) PLAYING CARD RED JOKER -1F0C0 ; Extended_Pictographic# NA [1] (🃀) -1F0C1..1F0CF ; Extended_Pictographic# 6.0 [15] (🃁..🃏) PLAYING CARD ACE OF DIAMONDS..joker -1F0D0 ; Extended_Pictographic# NA [1] (🃐) -1F0D1..1F0DF ; Extended_Pictographic# 6.0 [15] (🃑..🃟) PLAYING CARD ACE OF CLUBS..PLAYING CARD WHITE JOKER -1F0E0..1F0F5 ; Extended_Pictographic# 7.0 [22] (🃠..🃵) PLAYING CARD FOOL..PLAYING CARD TRUMP-21 -1F0F6..1F0FF ; Extended_Pictographic# NA [10] (🃶..🃿) .. -1F10D..1F10F ; Extended_Pictographic# NA [3] (🄍..🄏) .. -1F12F ; Extended_Pictographic# 11.0 [1] (🄯) COPYLEFT SYMBOL -1F16C ; Extended_Pictographic# 12.0 [1] (🅬) RAISED MR SIGN -1F16D..1F16F ; Extended_Pictographic# NA [3] (🅭..🅯) .. -1F170..1F171 ; Extended_Pictographic# 6.0 [2] (🅰️..🅱️) A button (blood type)..B button (blood type) -1F17E ; Extended_Pictographic# 6.0 [1] (🅾️) O button (blood type) -1F17F ; Extended_Pictographic# 5.2 [1] (🅿️) P button -1F18E ; Extended_Pictographic# 6.0 [1] (🆎) AB button (blood type) -1F191..1F19A ; Extended_Pictographic# 6.0 [10] (🆑..🆚) CL button..VS button -1F1AD..1F1E5 ; Extended_Pictographic# NA [57] (🆭..🇥) .. -1F201..1F202 ; Extended_Pictographic# 6.0 [2] (🈁..🈂️) Japanese “here” button..Japanese “service charge” button -1F203..1F20F ; Extended_Pictographic# NA [13] (🈃..🈏) .. -1F21A ; Extended_Pictographic# 5.2 [1] (🈚) Japanese “free of charge” button -1F22F ; Extended_Pictographic# 5.2 [1] (🈯) Japanese “reserved” button -1F232..1F23A ; Extended_Pictographic# 6.0 [9] (🈲..🈺) Japanese “prohibited” button..Japanese “open for business” button -1F23C..1F23F ; Extended_Pictographic# NA [4] (🈼..🈿) .. -1F249..1F24F ; Extended_Pictographic# NA [7] (🉉..🉏) .. -1F250..1F251 ; Extended_Pictographic# 6.0 [2] (🉐..🉑) Japanese “bargain” button..Japanese “acceptable” button -1F252..1F25F ; Extended_Pictographic# NA [14] (🉒..🉟) .. -1F260..1F265 ; Extended_Pictographic# 10.0 [6] (🉠..🉥) ROUNDED SYMBOL FOR FU..ROUNDED SYMBOL FOR CAI -1F266..1F2FF ; Extended_Pictographic# NA[154] (🉦..🋿) .. -1F300..1F320 ; Extended_Pictographic# 6.0 [33] (🌀..🌠) cyclone..shooting star -1F321..1F32C ; Extended_Pictographic# 7.0 [12] (🌡️..🌬️) thermometer..wind face -1F32D..1F32F ; Extended_Pictographic# 8.0 [3] (🌭..🌯) hot dog..burrito -1F330..1F335 ; Extended_Pictographic# 6.0 [6] (🌰..🌵) chestnut..cactus -1F336 ; Extended_Pictographic# 7.0 [1] (🌶️) hot pepper -1F337..1F37C ; Extended_Pictographic# 6.0 [70] (🌷..🍼) tulip..baby bottle -1F37D ; Extended_Pictographic# 7.0 [1] (🍽️) fork and knife with plate -1F37E..1F37F ; Extended_Pictographic# 8.0 [2] (🍾..🍿) bottle with popping cork..popcorn -1F380..1F393 ; Extended_Pictographic# 6.0 [20] (🎀..🎓) ribbon..graduation cap -1F394..1F39F ; Extended_Pictographic# 7.0 [12] (🎔..🎟️) HEART WITH TIP ON THE LEFT..admission tickets -1F3A0..1F3C4 ; Extended_Pictographic# 6.0 [37] (🎠..🏄) carousel horse..person surfing -1F3C5 ; Extended_Pictographic# 7.0 [1] (🏅) sports medal -1F3C6..1F3CA ; Extended_Pictographic# 6.0 [5] (🏆..🏊) trophy..person swimming -1F3CB..1F3CE ; Extended_Pictographic# 7.0 [4] (🏋️..🏎️) person lifting weights..racing car -1F3CF..1F3D3 ; Extended_Pictographic# 8.0 [5] (🏏..🏓) cricket game..ping pong -1F3D4..1F3DF ; Extended_Pictographic# 7.0 [12] (🏔️..🏟️) snow-capped mountain..stadium -1F3E0..1F3F0 ; Extended_Pictographic# 6.0 [17] (🏠..🏰) house..castle -1F3F1..1F3F7 ; Extended_Pictographic# 7.0 [7] (🏱..🏷️) WHITE PENNANT..label -1F3F8..1F3FA ; Extended_Pictographic# 8.0 [3] (🏸..🏺) badminton..amphora -1F400..1F43E ; Extended_Pictographic# 6.0 [63] (🐀..🐾) rat..paw prints -1F43F ; Extended_Pictographic# 7.0 [1] (🐿️) chipmunk -1F440 ; Extended_Pictographic# 6.0 [1] (👀) eyes -1F441 ; Extended_Pictographic# 7.0 [1] (👁️) eye -1F442..1F4F7 ; Extended_Pictographic# 6.0[182] (👂..📷) ear..camera -1F4F8 ; Extended_Pictographic# 7.0 [1] (📸) camera with flash -1F4F9..1F4FC ; Extended_Pictographic# 6.0 [4] (📹..📼) video camera..videocassette -1F4FD..1F4FE ; Extended_Pictographic# 7.0 [2] (📽️..📾) film projector..PORTABLE STEREO -1F4FF ; Extended_Pictographic# 8.0 [1] (📿) prayer beads -1F500..1F53D ; Extended_Pictographic# 6.0 [62] (🔀..🔽) shuffle tracks button..downwards button -1F546..1F54A ; Extended_Pictographic# 7.0 [5] (🕆..🕊️) WHITE LATIN CROSS..dove -1F54B..1F54F ; Extended_Pictographic# 8.0 [5] (🕋..🕏) kaaba..BOWL OF HYGIEIA -1F550..1F567 ; Extended_Pictographic# 6.0 [24] (🕐..🕧) one o’clock..twelve-thirty -1F568..1F579 ; Extended_Pictographic# 7.0 [18] (🕨..🕹️) RIGHT SPEAKER..joystick -1F57A ; Extended_Pictographic# 9.0 [1] (🕺) man dancing -1F57B..1F5A3 ; Extended_Pictographic# 7.0 [41] (🕻..🖣) LEFT HAND TELEPHONE RECEIVER..BLACK DOWN POINTING BACKHAND INDEX -1F5A4 ; Extended_Pictographic# 9.0 [1] (🖤) black heart -1F5A5..1F5FA ; Extended_Pictographic# 7.0 [86] (🖥️..🗺️) desktop computer..world map -1F5FB..1F5FF ; Extended_Pictographic# 6.0 [5] (🗻..🗿) mount fuji..moai -1F600 ; Extended_Pictographic# 6.1 [1] (😀) grinning face -1F601..1F610 ; Extended_Pictographic# 6.0 [16] (😁..😐) beaming face with smiling eyes..neutral face -1F611 ; Extended_Pictographic# 6.1 [1] (😑) expressionless face -1F612..1F614 ; Extended_Pictographic# 6.0 [3] (😒..😔) unamused face..pensive face -1F615 ; Extended_Pictographic# 6.1 [1] (😕) confused face -1F616 ; Extended_Pictographic# 6.0 [1] (😖) confounded face -1F617 ; Extended_Pictographic# 6.1 [1] (😗) kissing face -1F618 ; Extended_Pictographic# 6.0 [1] (😘) face blowing a kiss -1F619 ; Extended_Pictographic# 6.1 [1] (😙) kissing face with smiling eyes -1F61A ; Extended_Pictographic# 6.0 [1] (😚) kissing face with closed eyes -1F61B ; Extended_Pictographic# 6.1 [1] (😛) face with tongue -1F61C..1F61E ; Extended_Pictographic# 6.0 [3] (😜..😞) winking face with tongue..disappointed face -1F61F ; Extended_Pictographic# 6.1 [1] (😟) worried face -1F620..1F625 ; Extended_Pictographic# 6.0 [6] (😠..😥) angry face..sad but relieved face -1F626..1F627 ; Extended_Pictographic# 6.1 [2] (😦..😧) frowning face with open mouth..anguished face -1F628..1F62B ; Extended_Pictographic# 6.0 [4] (😨..😫) fearful face..tired face -1F62C ; Extended_Pictographic# 6.1 [1] (😬) grimacing face -1F62D ; Extended_Pictographic# 6.0 [1] (😭) loudly crying face -1F62E..1F62F ; Extended_Pictographic# 6.1 [2] (😮..😯) face with open mouth..hushed face -1F630..1F633 ; Extended_Pictographic# 6.0 [4] (😰..😳) anxious face with sweat..flushed face -1F634 ; Extended_Pictographic# 6.1 [1] (😴) sleeping face -1F635..1F640 ; Extended_Pictographic# 6.0 [12] (😵..🙀) dizzy face..weary cat -1F641..1F642 ; Extended_Pictographic# 7.0 [2] (🙁..🙂) slightly frowning face..slightly smiling face -1F643..1F644 ; Extended_Pictographic# 8.0 [2] (🙃..🙄) upside-down face..face with rolling eyes -1F645..1F64F ; Extended_Pictographic# 6.0 [11] (🙅..🙏) person gesturing NO..folded hands -1F680..1F6C5 ; Extended_Pictographic# 6.0 [70] (🚀..🛅) rocket..left luggage -1F6C6..1F6CF ; Extended_Pictographic# 7.0 [10] (🛆..🛏️) TRIANGLE WITH ROUNDED CORNERS..bed -1F6D0 ; Extended_Pictographic# 8.0 [1] (🛐) place of worship -1F6D1..1F6D2 ; Extended_Pictographic# 9.0 [2] (🛑..🛒) stop sign..shopping cart -1F6D3..1F6D4 ; Extended_Pictographic# 10.0 [2] (🛓..🛔) STUPA..PAGODA -1F6D5 ; Extended_Pictographic# 12.0 [1] (🛕) hindu temple -1F6D6..1F6DF ; Extended_Pictographic# NA [10] (🛖..🛟) .. -1F6E0..1F6EC ; Extended_Pictographic# 7.0 [13] (🛠️..🛬) hammer and wrench..airplane arrival -1F6ED..1F6EF ; Extended_Pictographic# NA [3] (🛭..🛯) .. -1F6F0..1F6F3 ; Extended_Pictographic# 7.0 [4] (🛰️..🛳️) satellite..passenger ship -1F6F4..1F6F6 ; Extended_Pictographic# 9.0 [3] (🛴..🛶) kick scooter..canoe -1F6F7..1F6F8 ; Extended_Pictographic# 10.0 [2] (🛷..🛸) sled..flying saucer -1F6F9 ; Extended_Pictographic# 11.0 [1] (🛹) skateboard -1F6FA ; Extended_Pictographic# 12.0 [1] (🛺) auto rickshaw -1F6FB..1F6FF ; Extended_Pictographic# NA [5] (🛻..🛿) .. -1F774..1F77F ; Extended_Pictographic# NA [12] (🝴..🝿) .. -1F7D5..1F7D8 ; Extended_Pictographic# 11.0 [4] (🟕..🟘) CIRCLED TRIANGLE..NEGATIVE CIRCLED SQUARE -1F7D9..1F7DF ; Extended_Pictographic# NA [7] (🟙..🟟) .. -1F7E0..1F7EB ; Extended_Pictographic# 12.0 [12] (🟠..🟫) orange circle..brown square -1F7EC..1F7FF ; Extended_Pictographic# NA [20] (🟬..🟿) .. -1F80C..1F80F ; Extended_Pictographic# NA [4] (🠌..🠏) .. -1F848..1F84F ; Extended_Pictographic# NA [8] (🡈..🡏) .. -1F85A..1F85F ; Extended_Pictographic# NA [6] (🡚..🡟) .. -1F888..1F88F ; Extended_Pictographic# NA [8] (🢈..🢏) .. -1F8AE..1F8FF ; Extended_Pictographic# NA [82] (🢮..🣿) .. -1F90C ; Extended_Pictographic# NA [1] (🤌) -1F90D..1F90F ; Extended_Pictographic# 12.0 [3] (🤍..🤏) white heart..pinching hand -1F910..1F918 ; Extended_Pictographic# 8.0 [9] (🤐..🤘) zipper-mouth face..sign of the horns -1F919..1F91E ; Extended_Pictographic# 9.0 [6] (🤙..🤞) call me hand..crossed fingers -1F91F ; Extended_Pictographic# 10.0 [1] (🤟) love-you gesture -1F920..1F927 ; Extended_Pictographic# 9.0 [8] (🤠..🤧) cowboy hat face..sneezing face -1F928..1F92F ; Extended_Pictographic# 10.0 [8] (🤨..🤯) face with raised eyebrow..exploding head -1F930 ; Extended_Pictographic# 9.0 [1] (🤰) pregnant woman -1F931..1F932 ; Extended_Pictographic# 10.0 [2] (🤱..🤲) breast-feeding..palms up together -1F933..1F93A ; Extended_Pictographic# 9.0 [8] (🤳..🤺) selfie..person fencing -1F93C..1F93E ; Extended_Pictographic# 9.0 [3] (🤼..🤾) people wrestling..person playing handball -1F93F ; Extended_Pictographic# 12.0 [1] (🤿) diving mask -1F940..1F945 ; Extended_Pictographic# 9.0 [6] (🥀..🥅) wilted flower..goal net -1F947..1F94B ; Extended_Pictographic# 9.0 [5] (🥇..🥋) 1st place medal..martial arts uniform -1F94C ; Extended_Pictographic# 10.0 [1] (🥌) curling stone -1F94D..1F94F ; Extended_Pictographic# 11.0 [3] (🥍..🥏) lacrosse..flying disc -1F950..1F95E ; Extended_Pictographic# 9.0 [15] (🥐..🥞) croissant..pancakes -1F95F..1F96B ; Extended_Pictographic# 10.0 [13] (🥟..🥫) dumpling..canned food -1F96C..1F970 ; Extended_Pictographic# 11.0 [5] (🥬..🥰) leafy green..smiling face with hearts -1F971 ; Extended_Pictographic# 12.0 [1] (🥱) yawning face -1F972 ; Extended_Pictographic# NA [1] (🥲) -1F973..1F976 ; Extended_Pictographic# 11.0 [4] (🥳..🥶) partying face..cold face -1F977..1F979 ; Extended_Pictographic# NA [3] (🥷..🥹) .. -1F97A ; Extended_Pictographic# 11.0 [1] (🥺) pleading face -1F97B ; Extended_Pictographic# 12.0 [1] (🥻) sari -1F97C..1F97F ; Extended_Pictographic# 11.0 [4] (🥼..🥿) lab coat..flat shoe -1F980..1F984 ; Extended_Pictographic# 8.0 [5] (🦀..🦄) crab..unicorn -1F985..1F991 ; Extended_Pictographic# 9.0 [13] (🦅..🦑) eagle..squid -1F992..1F997 ; Extended_Pictographic# 10.0 [6] (🦒..🦗) giraffe..cricket -1F998..1F9A2 ; Extended_Pictographic# 11.0 [11] (🦘..🦢) kangaroo..swan -1F9A3..1F9A4 ; Extended_Pictographic# NA [2] (🦣..🦤) .. -1F9A5..1F9AA ; Extended_Pictographic# 12.0 [6] (🦥..🦪) sloth..oyster -1F9AB..1F9AD ; Extended_Pictographic# NA [3] (🦫..🦭) .. -1F9AE..1F9AF ; Extended_Pictographic# 12.0 [2] (🦮..🦯) guide dog..probing cane -1F9B0..1F9B9 ; Extended_Pictographic# 11.0 [10] (🦰..🦹) red hair..supervillain -1F9BA..1F9BF ; Extended_Pictographic# 12.0 [6] (🦺..🦿) safety vest..mechanical leg -1F9C0 ; Extended_Pictographic# 8.0 [1] (🧀) cheese wedge -1F9C1..1F9C2 ; Extended_Pictographic# 11.0 [2] (🧁..🧂) cupcake..salt -1F9C3..1F9CA ; Extended_Pictographic# 12.0 [8] (🧃..🧊) beverage box..ice cube -1F9CB..1F9CC ; Extended_Pictographic# NA [2] (🧋..🧌) .. -1F9CD..1F9CF ; Extended_Pictographic# 12.0 [3] (🧍..🧏) person standing..deaf person -1F9D0..1F9E6 ; Extended_Pictographic# 10.0 [23] (🧐..🧦) face with monocle..socks -1F9E7..1F9FF ; Extended_Pictographic# 11.0 [25] (🧧..🧿) red envelope..nazar amulet -1FA00..1FA53 ; Extended_Pictographic# 12.0 [84] (🨀..🩓) NEUTRAL CHESS KING..BLACK CHESS KNIGHT-BISHOP -1FA54..1FA5F ; Extended_Pictographic# NA [12] (🩔..🩟) .. -1FA60..1FA6D ; Extended_Pictographic# 11.0 [14] (🩠..🩭) XIANGQI RED GENERAL..XIANGQI BLACK SOLDIER -1FA6E..1FA6F ; Extended_Pictographic# NA [2] (🩮..🩯) .. -1FA70..1FA73 ; Extended_Pictographic# 12.0 [4] (🩰..🩳) ballet shoes..shorts -1FA74..1FA77 ; Extended_Pictographic# NA [4] (🩴..🩷) .. -1FA78..1FA7A ; Extended_Pictographic# 12.0 [3] (🩸..🩺) drop of blood..stethoscope -1FA7B..1FA7F ; Extended_Pictographic# NA [5] (🩻..🩿) .. -1FA80..1FA82 ; Extended_Pictographic# 12.0 [3] (🪀..🪂) yo-yo..parachute -1FA83..1FA8F ; Extended_Pictographic# NA [13] (🪃..🪏) .. -1FA90..1FA95 ; Extended_Pictographic# 12.0 [6] (🪐..🪕) ringed planet..banjo -1FA96..1FFFD ; Extended_Pictographic# NA[1384] (🪖..🿽) .. - -# Total elements: 3793 - -#EOF -- cgit v1.2.3 From c9afb350e7a38aa915418c6b98cedd863ca0405b Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 2 Dec 2020 19:16:36 +0400 Subject: Document follow relationship updates and cleanup --- lib/pleroma/following_relationship.ex | 2 +- lib/pleroma/web/streamer.ex | 21 +++++++++------------ lib/pleroma/web/views/streamer_view.ex | 4 ++-- 3 files changed, 12 insertions(+), 15 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index bc6a7eaf9..5390a58e1 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -96,7 +96,7 @@ defmodule Pleroma.FollowingRelationship do defp after_update(state, %User{} = follower, %User{} = following) do with {:ok, following} <- User.update_follower_count(following), {:ok, follower} <- User.update_following_count(follower) do - Pleroma.Web.Streamer.stream("relationships:update", %{ + Pleroma.Web.Streamer.stream("follow_relationship", %{ state: state, following: following, follower: follower diff --git a/lib/pleroma/web/streamer.ex b/lib/pleroma/web/streamer.ex index 0b6cc89e9..7d4a1304a 100644 --- a/lib/pleroma/web/streamer.ex +++ b/lib/pleroma/web/streamer.ex @@ -186,18 +186,15 @@ defmodule Pleroma.Web.Streamer do end) end - defp do_stream("relationships:update", item) do - text = StreamerView.render("relationships_update.json", item) - - [item.follower, item.following] - |> Enum.map(fn %{id: id} -> "user:#{id}" end) - |> Enum.each(fn user_topic -> - Logger.debug("Trying to push relationships:update to #{user_topic}\n\n") - - Registry.dispatch(@registry, user_topic, fn list -> - Enum.each(list, fn {pid, _auth} -> - send(pid, {:text, text}) - end) + defp do_stream("follow_relationship", item) do + text = StreamerView.render("follow_relationships_update.json", item) + user_topic = "user:#{item.follower.id}" + + Logger.debug("Trying to push follow relationship update to #{user_topic}\n\n") + + Registry.dispatch(@registry, user_topic, fn list -> + Enum.each(list, fn {pid, _auth} -> + send(pid, {:text, text}) end) end) end diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index 92239a411..4fc14166d 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -74,9 +74,9 @@ defmodule Pleroma.Web.StreamerView do |> Jason.encode!() end - def render("relationships_update.json", item) do + def render("follow_relationships_update.json", item) do %{ - event: "pleroma:relationships_update", + event: "pleroma:follow_relationships_update", payload: %{ state: item.state, -- cgit v1.2.3 From 8fb259e7395d4dd2bdac407b7eca0840ce490a99 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 2 Dec 2020 16:46:19 +0100 Subject: Emoji: Only accept RGI emoji. "recommended for general interchange" --- lib/pleroma/emoji.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/emoji.ex b/lib/pleroma/emoji.ex index 98644f84e..201212779 100644 --- a/lib/pleroma/emoji.ex +++ b/lib/pleroma/emoji.ex @@ -108,7 +108,10 @@ defmodule Pleroma.Emoji do @external_resource |> File.read!() |> String.split("\n") - |> Enum.filter(fn line -> line != "" and not String.starts_with?(line, "#") end) + |> Enum.filter(fn line -> + line != "" and not String.starts_with?(line, "#") and + String.contains?(line, "fully-qualified") + end) |> Enum.map(fn line -> line |> String.split(";", parts: 2) -- cgit v1.2.3 From 6dcc36baa9b19d18785d6f7ab8ceb7dd941c6180 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 25 Nov 2020 12:44:06 -0600 Subject: Add mix tasks to give additional recovery and debugging options - pleroma.config dump: prints the entire config as it would be exported to the filesystem - pleroma.config dump KEY: prints the configuration under a specific ConfigDB key in the database - pleroma.config keylist: lists the available keys in ConfigDB - pleroma.config keydel KEY: deletes ConfigDB entry stored under the key This should prevent the need for users to manually execute SQL queries. --- lib/mix/tasks/pleroma/config.ex | 89 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 18f99318d..b49854528 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -30,6 +30,83 @@ defmodule Mix.Tasks.Pleroma.Config do migrate_from_db(opts) end + def run(["dump"]) do + with true <- Pleroma.Config.get([:configurable_from_database]) do + start_pleroma() + + header = config_header() + + shell_info("#{header}") + + ConfigDB + |> Repo.all() + |> Enum.each(&dump(&1)) + else + _ -> configdb_not_enabled() + end + end + + def run(["dump" | dbkey]) do + with true <- Pleroma.Config.get([:configurable_from_database]) do + start_pleroma() + + dbkey = dbkey |> List.first() |> String.to_atom() + + ConfigDB + |> Repo.all() + |> Enum.filter(fn x -> + if x.key == dbkey do + x |> dump + end + end) + else + _ -> configdb_not_enabled() + end + end + + def run(["keylist"]) do + with true <- Pleroma.Config.get([:configurable_from_database]) do + start_pleroma() + + keys = + ConfigDB + |> Repo.all() + |> Enum.map(fn x -> x.key end) + + if length(keys) > 0 do + shell_info("The following configuration keys are set in ConfigDB:\r\n") + keys |> Enum.each(fn x -> shell_info("- #{x}") end) + shell_info("\r\n") + end + else + _ -> configdb_not_enabled() + end + end + + def run(["keydel" | dbkey]) do + unless [] == dbkey do + with true <- Pleroma.Config.get([:configurable_from_database]) do + start_pleroma() + + dbkey = dbkey |> List.first() |> String.to_atom() + + ConfigDB + |> Repo.all() + |> Enum.filter(fn x -> + if x.key == dbkey do + x |> delete(true) + end + end) + else + _ -> configdb_not_enabled() + end + else + shell_error( + "You must provide a key to delete. Use the keylist command to get a list of valid keys." + ) + end + end + @spec migrate_to_db(Path.t() | nil) :: any() def migrate_to_db(file_path \\ nil) do with true <- Pleroma.Config.get([:configurable_from_database]), @@ -154,4 +231,16 @@ defmodule Mix.Tasks.Pleroma.Config do end defp delete(_config, _), do: :ok + + defp dump(%Pleroma.ConfigDB{} = config) do + value = inspect(config.value, limit: :infinity) + + shell_info("config #{inspect(config.group)}, #{inspect(config.key)}, #{value}\r\n\r\n") + end + + defp configdb_not_enabled do + shell_error( + "ConfigDB not enabled. Please check the value of :configurable_from_database in your configuration." + ) + end end -- cgit v1.2.3 From a82ba66662fdcdccf0de384b0f57dd20bef0fd9d Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 25 Nov 2020 17:16:23 -0600 Subject: Better deletion message --- lib/mix/tasks/pleroma/config.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index b49854528..675dda0d0 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -227,7 +227,7 @@ defmodule Mix.Tasks.Pleroma.Config do defp delete(config, true) do {:ok, _} = Repo.delete(config) - shell_info("#{config.key} deleted from DB.") + shell_info("#{config.key} deleted from the ConfigDB.") end defp delete(_config, _), do: :ok -- cgit v1.2.3 From e8a4062d9dc042253adc05f2ab964bbd468ace12 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 25 Nov 2020 17:31:44 -0600 Subject: Document how to delete individual configuration groups and completely reset the config without SQL --- lib/mix/tasks/pleroma/config.ex | 13 +++++++++++++ 1 file changed, 13 insertions(+) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 675dda0d0..574f8f4be 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -83,6 +83,19 @@ defmodule Mix.Tasks.Pleroma.Config do end end + def run(["reset"]) do + with true <- Pleroma.Config.get([:configurable_from_database]) do + start_pleroma() + + Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;") + Ecto.Adapters.SQL.query!(Repo, "ALTER SEQUENCE config_id_seq RESTART;") + + shell_info("The ConfigDB settings have been removed from the database.") + else + _ -> configdb_not_enabled() + end + end + def run(["keydel" | dbkey]) do unless [] == dbkey do with true <- Pleroma.Config.get([:configurable_from_database]) do -- cgit v1.2.3 From ada073f2511ae57eb22dc9e8a4220b2382b9f97c Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 25 Nov 2020 17:49:36 -0600 Subject: Rename keys to groups --- lib/mix/tasks/pleroma/config.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 574f8f4be..3c94f1f5f 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -64,7 +64,7 @@ defmodule Mix.Tasks.Pleroma.Config do end end - def run(["keylist"]) do + def run(["groups"]) do with true <- Pleroma.Config.get([:configurable_from_database]) do start_pleroma() @@ -96,7 +96,7 @@ defmodule Mix.Tasks.Pleroma.Config do end end - def run(["keydel" | dbkey]) do + def run(["groupdel" | dbkey]) do unless [] == dbkey do with true <- Pleroma.Config.get([:configurable_from_database]) do start_pleroma() @@ -115,7 +115,7 @@ defmodule Mix.Tasks.Pleroma.Config do end else shell_error( - "You must provide a key to delete. Use the keylist command to get a list of valid keys." + "You must provide a group to delete. Use the groups command to get a list of valid configDB groups." ) end end -- cgit v1.2.3 From a51da3c1d8355de0747605608fc929f5fa345b3f Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 27 Nov 2020 12:32:53 -0600 Subject: Sort output by group Not the best sorting, but better than nothing. --- lib/mix/tasks/pleroma/config.ex | 1 + 1 file changed, 1 insertion(+) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 3c94f1f5f..76753e13c 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -40,6 +40,7 @@ defmodule Mix.Tasks.Pleroma.Config do ConfigDB |> Repo.all() + |> Enum.sort() |> Enum.each(&dump(&1)) else _ -> configdb_not_enabled() -- cgit v1.2.3 From 67437feafc048e56d023370266fe3762405f3199 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 27 Nov 2020 12:33:55 -0600 Subject: Support listing groups, listing keys in a group, and dumping the config based on group or specific key in that group --- lib/mix/tasks/pleroma/config.ex | 75 ++++++++++++++++++++++++++++++++++------- 1 file changed, 63 insertions(+), 12 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 76753e13c..5c01b21f8 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -47,35 +47,61 @@ defmodule Mix.Tasks.Pleroma.Config do end end - def run(["dump" | dbkey]) do + def run(["dump" | args]) when is_list(args) and length(args) < 3 do with true <- Pleroma.Config.get([:configurable_from_database]) do start_pleroma() - dbkey = dbkey |> List.first() |> String.to_atom() - - ConfigDB - |> Repo.all() - |> Enum.filter(fn x -> - if x.key == dbkey do - x |> dump - end - end) + if length(args) > 1 do + [group, key] = args + dump_key(group, key) + else + [group] = args + dump_group(group) + end else _ -> configdb_not_enabled() end end def run(["groups"]) do + with true <- Pleroma.Config.get([:configurable_from_database]) do + start_pleroma() + + groups = + ConfigDB + |> Repo.all() + |> Enum.map(fn x -> x.group end) + |> Enum.sort() + |> Enum.uniq() + + if length(groups) > 0 do + shell_info("The following configuration groups are set in ConfigDB:\r\n") + groups |> Enum.each(fn x -> shell_info("- #{x}") end) + shell_info("\r\n") + end + else + _ -> configdb_not_enabled() + end + end + + def run(["keys" | group]) do with true <- Pleroma.Config.get([:configurable_from_database]) do start_pleroma() keys = ConfigDB |> Repo.all() - |> Enum.map(fn x -> x.key end) + |> Enum.map(fn x -> + if x.group == group do + x.key + end + end) + |> Enum.sort() + |> Enum.uniq() + |> Enum.reject(fn x -> x == nil end) if length(keys) > 0 do - shell_info("The following configuration keys are set in ConfigDB:\r\n") + shell_info("The following configuration keys under :#{group} are set in ConfigDB:\r\n") keys |> Enum.each(fn x -> shell_info("- #{x}") end) shell_info("\r\n") end @@ -257,4 +283,29 @@ defmodule Mix.Tasks.Pleroma.Config do "ConfigDB not enabled. Please check the value of :configurable_from_database in your configuration." ) end + + defp dump_key(group, key) do + group = group |> String.to_atom() + key = key |> String.to_atom() + + ConfigDB + |> Repo.all() + |> Enum.filter(fn x -> + if x.group == group && x.key == key do + x |> dump + end + end) + end + + defp dump_group(group) do + group = group |> String.to_atom() + + ConfigDB + |> Repo.all() + |> Enum.filter(fn x -> + if x.group == group do + x |> dump + end + end) + end end -- cgit v1.2.3 From c6a0ca2213be0eac1233ae28c11e563109771c85 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 27 Nov 2020 13:55:43 -0600 Subject: Improve dumping groups and specific keys; add prompts for delete and reset --- lib/mix/tasks/pleroma/config.ex | 48 +++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 21 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 5c01b21f8..a794344cb 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -47,17 +47,21 @@ defmodule Mix.Tasks.Pleroma.Config do end end - def run(["dump" | args]) when is_list(args) and length(args) < 3 do + def run(["dump", group, key]) do with true <- Pleroma.Config.get([:configurable_from_database]) do start_pleroma() - if length(args) > 1 do - [group, key] = args - dump_key(group, key) - else - [group] = args - dump_group(group) - end + dump_key(group, key) + else + _ -> configdb_not_enabled() + end + end + + def run(["dump", group]) do + with true <- Pleroma.Config.get([:configurable_from_database]) do + start_pleroma() + + dump_group(group) else _ -> configdb_not_enabled() end @@ -114,36 +118,38 @@ defmodule Mix.Tasks.Pleroma.Config do with true <- Pleroma.Config.get([:configurable_from_database]) do start_pleroma() - Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;") - Ecto.Adapters.SQL.query!(Repo, "ALTER SEQUENCE config_id_seq RESTART;") + if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do + Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;") + Ecto.Adapters.SQL.query!(Repo, "ALTER SEQUENCE config_id_seq RESTART;") - shell_info("The ConfigDB settings have been removed from the database.") + shell_info("The ConfigDB settings have been removed from the database.") + else + shell_info("No changes made.") + end else _ -> configdb_not_enabled() end end - def run(["groupdel" | dbkey]) do - unless [] == dbkey do - with true <- Pleroma.Config.get([:configurable_from_database]) do - start_pleroma() + def run(["delete" | args]) when is_list(args) and length(args) == 2 do + with true <- Pleroma.Config.get([:configurable_from_database]) do + start_pleroma() - dbkey = dbkey |> List.first() |> String.to_atom() + [group, key] = args + if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do ConfigDB |> Repo.all() |> Enum.filter(fn x -> - if x.key == dbkey do + if x.group == group and x.key == key do x |> delete(true) end end) else - _ -> configdb_not_enabled() + shell_info("No changes made.") end else - shell_error( - "You must provide a group to delete. Use the groups command to get a list of valid configDB groups." - ) + _ -> configdb_not_enabled() end end -- cgit v1.2.3 From ae7d37de0665021373d9bc4d01d648c7d812eaed Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 27 Nov 2020 14:02:45 -0600 Subject: Fix deletion regression due to strings instead of atoms Improve message after successful deletion --- lib/mix/tasks/pleroma/config.ex | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index a794344cb..e5536d16a 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -131,11 +131,34 @@ defmodule Mix.Tasks.Pleroma.Config do end end - def run(["delete" | args]) when is_list(args) and length(args) == 2 do + def run(["delete", group]) do with true <- Pleroma.Config.get([:configurable_from_database]) do start_pleroma() - [group, key] = args + group = group |> String.to_atom() + + if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do + ConfigDB + |> Repo.all() + |> Enum.filter(fn x -> + if x.group == group do + x |> delete(true) + end + end) + else + shell_info("No changes made.") + end + else + _ -> configdb_not_enabled() + end + end + + def run(["delete", group, key]) do + with true <- Pleroma.Config.get([:configurable_from_database]) do + start_pleroma() + + group = group |> String.to_atom() + key = key |> String.to_atom() if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do ConfigDB @@ -273,7 +296,7 @@ defmodule Mix.Tasks.Pleroma.Config do defp delete(config, true) do {:ok, _} = Repo.delete(config) - shell_info("#{config.key} deleted from the ConfigDB.") + shell_info(":#{config.group}, :#{config.key} deleted from the ConfigDB.") end defp delete(_config, _), do: :ok -- cgit v1.2.3 From 3df115b2b0ee9f5ca6f2507550d18002379eeaa8 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 27 Nov 2020 14:44:05 -0600 Subject: Support atoms and strings as args to the mix task Improve output. Show the user what will be deleted before the prompt. --- lib/mix/tasks/pleroma/config.ex | 95 +++++++++++++++++++++++++++++------------ 1 file changed, 67 insertions(+), 28 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index e5536d16a..078a4110b 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -36,12 +36,18 @@ defmodule Mix.Tasks.Pleroma.Config do header = config_header() - shell_info("#{header}") + settings = + ConfigDB + |> Repo.all() + |> Enum.sort() - ConfigDB - |> Repo.all() - |> Enum.sort() - |> Enum.each(&dump(&1)) + unless settings == [] do + shell_info("#{header}") + + settings |> Enum.each(&dump(&1)) + else + shell_error("No settings in ConfigDB.") + end else _ -> configdb_not_enabled() end @@ -51,6 +57,9 @@ defmodule Mix.Tasks.Pleroma.Config do with true <- Pleroma.Config.get([:configurable_from_database]) do start_pleroma() + group = atomize(group) + key = atomize(key) + dump_key(group, key) else _ -> configdb_not_enabled() @@ -61,6 +70,8 @@ defmodule Mix.Tasks.Pleroma.Config do with true <- Pleroma.Config.get([:configurable_from_database]) do start_pleroma() + group = atomize(group) + dump_group(group) else _ -> configdb_not_enabled() @@ -88,10 +99,12 @@ defmodule Mix.Tasks.Pleroma.Config do end end - def run(["keys" | group]) do + def run(["keys", group]) do with true <- Pleroma.Config.get([:configurable_from_database]) do start_pleroma() + group = atomize(group) + keys = ConfigDB |> Repo.all() @@ -124,7 +137,7 @@ defmodule Mix.Tasks.Pleroma.Config do shell_info("The ConfigDB settings have been removed from the database.") else - shell_info("No changes made.") + shell_error("No changes made.") end else _ -> configdb_not_enabled() @@ -135,18 +148,26 @@ defmodule Mix.Tasks.Pleroma.Config do with true <- Pleroma.Config.get([:configurable_from_database]) do start_pleroma() - group = group |> String.to_atom() + group = atomize(group) - if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do - ConfigDB - |> Repo.all() - |> Enum.filter(fn x -> - if x.group == group do - x |> delete(true) - end - end) + if group_exists?(group) do + shell_info("The following settings will be removed from ConfigDB:\n") + + dump_group(group) + + if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do + ConfigDB + |> Repo.all() + |> Enum.filter(fn x -> + if x.group == group do + x |> delete(true) + end + end) + else + shell_error("No changes made.") + end else - shell_info("No changes made.") + shell_error("No settings in ConfigDB for :#{group}. Aborting.") end else _ -> configdb_not_enabled() @@ -157,8 +178,8 @@ defmodule Mix.Tasks.Pleroma.Config do with true <- Pleroma.Config.get([:configurable_from_database]) do start_pleroma() - group = group |> String.to_atom() - key = key |> String.to_atom() + group = atomize(group) + key = atomize(key) if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do ConfigDB @@ -169,7 +190,7 @@ defmodule Mix.Tasks.Pleroma.Config do end end) else - shell_info("No changes made.") + shell_error("No changes made.") end else _ -> configdb_not_enabled() @@ -296,7 +317,10 @@ defmodule Mix.Tasks.Pleroma.Config do defp delete(config, true) do {:ok, _} = Repo.delete(config) - shell_info(":#{config.group}, :#{config.key} deleted from the ConfigDB.") + + shell_info( + "config #{inspect(config.group)}, #{inspect(config.key)} deleted from the ConfigDB." + ) end defp delete(_config, _), do: :ok @@ -313,10 +337,7 @@ defmodule Mix.Tasks.Pleroma.Config do ) end - defp dump_key(group, key) do - group = group |> String.to_atom() - key = key |> String.to_atom() - + defp dump_key(group, key) when is_atom(group) and is_atom(key) do ConfigDB |> Repo.all() |> Enum.filter(fn x -> @@ -326,9 +347,7 @@ defmodule Mix.Tasks.Pleroma.Config do end) end - defp dump_group(group) do - group = group |> String.to_atom() - + defp dump_group(group) when is_atom(group) do ConfigDB |> Repo.all() |> Enum.filter(fn x -> @@ -337,4 +356,24 @@ defmodule Mix.Tasks.Pleroma.Config do end end) end + + defp group_exists?(group) when is_atom(group) do + result = + ConfigDB + |> Repo.all() + |> Enum.filter(fn x -> + if x.group == group do + x + end + end) + + unless result == [] do + true + else + false + end + end + + defp atomize(x) when is_atom(x), do: x + defp atomize(x) when is_binary(x), do: String.to_atom(x) end -- cgit v1.2.3 From 4bdfcf1682f1429e72102bf9f54ddee9e7ede0bc Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 27 Nov 2020 16:20:28 -0600 Subject: Transform strings to atoms for all cases, including when the atom is a module like Pleroma.Emails.Mailer --- lib/mix/tasks/pleroma/config.ex | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 078a4110b..7ab15e60b 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -57,8 +57,8 @@ defmodule Mix.Tasks.Pleroma.Config do with true <- Pleroma.Config.get([:configurable_from_database]) do start_pleroma() - group = atomize(group) - key = atomize(key) + group = maybe_atomize(group) + key = maybe_atomize(key) dump_key(group, key) else @@ -70,7 +70,7 @@ defmodule Mix.Tasks.Pleroma.Config do with true <- Pleroma.Config.get([:configurable_from_database]) do start_pleroma() - group = atomize(group) + group = maybe_atomize(group) dump_group(group) else @@ -103,7 +103,7 @@ defmodule Mix.Tasks.Pleroma.Config do with true <- Pleroma.Config.get([:configurable_from_database]) do start_pleroma() - group = atomize(group) + group = maybe_atomize(group) keys = ConfigDB @@ -148,7 +148,7 @@ defmodule Mix.Tasks.Pleroma.Config do with true <- Pleroma.Config.get([:configurable_from_database]) do start_pleroma() - group = atomize(group) + group = maybe_atomize(group) if group_exists?(group) do shell_info("The following settings will be removed from ConfigDB:\n") @@ -178,8 +178,8 @@ defmodule Mix.Tasks.Pleroma.Config do with true <- Pleroma.Config.get([:configurable_from_database]) do start_pleroma() - group = atomize(group) - key = atomize(key) + group = maybe_atomize(group) + key = maybe_atomize(key) if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do ConfigDB @@ -337,7 +337,7 @@ defmodule Mix.Tasks.Pleroma.Config do ) end - defp dump_key(group, key) when is_atom(group) and is_atom(key) do + defp dump_key(group, key) when is_atom(group) and is_atom(key) do ConfigDB |> Repo.all() |> Enum.filter(fn x -> @@ -374,6 +374,15 @@ defmodule Mix.Tasks.Pleroma.Config do end end - defp atomize(x) when is_atom(x), do: x - defp atomize(x) when is_binary(x), do: String.to_atom(x) + def maybe_atomize(arg) when is_atom(arg), do: arg + + def maybe_atomize(arg) when is_binary(arg) do + chars = String.codepoints(arg) + + if "." in chars do + :"Elixir.#{arg}" + else + String.to_atom(arg) + end + end end -- cgit v1.2.3 From d4320e0daf7c732ba2c791cae697dea27c4919d2 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 27 Nov 2020 16:32:32 -0600 Subject: Both are really atoms --- lib/mix/tasks/pleroma/config.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 7ab15e60b..a7c307f77 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -337,7 +337,7 @@ defmodule Mix.Tasks.Pleroma.Config do ) end - defp dump_key(group, key) when is_atom(group) and is_atom(key) do + defp dump_key(group, key) when is_atom(group) and is_atom(key) do ConfigDB |> Repo.all() |> Enum.filter(fn x -> -- cgit v1.2.3 From 0847e3e496624a97c7eb933cf69a92fd84677ce0 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 27 Nov 2020 16:32:46 -0600 Subject: Print whole config when resetting and include a scary looking message. --- lib/mix/tasks/pleroma/config.ex | 9 +++++++++ 1 file changed, 9 insertions(+) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index a7c307f77..0c8170c9c 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -131,6 +131,15 @@ defmodule Mix.Tasks.Pleroma.Config do with true <- Pleroma.Config.get([:configurable_from_database]) do start_pleroma() + shell_info("The following settings will be permanently removed:") + + ConfigDB + |> Repo.all() + |> Enum.sort() + |> Enum.each(&dump(&1)) + + shell_error("THIS CANNOT BE UNDONE!") + if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;") Ecto.Adapters.SQL.query!(Repo, "ALTER SEQUENCE config_id_seq RESTART;") -- cgit v1.2.3 From d0cb73527f1bc21aa6bb6d21bfcdf58c406c5b0c Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sat, 28 Nov 2020 12:05:01 -0600 Subject: Ensure scary warning starts on a new line --- lib/mix/tasks/pleroma/config.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 0c8170c9c..fe0cd81f8 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -138,7 +138,7 @@ defmodule Mix.Tasks.Pleroma.Config do |> Enum.sort() |> Enum.each(&dump(&1)) - shell_error("THIS CANNOT BE UNDONE!") + shell_error("\nTHIS CANNOT BE UNDONE!") if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;") -- cgit v1.2.3 From 6334ba9ad3d275e9e50b30319e2dbed3aac35fac Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sat, 28 Nov 2020 12:22:30 -0600 Subject: Remove unnecessary keys command --- lib/mix/tasks/pleroma/config.ex | 28 ---------------------------- 1 file changed, 28 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index fe0cd81f8..f657adf46 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -99,34 +99,6 @@ defmodule Mix.Tasks.Pleroma.Config do end end - def run(["keys", group]) do - with true <- Pleroma.Config.get([:configurable_from_database]) do - start_pleroma() - - group = maybe_atomize(group) - - keys = - ConfigDB - |> Repo.all() - |> Enum.map(fn x -> - if x.group == group do - x.key - end - end) - |> Enum.sort() - |> Enum.uniq() - |> Enum.reject(fn x -> x == nil end) - - if length(keys) > 0 do - shell_info("The following configuration keys under :#{group} are set in ConfigDB:\r\n") - keys |> Enum.each(fn x -> shell_info("- #{x}") end) - shell_info("\r\n") - end - else - _ -> configdb_not_enabled() - end - end - def run(["reset"]) do with true <- Pleroma.Config.get([:configurable_from_database]) do start_pleroma() -- cgit v1.2.3 From 5135a8189f9e297354a1d9f61f3cb7454711923c Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sat, 28 Nov 2020 12:24:37 -0600 Subject: Use inspect instead of faking the output --- lib/mix/tasks/pleroma/config.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index f657adf46..3e1449550 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -148,7 +148,7 @@ defmodule Mix.Tasks.Pleroma.Config do shell_error("No changes made.") end else - shell_error("No settings in ConfigDB for :#{group}. Aborting.") + shell_error("No settings in ConfigDB for #{inspect(group)}. Aborting.") end else _ -> configdb_not_enabled() @@ -228,7 +228,7 @@ defmodule Mix.Tasks.Pleroma.Config do shell_info("Settings for key #{key} migrated.") end) - shell_info("Settings for group :#{group} migrated.") + shell_info("Settings for group #{inspect(group)} migrated.") end defp migrate_from_db(opts) do -- cgit v1.2.3 From 53a5ec195239b399c2bc072f754346eba3b3b6b2 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sun, 29 Nov 2020 12:59:03 -0600 Subject: Left public during debugging --- lib/mix/tasks/pleroma/config.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 3e1449550..a781f3bf1 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -355,9 +355,9 @@ defmodule Mix.Tasks.Pleroma.Config do end end - def maybe_atomize(arg) when is_atom(arg), do: arg + defp maybe_atomize(arg) when is_atom(arg), do: arg - def maybe_atomize(arg) when is_binary(arg) do + defp maybe_atomize(arg) when is_binary(arg) do chars = String.codepoints(arg) if "." in chars do -- cgit v1.2.3 From a7b5280b5b620e3548bbd387752a04c918418f61 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sun, 29 Nov 2020 13:29:36 -0600 Subject: Centralize check that configdb is enabled which now raises an exception --- lib/mix/tasks/pleroma/config.ex | 235 ++++++++++++++++++---------------------- 1 file changed, 106 insertions(+), 129 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index a781f3bf1..df4ee55c1 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -14,11 +14,13 @@ defmodule Mix.Tasks.Pleroma.Config do @moduledoc File.read!("docs/administration/CLI_tasks/config.md") def run(["migrate_to_db"]) do + check_configdb() start_pleroma() migrate_to_db() end def run(["migrate_from_db" | options]) do + check_configdb() start_pleroma() {opts, _} = @@ -31,142 +33,101 @@ defmodule Mix.Tasks.Pleroma.Config do end def run(["dump"]) do - with true <- Pleroma.Config.get([:configurable_from_database]) do - start_pleroma() + check_configdb() + start_pleroma() - header = config_header() + header = config_header() - settings = - ConfigDB - |> Repo.all() - |> Enum.sort() + settings = + ConfigDB + |> Repo.all() + |> Enum.sort() - unless settings == [] do - shell_info("#{header}") + unless settings == [] do + shell_info("#{header}") - settings |> Enum.each(&dump(&1)) - else - shell_error("No settings in ConfigDB.") - end + settings |> Enum.each(&dump(&1)) else - _ -> configdb_not_enabled() + shell_error("No settings in ConfigDB.") end end def run(["dump", group, key]) do - with true <- Pleroma.Config.get([:configurable_from_database]) do - start_pleroma() + check_configdb() + start_pleroma() - group = maybe_atomize(group) - key = maybe_atomize(key) + group = maybe_atomize(group) + key = maybe_atomize(key) - dump_key(group, key) - else - _ -> configdb_not_enabled() - end + dump_key(group, key) end def run(["dump", group]) do - with true <- Pleroma.Config.get([:configurable_from_database]) do - start_pleroma() + check_configdb() + start_pleroma() - group = maybe_atomize(group) + group = maybe_atomize(group) - dump_group(group) - else - _ -> configdb_not_enabled() - end + dump_group(group) end def run(["groups"]) do - with true <- Pleroma.Config.get([:configurable_from_database]) do - start_pleroma() + check_configdb() + start_pleroma() - groups = - ConfigDB - |> Repo.all() - |> Enum.map(fn x -> x.group end) - |> Enum.sort() - |> Enum.uniq() - - if length(groups) > 0 do - shell_info("The following configuration groups are set in ConfigDB:\r\n") - groups |> Enum.each(fn x -> shell_info("- #{x}") end) - shell_info("\r\n") - end - else - _ -> configdb_not_enabled() + groups = + ConfigDB + |> Repo.all() + |> Enum.map(fn x -> x.group end) + |> Enum.sort() + |> Enum.uniq() + + if length(groups) > 0 do + shell_info("The following configuration groups are set in ConfigDB:\r\n") + groups |> Enum.each(fn x -> shell_info("- #{x}") end) + shell_info("\r\n") end end def run(["reset"]) do - with true <- Pleroma.Config.get([:configurable_from_database]) do - start_pleroma() + check_configdb() + start_pleroma() - shell_info("The following settings will be permanently removed:") + shell_info("The following settings will be permanently removed:") - ConfigDB - |> Repo.all() - |> Enum.sort() - |> Enum.each(&dump(&1)) + ConfigDB + |> Repo.all() + |> Enum.sort() + |> Enum.each(&dump(&1)) - shell_error("\nTHIS CANNOT BE UNDONE!") + shell_error("\nTHIS CANNOT BE UNDONE!") - if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do - Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;") - Ecto.Adapters.SQL.query!(Repo, "ALTER SEQUENCE config_id_seq RESTART;") + if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do + Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;") + Ecto.Adapters.SQL.query!(Repo, "ALTER SEQUENCE config_id_seq RESTART;") - shell_info("The ConfigDB settings have been removed from the database.") - else - shell_error("No changes made.") - end + shell_info("The ConfigDB settings have been removed from the database.") else - _ -> configdb_not_enabled() + shell_error("No changes made.") end end def run(["delete", group]) do - with true <- Pleroma.Config.get([:configurable_from_database]) do - start_pleroma() - - group = maybe_atomize(group) - - if group_exists?(group) do - shell_info("The following settings will be removed from ConfigDB:\n") - - dump_group(group) + check_configdb() + start_pleroma() - if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do - ConfigDB - |> Repo.all() - |> Enum.filter(fn x -> - if x.group == group do - x |> delete(true) - end - end) - else - shell_error("No changes made.") - end - else - shell_error("No settings in ConfigDB for #{inspect(group)}. Aborting.") - end - else - _ -> configdb_not_enabled() - end - end + group = maybe_atomize(group) - def run(["delete", group, key]) do - with true <- Pleroma.Config.get([:configurable_from_database]) do - start_pleroma() + if group_exists?(group) do + shell_info("The following settings will be removed from ConfigDB:\n") - group = maybe_atomize(group) - key = maybe_atomize(key) + dump_group(group) if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do ConfigDB |> Repo.all() |> Enum.filter(fn x -> - if x.group == group and x.key == key do + if x.group == group do x |> delete(true) end end) @@ -174,14 +135,33 @@ defmodule Mix.Tasks.Pleroma.Config do shell_error("No changes made.") end else - _ -> configdb_not_enabled() + shell_error("No settings in ConfigDB for #{inspect(group)}. Aborting.") + end + end + + def run(["delete", group, key]) do + check_configdb() + start_pleroma() + + group = maybe_atomize(group) + key = maybe_atomize(key) + + if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do + ConfigDB + |> Repo.all() + |> Enum.filter(fn x -> + if x.group == group and x.key == key do + x |> delete(true) + end + end) + else + shell_error("No changes made.") end end @spec migrate_to_db(Path.t() | nil) :: any() def migrate_to_db(file_path \\ nil) do - with true <- Pleroma.Config.get([:configurable_from_database]), - :ok <- Pleroma.Config.DeprecationWarnings.warn() do + with :ok <- Pleroma.Config.DeprecationWarnings.warn() do config_file = if file_path do file_path @@ -195,8 +175,7 @@ defmodule Mix.Tasks.Pleroma.Config do do_migrate_to_db(config_file) else - :error -> deprecation_error() - _ -> migration_error() + _ -> deprecation_error() end end @@ -232,41 +211,31 @@ defmodule Mix.Tasks.Pleroma.Config do end defp migrate_from_db(opts) do - if Pleroma.Config.get([:configurable_from_database]) do - env = opts[:env] || Pleroma.Config.get(:env) - - config_path = - if Pleroma.Config.get(:release) do - :config_path - |> Pleroma.Config.get() - |> Path.dirname() - else - "config" - end - |> Path.join("#{env}.exported_from_db.secret.exs") + env = opts[:env] || Pleroma.Config.get(:env) - file = File.open!(config_path, [:write, :utf8]) + config_path = + if Pleroma.Config.get(:release) do + :config_path + |> Pleroma.Config.get() + |> Path.dirname() + else + "config" + end + |> Path.join("#{env}.exported_from_db.secret.exs") - IO.write(file, config_header()) + file = File.open!(config_path, [:write, :utf8]) - ConfigDB - |> Repo.all() - |> Enum.each(&write_and_delete(&1, file, opts[:delete])) + IO.write(file, config_header()) - :ok = File.close(file) - System.cmd("mix", ["format", config_path]) + ConfigDB + |> Repo.all() + |> Enum.each(&write_and_delete(&1, file, opts[:delete])) - shell_info( - "Database configuration settings have been exported to config/#{env}.exported_from_db.secret.exs" - ) - else - migration_error() - end - end + :ok = File.close(file) + System.cmd("mix", ["format", config_path]) - defp migration_error do - shell_error( - "Migration is not allowed in config. You can change this behavior by setting `config :pleroma, configurable_from_database: true`" + shell_info( + "Database configuration settings have been exported to config/#{env}.exported_from_db.secret.exs" ) end @@ -313,7 +282,7 @@ defmodule Mix.Tasks.Pleroma.Config do end defp configdb_not_enabled do - shell_error( + raise( "ConfigDB not enabled. Please check the value of :configurable_from_database in your configuration." ) end @@ -366,4 +335,12 @@ defmodule Mix.Tasks.Pleroma.Config do String.to_atom(arg) end end + + defp check_configdb() do + with true <- Pleroma.Config.get([:configurable_from_database]) do + :ok + else + _ -> configdb_not_enabled() + end + end end -- cgit v1.2.3 From 13947999ad28eac6668a601bf957d2e64edda9d3 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 2 Dec 2020 12:33:34 -0600 Subject: Use a callback strategy to short circuit the functions and print a nice error --- lib/mix/tasks/pleroma/config.ex | 199 +++++++++++++++++++++------------------- 1 file changed, 104 insertions(+), 95 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index df4ee55c1..d509f150e 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -14,149 +14,158 @@ defmodule Mix.Tasks.Pleroma.Config do @moduledoc File.read!("docs/administration/CLI_tasks/config.md") def run(["migrate_to_db"]) do - check_configdb() - start_pleroma() - migrate_to_db() + check_configdb(fn -> + start_pleroma() + migrate_to_db() + end) end def run(["migrate_from_db" | options]) do - check_configdb() - start_pleroma() + check_configdb(fn -> + start_pleroma() - {opts, _} = - OptionParser.parse!(options, - strict: [env: :string, delete: :boolean], - aliases: [d: :delete] - ) + {opts, _} = + OptionParser.parse!(options, + strict: [env: :string, delete: :boolean], + aliases: [d: :delete] + ) - migrate_from_db(opts) + migrate_from_db(opts) + end) end def run(["dump"]) do - check_configdb() - start_pleroma() + check_configdb(fn -> + start_pleroma() - header = config_header() + header = config_header() - settings = - ConfigDB - |> Repo.all() - |> Enum.sort() + settings = + ConfigDB + |> Repo.all() + |> Enum.sort() - unless settings == [] do - shell_info("#{header}") + unless settings == [] do + shell_info("#{header}") - settings |> Enum.each(&dump(&1)) - else - shell_error("No settings in ConfigDB.") - end + settings |> Enum.each(&dump(&1)) + else + shell_error("No settings in ConfigDB.") + end + end) end def run(["dump", group, key]) do - check_configdb() - start_pleroma() + check_configdb(fn -> + start_pleroma() - group = maybe_atomize(group) - key = maybe_atomize(key) + group = maybe_atomize(group) + key = maybe_atomize(key) - dump_key(group, key) + dump_key(group, key) + end) end def run(["dump", group]) do - check_configdb() - start_pleroma() + check_configdb(fn -> + start_pleroma() - group = maybe_atomize(group) + group = maybe_atomize(group) - dump_group(group) + dump_group(group) + end) end def run(["groups"]) do - check_configdb() - start_pleroma() - - groups = - ConfigDB - |> Repo.all() - |> Enum.map(fn x -> x.group end) - |> Enum.sort() - |> Enum.uniq() + check_configdb(fn -> + start_pleroma() - if length(groups) > 0 do - shell_info("The following configuration groups are set in ConfigDB:\r\n") - groups |> Enum.each(fn x -> shell_info("- #{x}") end) - shell_info("\r\n") - end + groups = + ConfigDB + |> Repo.all() + |> Enum.map(fn x -> x.group end) + |> Enum.sort() + |> Enum.uniq() + + if length(groups) > 0 do + shell_info("The following configuration groups are set in ConfigDB:\r\n") + groups |> Enum.each(fn x -> shell_info("- #{x}") end) + shell_info("\r\n") + end + end) end def run(["reset"]) do - check_configdb() - start_pleroma() + check_configdb(fn -> + start_pleroma() - shell_info("The following settings will be permanently removed:") + shell_info("The following settings will be permanently removed:") - ConfigDB - |> Repo.all() - |> Enum.sort() - |> Enum.each(&dump(&1)) + ConfigDB + |> Repo.all() + |> Enum.sort() + |> Enum.each(&dump(&1)) - shell_error("\nTHIS CANNOT BE UNDONE!") + shell_error("\nTHIS CANNOT BE UNDONE!") - if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do - Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;") - Ecto.Adapters.SQL.query!(Repo, "ALTER SEQUENCE config_id_seq RESTART;") + if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do + Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;") + Ecto.Adapters.SQL.query!(Repo, "ALTER SEQUENCE config_id_seq RESTART;") - shell_info("The ConfigDB settings have been removed from the database.") - else - shell_error("No changes made.") - end + shell_info("The ConfigDB settings have been removed from the database.") + else + shell_error("No changes made.") + end + end) end def run(["delete", group]) do - check_configdb() - start_pleroma() + check_configdb(fn -> + start_pleroma() - group = maybe_atomize(group) + group = maybe_atomize(group) - if group_exists?(group) do - shell_info("The following settings will be removed from ConfigDB:\n") + if group_exists?(group) do + shell_info("The following settings will be removed from ConfigDB:\n") - dump_group(group) + dump_group(group) + + if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do + ConfigDB + |> Repo.all() + |> Enum.filter(fn x -> + if x.group == group do + x |> delete(true) + end + end) + else + shell_error("No changes made.") + end + else + shell_error("No settings in ConfigDB for #{inspect(group)}. Aborting.") + end + end) + end + + def run(["delete", group, key]) do + check_configdb(fn -> + start_pleroma() + + group = maybe_atomize(group) + key = maybe_atomize(key) if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do ConfigDB |> Repo.all() |> Enum.filter(fn x -> - if x.group == group do + if x.group == group and x.key == key do x |> delete(true) end end) else shell_error("No changes made.") end - else - shell_error("No settings in ConfigDB for #{inspect(group)}. Aborting.") - end - end - - def run(["delete", group, key]) do - check_configdb() - start_pleroma() - - group = maybe_atomize(group) - key = maybe_atomize(key) - - if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do - ConfigDB - |> Repo.all() - |> Enum.filter(fn x -> - if x.group == group and x.key == key do - x |> delete(true) - end - end) - else - shell_error("No changes made.") - end + end) end @spec migrate_to_db(Path.t() | nil) :: any() @@ -282,7 +291,7 @@ defmodule Mix.Tasks.Pleroma.Config do end defp configdb_not_enabled do - raise( + shell_error( "ConfigDB not enabled. Please check the value of :configurable_from_database in your configuration." ) end @@ -336,9 +345,9 @@ defmodule Mix.Tasks.Pleroma.Config do end end - defp check_configdb() do + defp check_configdb(callback) do with true <- Pleroma.Config.get([:configurable_from_database]) do - :ok + callback.() else _ -> configdb_not_enabled() end -- cgit v1.2.3 From 25fab7da69e2a6019598132e5d776d7cebe42045 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 2 Dec 2020 13:00:07 -0600 Subject: No need for a separate functions here --- lib/mix/tasks/pleroma/config.ex | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index d509f150e..e53e21a0b 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -184,7 +184,8 @@ defmodule Mix.Tasks.Pleroma.Config do do_migrate_to_db(config_file) else - _ -> deprecation_error() + _ -> + shell_error("Migration is not allowed until all deprecation warnings have been resolved.") end end @@ -248,10 +249,6 @@ defmodule Mix.Tasks.Pleroma.Config do ) end - defp deprecation_error do - shell_error("Migration is not allowed until all deprecation warnings have been resolved.") - end - if Code.ensure_loaded?(Config.Reader) do defp config_header, do: "import Config\r\n\r\n" defp read_file(config_file), do: Config.Reader.read_imports!(config_file) @@ -290,12 +287,6 @@ defmodule Mix.Tasks.Pleroma.Config do shell_info("config #{inspect(config.group)}, #{inspect(config.key)}, #{value}\r\n\r\n") end - defp configdb_not_enabled do - shell_error( - "ConfigDB not enabled. Please check the value of :configurable_from_database in your configuration." - ) - end - defp dump_key(group, key) when is_atom(group) and is_atom(key) do ConfigDB |> Repo.all() @@ -349,7 +340,10 @@ defmodule Mix.Tasks.Pleroma.Config do with true <- Pleroma.Config.get([:configurable_from_database]) do callback.() else - _ -> configdb_not_enabled() + _ -> + shell_error( + "ConfigDB not enabled. Please check the value of :configurable_from_database in your configuration." + ) end end end -- cgit v1.2.3 From 20a911f9f725088e841f2ebce220b26b1b4fe222 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 2 Dec 2020 14:22:59 -0600 Subject: Add comment for this mysterious behavior --- lib/mix/tasks/pleroma/config.ex | 2 ++ 1 file changed, 2 insertions(+) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index e53e21a0b..e2c4cc680 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -329,6 +329,8 @@ defmodule Mix.Tasks.Pleroma.Config do defp maybe_atomize(arg) when is_binary(arg) do chars = String.codepoints(arg) + # hack to make sure input like Pleroma.Mailer.Foo is formatted correctly + # for matching against values returned by Ecto if "." in chars do :"Elixir.#{arg}" else -- cgit v1.2.3 From e379ab8277f552d66737963a9c908ae3fc01c1ff Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 2 Dec 2020 16:24:32 -0600 Subject: Add --force flag for delete and reset commands Bunch of reorganization and consolidation --- lib/mix/tasks/pleroma/config.ex | 110 +++++++++++++++++++++++++--------------- 1 file changed, 69 insertions(+), 41 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index e2c4cc680..014782c35 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -95,7 +95,7 @@ defmodule Mix.Tasks.Pleroma.Config do end) end - def run(["reset"]) do + def run(["reset" | options]) do check_configdb(fn -> start_pleroma() @@ -108,7 +108,11 @@ defmodule Mix.Tasks.Pleroma.Config do shell_error("\nTHIS CANNOT BE UNDONE!") - if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do + proceed? = + "--force" in options or + shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) + + if proceed? do Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;") Ecto.Adapters.SQL.query!(Repo, "ALTER SEQUENCE config_id_seq RESTART;") @@ -119,53 +123,46 @@ defmodule Mix.Tasks.Pleroma.Config do end) end - def run(["delete", group]) do - check_configdb(fn -> - start_pleroma() + def run(["delete", "--force", group, key]) do + start_pleroma() - group = maybe_atomize(group) + group = maybe_atomize(group) + key = maybe_atomize(key) - if group_exists?(group) do - shell_info("The following settings will be removed from ConfigDB:\n") + delete_key(group, key) + end - dump_group(group) + def run(["delete", "--force", group]) do + start_pleroma() - if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do - ConfigDB - |> Repo.all() - |> Enum.filter(fn x -> - if x.group == group do - x |> delete(true) - end - end) - else - shell_error("No changes made.") - end - else - shell_error("No settings in ConfigDB for #{inspect(group)}. Aborting.") - end - end) + group = maybe_atomize(group) + + delete_group(group) end def run(["delete", group, key]) do - check_configdb(fn -> - start_pleroma() + start_pleroma() - group = maybe_atomize(group) - key = maybe_atomize(key) + group = maybe_atomize(group) + key = maybe_atomize(key) - if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do - ConfigDB - |> Repo.all() - |> Enum.filter(fn x -> - if x.group == group and x.key == key do - x |> delete(true) - end - end) - else - shell_error("No changes made.") - end - end) + if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do + delete_key(group, key) + else + shell_error("No changes made.") + end + end + + def run(["delete", group]) do + start_pleroma() + + group = maybe_atomize(group) + + if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do + delete_group(group) + else + shell_error("No changes made.") + end end @spec migrate_to_db(Path.t() | nil) :: any() @@ -275,7 +272,7 @@ defmodule Mix.Tasks.Pleroma.Config do {:ok, _} = Repo.delete(config) shell_info( - "config #{inspect(config.group)}, #{inspect(config.key)} deleted from the ConfigDB." + "config #{inspect(config.group)}, #{inspect(config.key)} was deleted from the ConfigDB." ) end @@ -348,4 +345,35 @@ defmodule Mix.Tasks.Pleroma.Config do ) end end + + defp delete_key(group, key) do + check_configdb(fn -> + ConfigDB + |> Repo.all() + |> Enum.filter(fn x -> + if x.group == group and x.key == key do + x |> delete(true) + end + end) + end) + end + + defp delete_group(group) do + check_configdb(fn -> + with true <- group_exists?(group) do + shell_info("The following settings will be removed from ConfigDB:\n") + dump_group(group) + + ConfigDB + |> Repo.all() + |> Enum.filter(fn x -> + if x.group == group do + x |> delete(true) + end + end) + else + _ -> shell_error("No settings in ConfigDB for #{inspect(group)}. Aborting.") + end + end) + end end -- cgit v1.2.3 From 16bdc2bcd0600ae4c1fcb55eaa84824af01ee61e Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 2 Dec 2020 16:34:23 -0600 Subject: Make the --force flag for reset command consistent with the others and deduplicate db truncation --- lib/mix/tasks/pleroma/config.ex | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 014782c35..ebaf2c623 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -95,7 +95,15 @@ defmodule Mix.Tasks.Pleroma.Config do end) end - def run(["reset" | options]) do + def run(["reset", "--force"]) do + check_configdb(fn -> + start_pleroma() + truncatedb() + shell_info("The ConfigDB settings have been removed from the database.") + end) + end + + def run(["reset"]) do check_configdb(fn -> start_pleroma() @@ -108,13 +116,8 @@ defmodule Mix.Tasks.Pleroma.Config do shell_error("\nTHIS CANNOT BE UNDONE!") - proceed? = - "--force" in options or - shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) - - if proceed? do - Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;") - Ecto.Adapters.SQL.query!(Repo, "ALTER SEQUENCE config_id_seq RESTART;") + if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do + truncatedb() shell_info("The ConfigDB settings have been removed from the database.") else @@ -189,8 +192,7 @@ defmodule Mix.Tasks.Pleroma.Config do defp do_migrate_to_db(config_file) do if File.exists?(config_file) do shell_info("Migrating settings from file: #{Path.expand(config_file)}") - Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;") - Ecto.Adapters.SQL.query!(Repo, "ALTER SEQUENCE config_id_seq RESTART;") + truncatedb() custom_config = config_file @@ -376,4 +378,9 @@ defmodule Mix.Tasks.Pleroma.Config do end end) end + + defp truncatedb() do + Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;") + Ecto.Adapters.SQL.query!(Repo, "ALTER SEQUENCE config_id_seq RESTART;") + end end -- cgit v1.2.3 From fa0d0b602f10a3671ff00151028990c57d8ab447 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 3 Dec 2020 16:17:39 +0100 Subject: Emoji: Also accept regional indicators --- lib/pleroma/emoji.ex | 7 +++++++ 1 file changed, 7 insertions(+) (limited to 'lib') diff --git a/lib/pleroma/emoji.ex b/lib/pleroma/emoji.ex index 201212779..513fb59f8 100644 --- a/lib/pleroma/emoji.ex +++ b/lib/pleroma/emoji.ex @@ -104,6 +104,11 @@ defmodule Pleroma.Emoji do @external_resource "lib/pleroma/emoji-test.txt" + regional_indicators = + Enum.map(127_462..127_487, fn codepoint -> + <> + end) + emojis = @external_resource |> File.read!() @@ -125,6 +130,8 @@ defmodule Pleroma.Emoji do end) |> Enum.uniq() + emojis = emojis ++ regional_indicators + for emoji <- emojis do def is_unicode_emoji?(unquote(emoji)), do: true end -- cgit v1.2.3 From 95e908e4e2273a4b07218e45b46ecbeaa0f08e1c Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 3 Dec 2020 09:58:24 -0600 Subject: Credo --- lib/mix/tasks/pleroma/config.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index ebaf2c623..a6173e0e2 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -379,7 +379,7 @@ defmodule Mix.Tasks.Pleroma.Config do end) end - defp truncatedb() do + defp truncatedb do Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;") Ecto.Adapters.SQL.query!(Repo, "ALTER SEQUENCE config_id_seq RESTART;") end -- cgit v1.2.3 From 60c4ac0f708b4a67d6168ed327327dcb13e7219f Mon Sep 17 00:00:00 2001 From: feld Date: Thu, 3 Dec 2020 16:03:14 +0000 Subject: Apply 6 suggestion(s) to 1 file(s) --- lib/mix/tasks/pleroma/config.ex | 61 ++++++++++++++--------------------------- 1 file changed, 20 insertions(+), 41 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index a6173e0e2..f4bb84a13 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -62,7 +62,10 @@ defmodule Mix.Tasks.Pleroma.Config do group = maybe_atomize(group) key = maybe_atomize(key) - dump_key(group, key) + %{group: group, key: key} + |> ConfigDB.get_by_params() + |> Repo.all() + |> Enum.each(&dump/1) end) end @@ -297,44 +300,27 @@ defmodule Mix.Tasks.Pleroma.Config do end defp dump_group(group) when is_atom(group) do - ConfigDB + %{group: group} + |> ConfigDB.get_by_params() |> Repo.all() - |> Enum.filter(fn x -> - if x.group == group do - x |> dump - end - end) + |> Enum.each(&dump/1) end - defp group_exists?(group) when is_atom(group) do - result = - ConfigDB + defp group_exists?(group) do + %{group: group} + |> ConfigDB.get_by_params() |> Repo.all() - |> Enum.filter(fn x -> - if x.group == group do - x - end - end) - - unless result == [] do - true - else - false - end + |> Enum.empty?() end defp maybe_atomize(arg) when is_atom(arg), do: arg defp maybe_atomize(arg) when is_binary(arg) do - chars = String.codepoints(arg) - - # hack to make sure input like Pleroma.Mailer.Foo is formatted correctly - # for matching against values returned by Ecto - if "." in chars do - :"Elixir.#{arg}" + if Pleroma.ConfigDB.module_name?(arg) do + String.to_existing_atom("Elixir." <> arg) else String.to_atom(arg) - end + end end defp check_configdb(callback) do @@ -350,13 +336,9 @@ defmodule Mix.Tasks.Pleroma.Config do defp delete_key(group, key) do check_configdb(fn -> - ConfigDB + ConfigDB.get_by_params(%{group: group, key: key}) |> Repo.all() - |> Enum.filter(fn x -> - if x.group == group and x.key == key do - x |> delete(true) - end - end) + |> Enum.each(&delete(&1, true)) end) end @@ -366,13 +348,10 @@ defmodule Mix.Tasks.Pleroma.Config do shell_info("The following settings will be removed from ConfigDB:\n") dump_group(group) - ConfigDB - |> Repo.all() - |> Enum.filter(fn x -> - if x.group == group do - x |> delete(true) - end - end) + ConfigDB.get_by_params(%{group: group}) + |> Repo.all() + |> Enum.each(&delete(&1, true)) + else _ -> shell_error("No settings in ConfigDB for #{inspect(group)}. Aborting.") end -- cgit v1.2.3 From 7fd4f4908bc31b3b4cc9d73a79169c3b3f08714c Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 3 Dec 2020 10:03:44 -0600 Subject: dump_key/2 no longer used --- lib/mix/tasks/pleroma/config.ex | 10 ---------- 1 file changed, 10 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index f4bb84a13..137aef038 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -289,16 +289,6 @@ defmodule Mix.Tasks.Pleroma.Config do shell_info("config #{inspect(config.group)}, #{inspect(config.key)}, #{value}\r\n\r\n") end - defp dump_key(group, key) when is_atom(group) and is_atom(key) do - ConfigDB - |> Repo.all() - |> Enum.filter(fn x -> - if x.group == group && x.key == key do - x |> dump - end - end) - end - defp dump_group(group) when is_atom(group) do %{group: group} |> ConfigDB.get_by_params() -- cgit v1.2.3 From a02eb8839650ecbf8bcad9bd6d346fc280985cae Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 3 Dec 2020 19:34:23 +0300 Subject: config_db search methods --- lib/mix/tasks/pleroma/config.ex | 30 +++++++++++++----------------- lib/pleroma/config_db.ex | 12 +++++++++++- 2 files changed, 24 insertions(+), 18 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 137aef038..63d8c46b5 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -62,9 +62,8 @@ defmodule Mix.Tasks.Pleroma.Config do group = maybe_atomize(group) key = maybe_atomize(key) - %{group: group, key: key} - |> ConfigDB.get_by_params() - |> Repo.all() + group + |> ConfigDB.get_all_by_group_and_key(key) |> Enum.each(&dump/1) end) end @@ -290,17 +289,15 @@ defmodule Mix.Tasks.Pleroma.Config do end defp dump_group(group) when is_atom(group) do - %{group: group} - |> ConfigDB.get_by_params() - |> Repo.all() + group + |> ConfigDB.get_all_by_group() |> Enum.each(&dump/1) end defp group_exists?(group) do - %{group: group} - |> ConfigDB.get_by_params() - |> Repo.all() - |> Enum.empty?() + group + |> ConfigDB.get_all_by_group() + |> Enum.empty?() end defp maybe_atomize(arg) when is_atom(arg), do: arg @@ -310,7 +307,7 @@ defmodule Mix.Tasks.Pleroma.Config do String.to_existing_atom("Elixir." <> arg) else String.to_atom(arg) - end + end end defp check_configdb(callback) do @@ -326,8 +323,8 @@ defmodule Mix.Tasks.Pleroma.Config do defp delete_key(group, key) do check_configdb(fn -> - ConfigDB.get_by_params(%{group: group, key: key}) - |> Repo.all() + group + |> ConfigDB.get_all_by_group_and_key(key) |> Enum.each(&delete(&1, true)) end) end @@ -338,10 +335,9 @@ defmodule Mix.Tasks.Pleroma.Config do shell_info("The following settings will be removed from ConfigDB:\n") dump_group(group) - ConfigDB.get_by_params(%{group: group}) - |> Repo.all() - |> Enum.each(&delete(&1, true)) - + group + |> ConfigDB.get_all_by_group() + |> Enum.each(&delete(&1, true)) else _ -> shell_error("No settings in ConfigDB for #{inspect(group)}. Aborting.") end diff --git a/lib/pleroma/config_db.ex b/lib/pleroma/config_db.ex index e5b7811aa..2c3c0cb5c 100644 --- a/lib/pleroma/config_db.ex +++ b/lib/pleroma/config_db.ex @@ -6,7 +6,7 @@ defmodule Pleroma.ConfigDB do use Ecto.Schema import Ecto.Changeset - import Ecto.Query, only: [select: 3] + import Ecto.Query, only: [select: 3, from: 2] import Pleroma.Web.Gettext alias __MODULE__ @@ -41,6 +41,16 @@ defmodule Pleroma.ConfigDB do end) end + @spec get_all_by_group(atom() | String.t()) :: [t()] + def get_all_by_group(group) do + from(c in ConfigDB, where: c.group == ^group) |> Repo.all() + end + + @spec get_all_by_group_and_key(atom() | String.t(), atom() | String.t()) :: [t()] + def get_all_by_group_and_key(group, key) do + from(c in ConfigDB, where: c.group == ^group and c.key == ^key) |> Repo.all() + end + @spec get_by_params(map()) :: ConfigDB.t() | nil def get_by_params(params), do: Repo.get_by(ConfigDB, params) -- cgit v1.2.3 From 4aad066091b63d88dcffa20458a097407da4f5b0 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 4 Dec 2020 11:04:53 -0600 Subject: Use Enum.any? to ensure we return true if there are results --- lib/mix/tasks/pleroma/config.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 63d8c46b5..d2e9a3760 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -297,7 +297,7 @@ defmodule Mix.Tasks.Pleroma.Config do defp group_exists?(group) do group |> ConfigDB.get_all_by_group() - |> Enum.empty?() + |> Enum.any?() end defp maybe_atomize(arg) when is_atom(arg), do: arg -- cgit v1.2.3 From 685e5c8509b4c08bb74eab2438912031ab9b1c19 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 4 Dec 2020 11:09:13 -0600 Subject: Use Pleroma.ConfigDB.delete/1 instead of rolling our own --- lib/mix/tasks/pleroma/config.ex | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index d2e9a3760..7ec791b36 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -323,9 +323,7 @@ defmodule Mix.Tasks.Pleroma.Config do defp delete_key(group, key) do check_configdb(fn -> - group - |> ConfigDB.get_all_by_group_and_key(key) - |> Enum.each(&delete(&1, true)) + Pleroma.ConfigDB.delete(%{group: group, key: key}) end) end -- cgit v1.2.3 From 696d39c3dc32da1e3e163abb413f42d68c3a731f Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 4 Dec 2020 11:19:58 -0600 Subject: Fix deleting an entire group. Also utilize Pleroma.ConfigDB.delete/1 --- lib/mix/tasks/pleroma/config.ex | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 7ec791b36..00e7be6f4 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -334,8 +334,10 @@ defmodule Mix.Tasks.Pleroma.Config do dump_group(group) group - |> ConfigDB.get_all_by_group() - |> Enum.each(&delete(&1, true)) + |> Pleroma.ConfigDB.get_all_by_group() + |> Enum.each(fn config -> + Pleroma.ConfigDB.delete(%{group: config.group, key: config.key}) + end) else _ -> shell_error("No settings in ConfigDB for #{inspect(group)}. Aborting.") end -- cgit v1.2.3 From 3bf5c5b0156e1357db22df8e377c5cd5c5c8ea5a Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 4 Dec 2020 11:30:48 -0600 Subject: Ensure deleting entire group prints out settings that will be removed before actually removing them --- lib/mix/tasks/pleroma/config.ex | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 00e7be6f4..99dfd0dc3 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -142,7 +142,13 @@ defmodule Mix.Tasks.Pleroma.Config do group = maybe_atomize(group) - delete_group(group) + with true <- group_exists?(group) do + shell_info("The following settings will be removed from ConfigDB:\n") + dump_group(group) + delete_group(group) + else + _ -> shell_error("No settings in ConfigDB for #{inspect(group)}. Aborting.") + end end def run(["delete", group, key]) do @@ -163,10 +169,17 @@ defmodule Mix.Tasks.Pleroma.Config do group = maybe_atomize(group) - if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do - delete_group(group) + with true <- group_exists?(group) do + shell_info("The following settings will be removed from ConfigDB:\n") + dump_group(group) + + if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do + delete_group(group) + else + shell_error("No changes made.") + end else - shell_error("No changes made.") + _ -> shell_error("No settings in ConfigDB for #{inspect(group)}. Aborting.") end end @@ -329,18 +342,11 @@ defmodule Mix.Tasks.Pleroma.Config do defp delete_group(group) do check_configdb(fn -> - with true <- group_exists?(group) do - shell_info("The following settings will be removed from ConfigDB:\n") - dump_group(group) - - group - |> Pleroma.ConfigDB.get_all_by_group() - |> Enum.each(fn config -> - Pleroma.ConfigDB.delete(%{group: config.group, key: config.key}) - end) - else - _ -> shell_error("No settings in ConfigDB for #{inspect(group)}. Aborting.") - end + group + |> Pleroma.ConfigDB.get_all_by_group() + |> Enum.each(fn config -> + Pleroma.ConfigDB.delete(%{group: config.group, key: config.key}) + end) end) end -- cgit v1.2.3 From 9dfda37821d663c4b2f8e113336a517d694abee0 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 4 Dec 2020 11:37:49 -0600 Subject: More compact representation --- lib/mix/tasks/pleroma/config.ex | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 99dfd0dc3..25f1ca05d 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -344,9 +344,7 @@ defmodule Mix.Tasks.Pleroma.Config do check_configdb(fn -> group |> Pleroma.ConfigDB.get_all_by_group() - |> Enum.each(fn config -> - Pleroma.ConfigDB.delete(%{group: config.group, key: config.key}) - end) + |> Enum.each(&ConfigDB.delete/1) end) end -- cgit v1.2.3 From 50aadc3d5cc35e5210cb12c4858ecfdba4df56b1 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 27 Nov 2020 13:42:28 -0600 Subject: shell_yes?/1 was not showing the correct message and always defaults to yes which is dangerous --- lib/mix/pleroma.ex | 6 ------ lib/mix/tasks/pleroma/user.ex | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) (limited to 'lib') diff --git a/lib/mix/pleroma.ex b/lib/mix/pleroma.ex index cd3f44074..7575f0ef8 100644 --- a/lib/mix/pleroma.ex +++ b/lib/mix/pleroma.ex @@ -98,12 +98,6 @@ defmodule Mix.Pleroma do end end - def shell_yes?(message) do - if mix_shell?(), - do: Mix.shell().yes?("Continue?"), - else: shell_prompt(message, "Continue?") in ~w(Yn Y y) - end - def shell_info(message) do if mix_shell?(), do: Mix.shell().info(message), diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex index a8d251411..ca9c8579f 100644 --- a/lib/mix/tasks/pleroma/user.ex +++ b/lib/mix/tasks/pleroma/user.ex @@ -60,7 +60,7 @@ defmodule Mix.Tasks.Pleroma.User do - admin: #{if(admin?, do: "true", else: "false")} """) - proceed? = assume_yes? or shell_yes?("Continue?") + proceed? = assume_yes? or shell_prompt("Continue?", "n") in ~w(Yn Y y) if proceed? do start_pleroma() -- cgit v1.2.3 From e9859b68fcb9c38b2ec27a45ffe0921e8d78b5e1 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sun, 6 Dec 2020 13:59:10 +0300 Subject: [#3112] Ensured presence and consistency of :user and :token assigns (EnsureUserTokenAssignsPlug). Refactored auth info dropping functions. --- lib/pleroma/helpers/auth_helper.ex | 6 +++ lib/pleroma/web.ex | 3 +- .../web/plugs/admin_secret_authentication_plug.ex | 18 ++++---- lib/pleroma/web/plugs/ensure_user_key_plug.ex | 19 -------- .../web/plugs/ensure_user_token_assigns_plug.ex | 36 +++++++++++++++ .../web/plugs/mapped_signature_to_identity_plug.ex | 51 ++++++++++++---------- lib/pleroma/web/plugs/o_auth_scopes_plug.ex | 12 +---- lib/pleroma/web/plugs/user_enabled_plug.ex | 10 ++--- lib/pleroma/web/router.ex | 7 ++- 9 files changed, 90 insertions(+), 72 deletions(-) delete mode 100644 lib/pleroma/web/plugs/ensure_user_key_plug.ex create mode 100644 lib/pleroma/web/plugs/ensure_user_token_assigns_plug.ex (limited to 'lib') diff --git a/lib/pleroma/helpers/auth_helper.ex b/lib/pleroma/helpers/auth_helper.ex index 392fa7d5d..8f87b38be 100644 --- a/lib/pleroma/helpers/auth_helper.ex +++ b/lib/pleroma/helpers/auth_helper.ex @@ -20,20 +20,26 @@ defmodule Pleroma.Helpers.AuthHelper do |> OAuthScopesPlug.skip_plug() end + @doc "Drops authentication info from connection" def drop_auth_info(conn) do + # To simplify debugging, setting a private variable on `conn` if auth info is dropped conn |> assign(:user, nil) |> assign(:token, nil) + |> put_private(:authentication_ignored, true) end + @doc "Gets OAuth token string from session" def get_session_token(%Conn{} = conn) do get_session(conn, @oauth_token_session_key) end + @doc "Updates OAuth token string in session" def put_session_token(%Conn{} = conn, token) when is_binary(token) do put_session(conn, @oauth_token_session_key, token) end + @doc "Deletes OAuth token string from session" def delete_session_token(%Conn{} = conn) do delete_session(conn, @oauth_token_session_key) end diff --git a/lib/pleroma/web.ex b/lib/pleroma/web.ex index 6ed19d3dd..3ca20455d 100644 --- a/lib/pleroma/web.ex +++ b/lib/pleroma/web.ex @@ -20,6 +20,7 @@ defmodule Pleroma.Web do below. """ + alias Pleroma.Helpers.AuthHelper alias Pleroma.Web.Plugs.EnsureAuthenticatedPlug alias Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Web.Plugs.ExpectAuthenticatedCheckPlug @@ -75,7 +76,7 @@ defmodule Pleroma.Web do defp maybe_drop_authentication_if_oauth_check_ignored(conn) do if PlugHelper.plug_called?(conn, ExpectPublicOrAuthenticatedCheckPlug) and not PlugHelper.plug_called_or_skipped?(conn, OAuthScopesPlug) do - OAuthScopesPlug.drop_auth_info(conn) + AuthHelper.drop_auth_info(conn) else conn end diff --git a/lib/pleroma/web/plugs/admin_secret_authentication_plug.ex b/lib/pleroma/web/plugs/admin_secret_authentication_plug.ex index ff49801f4..ff851a874 100644 --- a/lib/pleroma/web/plugs/admin_secret_authentication_plug.ex +++ b/lib/pleroma/web/plugs/admin_secret_authentication_plug.ex @@ -13,13 +13,6 @@ defmodule Pleroma.Web.Plugs.AdminSecretAuthenticationPlug do options end - def secret_token do - case Pleroma.Config.get(:admin_token) do - blank when blank in [nil, ""] -> nil - token -> token - end - end - def call(%{assigns: %{user: %User{}}} = conn, _), do: conn def call(conn, _) do @@ -30,7 +23,7 @@ defmodule Pleroma.Web.Plugs.AdminSecretAuthenticationPlug do end end - def authenticate(%{params: %{"admin_token" => admin_token}} = conn) do + defp authenticate(%{params: %{"admin_token" => admin_token}} = conn) do if admin_token == secret_token() do assign_admin_user(conn) else @@ -38,7 +31,7 @@ defmodule Pleroma.Web.Plugs.AdminSecretAuthenticationPlug do end end - def authenticate(conn) do + defp authenticate(conn) do token = secret_token() case get_req_header(conn, "x-admin-token") do @@ -48,6 +41,13 @@ defmodule Pleroma.Web.Plugs.AdminSecretAuthenticationPlug do end end + defp secret_token do + case Pleroma.Config.get(:admin_token) do + blank when blank in [nil, ""] -> nil + token -> token + end + end + defp assign_admin_user(conn) do conn |> assign(:user, %User{is_admin: true}) diff --git a/lib/pleroma/web/plugs/ensure_user_key_plug.ex b/lib/pleroma/web/plugs/ensure_user_key_plug.ex deleted file mode 100644 index 31608dbbf..000000000 --- a/lib/pleroma/web/plugs/ensure_user_key_plug.ex +++ /dev/null @@ -1,19 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.Plugs.EnsureUserKeyPlug do - import Plug.Conn - - @moduledoc "Ensures `conn.assigns.user` is initialized." - - def init(opts) do - opts - end - - def call(%{assigns: %{user: _}} = conn, _), do: conn - - def call(conn, _) do - assign(conn, :user, nil) - end -end diff --git a/lib/pleroma/web/plugs/ensure_user_token_assigns_plug.ex b/lib/pleroma/web/plugs/ensure_user_token_assigns_plug.ex new file mode 100644 index 000000000..4253458b2 --- /dev/null +++ b/lib/pleroma/web/plugs/ensure_user_token_assigns_plug.ex @@ -0,0 +1,36 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Plugs.EnsureUserTokenAssignsPlug do + import Plug.Conn + + alias Pleroma.Helpers.AuthHelper + alias Pleroma.User + alias Pleroma.Web.OAuth.Token + + @moduledoc "Ensures presence and consistency of :user and :token assigns." + + def init(opts) do + opts + end + + def call(%{assigns: %{user: %User{id: user_id}} = assigns} = conn, _) do + with %Token{user_id: ^user_id} <- assigns[:token] do + conn + else + %Token{} -> + # A safety net for abnormal (unexpected) scenario: :token belongs to another user + AuthHelper.drop_auth_info(conn) + + _ -> + assign(conn, :token, nil) + end + end + + def call(conn, _) do + conn + |> assign(:user, nil) + |> assign(:token, nil) + end +end diff --git a/lib/pleroma/web/plugs/mapped_signature_to_identity_plug.ex b/lib/pleroma/web/plugs/mapped_signature_to_identity_plug.ex index f44d4dee5..a0a0c5a9b 100644 --- a/lib/pleroma/web/plugs/mapped_signature_to_identity_plug.ex +++ b/lib/pleroma/web/plugs/mapped_signature_to_identity_plug.ex @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Plugs.MappedSignatureToIdentityPlug do + alias Pleroma.Helpers.AuthHelper alias Pleroma.Signature alias Pleroma.User alias Pleroma.Web.ActivityPub.Utils @@ -12,34 +13,16 @@ defmodule Pleroma.Web.Plugs.MappedSignatureToIdentityPlug do def init(options), do: options - defp key_id_from_conn(conn) do - with %{"keyId" => key_id} <- HTTPSignatures.signature_for_conn(conn), - {:ok, ap_id} <- Signature.key_id_to_actor_id(key_id) do - ap_id - else - _ -> - nil - end - end - - defp user_from_key_id(conn) do - with key_actor_id when is_binary(key_actor_id) <- key_id_from_conn(conn), - {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(key_actor_id) do - user - else - _ -> - nil - end - end - - def call(%{assigns: %{user: _}} = conn, _opts), do: conn + def call(%{assigns: %{user: %User{}}} = conn, _opts), do: conn # if this has payload make sure it is signed by the same actor that made it def call(%{assigns: %{valid_signature: true}, params: %{"actor" => actor}} = conn, _opts) do with actor_id <- Utils.get_ap_id(actor), {:user, %User{} = user} <- {:user, user_from_key_id(conn)}, {:user_match, true} <- {:user_match, user.ap_id == actor_id} do - assign(conn, :user, user) + conn + |> assign(:user, user) + |> AuthHelper.skip_oauth() else {:user_match, false} -> Logger.debug("Failed to map identity from signature (payload actor mismatch)") @@ -57,7 +40,9 @@ defmodule Pleroma.Web.Plugs.MappedSignatureToIdentityPlug do # no payload, probably a signed fetch def call(%{assigns: %{valid_signature: true}} = conn, _opts) do with %User{} = user <- user_from_key_id(conn) do - assign(conn, :user, user) + conn + |> assign(:user, user) + |> AuthHelper.skip_oauth() else _ -> Logger.debug("Failed to map identity from signature (no payload actor mismatch)") @@ -68,4 +53,24 @@ defmodule Pleroma.Web.Plugs.MappedSignatureToIdentityPlug do # no signature at all def call(conn, _opts), do: conn + + defp key_id_from_conn(conn) do + with %{"keyId" => key_id} <- HTTPSignatures.signature_for_conn(conn), + {:ok, ap_id} <- Signature.key_id_to_actor_id(key_id) do + ap_id + else + _ -> + nil + end + end + + defp user_from_key_id(conn) do + with key_actor_id when is_binary(key_actor_id) <- key_id_from_conn(conn), + {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(key_actor_id) do + user + else + _ -> + nil + end + end end diff --git a/lib/pleroma/web/plugs/o_auth_scopes_plug.ex b/lib/pleroma/web/plugs/o_auth_scopes_plug.ex index cfc30837c..e6d398b14 100644 --- a/lib/pleroma/web/plugs/o_auth_scopes_plug.ex +++ b/lib/pleroma/web/plugs/o_auth_scopes_plug.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.Plugs.OAuthScopesPlug do import Pleroma.Web.Gettext alias Pleroma.Config + alias Pleroma.Helpers.AuthHelper use Pleroma.Web, :plug @@ -28,7 +29,7 @@ defmodule Pleroma.Web.Plugs.OAuthScopesPlug do conn options[:fallback] == :proceed_unauthenticated -> - drop_auth_info(conn) + AuthHelper.drop_auth_info(conn) true -> missing_scopes = scopes -- matched_scopes @@ -44,15 +45,6 @@ defmodule Pleroma.Web.Plugs.OAuthScopesPlug do end end - @doc "Drops authentication info from connection" - def drop_auth_info(conn) do - # To simplify debugging, setting a private variable on `conn` if auth info is dropped - conn - |> put_private(:authentication_ignored, true) - |> assign(:user, nil) - |> assign(:token, nil) - end - @doc "Keeps those of `scopes` which are descendants of `supported_scopes`" def filter_descendants(scopes, supported_scopes) do Enum.filter( diff --git a/lib/pleroma/web/plugs/user_enabled_plug.ex b/lib/pleroma/web/plugs/user_enabled_plug.ex index 291d1f568..4f1b163bd 100644 --- a/lib/pleroma/web/plugs/user_enabled_plug.ex +++ b/lib/pleroma/web/plugs/user_enabled_plug.ex @@ -11,12 +11,10 @@ defmodule Pleroma.Web.Plugs.UserEnabledPlug do end def call(%{assigns: %{user: %User{} = user}} = conn, _) do - case User.account_status(user) do - :active -> - conn - - _ -> - AuthHelper.drop_auth_info(conn) + if User.account_status(user) == :active do + conn + else + AuthHelper.drop_auth_info(conn) end end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index b3462ba00..aefc9f0be 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -34,7 +34,7 @@ defmodule Pleroma.Web.Router do plug(:fetch_session) plug(Pleroma.Web.Plugs.OAuthPlug) plug(Pleroma.Web.Plugs.UserEnabledPlug) - plug(Pleroma.Web.Plugs.EnsureUserKeyPlug) + plug(Pleroma.Web.Plugs.EnsureUserTokenAssignsPlug) end pipeline :expect_authentication do @@ -55,7 +55,7 @@ defmodule Pleroma.Web.Router do pipeline :after_auth do plug(Pleroma.Web.Plugs.UserEnabledPlug) plug(Pleroma.Web.Plugs.SetUserSessionIdPlug) - plug(Pleroma.Web.Plugs.EnsureUserKeyPlug) + plug(Pleroma.Web.Plugs.EnsureUserTokenAssignsPlug) end pipeline :base_api do @@ -99,7 +99,7 @@ defmodule Pleroma.Web.Router do pipeline :pleroma_html do plug(:browser) plug(:authenticate) - plug(Pleroma.Web.Plugs.EnsureUserKeyPlug) + plug(Pleroma.Web.Plugs.EnsureUserTokenAssignsPlug) end pipeline :well_known do @@ -291,7 +291,6 @@ defmodule Pleroma.Web.Router do post("/main/ostatus", UtilController, :remote_subscribe) get("/ostatus_subscribe", RemoteFollowController, :follow) - post("/ostatus_subscribe", RemoteFollowController, :do_follow) end -- cgit v1.2.3 From e00c66714590948ef917909779772155e20a3c96 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sun, 6 Dec 2020 18:02:30 +0300 Subject: [#3174] Refactoring: ConfigDB fetching functions, ConfigDB tests. Minor fixes. --- lib/mix/tasks/pleroma/config.ex | 22 ++++++++++++---------- lib/pleroma/config_db.ex | 8 ++++---- 2 files changed, 16 insertions(+), 14 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 25f1ca05d..b5d802948 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -5,6 +5,7 @@ defmodule Mix.Tasks.Pleroma.Config do use Mix.Task + import Ecto.Query import Mix.Pleroma alias Pleroma.ConfigDB @@ -48,7 +49,7 @@ defmodule Mix.Tasks.Pleroma.Config do unless settings == [] do shell_info("#{header}") - settings |> Enum.each(&dump(&1)) + Enum.each(settings, &dump(&1)) else shell_error("No settings in ConfigDB.") end @@ -63,8 +64,8 @@ defmodule Mix.Tasks.Pleroma.Config do key = maybe_atomize(key) group - |> ConfigDB.get_all_by_group_and_key(key) - |> Enum.each(&dump/1) + |> ConfigDB.get_by_group_and_key(key) + |> dump() end) end @@ -84,10 +85,9 @@ defmodule Mix.Tasks.Pleroma.Config do groups = ConfigDB + |> distinct([c], true) + |> select([c], c.group) |> Repo.all() - |> Enum.map(fn x -> x.group end) - |> Enum.sort() - |> Enum.uniq() if length(groups) > 0 do shell_info("The following configuration groups are set in ConfigDB:\r\n") @@ -295,12 +295,14 @@ defmodule Mix.Tasks.Pleroma.Config do defp delete(_config, _), do: :ok - defp dump(%Pleroma.ConfigDB{} = config) do + defp dump(%ConfigDB{} = config) do value = inspect(config.value, limit: :infinity) shell_info("config #{inspect(config.group)}, #{inspect(config.key)}, #{value}\r\n\r\n") end + defp dump(_), do: :noop + defp dump_group(group) when is_atom(group) do group |> ConfigDB.get_all_by_group() @@ -316,7 +318,7 @@ defmodule Mix.Tasks.Pleroma.Config do defp maybe_atomize(arg) when is_atom(arg), do: arg defp maybe_atomize(arg) when is_binary(arg) do - if Pleroma.ConfigDB.module_name?(arg) do + if ConfigDB.module_name?(arg) do String.to_existing_atom("Elixir." <> arg) else String.to_atom(arg) @@ -336,14 +338,14 @@ defmodule Mix.Tasks.Pleroma.Config do defp delete_key(group, key) do check_configdb(fn -> - Pleroma.ConfigDB.delete(%{group: group, key: key}) + ConfigDB.delete(%{group: group, key: key}) end) end defp delete_group(group) do check_configdb(fn -> group - |> Pleroma.ConfigDB.get_all_by_group() + |> ConfigDB.get_all_by_group() |> Enum.each(&ConfigDB.delete/1) end) end diff --git a/lib/pleroma/config_db.ex b/lib/pleroma/config_db.ex index 2c3c0cb5c..8e8bb732f 100644 --- a/lib/pleroma/config_db.ex +++ b/lib/pleroma/config_db.ex @@ -46,13 +46,13 @@ defmodule Pleroma.ConfigDB do from(c in ConfigDB, where: c.group == ^group) |> Repo.all() end - @spec get_all_by_group_and_key(atom() | String.t(), atom() | String.t()) :: [t()] - def get_all_by_group_and_key(group, key) do - from(c in ConfigDB, where: c.group == ^group and c.key == ^key) |> Repo.all() + @spec get_by_group_and_key(atom() | String.t(), atom() | String.t()) :: t() | nil + def get_by_group_and_key(group, key) do + get_by_params(%{group: group, key: key}) end @spec get_by_params(map()) :: ConfigDB.t() | nil - def get_by_params(params), do: Repo.get_by(ConfigDB, params) + def get_by_params(%{group: _, key: _} = params), do: Repo.get_by(ConfigDB, params) @spec changeset(ConfigDB.t(), map()) :: Changeset.t() def changeset(config, params \\ %{}) do -- cgit v1.2.3 From d817bae802c40bd2db9a88970cf24e98374b0af0 Mon Sep 17 00:00:00 2001 From: feld Date: Mon, 7 Dec 2020 17:13:29 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- lib/mix/tasks/pleroma/config.ex | 2 ++ 1 file changed, 2 insertions(+) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index b5d802948..2ecad3578 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -316,6 +316,8 @@ defmodule Mix.Tasks.Pleroma.Config do end defp maybe_atomize(arg) when is_atom(arg), do: arg + + defp maybe_atomize(":" <> arg), do: maybe_atomize(arg) defp maybe_atomize(arg) when is_binary(arg) do if ConfigDB.module_name?(arg) do -- cgit v1.2.3 From 61494b5245619eda38f05d010511df068280cff8 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 7 Dec 2020 11:22:07 -0600 Subject: Formatting --- lib/mix/tasks/pleroma/config.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 2ecad3578..d1af0a60c 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -316,8 +316,8 @@ defmodule Mix.Tasks.Pleroma.Config do end defp maybe_atomize(arg) when is_atom(arg), do: arg - - defp maybe_atomize(":" <> arg), do: maybe_atomize(arg) + + defp maybe_atomize(":" <> arg), do: maybe_atomize(arg) defp maybe_atomize(arg) when is_binary(arg) do if ConfigDB.module_name?(arg) do -- cgit v1.2.3 From 93428d7c11ce30d38fa23192c9a15e2e713a50be Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 7 Dec 2020 11:45:56 -0600 Subject: Print out settings that will be removed when specifying the group and key for consistency Fix error message when specified key doesn't exist --- lib/mix/tasks/pleroma/config.ex | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index d1af0a60c..d7e2e97e7 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -134,7 +134,18 @@ defmodule Mix.Tasks.Pleroma.Config do group = maybe_atomize(group) key = maybe_atomize(key) - delete_key(group, key) + with true <- key_exists?(group, key) do + shell_info("The following settings will be removed from ConfigDB:\n") + + group + |> ConfigDB.get_by_group_and_key(key) + |> dump() + + delete_key(group, key) + else + _ -> + shell_error("No settings in ConfigDB for #{inspect(group)}, #{inspect(key)}. Aborting.") + end end def run(["delete", "--force", group]) do @@ -157,10 +168,21 @@ defmodule Mix.Tasks.Pleroma.Config do group = maybe_atomize(group) key = maybe_atomize(key) - if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do - delete_key(group, key) + with true <- key_exists?(group, key) do + shell_info("The following settings will be removed from ConfigDB:\n") + + group + |> ConfigDB.get_by_group_and_key(key) + |> dump() + + if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do + delete_key(group, key) + else + shell_error("No changes made.") + end else - shell_error("No changes made.") + _ -> + shell_error("No settings in ConfigDB for #{inspect(group)}, #{inspect(key)}. Aborting.") end end @@ -315,6 +337,13 @@ defmodule Mix.Tasks.Pleroma.Config do |> Enum.any?() end + defp key_exists?(group, key) do + group + |> ConfigDB.get_by_group_and_key(key) + |> is_nil + |> Kernel.!() + end + defp maybe_atomize(arg) when is_atom(arg), do: arg defp maybe_atomize(":" <> arg), do: maybe_atomize(arg) -- cgit v1.2.3 From e1a2e8b17cca0d9f50b72fcea0ec5ffb8e613db1 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 7 Dec 2020 20:09:34 +0100 Subject: instance: Do not fetch unreachable instances Closes: https://git.pleroma.social/pleroma/pleroma/-/issues/2346 --- lib/pleroma/instances/instance.ex | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/instances/instance.ex b/lib/pleroma/instances/instance.ex index df471a39d..c9ca3aac7 100644 --- a/lib/pleroma/instances/instance.ex +++ b/lib/pleroma/instances/instance.ex @@ -166,7 +166,8 @@ defmodule Pleroma.Instances.Instance do defp scrape_favicon(%URI{} = instance_uri) do try do - with {:ok, %Tesla.Env{body: html}} <- + with {_, true} <- {:reachable, reachable?(instance_uri.host)}, + {:ok, %Tesla.Env{body: html}} <- Pleroma.HTTP.get(to_string(instance_uri), [{"accept", "text/html"}], pool: :media), {_, [favicon_rel | _]} when is_binary(favicon_rel) <- {:parse, @@ -175,7 +176,15 @@ defmodule Pleroma.Instances.Instance do {:merge, URI.merge(instance_uri, favicon_rel) |> to_string()} do favicon else - _ -> nil + {:reachable, false} -> + Logger.debug( + "Instance.scrape_favicon(\"#{to_string(instance_uri)}\") ignored unreachable host" + ) + + nil + + _ -> + nil end rescue e -> -- cgit v1.2.3 From 1403798820da21660fb8787ffaf9f54817597636 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 7 Dec 2020 21:18:51 +0100 Subject: instance.reachable?: Limit to binary input --- lib/pleroma/instances/instance.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/instances/instance.ex b/lib/pleroma/instances/instance.ex index c9ca3aac7..2e1696fe2 100644 --- a/lib/pleroma/instances/instance.ex +++ b/lib/pleroma/instances/instance.ex @@ -77,7 +77,7 @@ defmodule Pleroma.Instances.Instance do ) end - def reachable?(_), do: true + def reachable?(url_or_host) when is_binary(url_or_host), do: true def set_reachable(url_or_host) when is_binary(url_or_host) do with host <- host(url_or_host), -- cgit v1.2.3 From fb3fd692c66ca0f09c25067c2d024157144e1118 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 7 Dec 2020 16:36:44 -0600 Subject: Add a startup error for modified Repo pool_size --- lib/pleroma/application_requirements.ex | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) (limited to 'lib') diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex index b977257a3..41f6c6e34 100644 --- a/lib/pleroma/application_requirements.ex +++ b/lib/pleroma/application_requirements.ex @@ -24,6 +24,7 @@ defmodule Pleroma.ApplicationRequirements do |> check_migrations_applied!() |> check_welcome_message_config!() |> check_rum!() + |> check_repo_pool_size!() |> handle_result() end @@ -188,6 +189,24 @@ defmodule Pleroma.ApplicationRequirements do defp check_system_commands!(result), do: result + defp check_repo_pool_size!(:ok) do + if Pleroma.Config.get([Pleroma.Repo, :pool_size], 10) != 10 and + not Pleroma.Config.get([:dangerzone, :override_repo_pool_size], false) do + Logger.error(""" + !!!CONFIG WARNING!!! + The database pool size has been altered from the recommended value of 10.\n + Please revert or ensure your database is tuned appropriately and then set\n + `config :pleroma, :dangerzone, override_repo_pool_size: true`. + """) + + {:error, "Repo.pool_size above recommended value."} + else + :ok + end + end + + defp check_repo_pool_size!(result), do: result + defp check_filter(filter, command_required) do filters = Config.get([Pleroma.Upload, :filters]) -- cgit v1.2.3 From 5b9b7b488807e86ecf3c648e8c6a1f1d86bf9806 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 8 Dec 2020 16:16:43 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- lib/pleroma/application_requirements.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex index 41f6c6e34..2c1864ef1 100644 --- a/lib/pleroma/application_requirements.ex +++ b/lib/pleroma/application_requirements.ex @@ -199,7 +199,7 @@ defmodule Pleroma.ApplicationRequirements do `config :pleroma, :dangerzone, override_repo_pool_size: true`. """) - {:error, "Repo.pool_size above recommended value."} + {:error, "Repo.pool_size different than recommended value."} else :ok end -- cgit v1.2.3 From 50d16a9e27189800f69901c4e90aa6f41bdf3193 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 8 Dec 2020 17:30:10 +0100 Subject: ApplicationRequirements: Add test, more text for pool size. --- lib/pleroma/application_requirements.ex | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex index 2c1864ef1..e61576644 100644 --- a/lib/pleroma/application_requirements.ex +++ b/lib/pleroma/application_requirements.ex @@ -194,9 +194,15 @@ defmodule Pleroma.ApplicationRequirements do not Pleroma.Config.get([:dangerzone, :override_repo_pool_size], false) do Logger.error(""" !!!CONFIG WARNING!!! - The database pool size has been altered from the recommended value of 10.\n - Please revert or ensure your database is tuned appropriately and then set\n + + The database pool size has been altered from the recommended value of 10. + + Please revert or ensure your database is tuned appropriately and then set `config :pleroma, :dangerzone, override_repo_pool_size: true`. + + If you are experiencing database timeouts, please check the "Optimizing + your PostgreSQL performance" section in the documentation. If you still + encounter issues after that, please open an issue on the tracker. """) {:error, "Repo.pool_size different than recommended value."} -- cgit v1.2.3 From 7da0349d731058f00904186ebdab9a7514b37a14 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 9 Dec 2020 19:59:46 +0300 Subject: Changed default OAuth token expiration time to 30 days. --- lib/pleroma/mfa/token.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/mfa/token.ex b/lib/pleroma/mfa/token.ex index 69b64c0e8..82d3817cc 100644 --- a/lib/pleroma/mfa/token.ex +++ b/lib/pleroma/mfa/token.ex @@ -11,7 +11,7 @@ defmodule Pleroma.MFA.Token do alias Pleroma.User alias Pleroma.Web.OAuth.Authorization - @expires 300 + @expires 3600 * 24 * 30 @type t() :: %__MODULE__{} -- cgit v1.2.3 From 7fff9c1bee009c7b05679ad8bd57de8bcf58e610 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 9 Dec 2020 21:14:39 +0300 Subject: Tweaks to OAuth entities expiration: changed default to 30 days, removed hardcoded values usage, fixed OAuthView (expires_in). --- lib/pleroma/mfa/token.ex | 2 +- lib/pleroma/web/o_auth/authorization.ex | 4 +++- lib/pleroma/web/o_auth/o_auth_view.ex | 4 +--- lib/pleroma/web/o_auth/token.ex | 12 +++++++----- 4 files changed, 12 insertions(+), 10 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/mfa/token.ex b/lib/pleroma/mfa/token.ex index 82d3817cc..69b64c0e8 100644 --- a/lib/pleroma/mfa/token.ex +++ b/lib/pleroma/mfa/token.ex @@ -11,7 +11,7 @@ defmodule Pleroma.MFA.Token do alias Pleroma.User alias Pleroma.Web.OAuth.Authorization - @expires 3600 * 24 * 30 + @expires 300 @type t() :: %__MODULE__{} diff --git a/lib/pleroma/web/o_auth/authorization.ex b/lib/pleroma/web/o_auth/authorization.ex index 268ee5b63..e766dcada 100644 --- a/lib/pleroma/web/o_auth/authorization.ex +++ b/lib/pleroma/web/o_auth/authorization.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.OAuth.Authorization do alias Pleroma.User alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.Authorization + alias Pleroma.Web.OAuth.Token import Ecto.Changeset import Ecto.Query @@ -53,7 +54,8 @@ defmodule Pleroma.Web.OAuth.Authorization do end defp add_lifetime(changeset) do - put_change(changeset, :valid_until, NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10)) + lifespan = Token.lifespan() + put_change(changeset, :valid_until, NaiveDateTime.add(NaiveDateTime.utc_now(), lifespan)) end @spec use_changeset(Authtorizatiton.t(), map()) :: Changeset.t() diff --git a/lib/pleroma/web/o_auth/o_auth_view.ex b/lib/pleroma/web/o_auth/o_auth_view.ex index f55247ebd..d22b2f7fe 100644 --- a/lib/pleroma/web/o_auth/o_auth_view.ex +++ b/lib/pleroma/web/o_auth/o_auth_view.ex @@ -13,7 +13,7 @@ defmodule Pleroma.Web.OAuth.OAuthView do token_type: "Bearer", access_token: token.token, refresh_token: token.refresh_token, - expires_in: expires_in(), + expires_in: NaiveDateTime.diff(token.valid_until, NaiveDateTime.utc_now()), scope: Enum.join(token.scopes, " "), created_at: Utils.format_created_at(token) } @@ -25,6 +25,4 @@ defmodule Pleroma.Web.OAuth.OAuthView do response end end - - defp expires_in, do: Pleroma.Config.get([:oauth2, :token_expires_in], 600) end diff --git a/lib/pleroma/web/o_auth/token.ex b/lib/pleroma/web/o_auth/token.ex index 9170a7ec7..886117d15 100644 --- a/lib/pleroma/web/o_auth/token.ex +++ b/lib/pleroma/web/o_auth/token.ex @@ -27,6 +27,10 @@ defmodule Pleroma.Web.OAuth.Token do timestamps() end + def lifespan do + Pleroma.Config.get!([:oauth2, :token_expires_in]) + end + @doc "Gets token by unique access token" @spec get_by_token(String.t()) :: {:ok, t()} | {:error, :not_found} def get_by_token(token) do @@ -83,11 +87,11 @@ defmodule Pleroma.Web.OAuth.Token do end defp put_valid_until(changeset, attrs) do - expires_in = - Map.get(attrs, :valid_until, NaiveDateTime.add(NaiveDateTime.utc_now(), expires_in())) + valid_until = + Map.get(attrs, :valid_until, NaiveDateTime.add(NaiveDateTime.utc_now(), lifespan())) changeset - |> change(%{valid_until: expires_in}) + |> change(%{valid_until: valid_until}) |> validate_required([:valid_until]) end @@ -138,6 +142,4 @@ defmodule Pleroma.Web.OAuth.Token do end def is_expired?(_), do: false - - defp expires_in, do: Pleroma.Config.get([:oauth2, :token_expires_in], 600) end -- cgit v1.2.3 From 6aece536eb394fd82e1368e7ae3e484959d05d8c Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sat, 12 Dec 2020 20:35:38 +0300 Subject: instance.gen task: Only show files which will be actually overwritten --- lib/mix/tasks/pleroma/instance.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/instance.ex b/lib/mix/tasks/pleroma/instance.ex index ac8688424..a4f1c81bc 100644 --- a/lib/mix/tasks/pleroma/instance.ex +++ b/lib/mix/tasks/pleroma/instance.ex @@ -253,7 +253,7 @@ defmodule Mix.Tasks.Pleroma.Instance do else shell_error( "The task would have overwritten the following files:\n" <> - (Enum.map(paths, &"- #{&1}\n") |> Enum.join("")) <> + (Enum.map(will_overwrite, &"- #{&1}\n") |> Enum.join("")) <> "Rerun with `--force` to overwrite them." ) end -- cgit v1.2.3 From 7133c0c5ea7f9966d92e53acb52429746fbe51e6 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sat, 12 Dec 2020 20:37:14 +0300 Subject: instance.gen: Warn that stripping exif requires exiftool And default to no if it is not installed Closes #2343 --- lib/mix/tasks/pleroma/instance.ex | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/instance.ex b/lib/mix/tasks/pleroma/instance.ex index a4f1c81bc..853c4eaa2 100644 --- a/lib/mix/tasks/pleroma/instance.ex +++ b/lib/mix/tasks/pleroma/instance.ex @@ -161,12 +161,21 @@ defmodule Mix.Tasks.Pleroma.Instance do ) |> Path.expand() + {strip_uploads_message, strip_uploads_default} = + if Pleroma.Utils.command_available?("exiftool") do + {"Do you want to strip location (GPS) data from uploaded images? This requires exiftool, it was detected as installed. (y/n)", + "y"} + else + {"Do you want to strip location (GPS) data from uploaded images? This requires exiftool, it was detected as not installed, please install it if you answer yes. (y/n)", + "n"} + end + strip_uploads = get_option( options, :strip_uploads, - "Do you want to strip location (GPS) data from uploaded images? (y/n)", - "y" + strip_uploads_message, + strip_uploads_default ) === "y" anonymize_uploads = -- cgit v1.2.3 From cebe3c7deff87ba24f43efcf50499c45d3b3e3f9 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Sat, 12 Dec 2020 17:30:08 +0300 Subject: Fix for dropping posts/notifs in WS when mix task is executed - start oban in mix tasks with empty queues, plugins and crontab - fix for update_users_following_followers_counts - fix for removed logo.png - typo in resend confirmation emails mix task docs - fix for uploads mix task (start Majic.Pool) - fix for creating user mix task (start :fast_html app) --- lib/mix/pleroma.ex | 16 ++++++++++++++-- lib/mix/tasks/pleroma/database.ex | 12 +++++++++--- lib/pleroma/emails/user_email.ex | 4 ++-- lib/pleroma/web/feed/feed_view.ex | 2 +- lib/pleroma/web/templates/email/digest.html.eex | 2 +- 5 files changed, 27 insertions(+), 9 deletions(-) (limited to 'lib') diff --git a/lib/mix/pleroma.ex b/lib/mix/pleroma.ex index 7575f0ef8..a33a9951c 100644 --- a/lib/mix/pleroma.ex +++ b/lib/mix/pleroma.ex @@ -12,7 +12,8 @@ defmodule Mix.Pleroma do :cachex, :flake_id, :swoosh, - :timex + :timex, + :fast_html ] @cachex_children ["object", "user", "scrubber", "web_resp"] @doc "Common functions to be reused in mix tasks" @@ -37,12 +38,23 @@ defmodule Mix.Pleroma do Enum.each(apps, &Application.ensure_all_started/1) + oban_config = [ + crontab: [], + repo: Pleroma.Repo, + log: false, + queues: [], + plugins: [] + ] + children = [ Pleroma.Repo, + Pleroma.Emoji, {Pleroma.Config.TransferTask, false}, Pleroma.Web.Endpoint, - {Oban, Pleroma.Config.get(Oban)} + {Oban, oban_config}, + {Majic.Pool, + [name: Pleroma.MajicPool, pool_size: Pleroma.Config.get([:majic_pool, :size], 2)]} ] ++ http_children(adapter) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index a01c36ece..22151ce08 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -48,9 +48,15 @@ defmodule Mix.Tasks.Pleroma.Database do def run(["update_users_following_followers_counts"]) do start_pleroma() - User - |> Repo.all() - |> Enum.each(&User.update_follower_count/1) + Repo.transaction( + fn -> + from(u in User, select: u) + |> Repo.stream() + |> Stream.each(&User.update_follower_count/1) + |> Stream.run() + end, + timeout: :infinity + ) end def run(["prune_objects" | args]) do diff --git a/lib/pleroma/emails/user_email.ex b/lib/pleroma/emails/user_email.ex index 806a61fd2..e6829b862 100644 --- a/lib/pleroma/emails/user_email.ex +++ b/lib/pleroma/emails/user_email.ex @@ -151,7 +151,7 @@ defmodule Pleroma.Emails.UserEmail do logo_path = if is_nil(logo) do - Path.join(:code.priv_dir(:pleroma), "static/static/logo.png") + Path.join(:code.priv_dir(:pleroma), "static/static/logo.svg") else Path.join(Config.get([:instance, :static_dir]), logo) end @@ -162,7 +162,7 @@ defmodule Pleroma.Emails.UserEmail do |> subject("Your digest from #{instance_name()}") |> put_layout(false) |> render_body("digest.html", html_data) - |> attachment(Swoosh.Attachment.new(logo_path, filename: "logo.png", type: :inline)) + |> attachment(Swoosh.Attachment.new(logo_path, filename: "logo.svg", type: :inline)) end end diff --git a/lib/pleroma/web/feed/feed_view.ex b/lib/pleroma/web/feed/feed_view.ex index 56c024617..30e0a2a55 100644 --- a/lib/pleroma/web/feed/feed_view.ex +++ b/lib/pleroma/web/feed/feed_view.ex @@ -51,7 +51,7 @@ defmodule Pleroma.Web.Feed.FeedView do def feed_logo do case Pleroma.Config.get([:feed, :logo]) do nil -> - "#{Pleroma.Web.base_url()}/static/logo.png" + "#{Pleroma.Web.base_url()}/static/logo.svg" logo -> "#{Pleroma.Web.base_url()}#{logo}" diff --git a/lib/pleroma/web/templates/email/digest.html.eex b/lib/pleroma/web/templates/email/digest.html.eex index 860df5f9c..60eceff22 100644 --- a/lib/pleroma/web/templates/email/digest.html.eex +++ b/lib/pleroma/web/templates/email/digest.html.eex @@ -126,7 +126,7 @@
Image -- cgit v1.2.3 From 6dac2ac71a0005419d1440b5e5daeab3aaabf889 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 14 Dec 2020 13:27:42 -0600 Subject: Minor refactoring of the logic for hiding followers/following counts. Field is not nullable anymore, and this is more readable. --- lib/pleroma/web/mastodon_api/views/account_view.ex | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 3158d09ed..026ae9458 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -187,18 +187,14 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do header_static = User.banner_url(user) |> MediaProxy.preview_url(static: true) following_count = - if !user.hide_follows_count or !user.hide_follows or opts[:for] == user do - user.following_count || 0 - else - 0 - end + if !user.hide_follows_count or !user.hide_follows or opts[:for] == user, + do: user.following_count, + else: 0 followers_count = - if !user.hide_followers_count or !user.hide_followers or opts[:for] == user do - user.follower_count || 0 - else - 0 - end + if !user.hide_followers_count or !user.hide_followers or opts[:for] == user, + do: user.follower_count, + else: 0 bot = user.actor_type == "Service" -- cgit v1.2.3 From 83cd7f2b5f269adf53b66b851fcea187cb914e28 Mon Sep 17 00:00:00 2001 From: FiveYellowMice Date: Fri, 18 Dec 2020 13:48:38 +0000 Subject: WebFinger: add subscribe_address in return data of JSON responses --- lib/pleroma/web/web_finger.ex | 3 +++ 1 file changed, 3 insertions(+) (limited to 'lib') diff --git a/lib/pleroma/web/web_finger.ex b/lib/pleroma/web/web_finger.ex index 6629f5356..ca200588a 100644 --- a/lib/pleroma/web/web_finger.ex +++ b/lib/pleroma/web/web_finger.ex @@ -116,6 +116,9 @@ defmodule Pleroma.Web.WebFinger do {"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", "self"} -> Map.put(data, "ap_id", link["href"]) + {nil, "http://ostatus.org/schema/1.0/subscribe"} -> + Map.put(data, "subscribe_address", link["template"]) + _ -> Logger.debug("Unhandled type: #{inspect(link["type"])}") data -- cgit v1.2.3 From 9a744d49c824e0a7d9963b00893fb2091e3ac4ab Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 18 Dec 2020 17:44:19 +0100 Subject: Jason: Remove by now superfluous jason_types file --- lib/jason_types.ex | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 lib/jason_types.ex (limited to 'lib') diff --git a/lib/jason_types.ex b/lib/jason_types.ex deleted file mode 100644 index f1fdc96f4..000000000 --- a/lib/jason_types.ex +++ /dev/null @@ -1,9 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -Postgrex.Types.define( - Pleroma.PostgresTypes, - [] ++ Ecto.Adapters.Postgres.extensions(), - json: Jason -) -- cgit v1.2.3 From 713612c37725c81b0906b03528c9eaa474816c7d Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 18 Dec 2020 17:44:46 +0100 Subject: Cachex: Make caching provider switchable at runtime. Defaults to Cachex. --- lib/pleroma/activity.ex | 4 ++- lib/pleroma/captcha.ex | 6 ++-- lib/pleroma/emoji/pack.ex | 6 ++-- lib/pleroma/html.ex | 6 ++-- lib/pleroma/object.ex | 12 ++++--- lib/pleroma/reverse_proxy.ex | 6 ++-- lib/pleroma/user.ex | 42 +++++++++++----------- lib/pleroma/web/activity_pub/side_effects.ex | 4 ++- .../controllers/media_proxy_cache_controller.ex | 4 ++- .../mastodon_api/controllers/poll_controller.ex | 4 ++- lib/pleroma/web/media_proxy.ex | 16 +++++---- .../views/chat/message_reference_view.ex | 4 ++- lib/pleroma/web/plugs/cache.ex | 8 +++-- lib/pleroma/web/plugs/idempotency_plug.ex | 6 ++-- lib/pleroma/web/plugs/rate_limiter.ex | 6 ++-- lib/pleroma/web/rel_me.ex | 3 +- lib/pleroma/web/rich_media/parser.ex | 8 +++-- 17 files changed, 89 insertions(+), 56 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 079823312..9d970a808 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -24,6 +24,8 @@ defmodule Pleroma.Activity do @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + schema "activities" do field(:data, :map) field(:local, :boolean, default: true) @@ -298,7 +300,7 @@ defmodule Pleroma.Activity do defp purge_web_resp_cache(%Activity{} = activity) do %{path: path} = URI.parse(activity.data["id"]) - Cachex.del(:web_resp_cache, path) + @cachex.del(:web_resp_cache, path) activity end diff --git a/lib/pleroma/captcha.ex b/lib/pleroma/captcha.ex index 6ab754b6f..990003dcd 100644 --- a/lib/pleroma/captcha.ex +++ b/lib/pleroma/captcha.ex @@ -7,6 +7,8 @@ defmodule Pleroma.Captcha do alias Plug.Crypto.KeyGenerator alias Plug.Crypto.MessageEncryptor + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + @doc """ Ask the configured captcha service for a new captcha """ @@ -86,7 +88,7 @@ defmodule Pleroma.Captcha do end defp validate_usage(token) do - if is_nil(Cachex.get!(:used_captcha_cache, token)) do + if is_nil(@cachex.get!(:used_captcha_cache, token)) do :ok else {:error, :already_used} @@ -95,7 +97,7 @@ defmodule Pleroma.Captcha do defp mark_captcha_as_used(token) do ttl = seconds_valid() |> :timer.seconds() - Cachex.put(:used_captcha_cache, token, true, ttl: ttl) + @cachex.put(:used_captcha_cache, token, true, ttl: ttl) end defp method, do: Pleroma.Config.get!([__MODULE__, :method]) diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index f768af19f..5a1a1a6c6 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -20,6 +20,8 @@ defmodule Pleroma.Emoji.Pack do name: String.t() } + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + alias Pleroma.Emoji alias Pleroma.Emoji.Pack alias Pleroma.Utils @@ -415,7 +417,7 @@ defmodule Pleroma.Emoji.Pack do ttl_per_file = Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file]) overall_ttl = :timer.seconds(ttl_per_file * Enum.count(files)) - Cachex.put!( + @cachex.put!( :emoji_packs_cache, pack.name, # if pack.json MD5 changes, the cache is not valid anymore @@ -618,7 +620,7 @@ defmodule Pleroma.Emoji.Pack do defp fetch_archive(pack) do hash = :crypto.hash(:md5, File.read!(pack.pack_file)) - case Cachex.get!(:emoji_packs_cache, pack.name) do + case @cachex.get!(:emoji_packs_cache, pack.name) do %{hash: ^hash, pack_data: archive} -> archive _ -> create_archive_and_cache(pack, hash) end diff --git a/lib/pleroma/html.ex b/lib/pleroma/html.ex index 43e9145be..c848c782c 100644 --- a/lib/pleroma/html.ex +++ b/lib/pleroma/html.ex @@ -6,6 +6,8 @@ defmodule Pleroma.HTML do # Scrubbers are compiled on boot so they can be configured in OTP releases # @on_load :compile_scrubbers + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + def compile_scrubbers do dir = Path.join(:code.priv_dir(:pleroma), "scrubbers") @@ -56,7 +58,7 @@ defmodule Pleroma.HTML do ) do key = "#{key}#{generate_scrubber_signature(scrubbers)}|#{activity.id}" - Cachex.fetch!(:scrubber_cache, key, fn _key -> + @cachex.fetch!(:scrubber_cache, key, fn _key -> object = Pleroma.Object.normalize(activity) ensure_scrubbed_html(content, scrubbers, object.data["fake"] || false, callback) end) @@ -105,7 +107,7 @@ defmodule Pleroma.HTML do unless object.data["fake"] do key = "URL|#{object.id}" - Cachex.fetch!(:scrubber_cache, key, fn _key -> + @cachex.fetch!(:scrubber_cache, key, fn _key -> {:commit, {:ok, extract_first_external_url(content)}} end) else diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 052ad413b..b4a994da9 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -23,6 +23,8 @@ defmodule Pleroma.Object do @derive {Jason.Encoder, only: [:data]} + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + schema "objects" do field(:data, :map) @@ -156,9 +158,9 @@ defmodule Pleroma.Object do def get_cached_by_ap_id(ap_id) do key = "object:#{ap_id}" - with {:ok, nil} <- Cachex.get(:object_cache, key), + with {:ok, nil} <- @cachex.get(:object_cache, key), object when not is_nil(object) <- get_by_ap_id(ap_id), - {:ok, true} <- Cachex.put(:object_cache, key, object) do + {:ok, true} <- @cachex.put(:object_cache, key, object) do object else {:ok, object} -> object @@ -216,13 +218,13 @@ defmodule Pleroma.Object do end def invalid_object_cache(%Object{data: %{"id" => id}}) do - with {:ok, true} <- Cachex.del(:object_cache, "object:#{id}") do - Cachex.del(:web_resp_cache, URI.parse(id).path) + with {:ok, true} <- @cachex.del(:object_cache, "object:#{id}") do + @cachex.del(:web_resp_cache, URI.parse(id).path) end end def set_cache(%Object{data: %{"id" => ap_id}} = object) do - Cachex.put(:object_cache, "object:#{ap_id}", object) + @cachex.put(:object_cache, "object:#{ap_id}", object) {:ok, object} end diff --git a/lib/pleroma/reverse_proxy.ex b/lib/pleroma/reverse_proxy.ex index 8ae1157df..3ea897c95 100644 --- a/lib/pleroma/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy.ex @@ -17,6 +17,8 @@ defmodule Pleroma.ReverseProxy do @failed_request_ttl :timer.seconds(60) @methods ~w(GET HEAD) + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + def max_read_duration_default, do: @max_read_duration def default_cache_control_header, do: @default_cache_control_header @@ -107,7 +109,7 @@ defmodule Pleroma.ReverseProxy do opts end - with {:ok, nil} <- Cachex.get(:failed_proxy_url_cache, url), + with {:ok, nil} <- @cachex.get(:failed_proxy_url_cache, url), {:ok, code, headers, client} <- request(method, url, req_headers, client_opts), :ok <- header_length_constraint( @@ -427,6 +429,6 @@ defmodule Pleroma.ReverseProxy do nil end - Cachex.put(:failed_proxy_url_cache, url, true, ttl: ttl) + @cachex.put(:failed_proxy_url_cache, url, true, ttl: ttl) end end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 1836643a6..6c1b77d9f 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -81,6 +81,8 @@ defmodule Pleroma.User do ] ] + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + schema "users" do field(:bio, :string, default: "") field(:raw_bio, :string) @@ -246,13 +248,13 @@ defmodule Pleroma.User do end def cached_blocked_users_ap_ids(user) do - Cachex.fetch!(:user_cache, "blocked_users_ap_ids:#{user.ap_id}", fn _ -> + @cachex.fetch!(:user_cache, "blocked_users_ap_ids:#{user.ap_id}", fn _ -> blocked_users_ap_ids(user) end) end def cached_muted_users_ap_ids(user) do - Cachex.fetch!(:user_cache, "muted_users_ap_ids:#{user.ap_id}", fn _ -> + @cachex.fetch!(:user_cache, "muted_users_ap_ids:#{user.ap_id}", fn _ -> muted_users_ap_ids(user) end) end @@ -1016,9 +1018,9 @@ defmodule Pleroma.User do def set_cache({:error, err}), do: {:error, err} def set_cache(%User{} = user) do - Cachex.put(:user_cache, "ap_id:#{user.ap_id}", user) - Cachex.put(:user_cache, "nickname:#{user.nickname}", user) - Cachex.put(:user_cache, "friends_ap_ids:#{user.nickname}", get_user_friends_ap_ids(user)) + @cachex.put(:user_cache, "ap_id:#{user.ap_id}", user) + @cachex.put(:user_cache, "nickname:#{user.nickname}", user) + @cachex.put(:user_cache, "friends_ap_ids:#{user.nickname}", get_user_friends_ap_ids(user)) {:ok, user} end @@ -1041,26 +1043,26 @@ defmodule Pleroma.User do @spec get_cached_user_friends_ap_ids(User.t()) :: [String.t()] def get_cached_user_friends_ap_ids(user) do - Cachex.fetch!(:user_cache, "friends_ap_ids:#{user.ap_id}", fn _ -> + @cachex.fetch!(:user_cache, "friends_ap_ids:#{user.ap_id}", fn _ -> get_user_friends_ap_ids(user) end) end def invalidate_cache(user) do - Cachex.del(:user_cache, "ap_id:#{user.ap_id}") - Cachex.del(:user_cache, "nickname:#{user.nickname}") - Cachex.del(:user_cache, "friends_ap_ids:#{user.ap_id}") - Cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}") - Cachex.del(:user_cache, "muted_users_ap_ids:#{user.ap_id}") + @cachex.del(:user_cache, "ap_id:#{user.ap_id}") + @cachex.del(:user_cache, "nickname:#{user.nickname}") + @cachex.del(:user_cache, "friends_ap_ids:#{user.ap_id}") + @cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}") + @cachex.del(:user_cache, "muted_users_ap_ids:#{user.ap_id}") end @spec get_cached_by_ap_id(String.t()) :: User.t() | nil def get_cached_by_ap_id(ap_id) do key = "ap_id:#{ap_id}" - with {:ok, nil} <- Cachex.get(:user_cache, key), + with {:ok, nil} <- @cachex.get(:user_cache, key), user when not is_nil(user) <- get_by_ap_id(ap_id), - {:ok, true} <- Cachex.put(:user_cache, key, user) do + {:ok, true} <- @cachex.put(:user_cache, key, user) do user else {:ok, user} -> user @@ -1072,11 +1074,11 @@ defmodule Pleroma.User do key = "id:#{id}" ap_id = - Cachex.fetch!(:user_cache, key, fn _ -> + @cachex.fetch!(:user_cache, key, fn _ -> user = get_by_id(id) if user do - Cachex.put(:user_cache, "ap_id:#{user.ap_id}", user) + @cachex.put(:user_cache, "ap_id:#{user.ap_id}", user) {:commit, user.ap_id} else {:ignore, ""} @@ -1089,7 +1091,7 @@ defmodule Pleroma.User do def get_cached_by_nickname(nickname) do key = "nickname:#{nickname}" - Cachex.fetch!(:user_cache, key, fn -> + @cachex.fetch!(:user_cache, key, fn -> case get_or_fetch_by_nickname(nickname) do {:ok, user} -> {:commit, user} {:error, _error} -> {:ignore, nil} @@ -1358,7 +1360,7 @@ defmodule Pleroma.User do ) end - Cachex.del(:user_cache, "muted_users_ap_ids:#{muter.ap_id}") + @cachex.del(:user_cache, "muted_users_ap_ids:#{muter.ap_id}") {:ok, Enum.filter([user_mute, user_notification_mute], & &1)} end @@ -1368,7 +1370,7 @@ defmodule Pleroma.User do with {:ok, user_mute} <- UserRelationship.delete_mute(muter, mutee), {:ok, user_notification_mute} <- UserRelationship.delete_notification_mute(muter, mutee) do - Cachex.del(:user_cache, "muted_users_ap_ids:#{muter.ap_id}") + @cachex.del(:user_cache, "muted_users_ap_ids:#{muter.ap_id}") {:ok, [user_mute, user_notification_mute]} end end @@ -2365,7 +2367,7 @@ defmodule Pleroma.User do {:ok, UserRelationship.t()} | {:error, Ecto.Changeset.t()} defp add_to_block(%User{} = user, %User{} = blocked) do with {:ok, relationship} <- UserRelationship.create_block(user, blocked) do - Cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}") + @cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}") {:ok, relationship} end end @@ -2374,7 +2376,7 @@ defmodule Pleroma.User do {:ok, UserRelationship.t()} | {:ok, nil} | {:error, Ecto.Changeset.t()} defp remove_from_block(%User{} = user, %User{} = blocked) do with {:ok, relationship} <- UserRelationship.delete_block(user, blocked) do - Cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}") + @cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}") {:ok, relationship} end end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 8556fca1d..c947e2c24 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -27,6 +27,8 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do require Logger + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + def handle(object, meta \\ []) # Task this handles @@ -312,7 +314,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) {:ok, cm_ref} = MessageReference.create(chat, object, user.ap_id != actor.ap_id) - Cachex.put( + @cachex.put( :chat_message_id_idempotency_key_cache, cm_ref.id, meta[:idempotency_key] diff --git a/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex index 6d92e9f7f..ecd369037 100644 --- a/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex @@ -9,6 +9,8 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheController do alias Pleroma.Web.MediaProxy alias Pleroma.Web.Plugs.OAuthScopesPlug + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug( @@ -38,7 +40,7 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheController do defp fetch_entries(params) do MediaProxy.cache_table() - |> Cachex.stream!(Cachex.Query.create(true, :key)) + |> @cachex.stream!(@cachex.Query.create(true, :key)) |> filter_entries(params[:query]) end diff --git a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex index 3dcd1c44f..42f263c8c 100644 --- a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex @@ -26,6 +26,8 @@ defmodule Pleroma.Web.MastodonAPI.PollController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PollOperation + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + @doc "GET /api/v1/polls/:id" def show(%{assigns: %{user: user}} = conn, %{id: id}) do with %Object{} = object <- Object.get_by_id_and_maybe_refetch(id, interval: 60), @@ -55,7 +57,7 @@ defmodule Pleroma.Web.MastodonAPI.PollController do defp get_cached_vote_or_vote(user, object, choices) do idempotency_key = "polls:#{user.id}:#{object.data["id"]}" - Cachex.fetch!(:idempotency_cache, idempotency_key, fn -> + @cachex.fetch!(:idempotency_cache, idempotency_key, fn -> case CommonAPI.vote(user, object, choices) do {:error, _message} = res -> {:ignore, res} res -> {:commit, res} diff --git a/lib/pleroma/web/media_proxy.ex b/lib/pleroma/web/media_proxy.ex index 8656b8cad..2793cabc1 100644 --- a/lib/pleroma/web/media_proxy.ex +++ b/lib/pleroma/web/media_proxy.ex @@ -12,29 +12,31 @@ defmodule Pleroma.Web.MediaProxy do @base64_opts [padding: false] @cache_table :banned_urls_cache + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + def cache_table, do: @cache_table @spec in_banned_urls(String.t()) :: boolean() - def in_banned_urls(url), do: elem(Cachex.exists?(@cache_table, url(url)), 1) + def in_banned_urls(url), do: elem(@cachex.exists?(@cache_table, url(url)), 1) def remove_from_banned_urls(urls) when is_list(urls) do - Cachex.execute!(@cache_table, fn cache -> - Enum.each(Invalidation.prepare_urls(urls), &Cachex.del(cache, &1)) + @cachex.execute!(@cache_table, fn cache -> + Enum.each(Invalidation.prepare_urls(urls), &@cachex.del(cache, &1)) end) end def remove_from_banned_urls(url) when is_binary(url) do - Cachex.del(@cache_table, url(url)) + @cachex.del(@cache_table, url(url)) end def put_in_banned_urls(urls) when is_list(urls) do - Cachex.execute!(@cache_table, fn cache -> - Enum.each(Invalidation.prepare_urls(urls), &Cachex.put(cache, &1, true)) + @cachex.execute!(@cache_table, fn cache -> + Enum.each(Invalidation.prepare_urls(urls), &@cachex.put(cache, &1, true)) end) end def put_in_banned_urls(url) when is_binary(url) do - Cachex.put(@cache_table, url(url), true) + @cachex.put(@cache_table, url(url), true) end def url(url) when is_nil(url) or url == "", do: nil diff --git a/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex b/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex index c058fb340..df48044e3 100644 --- a/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex @@ -10,6 +10,8 @@ defmodule Pleroma.Web.PleromaAPI.Chat.MessageReferenceView do alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.StatusView + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + def render( "show.json", %{ @@ -51,7 +53,7 @@ defmodule Pleroma.Web.PleromaAPI.Chat.MessageReferenceView do end defp put_idempotency_key(data) do - with {:ok, idempotency_key} <- Cachex.get(:chat_message_id_idempotency_key_cache, data.id) do + with {:ok, idempotency_key} <- @cachex.get(:chat_message_id_idempotency_key_cache, data.id) do data |> Maps.put_if_present(:idempotency_key, idempotency_key) else diff --git a/lib/pleroma/web/plugs/cache.ex b/lib/pleroma/web/plugs/cache.ex index 6de01804a..18880716a 100644 --- a/lib/pleroma/web/plugs/cache.ex +++ b/lib/pleroma/web/plugs/cache.ex @@ -41,6 +41,8 @@ defmodule Pleroma.Web.Plugs.Cache do @defaults %{ttl: nil, query_params: true} + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + @impl true def init([]), do: @defaults @@ -53,7 +55,7 @@ defmodule Pleroma.Web.Plugs.Cache do def call(%{method: "GET"} = conn, opts) do key = cache_key(conn, opts) - case Cachex.get(:web_resp_cache, key) do + case @cachex.get(:web_resp_cache, key) do {:ok, nil} -> cache_resp(conn, opts) @@ -97,11 +99,11 @@ defmodule Pleroma.Web.Plugs.Cache do conn = unless opts[:tracking_fun] do - Cachex.put(:web_resp_cache, key, {content_type, body}, ttl: ttl) + @cachex.put(:web_resp_cache, key, {content_type, body}, ttl: ttl) conn else tracking_fun_data = Map.get(conn.assigns, :tracking_fun_data, nil) - Cachex.put(:web_resp_cache, key, {content_type, body, tracking_fun_data}, ttl: ttl) + @cachex.put(:web_resp_cache, key, {content_type, body, tracking_fun_data}, ttl: ttl) opts.tracking_fun.(conn, tracking_fun_data) end diff --git a/lib/pleroma/web/plugs/idempotency_plug.ex b/lib/pleroma/web/plugs/idempotency_plug.ex index 254a790b0..4f908779c 100644 --- a/lib/pleroma/web/plugs/idempotency_plug.ex +++ b/lib/pleroma/web/plugs/idempotency_plug.ex @@ -8,6 +8,8 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlug do @behaviour Plug + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + @impl true def init(opts), do: opts @@ -25,7 +27,7 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlug do def call(conn, _), do: conn def process_request(conn, key) do - case Cachex.get(:idempotency_cache, key) do + case @cachex.get(:idempotency_cache, key) do {:ok, nil} -> cache_resposnse(conn, key) @@ -43,7 +45,7 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlug do content_type = get_content_type(conn) record = {request_id, content_type, conn.status, conn.resp_body} - {:ok, _} = Cachex.put(:idempotency_cache, key, record) + {:ok, _} = @cachex.put(:idempotency_cache, key, record) conn |> put_resp_header("idempotency-key", key) diff --git a/lib/pleroma/web/plugs/rate_limiter.ex b/lib/pleroma/web/plugs/rate_limiter.ex index a589610d1..034a5bbe2 100644 --- a/lib/pleroma/web/plugs/rate_limiter.ex +++ b/lib/pleroma/web/plugs/rate_limiter.ex @@ -72,6 +72,8 @@ defmodule Pleroma.Web.Plugs.RateLimiter do require Logger + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + @doc false def init(plug_opts) do plug_opts @@ -124,7 +126,7 @@ defmodule Pleroma.Web.Plugs.RateLimiter do key_name = make_key_name(action_settings) limit = get_limits(action_settings) - case Cachex.get(bucket_name, key_name) do + case @cachex.get(bucket_name, key_name) do {:error, :no_cache} -> @inspect_bucket_not_found @@ -157,7 +159,7 @@ defmodule Pleroma.Web.Plugs.RateLimiter do key_name = make_key_name(action_settings) limit = get_limits(action_settings) - case Cachex.get_and_update(bucket_name, key_name, &increment_value(&1, limit)) do + case @cachex.get_and_update(bucket_name, key_name, &increment_value(&1, limit)) do {:commit, value} -> {:ok, value} diff --git a/lib/pleroma/web/rel_me.ex b/lib/pleroma/web/rel_me.ex index 28f75b18d..650c6a3fc 100644 --- a/lib/pleroma/web/rel_me.ex +++ b/lib/pleroma/web/rel_me.ex @@ -12,8 +12,9 @@ defmodule Pleroma.Web.RelMe do if Pleroma.Config.get(:env) == :test do def parse(url) when is_binary(url), do: parse_url(url) else + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) def parse(url) when is_binary(url) do - Cachex.fetch!(:rel_me_cache, url, fn _ -> + @cachex.fetch!(:rel_me_cache, url, fn _ -> {:commit, parse_url(url)} end) rescue diff --git a/lib/pleroma/web/rich_media/parser.ex b/lib/pleroma/web/rich_media/parser.ex index c70d2fdba..d7a491198 100644 --- a/lib/pleroma/web/rich_media/parser.ex +++ b/lib/pleroma/web/rich_media/parser.ex @@ -5,6 +5,8 @@ defmodule Pleroma.Web.RichMedia.Parser do require Logger + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + defp parsers do Pleroma.Config.get([:rich_media, :parsers]) end @@ -24,7 +26,7 @@ defmodule Pleroma.Web.RichMedia.Parser do end defp get_cached_or_parse(url) do - case Cachex.fetch(:rich_media_cache, url, fn -> + case @cachex.fetch(:rich_media_cache, url, fn -> case parse_url(url) do {:ok, _} = res -> {:commit, res} @@ -64,7 +66,7 @@ defmodule Pleroma.Web.RichMedia.Parser do defp set_error_ttl(url, _reason) do ttl = Pleroma.Config.get([:rich_media, :failure_backoff], 60_000) - Cachex.expire(:rich_media_cache, url, ttl) + @cachex.expire(:rich_media_cache, url, ttl) :ok end @@ -106,7 +108,7 @@ defmodule Pleroma.Web.RichMedia.Parser do {:ok, ttl} when is_number(ttl) -> ttl = ttl * 1000 - case Cachex.expire_at(:rich_media_cache, url, ttl) do + case @cachex.expire_at(:rich_media_cache, url, ttl) do {:ok, true} -> {:ok, ttl} {:ok, false} -> {:error, :no_key} end -- cgit v1.2.3 From 95a9bdfc374a013be47e74b25bdba5d91f51948b Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 18 Dec 2020 19:49:01 +0100 Subject: Tests: Use NullCache for async tests. Caching can't work in async tests, so for them it is mocked to a null cache that is always empty. Synchronous tests are stubbed with the real Cachex, which is emptied after every test. --- lib/pleroma/emoji/pack.ex | 2 +- lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index 5a1a1a6c6..ec97aa652 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -417,7 +417,7 @@ defmodule Pleroma.Emoji.Pack do ttl_per_file = Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file]) overall_ttl = :timer.seconds(ttl_per_file * Enum.count(files)) - @cachex.put!( + @cachex.put( :emoji_packs_cache, pack.name, # if pack.json MD5 changes, the cache is not valid anymore diff --git a/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex index ecd369037..2f712fb8c 100644 --- a/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex @@ -40,7 +40,7 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheController do defp fetch_entries(params) do MediaProxy.cache_table() - |> @cachex.stream!(@cachex.Query.create(true, :key)) + |> @cachex.stream!(Cachex.Query.create(true, :key)) |> filter_entries(params[:query]) end -- cgit v1.2.3 From fb02241580e095a7481c2e8e7ff0e5f6dc535c72 Mon Sep 17 00:00:00 2001 From: Kaizhe Huang Date: Mon, 21 Dec 2020 00:54:15 +1100 Subject: Fix trailing whitespaces in lib/pleroma/web/web_finger.ex --- lib/pleroma/web/web_finger.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/pleroma/web/web_finger.ex b/lib/pleroma/web/web_finger.ex index ca200588a..2e39ae048 100644 --- a/lib/pleroma/web/web_finger.ex +++ b/lib/pleroma/web/web_finger.ex @@ -116,7 +116,7 @@ defmodule Pleroma.Web.WebFinger do {"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", "self"} -> Map.put(data, "ap_id", link["href"]) - {nil, "http://ostatus.org/schema/1.0/subscribe"} -> + {nil, "http://ostatus.org/schema/1.0/subscribe"} -> Map.put(data, "subscribe_address", link["template"]) _ -> -- cgit v1.2.3 From ee81a94ab2631e994f7d963d74fadf1ce0704837 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 21 Dec 2020 11:42:53 +0100 Subject: Caching: Add caching behavior, add null implementation. --- lib/pleroma/caching.ex | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 lib/pleroma/caching.ex (limited to 'lib') diff --git a/lib/pleroma/caching.ex b/lib/pleroma/caching.ex new file mode 100644 index 000000000..766d12d1b --- /dev/null +++ b/lib/pleroma/caching.ex @@ -0,0 +1,19 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Caching do + @callback get!(Cachex.cache(), any()) :: any() + @callback get(Cachex.cache(), any()) :: {atom(), any()} + @callback put(Cachex.cache(), any(), any(), Keyword.t()) :: {Cachex.status(), boolean()} + @callback put(Cachex.cache(), any(), any()) :: {Cachex.status(), boolean()} + @callback fetch!(Cachex.cache(), any(), function() | nil) :: any() + # @callback del(Cachex.cache(), any(), Keyword.t()) :: {Cachex.status(), boolean()} + @callback del(Cachex.cache(), any()) :: {Cachex.status(), boolean()} + @callback stream!(Cachex.cache(), any()) :: Enumerable.t() + @callback expire_at(Cachex.cache(), binary(), number()) :: {Cachex.status(), boolean()} + @callback exists?(Cachex.cache(), any()) :: {Cachex.status(), boolean()} + @callback execute!(Cachex.cache(), function()) :: any() + @callback get_and_update(Cachex.cache(), any(), function()) :: + {:commit | :ignore, any()} +end -- cgit v1.2.3 From c9d73af74dc816dac484f1be2e842baac6fdaa6c Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 21 Dec 2020 12:03:58 +0100 Subject: Cachex: Unify arity of callback function --- lib/pleroma/user.ex | 2 +- lib/pleroma/web/mastodon_api/controllers/poll_controller.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 6c1b77d9f..26a9572a4 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1091,7 +1091,7 @@ defmodule Pleroma.User do def get_cached_by_nickname(nickname) do key = "nickname:#{nickname}" - @cachex.fetch!(:user_cache, key, fn -> + @cachex.fetch!(:user_cache, key, fn _ -> case get_or_fetch_by_nickname(nickname) do {:ok, user} -> {:commit, user} {:error, _error} -> {:ignore, nil} diff --git a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex index 42f263c8c..e26ec7136 100644 --- a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex @@ -57,7 +57,7 @@ defmodule Pleroma.Web.MastodonAPI.PollController do defp get_cached_vote_or_vote(user, object, choices) do idempotency_key = "polls:#{user.id}:#{object.data["id"]}" - @cachex.fetch!(:idempotency_cache, idempotency_key, fn -> + @cachex.fetch!(:idempotency_cache, idempotency_key, fn _ -> case CommonAPI.vote(user, object, choices) do {:error, _message} = res -> {:ignore, res} res -> {:commit, res} -- cgit v1.2.3 From 5db1e6c8d37ea114433afe0a9247314ab92cc52f Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 16 Dec 2020 17:51:48 +0100 Subject: Pipeline test: Switch from Mock to Mox. Speeds up the test and makes it possible to run async. --- lib/pleroma/config.ex | 10 ++++++++++ lib/pleroma/web/activity_pub/activity_pub.ex | 9 ++++++++- lib/pleroma/web/activity_pub/mrf.ex | 7 +++++++ lib/pleroma/web/activity_pub/object_validator.ex | 8 +++++++- lib/pleroma/web/activity_pub/pipeline.ex | 21 ++++++++++++++------- lib/pleroma/web/activity_pub/side_effects.ex | 21 +++++++++++++++++++++ lib/pleroma/web/federator.ex | 8 ++++++++ 7 files changed, 75 insertions(+), 9 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/config.ex b/lib/pleroma/config.ex index 97f877595..1ee4777f6 100644 --- a/lib/pleroma/config.ex +++ b/lib/pleroma/config.ex @@ -2,15 +2,24 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only +defmodule Pleroma.Config.Getting do + @callback get(any()) :: any() + @callback get(any(), any()) :: any() +end + defmodule Pleroma.Config do + @behaviour Pleroma.Config.Getting defmodule Error do defexception [:message] end + @impl true def get(key), do: get(key, nil) + @impl true def get([key], default), do: get(key, default) + @impl true def get([_ | _] = path, default) do case fetch(path) do {:ok, value} -> value @@ -18,6 +27,7 @@ defmodule Pleroma.Config do end end + @impl true def get(key, default) do Application.get_env(:pleroma, key, default) end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 1c91bc074..0f839af10 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -2,6 +2,10 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only +defmodule Pleroma.Web.ActivityPub.ActivityPub.Persisting do + @callback persist(map(), keyword()) :: {:ok, Activity.t() | Object.t()} +end + defmodule Pleroma.Web.ActivityPub.ActivityPub do alias Pleroma.Activity alias Pleroma.Activity.Ir.Topics @@ -32,6 +36,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do require Logger require Pleroma.Constants + @behaviour Pleroma.Web.ActivityPub.ActivityPub.Persisting + defp get_recipients(%{"type" => "Create"} = data) do to = Map.get(data, "to", []) cc = Map.get(data, "cc", []) @@ -85,13 +91,14 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp increase_replies_count_if_reply(_create_data), do: :noop @object_types ~w[ChatMessage Question Answer Audio Video Event Article] - @spec persist(map(), keyword()) :: {:ok, Activity.t() | Object.t()} + @impl true def persist(%{"type" => type} = object, meta) when type in @object_types do with {:ok, object} <- Object.create(object) do {:ok, object, meta} end end + @impl true def persist(object, meta) do with local <- Keyword.fetch!(meta, :local), {recipients, _, _} <- get_recipients(object), diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index 6e73b2f22..3de21b219 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -2,9 +2,15 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only +defmodule Pleroma.Web.ActivityPub.MRF.PipelineFiltering do + @callback pipeline_filter(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} +end + defmodule Pleroma.Web.ActivityPub.MRF do require Logger + @behaviour Pleroma.Web.ActivityPub.MRF.PipelineFiltering + @mrf_config_descriptions [ %{ group: :pleroma, @@ -70,6 +76,7 @@ defmodule Pleroma.Web.ActivityPub.MRF do def filter(%{} = object), do: get_policies() |> filter(object) + @impl true def pipeline_filter(%{} = message, meta) do object = meta[:object_data] ap_id = message["object"] diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index bd0a2a8dc..a731c5a1c 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -2,6 +2,10 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only +defmodule Pleroma.Web.ActivityPub.ObjectValidator.Validating do + @callback validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} +end + defmodule Pleroma.Web.ActivityPub.ObjectValidator do @moduledoc """ This module is responsible for validating an object (which can be an activity) @@ -9,6 +13,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do the system. """ + @behaviour Pleroma.Web.ActivityPub.ObjectValidator.Validating + alias Pleroma.Activity alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Object @@ -32,7 +38,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator alias Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator - @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} + @impl true def validate(object, meta) def validate(%{"type" => type} = object, meta) diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 98c32a42b..2715b94d4 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -14,12 +14,19 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.Federator + @side_effects Config.get([:pipeline, :side_effects], SideEffects) + @federator Config.get([:pipeline, :federator], Federator) + @object_validator Config.get([:pipeline, :object_validator], ObjectValidator) + @mrf Config.get([:pipeline, :mrf], MRF) + @activity_pub Config.get([:pipeline, :activity_pub], ActivityPub) + @config Config.get([:pipeline, :config], Config) + @spec common_pipeline(map(), keyword()) :: {:ok, Activity.t() | Object.t(), keyword()} | {:error, any()} def common_pipeline(object, meta) do case Repo.transaction(fn -> do_common_pipeline(object, meta) end) do {:ok, {:ok, activity, meta}} -> - SideEffects.handle_after_transaction(meta) + @side_effects.handle_after_transaction(meta) {:ok, activity, meta} {:ok, value} -> @@ -35,13 +42,13 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do def do_common_pipeline(object, meta) do with {_, {:ok, validated_object, meta}} <- - {:validate_object, ObjectValidator.validate(object, meta)}, + {:validate_object, @object_validator.validate(object, meta)}, {_, {:ok, mrfd_object, meta}} <- - {:mrf_object, MRF.pipeline_filter(validated_object, meta)}, + {:mrf_object, @mrf.pipeline_filter(validated_object, meta)}, {_, {:ok, activity, meta}} <- - {:persist_object, ActivityPub.persist(mrfd_object, meta)}, + {:persist_object, @activity_pub.persist(mrfd_object, meta)}, {_, {:ok, activity, meta}} <- - {:execute_side_effects, SideEffects.handle(activity, meta)}, + {:execute_side_effects, @side_effects.handle(activity, meta)}, {_, {:ok, _}} <- {:federation, maybe_federate(activity, meta)} do {:ok, activity, meta} else @@ -54,7 +61,7 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do defp maybe_federate(%Activity{} = activity, meta) do with {:ok, local} <- Keyword.fetch(meta, :local) do - do_not_federate = meta[:do_not_federate] || !Config.get([:instance, :federating]) + do_not_federate = meta[:do_not_federate] || !@config.get([:instance, :federating]) if !do_not_federate and local and not Visibility.is_local_public?(activity) do activity = @@ -64,7 +71,7 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do activity end - Federator.publish(activity) + @federator.publish(activity) {:ok, :federated} else {:ok, :not_federated} diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index c947e2c24..cb54eb89a 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -2,6 +2,11 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only +defmodule Pleroma.Web.ActivityPub.SideEffects.Handling do + @callback handle(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} + @callback handle_after_transaction(map()) :: map() +end + defmodule Pleroma.Web.ActivityPub.SideEffects do @moduledoc """ This module looks at an inserted object and executes the side effects that it @@ -29,11 +34,15 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + @behaviour Pleroma.Web.ActivityPub.SideEffects.Handling + + @impl true def handle(object, meta \\ []) # Task this handles # - Follows # - Sends a notification + @impl true def handle( %{ data: %{ @@ -61,6 +70,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do # - Rejects all existing follow activities for this person # - Updates the follow state # - Dismisses notification + @impl true def handle( %{ data: %{ @@ -87,6 +97,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do # - Follows if possible # - Sends a notification # - Generates accept or reject if appropriate + @impl true def handle( %{ data: %{ @@ -128,6 +139,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do # Tasks this handles: # - Unfollow and block + @impl true def handle( %{data: %{"type" => "Block", "object" => blocked_user, "actor" => blocking_user}} = object, @@ -146,6 +158,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do # # For a local user, we also get a changeset with the full information, so we # can update non-federating, non-activitypub settings as well. + @impl true def handle(%{data: %{"type" => "Update", "object" => updated_object}} = object, meta) do if changeset = Keyword.get(meta, :user_update_changeset) do changeset @@ -164,6 +177,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do # Tasks this handles: # - Add like to object # - Set up notification + @impl true def handle(%{data: %{"type" => "Like"}} = object, meta) do liked_object = Object.get_by_ap_id(object.data["object"]) Utils.add_like_to_object(object, liked_object) @@ -181,6 +195,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do # - Increase replies count # - Set up ActivityExpiration # - Set up notifications + @impl true def handle(%{data: %{"type" => "Create"}} = activity, meta) do with {:ok, object, meta} <- handle_object_creation(meta[:object_data], meta), %User{} = user <- User.get_cached_by_ap_id(activity.data["actor"]) do @@ -209,6 +224,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do # - Add announce to object # - Set up notification # - Stream out the announce + @impl true def handle(%{data: %{"type" => "Announce"}} = object, meta) do announced_object = Object.get_by_ap_id(object.data["object"]) user = User.get_cached_by_ap_id(object.data["actor"]) @@ -226,6 +242,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do {:ok, object, meta} end + @impl true def handle(%{data: %{"type" => "Undo", "object" => undone_object}} = object, meta) do with undone_object <- Activity.get_by_ap_id(undone_object), :ok <- handle_undoing(undone_object) do @@ -236,6 +253,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do # Tasks this handles: # - Add reaction to object # - Set up notification + @impl true def handle(%{data: %{"type" => "EmojiReact"}} = object, meta) do reacted_object = Object.get_by_ap_id(object.data["object"]) Utils.add_emoji_reaction_to_object(object, reacted_object) @@ -252,6 +270,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do # - Reduce the user note count # - Reduce the reply count # - Stream out the activity + @impl true def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, meta) do deleted_object = Object.normalize(deleted_object, false) || @@ -297,6 +316,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do end # Nothing to do + @impl true def handle(object, meta) do {:ok, object, meta} end @@ -441,6 +461,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do |> Keyword.put(:notifications, notifications ++ existing) end + @impl true def handle_after_transaction(meta) do meta |> send_notifications() diff --git a/lib/pleroma/web/federator.ex b/lib/pleroma/web/federator.ex index 130654145..186861fd9 100644 --- a/lib/pleroma/web/federator.ex +++ b/lib/pleroma/web/federator.ex @@ -2,6 +2,10 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only +defmodule Pleroma.Web.Federator.Publishing do + @callback publish(map()) :: any() +end + defmodule Pleroma.Web.Federator do alias Pleroma.Activity alias Pleroma.Object.Containment @@ -15,6 +19,8 @@ defmodule Pleroma.Web.Federator do require Logger + @behaviour Pleroma.Web.Federator.Publishing + @doc """ Returns `true` if the distance to target object does not exceed max configured value. Serves to prevent fetching of very long threads, especially useful on smaller instances. @@ -39,10 +45,12 @@ defmodule Pleroma.Web.Federator do ReceiverWorker.enqueue("incoming_ap_doc", %{"params" => params}) end + @impl true def publish(%{id: "pleroma:fakeid"} = activity) do perform(:publish, activity) end + @impl true def publish(activity) do PublisherWorker.enqueue("publish", %{"activity_id" => activity.id}) end -- cgit v1.2.3 From ab633e51abf6c02fafb0d2daae04cdb35b8bca8b Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 16 Dec 2020 20:41:35 +0100 Subject: Linting --- lib/pleroma/config.ex | 5 ----- lib/pleroma/config/getting.ex | 8 ++++++++ lib/pleroma/web/activity_pub/activity_pub.ex | 4 ---- lib/pleroma/web/activity_pub/activity_pub/persisting.ex | 7 +++++++ lib/pleroma/web/activity_pub/mrf.ex | 4 ---- lib/pleroma/web/activity_pub/mrf/pipeline_filtering.ex | 7 +++++++ lib/pleroma/web/activity_pub/object_validator.ex | 4 ---- lib/pleroma/web/activity_pub/object_validator/validating.ex | 7 +++++++ lib/pleroma/web/activity_pub/side_effects.ex | 5 ----- lib/pleroma/web/activity_pub/side_effects/handling.ex | 8 ++++++++ lib/pleroma/web/federator.ex | 4 ---- lib/pleroma/web/federator/publishing.ex | 7 +++++++ 12 files changed, 44 insertions(+), 26 deletions(-) create mode 100644 lib/pleroma/config/getting.ex create mode 100644 lib/pleroma/web/activity_pub/activity_pub/persisting.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/pipeline_filtering.ex create mode 100644 lib/pleroma/web/activity_pub/object_validator/validating.ex create mode 100644 lib/pleroma/web/activity_pub/side_effects/handling.ex create mode 100644 lib/pleroma/web/federator/publishing.ex (limited to 'lib') diff --git a/lib/pleroma/config.ex b/lib/pleroma/config.ex index 1ee4777f6..86d4f6b72 100644 --- a/lib/pleroma/config.ex +++ b/lib/pleroma/config.ex @@ -2,11 +2,6 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Config.Getting do - @callback get(any()) :: any() - @callback get(any(), any()) :: any() -end - defmodule Pleroma.Config do @behaviour Pleroma.Config.Getting defmodule Error do diff --git a/lib/pleroma/config/getting.ex b/lib/pleroma/config/getting.ex new file mode 100644 index 000000000..cc557674c --- /dev/null +++ b/lib/pleroma/config/getting.ex @@ -0,0 +1,8 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Config.Getting do + @callback get(any()) :: any() + @callback get(any(), any()) :: any() +end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 0f839af10..5059bff03 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -2,10 +2,6 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.ActivityPub.ActivityPub.Persisting do - @callback persist(map(), keyword()) :: {:ok, Activity.t() | Object.t()} -end - defmodule Pleroma.Web.ActivityPub.ActivityPub do alias Pleroma.Activity alias Pleroma.Activity.Ir.Topics diff --git a/lib/pleroma/web/activity_pub/activity_pub/persisting.ex b/lib/pleroma/web/activity_pub/activity_pub/persisting.ex new file mode 100644 index 000000000..3894f48e2 --- /dev/null +++ b/lib/pleroma/web/activity_pub/activity_pub/persisting.ex @@ -0,0 +1,7 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ActivityPub.Persisting do + @callback persist(map(), keyword()) :: {:ok, Activity.t() | Object.t()} +end diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index 3de21b219..02fdee5fc 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -2,10 +2,6 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.ActivityPub.MRF.PipelineFiltering do - @callback pipeline_filter(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} -end - defmodule Pleroma.Web.ActivityPub.MRF do require Logger diff --git a/lib/pleroma/web/activity_pub/mrf/pipeline_filtering.ex b/lib/pleroma/web/activity_pub/mrf/pipeline_filtering.ex new file mode 100644 index 000000000..8e0069bc5 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/pipeline_filtering.ex @@ -0,0 +1,7 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.PipelineFiltering do + @callback pipeline_filter(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} +end diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index a731c5a1c..ce8e7341b 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -2,10 +2,6 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.ActivityPub.ObjectValidator.Validating do - @callback validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} -end - defmodule Pleroma.Web.ActivityPub.ObjectValidator do @moduledoc """ This module is responsible for validating an object (which can be an activity) diff --git a/lib/pleroma/web/activity_pub/object_validator/validating.ex b/lib/pleroma/web/activity_pub/object_validator/validating.ex new file mode 100644 index 000000000..64c0c30c5 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validator/validating.ex @@ -0,0 +1,7 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidator.Validating do + @callback validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} +end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index cb54eb89a..55c99ad0c 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -2,11 +2,6 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.ActivityPub.SideEffects.Handling do - @callback handle(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} - @callback handle_after_transaction(map()) :: map() -end - defmodule Pleroma.Web.ActivityPub.SideEffects do @moduledoc """ This module looks at an inserted object and executes the side effects that it diff --git a/lib/pleroma/web/activity_pub/side_effects/handling.ex b/lib/pleroma/web/activity_pub/side_effects/handling.ex new file mode 100644 index 000000000..9d64c0e47 --- /dev/null +++ b/lib/pleroma/web/activity_pub/side_effects/handling.ex @@ -0,0 +1,8 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.SideEffects.Handling do + @callback handle(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} + @callback handle_after_transaction(map()) :: map() +end diff --git a/lib/pleroma/web/federator.ex b/lib/pleroma/web/federator.ex index 186861fd9..658d20954 100644 --- a/lib/pleroma/web/federator.ex +++ b/lib/pleroma/web/federator.ex @@ -2,10 +2,6 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.Federator.Publishing do - @callback publish(map()) :: any() -end - defmodule Pleroma.Web.Federator do alias Pleroma.Activity alias Pleroma.Object.Containment diff --git a/lib/pleroma/web/federator/publishing.ex b/lib/pleroma/web/federator/publishing.ex new file mode 100644 index 000000000..d6fba8f24 --- /dev/null +++ b/lib/pleroma/web/federator/publishing.ex @@ -0,0 +1,7 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Federator.Publishing do + @callback publish(map()) :: any() +end -- cgit v1.2.3 From acb03d591bea1b20a715201f479f1ad7bf7bb67b Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 31 Jul 2020 16:46:35 +0200 Subject: Insert text representation of hashtags into object["hashtags"] Includes a new mix task: pleroma.database fill_old_hashtags --- lib/mix/tasks/pleroma/database.ex | 43 ++++++++++++++++++++++ lib/pleroma/activity/ir/topics.ex | 4 ++ lib/pleroma/constants.ex | 3 +- lib/pleroma/web/activity_pub/activity_pub.ex | 8 ++-- lib/pleroma/web/activity_pub/mrf/simple_policy.ex | 8 ++-- lib/pleroma/web/activity_pub/transmogrifier.ex | 15 ++++---- lib/pleroma/web/common_api/utils.ex | 11 +++++- lib/pleroma/web/mastodon_api/views/status_view.ex | 2 +- .../web/templates/feed/feed/_activity.atom.eex | 4 +- .../web/templates/feed/feed/_activity.rss.eex | 4 +- .../web/templates/feed/feed/_tag_activity.atom.eex | 4 +- 11 files changed, 82 insertions(+), 24 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 22151ce08..0c1343313 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -128,6 +128,49 @@ defmodule Mix.Tasks.Pleroma.Database do |> Stream.run() end + def run(["fill_old_hashtags"]) do + import Ecto.Query + + start_pleroma() + + from( + o in Object, + where: fragment("(?)->>'hashtags' is null", o.data), + where: fragment("(?)->>'tag' != '[]'", o.data), + select: %{id: o.id, tag: fragment("(?)->>'tag'", o.data)}, + order_by: [:desc, o.id] + ) + |> Pleroma.Repo.chunk_stream(200, :batches) + |> Stream.each(fn objects -> + Repo.transaction(fn -> + objects_first = objects |> List.first() + objects_last = objects |> List.last() + + Logger.info( + "fill_old_hashtags: #{objects_first.id} (#{objects_first.inserted_at}) -- #{ + objects_last.id + } (#{objects_last.inserted_at})" + ) + + objects + |> Enum.map(fn object -> + tags = + object.tag + |> Jason.decode!() + |> Enum.filter(&is_bitstring(&1)) + + Object + |> where([o], o.id == ^object.id) + |> update([o], + set: [data: fragment("safe_jsonb_set(?, '{hashtags}', ?, true)", o.data, ^tags)] + ) + |> Repo.update_all([], timeout: :infinity) + end) + end) + end) + |> Stream.run() + end + def run(["vacuum", args]) do start_pleroma() diff --git a/lib/pleroma/activity/ir/topics.ex b/lib/pleroma/activity/ir/topics.ex index fe2e8cb5c..2cdecf1e4 100644 --- a/lib/pleroma/activity/ir/topics.ex +++ b/lib/pleroma/activity/ir/topics.ex @@ -48,6 +48,10 @@ defmodule Pleroma.Activity.Ir.Topics do tags end + defp hashtags_to_topics(%{data: %{"hashtags" => tags}}) do + Enum.map(tags, fn tag -> "hashtag:" <> tag end) + end + defp hashtags_to_topics(%{data: %{"tag" => tags}}) do tags |> Enum.filter(&is_bitstring(&1)) diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index cf8182d55..8f265715c 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -18,7 +18,8 @@ defmodule Pleroma.Constants do "emoji", "context_id", "deleted_activity_id", - "pleroma_internal" + "pleroma_internal", + "hashtags" ] ) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 1c91bc074..61c1043ed 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -666,7 +666,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_tag_reject(query, %{tag_reject: [_ | _] = tag_reject}) do from( [_activity, object] in query, - where: fragment("not (?)->'tag' \\?| (?)", object.data, ^tag_reject) + where: fragment("not (?)->'hashtags' \\?| (?)", object.data, ^tag_reject) ) end @@ -679,7 +679,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_tag_all(query, %{tag_all: [_ | _] = tag_all}) do from( [_activity, object] in query, - where: fragment("(?)->'tag' \\?& (?)", object.data, ^tag_all) + where: fragment("(?)->'hashtags' \\?& (?)", object.data, ^tag_all) ) end @@ -692,14 +692,14 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_tag(query, %{tag: tag}) when is_list(tag) do from( [_activity, object] in query, - where: fragment("(?)->'tag' \\?| (?)", object.data, ^tag) + where: fragment("(?)->'hashtags' \\?| (?)", object.data, ^tag) ) end defp restrict_tag(query, %{tag: tag}) when is_binary(tag) do from( [_activity, object] in query, - where: fragment("(?)->'tag' \\? (?)", object.data, ^tag) + where: fragment("(?)->'hashtags' \\? (?)", object.data, ^tag) ) end diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index 6cd91826d..2fa7b3194 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -74,9 +74,11 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do 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) + child_object = + child_object + |> Map.put("hashtags", (child_object["hashtags"] || []) ++ ["nsfw"]) + |> Map.put("sensitive", true) + Map.put(object, "object", child_object) else object diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 565d32433..d3dc637da 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -312,16 +312,15 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def fix_emoji(object), do: object def fix_tag(%{"tag" => tag} = object) when is_list(tag) do - tags = + hashtags = 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) + Map.put(object, "hashtags", hashtags) end def fix_tag(%{"tag" => %{} = tag} = object) do @@ -865,7 +864,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def add_hashtags(object) do tags = - (object["tag"] || []) + ((object["hashtags"] || []) ++ (object["tag"] || [])) |> Enum.map(fn # Expand internal representation tags into AS2 tags. tag when is_binary(tag) -> @@ -936,7 +935,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do end def set_sensitive(object) do - tags = object["tag"] || [] + tags = object["hashtags"] || object["tag"] || [] Map.put(object, "sensitive", "nsfw" in tags) end diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 1c74ea787..880b5d78f 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -310,7 +310,16 @@ defmodule Pleroma.Web.CommonAPI.Utils do "context" => draft.context, "attachment" => draft.attachments, "actor" => draft.user.ap_id, - "tag" => Keyword.values(draft.tags) |> Enum.uniq() + "tag" => Enum.filter(draft.tags, &is_map(&1)) |> Enum.uniq(), + "hashtags" => + draft.tags + |> Enum.reduce([], fn + # Why so many formats + {:name, x}, acc -> if is_bitstring(x), do: [x | acc], else: acc + {"#" <> _, x}, acc -> if is_bitstring(x), do: [x | acc], else: acc + x, acc -> if is_bitstring(x), do: [x | acc], else: acc + end) + |> Enum.uniq() } |> add_in_reply_to(draft.in_reply_to) |> Map.merge(draft.extra) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 2301e21cf..6fc6272c2 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -347,7 +347,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do media_attachments: attachments, poll: render(PollView, "show.json", object: object, for: opts[:for]), mentions: mentions, - tags: build_tags(tags), + tags: build_tags(object.data["hashtags"] || tags), application: %{ name: "Web", website: nil diff --git a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex index 3fd150c4e..12a9545f3 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex @@ -22,8 +22,8 @@ <% end %> - <%= for tag <- @data["tag"] || [] do %> - + <%= for hashtag <- @data["hashtags"] || [] do %> + <% end %> <%= for attachment <- @data["attachment"] || [] do %> diff --git a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex index 42960de7d..00872b4b7 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex @@ -21,8 +21,8 @@ <%= @data["external_url"] %> <% end %> - <%= for tag <- @data["tag"] || [] do %> - + <%= for hashtag <- @data["hashtags"] || [] do %> + <% end %> <%= for attachment <- @data["attachment"] || [] do %> diff --git a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex index cf5874a91..1377a6bbc 100644 --- a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex @@ -41,8 +41,8 @@ <% end %> <% end %> - <%= for tag <- @data["tag"] || [] do %> - + <%= for hashtag <- @data["hashtags"] || [] do %> + <% end %> <%= for {emoji, file} <- @data["emoji"] || %{} do %> -- cgit v1.2.3 From 87b13c543039859007d9e2ba27c0236ab4092a9d Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 22 Dec 2020 03:54:57 +0100 Subject: Create Object.hashtags/1 wrapper --- lib/pleroma/object.ex | 4 ++++ lib/pleroma/web/activity_pub/mrf/simple_policy.ex | 3 ++- lib/pleroma/web/activity_pub/transmogrifier.ex | 25 +++++++++------------- lib/pleroma/web/mastodon_api/views/status_view.ex | 2 +- .../web/templates/feed/feed/_activity.atom.eex | 2 +- .../web/templates/feed/feed/_activity.rss.eex | 2 +- .../web/templates/feed/feed/_tag_activity.atom.eex | 2 +- 7 files changed, 20 insertions(+), 20 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 052ad413b..00d561d1b 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -344,4 +344,8 @@ defmodule Pleroma.Object do def self_replies(object, opts \\ []), do: replies(object, Keyword.put(opts, :self_only, true)) + + def hashtags(%{"hashtags" => hashtags}), do: hashtags || [] + def hashtags(%{"tag" => tags}), do: Enum.filter(tags, &is_bitstring(&1)) + def hashtags(_), do: [] end diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index 2fa7b3194..8e0514dc8 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do alias Pleroma.Config alias Pleroma.FollowingRelationship + alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.MRF @@ -76,7 +77,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do if MRF.subdomain_match?(media_nsfw, actor_host) do child_object = child_object - |> Map.put("hashtags", (child_object["hashtags"] || []) ++ ["nsfw"]) + |> Map.put("hashtags", Object.hashtags(child_object) ++ ["nsfw"]) |> Map.put("sensitive", true) Map.put(object, "object", child_object) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index d3dc637da..36ef6a454 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -863,23 +863,18 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def maybe_fix_object_url(data), do: data def add_hashtags(object) do - tags = - ((object["hashtags"] || []) ++ (object["tag"] || [])) - |> Enum.map(fn - # Expand internal representation tags into AS2 tags. - tag when is_binary(tag) -> - %{ - "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}", - "name" => "##{tag}", - "type" => "Hashtag" - } - - # Do not process tags which are already AS2 tag objects. - tag when is_map(tag) -> - tag + hashtags = + object + |> Object.hashtags() + |> Enum.map(fn tag -> + %{ + "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}", + "name" => "##{tag}", + "type" => "Hashtag" + } end) - Map.put(object, "tag", tags) + Map.put(object, "tag", hashtags ++ (object["tag"] || [])) end # TODO These should be added on our side on insertion, it doesn't make much diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 6fc6272c2..b39f05d13 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -347,7 +347,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do media_attachments: attachments, poll: render(PollView, "show.json", object: object, for: opts[:for]), mentions: mentions, - tags: build_tags(object.data["hashtags"] || tags), + tags: build_tags(Object.hashtags(object.data)), application: %{ name: "Web", website: nil diff --git a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex index 12a9545f3..cb18abb5d 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex @@ -22,7 +22,7 @@ <% end %> - <%= for hashtag <- @data["hashtags"] || [] do %> + <%= for hashtag <- Object.hashtags(@data) do %> <% end %> diff --git a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex index 00872b4b7..6ef24d0ef 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex @@ -21,7 +21,7 @@ <%= @data["external_url"] %> <% end %> - <%= for hashtag <- @data["hashtags"] || [] do %> + <%= for hashtag <- Object.hashtags(@data) do %> <% end %> diff --git a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex index 1377a6bbc..098e28205 100644 --- a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex @@ -41,7 +41,7 @@ <% end %> <% end %> - <%= for hashtag <- @data["hashtags"] || [] do %> + <%= for hashtag <- Object.hashtags(@data) do %> <% end %> -- cgit v1.2.3 From 18b536c176d3b51f3a91f42ba5a001711ab85490 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 28 Dec 2020 10:33:28 +0100 Subject: Pleroma.Object/1: take %Object{} as argument instead --- lib/pleroma/activity/ir/topics.ex | 12 +++--------- lib/pleroma/object.ex | 4 ++-- lib/pleroma/web/activity_pub/mrf/simple_policy.ex | 2 +- lib/pleroma/web/activity_pub/transmogrifier.ex | 2 +- lib/pleroma/web/feed/feed_view.ex | 1 + lib/pleroma/web/mastodon_api/views/status_view.ex | 2 +- lib/pleroma/web/templates/feed/feed/_activity.atom.eex | 2 +- lib/pleroma/web/templates/feed/feed/_activity.rss.eex | 2 +- lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex | 2 +- 9 files changed, 12 insertions(+), 17 deletions(-) (limited to 'lib') diff --git a/lib/pleroma/activity/ir/topics.ex b/lib/pleroma/activity/ir/topics.ex index 2cdecf1e4..b7553c728 100644 --- a/lib/pleroma/activity/ir/topics.ex +++ b/lib/pleroma/activity/ir/topics.ex @@ -48,18 +48,12 @@ defmodule Pleroma.Activity.Ir.Topics do tags end - defp hashtags_to_topics(%{data: %{"hashtags" => tags}}) do - Enum.map(tags, fn tag -> "hashtag:" <> tag end) - end - - defp hashtags_to_topics(%{data: %{"tag" => tags}}) do - tags - |> Enum.filter(&is_bitstring(&1)) + defp hashtags_to_topics(object) do + object + |> Object.hashtags() |> Enum.map(fn tag -> "hashtag:" <> tag end) end - defp hashtags_to_topics(_), do: [] - defp remote_topics(%{local: true}), do: [] defp remote_topics(%{actor: actor}) when is_binary(actor), diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 00d561d1b..9cbc1c6a6 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -345,7 +345,7 @@ defmodule Pleroma.Object do def self_replies(object, opts \\ []), do: replies(object, Keyword.put(opts, :self_only, true)) - def hashtags(%{"hashtags" => hashtags}), do: hashtags || [] - def hashtags(%{"tag" => tags}), do: Enum.filter(tags, &is_bitstring(&1)) + def hashtags(%Object{data: %{"hashtags" => hashtags}}), do: hashtags || [] + def hashtags(%Object{data: %{"tag" => tags}}), do: Enum.filter(tags, &is_bitstring(&1)) def hashtags(_), do: [] end diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index 8e0514dc8..94933ce99 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -77,7 +77,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do if MRF.subdomain_match?(media_nsfw, actor_host) do child_object = child_object - |> Map.put("hashtags", Object.hashtags(child_object) ++ ["nsfw"]) + |> Map.put("hashtags", Object.hashtags(%Object{data: child_object}) ++ ["nsfw"]) |> Map.put("sensitive", true) Map.put(object, "object", child_object) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 36ef6a454..109f03641 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -864,7 +864,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def add_hashtags(object) do hashtags = - object + %Object{data: object} |> Object.hashtags() |> Enum.map(fn tag -> %{ diff --git a/lib/pleroma/web/feed/feed_view.ex b/lib/pleroma/web/feed/feed_view.ex index 30e0a2a55..1155c6a39 100644 --- a/lib/pleroma/web/feed/feed_view.ex +++ b/lib/pleroma/web/feed/feed_view.ex @@ -32,6 +32,7 @@ defmodule Pleroma.Web.Feed.FeedView do %{ activity: activity, + object: object, data: Map.get(object, :data), actor: actor } diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index b39f05d13..3ba453d1f 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -347,7 +347,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do media_attachments: attachments, poll: render(PollView, "show.json", object: object, for: opts[:for]), mentions: mentions, - tags: build_tags(Object.hashtags(object.data)), + tags: build_tags(Object.hashtags(object)), application: %{ name: "Web", website: nil diff --git a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex index cb18abb5d..4a1a3e993 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex @@ -22,7 +22,7 @@ <% end %> - <%= for hashtag <- Object.hashtags(@data) do %> + <%= for hashtag <- Object.hashtags(@object) do %> <% end %> diff --git a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex index 6ef24d0ef..9ebaa3300 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex @@ -21,7 +21,7 @@ <%= @data["external_url"] %> <% end %> - <%= for hashtag <- Object.hashtags(@data) do %> + <%= for hashtag <- Object.hashtags(@object) do %> <% end %> diff --git a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex index 098e28205..8d78520cf 100644 --- a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex @@ -41,7 +41,7 @@ <% end %> <% end %> - <%= for hashtag <- Object.hashtags(@data) do %> + <%= for hashtag <- Object.hashtags(@object) do %> <% end %> -- cgit v1.2.3 From d0c2479710b40a88b3a74c85c9ed9e72e9a2bfaf Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 22 Dec 2020 05:11:19 +0100 Subject: pleroma.database fill_old_hashtags: Add month_limit argument --- lib/mix/tasks/pleroma/database.ex | 74 ++++++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 33 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 0c1343313..a09852503 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -128,47 +128,55 @@ defmodule Mix.Tasks.Pleroma.Database do |> Stream.run() end - def run(["fill_old_hashtags"]) do + def run(["fill_old_hashtags", month_limit]) do import Ecto.Query start_pleroma() - from( - o in Object, - where: fragment("(?)->>'hashtags' is null", o.data), - where: fragment("(?)->>'tag' != '[]'", o.data), - select: %{id: o.id, tag: fragment("(?)->>'tag'", o.data)}, - order_by: [:desc, o.id] - ) - |> Pleroma.Repo.chunk_stream(200, :batches) - |> Stream.each(fn objects -> - Repo.transaction(fn -> - objects_first = objects |> List.first() - objects_last = objects |> List.last() - - Logger.info( - "fill_old_hashtags: #{objects_first.id} (#{objects_first.inserted_at}) -- #{ - objects_last.id - } (#{objects_last.inserted_at})" - ) + month_limit = String.to_integer(month_limit) - objects - |> Enum.map(fn object -> - tags = - object.tag - |> Jason.decode!() - |> Enum.filter(&is_bitstring(&1)) - - Object - |> where([o], o.id == ^object.id) - |> update([o], - set: [data: fragment("safe_jsonb_set(?, '{hashtags}', ?, true)", o.data, ^tags)] + if month_limit < 1 do + shell_error("Invalid `month_limit` argument, needs to be greater than 1") + else + time_limit = DateTime.utc_now() |> Timex.shift(months: -month_limit) + + from( + o in Object, + where: fragment("(?)->>'hashtags' is null", o.data), + where: fragment("(?)->>'tag' != '[]'", o.data), + where: o.inserted_at < ^time_limit, + select: %{id: o.id, tag: fragment("(?)->>'tag'", o.data)} + ) + |> Pleroma.Repo.chunk_stream(200, :batches) + |> Stream.each(fn objects -> + Repo.transaction(fn -> + objects_first = objects |> List.first() + objects_last = objects |> List.last() + + Logger.info( + "fill_old_hashtags: #{objects_first.id} (#{objects_first.inserted_at}) -- #{ + objects_last.id + } (#{objects_last.inserted_at})" ) - |> Repo.update_all([], timeout: :infinity) + + objects + |> Enum.map(fn object -> + tags = + object.tag + |> Jason.decode!() + |> Enum.filter(&is_bitstring(&1)) + + Object + |> where([o], o.id == ^object.id) + |> update([o], + set: [data: fragment("safe_jsonb_set(?, '{hashtags}', ?, true)", o.data, ^tags)] + ) + |> Repo.update_all([], timeout: :infinity) + end) end) end) - end) - |> Stream.run() + |> Stream.run() + end end def run(["vacuum", args]) do -- cgit v1.2.3 From 3966add048fda791e6893540d8304b0e626ab9f4 Mon Sep 17 00:00:00 2001 From: Haelwenn Date: Mon, 28 Dec 2020 12:02:16 +0000 Subject: Revert "Merge branch 'features/hashtag-column' into 'develop'" This reverts merge request !2824 --- lib/mix/tasks/pleroma/database.ex | 51 ---------------------- lib/pleroma/activity/ir/topics.ex | 8 ++-- lib/pleroma/constants.ex | 3 +- lib/pleroma/object.ex | 4 -- lib/pleroma/web/activity_pub/activity_pub.ex | 8 ++-- lib/pleroma/web/activity_pub/mrf/simple_policy.ex | 9 ++-- lib/pleroma/web/activity_pub/transmogrifier.ex | 38 +++++++++------- lib/pleroma/web/common_api/utils.ex | 11 +---- lib/pleroma/web/feed/feed_view.ex | 1 - lib/pleroma/web/mastodon_api/views/status_view.ex | 2 +- .../web/templates/feed/feed/_activity.atom.eex | 4 +- .../web/templates/feed/feed/_activity.rss.eex | 4 +- .../web/templates/feed/feed/_tag_activity.atom.eex | 4 +- 13 files changed, 43 insertions(+), 104 deletions(-) (limited to 'lib') diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index a09852503..22151ce08 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -128,57 +128,6 @@ defmodule Mix.Tasks.Pleroma.Database do |> Stream.run() end - def run(["fill_old_hashtags", month_limit]) do - import Ecto.Query - - start_pleroma() - - month_limit = String.to_integer(month_limit) - - if month_limit < 1 do - shell_error("Invalid `month_limit` argument, needs to be greater than 1") - else - time_limit = DateTime.utc_now() |> Timex.shift(months: -month_limit) - - from( - o in Object, - where: fragment("(?)->>'hashtags' is null", o.data), - where: fragment("(?)->>'tag' != '[]'", o.data), - where: o.inserted_at < ^time_limit, - select: %{id: o.id, tag: fragment("(?)->>'tag'", o.data)} - ) - |> Pleroma.Repo.chunk_stream(200, :batches) - |> Stream.each(fn objects -> - Repo.transaction(fn -> - objects_first = objects |> List.first() - objects_last = objects |> List.last() - - Logger.info( - "fill_old_hashtags: #{objects_first.id} (#{objects_first.inserted_at}) -- #{ - objects_last.id - } (#{objects_last.inserted_at})" - ) - - objects - |> Enum.map(fn object -> - tags = - object.tag - |> Jason.decode!() - |> Enum.filter(&is_bitstring(&1)) - - Object - |> where([o], o.id == ^object.id) - |> update([o], - set: [data: fragment("safe_jsonb_set(?, '{hashtags}', ?, true)", o.data, ^tags)] - ) - |> Repo.update_all([], timeout: :infinity) - end) - end) - end) - |> Stream.run() - end - end - def run(["vacuum", args]) do start_pleroma() diff --git a/lib/pleroma/activity/ir/topics.ex b/lib/pleroma/activity/ir/topics.ex index b7553c728..fe2e8cb5c 100644 --- a/lib/pleroma/activity/ir/topics.ex +++ b/lib/pleroma/activity/ir/topics.ex @@ -48,12 +48,14 @@ defmodule Pleroma.Activity.Ir.Topics do tags end - defp hashtags_to_topics(object) do - object - |> Object.hashtags() + defp hashtags_to_topics(%{data: %{"tag" => tags}}) do + tags + |> Enum.filter(&is_bitstring(&1)) |> Enum.map(fn tag -> "hashtag:" <> tag end) end + defp hashtags_to_topics(_), do: [] + defp remote_topics(%{local: true}), do: [] defp remote_topics(%{actor: actor}) when is_binary(actor), diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index 8f265715c..cf8182d55 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -18,8 +18,7 @@ defmodule Pleroma.Constants do "emoji", "context_id", "deleted_activity_id", - "pleroma_internal", - "hashtags" + "pleroma_internal" ] ) diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 8836beaac..b4a994da9 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -346,8 +346,4 @@ defmodule Pleroma.Object do def self_replies(object, opts \\ []), do: replies(object, Keyword.put(opts, :self_only, true)) - - def hashtags(%Object{data: %{"hashtags" => hashtags}}), do: hashtags || [] - def hashtags(%Object{data: %{"tag" => tags}}), do: Enum.filter(tags, &is_bitstring(&1)) - def hashtags(_), do: [] end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 07a23b09b..5059bff03 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -669,7 +669,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_tag_reject(query, %{tag_reject: [_ | _] = tag_reject}) do from( [_activity, object] in query, - where: fragment("not (?)->'hashtags' \\?| (?)", object.data, ^tag_reject) + where: fragment("not (?)->'tag' \\?| (?)", object.data, ^tag_reject) ) end @@ -682,7 +682,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_tag_all(query, %{tag_all: [_ | _] = tag_all}) do from( [_activity, object] in query, - where: fragment("(?)->'hashtags' \\?& (?)", object.data, ^tag_all) + where: fragment("(?)->'tag' \\?& (?)", object.data, ^tag_all) ) end @@ -695,14 +695,14 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_tag(query, %{tag: tag}) when is_list(tag) do from( [_activity, object] in query, - where: fragment("(?)->'hashtags' \\?| (?)", object.data, ^tag) + where: fragment("(?)->'tag' \\?| (?)", object.data, ^tag) ) end defp restrict_tag(query, %{tag: tag}) when is_binary(tag) do from( [_activity, object] in query, - where: fragment("(?)->'hashtags' \\? (?)", object.data, ^tag) + where: fragment("(?)->'tag' \\? (?)", object.data, ^tag) ) end diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index 94933ce99..6cd91826d 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -8,7 +8,6 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do alias Pleroma.Config alias Pleroma.FollowingRelationship - alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.MRF @@ -75,11 +74,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do object = if MRF.subdomain_match?(media_nsfw, actor_host) do - child_object = - child_object - |> Map.put("hashtags", Object.hashtags(%Object{data: child_object}) ++ ["nsfw"]) - |> Map.put("sensitive", true) - + 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) else object diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 109f03641..565d32433 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -312,15 +312,16 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def fix_emoji(object), do: object def fix_tag(%{"tag" => tag} = object) when is_list(tag) do - hashtags = + tags = tag |> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end) - |> Enum.map(fn - %{"name" => "#" <> hashtag} -> String.downcase(hashtag) - %{"name" => hashtag} -> String.downcase(hashtag) + |> Enum.map(fn %{"name" => name} -> + name + |> String.slice(1..-1) + |> String.downcase() end) - Map.put(object, "hashtags", hashtags) + Map.put(object, "tag", tag ++ tags) end def fix_tag(%{"tag" => %{} = tag} = object) do @@ -863,18 +864,23 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def maybe_fix_object_url(data), do: data def add_hashtags(object) do - hashtags = - %Object{data: object} - |> Object.hashtags() - |> Enum.map(fn tag -> - %{ - "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}", - "name" => "##{tag}", - "type" => "Hashtag" - } + tags = + (object["tag"] || []) + |> Enum.map(fn + # Expand internal representation tags into AS2 tags. + tag when is_binary(tag) -> + %{ + "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}", + "name" => "##{tag}", + "type" => "Hashtag" + } + + # Do not process tags which are already AS2 tag objects. + tag when is_map(tag) -> + tag end) - Map.put(object, "tag", hashtags ++ (object["tag"] || [])) + Map.put(object, "tag", tags) end # TODO These should be added on our side on insertion, it doesn't make much @@ -930,7 +936,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do end def set_sensitive(object) do - tags = object["hashtags"] || object["tag"] || [] + tags = object["tag"] || [] Map.put(object, "sensitive", "nsfw" in tags) end diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 880b5d78f..1c74ea787 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -310,16 +310,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do "context" => draft.context, "attachment" => draft.attachments, "actor" => draft.user.ap_id, - "tag" => Enum.filter(draft.tags, &is_map(&1)) |> Enum.uniq(), - "hashtags" => - draft.tags - |> Enum.reduce([], fn - # Why so many formats - {:name, x}, acc -> if is_bitstring(x), do: [x | acc], else: acc - {"#" <> _, x}, acc -> if is_bitstring(x), do: [x | acc], else: acc - x, acc -> if is_bitstring(x), do: [x | acc], else: acc - end) - |> Enum.uniq() + "tag" => Keyword.values(draft.tags) |> Enum.uniq() } |> add_in_reply_to(draft.in_reply_to) |> Map.merge(draft.extra) diff --git a/lib/pleroma/web/feed/feed_view.ex b/lib/pleroma/web/feed/feed_view.ex index 1155c6a39..30e0a2a55 100644 --- a/lib/pleroma/web/feed/feed_view.ex +++ b/lib/pleroma/web/feed/feed_view.ex @@ -32,7 +32,6 @@ defmodule Pleroma.Web.Feed.FeedView do %{ activity: activity, - object: object, data: Map.get(object, :data), actor: actor } diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 3ba453d1f..2301e21cf 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -347,7 +347,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do media_attachments: attachments, poll: render(PollView, "show.json", object: object, for: opts[:for]), mentions: mentions, - tags: build_tags(Object.hashtags(object)), + tags: build_tags(tags), application: %{ name: "Web", website: nil diff --git a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex index 4a1a3e993..3fd150c4e 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex @@ -22,8 +22,8 @@ <% end %> - <%= for hashtag <- Object.hashtags(@object) do %> - + <%= for tag <- @data["tag"] || [] do %> + <% end %> <%= for attachment <- @data["attachment"] || [] do %> diff --git a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex index 9ebaa3300..42960de7d 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex @@ -21,8 +21,8 @@ <%= @data["external_url"] %> <% end %> - <%= for hashtag <- Object.hashtags(@object) do %> - + <%= for tag <- @data["tag"] || [] do %> + <% end %> <%= for attachment <- @data["attachment"] || [] do %> diff --git a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex index 8d78520cf..cf5874a91 100644 --- a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex @@ -41,8 +41,8 @@ <% end %> <% end %> - <%= for hashtag <- Object.hashtags(@object) do %> - + <%= for tag <- @data["tag"] || [] do %> + <% end %> <%= for {emoji, file} <- @data["emoji"] || %{} do %> -- cgit v1.2.3